Allow deleting templates

This commit is contained in:
PapaTutuWawa 2025-05-11 15:40:12 +02:00
parent 058291fa80
commit 384aa4eb6f
10 changed files with 194 additions and 125 deletions

View File

@ -15,4 +15,18 @@ class RecurringTransaction {
final template = IsarLink<TransactionTemplate>(); final template = IsarLink<TransactionTemplate>();
final account = IsarLink<Account>(); final account = IsarLink<Account>();
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();
}
} }

View File

@ -83,9 +83,7 @@ Stream<void> watchRecurringTransactions(Account account) {
Future<void> upsertAccount(Account account) async { Future<void> upsertAccount(Account account) async {
final db = GetIt.I.get<Isar>(); final db = GetIt.I.get<Isar>();
return db.writeTxn(() async { return db.writeTxn(() async {
print("Before account insert"); await db.accounts.put(account);
final id = await db.accounts.put(account);
print("After account insert: $id");
}); });
} }
@ -116,6 +114,14 @@ Future<void> upsertTransactionTemplate(TransactionTemplate template) async {
}); });
} }
Future<void> deleteRecurringTransactionTemplate(RecurringTransaction template) {
final db = GetIt.I.get<Isar>();
return db.writeTxn(() async {
await db.transactionTemplates.delete(template.template.value!.id);
await db.recurringTransactions.delete(template.id);
});
}
Future<void> upsertRecurringTransaction(RecurringTransaction template) async { Future<void> upsertRecurringTransaction(RecurringTransaction template) async {
final db = GetIt.I.get<Isar>(); final db = GetIt.I.get<Isar>();
return db.writeTxn(() async { return db.writeTxn(() async {
@ -148,22 +154,24 @@ Stream<void> watchTransactionTemplates(Account account) {
.watchLazy(fireImmediately: true); .watchLazy(fireImmediately: true);
} }
Future<List<TransactionTemplate>> getTransactionTemplates( Future<void> deleteTransactionTemplate(TransactionTemplate template) {
Account? account, final db = GetIt.I.get<Isar>();
) async { return db.writeTxn(() async {
await db.transactionTemplates.delete(template.id);
});
}
Future<List<TransactionTemplate>> getTransactionTemplates(Account? account) {
if (account == null) { if (account == null) {
return Future.value([]); return Future.value([]);
} }
final a = return GetIt.I
await GetIt.I .get<Isar>()
.get<Isar>() .transactionTemplates
.transactionTemplates .filter()
.filter() .account((q) => q.idEqualTo(account.id))
.account((q) => q.idEqualTo(account.id)) .findAll();
.findAll();
return a;
} }
Stream<void> watchTransactions(Account account) { Stream<void> watchTransactions(Account account) {

View File

@ -1,26 +1,24 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/collections/recurrent.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/state/core.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 { class UpcomingTransactionsCard extends StatelessWidget {
const UpcomingTransactionsCard({super.key}); const UpcomingTransactionsCard({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = GetIt.I.get<CoreCubit>();
return BlocBuilder<CoreCubit, CoreState>( return BlocBuilder<CoreCubit, CoreState>(
builder: (context, state) { builder: (context, state) {
final today = DateTime.now(); final today = DateTime.now();
final upcomingRaw = final upcomingRaw =
state.recurringTransactions.where((t) { state.recurringTransactions.where((t) => t.isDue(today)).toList();
if (t.lastExecution == null) {
return true;
}
return today.difference(t.lastExecution!).inDays <=
(t.days * 1.5).toInt();
}).toList();
final List<RecurringTransaction> upcoming = final List<RecurringTransaction> upcoming =
upcomingRaw.isEmpty upcomingRaw.isEmpty
? List.empty() ? List.empty()
@ -53,7 +51,23 @@ class UpcomingTransactionsCard extends StatelessWidget {
), ),
trailing: IconButton( trailing: IconButton(
icon: Icon(Icons.play_arrow), 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();
},
),
);
},
), ),
), ),
) )

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:grouped_list/grouped_list.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/state/core.dart';
import 'package:okane/ui/utils.dart'; import 'package:okane/ui/utils.dart';
import 'package:okane/ui/widgets/add_template.dart'; import 'package:okane/ui/widgets/add_template.dart';
@ -30,7 +31,25 @@ class TemplateListState extends State<TemplateListPage> {
itemCount: nonRecurringTemplates.length, itemCount: nonRecurringTemplates.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final template = nonRecurringTemplates[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")), SliverToBoxAdapter(child: Text("Recurring")),
@ -38,31 +57,32 @@ class TemplateListState extends State<TemplateListPage> {
itemCount: state.recurringTransactions.length, itemCount: state.recurringTransactions.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final template = state.recurringTransactions[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( Positioned(
right: 16, right: 16,
bottom: 16, bottom: 16,
child: FloatingActionButton( child: FloatingActionButton(
child: Icon(Icons.add),
onPressed: onPressed:
account == null account == null
? () {} ? () {}
@ -80,6 +100,7 @@ class TemplateListState extends State<TemplateListPage> {
showDragHandle: true, showDragHandle: true,
); );
}, },
child: Icon(Icons.add),
), ),
), ),
], ],

View File

@ -83,58 +83,10 @@ class TransactionListState extends State<TransactionListPage> {
), ),
], ],
), ),
/*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<CoreCubit>().setActiveTransaction(
item,
);
if (getScreenSize(ctx) == ScreenSize.small) {
Navigator.of(
context,
).pushNamed("/transactions/details");
}
},
),
),
),
],
),*/
Positioned( Positioned(
right: 16, right: 16,
bottom: 16, bottom: 16,
child: FloatingActionButton( child: FloatingActionButton(
child: Icon(Icons.add),
onPressed: onPressed:
account == null account == null
? () {} ? () {}
@ -144,7 +96,7 @@ class TransactionListState extends State<TransactionListPage> {
builder: builder:
(ctx) => AddTransactionWidget( (ctx) => AddTransactionWidget(
activeAccountItem: account, activeAccountItem: account,
onAdd: () { onAdd: (_) {
setState(() {}); setState(() {});
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
@ -152,6 +104,7 @@ class TransactionListState extends State<TransactionListPage> {
showDragHandle: true, showDragHandle: true,
); );
}, },
child: Icon(Icons.add),
), ),
), ),
], ],

