diff --git a/lib/database/collections/recurrent.dart b/lib/database/collections/recurrent.dart index 0576243..e4c4487 100644 --- a/lib/database/collections/recurrent.dart +++ b/lib/database/collections/recurrent.dart @@ -15,4 +15,18 @@ class RecurringTransaction { final template = IsarLink(); final account = IsarLink(); + + bool isDue(DateTime now) { + if (lastExecution == null) { + return true; + } + + final expectedNextExecution = lastExecution!.add(Duration(days: days)); + if (now.isAfter(expectedNextExecution)) { + return true; + } + + return now.difference(expectedNextExecution).inDays.abs() <= + (days * 0.5).toInt(); + } } diff --git a/lib/database/database.dart b/lib/database/database.dart index 4ae01b5..8b56f19 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -83,9 +83,7 @@ Stream watchRecurringTransactions(Account account) { Future upsertAccount(Account account) async { final db = GetIt.I.get(); return db.writeTxn(() async { - print("Before account insert"); - final id = await db.accounts.put(account); - print("After account insert: $id"); + await db.accounts.put(account); }); } @@ -116,6 +114,14 @@ Future upsertTransactionTemplate(TransactionTemplate template) async { }); } +Future deleteRecurringTransactionTemplate(RecurringTransaction template) { + final db = GetIt.I.get(); + return db.writeTxn(() async { + await db.transactionTemplates.delete(template.template.value!.id); + await db.recurringTransactions.delete(template.id); + }); +} + Future upsertRecurringTransaction(RecurringTransaction template) async { final db = GetIt.I.get(); return db.writeTxn(() async { @@ -148,22 +154,24 @@ Stream watchTransactionTemplates(Account account) { .watchLazy(fireImmediately: true); } -Future> getTransactionTemplates( - Account? account, -) async { +Future deleteTransactionTemplate(TransactionTemplate template) { + final db = GetIt.I.get(); + return db.writeTxn(() async { + await db.transactionTemplates.delete(template.id); + }); +} + +Future> getTransactionTemplates(Account? account) { if (account == null) { return Future.value([]); } - final a = - await GetIt.I - .get() - .transactionTemplates - .filter() - .account((q) => q.idEqualTo(account.id)) - .findAll(); - - return a; + return GetIt.I + .get() + .transactionTemplates + .filter() + .account((q) => q.idEqualTo(account.id)) + .findAll(); } Stream watchTransactions(Account account) { diff --git a/lib/ui/pages/account/upcoming_transactions_card.dart b/lib/ui/pages/account/upcoming_transactions_card.dart index da5ac3c..857fab1 100644 --- a/lib/ui/pages/account/upcoming_transactions_card.dart +++ b/lib/ui/pages/account/upcoming_transactions_card.dart @@ -1,26 +1,24 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; import 'package:okane/database/collections/recurrent.dart'; +import 'package:okane/database/database.dart'; import 'package:okane/ui/state/core.dart'; +import 'package:okane/ui/utils.dart'; +import 'package:okane/ui/widgets/add_transaction.dart'; class UpcomingTransactionsCard extends StatelessWidget { const UpcomingTransactionsCard({super.key}); @override Widget build(BuildContext context) { + final bloc = GetIt.I.get(); return BlocBuilder( builder: (context, state) { final today = DateTime.now(); final upcomingRaw = - state.recurringTransactions.where((t) { - if (t.lastExecution == null) { - return true; - } - - return today.difference(t.lastExecution!).inDays <= - (t.days * 1.5).toInt(); - }).toList(); + state.recurringTransactions.where((t) => t.isDue(today)).toList(); final List upcoming = upcomingRaw.isEmpty ? List.empty() @@ -53,7 +51,23 @@ class UpcomingTransactionsCard extends StatelessWidget { ), trailing: IconButton( icon: Icon(Icons.play_arrow), - onPressed: () {}, + onPressed: () { + showDialogOrModal( + context: context, + builder: + (context) => AddTransactionWidget( + activeAccountItem: bloc.activeAccount!, + template: t.template.value!, + onAdd: (transaction) async { + // Update the recurring template + print(transaction.date); + t.lastExecution = transaction.date; + await upsertRecurringTransaction(t); + Navigator.of(context).pop(); + }, + ), + ); + }, ), ), ) diff --git a/lib/ui/pages/template_list.dart b/lib/ui/pages/template_list.dart index 38376bf..1cd7a42 100644 --- a/lib/ui/pages/template_list.dart +++ b/lib/ui/pages/template_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:grouped_list/grouped_list.dart'; +import 'package:okane/database/database.dart'; import 'package:okane/ui/state/core.dart'; import 'package:okane/ui/utils.dart'; import 'package:okane/ui/widgets/add_template.dart'; @@ -30,7 +31,25 @@ class TemplateListState extends State { itemCount: nonRecurringTemplates.length, itemBuilder: (context, index) { final template = nonRecurringTemplates[index]; - return ListTile(title: Text(template.name)); + return ListTile( + title: Text(template.name), + trailing: IconButton( + icon: Icon(Icons.delete), + color: Colors.red, + onPressed: () async { + final result = await confirm( + context, + "Remove Template", + "Are you sure you want to remove the template '${template.name}'", + ); + if (!result) { + return; + } + + await deleteTransactionTemplate(template); + }, + ), + ); }, ), SliverToBoxAdapter(child: Text("Recurring")), @@ -38,31 +57,32 @@ class TemplateListState extends State { itemCount: state.recurringTransactions.length, itemBuilder: (context, index) { final template = state.recurringTransactions[index]; - return ListTile(title: Text(template.template.value!.name)); + return ListTile( + title: Text(template.template.value!.name), + trailing: IconButton( + icon: Icon(Icons.delete, color: Colors.red), + onPressed: () async { + final result = await confirm( + context, + "Remove Template", + "Are you sure you want to remove the template '${template.template.value!.name}'", + ); + if (!result) { + return; + } + + await deleteRecurringTransactionTemplate(template); + }, + ), + ); }, ), ], ), - /*Padding( - padding: EdgeInsets.only(top: 16), - child: ListView.builder( - itemCount: state.recurringTransactions.length, - shrinkWrap: true, - itemBuilder: (ctx, idx) { - print(idx); - return ListTile( - title: Text( - state.recurringTransactions[idx].template.value!.name, - ), - ); - }, - ), - ),*/ Positioned( right: 16, bottom: 16, child: FloatingActionButton( - child: Icon(Icons.add), onPressed: account == null ? () {} @@ -80,6 +100,7 @@ class TemplateListState extends State { showDragHandle: true, ); }, + child: Icon(Icons.add), ), ), ], diff --git a/lib/ui/pages/transaction_list.dart b/lib/ui/pages/transaction_list.dart index 5076f4a..df0e773 100644 --- a/lib/ui/pages/transaction_list.dart +++ b/lib/ui/pages/transaction_list.dart @@ -83,58 +83,10 @@ class TransactionListState extends State { ), ], ), - /*Column( - children: [ - Padding( - padding: EdgeInsets.only(top: 16), - child: GroupedListView( - elements: state.transactions, - reverse: true, - groupBy: - (Transaction item) => formatDateTime(item.date), - groupHeaderBuilder: - (item) => Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withAlpha(170), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: EdgeInsets.all(4), - child: Text( - formatDateTime(item.date), - style: TextStyle(color: Colors.white), - ), - ), - ), - ], - ), - shrinkWrap: true, - indexedItemBuilder: - (ctx, item, idx) => TransactionCard( - transaction: item, - onTap: () { - GetIt.I.get().setActiveTransaction( - item, - ); - if (getScreenSize(ctx) == ScreenSize.small) { - Navigator.of( - context, - ).pushNamed("/transactions/details"); - } - }, - ), - ), - ), - ], - ),*/ Positioned( right: 16, bottom: 16, child: FloatingActionButton( - child: Icon(Icons.add), onPressed: account == null ? () {} @@ -144,7 +96,7 @@ class TransactionListState extends State { builder: (ctx) => AddTransactionWidget( activeAccountItem: account, - onAdd: () { + onAdd: (_) { setState(() {}); Navigator.of(context).pop(); }, @@ -152,6 +104,7 @@ class TransactionListState extends State { showDragHandle: true, ); }, + child: Icon(Icons.add), ), ), ], diff --git a/lib/ui/state/core.dart b/lib/ui/state/core.dart index 1a1a33d..2ce84e8 100644 --- a/lib/ui/state/core.dart +++ b/lib/ui/state/core.dart @@ -66,6 +66,7 @@ class CoreCubit extends Cubit { _recurringTransactionStreamSubscription = watchRecurringTransactions( activeAccount!, ).listen((_) async { + print("RECURRING UPDATE"); emit( state.copyWith( recurringTransactions: await getRecurringTransactions(activeAccount!), @@ -76,7 +77,6 @@ class CoreCubit extends Cubit { _transactionTemplatesStreamSubcription = watchTransactionTemplates( activeAccount!, ).listen((_) async { - print("UPDATE"); emit( state.copyWith( transactionTemplates: await getTransactionTemplates(activeAccount!), diff --git a/lib/ui/utils.dart b/lib/ui/utils.dart index 80cc5b8..3f0af93 100644 --- a/lib/ui/utils.dart +++ b/lib/ui/utils.dart @@ -116,3 +116,29 @@ String formatCurrency(double amount, {bool precise = true}) { DateTime monthEnding(DateTime now) { return DateTime(now.year, now.month, 32, 23, 59, 59); } + +Future confirm(BuildContext context, String title, String body) async { + final result = await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text("Delete", style: TextStyle(color: Colors.red)), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text("Cancel"), + ), + ], + ), + ); + return result ?? false; +} diff --git a/lib/ui/widgets/add_transaction.dart b/lib/ui/widgets/add_transaction.dart index e087a46..e965222 100644 --- a/lib/ui/widgets/add_transaction.dart +++ b/lib/ui/widgets/add_transaction.dart @@ -4,6 +4,7 @@ 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/collections/transaction.dart'; import 'package:okane/database/database.dart'; import 'package:okane/ui/state/core.dart'; @@ -12,13 +13,18 @@ import 'package:okane/ui/utils.dart'; import 'package:okane/ui/widgets/add_expense_category.dart'; import 'package:searchfield/searchfield.dart'; +typedef AddTransactionCallback = void Function(Transaction); + class AddTransactionWidget extends StatefulWidget { - final VoidCallback onAdd; + final AddTransactionCallback onAdd; final Account activeAccountItem; + final TransactionTemplate? template; + const AddTransactionWidget({ super.key, + this.template, required this.activeAccountItem, required this.onAdd, }); @@ -38,7 +44,26 @@ class _AddTransactionWidgetState extends State { TransactionDirection _selectedDirection = TransactionDirection.send; - ExpenseCategory? _expenseCategory = null; + ExpenseCategory? _expenseCategory; + + @override + void initState() { + super.initState(); + + if (widget.template != null) { + _selectedDirection = + widget.template!.amount > 0 + ? TransactionDirection.receive + : TransactionDirection.send; + _amountTextController.text = widget.template!.amount.toString(); + _beneficiaryTextController.text = + widget.template!.beneficiary.value!.name; + _selectedBeneficiary = SearchFieldListItem( + getBeneficiaryName(widget.template!.beneficiary.value!), + item: widget.template!.beneficiary.value!, + ); + } + } String getBeneficiaryName(Beneficiary item) { return switch (item.type) { @@ -122,7 +147,7 @@ class _AddTransactionWidgetState extends State { await upsertTransaction(otherTransaction); } - widget.onAdd(); + widget.onAdd(transaction); } @override diff --git a/test/database/collections/recurrent.dart b/test/database/collections/recurrent.dart new file mode 100644 index 0000000..ef65596 --- /dev/null +++ b/test/database/collections/recurrent.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:okane/database/collections/recurrent.dart'; + +void main() { + group("isDue", () { + test("null value", () { + final t = RecurringTransaction()..lastExecution = null; + expect(t.isDue(DateTime.now()), true); + }); + + test("Date before", () { + final now = DateTime.now(); + final t = + RecurringTransaction() + ..lastExecution = now + ..days = 30; + expect(t.isDue(now.add(Duration(days: 10))), false); + }); + + test("Date before warning", () { + final now = DateTime.now(); + final t = + RecurringTransaction() + ..lastExecution = now + ..days = 30; + expect(t.isDue(now.add(Duration(days: 20))), true); + }); + + test("Expired", () { + final now = DateTime.now(); + final t = + RecurringTransaction() + ..lastExecution = now + ..days = 30; + expect(t.isDue(now.add(Duration(days: 31))), true); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 9ace553..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:okane/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}