From 223c1d5c8aa470d277cf486cf7acbab646c2bf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 18:21:31 +0100 Subject: [PATCH 1/4] feat: add creation of recurring entries --- lib/api/recurring_entry.dart | 52 +++ lib/api/recurring_entry.g.dart | 45 +++ lib/api/wallet.dart | 9 +- lib/api/wallet.g.dart | 6 + .../{walletentry.dart => wallet_entry.dart} | 2 +- ...walletentry.g.dart => wallet_entry.g.dart} | 2 +- ...walletmanager.dart => wallet_manager.dart} | 0 lib/l10n/app_cs.arb | 8 +- lib/l10n/app_en.arb | 44 ++- lib/util/drawer.dart | 17 + lib/util/graphs.dart | 3 +- lib/views/create_entry.dart | 12 +- lib/views/create_recur_entry.dart | 361 ++++++++++++++++++ lib/views/graph_view.dart | 2 +- lib/views/home.dart | 14 +- lib/views/recurring_view.dart | 290 ++++++++++++++ lib/views/settings/edit_categories.dart | 2 +- lib/views/setup.dart | 2 +- 18 files changed, 848 insertions(+), 23 deletions(-) create mode 100644 lib/api/recurring_entry.dart create mode 100644 lib/api/recurring_entry.g.dart rename lib/api/{walletentry.dart => wallet_entry.dart} (97%) rename lib/api/{walletentry.g.dart => wallet_entry.g.dart} (97%) rename lib/api/{walletmanager.dart => wallet_manager.dart} (100%) create mode 100644 lib/views/create_recur_entry.dart create mode 100644 lib/views/recurring_view.dart diff --git a/lib/api/recurring_entry.dart b/lib/api/recurring_entry.dart new file mode 100644 index 0000000..f45aac8 --- /dev/null +++ b/lib/api/recurring_entry.dart @@ -0,0 +1,52 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:prasule/api/category.dart'; +import 'package:prasule/api/entry_data.dart'; +import 'package:prasule/api/wallet_entry.dart'; + +part 'recurring_entry.g.dart'; + +/// This is a [WalletSingleEntry] that is automatically recurring +@JsonSerializable() +class RecurringWalletEntry extends WalletSingleEntry { + /// This is a [WalletSingleEntry] that is automatically recurring + RecurringWalletEntry({ + required super.data, + required super.type, + required super.date, + required super.category, + required super.id, + required this.lastRunDate, + required this.recurType, + this.repeatAfter = 1, + }); + + /// Connects generated fromJson method + factory RecurringWalletEntry.fromJson(Map json) => + _$RecurringWalletEntryFromJson(json); + + /// Connects generated toJson method + @override + Map toJson() => _$RecurringWalletEntryToJson(this); + + /// Last date the recurring entry was added into the single entry list + DateTime lastRunDate; + + /// After how many {recurType} should the entry recur + int repeatAfter; + + /// What type of recurrence should happen + RecurType recurType; +} + +/// How a [RecurringWalletEntry] should recur +@JsonEnum() +enum RecurType { + /// Will recur every {repeatAfter} months + month, + + /// Will recur every {repeatAfter} years + year, + + /// Will recur every {repeatAfter} days + day +} diff --git a/lib/api/recurring_entry.g.dart b/lib/api/recurring_entry.g.dart new file mode 100644 index 0000000..75400d6 --- /dev/null +++ b/lib/api/recurring_entry.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recurring_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RecurringWalletEntry _$RecurringWalletEntryFromJson( + Map json) => + RecurringWalletEntry( + data: EntryData.fromJson(json['data'] as Map), + type: $enumDecode(_$EntryTypeEnumMap, json['type']), + date: DateTime.parse(json['date'] as String), + category: + WalletCategory.fromJson(json['category'] as Map), + id: json['id'] as int, + lastRunDate: DateTime.parse(json['lastRunDate'] as String), + repeatAfter: json['repeatAfter'] as int, + recurType: $enumDecode(_$RecurTypeEnumMap, json['recurType']), + ); + +Map _$RecurringWalletEntryToJson( + RecurringWalletEntry instance) => + { + 'type': _$EntryTypeEnumMap[instance.type]!, + 'data': instance.data, + 'date': instance.date.toIso8601String(), + 'category': instance.category, + 'id': instance.id, + 'lastRunDate': instance.lastRunDate.toIso8601String(), + 'repeatAfter': instance.repeatAfter, + 'recurType': _$RecurTypeEnumMap[instance.recurType]!, + }; + +const _$EntryTypeEnumMap = { + EntryType.expense: 'expense', + EntryType.income: 'income', +}; + +const _$RecurTypeEnumMap = { + RecurType.month: 'month', + RecurType.year: 'year', + RecurType.day: 'day', +}; diff --git a/lib/api/wallet.dart b/lib/api/wallet.dart index e38b844..39e4258 100644 --- a/lib/api/wallet.dart +++ b/lib/api/wallet.dart @@ -1,8 +1,9 @@ import 'package:currency_picker/currency_picker.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:prasule/api/category.dart'; -import 'package:prasule/api/walletentry.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/recurring_entry.dart'; +import 'package:prasule/api/wallet_entry.dart'; +import 'package:prasule/api/wallet_manager.dart'; part 'wallet.g.dart'; Currency _currencyFromJson(Map data) => @@ -19,12 +20,16 @@ class Wallet { required this.currency, this.categories = const [], this.entries = const [], + this.recurringEntries = const [], this.starterBalance = 0, }); /// Connects generated fromJson method factory Wallet.fromJson(Map json) => _$WalletFromJson(json); + /// A list of all [RecurringWalletEntry]s + final List recurringEntries; + /// Name of the wallet final String name; diff --git a/lib/api/wallet.g.dart b/lib/api/wallet.g.dart index d2affe5..561b831 100644 --- a/lib/api/wallet.g.dart +++ b/lib/api/wallet.g.dart @@ -18,10 +18,16 @@ Wallet _$WalletFromJson(Map json) => Wallet( (e) => WalletSingleEntry.fromJson(e as Map)) .toList() ?? const [], + recurringEntries: (json['recurringEntries'] as List?) + ?.map((e) => + RecurringWalletEntry.fromJson(e as Map)) + .toList() ?? + const [], starterBalance: (json['starterBalance'] as num?)?.toDouble() ?? 0, ); Map _$WalletToJson(Wallet instance) => { + 'recurringEntries': instance.recurringEntries, 'name': instance.name, 'categories': instance.categories, 'entries': instance.entries, diff --git a/lib/api/walletentry.dart b/lib/api/wallet_entry.dart similarity index 97% rename from lib/api/walletentry.dart rename to lib/api/wallet_entry.dart index a515fc5..e6f85da 100644 --- a/lib/api/walletentry.dart +++ b/lib/api/wallet_entry.dart @@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; -part 'walletentry.g.dart'; +part 'wallet_entry.g.dart'; @JsonSerializable() diff --git a/lib/api/walletentry.g.dart b/lib/api/wallet_entry.g.dart similarity index 97% rename from lib/api/walletentry.g.dart rename to lib/api/wallet_entry.g.dart index fcdd640..c994ecf 100644 --- a/lib/api/walletentry.g.dart +++ b/lib/api/wallet_entry.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'walletentry.dart'; +part of 'wallet_entry.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/walletmanager.dart b/lib/api/wallet_manager.dart similarity index 100% rename from lib/api/walletmanager.dart rename to lib/api/wallet_manager.dart diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 0c9bc09..d1794ff 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -81,5 +81,11 @@ "noCategory":"Žádná kategorie", "done":"Hotovo", "pickColor":"Zvolte barvu", - "changeDate":"Změnit ze kterého měsíce/roku brát data" + "changeDate":"Změnit ze kterého měsíce/roku brát data", + "recurringPayments":"Opakující se platby", + "monthCounter": "{count, plural, =1{měsíc} few{měsíce} many{měsíců} other{měsíců} }", + "dayCounter":"{count, plural, =1{den} few{dny} many{dnů} other{dnů} }", + "yearCounter":"{count, plural, =1{rok} few{rok} many{let} other{let} }", + "recurEvery":"{count, plural, =1{Opakovat každý} few{Opakovat každé} many{Opakovat každých} other{Opakovat každých}}", + "startingWithDate": "počínaje datem" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b96bd6f..c2fe305 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -161,5 +161,47 @@ "noCategory":"No category", "done":"Done", "pickColor":"Pick a color", - "changeDate":"Change what month/year to pick data from" + "changeDate":"Change what month/year to pick data from", + "recurringPayments":"Recurring payments", + "recurEvery":"{count, plural, other{Recur every}}", + "@recurEvery":{ + "description": "Shown when creating recurring entries, ex.: Recur every 2 months", + "placeholders": { + "count":{ + "description": "Specifies how many X are being counted", + "type": "int" + } + } + }, + "monthCounter":"{count, plural, =1{month} other{months} }", + "@monthCounter":{ + "placeholders": { + "count":{ + "description": "Specifies how many months are being counted", + "type": "int" + } + } + }, + "dayCounter":"{count, plural, =1{day} other{days} }", + "@dayCounter":{ + "placeholders": { + "count":{ + "description": "Specifies how many days are being counted", + "type": "int" + } + } + }, + "yearCounter":"{count, plural, =1{year} other{years} }", + "@yearCounter":{ + "placeholders": { + "count":{ + "description": "Specifies how many years are being counted", + "type": "int" + } + } + }, + "startingWithDate": "starting", + "@startingWithDate":{ + "description": "Shown after 'Recur every X Y', e.g. 'Recur every 2 month starting 20th June 2023'" + } } \ No newline at end of file diff --git a/lib/util/drawer.dart b/lib/util/drawer.dart index c49d3ae..76308db 100644 --- a/lib/util/drawer.dart +++ b/lib/util/drawer.dart @@ -3,6 +3,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/views/graph_view.dart'; import 'package:prasule/views/home.dart'; +import 'package:prasule/views/recurring_view.dart'; /// Makes the drawer because I won't enter the same code in every view Drawer makeDrawer(BuildContext context, int page) => Drawer( @@ -39,6 +40,22 @@ Drawer makeDrawer(BuildContext context, int page) => Drawer( .pushReplacement(platformRoute((p0) => const GraphView())); }, ), + ListTile( + leading: const Icon(Icons.repeat), + title: Text( + AppLocalizations.of(context).recurringPayments, + ), + selected: page == 3, + onTap: () { + if (page == 3) { + Navigator.of(context).pop(); + return; + } + Navigator.of(context).pushReplacement( + platformRoute((p0) => const RecurringEntriesView()), + ); + }, + ), ], ), ); diff --git a/lib/util/graphs.dart b/lib/util/graphs.dart index 2ef91fe..2ccd5e5 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -5,8 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; -import 'package:prasule/api/walletentry.dart'; -import 'package:prasule/main.dart'; +import 'package:prasule/api/wallet_entry.dart'; import 'package:prasule/util/get_last_date.dart'; import 'package:prasule/util/text_color.dart'; diff --git a/lib/views/create_entry.dart b/lib/views/create_entry.dart index 9ed1136..3640241 100644 --- a/lib/views/create_entry.dart +++ b/lib/views/create_entry.dart @@ -4,15 +4,15 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletentry.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_entry.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformfield.dart'; /// Used when user wants to add new entry -class CreateEntryView extends StatefulWidget { +class CreateSingleEntryView extends StatefulWidget { /// Used when user wants to add new entry - const CreateEntryView({required this.w, super.key, this.editEntry}); + const CreateSingleEntryView({required this.w, super.key, this.editEntry}); /// The wallet, where the entry will be saved to final Wallet w; @@ -23,10 +23,10 @@ class CreateEntryView extends StatefulWidget { final WalletSingleEntry? editEntry; @override - State createState() => _CreateEntryViewState(); + State createState() => _CreateSingleEntryViewState(); } -class _CreateEntryViewState extends State { +class _CreateSingleEntryViewState extends State { late WalletSingleEntry newEntry; @override void initState() { diff --git a/lib/views/create_recur_entry.dart b/lib/views/create_recur_entry.dart new file mode 100644 index 0000000..f3b7b31 --- /dev/null +++ b/lib/views/create_recur_entry.dart @@ -0,0 +1,361 @@ +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:prasule/api/category.dart'; +import 'package:prasule/api/entry_data.dart'; +import 'package:prasule/api/recurring_entry.dart'; +import 'package:prasule/api/wallet.dart'; +import 'package:prasule/api/wallet_manager.dart'; +import 'package:prasule/main.dart'; +import 'package:prasule/pw/platformbutton.dart'; +import 'package:prasule/pw/platformfield.dart'; +import 'package:prasule/util/text_color.dart'; + +/// Used when user wants to add new entry +class CreateRecurringEntryView extends StatefulWidget { + /// Used when user wants to add new entry + const CreateRecurringEntryView({ + required this.w, + required this.locale, + super.key, + this.editEntry, + }); + + /// The wallet, where the entry will be saved to + final Wallet w; + + /// Entry we want to edit + /// + /// Is null unless we are editing an existing entry + final RecurringWalletEntry? editEntry; + + /// Selected locale + final String locale; + + @override + State createState() => _CreateRecurringEntryViewState(); +} + +class _CreateRecurringEntryViewState extends State { + late RecurringWalletEntry newEntry; + @override + void initState() { + super.initState(); + if (widget.editEntry != null) { + newEntry = widget.editEntry!; + } else { + newEntry = RecurringWalletEntry( + id: widget.w.nextId, + data: EntryData(amount: 0, name: ""), + type: EntryType.expense, + date: DateTime.now(), + category: widget.w.categories.first, + lastRunDate: DateTime.now(), + recurType: RecurType.month, + ); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context).createEntry), + ), + body: SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: PlatformField( + labelText: AppLocalizations.of(context).name, + controller: TextEditingController(text: newEntry.data.name), + onChanged: (v) { + newEntry.data.name = v; + }, + ), + ), + const SizedBox( + height: 15, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: PlatformField( + labelText: AppLocalizations.of(context).amount, + controller: TextEditingController( + text: newEntry.data.amount.toString(), + ), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'\d+[\.,]{0,1}\d{0,}'), + ), + ], + onChanged: (v) { + logger.i(v); + newEntry.data.amount = double.parse(v); + }, + ), + ), + const SizedBox( + height: 20, + ), + Text(AppLocalizations.of(context).type), + const SizedBox( + height: 10, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: DropdownButton( + value: newEntry.type, + items: [ + DropdownMenuItem( + value: EntryType.expense, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.8 - 24, + child: Text( + AppLocalizations.of(context).expense, + ), + ), + ), + DropdownMenuItem( + value: EntryType.income, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.8 - 24, + child: Text(AppLocalizations.of(context).income), + ), + ), + ], + onChanged: (v) { + if (v == null) return; + newEntry.type = v; + setState(() {}); + }, + ), + ), + const SizedBox( + height: 20, + ), + Text(AppLocalizations.of(context).category), + const SizedBox( + height: 10, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: DropdownButton( + value: newEntry.category.id, + items: List.generate( + widget.w.categories.length, + (index) => DropdownMenuItem( + value: widget.w.categories[index].id, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.8 - 24, + child: Text( + widget.w.categories[index].name, + ), + ), + ), + ), + onChanged: (v) { + if (v == null) return; + newEntry.category = widget.w.categories + .where((element) => element.id == v) + .first; + setState(() {}); + }, + ), + ), + const SizedBox( + height: 20, + ), + Text(AppLocalizations.of(context).description), + const SizedBox( + height: 10, + ), + ConstrainedBox( + constraints: BoxConstraints( + minWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.of(context).size.width * 0.8, + maxHeight: 300, + ), + child: PlatformField( + keyboardType: TextInputType.multiline, + maxLines: null, + controller: TextEditingController( + text: newEntry.data.description, + ), + onChanged: (v) { + newEntry.data.description = v; + }, + ), + ), + const SizedBox( + height: 20, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + AppLocalizations.of(context) + .recurEvery(newEntry.repeatAfter), + ), + const SizedBox( + width: 10, + ), + SizedBox( + width: 50, + child: PlatformField( + controller: TextEditingController( + text: newEntry.repeatAfter.toString(), + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + FilteringTextInputFormatter.deny( + RegExp(r"^0$"), + replacementString: "1", + ), + ], + onChanged: (s) { + final n = int.tryParse(s); + if (n == null) return; + newEntry.repeatAfter = n; + setState(() {}); + }, + ), + ), + ], + ), + ), + SizedBox( + width: 200, + child: DropdownButton( + value: newEntry.recurType, + items: [ + DropdownMenuItem( + value: RecurType.day, + child: SizedBox( + width: 176, + child: Text( + AppLocalizations.of(context) + .dayCounter(newEntry.repeatAfter), + ), + ), + ), + DropdownMenuItem( + value: RecurType.month, + child: SizedBox( + width: 176, + child: Text( + AppLocalizations.of(context) + .monthCounter(newEntry.repeatAfter), + ), + ), + ), + DropdownMenuItem( + value: RecurType.year, + child: SizedBox( + width: 176, + child: Text( + AppLocalizations.of(context) + .yearCounter(newEntry.repeatAfter), + ), + ), + ), + ], + onChanged: (v) { + if (v == null) return; + newEntry.recurType = v; + setState(() {}); + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(AppLocalizations.of(context).startingWithDate), + const SizedBox( + width: 10, + ), // TODO: maybe use sizedbox on row with spaceEvenly? + PlatformButton( + text: DateFormat.yMMMMd(widget.locale) + .format(newEntry.lastRunDate), + style: (widget.editEntry != null) + ? ButtonStyle( + backgroundColor: MaterialStateProperty.all( + Colors.grey.harmonizeWith( + Theme.of(context).colorScheme.primary, + ), + ), + foregroundColor: MaterialStateProperty.all( + Colors.grey + .harmonizeWith( + Theme.of(context).colorScheme.primary, + ) + .calculateTextColor() + .harmonizeWith( + Theme.of(context).colorScheme.primary), + ), + ) + : null, + onPressed: () async { + if (widget.editEntry != null) { + return; // disabled on edit + } + final d = await showDatePicker( + context: context, + firstDate: DateTime.now(), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (d == null) return; + newEntry.lastRunDate = d; + setState(() {}); + }, + ), + ], + ), + const SizedBox( + height: 15, + ), + PlatformButton( + text: AppLocalizations.of(context).save, + onPressed: () { + if (newEntry.data.name.isEmpty) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(AppLocalizations.of(context).errorEmptyName), + ), + ); + return; + } + if (widget.editEntry != null) { + Navigator.of(context).pop(newEntry); + return; + } + widget.w.recurringEntries.add(newEntry); + WalletManager.saveWallet(widget.w).then( + (value) => Navigator.of(context).pop(widget.w), + ); // TODO loading circle? + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/graph_view.dart b/lib/views/graph_view.dart index 37d6cb6..b2fc2d8 100644 --- a/lib/views/graph_view.dart +++ b/lib/views/graph_view.dart @@ -5,7 +5,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformroute.dart'; diff --git a/lib/views/home.dart b/lib/views/home.dart index a1edb1e..0fff9db 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -16,8 +16,8 @@ import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletentry.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_entry.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/network/tessdata.dart'; import 'package:prasule/pw/platformbutton.dart'; @@ -127,7 +127,7 @@ class _HomeViewState extends State { onTap: () async { final sw = await Navigator.of(context).push( MaterialPageRoute( - builder: (c) => CreateEntryView(w: selectedWallet!), + builder: (c) => CreateSingleEntryView(w: selectedWallet!), ), ); if (sw != null) { @@ -291,7 +291,7 @@ class _HomeViewState extends State { Navigator.of(context) .push( MaterialPageRoute( - builder: (c) => CreateEntryView( + builder: (c) => CreateSingleEntryView( w: selectedWallet!, editEntry: element, ), @@ -372,7 +372,9 @@ class _HomeViewState extends State { ), title: Text(element.data.name), subtitle: Text( - "${element.data.amount} ${selectedWallet!.currency.symbol}", + NumberFormat.currency( + symbol: selectedWallet!.currency.symbol, + ).format(element.data.amount), ), ), ), @@ -472,7 +474,7 @@ class _HomeViewState extends State { final newEntry = await Navigator.of(context).push( platformRoute( - (c) => CreateEntryView( + (c) => CreateSingleEntryView( w: selectedWallet!, editEntry: WalletSingleEntry( data: EntryData( diff --git a/lib/views/recurring_view.dart b/lib/views/recurring_view.dart new file mode 100644 index 0000000..b3482b1 --- /dev/null +++ b/lib/views/recurring_view.dart @@ -0,0 +1,290 @@ +// ignore_for_file: inference_failure_on_function_invocation + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; +import 'package:prasule/api/recurring_entry.dart'; +import 'package:prasule/api/wallet.dart'; +import 'package:prasule/api/wallet_manager.dart'; +import 'package:prasule/pw/platformbutton.dart'; +import 'package:prasule/pw/platformdialog.dart'; +import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/util/drawer.dart'; +import 'package:prasule/util/text_color.dart'; +import 'package:prasule/views/create_recur_entry.dart'; +import 'package:prasule/views/settings/settings.dart'; +import 'package:prasule/views/setup.dart'; + +/// Used to set up recurring entries +class RecurringEntriesView extends StatefulWidget { + /// Used to set up recurring entries + const RecurringEntriesView({super.key}); + + @override + State createState() => _RecurringEntriesViewState(); +} + +class _RecurringEntriesViewState extends State { + Wallet? selectedWallet; + List wallets = []; + + late String locale; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + locale = Localizations.localeOf(context).languageCode; + initializeDateFormatting(Localizations.localeOf(context).languageCode); + } + + @override + void initState() { + super.initState(); + loadWallet(); + } + + Future loadWallet() async { + wallets = await WalletManager.listWallets(); + if (wallets.isEmpty && mounted) { + unawaited( + Navigator.of(context) + .pushReplacement(platformRoute((c) => const SetupView())), + ); + return; + } + selectedWallet = wallets.first; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + drawer: makeDrawer(context, 3), + appBar: AppBar( + title: DropdownButton( + value: + (selectedWallet == null) ? -1 : wallets.indexOf(selectedWallet!), + items: [ + ...wallets.map( + (e) => DropdownMenuItem( + value: wallets.indexOf( + e, + ), + child: Text(e.name), + ), + ), + DropdownMenuItem( + value: -1, + child: Text(AppLocalizations.of(context).newWallet), + ), + ], + onChanged: (v) async { + if (v == null || v == -1) { + await Navigator.of(context).push( + platformRoute( + (c) => const SetupView( + newWallet: true, + ), + ), + ); + wallets = await WalletManager.listWallets(); + selectedWallet = wallets.last; + setState(() {}); + return; + } + selectedWallet = wallets[v]; + setState(() {}); + }, + ), + actions: [ + PopupMenuButton( + itemBuilder: (context) => [ + AppLocalizations.of(context).settings, + AppLocalizations.of(context).about, + ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), + onSelected: (value) { + if (value == AppLocalizations.of(context).settings) { + Navigator.of(context) + .push( + platformRoute( + (context) => const SettingsView(), + ), + ) + .then((value) async { + selectedWallet = + await WalletManager.loadWallet(selectedWallet!.name); + }); + } else if (value == AppLocalizations.of(context).about) { + showAboutDialog( + context: context, + applicationLegalese: AppLocalizations.of(context).license, + applicationName: "Prašule", + ); + } + }, + ), + ], + ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { + Navigator.of(context).push( + platformRoute( + (p0) => CreateRecurringEntryView( + w: selectedWallet!, + locale: locale, + ), + ), + ); + }, + ), + body: Center( + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: (selectedWallet == null) + ? const Column( + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator(), + ), + ], + ) + : (selectedWallet!.recurringEntries.isEmpty) + ? Column( + children: [ + Text( + AppLocalizations.of(context).noEntries, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + AppLocalizations.of(context).noEntriesSub, + ), + ], + ) + : ListView.builder( + itemBuilder: (c, i) => Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + SlidableAction( + onPressed: (c) { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (c) => CreateRecurringEntryView( + w: selectedWallet!, + locale: locale, + editEntry: + selectedWallet!.recurringEntries[i], + ), + ), + ) + .then( + (editedEntry) { + if (editedEntry == null) return; + selectedWallet!.entries.remove( + selectedWallet!.recurringEntries[i], + ); + selectedWallet!.entries.add(editedEntry); + WalletManager.saveWallet(selectedWallet!); + setState(() {}); + }, + ); + }, + backgroundColor: + Theme.of(context).colorScheme.secondary, + foregroundColor: + Theme.of(context).colorScheme.onSecondary, + icon: Icons.edit, + ), + SlidableAction( + backgroundColor: + Theme.of(context).colorScheme.error, + foregroundColor: + Theme.of(context).colorScheme.onError, + icon: Icons.delete, + onPressed: (c) { + showDialog( + context: context, + builder: (cx) => PlatformDialog( + title: + AppLocalizations.of(context).sureDialog, + content: Text( + AppLocalizations.of(context).deleteSure, + ), + actions: [ + PlatformButton( + text: AppLocalizations.of(context).yes, + onPressed: () { + selectedWallet!.recurringEntries + .removeWhere( + (e) => + e.id == + selectedWallet! + .recurringEntries[i].id, + ); + WalletManager.saveWallet( + selectedWallet!, + ); + Navigator.of(cx).pop(); + setState(() {}); + }, + ), + PlatformButton( + text: AppLocalizations.of(context).no, + onPressed: () { + Navigator.of(cx).pop(); + }, + ), + ], + ), + ); + }, + ), + ], + ), + child: ListTile( + leading: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: selectedWallet! + .recurringEntries[i].category.color, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + selectedWallet! + .recurringEntries[i].category.icon, + color: selectedWallet! + .recurringEntries[i].category.color + .calculateTextColor(), + ), + ), + ), + title: Text( + selectedWallet!.recurringEntries[i].data.name, + ), + subtitle: Text( + NumberFormat.currency( + symbol: selectedWallet!.currency.symbol, + ).format( + selectedWallet!.recurringEntries[i].data.amount, + ), + ), + ), + ), + itemCount: selectedWallet!.recurringEntries.length, + ), + ), + ), + ); + } +} diff --git a/lib/views/settings/edit_categories.dart b/lib/views/settings/edit_categories.dart index 98fe706..832234d 100644 --- a/lib/views/settings/edit_categories.dart +++ b/lib/views/settings/edit_categories.dart @@ -9,7 +9,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_iconpicker/flutter_iconpicker.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; diff --git a/lib/views/setup.dart b/lib/views/setup.dart index bc0b071..e273149 100644 --- a/lib/views/setup.dart +++ b/lib/views/setup.dart @@ -10,7 +10,7 @@ import 'package:flutter_iconpicker/flutter_iconpicker.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformfield.dart'; -- 2.45.2 From 741fb1f11a670df18cdb4cfd5fa8fd82e02df2b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 18:21:39 +0100 Subject: [PATCH 2/4] docs: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4097a41..d6c7440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Create a default "no category" category, mainly to store entries with removed categories - Categories now have changeable colors assigned to them - Added pie chart for expense/income data per category +- Added recurring entries # 1.0.0-alpha+2 - Fixed localization issues - Added graphs for expenses and income per month/year -- 2.45.2 From 2873848e744a3dbd181b61e2b5bb168e3f338684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 21:11:39 +0100 Subject: [PATCH 3/4] feat: add recurring entries into the entrylist on start --- lib/api/wallet.dart | 52 +++++++++++++++++++++++++++++++ lib/views/create_recur_entry.dart | 27 +++------------- lib/views/home.dart | 39 ++++++++++++++++++++++- lib/views/recurring_view.dart | 8 ++--- 4 files changed, 97 insertions(+), 29 deletions(-) diff --git a/lib/api/wallet.dart b/lib/api/wallet.dart index 39e4258..959add1 100644 --- a/lib/api/wallet.dart +++ b/lib/api/wallet.dart @@ -1,9 +1,11 @@ import 'package:currency_picker/currency_picker.dart'; +import 'package:intl/intl.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/recurring_entry.dart'; import 'package:prasule/api/wallet_entry.dart'; import 'package:prasule/api/wallet_manager.dart'; +import 'package:prasule/main.dart'; part 'wallet.g.dart'; Currency _currencyFromJson(Map data) => @@ -70,6 +72,56 @@ class Wallet { return id; } + /// Handles adding recurring entries to the entry list + void recurEntries() { + final n = DateTime.now(); + for (final ent in recurringEntries) { + var m = DateTime( + (ent.recurType == RecurType.year) + ? ent.lastRunDate.year + ent.repeatAfter + : ent.lastRunDate.year, + (ent.recurType == RecurType.month) + ? ent.lastRunDate.month + ent.repeatAfter + : ent.lastRunDate.month, + (ent.recurType == RecurType.day) + ? ent.lastRunDate.day + ent.repeatAfter + : ent.lastRunDate.day, + ); // create the date after which we should recur + + while (n.isAfter( + m, + )) { + logger.i("Adding recurring entry ${ent.data.name}"); + recurringEntries[recurringEntries.indexOf(ent)].lastRunDate = + m; // update date on recurring entry + logger.i(recurringEntries[recurringEntries.indexOf(ent)].lastRunDate); + final addedEntry = (recurringEntries[recurringEntries.indexOf(ent)] + as WalletSingleEntry) + ..date = DateTime.now() + ..id = nextId; // copy entry with today's date and unique ID + entries.add( + addedEntry, + ); // add it to entries + + m = DateTime( + (ent.recurType == RecurType.year) + ? ent.lastRunDate.year + ent.repeatAfter + : ent.lastRunDate.year, + (ent.recurType == RecurType.month) + ? ent.lastRunDate.month + ent.repeatAfter + : ent.lastRunDate.month, + (ent.recurType == RecurType.day) + ? ent.lastRunDate.day + ent.repeatAfter + : ent.lastRunDate.day, + ); // add tne variable again to check if we aren't missing any entries + logger.i( + "Last recurred date is now on ${DateFormat.yMMMMd().format(m)} (${n.isAfter(m)})"); + } + + WalletManager.saveWallet(this); // save wallet + } + } + /// Removes the specified category. /// /// All [WalletSingleEntry]s will have their category reassigned diff --git a/lib/views/create_recur_entry.dart b/lib/views/create_recur_entry.dart index f3b7b31..6047980 100644 --- a/lib/views/create_recur_entry.dart +++ b/lib/views/create_recur_entry.dart @@ -1,4 +1,3 @@ -import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -11,7 +10,6 @@ import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformfield.dart'; -import 'package:prasule/util/text_color.dart'; /// Used when user wants to add new entry class CreateRecurringEntryView extends StatefulWidget { @@ -225,6 +223,10 @@ class _CreateRecurringEntryViewState extends State { RegExp(r"^0$"), replacementString: "1", ), + FilteringTextInputFormatter.deny( + r"\d+[\.,]{0,1}\d{0,}", + replacementString: "1", + ), ], onChanged: (s) { final n = int.tryParse(s); @@ -290,28 +292,7 @@ class _CreateRecurringEntryViewState extends State { PlatformButton( text: DateFormat.yMMMMd(widget.locale) .format(newEntry.lastRunDate), - style: (widget.editEntry != null) - ? ButtonStyle( - backgroundColor: MaterialStateProperty.all( - Colors.grey.harmonizeWith( - Theme.of(context).colorScheme.primary, - ), - ), - foregroundColor: MaterialStateProperty.all( - Colors.grey - .harmonizeWith( - Theme.of(context).colorScheme.primary, - ) - .calculateTextColor() - .harmonizeWith( - Theme.of(context).colorScheme.primary), - ), - ) - : null, onPressed: () async { - if (widget.editEntry != null) { - return; // disabled on edit - } final d = await showDatePicker( context: context, firstDate: DateTime.now(), diff --git a/lib/views/home.dart b/lib/views/home.dart index 0fff9db..2ed0fac 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -15,6 +15,7 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; +import 'package:prasule/api/recurring_entry.dart'; import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/wallet_entry.dart'; import 'package:prasule/api/wallet_manager.dart'; @@ -67,6 +68,7 @@ class _HomeViewState extends State { return; } selectedWallet = wallets.first; + selectedWallet!.recurEntries(); setState(() {}); } @@ -86,6 +88,7 @@ class _HomeViewState extends State { // debug option to quickly fill a wallet with data if (selectedWallet == null) return; selectedWallet!.entries.clear(); + selectedWallet!.recurringEntries.clear(); final random = Random(); for (var i = 0; i < 30; i++) { selectedWallet!.entries.add( @@ -109,7 +112,41 @@ class _HomeViewState extends State { ); } - logger.i(selectedWallet!.entries.length); + logger.d( + "Created ${selectedWallet!.entries.length} regular entries", + ); + + for (var i = 0; i < 3; i++) { + final type = random.nextInt(3); + selectedWallet!.recurringEntries.add( + RecurringWalletEntry( + data: EntryData( + name: "Recurring Entry #${i + 1}", + amount: random.nextInt(20000).toDouble(), + ), + type: (random.nextInt(3) > 0) + ? EntryType.expense + : EntryType.income, + date: DateTime( + 2023, + random.nextInt(12) + 1, + random.nextInt(28) + 1, + ), + category: selectedWallet!.categories[ + random.nextInt(selectedWallet!.categories.length)], + id: selectedWallet!.nextId, + lastRunDate: DateTime.now().subtract( + Duration( + days: (type > 0) ? 3 : 3 * 31, + ), + ), + recurType: (type > 0) ? RecurType.day : RecurType.month, + ), + ); + } + + logger.d( + "Created ${selectedWallet!.recurringEntries.length} recurring entries"); // save and reload WalletManager.saveWallet(selectedWallet!).then((value) { diff --git a/lib/views/recurring_view.dart b/lib/views/recurring_view.dart index b3482b1..49bc3e3 100644 --- a/lib/views/recurring_view.dart +++ b/lib/views/recurring_view.dart @@ -129,6 +129,7 @@ class _RecurringEntriesViewState extends State { ], ), floatingActionButton: FloatingActionButton( + shape: const CircleBorder(), child: const Icon(Icons.add), onPressed: () { Navigator.of(context).push( @@ -225,11 +226,8 @@ class _RecurringEntriesViewState extends State { text: AppLocalizations.of(context).yes, onPressed: () { selectedWallet!.recurringEntries - .removeWhere( - (e) => - e.id == - selectedWallet! - .recurringEntries[i].id, + .remove( + selectedWallet!.recurringEntries[i], ); WalletManager.saveWallet( selectedWallet!, -- 2.45.2 From b621133c2405c753ec993a1a5b42fa37a755ca0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 21:15:24 +0100 Subject: [PATCH 4/4] fix: correctly sort --- CHANGELOG.md | 1 + lib/views/home.dart | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c7440..5d8977c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Categories now have changeable colors assigned to them - Added pie chart for expense/income data per category - Added recurring entries +- Fixed wrong default sorting # 1.0.0-alpha+2 - Fixed localization issues - Added graphs for expenses and income per month/year diff --git a/lib/views/home.dart b/lib/views/home.dart index 2ed0fac..950c459 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -302,8 +302,8 @@ class _HomeViewState extends State { if (yearA == null) return 0; final yearB = RegExp(r'\d+').firstMatch(b); if (yearB == null) return 0; - final compareYears = int.parse(yearA.group(0)!) - .compareTo(int.parse(yearB.group(0)!)); + final compareYears = int.parse(yearB.group(0)!) + .compareTo(int.parse(yearA.group(0)!)); if (compareYears != 0) return compareYears; final months = List.generate( 12, -- 2.45.2