Initial commit

This commit is contained in:
2025-05-04 02:54:07 +02:00
commit 5cc0bba09a
69 changed files with 8690 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:okane/database/collections/expense_category.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/state/core.dart';
class AddExpenseCategory extends StatefulWidget {
const AddExpenseCategory({super.key});
@override
AddExpenseCategoryState createState() => AddExpenseCategoryState();
}
class AddExpenseCategoryState extends State<AddExpenseCategory> {
final TextEditingController _categoryNameController = TextEditingController();
@override
Widget build(BuildContext context) {
return BlocBuilder<CoreCubit, CoreState>(
builder:
(context, state) => ConstrainedBox(
constraints: BoxConstraints(maxHeight: 300),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListView.builder(
itemCount: state.expenseCategories.length,
shrinkWrap: true,
itemBuilder:
(context, index) => ListTile(
title: Text(state.expenseCategories[index].name),
onTap: () {
_categoryNameController.text = "";
Navigator.of(
context,
).pop(state.expenseCategories[index]);
},
),
),
TextField(
decoration: InputDecoration(hintText: "Category name"),
controller: _categoryNameController,
),
Row(
children: [
Spacer(),
OutlinedButton(
onPressed: () async {
final category =
ExpenseCategory()
..name = _categoryNameController.text;
await upsertExpenseCategory(category);
_categoryNameController.text = "";
Navigator.of(context).pop(category);
},
child: Text("Add"),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:flutter_picker_plus/picker.dart';
import 'package:get_it/get_it.dart';
import 'package:okane/database/collections/account.dart';
import 'package:okane/database/collections/beneficiary.dart';
import 'package:okane/database/collections/recurrent.dart';
import 'package:okane/database/collections/template.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/state/core.dart';
import 'package:okane/ui/transaction.dart';
import 'package:okane/ui/utils.dart';
import 'package:searchfield/searchfield.dart';
enum Period { days, weeks, months, years }
class AddRecurringTransactionTemplateWidget extends StatefulWidget {
final VoidCallback onAdd;
final Account activeAccountItem;
const AddRecurringTransactionTemplateWidget({
super.key,
required this.activeAccountItem,
required this.onAdd,
});
@override
State<AddRecurringTransactionTemplateWidget> createState() =>
_AddRecurringTransactionTemplateWidgetState();
}
class _AddRecurringTransactionTemplateWidgetState
extends State<AddRecurringTransactionTemplateWidget> {
final TextEditingController _beneficiaryTextController =
TextEditingController();
final TextEditingController _amountTextController = TextEditingController();
final TextEditingController _templateNameController = TextEditingController();
List<Beneficiary> beneficiaries = [];
SearchFieldListItem<Beneficiary>? _selectedBeneficiary;
TransactionDirection _selectedDirection = TransactionDirection.send;
Period _selectedPeriod = Period.months;
int _periodSize = 1;
String getBeneficiaryName(Beneficiary item) {
return switch (item.type) {
BeneficiaryType.account => "${item.name} (Account)",
BeneficiaryType.other => item.name,
};
}
Future<void> _submit(BuildContext context) async {
final beneficiaryName = _beneficiaryTextController.text;
if (_selectedBeneficiary == null && beneficiaryName.isEmpty) {
return;
}
if (_templateNameController.text.isEmpty) {
return;
}
Beneficiary? beneficiary = _selectedBeneficiary?.item;
if (beneficiary == null ||
getBeneficiaryName(beneficiary) != beneficiaryName) {
// Add a new beneficiary, if none was selected
final result = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text("Add Beneficiary"),
content: Text(
"The beneficiary '$beneficiaryName' does not exist. Do you want to add it?",
),
actions: [
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: const Text('Add'),
onPressed: () => Navigator.of(context).pop(true),
),
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: const Text('Cancel'),
onPressed: () => Navigator.of(context).pop(false),
),
],
),
);
if (result == null || !result) {
return;
}
beneficiary =
Beneficiary()
..name = beneficiaryName
..type = BeneficiaryType.other;
await upsertBeneficiary(beneficiary);
}
final days = switch (_selectedPeriod) {
Period.days => _periodSize,
Period.weeks => _periodSize * 7,
Period.months => _periodSize * 31,
Period.years => _periodSize * 365,
};
final factor = switch (_selectedDirection) {
TransactionDirection.send => -1,
TransactionDirection.receive => 1,
};
final amount = factor * double.parse(_amountTextController.text).abs();
final template =
TransactionTemplate()
..name = _templateNameController.text
..beneficiary.value = beneficiary
..account.value = widget.activeAccountItem
..recurring = true
..amount = amount;
await upsertTransactionTemplate(template);
final transaction =
RecurringTransaction()
..lastExecution = null
..template.value = template
..account.value = widget.activeAccountItem
..days = days;
await upsertRecurringTransaction(transaction);
_periodSize = 1;
_selectedPeriod = Period.weeks;
_amountTextController.text = "";
_templateNameController.text = "";
widget.onAdd();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: _templateNameController,
decoration: InputDecoration(label: Text("Template name")),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SearchField<Beneficiary>(
suggestions:
beneficiaries
.where((el) {
final bloc = GetIt.I.get<CoreCubit>();
if (el.type == BeneficiaryType.account) {
return el.account.value?.id != bloc.activeAccount?.id;
}
return true;
})
.map((el) {
return SearchFieldListItem(
getBeneficiaryName(el),
item: el,
);
})
.toList(),
hint: "Beneficiary",
controller: _beneficiaryTextController,
selectedValue: _selectedBeneficiary,
onSuggestionTap: (beneficiary) {
setState(() => _selectedBeneficiary = beneficiary);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: _amountTextController,
keyboardType: TextInputType.numberWithOptions(
signed: false,
decimal: false,
),
decoration: InputDecoration(hintText: "Amount"),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SegmentedButton<TransactionDirection>(
segments: [
ButtonSegment(
value: TransactionDirection.send,
label: Text("Send"),
icon: Icon(Icons.remove),
),
ButtonSegment(
value: TransactionDirection.receive,
label: Text("Receive"),
icon: Icon(Icons.add),
),
],
selected: <TransactionDirection>{_selectedDirection},
multiSelectionEnabled: false,
onSelectionChanged: (selection) {
setState(() => _selectedDirection = selection.first);
},
),
),
Padding(
padding: EdgeInsets.only(left: 16, right: 16, top: 16),
child: SegmentedButton<Period>(
segments: [
ButtonSegment(value: Period.days, label: Text("Days")),
ButtonSegment(value: Period.weeks, label: Text("Weeks")),
ButtonSegment(value: Period.months, label: Text("Months")),
ButtonSegment(value: Period.years, label: Text("Years")),
],
selected: <Period>{_selectedPeriod},
multiSelectionEnabled: false,
onSelectionChanged: (selection) {
setState(() => _selectedPeriod = selection.first);
},
),
),
Text.rich(
TextSpan(
text: "Repeat every ",
children: [
WidgetSpan(
child: TextButton(
onPressed: () {
Picker(
adapter: NumberPickerAdapter(
data: [
NumberPickerColumn(
begin: 1,
end: 999,
initValue: _periodSize,
),
],
),
hideHeader: true,
selectedTextStyle: TextStyle(color: Colors.blue),
onConfirm: (Picker picker, List value) {
setState(() {
_periodSize = (value.first as int) + 1;
});
},
).showDialog(context);
},
child: Text(_periodSize.toString()),
),
),
TextSpan(
text: switch (_selectedPeriod) {
Period.days => " days",
Period.weeks => " weeks",
Period.months => " months",
Period.years => " years",
},
),
],
),
),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton(
onPressed: () => _submit(context),
child: Text("Add"),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:okane/database/collections/account.dart';
import 'package:okane/database/collections/beneficiary.dart';
import 'package:okane/database/collections/expense_category.dart';
import 'package:okane/database/collections/template.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/state/core.dart';
import 'package:okane/ui/transaction.dart';
import 'package:okane/ui/utils.dart';
import 'package:okane/ui/widgets/add_expense_category.dart';
import 'package:searchfield/searchfield.dart';
class AddTransactionTemplateWidget extends StatefulWidget {
final VoidCallback onAdd;
final Account activeAccountItem;
const AddTransactionTemplateWidget({
super.key,
required this.activeAccountItem,
required this.onAdd,
});
@override
State<AddTransactionTemplateWidget> createState() =>
_AddTransactionTemplateWidgetState();
}
class _AddTransactionTemplateWidgetState
extends State<AddTransactionTemplateWidget> {
final TextEditingController _beneficiaryTextController =
TextEditingController();
final TextEditingController _amountTextController = TextEditingController();
final TextEditingController _templateNameController = TextEditingController();
SearchFieldListItem<Beneficiary>? _selectedBeneficiary;
TransactionDirection _selectedDirection = TransactionDirection.send;
ExpenseCategory? _expenseCategory = null;
String getBeneficiaryName(Beneficiary item) {
return switch (item.type) {
BeneficiaryType.account => "${item.name} (Account)",
BeneficiaryType.other => item.name,
};
}
Future<void> _submit(BuildContext context) async {
final beneficiaryName = _beneficiaryTextController.text;
if (_selectedBeneficiary == null && beneficiaryName.isEmpty) {
return;
}
if (_templateNameController.text.isEmpty) {
return;
}
Beneficiary? beneficiary = _selectedBeneficiary?.item;
if (beneficiary == null ||
getBeneficiaryName(beneficiary) != beneficiaryName) {
// Add a new beneficiary, if none was selected
final result = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text("Add Beneficiary"),
content: Text(
"The beneficiary '$beneficiaryName' does not exist. Do you want to add it?",
),
actions: [
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: const Text('Add'),
onPressed: () => Navigator.of(context).pop(true),
),
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: const Text('Cancel'),
onPressed: () => Navigator.of(context).pop(false),
),
],
),
);
if (result == null || !result) {
return;
}
beneficiary =
Beneficiary()
..name = beneficiaryName
..type = BeneficiaryType.other;
await upsertBeneficiary(beneficiary);
}
final factor = switch (_selectedDirection) {
TransactionDirection.send => -1,
TransactionDirection.receive => 1,
};
final amount = factor * double.parse(_amountTextController.text).abs();
final transaction =
TransactionTemplate()
..name = _templateNameController.text
..account.value = widget.activeAccountItem
..beneficiary.value = beneficiary
..expenseCategory.value = _expenseCategory
..recurring = false
..amount = amount;
await upsertTransactionTemplate(transaction);
widget.onAdd();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: _templateNameController,
decoration: InputDecoration(label: Text("Template name")),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: BlocBuilder<CoreCubit, CoreState>(
builder:
(context, state) => SearchField<Beneficiary>(
suggestions:
state.beneficiaries
.where((el) {
final bloc = GetIt.I.get<CoreCubit>();
if (el.type == BeneficiaryType.account) {
return el.account.value?.id !=
bloc.activeAccount?.id;
}
return true;
})
.map((el) {
return SearchFieldListItem(
getBeneficiaryName(el),
item: el,
);
})
.toList(),
hint: "Beneficiary",
controller: _beneficiaryTextController,
selectedValue: _selectedBeneficiary,
onSuggestionTap: (beneficiary) {
setState(() => _selectedBeneficiary = beneficiary);
},
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: _amountTextController,
keyboardType: TextInputType.numberWithOptions(
signed: false,
decimal: false,
),
decoration: InputDecoration(hintText: "Amount"),
),
),
Row(
children: [
Text("Expense category"),
OutlinedButton(
onPressed: () async {
final category = await showDialogOrModal(
context: context,
builder: (_) => AddExpenseCategory(),
);
if (category == null) {
return;
}
setState(() => _expenseCategory = category);
},
child: Text(_expenseCategory?.name ?? "None"),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SegmentedButton<TransactionDirection>(
segments: [
ButtonSegment(
value: TransactionDirection.send,
label: Text("Send"),
icon: Icon(Icons.remove),
),
ButtonSegment(
value: TransactionDirection.receive,
label: Text("Receive"),
icon: Icon(Icons.add),
),
],
selected: <TransactionDirection>{_selectedDirection},
multiSelectionEnabled: false,
onSelectionChanged: (selection) {
setState(() => _selectedDirection = selection.first);
},
),
),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton(
onPressed: () => _submit(context),
child: Text("Add"),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:okane/database/collections/account.dart';
import 'package:okane/database/collections/beneficiary.dart';
import 'package:okane/database/collections/expense_category.dart';
import 'package:okane/database/collections/transaction.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/state/core.dart';
import 'package:okane/ui/transaction.dart';
import 'package:okane/ui/utils.dart';
import 'package:okane/ui/widgets/add_expense_category.dart';
import 'package:searchfield/searchfield.dart';
class AddTransactionWidget extends StatefulWidget {
final VoidCallback onAdd;
final Account activeAccountItem;
const AddTransactionWidget({
super.key,
required this.activeAccountItem,
required this.onAdd,
});
@override
State<AddTransactionWidget> createState() => _AddTransactionWidgetState();
}
class _AddTransactionWidgetState extends State<AddTransactionWidget> {
final TextEditingController _beneficiaryTextController =
TextEditingController();
final TextEditingController _amountTextController = TextEditingController();
DateTime _selectedDate = DateTime.now();
SearchFieldListItem<Beneficiary>? _selectedBeneficiary;
TransactionDirection _selectedDirection = TransactionDirection.send;
ExpenseCategory? _expenseCategory = null;
String getBeneficiaryName(Beneficiary item) {
return switch (item.type) {
BeneficiaryType.account => "${item.name} (Account)",
BeneficiaryType.other => item.name,
};
}
Future<void> _submit(BuildContext context) async {
final beneficiaryName = _beneficiaryTextController.text;
if (_selectedBeneficiary == null && beneficiaryName.isEmpty) {
return;
}
Beneficiary? beneficiary = _selectedBeneficiary?.item;
if (beneficiary == null ||
getBeneficiaryName(beneficiary) != beneficiaryName) {
// Add a new beneficiary, if none was selected
final result = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text("Add Beneficiary"),
content: Text(
"The beneficiary '$beneficiaryName' does not exist. Do you want to add it?",
),
actions: [
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: const Text('Add'),
onPressed: () => Navigator.of(context).pop(true),
),
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: const Text('Cancel'),
onPressed: () => Navigator.of(context).pop(false),
),
],
),
);
if (result == null || !result) {
return;
}
beneficiary =
Beneficiary()
..name = beneficiaryName
..type = BeneficiaryType.other;
await upsertBeneficiary(beneficiary);
}
final factor = switch (_selectedDirection) {
TransactionDirection.send => -1,
TransactionDirection.receive => 1,
};
final amount = factor * double.parse(_amountTextController.text).abs();
final transaction =
Transaction()
..account.value = widget.activeAccountItem
..beneficiary.value = beneficiary
..amount = amount
..tags = []
..expenseCategory.value = _expenseCategory
..date = _selectedDate;
await upsertTransaction(transaction);
if (beneficiary.type == BeneficiaryType.account) {
final otherTransaction =
Transaction()
..account.value = beneficiary.account.value!
..beneficiary.value = await getAccountBeneficiary(
widget.activeAccountItem,
)
..date = _selectedDate
..expenseCategory.value = _expenseCategory
..amount = -1 * amount;
await upsertTransaction(otherTransaction);
}
widget.onAdd();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: ListView(
shrinkWrap: true,
children: [
OutlinedButton(
onPressed: () async {
final template = await showDialogOrModal<Transaction>(
context: context,
builder: (context) {
return BlocBuilder<CoreCubit, CoreState>(
builder: (context, state) {
if (state.transactionTemplates.isEmpty) {
return Text("No templates defined");
}
return ListView.builder(
itemCount: state.transactionTemplates.length,
itemBuilder:
(context, index) => ListTile(
title: Text(
state.transactionTemplates[index].name,
),
onTap: () {
Navigator.of(
context,
).pop(state.transactionTemplates[index]);
},
),
);
},
);
},
);
if (template == null) {
return;
}
_amountTextController.text = template.amount.toString();
_selectedDirection =
template.amount > 0
? TransactionDirection.receive
: TransactionDirection.send;
_selectedBeneficiary = SearchFieldListItem(
getBeneficiaryName(template.beneficiary.value!),
item: template.beneficiary.value!,
);
_beneficiaryTextController.text = getBeneficiaryName(
template.beneficiary.value!,
);
},
child: Text("Use template"),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: BlocBuilder<CoreCubit, CoreState>(
builder:
(context, state) => SearchField<Beneficiary>(
suggestions:
state.beneficiaries
.where((el) {
final bloc = GetIt.I.get<CoreCubit>();
if (el.type == BeneficiaryType.account) {
return el.account.value?.id.toInt() ==
bloc.activeAccount?.id.toInt();
}
return true;
})
.map((el) {
return SearchFieldListItem(
getBeneficiaryName(el),
item: el,
);
})
.toList(),
hint: "Beneficiary",
controller: _beneficiaryTextController,
selectedValue: _selectedBeneficiary,
onSuggestionTap: (beneficiary) {
setState(() => _selectedBeneficiary = beneficiary);
},
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: _amountTextController,
keyboardType: TextInputType.numberWithOptions(
signed: false,
decimal: false,
),
decoration: InputDecoration(hintText: "Amount"),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text("Date"),
OutlinedButton(
onPressed: () async {
final dt = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(1),
lastDate: DateTime(9999),
);
if (dt == null) return;
setState(() => _selectedDate = dt);
},
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.date_range),
Text(formatDateTime(_selectedDate)),
],
),
),
],
),
),
Row(
children: [
Text("Expense category"),
OutlinedButton(
onPressed: () async {
final category = await showDialogOrModal(
context: context,
builder: (_) => AddExpenseCategory(),
);
if (category == null) {
return;
}
setState(() => _expenseCategory = category);
},
child: Text(_expenseCategory?.name ?? "None"),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SegmentedButton<TransactionDirection>(
segments: [
ButtonSegment(
value: TransactionDirection.send,
label: Text("Send"),
icon: Icon(Icons.remove),
),
ButtonSegment(
value: TransactionDirection.receive,
label: Text("Receive"),
icon: Icon(Icons.add),
),
],
selected: <TransactionDirection>{_selectedDirection},
multiSelectionEnabled: false,
onSelectionChanged: (selection) {
setState(() => _selectedDirection = selection.first);
},
),
),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton(
onPressed: () => _submit(context),
child: Text("Add"),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'dart:io';
import 'package:flutter/material.dart';
class ImageWrapper extends StatelessWidget {
final String title;
final String? path;
final VoidCallback onTap;
const ImageWrapper({
super.key,
required this.title,
required this.onTap,
this.path,
});
@override
Widget build(BuildContext context) {
Widget widget;
if (path == null) {
widget = SizedBox(
width: 45,
height: 45,
child: Container(
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(8),
),
child: Center(child: Text(title[0])),
),
);
} else {
widget = ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(File(path!), width: 45, height: 45),
);
}
return InkWell(onTap: onTap, child: widget);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:okane/database/collections/transaction.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/utils.dart';
import 'package:okane/ui/widgets/image_wrapper.dart';
class TransactionCard extends StatelessWidget {
final Widget? subtitle;
const TransactionCard({
super.key,
required this.transaction,
required this.onTap,
this.subtitle,
});
final Transaction transaction;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
onTap: onTap,
leading: ImageWrapper(
title: transaction.beneficiary.value!.name,
path: transaction.beneficiary.value!.imagePath,
onTap: () {},
),
trailing: Text(formatDateTime(transaction.date)),
title: Text(transaction.beneficiary.value!.name),
subtitle: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
formatCurrency(transaction.amount),
style: TextStyle(
color: transaction.amount < 0 ? Colors.red : Colors.green,
),
),
if (subtitle != null) subtitle!,
],
),
),
);
}
}