View File

@ -66,6 +66,7 @@ class CoreCubit extends Cubit<CoreState> {
_recurringTransactionStreamSubscription = watchRecurringTransactions( _recurringTransactionStreamSubscription = watchRecurringTransactions(
activeAccount!, activeAccount!,
).listen((_) async { ).listen((_) async {
print("RECURRING UPDATE");
emit( emit(
state.copyWith( state.copyWith(
recurringTransactions: await getRecurringTransactions(activeAccount!), recurringTransactions: await getRecurringTransactions(activeAccount!),
@ -76,7 +77,6 @@ class CoreCubit extends Cubit<CoreState> {
_transactionTemplatesStreamSubcription = watchTransactionTemplates( _transactionTemplatesStreamSubcription = watchTransactionTemplates(
activeAccount!, activeAccount!,
).listen((_) async { ).listen((_) async {
print("UPDATE");
emit( emit(
state.copyWith( state.copyWith(
transactionTemplates: await getTransactionTemplates(activeAccount!), transactionTemplates: await getTransactionTemplates(activeAccount!),

View File

@ -116,3 +116,29 @@ String formatCurrency(double amount, {bool precise = true}) {
DateTime monthEnding(DateTime now) { DateTime monthEnding(DateTime now) {
return DateTime(now.year, now.month, 32, 23, 59, 59); return DateTime(now.year, now.month, 32, 23, 59, 59);
} }
Future<bool> confirm(BuildContext context, String title, String body) async {
final result = await showDialog<bool>(
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;
}

View File

@ -4,6 +4,7 @@ import 'package:get_it/get_it.dart';
import 'package:okane/database/collections/account.dart'; import 'package:okane/database/collections/account.dart';
import 'package:okane/database/collections/beneficiary.dart'; import 'package:okane/database/collections/beneficiary.dart';
import 'package:okane/database/collections/expense_category.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/collections/transaction.dart';
import 'package:okane/database/database.dart'; import 'package:okane/database/database.dart';
import 'package:okane/ui/state/core.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:okane/ui/widgets/add_expense_category.dart';
import 'package:searchfield/searchfield.dart'; import 'package:searchfield/searchfield.dart';
typedef AddTransactionCallback = void Function(Transaction);
class AddTransactionWidget extends StatefulWidget { class AddTransactionWidget extends StatefulWidget {
final VoidCallback onAdd; final AddTransactionCallback onAdd;
final Account activeAccountItem; final Account activeAccountItem;
final TransactionTemplate? template;
const AddTransactionWidget({ const AddTransactionWidget({
super.key, super.key,
this.template,
required this.activeAccountItem, required this.activeAccountItem,
required this.onAdd, required this.onAdd,
}); });
@ -38,7 +44,26 @@ class _AddTransactionWidgetState extends State<AddTransactionWidget> {
TransactionDirection _selectedDirection = TransactionDirection.send; 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) { String getBeneficiaryName(Beneficiary item) {
return switch (item.type) { return switch (item.type) {
@ -122,7 +147,7 @@ class _AddTransactionWidgetState extends State<AddTransactionWidget> {
await upsertTransaction(otherTransaction); await upsertTransaction(otherTransaction);
} }
widget.onAdd(); widget.onAdd(transaction);
} }
@override @override

View File

@ -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);
});
});
}

View File

@ -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);
});
}