From 77132edf01351b924a97720d8e8dbaf3d7588e44 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 15 Jul 2024 15:52:31 +0800 Subject: [PATCH 01/28] draft 1 --- assets/l10n/en_US.json | 2 + assets/l10n/it_IT.json | 3 + assets/l10n/mn_MN.json | 2 + lib/data/transactions_filter.dart | 87 +++++++++++ lib/objectbox/actions.dart | 33 +++-- lib/routes/home/accounts_tab.dart | 4 +- lib/routes/home/home_tab.dart | 2 + .../select_category_sheet.dart | 6 +- .../select_multi_account_sheet.dart | 107 ++++++++++++++ .../select_multi_category_sheet.dart | 94 ++++++++++++ lib/routes/setup/setup_categories_page.dart | 4 +- lib/routes/transaction_page.dart | 6 +- lib/utils/optional.dart | 5 + lib/utils/value_or.dart | 5 - lib/widgets/account_card.dart | 4 +- lib/widgets/category_card.dart | 4 +- lib/widgets/grouped_transaction_list.dart | 4 + .../categories/category_preset_card.dart | 4 +- lib/widgets/transaction_filter_head.dart | 137 ++++++++++++++++++ 19 files changed, 483 insertions(+), 30 deletions(-) create mode 100644 lib/data/transactions_filter.dart create mode 100644 lib/routes/new_transaction/select_multi_account_sheet.dart create mode 100644 lib/routes/new_transaction/select_multi_category_sheet.dart create mode 100644 lib/utils/optional.dart delete mode 100644 lib/utils/value_or.dart create mode 100644 lib/widgets/transaction_filter_head.dart diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 335bfc8..ae4d9a8 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -84,8 +84,10 @@ "transaction.new": "New transaction", "transaction.edit": "Edit transaction", "transaction.edit.selectAccount": "Select an account", + "transaction.edit.selectAccount.multiple": "Select accounts", "transaction.edit.selectAccount.noPossibleChoice": "No accounts to select", "transaction.edit.selectCategory": "Select a category", + "transaction.edit.selectCategory.multiple": "Select categories", "transaction.date": "Transaction date", "transaction.createdDate": "Created at", "transaction.fallbackTitle": "Untitled transaction", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 5905996..7d80ba1 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -84,8 +84,10 @@ "transaction.new": "Nuova transazione", "transaction.edit": "Modifica transazione", "transaction.edit.selectAccount": "Seleziona un conto", + "transaction.edit.selectAccount.multiple": "Seleziona conti", "transaction.edit.selectAccount.noPossibleChoice": "Nessun conto da selezionare", "transaction.edit.selectCategory": "Seleziona una categoria", + "transaction.edit.selectCategory.multiple": "Seleziona categorie", "transaction.date": "Data della transazione", "transaction.createdDate": "Creata il", "transaction.fallbackTitle": "Transazione senza titolo", @@ -229,6 +231,7 @@ "sync.export.onDeviceWarning": "Tutti i backup sono memorizzati sul dispositivo, il che significa che quando disinstalli Flow o reimposti il tuo dispositivo, tutti i backup saranno persi!", "sync.export.history": "Cronologia backup", "sync.export.history.empty": "Non ci sono backup", + "sync.export.history.empty.description": "Verranno elencati qui i backup effettuati manualmente e automaticamente", "sync.export.history.description": "Vedi i backup fatti da te e creati automaticamente", "sync.export.success": "Esportazione riuscita!", "sync.export.success.filePath[0]": "Salvato in ", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 628a213..aa3536d 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -84,8 +84,10 @@ "transaction.new": "Гүйлгээ нэмэх", "transaction.edit": "Гүйлгээ засварлах", "transaction.edit.selectAccount": "Данс сонгох", + "transaction.edit.selectAccount.multiple": "Дансууд сонгох", "transaction.edit.selectAccount.noPossibleChoice": "Сонгох боломжтой данс алга байна", "transaction.edit.selectCategory": "Ангилал сонгох", + "transaction.edit.selectCategory.multiple": "Ангилаллууд сонгох", "transaction.date": "Гүйлгээний огноо", "transaction.createdDate": "Үүсгэсэн огноо", "transaction.fallbackTitle": "Гарчиггүй гүйлгээ", diff --git a/lib/data/transactions_filter.dart b/lib/data/transactions_filter.dart new file mode 100644 index 0000000..e96e8e7 --- /dev/null +++ b/lib/data/transactions_filter.dart @@ -0,0 +1,87 @@ +import 'package:flow/entity/account.dart'; +import 'package:flow/entity/category.dart'; +import 'package:flow/entity/transaction.dart'; +import 'package:flow/objectbox/actions.dart'; +import 'package:flow/utils/optional.dart'; +import 'package:moment_dart/moment_dart.dart'; + +typedef TransactionPredicate = bool Function(Transaction); + +/// For all fields, disabled if it's null. +/// +/// All values must be wrapped by [Optional] +class TransactionFilter { + final TimeRange? range; + final String? keyword; + + /// Base score is 10.0 + final double keywordScoreThreshold; + final List? cateogries; + final List? accounts; + + const TransactionFilter({ + this.range, + this.keyword, + this.cateogries, + this.accounts, + this.keywordScoreThreshold = 80.0, + }); + + static const empty = TransactionFilter(); + + List get predicates { + final List predicates = []; + + if (range case TimeRange filterTimeRange) { + predicates + .add((Transaction t) => filterTimeRange.contains(t.transactionDate)); + } + + if (keyword case String filterKeyword) { + predicates.add( + (Transaction t) { + final double score = t.titleSuggestionScore( + query: filterKeyword, + fuzzyPartial: false, + ); + return score >= keywordScoreThreshold; + }, + ); + } + + if (cateogries?.isNotEmpty == true) { + predicates.add( + (Transaction t) => cateogries!.any( + (category) => t.categoryUuid == category.uuid, + ), + ); + } + + if (accounts?.isNotEmpty == true) { + predicates.add( + (Transaction t) => accounts!.any( + (account) => t.accountUuid == account.uuid, + ), + ); + } + + return predicates; + } + + TransactionFilter copyWithOptional({ + Optional? range, + Optional? keyword, + Optional>? cateogries, + Optional>? accounts, + double? keywordScoreThreshold, + }) { + return TransactionFilter( + range: range == null ? this.range : range.value, + keyword: keyword == null ? this.keyword : keyword.value, + cateogries: cateogries == null ? this.cateogries : cateogries.value, + accounts: accounts == null ? this.accounts : accounts.value, + keywordScoreThreshold: + keywordScoreThreshold ?? this.keywordScoreThreshold, + ); + } +} diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 0f49bc9..cf20509 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -6,6 +6,7 @@ import 'package:flow/data/flow_analytics.dart'; import 'package:flow/data/memo.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/data/prefs/frecency_group.dart'; +import 'package:flow/data/transactions_filter.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/backup_entry.dart'; import 'package:flow/entity/category.dart'; @@ -256,21 +257,30 @@ extension MainActions on ObjectBox { } extension TransactionActions on Transaction { + /// Base score is 10.0 + /// + /// * If [query] is exactly same as [title], score is base + 100.0 (110.0) + /// * If [accountId] matches, score is increased by 25% + /// * If [transactionType] matches, score is increased by 75% + /// * If [categoryId] matches, score is increased by 275% + /// + /// **Max score**: 412.5 + /// **Query only max score**: 110.0 + /// + /// Recommended to set [fuzzyPartial] to false when using for filtering purposes double titleSuggestionScore({ String? query, int? accountId, int? categoryId, TransactionType? transactionType, + bool fuzzyPartial = true, }) { - late double score; - - if (query == null || - query.trim().isEmpty || - title == null || - title!.trim().isEmpty) { - score = 10.0; // Full match score is 100 - } else { - score = partialRatio(query, title!).toDouble() + 10.0; + double score = 10.0; + + if (query?.trim().isNotEmpty == true && title?.trim().isNotEmpty == true) { + score += fuzzyPartial + ? partialRatio(query!, title!).toDouble() + : ratio(query!, title!).toDouble(); } double multipler = 1.0; @@ -413,6 +423,11 @@ extension TransactionListActions on Iterable { return value; } + + List filter(TransactionFilter filter) => + where((Transaction t) => filter.predicates + .map((predicate) => predicate(t)) + .every((element) => element)).toList(); } extension AccountActions on Account { diff --git a/lib/routes/home/accounts_tab.dart b/lib/routes/home/accounts_tab.dart index 475ff83..7993ea4 100644 --- a/lib/routes/home/accounts_tab.dart +++ b/lib/routes/home/accounts_tab.dart @@ -8,7 +8,7 @@ import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/prefs.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/utils/value_or.dart'; +import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/account_card.dart'; import 'package:flow/widgets/account_card_skeleton.dart'; import 'package:flow/widgets/general/spinner.dart'; @@ -101,7 +101,7 @@ class _AccountsTabState extends State excludeTransfersInTotal == true, onTapOverride: - ValueOr(() async { + Optional(() async { await context.push( "/account/${account.id}"); setState(() {}); diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 13b7446..b6dd7df 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -7,6 +7,7 @@ import 'package:flow/widgets/home/home/no_transactions.dart'; import 'package:flow/widgets/home/greetings_bar.dart'; import 'package:flow/widgets/grouped_transaction_list.dart'; import 'package:flow/widgets/home/transactions_date_header.dart'; +import 'package:flow/widgets/transaction_filter_head.dart'; import 'package:flutter/material.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -92,6 +93,7 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { top: 0, bottom: 80.0, ), + header: TransactionFilterHead(onChanged: (_) => {}), headerBuilder: ( TimeRange range, List transactions, diff --git a/lib/routes/new_transaction/select_category_sheet.dart b/lib/routes/new_transaction/select_category_sheet.dart index ae9f726..2d7cf61 100644 --- a/lib/routes/new_transaction/select_category_sheet.dart +++ b/lib/routes/new_transaction/select_category_sheet.dart @@ -1,6 +1,6 @@ import 'package:flow/entity/category.dart'; import 'package:flow/l10n/extensions.dart'; -import 'package:flow/utils/value_or.dart'; +import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flow/widgets/general/modal_sheet.dart'; import 'package:flutter/material.dart'; @@ -25,7 +25,7 @@ class SelectCategorySheet extends StatelessWidget { trailing: ButtonBar( children: [ TextButton.icon( - onPressed: () => context.pop(const ValueOr(null)), + onPressed: () => context.pop(const Optional(null)), icon: const Icon(Symbols.block_rounded), label: Text("category.skip".t(context)), ), @@ -41,7 +41,7 @@ class SelectCategorySheet extends StatelessWidget { title: Text(category.name), leading: FlowIcon(category.icon), trailing: const Icon(Symbols.chevron_right_rounded), - onTap: () => context.pop(ValueOr(category)), + onTap: () => context.pop(Optional(category)), selected: currentlySelectedCategoryId == category.id, ), ), diff --git a/lib/routes/new_transaction/select_multi_account_sheet.dart b/lib/routes/new_transaction/select_multi_account_sheet.dart new file mode 100644 index 0000000..9e6f28f --- /dev/null +++ b/lib/routes/new_transaction/select_multi_account_sheet.dart @@ -0,0 +1,107 @@ +import 'package:flow/entity/account.dart'; +import 'package:flow/l10n/extensions.dart'; +import 'package:flow/widgets/general/modal_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +/// Pops with [List] of selected [Account]s +class SelectMultiAccountSheet extends StatefulWidget { + final List accounts; + final List? selectedUuids; + + final String? titleOverride; + + const SelectMultiAccountSheet({ + super.key, + required this.accounts, + this.titleOverride, + this.selectedUuids, + }); + + @override + State createState() => + _SelectMultiAccountSheetState(); +} + +class _SelectMultiAccountSheetState extends State { + late Set selectedUuids; + + @override + void initState() { + super.initState(); + selectedUuids = Set.from(widget.selectedUuids ?? (const [])); + } + + @override + void didUpdateWidget(SelectMultiAccountSheet oldWidget) { + if (widget.selectedUuids != oldWidget.selectedUuids) { + selectedUuids = Set.from(widget.selectedUuids ?? (const [])); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return ModalSheet.scrollable( + title: Text( + widget.titleOverride ?? "transaction.edit.selectAccount".t(context)), + scrollableContentMaxHeight: MediaQuery.of(context).size.height * .5, + trailing: ButtonBar( + children: [ + TextButton.icon( + onPressed: pop, + icon: const Icon(Symbols.check), + label: Text("general.done".t(context)), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.accounts.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(24.0), + child: Text( + "transaction.edit.selectAccount.noPossibleChoice".t(context), + textAlign: TextAlign.center, + ), + ), + ...widget.accounts.map( + (account) => CheckboxListTile.adaptive( + title: Text(account.name), + // leading: FlowIcon(account.icon), + // trailing: const Icon(Symbols.chevron_right_rounded), + // onTap: () => context.pop(account), + value: selectedUuids.contains(account.uuid), + onChanged: (value) => select(account.uuid, value), + ), + ), + ], + ), + ), + ); + } + + void select(String uuid, bool? selected) { + if (selected == null) return; + + if (selectedUuids.contains(uuid)) { + selectedUuids.remove(uuid); + } else { + selectedUuids.add(uuid); + } + + setState(() {}); + } + + void pop() { + final List selectedAccounts = widget.accounts + .where((account) => selectedUuids.contains(account.uuid)) + .toList(); + + context.pop(selectedAccounts); + } +} diff --git a/lib/routes/new_transaction/select_multi_category_sheet.dart b/lib/routes/new_transaction/select_multi_category_sheet.dart new file mode 100644 index 0000000..efd66d8 --- /dev/null +++ b/lib/routes/new_transaction/select_multi_category_sheet.dart @@ -0,0 +1,94 @@ +import 'package:flow/entity/category.dart'; +import 'package:flow/l10n/extensions.dart'; +import 'package:flow/widgets/general/modal_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +/// Pops with [List] of selected [Category]s +class SelectMultiCategorySheet extends StatefulWidget { + final List categories; + final List? selectedUuids; + + const SelectMultiCategorySheet({ + super.key, + required this.categories, + this.selectedUuids, + }); + + @override + State createState() => + _SelectMultiCategorySheetState(); +} + +class _SelectMultiCategorySheetState extends State { + late Set selectedUuids; + + @override + void initState() { + super.initState(); + selectedUuids = Set.from(widget.selectedUuids ?? (const [])); + } + + @override + void didUpdateWidget(covariant SelectMultiCategorySheet oldWidget) { + if (widget.selectedUuids != oldWidget.selectedUuids) { + selectedUuids = Set.from(widget.selectedUuids ?? (const [])); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return ModalSheet.scrollable( + title: Text("transaction.edit.selectCategory.multiple".t(context)), + trailing: ButtonBar( + children: [ + TextButton.icon( + onPressed: () => context.pop(), + icon: const Icon(Symbols.check), + label: Text("general.done".t(context)), + ), + ], + ), + scrollableContentMaxHeight: MediaQuery.of(context).size.height * 0.5, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...widget.categories.map( + (category) => CheckboxListTile.adaptive( + title: Text(category.name), + value: selectedUuids.contains(category.uuid), + onChanged: (value) => select(category.uuid, value), + // leading: FlowIcon(category.icon), + // trailing: const Icon(Symbols.chevron_right_rounded), + // onTap: () => context.pop(Optional(category)), + ), + ), + ], + ), + ), + ); + } + + void select(String uuid, bool? selected) { + if (selected == null) return; + + if (selectedUuids.contains(uuid)) { + selectedUuids.remove(uuid); + } else { + selectedUuids.add(uuid); + } + + setState(() {}); + } + + void pop() { + final List selectedAccounts = widget.categories + .where((category) => selectedUuids.contains(category.uuid)) + .toList(); + + context.pop(selectedAccounts); + } +} diff --git a/lib/routes/setup/setup_categories_page.dart b/lib/routes/setup/setup_categories_page.dart index 7a81c7d..c08d979 100644 --- a/lib/routes/setup/setup_categories_page.dart +++ b/lib/routes/setup/setup_categories_page.dart @@ -4,7 +4,7 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/utils/value_or.dart'; +import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/add_category_card.dart'; import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/category_card.dart'; @@ -109,7 +109,7 @@ class _SetupCategoriesPageState extends State { padding: const EdgeInsets.only(bottom: 16.0), child: CategoryCard( category: e, - onTapOverride: const ValueOr(null), + onTapOverride: const Optional(null), showAmount: false, ), ), diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index 570d714..af28cb8 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -16,7 +16,7 @@ import 'package:flow/theme/theme.dart'; import 'package:flow/utils/shortcut.dart'; import 'package:flow/utils/toast.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/utils/value_or.dart'; +import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/delete_button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flow/widgets/general/form_close_button.dart'; @@ -468,8 +468,8 @@ class _TransactionPageState extends State { return; } - final ValueOr? result = - await showModalBottomSheet>( + final Optional? result = + await showModalBottomSheet>( context: context, builder: (context) => SelectCategorySheet( categories: categories, diff --git a/lib/utils/optional.dart b/lib/utils/optional.dart new file mode 100644 index 0000000..57b82b4 --- /dev/null +++ b/lib/utils/optional.dart @@ -0,0 +1,5 @@ +class Optional { + final T? value; + + const Optional(this.value); +} diff --git a/lib/utils/value_or.dart b/lib/utils/value_or.dart deleted file mode 100644 index db851ae..0000000 --- a/lib/utils/value_or.dart +++ /dev/null @@ -1,5 +0,0 @@ -class ValueOr { - final T? value; - - const ValueOr(this.value); -} diff --git a/lib/widgets/account_card.dart b/lib/widgets/account_card.dart index 682112d..9adecc1 100644 --- a/lib/widgets/account_card.dart +++ b/lib/widgets/account_card.dart @@ -2,7 +2,7 @@ import 'package:flow/entity/account.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/utils/value_or.dart'; +import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flow/widgets/general/surface.dart'; import 'package:flutter/cupertino.dart'; @@ -12,7 +12,7 @@ import 'package:go_router/go_router.dart'; class AccountCard extends StatelessWidget { final Account account; - final ValueOr? onTapOverride; + final Optional? onTapOverride; final bool useCupertinoContextMenu; diff --git a/lib/widgets/category_card.dart b/lib/widgets/category_card.dart index ff30ff9..1cfe010 100644 --- a/lib/widgets/category_card.dart +++ b/lib/widgets/category_card.dart @@ -2,7 +2,7 @@ import 'package:flow/entity/category.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/utils/value_or.dart'; +import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flow/widgets/general/surface.dart'; import 'package:flutter/material.dart'; @@ -15,7 +15,7 @@ class CategoryCard extends StatelessWidget { final bool showAmount; - final ValueOr? onTapOverride; + final Optional? onTapOverride; final Widget? trailing; diff --git a/lib/widgets/grouped_transaction_list.dart b/lib/widgets/grouped_transaction_list.dart index 00647d8..ab70cbc 100644 --- a/lib/widgets/grouped_transaction_list.dart +++ b/lib/widgets/grouped_transaction_list.dart @@ -1,3 +1,4 @@ +import 'package:flow/data/transactions_filter.dart'; import 'package:flow/entity/transaction.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox/actions.dart'; @@ -43,6 +44,8 @@ class GroupedTransactionList extends StatelessWidget { final bool implyHeader; + final TransactionFilter? filter; + const GroupedTransactionList({ super.key, required this.transactions, @@ -60,6 +63,7 @@ class GroupedTransactionList extends StatelessWidget { ), this.firstHeaderTopPadding = 8.0, this.shouldCombineTransferIfNeeded = false, + this.filter, }); @override diff --git a/lib/widgets/setup/categories/category_preset_card.dart b/lib/widgets/setup/categories/category_preset_card.dart index e0bf81f..63cd6c7 100644 --- a/lib/widgets/setup/categories/category_preset_card.dart +++ b/lib/widgets/setup/categories/category_preset_card.dart @@ -1,5 +1,5 @@ import 'package:flow/entity/category.dart'; -import 'package:flow/utils/value_or.dart'; +import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/category_card.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -25,7 +25,7 @@ class CategoryPresetCard extends StatelessWidget { opacity: selected ? 1.0 : 0.46, child: CategoryCard( category: category, - onTapOverride: ValueOr(() => onSelect(!selected)), + onTapOverride: Optional(() => onSelect(!selected)), showAmount: false, trailing: preexisting ? null diff --git a/lib/widgets/transaction_filter_head.dart b/lib/widgets/transaction_filter_head.dart new file mode 100644 index 0000000..d066982 --- /dev/null +++ b/lib/widgets/transaction_filter_head.dart @@ -0,0 +1,137 @@ +import 'package:flow/data/transactions_filter.dart'; +import 'package:flow/entity/account.dart'; +import 'package:flow/entity/category.dart'; +import 'package:flow/objectbox.dart'; +import 'package:flow/objectbox/actions.dart'; +import 'package:flow/routes/new_transaction/select_multi_account_sheet.dart'; +import 'package:flow/routes/new_transaction/select_multi_category_sheet.dart'; +import 'package:flow/utils/optional.dart'; +import 'package:flow/widgets/utils/time_and_range.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:moment_dart/moment_dart.dart'; + +class TransactionFilterHead extends StatefulWidget { + final TransactionFilter current; + + final void Function(TransactionFilter) onChanged; + + const TransactionFilterHead({ + super.key, + required this.onChanged, + this.current = TransactionFilter.empty, + }); + + @override + State createState() => _TransactionFilterHeadState(); +} + +class _TransactionFilterHeadState extends State { + late TransactionFilter _filter; + + TransactionFilter get filter => _filter; + set filter(TransactionFilter value) { + _filter = value; + widget.onChanged(value); + } + + @override + void initState() { + super.initState(); + _filter = widget.current; + } + + @override + void didUpdateWidget(TransactionFilterHead oldWidget) { + if (oldWidget.current != widget.current) { + _filter = widget.current; + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48.0, + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip( + avatar: const Icon(Symbols.search_rounded), + label: const Text("Search"), + onSelected: onSelectRange, + selected: _filter.keyword?.isNotEmpty == true, + ), + const SizedBox(width: 8.0), + FilterChip( + label: const Text("Time Range"), + onSelected: onSelectRange, + selected: _filter.range != null, + ), + const SizedBox(width: 8.0), + FilterChip( + label: const Text("Accounts"), + onSelected: onSelectAccounts, + selected: _filter.accounts?.isNotEmpty == true, + ), + const SizedBox(width: 8.0), + FilterChip( + label: const Text("Categories"), + onSelected: onSelectCategories, + selected: _filter.accounts?.isNotEmpty == true, + ), + ], + ), + ), + ); + } + + void onSelectAccounts(bool _) async { + final List? accounts = await showModalBottomSheet>( + context: context, + builder: (context) => SelectMultiAccountSheet( + accounts: ObjectBox().getAccounts(), + selectedUuids: filter.accounts?.map((account) => account.uuid).toList(), + ), + isScrollControlled: true, + ); + + if (accounts != null) { + setState(() { + _filter = _filter.copyWithOptional(accounts: Optional(accounts)); + }); + } + } + + void onSelectCategories(bool _) async { + final List? categories = + await showModalBottomSheet>( + context: context, + builder: (context) => SelectMultiCategorySheet( + categories: ObjectBox().getCategories(), + selectedUuids: + filter.cateogries?.map((category) => category.uuid).toList(), + ), + isScrollControlled: true, + ); + + if (categories != null) { + setState(() { + _filter = _filter.copyWithOptional(cateogries: Optional(categories)); + }); + } + } + + void onSelectRange(bool _) async { + final TimeRange? newRange = + await showTimeRangePickerSheet(context, initialValue: _filter.range); + + if (!mounted || newRange == null) return; + + setState(() { + _filter = _filter.copyWithOptional(range: Optional(newRange)); + }); + } +} From 38b5779950999419269f034267bca68df976053d Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 21 Jul 2024 17:33:16 +0800 Subject: [PATCH 02/28] home page preference localization, filters draft 2 --- assets/l10n/en_US.json | 18 +++ assets/l10n/it_IT.json | 18 +++ assets/l10n/mn_MN.json | 18 +++ lib/data/transactions_filter.dart | 75 +++++++++- lib/routes/home/home_tab.dart | 46 ++++-- .../default_transaction_filter_head.dart | 139 ++++++++++++++++++ lib/widgets/transaction_filter_head.dart | 128 ++-------------- .../transaction_filter_chip.dart | 107 ++++++++++++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- 10 files changed, 420 insertions(+), 135 deletions(-) create mode 100644 lib/widgets/default_transaction_filter_head.dart create mode 100644 lib/widgets/transaction_filter_head/transaction_filter_chip.dart diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index ae4d9a8..9455010 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -104,6 +104,14 @@ "transactions.upcoming": "Upcoming transactions", "transactions.query.noResult": "No transactions to show", "transactions.query.noResult.description": "Try updating the filters", + "transactions.query.filter.keyword": "Search", + "transactions.query.filter.keyword.all": "Search", + "transactions.query.filter.timeRange": "Time Range", + "transactions.query.filter.timeRange.all": "All Time", + "transactions.query.filter.accounts": "Accounts", + "transactions.query.filter.accounts.all": "All Accounts", + "transactions.query.filter.categories": "Categories", + "transactions.query.filter.categories.all": "All Categories", "transactions.count": "{} transactions", "category": "Category", @@ -143,6 +151,16 @@ "preferences.transfer.combineTransferTransaction.separate": "Separate", "preferences.transfer.excludeTransferFromFlow": "Exclude from totals", "preferences.transfer.excludeTransferFromFlow.description": "Don't count towards total expense/income", + "preferences.home": "Home page", + "preferences.home.transactions": "Transactions", + "preferences.home.transactions.description": "Time range of transactions to show initially", + "preferences.home.transactions.last30d": "Last 30 days", + "preferences.home.upcoming": "Upcoming transactions", + "preferences.home.upcoming.description": "Range of upcoming transactions to show", + "preferences.home.upcoming.alwaysVisible": "Always show", + "preferences.home.upcoming.alwaysVisible.description": "Stay visible when the filters are active", + "preferences.home.upcoming.next7d": "Next 7 days", + "preferences.home.upcoming.next30d": "Next 30 days", "tabs.home": "Home", "tabs.home.greetings": "Hi, {name}!", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 7d80ba1..e6480cf 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -104,6 +104,14 @@ "transactions.upcoming": "Prossime transazioni", "transactions.query.noResult": "Nessuna transazione da mostrare", "transactions.query.noResult.description": "Prova ad aggiornare i filtri", + "transactions.query.filter.keyword": "Cerca", + "transactions.query.filter.keyword.all": "Cerca", + "transactions.query.filter.timeRange": "Periodo", + "transactions.query.filter.timeRange.all": "Tutto il tempo", + "transactions.query.filter.accounts": "Conti", + "transactions.query.filter.accounts.all": "Tutti i conti", + "transactions.query.filter.categories": "Categorie", + "transactions.query.filter.categories.all": "Tutte le categorie", "transactions.count": "{count} transazioni", "category": "Categoria", @@ -143,6 +151,16 @@ "preferences.transfer.combineTransferTransaction.separate": "Separa", "preferences.transfer.excludeTransferFromFlow": "Escludi dai totali", "preferences.transfer.excludeTransferFromFlow.description": "Non conteggiare verso la spesa/entrata totale", + "preferences.home": "Home page", + "preferences.home.transactions": "Transazioni", + "preferences.home.transactions.description": "Intervallo iniziale di visualizzazione delle transazioni", + "preferences.home.transactions.last30d": "Ultimi 30 giorni", + "preferences.home.upcoming": "Prossime transazioni", + "preferences.home.upcoming.description": "Intervallo di visualizzazione delle prossime transazioni", + "preferences.home.upcoming.alwaysVisible": "Mostra sempre", + "preferences.home.upcoming.alwaysVisible.description": "Rimane visibile quando i filtri sono attivi", + "preferences.home.upcoming.next7d": "Prossimi 7 giorni", + "preferences.home.upcoming.next30d": "Prossimi 30 giorni", "tabs.home": "Home", "tabs.home.greetings": "Ciao, {name}!", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index aa3536d..0301de1 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -104,6 +104,14 @@ "transactions.upcoming": "Төлөвлөсөн гүйлгээнүүд", "transactions.query.noResult": "Тохирох гүйлгээнүүд олдсонгүй", "transactions.query.noResult.description": "Шүүлтүүрээ өөрчлөөд дахин оролдоно уу", + "transactions.query.filter.keyword": "Хайх", + "transactions.query.filter.keyword.all": "Хайх", + "transactions.query.filter.timeRange": "Хугацаа", + "transactions.query.filter.timeRange.all": "Бүх цаг үе", + "transactions.query.filter.accounts": "Данс", + "transactions.query.filter.accounts.all": "Бүх данс", + "transactions.query.filter.categories": "Ангилал", + "transactions.query.filter.categories.all": "Бүх ангилал", "transactions.count": "{} гүйлгээ", "category": "Ангилал", @@ -143,6 +151,16 @@ "preferences.transfer.combineTransferTransaction.separate": "Салгах", "preferences.transfer.excludeTransferFromFlow": "Нийт дүнд оруулахгүй", "preferences.transfer.excludeTransferFromFlow.description": "Идэвхтэй үед орлого/зарлага-д тоолохгүй", + "preferences.home": "Нүүр хуудас", + "preferences.home.transactions": "Гүйлгээнүүд", + "preferences.home.transactions.description": "Анх харуулах гүйлгээнүүдийн хугацаа", + "preferences.home.transactions.last30d": "Сүүлийн 30 хоног", + "preferences.home.upcoming": "Төлөвлөсөн гүйлгээнүүд", + "preferences.home.upcoming.description": "Харуулах төлөвлөгөөт гүйлгээнүүдийн хугацаа", + "preferences.home.upcoming.alwaysVisible": "Үргэлж харуулах", + "preferences.home.upcoming.alwaysVisible.description": "Шүүлтүүр өөрчлөгдсөн ч харуулах", + "preferences.home.upcoming.next7d": "Ирэх 7 хоног", + "preferences.home.upcoming.next30d": "Ирэх 30 хоног", "tabs.home": "Нүүр", "tabs.home.greetings": "Сайн уу, {name}?", diff --git a/lib/data/transactions_filter.dart b/lib/data/transactions_filter.dart index e96e8e7..b7f1761 100644 --- a/lib/data/transactions_filter.dart +++ b/lib/data/transactions_filter.dart @@ -1,30 +1,47 @@ import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; import 'package:flow/entity/transaction.dart'; +import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; +import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/utils/optional.dart'; import 'package:moment_dart/moment_dart.dart'; typedef TransactionPredicate = bool Function(Transaction); +enum TransactionSortField { + /// Default + transactionDate, + amount, + createdDate; +} + /// For all fields, disabled if it's null. /// /// All values must be wrapped by [Optional] class TransactionFilter { + /// If null, all-time final TimeRange? range; + final String? keyword; /// Base score is 10.0 final double keywordScoreThreshold; - final List? cateogries; + + final List? categories; final List? accounts; + final bool sortDescending; + final TransactionSortField sortBy; + const TransactionFilter({ this.range, this.keyword, - this.cateogries, + this.categories, this.accounts, this.keywordScoreThreshold = 80.0, + this.sortDescending = true, + this.sortBy = TransactionSortField.transactionDate, }); static const empty = TransactionFilter(); @@ -49,9 +66,9 @@ class TransactionFilter { ); } - if (cateogries?.isNotEmpty == true) { + if (categories?.isNotEmpty == true) { predicates.add( - (Transaction t) => cateogries!.any( + (Transaction t) => categories!.any( (category) => t.categoryUuid == category.uuid, ), ); @@ -68,6 +85,54 @@ class TransactionFilter { return predicates; } + /// Here, we don't have any fancy fuzzy finding, so + /// [ignoreKeywordFilter] is enabled by default. + /// + /// For now, let's do fuzzywuzzy after we fetch the objects + /// into memory + QueryBuilder queryBuilder({bool ignoreKeywordFilter = true}) { + final List> conditions = []; + + if (range case TimeRange filterTimeRange) { + conditions.add(Transaction_.transactionDate + .betweenDate(filterTimeRange.from, filterTimeRange.to)); + } + + if (keyword case String filterKeyword) { + conditions.add( + Transaction_.title.contains(filterKeyword, caseSensitive: false)); + } + + if (categories?.isNotEmpty == true) { + conditions.add(Transaction_.categoryUuid + .oneOf(categories!.map((category) => category.uuid).toList())); + } + + if (accounts?.isNotEmpty == true) { + conditions.add(Transaction_.accountUuid + .oneOf(accounts!.map((account) => account.uuid).toList())); + } + + final filtered = ObjectBox() + .box() + .query(conditions.reduce((a, b) => a & b)); + + return switch (sortBy) { + TransactionSortField.amount => filtered.order( + Transaction_.amount, + flags: sortDescending ? Order.descending : 0, + ), + TransactionSortField.createdDate => filtered.order( + Transaction_.createdDate, + flags: sortDescending ? Order.descending : 0, + ), + TransactionSortField.transactionDate => filtered.order( + Transaction_.transactionDate, + flags: sortDescending ? Order.descending : 0, + ), + }; + } + TransactionFilter copyWithOptional({ Optional? range, Optional? keyword, @@ -78,7 +143,7 @@ class TransactionFilter { return TransactionFilter( range: range == null ? this.range : range.value, keyword: keyword == null ? this.keyword : keyword.value, - cateogries: cateogries == null ? this.cateogries : cateogries.value, + categories: cateogries == null ? categories : cateogries.value, accounts: accounts == null ? this.accounts : accounts.value, keywordScoreThreshold: keywordScoreThreshold ?? this.keywordScoreThreshold, diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index b6dd7df..7fe8c5d 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -1,13 +1,13 @@ +import 'package:flow/data/transactions_filter.dart'; import 'package:flow/entity/transaction.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; -import 'package:flow/objectbox/objectbox.g.dart'; +import 'package:flow/widgets/default_transaction_filter_head.dart'; import 'package:flow/widgets/general/wavy_divider.dart'; -import 'package:flow/widgets/home/home/no_transactions.dart'; -import 'package:flow/widgets/home/greetings_bar.dart'; import 'package:flow/widgets/grouped_transaction_list.dart'; +import 'package:flow/widgets/home/greetings_bar.dart'; +import 'package:flow/widgets/home/home/no_transactions.dart'; import 'package:flow/widgets/home/transactions_date_header.dart'; -import 'package:flow/widgets/transaction_filter_head.dart'; import 'package:flutter/material.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -21,18 +21,18 @@ class HomeTab extends StatefulWidget { } class _HomeTabState extends State with AutomaticKeepAliveClientMixin { + final TransactionFilter defaultFilter = TransactionFilter( + range: Moment.now() + .subtract(const Duration(days: 29)) + .startOfDay() + .rangeTo(Moment.maxValue), + ); + + late TransactionFilter currentFilter = defaultFilter.copyWithOptional(); + final DateTime startDate = Moment.now().subtract(const Duration(days: 29)).startOfDay(); - QueryBuilder qb() => ObjectBox() - .box() - .query( - Transaction_.transactionDate.greaterOrEqual( - startDate.millisecondsSinceEpoch, - ), - ) - .order(Transaction_.transactionDate, flags: Order.descending); - late final bool noTransactionsAtAll; @override @@ -46,16 +46,33 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { super.build(context); return StreamBuilder>( - stream: qb().watch(triggerImmediately: true).map((event) => event.find()), + stream: currentFilter + .queryBuilder() + .watch(triggerImmediately: true) + .map((event) => event.find()), builder: (context, snapshot) { final List? transactions = snapshot.data; + final Widget header = DefaultTransactionsFilterHead( + defaultFilter: defaultFilter, + current: currentFilter, + onChanged: (value) { + setState(() { + currentFilter = value; + }); + }, + ); + return Column( children: [ const Padding( padding: EdgeInsets.all(16.0), child: GreetingsBar(), ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: header, + ), switch ((transactions?.length ?? 0, snapshot.hasData)) { (0, true) => Expanded( child: NoTransactions( @@ -93,7 +110,6 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { top: 0, bottom: 80.0, ), - header: TransactionFilterHead(onChanged: (_) => {}), headerBuilder: ( TimeRange range, List transactions, diff --git a/lib/widgets/default_transaction_filter_head.dart b/lib/widgets/default_transaction_filter_head.dart new file mode 100644 index 0000000..5f5664d --- /dev/null +++ b/lib/widgets/default_transaction_filter_head.dart @@ -0,0 +1,139 @@ +import 'package:flow/data/transactions_filter.dart'; +import 'package:flow/entity/account.dart'; +import 'package:flow/entity/category.dart'; +import 'package:flow/objectbox.dart'; +import 'package:flow/objectbox/actions.dart'; +import 'package:flow/routes/new_transaction/select_multi_account_sheet.dart'; +import 'package:flow/routes/new_transaction/select_multi_category_sheet.dart'; +import 'package:flow/utils/optional.dart'; +import 'package:flow/widgets/transaction_filter_head.dart'; +import 'package:flow/widgets/transaction_filter_head/transaction_filter_chip.dart'; +import 'package:flow/widgets/utils/time_and_range.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:moment_dart/moment_dart.dart'; + +class DefaultTransactionsFilterHead extends StatefulWidget { + final TransactionFilter current; + final TransactionFilter defaultFilter; + + final void Function(TransactionFilter) onChanged; + + const DefaultTransactionsFilterHead({ + super.key, + required this.current, + required this.onChanged, + this.defaultFilter = TransactionFilter.empty, + }); + + @override + State createState() => + _DefaultTransactionsFilterHeadState(); +} + +class _DefaultTransactionsFilterHeadState + extends State { + late TransactionFilter _filter; + + TransactionFilter get filter => _filter; + set filter(TransactionFilter value) { + _filter = value; + widget.onChanged(value); + } + + @override + void initState() { + super.initState(); + _filter = widget.current; + } + + @override + void didUpdateWidget(DefaultTransactionsFilterHead oldWidget) { + if (oldWidget.current != widget.current) { + _filter = widget.current; + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return TransactionFilterHead(filterChips: [ + TransactionFilterChip( + translationKey: "transactions.query.filter.keyword", + avatar: const Icon(Symbols.search_rounded), + onSelect: () {}, + defaultValue: widget.defaultFilter.keyword, + value: _filter.keyword?.isNotEmpty == true ? _filter.keyword : null, + ), + TransactionFilterChip( + translationKey: "transactions.query.filter.timeRange", + avatar: const Icon(Symbols.history_rounded), + onSelect: onSelectRange, + defaultValue: widget.defaultFilter.range, + value: _filter.range, + ), + TransactionFilterChip>( + translationKey: "transactions.query.filter.accounts", + avatar: const Icon(Symbols.wallet_rounded), + onSelect: onSelectAccounts, + defaultValue: widget.defaultFilter.accounts, + value: _filter.accounts?.isNotEmpty == true ? _filter.accounts : null, + ), + TransactionFilterChip>( + translationKey: "transactions.query.filter.categories", + avatar: const Icon(Symbols.category_rounded), + onSelect: onSelectCategories, + defaultValue: widget.defaultFilter.categories, + value: + _filter.categories?.isNotEmpty == true ? _filter.categories : null, + ), + ]); + } + + void onSelectAccounts() async { + final List? accounts = await showModalBottomSheet>( + context: context, + builder: (context) => SelectMultiAccountSheet( + accounts: ObjectBox().getAccounts(), + selectedUuids: filter.accounts?.map((account) => account.uuid).toList(), + ), + isScrollControlled: true, + ); + + if (accounts != null) { + setState(() { + filter = filter.copyWithOptional(accounts: Optional(accounts)); + }); + } + } + + void onSelectCategories() async { + final List? categories = + await showModalBottomSheet>( + context: context, + builder: (context) => SelectMultiCategorySheet( + categories: ObjectBox().getCategories(), + selectedUuids: + filter.categories?.map((category) => category.uuid).toList(), + ), + isScrollControlled: true, + ); + + if (categories != null) { + setState(() { + filter = filter.copyWithOptional(cateogries: Optional(categories)); + }); + } + } + + void onSelectRange() async { + final TimeRange? newRange = + await showTimeRangePickerSheet(context, initialValue: _filter.range); + + if (!mounted || newRange == null) return; + + setState(() { + filter = filter.copyWithOptional(range: Optional(newRange)); + }); + } +} diff --git a/lib/widgets/transaction_filter_head.dart b/lib/widgets/transaction_filter_head.dart index d066982..3556e31 100644 --- a/lib/widgets/transaction_filter_head.dart +++ b/lib/widgets/transaction_filter_head.dart @@ -1,137 +1,41 @@ import 'package:flow/data/transactions_filter.dart'; -import 'package:flow/entity/account.dart'; -import 'package:flow/entity/category.dart'; -import 'package:flow/objectbox.dart'; -import 'package:flow/objectbox/actions.dart'; -import 'package:flow/routes/new_transaction/select_multi_account_sheet.dart'; -import 'package:flow/routes/new_transaction/select_multi_category_sheet.dart'; -import 'package:flow/utils/optional.dart'; -import 'package:flow/widgets/utils/time_and_range.dart'; import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:moment_dart/moment_dart.dart'; -class TransactionFilterHead extends StatefulWidget { - final TransactionFilter current; +/// Renders a row of [TransactionFilterChip]s. +class TransactionFilterHead extends StatelessWidget { + final TransactionFilter value; - final void Function(TransactionFilter) onChanged; + /// Usually List of [TransactionFilterChip]s + final List filterChips; const TransactionFilterHead({ super.key, - required this.onChanged, - this.current = TransactionFilter.empty, + required this.filterChips, + this.value = TransactionFilter.empty, }); @override - State createState() => _TransactionFilterHeadState(); -} - -class _TransactionFilterHeadState extends State { - late TransactionFilter _filter; - - TransactionFilter get filter => _filter; - set filter(TransactionFilter value) { - _filter = value; - widget.onChanged(value); - } + Widget build(BuildContext context) { + final List children = []; - @override - void initState() { - super.initState(); - _filter = widget.current; - } + for (final chip in filterChips) { + children.add(chip); + children.add(const SizedBox(width: 12.0)); + } - @override - void didUpdateWidget(TransactionFilterHead oldWidget) { - if (oldWidget.current != widget.current) { - _filter = widget.current; + if (children.isNotEmpty && children.last is SizedBox) { + children.removeLast(); } - super.didUpdateWidget(oldWidget); - } - @override - Widget build(BuildContext context) { return SizedBox( height: 48.0, width: double.infinity, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: [ - FilterChip( - avatar: const Icon(Symbols.search_rounded), - label: const Text("Search"), - onSelected: onSelectRange, - selected: _filter.keyword?.isNotEmpty == true, - ), - const SizedBox(width: 8.0), - FilterChip( - label: const Text("Time Range"), - onSelected: onSelectRange, - selected: _filter.range != null, - ), - const SizedBox(width: 8.0), - FilterChip( - label: const Text("Accounts"), - onSelected: onSelectAccounts, - selected: _filter.accounts?.isNotEmpty == true, - ), - const SizedBox(width: 8.0), - FilterChip( - label: const Text("Categories"), - onSelected: onSelectCategories, - selected: _filter.accounts?.isNotEmpty == true, - ), - ], + children: children, ), ), ); } - - void onSelectAccounts(bool _) async { - final List? accounts = await showModalBottomSheet>( - context: context, - builder: (context) => SelectMultiAccountSheet( - accounts: ObjectBox().getAccounts(), - selectedUuids: filter.accounts?.map((account) => account.uuid).toList(), - ), - isScrollControlled: true, - ); - - if (accounts != null) { - setState(() { - _filter = _filter.copyWithOptional(accounts: Optional(accounts)); - }); - } - } - - void onSelectCategories(bool _) async { - final List? categories = - await showModalBottomSheet>( - context: context, - builder: (context) => SelectMultiCategorySheet( - categories: ObjectBox().getCategories(), - selectedUuids: - filter.cateogries?.map((category) => category.uuid).toList(), - ), - isScrollControlled: true, - ); - - if (categories != null) { - setState(() { - _filter = _filter.copyWithOptional(cateogries: Optional(categories)); - }); - } - } - - void onSelectRange(bool _) async { - final TimeRange? newRange = - await showTimeRangePickerSheet(context, initialValue: _filter.range); - - if (!mounted || newRange == null) return; - - setState(() { - _filter = _filter.copyWithOptional(range: Optional(newRange)); - }); - } } diff --git a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart new file mode 100644 index 0000000..ca07d4d --- /dev/null +++ b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart @@ -0,0 +1,107 @@ +import 'package:flow/entity/account.dart'; +import 'package:flow/entity/category.dart'; +import 'package:flow/l10n/extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:moment_dart/moment_dart.dart'; + +class TransactionFilterChip extends StatelessWidget { + final Widget? avatar; + + /// Translation key for the label + /// + /// Requires following keys in the translation file: + /// * `${translationKey}` + /// * `${translationKey}.all` + final String translationKey; + final T? value; + final T? defaultValue; + + bool get highlight => value != defaultValue; + + /// * If [defaultValue] and [value] are null, displays translated [translationKey] + /// * If [defaultValue] isn't null, but [value] is null, displays translated `$translationKey.all` + /// * Otherwise, `valueLabelOverride(value)` if available, else `value.toString()`. + /// + /// First argument is the **current** value, second is the **default**. + final String Function(T? value, T? defaultValue)? displayLabelOverride; + + /// Override [getValueLabel]. If `null` was returned, continues with the default + /// implementation. For example, you can typecheck the value to override specific values + final String? Function(T?)? valueLabelOverride; + + final VoidCallback onSelect; + + const TransactionFilterChip({ + super.key, + this.avatar, + this.value, + this.defaultValue, + this.displayLabelOverride, + required this.translationKey, + required this.onSelect, + this.valueLabelOverride, + }); + + @override + Widget build(BuildContext context) { + return FilterChip( + showCheckmark: false, + avatar: avatar, + label: Text( + getLabel(context), + overflow: TextOverflow.ellipsis, + ), + onSelected: (_) => onSelect(), + selected: highlight, + ); + } + + String getValueLabel(BuildContext context, T? value) { + if (valueLabelOverride != null) { + final String? overriden = valueLabelOverride!(value); + if (overriden != null) { + return overriden; + } + } + + if (value == null) { + return "$translationKey.all".t(context); + } + + if (value case TimeRange timeRange) { + return timeRange.format(); + } + + if (value case Account account) { + return account.name; + } + if (value case Category category) { + return category.name; + } + + if (value case List list) { + final String items = + list.map((item) => getValueLabel(context, item)).join(", "); + + return "(${list.length}) $items"; + } + + return value.toString(); + } + + String getLabel(BuildContext context) { + if (displayLabelOverride != null) { + return displayLabelOverride!(value, defaultValue); + } + + if (value != null) { + return getValueLabel(context, value); + } + + if (defaultValue == null) { + return translationKey.t(context); + } else { + return "$translationKey.all".t(context); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 7b6d5c6..7632388 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -785,10 +785,10 @@ packages: dependency: "direct main" description: name: moment_dart - sha256: e8683f46ea80dbe47bb0f9f684253630f6a09faa5f626869e1f31bdcf14f6b91 + sha256: "721f008251341578a818140afe402be827bc29951846575598426f4039a0799b" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.1+beta.0" objectbox: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8d82222..18ea43e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: local_settings: ^0.3.1 mask_text_input_formatter: ^2.8.0 material_symbols_icons: ^4.2719.1 - moment_dart: ^2.1.0 + moment_dart: ^2.2.1+beta.0 objectbox: ^2.4.0 objectbox_flutter_libs: ^2.4.0 package_info_plus: ^7.0.0 From 8890329cea5ada02ff94d308d37a2f1eddc8cce0 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 28 Jul 2024 12:12:51 +0800 Subject: [PATCH 03/28] ux; n account/categories --- assets/l10n/en_US.json | 2 ++ assets/l10n/it_IT.json | 2 ++ assets/l10n/mn_MN.json | 2 ++ .../new_transaction/select_multi_category_sheet.dart | 4 ++-- .../transaction_filter_chip.dart | 12 ++++++++++-- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 9455010..99283a8 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -109,8 +109,10 @@ "transactions.query.filter.timeRange": "Time Range", "transactions.query.filter.timeRange.all": "All Time", "transactions.query.filter.accounts": "Accounts", + "transactions.query.filter.accounts.n": "{} accounts", "transactions.query.filter.accounts.all": "All Accounts", "transactions.query.filter.categories": "Categories", + "transactions.query.filter.categories.n": "{} categories", "transactions.query.filter.categories.all": "All Categories", "transactions.count": "{} transactions", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index e6480cf..065ffc7 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -109,8 +109,10 @@ "transactions.query.filter.timeRange": "Periodo", "transactions.query.filter.timeRange.all": "Tutto il tempo", "transactions.query.filter.accounts": "Conti", + "transactions.query.filter.accounts.n": "{} conti", "transactions.query.filter.accounts.all": "Tutti i conti", "transactions.query.filter.categories": "Categorie", + "transactions.query.filter.categories.n": "{} categorie", "transactions.query.filter.categories.all": "Tutte le categorie", "transactions.count": "{count} transazioni", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 0301de1..2fb702f 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -109,8 +109,10 @@ "transactions.query.filter.timeRange": "Хугацаа", "transactions.query.filter.timeRange.all": "Бүх цаг үе", "transactions.query.filter.accounts": "Данс", + "transactions.query.filter.accounts.n": "{} данс", "transactions.query.filter.accounts.all": "Бүх данс", "transactions.query.filter.categories": "Ангилал", + "transactions.query.filter.categories.n": "{} ангилал", "transactions.query.filter.categories.all": "Бүх ангилал", "transactions.count": "{} гүйлгээ", diff --git a/lib/routes/new_transaction/select_multi_category_sheet.dart b/lib/routes/new_transaction/select_multi_category_sheet.dart index efd66d8..8e4b75d 100644 --- a/lib/routes/new_transaction/select_multi_category_sheet.dart +++ b/lib/routes/new_transaction/select_multi_category_sheet.dart @@ -85,10 +85,10 @@ class _SelectMultiCategorySheetState extends State { } void pop() { - final List selectedAccounts = widget.categories + final List selectedCategories = widget.categories .where((category) => selectedUuids.contains(category.uuid)) .toList(); - context.pop(selectedAccounts); + context.pop(selectedCategories); } } diff --git a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart index ca07d4d..3dd4194 100644 --- a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart +++ b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart @@ -56,7 +56,7 @@ class TransactionFilterChip extends StatelessWidget { ); } - String getValueLabel(BuildContext context, T? value) { + String getValueLabel(BuildContext context, dynamic value) { if (valueLabelOverride != null) { final String? overriden = valueLabelOverride!(value); if (overriden != null) { @@ -80,8 +80,16 @@ class TransactionFilterChip extends StatelessWidget { } if (value case List list) { + if (list.length > 2) { + if (list.first is Account) { + return "transactions.query.filter.accounts.n".tr(list.length); + } else if (list.first is Category) { + return "transactions.query.filter.categories.n".tr(list.length); + } + } + final String items = - list.map((item) => getValueLabel(context, item)).join(", "); + list.map((item) => getValueLabel(context, item)).join(", "); return "(${list.length}) $items"; } From 37e996e05eba55f0d7c7ae48897e05e91f6e617e Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 28 Jul 2024 12:57:19 +0800 Subject: [PATCH 04/28] separate txns when there's account filter --- assets/l10n/en_US.json | 1 + assets/l10n/it_IT.json | 1 + assets/l10n/mn_MN.json | 3 ++- lib/routes/home/home_tab.dart | 2 +- .../new_transaction/select_multi_category_sheet.dart | 2 +- lib/routes/preferences/transfer_preferences_page.dart | 11 +++++++++++ .../transaction_filter_chip.dart | 4 ++++ 7 files changed, 21 insertions(+), 3 deletions(-) diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 99283a8..0ad6655 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -151,6 +151,7 @@ "preferences.transfer.combineTransferTransaction": "Layout", "preferences.transfer.combineTransferTransaction.combine": "Combine", "preferences.transfer.combineTransferTransaction.separate": "Separate", + "preferences.transfer.combineTransferTransaction.filterDescription": "When using filters, transfers will always display separately", "preferences.transfer.excludeTransferFromFlow": "Exclude from totals", "preferences.transfer.excludeTransferFromFlow.description": "Don't count towards total expense/income", "preferences.home": "Home page", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 065ffc7..2f83d34 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -151,6 +151,7 @@ "preferences.transfer.combineTransferTransaction": "Layout", "preferences.transfer.combineTransferTransaction.combine": "Combina", "preferences.transfer.combineTransferTransaction.separate": "Separa", + "preferences.transfer.combineTransferTransaction.filterDescription": "Utilizzando i filtri, i trasferimenti verranno sempre visualizzati separatamente", "preferences.transfer.excludeTransferFromFlow": "Escludi dai totali", "preferences.transfer.excludeTransferFromFlow.description": "Non conteggiare verso la spesa/entrata totale", "preferences.home": "Home page", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 2fb702f..a76cc86 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -146,11 +146,12 @@ "preferences.transactionButtonOrder": "Товчны байрлал", "preferences.transactionButtonOrder.description": "Шинэ гүйлгээ хийх товчны байрлал өөрчлөх", "preferences.transactionButtonOrder.guide": "Чирж байрлалыг өөрчлөөрэй", - "preferences.transfer": "Шижлүүлэг", + "preferences.transfer": "Шилжүүлэг", "preferences.transfer.description": "Нэгтгэж харах, орлого/зарлагаас хасах", "preferences.transfer.combineTransferTransaction": "Харагдах байдал", "preferences.transfer.combineTransferTransaction.combine": "Нэгтгэх", "preferences.transfer.combineTransferTransaction.separate": "Салгах", + "preferences.transfer.combineTransferTransaction.filterDescription": "Шүүлтүүр ашиглаж байх үед үргэлж салангид харагдах болно", "preferences.transfer.excludeTransferFromFlow": "Нийт дүнд оруулахгүй", "preferences.transfer.excludeTransferFromFlow.description": "Идэвхтэй үед орлого/зарлага-д тоолохгүй", "preferences.home": "Нүүр хуудас", diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 7fe8c5d..2a790ac 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -104,7 +104,7 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { return GroupedTransactionList( controller: widget.scrollController, transactions: grouped, - shouldCombineTransferIfNeeded: true, + shouldCombineTransferIfNeeded: currentFilter.accounts?.isNotEmpty != true, futureDivider: const WavyDivider(), listPadding: const EdgeInsets.only( top: 0, diff --git a/lib/routes/new_transaction/select_multi_category_sheet.dart b/lib/routes/new_transaction/select_multi_category_sheet.dart index 8e4b75d..77ea4ec 100644 --- a/lib/routes/new_transaction/select_multi_category_sheet.dart +++ b/lib/routes/new_transaction/select_multi_category_sheet.dart @@ -45,7 +45,7 @@ class _SelectMultiCategorySheetState extends State { trailing: ButtonBar( children: [ TextButton.icon( - onPressed: () => context.pop(), + onPressed: pop, icon: const Icon(Symbols.check), label: Text("general.done".t(context)), ), diff --git a/lib/routes/preferences/transfer_preferences_page.dart b/lib/routes/preferences/transfer_preferences_page.dart index e84e525..df5d1ba 100644 --- a/lib/routes/preferences/transfer_preferences_page.dart +++ b/lib/routes/preferences/transfer_preferences_page.dart @@ -2,6 +2,7 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/prefs.dart'; import 'package:flow/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart'; import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/general/info_text.dart'; import 'package:flow/widgets/general/list_header.dart'; import 'package:flutter/material.dart'; @@ -53,6 +54,16 @@ class _TransferPreferencesPageState extends State { ], ), ), + const SizedBox(height: 8.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: InfoText( + child: Text( + "preferences.transfer.combineTransferTransaction.filterDescription" + .t(context), + ), + ), + ), const SizedBox(height: 32.0), CheckboxListTile.adaptive( title: Text( diff --git a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart index 3dd4194..2c49bb5 100644 --- a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart +++ b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart @@ -91,6 +91,10 @@ class TransactionFilterChip extends StatelessWidget { final String items = list.map((item) => getValueLabel(context, item)).join(", "); + if (list.length == 1) { + return items; + } + return "(${list.length}) $items"; } From 52938e4a8ea1b1ad2d38bb8f733283c8d02e1f53 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 28 Jul 2024 18:15:31 +0800 Subject: [PATCH 05/28] draft 3 --- assets/l10n/en_US.json | 4 + assets/l10n/it_IT.json | 4 + assets/l10n/mn_MN.json | 4 + lib/data/transactions_filter.dart | 89 +++++++---- lib/data/transactions_filter/search_data.dart | 121 +++++++++++++++ lib/objectbox/actions.dart | 6 + lib/routes/account/account_edit_page.dart | 4 +- lib/routes/home/home_tab.dart | 7 +- .../default_transaction_filter_head.dart | 33 ++++- lib/widgets/select_currency_sheet.dart | 2 +- .../select_multi_account_sheet.dart | 5 + .../select_multi_category_sheet.dart | 5 + .../transaction_filter_chip.dart | 25 +++- .../transaction_search_sheet.dart | 95 ++++++++++++ pubspec.lock | 140 +++++++++--------- pubspec.yaml | 18 +-- 16 files changed, 445 insertions(+), 117 deletions(-) create mode 100644 lib/data/transactions_filter/search_data.dart rename lib/{routes/new_transaction => widgets/transaction_filter_head}/select_multi_account_sheet.dart (93%) rename lib/{routes/new_transaction => widgets/transaction_filter_head}/select_multi_category_sheet.dart (92%) create mode 100644 lib/widgets/transaction_filter_head/transaction_search_sheet.dart diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 0ad6655..c783af7 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -104,8 +104,12 @@ "transactions.upcoming": "Upcoming transactions", "transactions.query.noResult": "No transactions to show", "transactions.query.noResult.description": "Try updating the filters", + "transactions.query.clearAll": "Clear filters", + "transactions.query.clearSelection": "Clear selections", "transactions.query.filter.keyword": "Search", "transactions.query.filter.keyword.all": "Search", + "transactions.query.filter.keyword.hint": "Search by title...", + "transactions.query.filter.keyword.clear": "Clear", "transactions.query.filter.timeRange": "Time Range", "transactions.query.filter.timeRange.all": "All Time", "transactions.query.filter.accounts": "Accounts", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 2f83d34..6f734f2 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -104,8 +104,12 @@ "transactions.upcoming": "Prossime transazioni", "transactions.query.noResult": "Nessuna transazione da mostrare", "transactions.query.noResult.description": "Prova ad aggiornare i filtri", + "transactions.query.clearAll": "Cancella filtri", + "transactions.query.clearSelection": "Cancella selezioni", "transactions.query.filter.keyword": "Cerca", "transactions.query.filter.keyword.all": "Cerca", + "transactions.query.filter.keyword.hint": "Cerca per titolo...", + "transactions.query.filter.keyword.clear": "Cancella cerca", "transactions.query.filter.timeRange": "Periodo", "transactions.query.filter.timeRange.all": "Tutto il tempo", "transactions.query.filter.accounts": "Conti", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index a76cc86..4b6df32 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -104,8 +104,12 @@ "transactions.upcoming": "Төлөвлөсөн гүйлгээнүүд", "transactions.query.noResult": "Тохирох гүйлгээнүүд олдсонгүй", "transactions.query.noResult.description": "Шүүлтүүрээ өөрчлөөд дахин оролдоно уу", + "transactions.query.clearAll": "Шүүлтүүрийг цэвэрлэх", + "transactions.query.clearSelection": "Сонголтуудыг цэвэрлэх", "transactions.query.filter.keyword": "Хайх", "transactions.query.filter.keyword.all": "Хайх", + "transactions.query.filter.keyword.hint": "Гарчгаар хайх...", + "transactions.query.filter.keyword.clear": "Цэвэрлэх", "transactions.query.filter.timeRange": "Хугацаа", "transactions.query.filter.timeRange.all": "Бүх цаг үе", "transactions.query.filter.accounts": "Данс", diff --git a/lib/data/transactions_filter.dart b/lib/data/transactions_filter.dart index b7f1761..566aabb 100644 --- a/lib/data/transactions_filter.dart +++ b/lib/data/transactions_filter.dart @@ -1,12 +1,15 @@ +import 'package:flow/data/transactions_filter/search_data.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; import 'package:flow/entity/transaction.dart'; import 'package:flow/objectbox.dart'; -import 'package:flow/objectbox/actions.dart'; import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/utils/optional.dart'; +import 'package:flutter/foundation.dart' hide Category; import 'package:moment_dart/moment_dart.dart'; +export 'package:flow/data/transactions_filter/search_data.dart'; + typedef TransactionPredicate = bool Function(Transaction); enum TransactionSortField { @@ -23,10 +26,9 @@ class TransactionFilter { /// If null, all-time final TimeRange? range; - final String? keyword; + final TransactionSearchData searchData; - /// Base score is 10.0 - final double keywordScoreThreshold; + final List? types; final List? categories; final List? accounts; @@ -35,17 +37,31 @@ class TransactionFilter { final TransactionSortField sortBy; const TransactionFilter({ - this.range, - this.keyword, this.categories, this.accounts, - this.keywordScoreThreshold = 80.0, + this.range, + this.types, this.sortDescending = true, + this.searchData = const TransactionSearchData(), this.sortBy = TransactionSortField.transactionDate, }); static const empty = TransactionFilter(); + List get postPredicates { + final List predicates = []; + + if (types?.isNotEmpty == true) { + predicates.add( + (Transaction t) => types!.contains(t.type), + ); + } + + predicates.add(searchData.predicate); + + return predicates; + } + List get predicates { final List predicates = []; @@ -54,18 +70,14 @@ class TransactionFilter { .add((Transaction t) => filterTimeRange.contains(t.transactionDate)); } - if (keyword case String filterKeyword) { + if (types?.isNotEmpty == true) { predicates.add( - (Transaction t) { - final double score = t.titleSuggestionScore( - query: filterKeyword, - fuzzyPartial: false, - ); - return score >= keywordScoreThreshold; - }, + (Transaction t) => types!.contains(t.type), ); } + predicates.add(searchData.predicate); + if (categories?.isNotEmpty == true) { predicates.add( (Transaction t) => categories!.any( @@ -98,9 +110,9 @@ class TransactionFilter { .betweenDate(filterTimeRange.from, filterTimeRange.to)); } - if (keyword case String filterKeyword) { - conditions.add( - Transaction_.title.contains(filterKeyword, caseSensitive: false)); + final searchFilter = searchData.filter; + if (searchFilter != null) { + conditions.add(searchFilter); } if (categories?.isNotEmpty == true) { @@ -134,19 +146,46 @@ class TransactionFilter { } TransactionFilter copyWithOptional({ + Optional>? types, Optional? range, - Optional? keyword, - Optional>? cateogries, + TransactionSearchData? searchData, + Optional>? categories, Optional>? accounts, - double? keywordScoreThreshold, + bool? sortDescending, + TransactionSortField? sortBy, }) { return TransactionFilter( + types: types == null ? this.types : types.value, range: range == null ? this.range : range.value, - keyword: keyword == null ? this.keyword : keyword.value, - categories: cateogries == null ? categories : cateogries.value, + searchData: searchData ?? this.searchData, + categories: categories == null ? this.categories : categories.value, accounts: accounts == null ? this.accounts : accounts.value, - keywordScoreThreshold: - keywordScoreThreshold ?? this.keywordScoreThreshold, + sortBy: sortBy ?? this.sortBy, + sortDescending: sortDescending ?? this.sortDescending, ); } + + @override + int get hashCode => Object.hashAll([ + types, + range, + searchData, + categories, + accounts, + sortDescending, + sortBy, + ]); + + @override + bool operator ==(Object other) { + if (other is! TransactionFilter) return false; + + return other.range == range && + other.sortDescending == sortDescending && + other.sortBy == sortBy && + other.searchData == searchData && + setEquals(other.types?.toSet(), types?.toSet()) && + setEquals(other.categories?.toSet(), categories?.toSet()) && + setEquals(other.accounts?.toSet(), accounts?.toSet()); + } } diff --git a/lib/data/transactions_filter/search_data.dart b/lib/data/transactions_filter/search_data.dart new file mode 100644 index 0000000..4e932f7 --- /dev/null +++ b/lib/data/transactions_filter/search_data.dart @@ -0,0 +1,121 @@ +import 'package:flow/entity/transaction.dart'; +import 'package:flow/objectbox/actions.dart'; +import 'package:flow/objectbox/objectbox.g.dart'; +import 'package:flow/utils/optional.dart'; + +/// Fuzzy finding is case insensitive regardless of [caseInsensitive] +class TransactionSearchData { + /// Recomend using normalizedKeyword. + final String? keyword; + + /// [keyword] trimmed, and lowercased if [caseInsensitive] is [true] + /// + /// Returns null when [keyword] is null or empty + String? get normalizedKeyword { + final trimmed = keyword?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + if (caseInsensitive) { + return trimmed.toLowerCase(); + } + + return trimmed; + } + + /// When [true], uses fuzzy matching + /// else, exact matching + final bool smartMatch; + + /// Fuzzy finding is case insensitive regardless of this + final bool caseInsensitive; + + /// Base score is [10.0] + /// + /// Defaults to [80.0] + /// + /// Exact match is [110.0] + final double smartMatchThreshold; + + const TransactionSearchData({ + this.keyword, + this.smartMatch = true, + this.caseInsensitive = true, + this.smartMatchThreshold = 80.0, + }); + + bool predicate(Transaction t) { + if (!smartMatch) { + return _stupidMatching(t); + } + + if (normalizedKeyword == null) return true; + + final double score = t.titleSuggestionScore( + query: normalizedKeyword, + fuzzyPartial: false, + ); + + return score >= smartMatchThreshold; + } + + bool _stupidMatching(Transaction t) { + if (normalizedKeyword == null) return true; + + final String? normalizedTitle = + caseInsensitive ? t.title?.trim().toLowerCase() : t.title?.trim(); + + if (normalizedTitle == null) return false; + + return normalizedTitle.contains( + normalizedKeyword!, + ); + } + + /// Filter is not available when smart match isn't enabled + Condition? get filter { + if (smartMatch) { + return null; + } + + if (normalizedKeyword == null) { + return null; + } + + return Transaction_.title.contains( + normalizedKeyword!, + caseSensitive: !caseInsensitive, + ); + } + + TransactionSearchData copyWithOptional({ + Optional? keyword, + bool? smartMatch, + bool? caseInsensitive, + double? smartMatchThreshold, + }) { + return TransactionSearchData( + keyword: keyword == null ? this.keyword : keyword.value, + smartMatch: smartMatch ?? this.smartMatch, + caseInsensitive: caseInsensitive ?? this.caseInsensitive, + smartMatchThreshold: smartMatchThreshold ?? this.smartMatchThreshold, + ); + } + + @override + int get hashCode => Object.hashAll( + [keyword, smartMatch, caseInsensitive, smartMatchThreshold]); + + @override + operator ==(Object other) { + if (other is! TransactionSearchData) { + return false; + } + + return keyword == other.keyword && + smartMatch == other.smartMatch && + caseInsensitive == other.caseInsensitive && + smartMatchThreshold == other.smartMatchThreshold; + } +} diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index cf20509..578ed2c 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -428,6 +428,12 @@ extension TransactionListActions on Iterable { where((Transaction t) => filter.predicates .map((predicate) => predicate(t)) .every((element) => element)).toList(); + + List search(TransactionSearchData? data) { + if (data == null || data.normalizedKeyword == null) return toList(); + + return where(data.predicate).toList(); + } } extension AccountActions on Account { diff --git a/lib/routes/account/account_edit_page.dart b/lib/routes/account/account_edit_page.dart index 3431c79..f4041ff 100644 --- a/lib/routes/account/account_edit_page.dart +++ b/lib/routes/account/account_edit_page.dart @@ -304,7 +304,7 @@ class _AccountEditPageState extends State { if (_balance != _currentlyEditing.balance) { _currentlyEditing.updateBalanceAndSave( _balance, - title: "account.updateBalance.transactionTitle".tr(), + title: "account.updateBalance.transactionTitle".t(context), ); } @@ -347,7 +347,7 @@ class _AccountEditPageState extends State { .then((value) { value.updateBalanceAndSave( _balance, - title: "account.updateBalance.transactionTitle".tr(), + title: "account.updateBalance.transactionTitle".t(context), ); ObjectBox().box().putAsync(value); }); diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 2a790ac..c2e3a72 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -46,10 +46,9 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { super.build(context); return StreamBuilder>( - stream: currentFilter - .queryBuilder() - .watch(triggerImmediately: true) - .map((event) => event.find()), + stream: currentFilter.queryBuilder().watch(triggerImmediately: true).map( + (event) => event.find().search(currentFilter.searchData), + ), builder: (context, snapshot) { final List? transactions = snapshot.data; diff --git a/lib/widgets/default_transaction_filter_head.dart b/lib/widgets/default_transaction_filter_head.dart index 5f5664d..dd44a7f 100644 --- a/lib/widgets/default_transaction_filter_head.dart +++ b/lib/widgets/default_transaction_filter_head.dart @@ -3,11 +3,12 @@ import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; -import 'package:flow/routes/new_transaction/select_multi_account_sheet.dart'; -import 'package:flow/routes/new_transaction/select_multi_category_sheet.dart'; import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/transaction_filter_head.dart'; +import 'package:flow/widgets/transaction_filter_head/select_multi_account_sheet.dart'; +import 'package:flow/widgets/transaction_filter_head/select_multi_category_sheet.dart'; import 'package:flow/widgets/transaction_filter_head/transaction_filter_chip.dart'; +import 'package:flow/widgets/transaction_filter_head/transaction_search_sheet.dart'; import 'package:flow/widgets/utils/time_and_range.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -58,12 +59,13 @@ class _DefaultTransactionsFilterHeadState @override Widget build(BuildContext context) { return TransactionFilterHead(filterChips: [ - TransactionFilterChip( + TransactionFilterChip( translationKey: "transactions.query.filter.keyword", avatar: const Icon(Symbols.search_rounded), - onSelect: () {}, - defaultValue: widget.defaultFilter.keyword, - value: _filter.keyword?.isNotEmpty == true ? _filter.keyword : null, + onSelect: onSearch, + defaultValue: widget.defaultFilter.searchData, + value: _filter.searchData, + highlightOverride: _filter.searchData.normalizedKeyword != null, ), TransactionFilterChip( translationKey: "transactions.query.filter.timeRange", @@ -90,6 +92,23 @@ class _DefaultTransactionsFilterHeadState ]); } + void onSearch() async { + final TransactionSearchData? searchData = + await showModalBottomSheet( + context: context, + builder: (context) => TransactionSearchSheet( + searchData: filter.searchData, + ), + isScrollControlled: true, + ); + + if (searchData != null) { + setState(() { + filter = filter.copyWithOptional(searchData: searchData); + }); + } + } + void onSelectAccounts() async { final List? accounts = await showModalBottomSheet>( context: context, @@ -121,7 +140,7 @@ class _DefaultTransactionsFilterHeadState if (categories != null) { setState(() { - filter = filter.copyWithOptional(cateogries: Optional(categories)); + filter = filter.copyWithOptional(categories: Optional(categories)); }); } } diff --git a/lib/widgets/select_currency_sheet.dart b/lib/widgets/select_currency_sheet.dart index 01ddcbb..4bc3a82 100644 --- a/lib/widgets/select_currency_sheet.dart +++ b/lib/widgets/select_currency_sheet.dart @@ -38,7 +38,7 @@ class _SelectCurrencySheetState extends State { extractTop( query: _query.trim(), choices: iso4217Currencies, - limit: 10, + limit: iso4217Currencies.length, getter: (currencyData) => "${currencyData.code} ${currencyData.name} ${currencyData.country}", ) diff --git a/lib/routes/new_transaction/select_multi_account_sheet.dart b/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart similarity index 93% rename from lib/routes/new_transaction/select_multi_account_sheet.dart rename to lib/widgets/transaction_filter_head/select_multi_account_sheet.dart index 9e6f28f..b2f90be 100644 --- a/lib/routes/new_transaction/select_multi_account_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart @@ -49,6 +49,11 @@ class _SelectMultiAccountSheetState extends State { scrollableContentMaxHeight: MediaQuery.of(context).size.height * .5, trailing: ButtonBar( children: [ + TextButton.icon( + onPressed: () => context.pop([]), + icon: const Icon(Symbols.block_rounded), + label: Text("transactions.query.clearSelection".t(context)), + ), TextButton.icon( onPressed: pop, icon: const Icon(Symbols.check), diff --git a/lib/routes/new_transaction/select_multi_category_sheet.dart b/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart similarity index 92% rename from lib/routes/new_transaction/select_multi_category_sheet.dart rename to lib/widgets/transaction_filter_head/select_multi_category_sheet.dart index 77ea4ec..1ecbe5c 100644 --- a/lib/routes/new_transaction/select_multi_category_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart @@ -44,6 +44,11 @@ class _SelectMultiCategorySheetState extends State { title: Text("transaction.edit.selectCategory.multiple".t(context)), trailing: ButtonBar( children: [ + TextButton.icon( + onPressed: () => context.pop([]), + icon: const Icon(Symbols.block_rounded), + label: Text("transactions.query.clearSelection".t(context)), + ), TextButton.icon( onPressed: pop, icon: const Icon(Symbols.check), diff --git a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart index 2c49bb5..239e94f 100644 --- a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart +++ b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart @@ -1,3 +1,4 @@ +import 'package:flow/data/transactions_filter.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; import 'package:flow/l10n/extensions.dart'; @@ -7,6 +8,8 @@ import 'package:moment_dart/moment_dart.dart'; class TransactionFilterChip extends StatelessWidget { final Widget? avatar; + final bool? highlightOverride; + /// Translation key for the label /// /// Requires following keys in the translation file: @@ -16,7 +19,7 @@ class TransactionFilterChip extends StatelessWidget { final T? value; final T? defaultValue; - bool get highlight => value != defaultValue; + bool get highlight => highlightOverride ?? value != defaultValue; /// * If [defaultValue] and [value] are null, displays translated [translationKey] /// * If [defaultValue] isn't null, but [value] is null, displays translated `$translationKey.all` @@ -40,6 +43,7 @@ class TransactionFilterChip extends StatelessWidget { required this.translationKey, required this.onSelect, this.valueLabelOverride, + this.highlightOverride, }); @override @@ -75,16 +79,31 @@ class TransactionFilterChip extends StatelessWidget { if (value case Account account) { return account.name; } + if (value case Category category) { return category.name; } + if (value case TransactionSearchData searchData) { + if (searchData.normalizedKeyword != null) { + return searchData.keyword ?? ""; + } else { + return "transactions.query.filter.keyword".t(context); + } + } + if (value case List list) { if (list.length > 2) { if (list.first is Account) { - return "transactions.query.filter.accounts.n".tr(list.length); + return "transactions.query.filter.accounts.n".t( + context, + list.length, + ); } else if (list.first is Category) { - return "transactions.query.filter.categories.n".tr(list.length); + return "transactions.query.filter.categories.n".t( + context, + list.length, + ); } } diff --git a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart new file mode 100644 index 0000000..f89aff9 --- /dev/null +++ b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart @@ -0,0 +1,95 @@ +import 'package:flow/data/transactions_filter.dart'; +import 'package:flow/l10n/extensions.dart'; +import 'package:flow/utils/optional.dart'; +import 'package:flow/widgets/general/modal_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +/// Pops with [TransactionSearchData] +class TransactionSearchSheet extends StatefulWidget { + final TransactionSearchData? searchData; + + const TransactionSearchSheet({super.key, this.searchData}); + + @override + State createState() => _TransactionSearchSheetState(); +} + +class _TransactionSearchSheetState extends State { + late TransactionSearchData _searchData; + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _searchData = widget.searchData ?? const TransactionSearchData(); + _controller = TextEditingController(text: _searchData.keyword); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ModalSheet.scrollable( + title: Text('transactions.query.filter.keyword'.t(context)), + trailing: ButtonBar( + children: [ + TextButton.icon( + onPressed: clear, + icon: const Icon(Symbols.block_rounded), + label: Text("transactions.query.filter.keyword.clear".t(context)), + ), + TextButton.icon( + onPressed: pop, + icon: const Icon(Symbols.check), + label: Text("general.done".t(context)), + ), + ], + ), + scrollableContentMaxHeight: MediaQuery.of(context).size.height * 0.5, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + controller: _controller, + onSubmitted: (_) => pop(), + decoration: InputDecoration( + hintText: "transactions.query.filter.keyword.hint".t(context), + prefixIcon: const Icon(Symbols.search_rounded), + ), + ), + ) + ], + ), + ), + ); + } + + void _updateText() { + _searchData = _searchData.copyWithOptional( + keyword: Optional(_controller.text), + ); + + if (!mounted) return; + + setState(() {}); + } + + void clear() { + _updateText(); + context.pop(const TransactionSearchData()); + } + + void pop() { + _updateText(); + context.pop(_searchData); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7632388..2f94cc4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -253,10 +253,18 @@ packages: dependency: transitive description: name: dio - sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" + sha256: e17f6b3097b8c51b72c74c9f071a605c47bcc8893839bd66732457a5ebe73714 url: "https://pub.dev" source: hosted - version: "5.4.3+1" + version: "5.5.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "36c5b2d79eb17cdae41e974b7a8284fec631651d2a6f39a8a2ff22327e90aeac" + url: "https://pub.dev" + source: hosted + version: "1.0.1" dotted_border: dependency: "direct main" description: @@ -301,10 +309,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "29c90806ac5f5fb896547720b73b17ee9aed9bba540dc5d91fe29f8c5745b10a" + sha256: "824f5b9f389bfc4dddac3dea76cd70c51092d9dff0b2ece7ef4f53db8547d258" url: "https://pub.dev" source: hosted - version: "8.0.3" + version: "8.0.6" file_saver: dependency: "direct main" description: @@ -341,10 +349,10 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + sha256: "2ad726953f6e8affbc4df8dc78b77c3b4a060967a291e528ef72ae846c60fb69" url: "https://pub.dev" source: hosted - version: "0.9.3+1" + version: "0.9.3+2" fixnum: dependency: transitive description: @@ -357,10 +365,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: "2b7c1f5d867da9a054661641c8f499c55c47c39acccb97b3bc673f5fa9a39e74" + sha256: d0f0d49112f2f4b192481c16d05b6418bd7820e021e265a3c22db98acf7ed7fb url: "https://pub.dev" source: hosted - version: "0.67.0" + version: "0.68.0" flat_buffers: dependency: transitive description: @@ -442,10 +450,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "4.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -455,18 +463,18 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e + sha256: "9d98bd47ef9d34e803d438f17fd32b116d31009f534a6fa5ce3a1167f189a6de" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.0.21" flutter_slidable: dependency: "direct main" description: name: flutter_slidable - sha256: "673403d2eeef1f9e8483bd6d8d92aae73b1d8bd71f382bc3930f699c731bc27c" + sha256: "2c5611c0b44e20d180e4342318e1bbc28b0a44ad2c442f5df16962606fd3e8e3" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.1" flutter_staggered_grid_view: dependency: "direct main" description: @@ -521,26 +529,26 @@ packages: dependency: "direct main" description: name: go_router - sha256: b465e99ce64ba75e61c8c0ce3d87b66d8ac07f0b35d0a7e0263fcfc10f99e836 + sha256: "39dd52168d6c59984454183148dc3a5776960c61083adfc708cc79a7b3ce1ba8" url: "https://pub.dev" source: hosted - version: "13.2.5" + version: "14.2.1" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" http: dependency: transitive description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -585,10 +593,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "4161e1f843d8480d2e9025ee22411778c3c9eb7e40076dcf2da23d8242b7b51c" + sha256: "8c3168469b005a6dbf5ba01f795917ae4f4e71077d3d7f2049a0d25a4760393e" url: "https://pub.dev" source: hosted - version: "0.8.12+3" + version: "0.8.12+8" image_picker_for_web: dependency: transitive description: @@ -705,10 +713,10 @@ packages: dependency: transitive description: name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" local_hero: dependency: "direct main" description: @@ -761,10 +769,10 @@ packages: dependency: "direct main" description: name: material_symbols_icons - sha256: b2d3cbc3c42b8a217715b0d97ff03aebb14b2b4592875736e5599c603fb2db7e + sha256: "37f88057af06224cd99242bd9b5ceda8c1ebddfff67bd5e8432521910a3d4598" url: "https://pub.dev" source: hosted - version: "4.2758.0" + version: "4.2771.0" meta: dependency: transitive description: @@ -793,26 +801,26 @@ packages: dependency: "direct main" description: name: objectbox - sha256: "9fb2810156e8f78d82ecf672c36a1aba2c1de16d7903675335e00e374bdc3ba8" + sha256: "70ff2a7538f6f8bb56136734d574f5bdc1cf29c50cd7207a14ea0c641ecb88ca" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "4.0.1" objectbox_flutter_libs: dependency: "direct main" description: name: objectbox_flutter_libs - sha256: dca86b2d1074110573b69cbd9afb6b67ab9d2c824704c6ac5187e546418baf9c + sha256: "97adc5f95d16f33c7114d56e5dec617db4300cd11ae5022134cf76fa5f30084d" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "4.0.1" objectbox_generator: dependency: "direct dev" description: name: objectbox_generator - sha256: c22c59c27edb90e709da00f0b2e788a5774a4cdce12d393d117a39500877cfb7 + sha256: "29d9295aac0a74ce44cd00afa011e0e22404e5c8f66e37587f84e3ef4b6bee52" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "4.0.1" package_config: dependency: transitive description: @@ -825,10 +833,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "2c582551839386fa7ddbc7770658be7c0f87f388a4bff72066478f597c34d17f" + sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "8.0.0" package_info_plus_platform_interface: dependency: transitive description: @@ -873,10 +881,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514" + sha256: e84c8a53fe1510ef4582f118c7b4bdf15b03002b51d7c2b66983c65843d61193 url: "https://pub.dev" source: hosted - version: "2.2.5" + version: "2.2.8" path_provider_foundation: dependency: transitive description: @@ -905,10 +913,10 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" pausable_timer: dependency: transitive description: @@ -929,18 +937,18 @@ packages: dependency: "direct main" description: name: pie_menu - sha256: ec570849e84318698bf76ca7ddb6b1661740ad8da33f730be14ff27e9d73cbac + sha256: "9ed31122c626f4bc92cd651360b34744913fb919a20857a8a94865680ddb98fd" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.1" platform: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -1017,18 +1025,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fb5319f3aab4c5dda5ebb92dca978179ba21f8c783ee4380910ef4c1c6824f51 + sha256: ef3489a969683c4f3d0239010cc8b7a2a46543a8d139e111c06c558875083544 url: "https://pub.dev" source: hosted - version: "8.0.3" + version: "9.0.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496" + sha256: "0f9e4418835d1b2c3ae78fdb918251959106cefdbc4dd43526e182f80e82f6d4" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "4.0.0" shared_preferences: dependency: "direct main" description: @@ -1041,10 +1049,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577" + sha256: "3d4571b3c5eb58ce52a419d86e655493d0bc3020672da79f72fa0c16ca3a8ec1" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" shared_preferences_foundation: dependency: transitive description: @@ -1065,10 +1073,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + sha256: "034650b71e73629ca08a0bd789fd1d83cc63c2d1e405946f7cef7bc37432f93a" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" shared_preferences_web: dependency: transitive description: @@ -1118,10 +1126,10 @@ packages: dependency: "direct main" description: name: smooth_page_indicator - sha256: "725bc638d5e79df0c84658e1291449996943f93bacbc2cec49963dbbab48d8ae" + sha256: "3b28b0c545fa67ed9e5997d9f9720d486f54c0c607e056a1094544e36934dff3" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0+3" source_gen: dependency: transitive description: @@ -1214,10 +1222,10 @@ packages: dependency: "direct main" description: name: toastification - sha256: "1e01495fe00b8fddce8a7f1da5e4775cd003763698e8363d7122bea4168a395e" + sha256: ffd10916d4cd0e8b944f3df334c7892ef55cdc7c1875b1c7b007780b9f5fc756 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" typed_data: dependency: transitive description: @@ -1238,18 +1246,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + sha256: c24484594a8dea685610569ab0f2547de9c7a1907500a9bc5e37e4c9a3cbfb23 url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.6" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_linux: dependency: transitive description: @@ -1286,18 +1294,18 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" uuid: dependency: "direct main" description: name: uuid - sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.4.2" vector_math: dependency: transitive description: @@ -1334,18 +1342,18 @@ packages: dependency: transitive description: name: web_socket - sha256: "24301d8c293ce6fe327ffe6f59d8fd8834735f0ec36e4fd383ec7ff8a64aa078" + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: a2d56211ee4d35d9b344d9d4ce60f362e4f5d1aafb988302906bd732bc731276 + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 18ea43e..5f156d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: dotted_border: ^2.1.0 file_picker: ^8.0.3 file_saver: ^0.2.9 - fl_chart: ^0.67.0 + fl_chart: ^0.68.0 flutter: sdk: flutter flutter_floating_bottom_bar: ^1.2.0 @@ -29,7 +29,7 @@ dependencies: flutter_staggered_grid_view: ^0.7.0 flutter_typeahead: ^5.2.0 fuzzywuzzy: ^1.1.6 - go_router: ^13.2.5 + go_router: ^14.2.1 image_picker: ^1.1.1 intl: ^0.19.0 json_annotation: ^4.9.0 @@ -38,18 +38,18 @@ dependencies: mask_text_input_formatter: ^2.8.0 material_symbols_icons: ^4.2719.1 moment_dart: ^2.2.1+beta.0 - objectbox: ^2.4.0 - objectbox_flutter_libs: ^2.4.0 - package_info_plus: ^7.0.0 + objectbox: ^4.0.1 + objectbox_flutter_libs: ^4.0.1 + package_info_plus: ^8.0.0 path: ^1.8.3 path_provider: ^2.1.1 pie_menu: ^3.2.0 salomon_bottom_bar: ^3.3.2 - share_plus: ^8.0.2 + share_plus: ^9.0.0 shared_preferences: ^2.2.2 simple_icons: ^10.1.3 smooth_page_indicator: ^1.1.0 - toastification: ^1.2.0 + toastification: ^2.1.0 url_launcher: ^6.2.3 uuid: ^4.2.2 @@ -58,11 +58,11 @@ dev_dependencies: flutter_launcher_icons: ^0.13.1 - flutter_lints: ^3.0.0 + flutter_lints: ^4.0.0 flutter_test: sdk: flutter json_serializable: ^6.8.0 - objectbox_generator: ^2.3.1 + objectbox_generator: ^4.0.1 flutter: generate: true From 8c2347d3c6c5041ef65dd1b1b8bf2c7fc5cfa76c Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 28 Jul 2024 18:41:18 +0800 Subject: [PATCH 06/28] autofocus --- .../transaction_filter_head/transaction_search_sheet.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart index f89aff9..ba25170 100644 --- a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart +++ b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart @@ -59,6 +59,7 @@ class _TransactionSearchSheetState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: TextField( + autofocus: true, controller: _controller, onSubmitted: (_) => pop(), decoration: InputDecoration( From 7f11591024f8aa50a267b4e4ac23d2297e0b130a Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 4 Aug 2024 19:54:59 +0800 Subject: [PATCH 07/28] draft 4 --- CHANGELOG.md | 6 + assets/l10n/en_US.json | 17 +- assets/l10n/it_IT.json | 13 +- assets/l10n/mn_MN.json | 11 +- lib/data/transactions_filter.dart | 20 ++ lib/data/transactions_filter/search_data.dart | 4 + lib/prefs.dart | 30 +++ lib/routes.dart | 5 + lib/routes/account/account_edit_page.dart | 1 - lib/routes/home/home_tab.dart | 50 ++++- .../input_amount_sheet/input_value.dart | 4 + .../preferences/home_tab_preferences.dart | 81 ++++++++ .../preferences/language_selection_sheet.dart | 15 +- .../numpad_selector_radio.dart | 2 - .../preferences/numpad_preferences_page.dart | 3 +- .../preferences/theme_selection_sheet.dart | 45 +++++ .../combine_transfer_radio.dart.dart | 1 - .../transfer_preferences_page.dart | 11 +- lib/routes/preferences_page.dart | 178 ++++++++++-------- lib/theme/theme.dart | 80 ++++++++ lib/widgets/general/flow_icon.dart | 6 + lib/widgets/select_time_range_mode_sheet.dart | 21 ++- .../transaction_filter_chip.dart | 5 + lib/widgets/utils/time_and_range.dart | 6 + 24 files changed, 480 insertions(+), 135 deletions(-) create mode 100644 lib/routes/preferences/home_tab_preferences.dart create mode 100644 lib/routes/preferences/theme_selection_sheet.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1522347..1407cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Beta 0.6.0 + +* Updated theme to correct `activeColor` for radio/checkboxes and its lists +* Added filters to home tab, and added preferences for home page +* Added error builder for Image `FlowIcon`s when the image is missing + ## Beta 0.5.5 * Selecting icons should be slightly better diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index c783af7..8b05f35 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -136,8 +136,9 @@ "preferences": "Preferences", "preferences.primaryCurrency": "Primary currency", "preferences.language": "Language", - "preferences.language.choose": "Choose language", + "preferences.language.choose": "Select a language", "preferences.themeMode": "Theme", + "preferences.themeMode.choose": "Select a theme", "preferences.themeMode.light": "Light", "preferences.themeMode.dark": "Dark", "preferences.themeMode.system": "Auto (system)", @@ -159,15 +160,12 @@ "preferences.transfer.excludeTransferFromFlow": "Exclude from totals", "preferences.transfer.excludeTransferFromFlow.description": "Don't count towards total expense/income", "preferences.home": "Home page", - "preferences.home.transactions": "Transactions", - "preferences.home.transactions.description": "Time range of transactions to show initially", - "preferences.home.transactions.last30d": "Last 30 days", "preferences.home.upcoming": "Upcoming transactions", - "preferences.home.upcoming.description": "Range of upcoming transactions to show", + "preferences.home.upcoming.none": "None", + "preferences.home.upcoming.description": "Shows planned transactions for the selected duration", + "preferences.home.upcoming.nextNdays": "Next {} days", "preferences.home.upcoming.alwaysVisible": "Always show", "preferences.home.upcoming.alwaysVisible.description": "Stay visible when the filters are active", - "preferences.home.upcoming.next7d": "Next 7 days", - "preferences.home.upcoming.next30d": "Next 30 days", "tabs.home": "Home", "tabs.home.greetings": "Hi, {name}!", @@ -185,6 +183,7 @@ "tabs.stats.timeRange.select": "Select range", "tabs.stats.timeRange.changeMode": "More options", "tabs.stats.timeRange.presets": "Common options", + "tabs.stats.timeRange.last30days": "Last 30 days", "tabs.stats.timeRange.thisWeek": "This week", "tabs.stats.timeRange.thisMonth": "This month", "tabs.stats.timeRange.thisYear": "This year", @@ -223,8 +222,8 @@ "flowIcon.type.icon.symbols": "Symbols", "flowIcon.type.icon.search": "Search icons...", "flowIcon.type.image": "Image", - "flowIcon.type.image.pick": "Choose an image", - "flowIcon.type.image.description": "Choose an image to use as an icon", + "flowIcon.type.image.pick": "Pick an image", + "flowIcon.type.image.description": "Pick an image to use as an icon", "flowIcon.type.character": "Character", "flowIcon.type.character.description": "Enter an emoji or a letter to use as an icon", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 6f734f2..c6b892e 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -136,8 +136,9 @@ "preferences": "Preferenze", "preferences.primaryCurrency": "Valuta principale", "preferences.language": "Lingua", - "preferences.language.choose": "Scegli lingua", + "preferences.language.choose": "Selezionare una lingua", "preferences.themeMode": "Tema", + "preferences.themeMode.choose": "Seleziona un tema", "preferences.themeMode.light": "Chiaro", "preferences.themeMode.dark": "Scuro", "preferences.themeMode.system": "Auto (sistema)", @@ -159,15 +160,12 @@ "preferences.transfer.excludeTransferFromFlow": "Escludi dai totali", "preferences.transfer.excludeTransferFromFlow.description": "Non conteggiare verso la spesa/entrata totale", "preferences.home": "Home page", - "preferences.home.transactions": "Transazioni", - "preferences.home.transactions.description": "Intervallo iniziale di visualizzazione delle transazioni", - "preferences.home.transactions.last30d": "Ultimi 30 giorni", "preferences.home.upcoming": "Prossime transazioni", - "preferences.home.upcoming.description": "Intervallo di visualizzazione delle prossime transazioni", + "preferences.home.upcoming.description": "Mostra le transazioni pianificate per la durata selezionata", + "preferences.home.upcoming.none": "Nessuno", + "preferences.home.upcoming.nextNdays": "Prossimi {} giorni", "preferences.home.upcoming.alwaysVisible": "Mostra sempre", "preferences.home.upcoming.alwaysVisible.description": "Rimane visibile quando i filtri sono attivi", - "preferences.home.upcoming.next7d": "Prossimi 7 giorni", - "preferences.home.upcoming.next30d": "Prossimi 30 giorni", "tabs.home": "Home", "tabs.home.greetings": "Ciao, {name}!", @@ -185,6 +183,7 @@ "tabs.stats.timeRange.select": "Seleziona intervallo", "tabs.stats.timeRange.changeMode": "Più opzioni", "tabs.stats.timeRange.presets": "Opzioni comuni", + "tabs.stats.timeRange.last30days": "Ultimi 30 giorni", "tabs.stats.timeRange.thisWeek": "Questa settimana", "tabs.stats.timeRange.thisMonth": "Questo mese", "tabs.stats.timeRange.thisYear": "Quest'anno", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 4b6df32..124add9 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -138,6 +138,7 @@ "preferences.language": "Хэл", "preferences.language.choose": "Хэл сонгох", "preferences.themeMode": "Үзэмж", + "preferences.themeMode.choose": "Үзэмж сонгох", "preferences.themeMode.light": "Гэгээлэг", "preferences.themeMode.dark": "Харанхуй", "preferences.themeMode.system": "Авто (систем)", @@ -159,15 +160,12 @@ "preferences.transfer.excludeTransferFromFlow": "Нийт дүнд оруулахгүй", "preferences.transfer.excludeTransferFromFlow.description": "Идэвхтэй үед орлого/зарлага-д тоолохгүй", "preferences.home": "Нүүр хуудас", - "preferences.home.transactions": "Гүйлгээнүүд", - "preferences.home.transactions.description": "Анх харуулах гүйлгээнүүдийн хугацаа", - "preferences.home.transactions.last30d": "Сүүлийн 30 хоног", "preferences.home.upcoming": "Төлөвлөсөн гүйлгээнүүд", - "preferences.home.upcoming.description": "Харуулах төлөвлөгөөт гүйлгээнүүдийн хугацаа", + "preferences.home.upcoming.description": "Сонгосон хугацааны төлөвлөгөөт гүйлгээнүүдийг харуулна", + "preferences.home.upcoming.none": "Харуулахгүй", + "preferences.home.upcoming.nextNdays": "Ирэх {} хоног", "preferences.home.upcoming.alwaysVisible": "Үргэлж харуулах", "preferences.home.upcoming.alwaysVisible.description": "Шүүлтүүр өөрчлөгдсөн ч харуулах", - "preferences.home.upcoming.next7d": "Ирэх 7 хоног", - "preferences.home.upcoming.next30d": "Ирэх 30 хоног", "tabs.home": "Нүүр", "tabs.home.greetings": "Сайн уу, {name}?", @@ -185,6 +183,7 @@ "tabs.stats.timeRange.select": "Хугацаа сонгох", "tabs.stats.timeRange.changeMode": "Өөр сонголтууд", "tabs.stats.timeRange.presets": "Түгээмэл сонголтууд", + "tabs.stats.timeRange.last30days": "Сүүлийн 30 хоног", "tabs.stats.timeRange.thisWeek": "Энэ долоо хоног", "tabs.stats.timeRange.thisMonth": "Энэ сар", "tabs.stats.timeRange.thisYear": "Энэ жил", diff --git a/lib/data/transactions_filter.dart b/lib/data/transactions_filter.dart index 566aabb..6569d26 100644 --- a/lib/data/transactions_filter.dart +++ b/lib/data/transactions_filter.dart @@ -165,6 +165,22 @@ class TransactionFilter { ); } + /// Returns a filter with planned transactions + /// + /// Overrides [to] of the TimeRange + TransactionFilter withPlannedTransactions(int days) => copyWithOptional( + range: range == null + ? null + : Optional( + CustomTimeRange( + range!.from, + Moment.startOfTomorrow() + .add(Duration(days: days - 1)) + .endOfDay(), + ), + ), + ); + @override int get hashCode => Object.hashAll([ types, @@ -178,6 +194,10 @@ class TransactionFilter { @override bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! TransactionFilter) return false; return other.range == range && diff --git a/lib/data/transactions_filter/search_data.dart b/lib/data/transactions_filter/search_data.dart index 4e932f7..975eabe 100644 --- a/lib/data/transactions_filter/search_data.dart +++ b/lib/data/transactions_filter/search_data.dart @@ -109,6 +109,10 @@ class TransactionSearchData { @override operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! TransactionSearchData) { return false; } diff --git a/lib/prefs.dart b/lib/prefs.dart index 8195da4..81ac37a 100644 --- a/lib/prefs.dart +++ b/lib/prefs.dart @@ -18,11 +18,36 @@ import 'package:shared_preferences/shared_preferences.dart'; class LocalPreferences { final SharedPreferences _prefs; + static const int homeTabPlannedTransactionsDaysDefault = 7; + + /// Main currency used in the app late final PrimitiveSettingsEntry primaryCurrency; + + /// Whether to use phone numpad layout + /// + /// When set to true, 1 2 3 will be the top row like + /// in a modern dialpad late final BoolSettingsEntry usePhoneNumpadLayout; + + /// Whether to enable haptic feedback on numpad touch late final BoolSettingsEntry enableNumpadHapticFeedback; + + /// Whether to combine transfer transactions in the transaction list + /// + /// Doesn't necessarily combine the transactions, but rather + /// shows them as a single transaction in the transaction list + /// + /// It will not work in transactions list where a filter has applied late final BoolSettingsEntry combineTransferTransactions; + + /// Whether to exclude transfer transactions from the flow + /// + /// When set to true, transfer transactions will not contribute + /// to total income/expense for a given context late final BoolSettingsEntry excludeTransferFromFlow; + + /// Shows next [homeTabPlannedTransactionsDays] days of planned transactions in the home tab + late final PrimitiveSettingsEntry homeTabPlannedTransactionsDays; late final JsonListSettingsEntry transactionButtonOrder; late final BoolSettingsEntry completedInitialSetup; @@ -60,6 +85,11 @@ class LocalPreferences { preferences: _prefs, initialValue: false, ); + homeTabPlannedTransactionsDays = PrimitiveSettingsEntry( + key: "flow.homeTabPlannedTransactionsDays", + preferences: _prefs, + initialValue: homeTabPlannedTransactionsDaysDefault, + ); transactionButtonOrder = JsonListSettingsEntry( key: "flow.transactionButtonOrder", preferences: _prefs, diff --git a/lib/routes.dart b/lib/routes.dart index e0a0382..e4d1651 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -13,6 +13,7 @@ import 'package:flow/routes/home_page.dart'; import 'package:flow/routes/import_page.dart'; import 'package:flow/routes/import_wizard/v1.dart'; import 'package:flow/routes/preferences/button_order_preferences_page.dart'; +import 'package:flow/routes/preferences/home_tab_preferences.dart'; import 'package:flow/routes/preferences/numpad_preferences_page.dart'; import 'package:flow/routes/preferences/transfer_preferences_page.dart'; import 'package:flow/routes/preferences_page.dart'; @@ -146,6 +147,10 @@ final router = GoRouter( path: '/preferences', builder: (context, state) => const PreferencesPage(), routes: [ + GoRoute( + path: 'home', + builder: (context, state) => const HomeTabPreferencesPage(), + ), GoRoute( path: 'numpad', builder: (context, state) => const NumpadPreferencesPage(), diff --git a/lib/routes/account/account_edit_page.dart b/lib/routes/account/account_edit_page.dart index f4041ff..a22f581 100644 --- a/lib/routes/account/account_edit_page.dart +++ b/lib/routes/account/account_edit_page.dart @@ -230,7 +230,6 @@ class _AccountEditPageState extends State { value: _excludeFromTotalBalance, onChanged: updateBalanceExclusion, title: Text("account.excludeFromTotalBalance".t(context)), - activeColor: context.colorScheme.primary, ), if (widget.isNewAccount) ListTile( diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index c2e3a72..d56fce0 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -2,12 +2,15 @@ import 'package:flow/data/transactions_filter.dart'; import 'package:flow/entity/transaction.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; +import 'package:flow/prefs.dart'; +import 'package:flow/utils/optional.dart'; import 'package:flow/widgets/default_transaction_filter_head.dart'; import 'package:flow/widgets/general/wavy_divider.dart'; import 'package:flow/widgets/grouped_transaction_list.dart'; import 'package:flow/widgets/home/greetings_bar.dart'; import 'package:flow/widgets/home/home/no_transactions.dart'; import 'package:flow/widgets/home/transactions_date_header.dart'; +import 'package:flow/widgets/utils/time_and_range.dart'; import 'package:flutter/material.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -21,17 +24,29 @@ class HomeTab extends StatefulWidget { } class _HomeTabState extends State with AutomaticKeepAliveClientMixin { + int _plannedTransactionDays = + LocalPreferences.homeTabPlannedTransactionsDaysDefault; + final TransactionFilter defaultFilter = TransactionFilter( - range: Moment.now() - .subtract(const Duration(days: 29)) - .startOfDay() - .rangeTo(Moment.maxValue), + range: last30Days(), ); late TransactionFilter currentFilter = defaultFilter.copyWithOptional(); - final DateTime startDate = - Moment.now().subtract(const Duration(days: 29)).startOfDay(); + TransactionFilter get currentFilterWithPlanned { + final Moment plannedTransactionTo = + Moment.now().add(Duration(days: _plannedTransactionDays)).endOfDay(); + + if (currentFilter.range != null && + currentFilter.range!.contains(Moment.now()) && + !currentFilter.range!.contains(plannedTransactionTo)) { + return currentFilter.copyWithOptional( + range: Optional(CustomTimeRange( + currentFilter.range!.from, plannedTransactionTo.endOfDay()))); + } + + return currentFilter; + } late final bool noTransactionsAtAll; @@ -39,6 +54,17 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { void initState() { super.initState(); noTransactionsAtAll = ObjectBox().box().count(limit: 1) == 0; + LocalPreferences() + .homeTabPlannedTransactionsDays + .addListener(_updatePlannedTransactionDays); + } + + @override + void dispose() { + LocalPreferences() + .homeTabPlannedTransactionsDays + .removeListener(_updatePlannedTransactionDays); + super.dispose(); } @override @@ -46,7 +72,10 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { super.build(context); return StreamBuilder>( - stream: currentFilter.queryBuilder().watch(triggerImmediately: true).map( + stream: currentFilterWithPlanned + .queryBuilder() + .watch(triggerImmediately: true) + .map( (event) => event.find().search(currentFilter.searchData), ), builder: (context, snapshot) { @@ -121,6 +150,13 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { ); } + void _updatePlannedTransactionDays() { + _plannedTransactionDays = + LocalPreferences().homeTabPlannedTransactionsDays.get() ?? + LocalPreferences.homeTabPlannedTransactionsDaysDefault; + setState(() {}); + } + @override bool get wantKeepAlive => true; } diff --git a/lib/routes/new_transaction/input_amount_sheet/input_value.dart b/lib/routes/new_transaction/input_amount_sheet/input_value.dart index 147f20e..50404a4 100644 --- a/lib/routes/new_transaction/input_amount_sheet/input_value.dart +++ b/lib/routes/new_transaction/input_amount_sheet/input_value.dart @@ -186,6 +186,10 @@ class InputValue implements Comparable { @override bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is InputValue) return currentAmount == other.currentAmount; if (other is! num) return false; diff --git a/lib/routes/preferences/home_tab_preferences.dart b/lib/routes/preferences/home_tab_preferences.dart new file mode 100644 index 0000000..35d7ac1 --- /dev/null +++ b/lib/routes/preferences/home_tab_preferences.dart @@ -0,0 +1,81 @@ +import 'package:flow/l10n/extensions.dart'; +import 'package:flow/prefs.dart'; +import 'package:flow/widgets/general/info_text.dart'; +import 'package:flow/widgets/general/list_header.dart'; +import 'package:flutter/material.dart'; + +class HomeTabPreferencesPage extends StatefulWidget { + const HomeTabPreferencesPage({super.key}); + + @override + State createState() => _HomeTabPreferencesPageState(); +} + +class _HomeTabPreferencesPageState extends State { + @override + Widget build(BuildContext context) { + final int homeTabPlannedTransactionsDays = + LocalPreferences().homeTabPlannedTransactionsDays.get() ?? + LocalPreferences.homeTabPlannedTransactionsDaysDefault; + + return Scaffold( + appBar: AppBar( + title: Text("preferences.home".t(context)), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16.0), + ListHeader("preferences.home.upcoming".t(context)), + const SizedBox(height: 8.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Wrap( + spacing: 12.0, + runSpacing: 8.0, + children: [0, 7, 14, 30] + .map( + (days) => FilterChip( + showCheckmark: false, + key: ValueKey(days), + label: Text( + days == 0 + ? "preferences.home.upcoming.none".t(context) + : "preferences.home.upcoming.nextNdays" + .t(context, days), + ), + onSelected: (bool value) => value + ? updateHomeTabPlannedTransactionsDays(days) + : null, + selected: days == homeTabPlannedTransactionsDays, + ), + ) + .toList(), + ), + ), + const SizedBox(height: 8.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: InfoText( + child: + Text("preferences.home.upcoming.description".t(context)), + ), + ), + const SizedBox(height: 16.0), + ], + ), + ), + ), + ); + } + + void updateHomeTabPlannedTransactionsDays(int days) async { + if (days < 0) return; + + await LocalPreferences().homeTabPlannedTransactionsDays.set(days); + + if (mounted) setState(() {}); + } +} diff --git a/lib/routes/preferences/language_selection_sheet.dart b/lib/routes/preferences/language_selection_sheet.dart index e75f822..bfa281d 100644 --- a/lib/routes/preferences/language_selection_sheet.dart +++ b/lib/routes/preferences/language_selection_sheet.dart @@ -4,16 +4,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; -class LanguageSelectionSheet extends StatefulWidget { +class LanguageSelectionSheet extends StatelessWidget { final Locale? currentLocale; const LanguageSelectionSheet({super.key, this.currentLocale}); - @override - State createState() => _LanguageSelectionSheetState(); -} - -class _LanguageSelectionSheetState extends State { @override Widget build(BuildContext context) { return ModalSheet.scrollable( @@ -32,11 +27,13 @@ class _LanguageSelectionSheetState extends State { child: Column( children: [ ...FlowLocalizations.supportedLanguages.map( - (locale) => ListTile( + (locale) => RadioListTile.adaptive( title: Text(locale.endonym), subtitle: Text(locale.name), - onTap: () => context.pop(locale), - selected: widget.currentLocale == locale, + selected: currentLocale == locale, + value: locale, + groupValue: currentLocale, + onChanged: (value) => context.pop(value), ), ), ], diff --git a/lib/routes/preferences/numpad_preferences/numpad_selector_radio.dart b/lib/routes/preferences/numpad_preferences/numpad_selector_radio.dart index f3e2588..19298c6 100644 --- a/lib/routes/preferences/numpad_preferences/numpad_selector_radio.dart +++ b/lib/routes/preferences/numpad_preferences/numpad_selector_radio.dart @@ -1,5 +1,4 @@ import 'package:flow/l10n/flow_localizations.dart'; -import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/numpad.dart'; import 'package:flutter/material.dart'; @@ -53,7 +52,6 @@ class NumpadSelectorRadio extends StatelessWidget { value: isPhoneLayout, groupValue: currentlyUsingPhoneLayout, onChanged: (_) {}, - activeColor: context.colorScheme.primary, ), ), ], diff --git a/lib/routes/preferences/numpad_preferences_page.dart b/lib/routes/preferences/numpad_preferences_page.dart index d7b0ab9..12a8b5b 100644 --- a/lib/routes/preferences/numpad_preferences_page.dart +++ b/lib/routes/preferences/numpad_preferences_page.dart @@ -1,7 +1,6 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/prefs.dart'; import 'package:flow/routes/preferences/numpad_preferences/numpad_selector_radio.dart'; -import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/general/list_header.dart'; import 'package:flutter/material.dart'; @@ -31,6 +30,7 @@ class _NumpadPreferencesPageState extends State { children: [ const SizedBox(height: 16.0), ListHeader("preferences.numpad.layout".t(context)), + const SizedBox(height: 8.0), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( @@ -58,7 +58,6 @@ class _NumpadPreferencesPageState extends State { onChanged: updateHapticUsage, subtitle: Text("preferences.numpad.haptics.description".t(context)), - activeColor: context.colorScheme.primary, ), const SizedBox(height: 16.0), ], diff --git a/lib/routes/preferences/theme_selection_sheet.dart b/lib/routes/preferences/theme_selection_sheet.dart new file mode 100644 index 0000000..acaab67 --- /dev/null +++ b/lib/routes/preferences/theme_selection_sheet.dart @@ -0,0 +1,45 @@ +import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/widgets/general/modal_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +/// Pops with a [ThemeMode] +class ThemeSelectionSheet extends StatelessWidget { + final ThemeMode? currentTheme; + + const ThemeSelectionSheet({super.key, this.currentTheme}); + + @override + Widget build(BuildContext context) { + return ModalSheet.scrollable( + scrollableContentMaxHeight: MediaQuery.of(context).size.height, + title: Text("preferences.themeMode.choose".t(context)), + trailing: ButtonBar( + children: [ + TextButton.icon( + onPressed: () => context.pop(), + icon: const Icon(Symbols.close_rounded), + label: Text("general.cancel".t(context)), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + children: [ + ...ThemeMode.values.map( + (themeMode) => RadioListTile.adaptive( + title: + Text("preferences.themeMode.${themeMode.name}".t(context)), + selected: currentTheme == themeMode, + value: themeMode, + groupValue: currentTheme, + onChanged: (value) => context.pop(value), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart b/lib/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart index edae5ee..14d2ade 100644 --- a/lib/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart +++ b/lib/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart @@ -52,7 +52,6 @@ class CombineTransferRadio extends StatelessWidget { value: combine, groupValue: currentlyUsingCombineMode, onChanged: (_) {}, - activeColor: context.colorScheme.primary, ), ), ], diff --git a/lib/routes/preferences/transfer_preferences_page.dart b/lib/routes/preferences/transfer_preferences_page.dart index df5d1ba..1132cee 100644 --- a/lib/routes/preferences/transfer_preferences_page.dart +++ b/lib/routes/preferences/transfer_preferences_page.dart @@ -1,7 +1,6 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/prefs.dart'; import 'package:flow/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart'; -import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/general/info_text.dart'; import 'package:flow/widgets/general/list_header.dart'; import 'package:flutter/material.dart'; @@ -33,9 +32,10 @@ class _TransferPreferencesPageState extends State { children: [ const SizedBox(height: 16.0), ListHeader( - "preferences.transfer.combineTransferTransaction".t(context)), + "preferences.transfer.combineTransferTransaction".t(context), + ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0), child: Row( children: [ Expanded( @@ -56,7 +56,7 @@ class _TransferPreferencesPageState extends State { ), const SizedBox(height: 8.0), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0), child: InfoText( child: Text( "preferences.transfer.combineTransferTransaction.filterDescription" @@ -64,7 +64,7 @@ class _TransferPreferencesPageState extends State { ), ), ), - const SizedBox(height: 32.0), + const SizedBox(height: 24.0), CheckboxListTile.adaptive( title: Text( "preferences.transfer.excludeTransferFromFlow".t(context)), @@ -73,7 +73,6 @@ class _TransferPreferencesPageState extends State { subtitle: Text( "preferences.transfer.excludeTransferFromFlow.description" .t(context)), - activeColor: context.colorScheme.primary, ), const SizedBox(height: 16.0), ], diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart index 56899c6..f47c9e2 100644 --- a/lib/routes/preferences_page.dart +++ b/lib/routes/preferences_page.dart @@ -6,7 +6,7 @@ import 'package:flow/l10n/flow_localizations.dart'; import 'package:flow/main.dart'; import 'package:flow/prefs.dart'; import 'package:flow/routes/preferences/language_selection_sheet.dart'; -import 'package:flow/theme/theme.dart'; +import 'package:flow/routes/preferences/theme_selection_sheet.dart'; import 'package:flow/widgets/select_currency_sheet.dart'; import 'package:flutter/material.dart' hide Flow; import 'package:go_router/go_router.dart'; @@ -28,81 +28,93 @@ class _PreferencesPageState extends State { Widget build(BuildContext context) { final ThemeMode currentThemeMode = Flow.of(context).themeMode; + final int showUpcomingTransactionDays = + LocalPreferences().homeTabPlannedTransactionsDays.get() ?? + LocalPreferences.homeTabPlannedTransactionsDaysDefault; + return Scaffold( appBar: AppBar( title: Text("preferences".t(context)), ), body: SafeArea( - child: ListView( - children: ListTile.divideTiles( - tiles: [ - ListTile( - title: Text("preferences.themeMode".t(context)), - leading: switch (currentThemeMode) { - ThemeMode.system => const Icon(Symbols.routine_rounded), - ThemeMode.dark => const Icon(Symbols.light_mode_rounded), - ThemeMode.light => const Icon(Symbols.dark_mode_rounded), - }, - subtitle: Text(switch (currentThemeMode) { - ThemeMode.system => "preferences.themeMode.system".t(context), - ThemeMode.dark => "preferences.themeMode.dark".t(context), - ThemeMode.light => "preferences.themeMode.light".t(context), - }), - onTap: () => updateTheme(), - onLongPress: () => updateTheme(ThemeMode.system), - trailing: const Icon(Symbols.chevron_right_rounded), - ), - ListTile( - title: Text("preferences.language".t(context)), - leading: const Icon(Symbols.language_rounded), - onTap: () => updateLanguage(), - subtitle: Text(FlowLocalizations.of(context).locale.endonym), - trailing: const Icon(Symbols.chevron_right_rounded), - ), - ListTile( - title: Text("preferences.primaryCurrency".t(context)), - leading: const Icon(Symbols.universal_currency_alt_rounded), - onTap: () => updatePrimaryCurrency(), - subtitle: Text(LocalPreferences().getPrimaryCurrency()), - trailing: const Icon(Symbols.chevron_right_rounded), - ), - ListTile( - title: Text("preferences.numpad".t(context)), - leading: const Icon(Symbols.dialpad_rounded), - onTap: openNumpadPrefs, - subtitle: Text( - LocalPreferences().usePhoneNumpadLayout.get() - ? "preferences.numpad.layout.modern".t(context) - : "preferences.numpad.layout.classic".t(context), - ), - trailing: const Icon(Symbols.chevron_right_rounded), - ), - ListTile( - title: Text("preferences.transfer".t(context)), - leading: const Icon(Symbols.sync_alt_rounded), - onTap: openTransferPrefs, - subtitle: Text( - "preferences.transfer.description".t(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: const Icon(Symbols.chevron_right_rounded), - ), - ListTile( - title: Text("preferences.transactionButtonOrder".t(context)), - leading: const Icon(Symbols.action_key_rounded), - onTap: openTransactionButtonOrderPrefs, - subtitle: Text( - "preferences.transactionButtonOrder.description".t(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: const Icon(Symbols.chevron_right_rounded), - ), - ], - color: context.colorScheme.onSurface.withAlpha(0x20), - ).toList(), - ), + child: ListView(children: [ + ListTile( + title: Text("preferences.home.upcoming".t(context)), + subtitle: Text( + showUpcomingTransactionDays == 0 + ? "preferences.home.upcoming.none".t(context) + : "preferences.home.upcoming.nextNdays" + .t(context, showUpcomingTransactionDays), + ), + leading: const Icon(Symbols.hourglass_top_rounded), + onTap: openHomeTabPrefs, + // subtitle: Text(FlowLocalizations.of(context).locale.endonym), + trailing: const Icon(Symbols.chevron_right_rounded), + ), + ListTile( + title: Text("preferences.themeMode".t(context)), + leading: switch (currentThemeMode) { + ThemeMode.system => const Icon(Symbols.routine_rounded), + ThemeMode.dark => const Icon(Symbols.dark_mode_rounded), + ThemeMode.light => const Icon(Symbols.light_mode_rounded), + }, + subtitle: Text(switch (currentThemeMode) { + ThemeMode.system => "preferences.themeMode.system".t(context), + ThemeMode.dark => "preferences.themeMode.dark".t(context), + ThemeMode.light => "preferences.themeMode.light".t(context), + }), + onTap: () => updateTheme(), + onLongPress: () => updateTheme(ThemeMode.system), + trailing: const Icon(Symbols.chevron_right_rounded), + ), + ListTile( + title: Text("preferences.language".t(context)), + leading: const Icon(Symbols.language_rounded), + onTap: () => updateLanguage(), + subtitle: Text(FlowLocalizations.of(context).locale.endonym), + trailing: const Icon(Symbols.chevron_right_rounded), + ), + ListTile( + title: Text("preferences.primaryCurrency".t(context)), + leading: const Icon(Symbols.universal_currency_alt_rounded), + onTap: () => updatePrimaryCurrency(), + subtitle: Text(LocalPreferences().getPrimaryCurrency()), + trailing: const Icon(Symbols.chevron_right_rounded), + ), + ListTile( + title: Text("preferences.numpad".t(context)), + leading: const Icon(Symbols.dialpad_rounded), + onTap: openNumpadPrefs, + subtitle: Text( + LocalPreferences().usePhoneNumpadLayout.get() + ? "preferences.numpad.layout.modern".t(context) + : "preferences.numpad.layout.classic".t(context), + ), + trailing: const Icon(Symbols.chevron_right_rounded), + ), + ListTile( + title: Text("preferences.transfer".t(context)), + leading: const Icon(Symbols.sync_alt_rounded), + onTap: openTransferPrefs, + subtitle: Text( + "preferences.transfer.description".t(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Symbols.chevron_right_rounded), + ), + ListTile( + title: Text("preferences.transactionButtonOrder".t(context)), + leading: const Icon(Symbols.action_key_rounded), + onTap: openTransactionButtonOrderPrefs, + subtitle: Text( + "preferences.transactionButtonOrder.description".t(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Symbols.chevron_right_rounded), + ), + ]), ), ); } @@ -115,15 +127,16 @@ class _PreferencesPageState extends State { }); try { - final ThemeMode newThemeMode = force ?? - switch ((Flow.of(context).themeMode, Flow.of(context).useDarkTheme)) { - (ThemeMode.light, _) => ThemeMode.dark, - (ThemeMode.dark, _) => ThemeMode.light, - (ThemeMode.system, true) => ThemeMode.light, - (ThemeMode.system, false) => ThemeMode.dark, - }; + final ThemeMode? selected = await showModalBottomSheet( + context: context, + builder: (context) => ThemeSelectionSheet( + currentTheme: Flow.of(context).themeMode, + ), + ); - await LocalPreferences().themeMode.set(newThemeMode); + if (selected != null) { + await LocalPreferences().themeMode.set(selected); + } if (mounted) { // Even tho the whole app state refreshes, it doesn't get refreshed @@ -209,6 +222,13 @@ class _PreferencesPageState extends State { await context.push("/preferences/transfer"); } + void openHomeTabPrefs() async { + await context.push("/preferences/home"); + + // Rebuild to update description text + if (mounted) setState(() {}); + } + void openTransactionButtonOrderPrefs() async { await context.push("/preferences/transactionButtonOrder"); } diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 7323cd5..43e2235 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -71,6 +71,45 @@ final lightTheme = ThemeData( iconColor: _light.primary, selectedTileColor: _light.secondary, ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.disabled)) { + return _light.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + return _light.primary; + } + if (states.contains(WidgetState.pressed)) { + return _light.onSurface; + } + if (states.contains(WidgetState.hovered)) { + return _light.onSurface; + } + if (states.contains(WidgetState.focused)) { + return _light.onSurface; + } + return _light.onSurfaceVariant; + }, + ), + ), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _light.onSurface.withOpacity(0.38); + } + return kTransparent; + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.error)) { + return _light.error; + } + return _light.primary; + } + return kTransparent; + }), + ), textSelectionTheme: TextSelectionThemeData( selectionColor: _light.secondary, cursorColor: _light.primary, @@ -126,6 +165,47 @@ final darkTheme = ThemeData( listTileTheme: ListTileThemeData( iconColor: _dark.primary, selectedTileColor: _dark.secondary, + selectedColor: _dark.primary, + ), + // The amount of buraucracy to get thru to see the default implementation is insane!!! + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.disabled)) { + return _dark.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + return _dark.primary; + } + if (states.contains(WidgetState.pressed)) { + return _dark.onSurface; + } + if (states.contains(WidgetState.hovered)) { + return _dark.onSurface; + } + if (states.contains(WidgetState.focused)) { + return _dark.onSurface; + } + return _dark.onSurfaceVariant; + }, + ), + ), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _dark.onSurface.withOpacity(0.38); + } + return kTransparent; + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.error)) { + return _dark.error; + } + return _dark.primary; + } + return kTransparent; + }), ), textSelectionTheme: TextSelectionThemeData( selectionColor: _dark.primary, diff --git a/lib/widgets/general/flow_icon.dart b/lib/widgets/general/flow_icon.dart index a5d6a97..1eb5058 100644 --- a/lib/widgets/general/flow_icon.dart +++ b/lib/widgets/general/flow_icon.dart @@ -5,6 +5,7 @@ import 'package:flow/objectbox.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/general/surface.dart'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:path/path.dart'; class FlowIcon extends StatelessWidget { @@ -87,6 +88,11 @@ class FlowIcon extends StatelessWidget { File(join(ObjectBox.appDataDirectory, image.imagePath)), width: size, height: size, + errorBuilder: (context, error, stackTrace) => Icon( + Symbols.error_rounded, + color: context.flowColors.expense, + size: size, + ), ), ), CharacterFlowIcon character => SizedBox.square( diff --git a/lib/widgets/select_time_range_mode_sheet.dart b/lib/widgets/select_time_range_mode_sheet.dart index 8063431..60af828 100644 --- a/lib/widgets/select_time_range_mode_sheet.dart +++ b/lib/widgets/select_time_range_mode_sheet.dart @@ -6,12 +6,17 @@ import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; enum TimeRangeMode { - thisWeek, - thisMonth, - thisYear, - byMonth, - byYear, - custom, + last30Days("last30Days"), + thisWeek("thisWeek"), + thisMonth("thisMonth"), + thisYear("thisYear"), + byMonth("byMonth"), + byYear("byYear"), + custom("custom"); + + final String value; + + const TimeRangeMode(this.value); } class SelectTimeRangeModeSheet extends StatelessWidget { @@ -52,6 +57,10 @@ class SelectTimeRangeModeSheet extends StatelessWidget { spacing: 12.0, runSpacing: 8.0, children: [ + ActionChip( + label: Text("tabs.stats.timeRange.last30days".t(context)), + onPressed: () => context.pop(TimeRangeMode.last30Days), + ), ActionChip( label: Text("tabs.stats.timeRange.thisWeek".t(context)), onPressed: () => context.pop(TimeRangeMode.thisWeek), diff --git a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart index 239e94f..06e5b36 100644 --- a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart +++ b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart @@ -2,6 +2,7 @@ import 'package:flow/data/transactions_filter.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; import 'package:flow/l10n/extensions.dart'; +import 'package:flow/widgets/utils/time_and_range.dart'; import 'package:flutter/material.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -73,6 +74,10 @@ class TransactionFilterChip extends StatelessWidget { } if (value case TimeRange timeRange) { + if (timeRange == last30Days()) { + return "tabs.stats.timeRange.last30days".t(context); + } + return timeRange.format(); } diff --git a/lib/widgets/utils/time_and_range.dart b/lib/widgets/utils/time_and_range.dart index 73db888..a1578fd 100644 --- a/lib/widgets/utils/time_and_range.dart +++ b/lib/widgets/utils/time_and_range.dart @@ -38,6 +38,7 @@ Future showTimeRangePickerSheet( if (!context.mounted) return null; return switch (mode) { + TimeRangeMode.last30Days => last30Days(), TimeRangeMode.thisWeek => TimeRange.thisLocalWeek(), TimeRangeMode.thisMonth => TimeRange.thisMonth(), TimeRangeMode.thisYear => TimeRange.thisYear(), @@ -63,3 +64,8 @@ Future showTimeRangePickerSheet( _ => null, // context.mounted == true }; } + +CustomTimeRange last30Days() => Moment.now() + .subtract(const Duration(days: 29)) + .startOfDay() + .rangeTo(Moment.endOfToday()); From d33337e5c252d1debadbf3c9494a9c31b92d014f Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 4 Aug 2024 21:11:07 +0800 Subject: [PATCH 08/28] organize stats tab graph --- lib/data/money_flow.dart | 8 + lib/routes/home/stats_tab.dart | 85 +++-- lib/routes/home/stats_tab/pie_graph_view.dart | 52 +++ lib/widgets/home/stats/group_pie_chart.dart | 284 +++++++-------- .../home/stats/group_pie_chart_old.dart | 334 ++++++++++++++++++ lib/widgets/home/stats/pie_percent_badge.dart | 59 ++++ 6 files changed, 629 insertions(+), 193 deletions(-) create mode 100644 lib/routes/home/stats_tab/pie_graph_view.dart create mode 100644 lib/widgets/home/stats/group_pie_chart_old.dart create mode 100644 lib/widgets/home/stats/pie_percent_badge.dart diff --git a/lib/data/money_flow.dart b/lib/data/money_flow.dart index 74f904f..aa33593 100644 --- a/lib/data/money_flow.dart +++ b/lib/data/money_flow.dart @@ -1,3 +1,5 @@ +import 'package:flow/entity/transaction.dart'; + class MoneyFlow implements Comparable { final T? associatedData; @@ -25,6 +27,12 @@ class MoneyFlow implements Comparable { amount.isNegative ? addExpense(amount) : addIncome(amount); void addAll(Iterable amounts) => amounts.forEach(add); + double getTotalByType(TransactionType type) => switch (type) { + TransactionType.expense => totalExpense, + TransactionType.income => totalIncome, + TransactionType.transfer => 0, + }; + operator +(MoneyFlow other) { return MoneyFlow( totalExpense: totalExpense + other.totalExpense, diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 720f534..b02fa61 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,16 +1,15 @@ import 'package:flow/data/flow_analytics.dart'; import 'package:flow/data/money_flow.dart'; -import 'package:flow/entity/category.dart'; +import 'package:flow/entity/transaction.dart'; import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/l10n/named_enum.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; +import 'package:flow/routes/home/stats_tab/pie_graph_view.dart'; import 'package:flow/widgets/general/spinner.dart'; -import 'package:flow/widgets/home/stats/group_pie_chart.dart'; -import 'package:flow/widgets/home/stats/no_data.dart'; import 'package:flow/widgets/time_range_selector.dart'; import 'package:flow/widgets/utils/time_and_range.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:moment_dart/moment_dart.dart'; class StatsTab extends StatefulWidget { @@ -20,7 +19,10 @@ class StatsTab extends StatefulWidget { State createState() => _StatsTabState(); } -class _StatsTabState extends State { +class _StatsTabState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + TimeRange range = TimeRange.thisMonth(); FlowAnalytics? analytics; @@ -31,12 +33,14 @@ class _StatsTabState extends State { void initState() { super.initState(); + _tabController = TabController(length: 2, vsync: this); + fetch(true); } @override Widget build(BuildContext context) { - final Map data = analytics == null + final Map expenses = analytics == null ? {} : Map.fromEntries( analytics!.flow.entries @@ -46,6 +50,16 @@ class _StatsTabState extends State { (a, b) => b.value.totalExpense.compareTo(a.value.totalExpense), ), ); + final Map incomes = analytics == null + ? {} + : Map.fromEntries( + analytics!.flow.entries + .where((element) => element.value.totalIncome > 0) + .toList() + ..sort( + (a, b) => a.value.totalIncome.compareTo(b.value.totalIncome), + ), + ); return Column( children: [ @@ -60,32 +74,39 @@ class _StatsTabState extends State { ), ), ), - busy - ? const Spinner() - : (data.isEmpty - ? Expanded( - child: NoData( - onTap: changeMode, - )) - : Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 96.0, top: 8.0), - child: GroupPieChart( - data: data, - unresolvedDataTitle: "category.none".t(context), - onReselect: (key) { - if (!data.containsKey(key)) return; - - final associatedData = data[key]!.associatedData; - - if (associatedData is Category) { - context.push( - "/category/${associatedData.id}?range=${Uri.encodeQueryComponent(range.toString())}"); - } - }, - ), - ), - )), + if (busy) + const Padding( + padding: EdgeInsets.all(24.0), + child: Spinner(), + ) + else ...[ + TabBar( + controller: _tabController, + tabs: [ + Tab(text: TransactionType.expense.localizedTextKey.t(context)), + Tab(text: TransactionType.income.localizedTextKey.t(context)), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + PieGraphView( + data: expenses, + changeMode: changeMode, + range: range, + type: TransactionType.expense, + ), + PieGraphView( + data: incomes, + changeMode: changeMode, + range: range, + type: TransactionType.income, + ), + ], + ), + ) + ], ], ); } diff --git a/lib/routes/home/stats_tab/pie_graph_view.dart b/lib/routes/home/stats_tab/pie_graph_view.dart new file mode 100644 index 0000000..1f68263 --- /dev/null +++ b/lib/routes/home/stats_tab/pie_graph_view.dart @@ -0,0 +1,52 @@ +import 'package:flow/data/money_flow.dart'; +import 'package:flow/entity/category.dart'; +import 'package:flow/entity/transaction.dart'; +import 'package:flow/l10n/extensions.dart'; +import 'package:flow/widgets/home/stats/group_pie_chart.dart'; +import 'package:flow/widgets/home/stats/no_data.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:moment_dart/moment_dart.dart'; + +class PieGraphView extends StatelessWidget { + final Map data; + final TimeRange range; + final void Function() changeMode; + final TransactionType type; + + const PieGraphView({ + super.key, + required this.data, + required this.range, + required this.changeMode, + required this.type, + }); + + @override + Widget build(BuildContext context) { + if (data.isEmpty) { + return NoData( + onTap: changeMode, + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 96.0, top: 8.0), + child: GroupPieChart( + type: type, + data: data, + unresolvedDataTitle: "category.none".t(context), + onReselect: (key) { + if (!data.containsKey(key)) return; + + final associatedData = data[key]!.associatedData; + + if (associatedData is Category) { + context.push( + "/category/${associatedData.id}?range=${Uri.encodeQueryComponent(range.toString())}"); + } + }, + ), + ); + } +} diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index 0f5cc2b..259d5e6 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -1,17 +1,17 @@ import 'dart:math' as math; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flow/data/flow_icon.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; -import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/entity/transaction.dart'; +import 'package:flow/l10n/extensions.dart'; import 'package:flow/main.dart'; import 'package:flow/theme/primary_colors.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/utils/utils.dart'; -import 'package:flow/widgets/general/flow_icon.dart'; -import 'package:flow/widgets/home/stats/legend_list_tile.dart'; +import 'package:flow/widgets/home/stats/pie_percent_badge.dart'; import 'package:flutter/material.dart' hide Flow; class GroupPieChart extends StatefulWidget { @@ -31,9 +31,15 @@ class GroupPieChart extends StatefulWidget { final void Function(String key)? onReselect; + final TransactionType type; + + static const double graphSizeMax = 320.0; + static const double graphHoleSizeMin = 96.0; + const GroupPieChart({ super.key, required this.data, + required this.type, this.chartPadding = const EdgeInsets.all(24.0), this.scrollPadding = EdgeInsets.zero, this.showLegend = true, @@ -52,9 +58,13 @@ class _GroupPieChartState extends State> { late Map> data; double get totalValue => data.values.fold( - 0, (previousValue, element) => previousValue + element.totalExpense); - - bool expense = true; + 0.0, + (previousValue, element) => + previousValue + + element.getTotalByType( + widget.type, + ), + ); String? selectedKey; @@ -77,158 +87,107 @@ class _GroupPieChartState extends State> { final MoneyFlow? selectedSection = selectedKey == null ? null : data[selectedKey!]; - final double selectedSectionProc = selectedSection == null - ? 0.0 - : (selectedSection.totalExpense / totalValue); + final String selectedSectionTotal = + selectedSection?.getTotalByType(widget.type).abs().money ?? "-"; return Column( mainAxisSize: MainAxisSize.min, children: [ - if (widget.showSelectedSection) ...[ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - selectedSection == null - ? "tabs.stats.chart.select.clickToSelect".t(context) - : resolveName(selectedSection.associatedData), - style: context.textTheme.headlineSmall, - ), - Text( - "${selectedSection?.totalExpense.abs().money ?? "-"} • ${(100 * selectedSectionProc).toStringAsFixed(1)}%"), - ], - ), - const SizedBox(height: 8.0), - ], + // Padding( padding: widget.chartPadding, child: ConstrainedBox( constraints: const BoxConstraints( - maxHeight: 300.0, - maxWidth: 300.0, + maxHeight: GroupPieChart.graphSizeMax, + maxWidth: GroupPieChart.graphSizeMax, ), child: AspectRatio( aspectRatio: 1.0, - child: LayoutBuilder(builder: (context, constraints) { - final double size = constraints.maxWidth; - - final double centerHoleDiameter = math.min(96.0, size * 0.25); - final double radius = (size - centerHoleDiameter) * 0.5; - - return PieChart( - PieChartData( - pieTouchData: - PieTouchData(touchCallback: (event, response) { - if (!event.isInterestedForInteractions || - response == null || - response.touchedSection == null) { - // setState(() { - // selectedKey = null; - // }); - return; - } - - final int index = - response.touchedSection!.touchedSectionIndex; - - if (index > -1) { - selectedKey = data.entries.elementAt(index).key; - setState(() {}); - } - }), - sectionsSpace: 2.0, - centerSpaceRadius: centerHoleDiameter / 2, - startDegreeOffset: -90.0, - sections: data.entries.indexed - .map( - (e) => sectionData( - data[e.$2.key]!, - selected: e.$2.key == selectedKey, - index: e.$1, - radius: radius, + child: LayoutBuilder( + builder: (context, constraints) { + final double size = constraints.maxWidth; + + final double centerHoleDiameter = + math.max(size * 0.5, GroupPieChart.graphHoleSizeMin); + final double radius = (size - centerHoleDiameter) * 0.5; + + return Stack( + children: [ + PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: (event, response) { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + return; + } + + final int index = + response.touchedSection!.touchedSectionIndex; + + if (index > -1) { + selectedKey = data.entries.elementAt(index).key; + setState(() {}); + } + }, + ), + sectionsSpace: 0.0, + centerSpaceRadius: centerHoleDiameter / 2, + startDegreeOffset: -90.0, + sections: data.entries.indexed + .map( + (e) => sectionData( + data[e.$2.key]!, + selected: e.$2.key == selectedKey, + index: e.$1, + radius: radius, + ), + ) + .toList(), + ), + ), + Positioned.fill( + child: Center( + child: ClipOval( + child: Container( + width: centerHoleDiameter, + height: centerHoleDiameter, + alignment: Alignment.center, + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + resolveName( + selectedSection?.associatedData, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + AutoSizeText( + selectedSectionTotal, + textAlign: TextAlign.center, + style: context.textTheme.headlineSmall, + ), + ], + ), + ), ), - ) - .toList(), - ), - ); - }), + ), + ) + ], + ); + }, + ), ), ), ), - if (widget.showLegend) buildLegend(context), ], ); } - Widget buildLegendItem( - BuildContext context, - int index, - MapEntry> entry, - ) { - final bool usingDarkTheme = Flow.of(context).useDarkTheme; - - final Color color = (usingDarkTheme - ? accentColors - : primaryColors)[index % primaryColors.length]; - final Color backgroundColor = (usingDarkTheme - ? primaryColors - : accentColors)[index % primaryColors.length]; - - return LegendListTile( - key: ValueKey(entry.key), - color: color, - leading: resolveBadgeWidget( - entry.value.associatedData, - color: color, - backgroundColor: backgroundColor, - ), - title: Text(resolveName(entry.value.associatedData)), - subtitle: Text((entry.value.totalExpense / totalValue).percent1), - trailing: Text( - entry.value.totalExpense.moneyCompact, - style: context.textTheme.bodyLarge, - ), - selected: entry.key == selectedKey, - onTap: () { - if (widget.onReselect != null && - selectedKey != null && - selectedKey == entry.key) { - widget.onReselect!(selectedKey!); - } else { - setState(() => selectedKey = entry.key); - } - }, - ); - } - - Widget buildLegend(BuildContext context) { - final indexed = data.entries.toList().indexed.toList(); - if (widget.sortLegend) { - indexed.sort( - (a, b) => a.$2.value.totalExpense.compareTo( - b.$2.value.totalExpense, - ), - ); - } - - if (widget.scrollLegendWithin) { - return Expanded( - child: ListView.builder( - itemBuilder: (context, index) => - buildLegendItem(context, indexed[index].$1, indexed[index].$2), - itemCount: indexed.length, - padding: widget.scrollPadding, - ), - ); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: - indexed.map((e) => buildLegendItem(context, e.$1, e.$2)).toList(), - ); - } - PieChartSectionData sectionData( MoneyFlow flow, { required double radius, @@ -247,7 +206,7 @@ class _GroupPieChartState extends State> { return PieChartSectionData( color: color, radius: radius, - value: flow.totalExpense.abs(), + value: flow.getTotalByType(widget.type).abs(), title: resolveName(flow.associatedData), showTitle: false, badgeWidget: selected @@ -255,45 +214,48 @@ class _GroupPieChartState extends State> { flow.associatedData, color: color, backgroundColor: backgroundColor, + percent: flow.getTotalByType(widget.type) / totalValue, ) : null, badgePositionPercentageOffset: 0.8, borderSide: selected ? BorderSide( - color: context.colorScheme.primary, - width: 2.0, - strokeAlign: BorderSide.strokeAlignInside, + color: backgroundColor, + width: 3.0, ) - : BorderSide.none, + : null, ); } String resolveName(Object? entity) => switch (entity) { Category category => category.name, Account account => account.name, - _ => widget.unresolvedDataTitle ?? "???" + _ => widget.unresolvedDataTitle ?? "-" }; - Widget? resolveBadgeWidget(Object? entity, - {Color? color, Color? backgroundColor}) => + Widget? resolveBadgeWidget( + Object? entity, { + Color? color, + Color? backgroundColor, + required double percent, + }) => switch (entity) { - Category category => FlowIcon( - category.icon, - plated: true, + Category category => PiePercentBadge( + icon: category.icon, color: color, - plateColor: backgroundColor ?? color?.withAlpha(0x40), + backgroundColor: backgroundColor ?? color?.withAlpha(0x40), + percent: percent, ), - Account account => FlowIcon( - account.icon, - plated: true, + Account account => PiePercentBadge( + icon: account.icon, color: color, - plateColor: backgroundColor ?? color?.withAlpha(0x40), + backgroundColor: backgroundColor ?? color?.withAlpha(0x40), + percent: percent, ), - _ => FlowIcon( - FlowIconData.emoji("?"), - plated: true, + _ => PiePercentBadge( + icon: FlowIconData.emoji("?"), color: color, - plateColor: backgroundColor ?? color?.withAlpha(0x40), - ), + backgroundColor: backgroundColor ?? color?.withAlpha(0x40), + percent: percent), }; } diff --git a/lib/widgets/home/stats/group_pie_chart_old.dart b/lib/widgets/home/stats/group_pie_chart_old.dart new file mode 100644 index 0000000..53c587b --- /dev/null +++ b/lib/widgets/home/stats/group_pie_chart_old.dart @@ -0,0 +1,334 @@ +import 'dart:math' as math; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flow/data/flow_icon.dart'; +import 'package:flow/data/money_flow.dart'; +import 'package:flow/entity/account.dart'; +import 'package:flow/entity/category.dart'; +import 'package:flow/entity/transaction.dart'; +import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/main.dart'; +import 'package:flow/theme/primary_colors.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/utils/utils.dart'; +import 'package:flow/widgets/general/flow_icon.dart'; +import 'package:flow/widgets/home/stats/legend_list_tile.dart'; +import 'package:flutter/material.dart' hide Flow; + +class GroupPieChart extends StatefulWidget { + final EdgeInsets chartPadding; + + final bool showSelectedSection; + + final bool showLegend; + final bool sortLegend; + + final bool scrollLegendWithin; + final EdgeInsets scrollPadding; + + final Map> data; + + final String? unresolvedDataTitle; + + final void Function(String key)? onReselect; + + final TransactionType type; + + const GroupPieChart({ + super.key, + required this.data, + required this.type, + this.chartPadding = const EdgeInsets.all(24.0), + this.scrollPadding = EdgeInsets.zero, + this.showLegend = true, + this.scrollLegendWithin = false, + this.showSelectedSection = true, + this.sortLegend = true, + this.unresolvedDataTitle, + this.onReselect, + }); + + @override + State> createState() => _GroupPieChartState(); +} + +class _GroupPieChartState extends State> { + late Map> data; + + double get totalValue => switch (widget.type) { + TransactionType.expense => data.values.fold(0.0, + (previousValue, element) => previousValue + element.totalExpense), + TransactionType.income => data.values.fold(0.0, + (previousValue, element) => previousValue + element.totalIncome), + _ => 0.0 + }; + + bool expense = true; + + String? selectedKey; + + @override + void initState() { + super.initState(); + + data = widget.data; + } + + @override + void didUpdateWidget(GroupPieChart oldWidget) { + data = widget.data; + + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + final MoneyFlow? selectedSection = + selectedKey == null ? null : data[selectedKey!]; + + // final double selectedSectionProc = selectedSection == null + // ? 0.0 + // : (selectedSection.totalExpense / totalValue); + + final double selectedSectionProc = switch (widget.type) { + TransactionType.expense when selectedSection != null => + selectedSection.totalExpense / totalValue, + TransactionType.income when selectedSection != null => + selectedSection.totalIncome / totalValue, + _ => 0.0 + }; + + final String selectedSectionTotal = switch (widget.type) { + TransactionType.expense when selectedSection != null => + selectedSection.totalExpense.abs().money, + TransactionType.income when selectedSection != null => + selectedSection.totalIncome.abs().money, + _ => "-" + }; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showSelectedSection) ...[ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + selectedSection == null + ? "tabs.stats.chart.select.clickToSelect".t(context) + : resolveName(selectedSection.associatedData), + style: context.textTheme.headlineSmall, + ), + Text( + "$selectedSectionTotal • ${(100 * selectedSectionProc).toStringAsFixed(1)}%"), + ], + ), + const SizedBox(height: 8.0), + ], + Padding( + padding: widget.chartPadding, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 300.0, + maxWidth: 300.0, + ), + child: AspectRatio( + aspectRatio: 1.0, + child: LayoutBuilder(builder: (context, constraints) { + final double size = constraints.maxWidth; + + final double centerHoleDiameter = math.min(96.0, size * 0.25); + final double radius = (size - centerHoleDiameter) * 0.5; + + return PieChart( + PieChartData( + pieTouchData: + PieTouchData(touchCallback: (event, response) { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + // setState(() { + // selectedKey = null; + // }); + return; + } + + final int index = + response.touchedSection!.touchedSectionIndex; + + if (index > -1) { + selectedKey = data.entries.elementAt(index).key; + setState(() {}); + } + }), + sectionsSpace: 2.0, + centerSpaceRadius: centerHoleDiameter / 2, + startDegreeOffset: -90.0, + sections: data.entries.indexed + .map( + (e) => sectionData( + data[e.$2.key]!, + selected: e.$2.key == selectedKey, + index: e.$1, + radius: radius, + ), + ) + .toList(), + ), + ); + }), + ), + ), + ), + if (widget.showLegend) buildLegend(context), + ], + ); + } + + Widget buildLegendItem( + BuildContext context, + int index, + MapEntry> entry, + ) { + final bool usingDarkTheme = Flow.of(context).useDarkTheme; + + final Color color = (usingDarkTheme + ? accentColors + : primaryColors)[index % primaryColors.length]; + final Color backgroundColor = (usingDarkTheme + ? primaryColors + : accentColors)[index % primaryColors.length]; + + return LegendListTile( + key: ValueKey(entry.key), + color: color, + leading: resolveBadgeWidget( + entry.value.associatedData, + color: color, + backgroundColor: backgroundColor, + ), + title: Text(resolveName(entry.value.associatedData)), + subtitle: Text((entry.value.totalExpense / totalValue).percent1), + trailing: Text( + entry.value.totalExpense.moneyCompact, + style: context.textTheme.bodyLarge, + ), + selected: entry.key == selectedKey, + onTap: () { + if (widget.onReselect != null && + selectedKey != null && + selectedKey == entry.key) { + widget.onReselect!(selectedKey!); + } else { + setState(() => selectedKey = entry.key); + } + }, + ); + } + + Widget buildLegend(BuildContext context) { + final indexed = data.entries.toList().indexed.toList(); + if (widget.sortLegend) { + if (widget.type == TransactionType.expense) { + indexed.sort( + (a, b) => a.$2.value.totalExpense.compareTo( + b.$2.value.totalExpense, + ), + ); + } else if (widget.type == TransactionType.income) { + indexed.sort( + (a, b) => b.$2.value.totalIncome.compareTo( + a.$2.value.totalIncome, + ), + ); + } + } + + if (widget.scrollLegendWithin) { + return Expanded( + child: ListView.builder( + itemBuilder: (context, index) => + buildLegendItem(context, indexed[index].$1, indexed[index].$2), + itemCount: indexed.length, + padding: widget.scrollPadding, + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: + indexed.map((e) => buildLegendItem(context, e.$1, e.$2)).toList(), + ); + } + + PieChartSectionData sectionData( + MoneyFlow flow, { + required double radius, + bool selected = false, + int index = 0, + }) { + final bool usingDarkTheme = Flow.of(context).useDarkTheme; + + final Color color = (usingDarkTheme + ? accentColors + : primaryColors)[index % primaryColors.length]; + final Color backgroundColor = (usingDarkTheme + ? primaryColors + : accentColors)[index % primaryColors.length]; + + return PieChartSectionData( + color: color, + radius: radius, + value: widget.type == TransactionType.expense + ? flow.totalExpense.abs() + : flow.totalIncome, + title: resolveName(flow.associatedData), + showTitle: false, + badgeWidget: selected + ? resolveBadgeWidget( + flow.associatedData, + color: color, + backgroundColor: backgroundColor, + ) + : null, + badgePositionPercentageOffset: 0.8, + borderSide: selected + ? BorderSide( + color: context.colorScheme.primary, + width: 2.0, + strokeAlign: BorderSide.strokeAlignInside, + ) + : BorderSide.none, + ); + } + + String resolveName(Object? entity) => switch (entity) { + Category category => category.name, + Account account => account.name, + _ => widget.unresolvedDataTitle ?? "???" + }; + + Widget? resolveBadgeWidget(Object? entity, + {Color? color, Color? backgroundColor}) => + switch (entity) { + Category category => FlowIcon( + category.icon, + plated: true, + color: color, + plateColor: backgroundColor ?? color?.withAlpha(0x40), + ), + Account account => FlowIcon( + account.icon, + plated: true, + color: color, + plateColor: backgroundColor ?? color?.withAlpha(0x40), + ), + _ => FlowIcon( + FlowIconData.emoji("?"), + plated: true, + color: color, + plateColor: backgroundColor ?? color?.withAlpha(0x40), + ), + }; +} diff --git a/lib/widgets/home/stats/pie_percent_badge.dart b/lib/widgets/home/stats/pie_percent_badge.dart new file mode 100644 index 0000000..0404caf --- /dev/null +++ b/lib/widgets/home/stats/pie_percent_badge.dart @@ -0,0 +1,59 @@ +import 'package:flow/data/flow_icon.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/general/flow_icon.dart'; +import 'package:flutter/material.dart'; + +class PiePercentBadge extends StatelessWidget { + final FlowIconData icon; + + /// Typically, value from 0.0 to 1.0 + /// + /// e.g., 0.67 for 67% + final double percent; + + final Color? color; + final Color? backgroundColor; + + final BorderRadius borderRadius; + + final EdgeInsets padding; + + const PiePercentBadge({ + super.key, + required this.icon, + required this.percent, + this.color, + this.backgroundColor, + this.borderRadius = const BorderRadius.all(Radius.circular(16.0)), + this.padding = const EdgeInsets.all(8.0), + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: borderRadius, + color: backgroundColor, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowIcon( + icon, + size: 24.0, + color: color, + ), + const SizedBox(width: 4.0), + Text( + "${(100 * percent).toStringAsFixed(1)}%", + style: context.textTheme.bodyMedium!.copyWith( + color: color, + ), + ) + ], + ), + ); + } +} From 94e092c8afd0bfd840ebbac8aaa7c6507741d566 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 4 Aug 2024 21:47:43 +0800 Subject: [PATCH 09/28] mouse wheel scroll --- CHANGELOG.md | 2 + lib/theme/theme.dart | 6 ++ lib/widgets/time_range_selector.dart | 87 ++++++++++++++++------------ 3 files changed, 58 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1407cdf..c15afda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Updated theme to correct `activeColor` for radio/checkboxes and its lists * Added filters to home tab, and added preferences for home page * Added error builder for Image `FlowIcon`s when the image is missing +* Made Stats tab better, hopefully... +* TimeRange selector now listens for mouse wheel scroll ## Beta 0.5.5 diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 43e2235..07184e0 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -115,6 +115,9 @@ final lightTheme = ThemeData( cursorColor: _light.primary, selectionHandleColor: _light.primary, ), + tabBarTheme: TabBarTheme( + dividerColor: _light.primary, + ), ); final darkTheme = ThemeData( useMaterial3: true, @@ -212,4 +215,7 @@ final darkTheme = ThemeData( cursorColor: _dark.primary, selectionHandleColor: _dark.primary, ), + tabBarTheme: TabBarTheme( + dividerColor: _dark.primary, + ), ); diff --git a/lib/widgets/time_range_selector.dart b/lib/widgets/time_range_selector.dart index bb65322..5c6b363 100644 --- a/lib/widgets/time_range_selector.dart +++ b/lib/widgets/time_range_selector.dart @@ -1,6 +1,7 @@ import 'package:flow/l10n/flow_localizations.dart'; import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/utils/time_and_range.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -68,53 +69,65 @@ class _TimeRangeSelectorState extends State { const SizedBox(width: 12.0), ], Expanded( - child: GestureDetector( - onHorizontalDragEnd: (details) { - final double? velocity = details.primaryVelocity; - if (velocity == null) return; + child: Listener( + onPointerSignal: (event) { if (_timeRange is! PageableRange) return; + if (event is! PointerScrollEvent) return; - if (velocity <= -_dragThreshold) { - next(); - } else if (velocity >= _dragThreshold) { + if (event.scrollDelta.dy < 0) { prev(); + } else if (event.scrollDelta.dy > 0) { + next(); } }, - child: switch (_timeRange) { - LocalWeekTimeRange localWeekTimeRange => Button( - onTap: selectRange, - child: Text( - "${localWeekTimeRange.from.toMoment().ll} -> ${localWeekTimeRange.to.toMoment().ll}", - textAlign: TextAlign.center, + child: GestureDetector( + onHorizontalDragEnd: (details) { + final double? velocity = details.primaryVelocity; + if (velocity == null) return; + if (_timeRange is! PageableRange) return; + + if (velocity <= -_dragThreshold) { + next(); + } else if (velocity >= _dragThreshold) { + prev(); + } + }, + child: switch (_timeRange) { + LocalWeekTimeRange localWeekTimeRange => Button( + onTap: selectRange, + child: Text( + "${localWeekTimeRange.from.toMoment().ll} -> ${localWeekTimeRange.to.toMoment().ll}", + textAlign: TextAlign.center, + ), ), - ), - MonthTimeRange monthTimeRange => Button( - onTap: pickMonth, - child: Text( - monthTimeRange.from.format( - payload: - monthTimeRange.from.isAtSameYearAs(DateTime.now()) - ? "MMMM" - : "MMMM YYYY", + MonthTimeRange monthTimeRange => Button( + onTap: pickMonth, + child: Text( + monthTimeRange.from.format( + payload: monthTimeRange.from + .isAtSameYearAs(DateTime.now()) + ? "MMMM" + : "MMMM YYYY", + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - YearTimeRange yearTimeRange => Button( - onTap: selectRange, - child: Text( - yearTimeRange.year.toString(), - textAlign: TextAlign.center, + YearTimeRange yearTimeRange => Button( + onTap: selectRange, + child: Text( + yearTimeRange.year.toString(), + textAlign: TextAlign.center, + ), ), - ), - _ => Button( - onTap: pickRange, - child: Text( - "${_timeRange.from.toMoment().ll} -> ${_timeRange.to.toMoment().ll}", - textAlign: TextAlign.center, + _ => Button( + onTap: pickRange, + child: Text( + "${_timeRange.from.toMoment().ll} -> ${_timeRange.to.toMoment().ll}", + textAlign: TextAlign.center, + ), ), - ), - }, + }, + ), ), ), if (buildNextPrev) ...[ From afdf0aa987e1fed114b26d937a92848007a8b2c5 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 4 Aug 2024 23:31:44 +0800 Subject: [PATCH 10/28] add exchange rates api, use unawaited where necessary --- analysis_options.yaml | 2 +- assets/l10n/en_US.json | 1 + assets/l10n/it_IT.json | 1 + assets/l10n/mn_MN.json | 1 + lib/data/exchange_rates.dart | 87 ++++++++ lib/main.dart | 12 +- lib/prefs.dart | 21 +- lib/routes/account/account_edit_page.dart | 41 ++-- lib/routes/category/category_edit_page.dart | 11 +- lib/routes/preferences_page.dart | 6 +- lib/routes/setup/setup_accounts_page.dart | 2 +- lib/routes/setup/setup_profile_page.dart | 5 +- lib/sync/export.dart | 27 ++- lib/widgets/home/stats/group_pie_chart.dart | 215 +++++++++++--------- lib/widgets/home/test.dart | 15 -- pubspec.lock | 2 +- pubspec.yaml | 1 + 17 files changed, 288 insertions(+), 162 deletions(-) create mode 100644 lib/data/exchange_rates.dart delete mode 100644 lib/widgets/home/test.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..d51a46d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -21,8 +21,8 @@ linter: # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: + unawaited_futures: true # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 8b05f35..2297c70 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -191,6 +191,7 @@ "tabs.stats.timeRange.mode.byWeek": "By week", "tabs.stats.timeRange.mode.byMonth": "By month", "tabs.stats.timeRange.mode.byYear": "By year", + "tabs.stats.chart.total": "Total", "tabs.stats.chart.noData": "No data to show", "tabs.stats.chart.select.clickToSelect": "Click to select", "tabs.accounts": "Accounts", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index c6b892e..b3b6fa0 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -191,6 +191,7 @@ "tabs.stats.timeRange.mode.byWeek": "Per settimana", "tabs.stats.timeRange.mode.byMonth": "Per mese", "tabs.stats.timeRange.mode.byYear": "Per anno", + "tabs.stats.chart.total": "Totale", "tabs.stats.chart.noData": "Nessun dato da mostrare", "tabs.stats.chart.select.clickToSelect": "Clicca per selezionare", "tabs.accounts": "Conti", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 124add9..9f6292c 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -191,6 +191,7 @@ "tabs.stats.timeRange.mode.byWeek": "Долоо хоногоор", "tabs.stats.timeRange.mode.byMonth": "Сараар", "tabs.stats.timeRange.mode.byYear": "Жилээр", + "tabs.stats.chart.total": "Нийт", "tabs.stats.chart.noData": "Харуулах өгөгдөл байхгүй байна", "tabs.stats.chart.select.clickToSelect": "Товшиж сонгоно уу", "tabs.accounts": "Данснууд", diff --git a/lib/data/exchange_rates.dart b/lib/data/exchange_rates.dart new file mode 100644 index 0000000..b496803 --- /dev/null +++ b/lib/data/exchange_rates.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flow/data/currencies.dart'; +import 'package:http/http.dart' as http; +import 'package:moment_dart/moment_dart.dart'; + +/// Uses endpoints from here: +class ExchangeRates { + final DateTime date; + final String baseCurrency; + final Map rates; + + const ExchangeRates({ + required this.date, + required this.baseCurrency, + required this.rates, + }); + + factory ExchangeRates.fromJson( + String baseCurrency, + Map json, + ) { + return ExchangeRates( + date: DateTime.parse(json['date']), + baseCurrency: baseCurrency, + rates: Map.from(json[baseCurrency.toLowerCase()]), + ); + } + + static final Map _cache = {}; + + static Future fetchRates( + String baseCurrency, [ + DateTime? dateTime, + ]) async { + final String normalizedCurrency = baseCurrency.trim().toLowerCase(); + + if (!iso4217Currencies + .any((currency) => currency.code.toLowerCase() == normalizedCurrency)) { + throw Exception("Invalid currency code: $baseCurrency"); + } + + final String dateParam = + dateTime == null ? "latest" : dateTime.format(payload: "yyyy-MM-dd"); + + Map? jsonResponse; + + try { + final response = await http.get(Uri.parse( + "https://$dateParam.currency-api.pages.dev/v1/currencies/$normalizedCurrency.json")); + jsonResponse = jsonDecode(response.body); + } catch (e) { + log("Failed to fetch exchange rates from side source", error: e); + } + + try { + final response = await http.get(Uri.parse( + "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@$dateParam/v1/currencies/$normalizedCurrency.json")); + jsonResponse = jsonDecode(response.body); + } catch (e) { + log("Failed to fetch exchange rates from main source", error: e); + } + + if (jsonResponse == null) { + throw Exception("Failed to fetch exchange rates"); + } + + final exchangeRates = + ExchangeRates.fromJson(normalizedCurrency, jsonResponse); + _cache[baseCurrency] = exchangeRates; + return exchangeRates; + } + + static Future tryFetchRates( + String baseCurrency, [ + DateTime? dateTime, + ]) async { + try { + final ExchangeRates exchangeRates = + await fetchRates(baseCurrency, dateTime); + return exchangeRates; + } catch (e) { + return _cache[baseCurrency]; + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 831b761..26a987f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,10 +15,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'package:flow/constants.dart'; +import 'package:flow/data/exchange_rates.dart'; import 'package:flow/entity/profile.dart'; import 'package:flow/entity/transaction.dart'; import 'package:flow/l10n/flow_localizations.dart'; @@ -39,13 +41,13 @@ void main() async { const String debugBuildSuffix = debugBuild ? " (dev)" : ""; - PackageInfo.fromPlatform() + unawaited(PackageInfo.fromPlatform() .then((value) => appVersion = "${value.version}+${value.buildNumber}$debugBuildSuffix") .catchError((e) { log("An error was occured while fetching app version: $e"); return appVersion = "+<0>$debugBuildSuffix"; - }); + })); if (flowDebugMode) { FlowLocalizations.printMissingKeys(); @@ -103,6 +105,12 @@ class FlowState extends State { if (ObjectBox().box().count(limit: 1) == 0) { Profile.createDefaultProfile(); } + + unawaited( + ExchangeRates.tryFetchRates( + LocalPreferences().getPrimaryCurrency(), + ), + ); } @override diff --git a/lib/prefs.dart b/lib/prefs.dart index 81ac37a..b5ec0df 100644 --- a/lib/prefs.dart +++ b/lib/prefs.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:developer'; @@ -143,8 +144,8 @@ class LocalPreferences { } if (transitiveLastTimeFrecencyUpdated.get() == null) { - _reevaluateCategoryFrecency(); - _reevaluateAccountFrecency(); + unawaited(_reevaluateCategoryFrecency()); + unawaited(_reevaluateAccountFrecency()); } } @@ -213,15 +214,17 @@ class LocalPreferences { categoryTransactionsQuery.close(); - // Future - setFrecencyData( + unawaited( + setFrecencyData( "category", category.uuid, FrecencyData( uuid: category.uuid, lastUsed: lastUsed, useCount: useCount, - )); + ), + ), + ); } catch (e) { log("Failed to build category FrecencyData for $category due to: $e"); } @@ -253,15 +256,17 @@ class LocalPreferences { accountTransactionsQuery.close(); - // Future - setFrecencyData( + unawaited( + setFrecencyData( "account", account.uuid, FrecencyData( uuid: account.uuid, lastUsed: lastUsed, useCount: useCount, - )); + ), + ), + ); } catch (e) { log("Failed to build account FrecencyData for $account due to: $e"); } diff --git a/lib/routes/account/account_edit_page.dart b/lib/routes/account/account_edit_page.dart index a22f581..cf8c3f9 100644 --- a/lib/routes/account/account_edit_page.dart +++ b/lib/routes/account/account_edit_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'package:flow/data/flow_icon.dart'; @@ -337,24 +338,30 @@ class _AccountEditPageState extends State { ); if (_balance.abs() != 0) { - ObjectBox() - .box() - .putAndGetAsync( - account, - mode: PutMode.insert, - ) - .then((value) { - value.updateBalanceAndSave( - _balance, - title: "account.updateBalance.transactionTitle".t(context), - ); - ObjectBox().box().putAsync(value); - }); + unawaited( + ObjectBox() + .box() + .putAndGetAsync( + account, + mode: PutMode.insert, + ) + .then( + (value) { + value.updateBalanceAndSave( + _balance, + title: "account.updateBalance.transactionTitle".t(context), + ); + ObjectBox().box().putAsync(value); + }, + ), + ); } else { - ObjectBox().box().putAsync( - account, - mode: PutMode.insert, - ); + unawaited( + ObjectBox().box().putAsync( + account, + mode: PutMode.insert, + ), + ); } context.pop(); diff --git a/lib/routes/category/category_edit_page.dart b/lib/routes/category/category_edit_page.dart index 3b86046..ea0d7fa 100644 --- a/lib/routes/category/category_edit_page.dart +++ b/lib/routes/category/category_edit_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'package:flow/data/flow_icon.dart'; @@ -166,10 +167,12 @@ class _CategoryEditPageState extends State { iconCode: iconCodeOrError, ); - ObjectBox().box().putAsync( - category, - mode: PutMode.insert, - ); + unawaited( + ObjectBox().box().putAsync( + category, + mode: PutMode.insert, + ), + ); context.pop(); } diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart index f47c9e2..2e8134a 100644 --- a/lib/routes/preferences_page.dart +++ b/lib/routes/preferences_page.dart @@ -151,19 +151,19 @@ class _PreferencesPageState extends State { void updateLanguage() async { if (Platform.isIOS) { - LocalPreferences().localeOverride.remove().catchError((e) { + await LocalPreferences().localeOverride.remove().catchError((e) { log("[PreferencesPage] failed to remove locale override: $e"); return false; }); try { - AppSettings.openAppSettings(type: AppSettingsType.appLocale); + await AppSettings.openAppSettings(type: AppSettingsType.appLocale); return; } catch (e) { log("[PreferencesPage] failed to open system app settings on iOS: $e"); } } - if (_languageBusy) return; + if (_languageBusy || !mounted) return; setState(() { _languageBusy = true; diff --git a/lib/routes/setup/setup_accounts_page.dart b/lib/routes/setup/setup_accounts_page.dart index fabf872..7cdd4df 100644 --- a/lib/routes/setup/setup_accounts_page.dart +++ b/lib/routes/setup/setup_accounts_page.dart @@ -171,7 +171,7 @@ class _SetupAccountsPageState extends State { -1); if (mounted) { - context.push("/setup/categories"); + await context.push("/setup/categories"); } } finally { busy = false; diff --git a/lib/routes/setup/setup_profile_page.dart b/lib/routes/setup/setup_profile_page.dart index 12df37b..e066cbc 100644 --- a/lib/routes/setup/setup_profile_page.dart +++ b/lib/routes/setup/setup_profile_page.dart @@ -98,7 +98,10 @@ class _SetupProfilePageState extends State { await ObjectBox().box().putAndGetAsync(_currentlyEditing!); if (mounted) { - context.push('/setup/profile/photo', extra: updatedProfile.imagePath); + await context.push( + '/setup/profile/photo', + extra: updatedProfile.imagePath, + ); } } finally { busy = false; diff --git a/lib/sync/export.dart b/lib/sync/export.dart index e08ba2a..3795d19 100644 --- a/lib/sync/export.dart +++ b/lib/sync/export.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'dart:math' as math; @@ -45,17 +46,21 @@ Future export({ ); // Try to add backup record - ObjectBox() - .box() - .putAsync(BackupEntry( - filePath: savedFilePath, - type: type.value, - fileExt: mode.fileExt, - )) - .catchError((error) { - log("[Export] Failed to add BackupEntry due to: $error"); - return -1; - }); + unawaited( + ObjectBox() + .box() + .putAsync(BackupEntry( + filePath: savedFilePath, + type: type.value, + fileExt: mode.fileExt, + )) + .catchError( + (error) { + log("[Export] Failed to add BackupEntry due to: $error"); + return -1; + }, + ), + ); if (!showShareDialog) { return (shareDialogSucceeded: false, filePath: savedFilePath); diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index 259d5e6..3bbbc58 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -12,18 +12,13 @@ import 'package:flow/main.dart'; import 'package:flow/theme/primary_colors.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/home/stats/pie_percent_badge.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Flow; class GroupPieChart extends StatefulWidget { final EdgeInsets chartPadding; - final bool showSelectedSection; - - final bool showLegend; - final bool sortLegend; - final bool scrollLegendWithin; - final EdgeInsets scrollPadding; final Map> data; @@ -41,11 +36,7 @@ class GroupPieChart extends StatefulWidget { required this.data, required this.type, this.chartPadding = const EdgeInsets.all(24.0), - this.scrollPadding = EdgeInsets.zero, - this.showLegend = true, this.scrollLegendWithin = false, - this.showSelectedSection = true, - this.sortLegend = true, this.unresolvedDataTitle, this.onReselect, }); @@ -68,6 +59,8 @@ class _GroupPieChartState extends State> { String? selectedKey; + bool usingMouse = false; + @override void initState() { super.initState(); @@ -88,103 +81,129 @@ class _GroupPieChartState extends State> { selectedKey == null ? null : data[selectedKey!]; final String selectedSectionTotal = - selectedSection?.getTotalByType(widget.type).abs().money ?? "-"; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // - Padding( - padding: widget.chartPadding, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: GroupPieChart.graphSizeMax, - maxWidth: GroupPieChart.graphSizeMax, - ), - child: AspectRatio( - aspectRatio: 1.0, - child: LayoutBuilder( - builder: (context, constraints) { - final double size = constraints.maxWidth; - - final double centerHoleDiameter = - math.max(size * 0.5, GroupPieChart.graphHoleSizeMin); - final double radius = (size - centerHoleDiameter) * 0.5; - - return Stack( - children: [ - PieChart( - PieChartData( - pieTouchData: PieTouchData( - touchCallback: (event, response) { - if (!event.isInterestedForInteractions || - response == null || - response.touchedSection == null) { - return; - } - - final int index = - response.touchedSection!.touchedSectionIndex; - - if (index > -1) { - selectedKey = data.entries.elementAt(index).key; - setState(() {}); - } - }, + selectedSection?.getTotalByType(widget.type).abs().formatMoney() ?? "-"; + + return MouseRegion( + onHover: (event) { + if (event.kind == PointerDeviceKind.mouse) { + setState(() { + usingMouse = true; + }); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16.0), + Text( + "tabs.stats.chart.total".t(context), + style: context.textTheme.labelMedium, + ), + Text( + totalValue.formatMoney(), + style: context.textTheme.headlineMedium, + ), + Padding( + padding: widget.chartPadding, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: GroupPieChart.graphSizeMax, + maxWidth: GroupPieChart.graphSizeMax, + ), + child: AspectRatio( + aspectRatio: 1.0, + child: LayoutBuilder( + builder: (context, constraints) { + final double size = constraints.maxWidth; + + final double centerHoleDiameter = + math.max(size * 0.5, GroupPieChart.graphHoleSizeMin); + final double radius = (size - centerHoleDiameter) * 0.5; + + return Stack( + children: [ + PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: (event, response) { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + return; + } + + final int index = response + .touchedSection!.touchedSectionIndex; + + if (index > -1) { + final String newSelectedKey = + data.entries.elementAt(index).key; + + if (!usingMouse && + newSelectedKey == selectedKey) { + widget.onReselect?.call(newSelectedKey); + } + + setState(() { + selectedKey = newSelectedKey; + }); + } + }, + ), + sectionsSpace: 0.0, + centerSpaceRadius: centerHoleDiameter / 2, + startDegreeOffset: -90.0, + sections: data.entries.indexed + .map( + (e) => sectionData( + data[e.$2.key]!, + selected: e.$2.key == selectedKey, + index: e.$1, + radius: radius, + ), + ) + .toList(), ), - sectionsSpace: 0.0, - centerSpaceRadius: centerHoleDiameter / 2, - startDegreeOffset: -90.0, - sections: data.entries.indexed - .map( - (e) => sectionData( - data[e.$2.key]!, - selected: e.$2.key == selectedKey, - index: e.$1, - radius: radius, - ), - ) - .toList(), ), - ), - Positioned.fill( - child: Center( - child: ClipOval( - child: Container( - width: centerHoleDiameter, - height: centerHoleDiameter, - alignment: Alignment.center, - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - resolveName( - selectedSection?.associatedData, + Positioned.fill( + child: Center( + child: ClipOval( + child: Container( + width: centerHoleDiameter, + height: centerHoleDiameter, + alignment: Alignment.center, + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + resolveName( + selectedSection?.associatedData, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - AutoSizeText( - selectedSectionTotal, - textAlign: TextAlign.center, - style: context.textTheme.headlineSmall, - ), - ], + AutoSizeText( + selectedSectionTotal, + textAlign: TextAlign.center, + style: context.textTheme.headlineSmall, + ), + ], + ), ), ), ), - ), - ) - ], - ); - }, + ) + ], + ); + }, + ), ), ), ), - ), - ], + ], + ), ); } diff --git a/lib/widgets/home/test.dart b/lib/widgets/home/test.dart deleted file mode 100644 index 8f2c43d..0000000 --- a/lib/widgets/home/test.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -class Test extends StatefulWidget { - const Test({super.key}); - - @override - State createState() => _TestState(); -} - -class _TestState extends State { - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} diff --git a/pubspec.lock b/pubspec.lock index 2f94cc4..92a722e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -542,7 +542,7 @@ packages: source: hosted version: "2.3.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 diff --git a/pubspec.yaml b/pubspec.yaml index 5f156d3..413e6e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: flutter_typeahead: ^5.2.0 fuzzywuzzy: ^1.1.6 go_router: ^14.2.1 + http: ^1.2.2 image_picker: ^1.1.1 intl: ^0.19.0 json_annotation: ^4.9.0 From 89c44c51f744348c8cc555a8a1488d40d33b0abd Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 5 Aug 2024 00:06:32 +0800 Subject: [PATCH 11/28] add exchange rates cache --- lib/data/currencies.dart | 4 ++ lib/data/exchange_rates.dart | 37 ++++++++++++-- lib/data/exchange_rates_set.dart | 38 +++++++++++++++ lib/data/money.dart | 83 ++++++++++++++++++++++++++++++++ lib/prefs.dart | 11 +++++ 5 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 lib/data/exchange_rates_set.dart create mode 100644 lib/data/money.dart diff --git a/lib/data/currencies.dart b/lib/data/currencies.dart index 4a28fb2..98796f7 100644 --- a/lib/data/currencies.dart +++ b/lib/data/currencies.dart @@ -1399,3 +1399,7 @@ final Map iso4217CurrenciesGrouped = ); }, ); + +bool isCurrencyCodeValid(String currencyCode) { + return iso4217CurrenciesGrouped.containsKey(currencyCode.toUpperCase()); +} diff --git a/lib/data/exchange_rates.dart b/lib/data/exchange_rates.dart index b496803..eee42fd 100644 --- a/lib/data/exchange_rates.dart +++ b/lib/data/exchange_rates.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'package:flow/data/currencies.dart'; +import 'package:flow/data/exchange_rates_set.dart'; +import 'package:flow/prefs.dart'; import 'package:http/http.dart' as http; import 'package:moment_dart/moment_dart.dart'; @@ -28,7 +31,32 @@ class ExchangeRates { ); } - static final Map _cache = {}; + Map toJson() { + return { + "date": date.format(payload: "yyyy-MM-dd"), + "baseCurrency": baseCurrency, + "rates": rates, + }; + } + + static const ExchangeRatesSet _cache = ExchangeRatesSet({}); + + static void updateCache(String baseCurrency, ExchangeRates exchangeRates) { + _cache.set(baseCurrency, exchangeRates); + + try { + unawaited(LocalPreferences().exchangeRatesCache.set(_cache)); + } catch (e) { + log("Failed to update exchange rates cache", error: e); + } + } + + static ExchangeRates? getCachedRates(String baseCurrency) => + _cache.get(baseCurrency); + + static ExchangeRates? getPrimaryCurrencyRates() { + return _cache.get(LocalPreferences().getPrimaryCurrency()); + } static Future fetchRates( String baseCurrency, [ @@ -36,8 +64,7 @@ class ExchangeRates { ]) async { final String normalizedCurrency = baseCurrency.trim().toLowerCase(); - if (!iso4217Currencies - .any((currency) => currency.code.toLowerCase() == normalizedCurrency)) { + if (!isCurrencyCodeValid(normalizedCurrency)) { throw Exception("Invalid currency code: $baseCurrency"); } @@ -68,7 +95,7 @@ class ExchangeRates { final exchangeRates = ExchangeRates.fromJson(normalizedCurrency, jsonResponse); - _cache[baseCurrency] = exchangeRates; + _cache.set(baseCurrency, exchangeRates); return exchangeRates; } @@ -81,7 +108,7 @@ class ExchangeRates { await fetchRates(baseCurrency, dateTime); return exchangeRates; } catch (e) { - return _cache[baseCurrency]; + return _cache.get(baseCurrency); } } } diff --git a/lib/data/exchange_rates_set.dart b/lib/data/exchange_rates_set.dart new file mode 100644 index 0000000..7256b33 --- /dev/null +++ b/lib/data/exchange_rates_set.dart @@ -0,0 +1,38 @@ +import 'package:flow/data/exchange_rates.dart'; + +class ExchangeRatesSet { + final Map rates; + + const ExchangeRatesSet(this.rates); + + void set(String baseCurrency, ExchangeRates exchangeRates) { + rates[baseCurrency] = exchangeRates; + } + + ExchangeRates? get(String baseCurrency) { + return rates[baseCurrency]; + } + + factory ExchangeRatesSet.fromJson(Map json) { + final Map rates = {}; + + for (final String baseCurrency in json.keys) { + rates[baseCurrency] = ExchangeRates.fromJson( + baseCurrency, + json[baseCurrency], + ); + } + + return ExchangeRatesSet(rates); + } + + Map toJson() { + final Map json = {}; + + for (final String baseCurrency in rates.keys) { + json[baseCurrency] = rates[baseCurrency]!.toJson(); + } + + return json; + } +} diff --git a/lib/data/money.dart b/lib/data/money.dart new file mode 100644 index 0000000..d9765b9 --- /dev/null +++ b/lib/data/money.dart @@ -0,0 +1,83 @@ +import 'package:flow/data/currencies.dart'; +import 'package:flow/data/exchange_rates.dart'; +import 'package:flow/prefs.dart'; + +class Money implements Comparable { + final double amount; + final String currency; + + static const String _invalidCurrency = " "; + + /// Not a money; lmao + static const Money nam = Money._(double.nan, _invalidCurrency); + + const Money._(this.amount, this.currency); + + factory Money(double amount, String currency) { + if (!isCurrencyCodeValid(currency)) { + throw Exception("Invalid or unsupported currency code: $currency"); + } + + return Money._(amount, currency.toUpperCase()); + } + + /// Assumes + Money convert(String newCurrency) { + if (!isCurrencyCodeValid(newCurrency)) { + throw Exception("Invalid or unsupported currency code: $currency"); + } + + if (currency == newCurrency) { + return this; + } + + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + final ExchangeRates rates = ExchangeRates.getPrimaryCurrencyRates()!; + + if (currency == primaryCurrency) { + return Money(amount * rates.rates[newCurrency]!, newCurrency); + } else { + return this & primaryCurrency & newCurrency; + } + } + + Money operator &(String currency) => convert(currency); + + Money operator +(Money other) { + if (currency != other.currency) { + return this + (other & currency); + } + + return Money(amount + other.amount, currency); + } + + Money operator -(Money other) { + if (currency != other.currency) { + return this - (other & currency); + } + + return Money(amount - other.amount, currency); + } + + Money operator -() { + return Money(-amount, currency); + } + + Money operator *(double multiplier) { + return Money(amount * multiplier, currency); + } + + Money operator /(double divisor) { + return Money(amount / divisor, currency); + } + + @override + int compareTo(Money other) { + if (currency != other.currency) { + // TODO (@sadespresso) convert currencies + throw ArgumentError("Cannot compare money with different currencies"); + } + + return amount.compareTo(other.amount); + } +} diff --git a/lib/prefs.dart b/lib/prefs.dart index b5ec0df..0d7e892 100644 --- a/lib/prefs.dart +++ b/lib/prefs.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; +import 'package:flow/data/exchange_rates_set.dart'; import 'package:flow/data/prefs/frecency.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; @@ -61,6 +62,8 @@ class LocalPreferences { late final DateTimeSettingsEntry transitiveLastTimeFrecencyUpdated; + late final JsonSettingsEntry exchangeRatesCache; + LocalPreferences._internal(this._prefs) { primaryCurrency = PrimitiveSettingsEntry( key: "flow.primaryCurrency", @@ -128,6 +131,14 @@ class LocalPreferences { preferences: _prefs, ); + exchangeRatesCache = JsonSettingsEntry( + initialValue: const ExchangeRatesSet({}), + key: "flow.caches.exchangeRatesCache", + preferences: _prefs, + fromJson: (json) => ExchangeRatesSet.fromJson(json), + toJson: (data) => data.toJson(), + ); + updateTransitiveProperties(); } From 1b4433dafd6ff3c7b1e40a8dff70da6868e86c10 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 5 Aug 2024 00:14:42 +0800 Subject: [PATCH 12/28] fix money, exchangerateset --- lib/data/exchange_rates.dart | 12 +++++++++--- lib/data/exchange_rates_set.dart | 2 +- lib/data/money.dart | 17 +++++++++++++++-- lib/prefs.dart | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/data/exchange_rates.dart b/lib/data/exchange_rates.dart index eee42fd..ea023e7 100644 --- a/lib/data/exchange_rates.dart +++ b/lib/data/exchange_rates.dart @@ -12,7 +12,7 @@ import 'package:moment_dart/moment_dart.dart'; class ExchangeRates { final DateTime date; final String baseCurrency; - final Map rates; + final Map rates; const ExchangeRates({ required this.date, @@ -27,7 +27,7 @@ class ExchangeRates { return ExchangeRates( date: DateTime.parse(json['date']), baseCurrency: baseCurrency, - rates: Map.from(json[baseCurrency.toLowerCase()]), + rates: Map.from(json[baseCurrency.toLowerCase()]), ); } @@ -39,7 +39,8 @@ class ExchangeRates { }; } - static const ExchangeRatesSet _cache = ExchangeRatesSet({}); + static final ExchangeRatesSet _cache = + ExchangeRatesSet({}); static void updateCache(String baseCurrency, ExchangeRates exchangeRates) { _cache.set(baseCurrency, exchangeRates); @@ -106,8 +107,13 @@ class ExchangeRates { try { final ExchangeRates exchangeRates = await fetchRates(baseCurrency, dateTime); + + inspect(exchangeRates); + return exchangeRates; } catch (e) { + log("Failed to fetch exchange rates", error: e); + return _cache.get(baseCurrency); } } diff --git a/lib/data/exchange_rates_set.dart b/lib/data/exchange_rates_set.dart index 7256b33..c9d869e 100644 --- a/lib/data/exchange_rates_set.dart +++ b/lib/data/exchange_rates_set.dart @@ -3,7 +3,7 @@ import 'package:flow/data/exchange_rates.dart'; class ExchangeRatesSet { final Map rates; - const ExchangeRatesSet(this.rates); + ExchangeRatesSet(this.rates); void set(String baseCurrency, ExchangeRates exchangeRates) { rates[baseCurrency] = exchangeRates; diff --git a/lib/data/money.dart b/lib/data/money.dart index d9765b9..db7fbaf 100644 --- a/lib/data/money.dart +++ b/lib/data/money.dart @@ -74,10 +74,23 @@ class Money implements Comparable { @override int compareTo(Money other) { if (currency != other.currency) { - // TODO (@sadespresso) convert currencies - throw ArgumentError("Cannot compare money with different currencies"); + return compareTo(other & currency); } return amount.compareTo(other.amount); } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is! Money) return false; + + return amount == other.amount && currency == other.currency; + } + + @override + int get hashCode => Object.hashAll([amount, currency]); } diff --git a/lib/prefs.dart b/lib/prefs.dart index 0d7e892..081f1c8 100644 --- a/lib/prefs.dart +++ b/lib/prefs.dart @@ -132,7 +132,7 @@ class LocalPreferences { ); exchangeRatesCache = JsonSettingsEntry( - initialValue: const ExchangeRatesSet({}), + initialValue: ExchangeRatesSet({}), key: "flow.caches.exchangeRatesCache", preferences: _prefs, fromJson: (json) => ExchangeRatesSet.fromJson(json), From 2f02dc1d9203d7d923fbfd4d54ac2cde3620e902 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 5 Aug 2024 00:28:36 +0800 Subject: [PATCH 13/28] money flow currency --- lib/data/money.dart | 22 +- lib/data/money_flow.dart | 41 ++- lib/entity/transaction.dart | 4 + lib/objectbox/actions.dart | 18 +- lib/routes/account_page.dart | 7 +- lib/routes/category_page.dart | 7 +- .../home/stats/group_pie_chart_old.dart | 334 ------------------ 7 files changed, 79 insertions(+), 354 deletions(-) delete mode 100644 lib/widgets/home/stats/group_pie_chart_old.dart diff --git a/lib/data/money.dart b/lib/data/money.dart index db7fbaf..fca8790 100644 --- a/lib/data/money.dart +++ b/lib/data/money.dart @@ -6,10 +6,12 @@ class Money implements Comparable { final double amount; final String currency; - static const String _invalidCurrency = " "; + static const String invalidCurrency = " "; /// Not a money; lmao - static const Money nam = Money._(double.nan, _invalidCurrency); + static const Money nam = Money._(double.nan, invalidCurrency); + + static const Money zeroUSD = Money._(0.0, "USD"); const Money._(this.amount, this.currency); @@ -21,7 +23,17 @@ class Money implements Comparable { return Money._(amount, currency.toUpperCase()); } - /// Assumes + static double convertDouble(String from, String to, double amount) { + if (from == to) return amount; + + if (!isCurrencyCodeValid(from) || !isCurrencyCodeValid(to)) { + throw Exception("Invalid or unsupported currency code"); + } + + return Money(amount, from).convert(to).amount; + } + + /// Assumes primary currency rates exist Money convert(String newCurrency) { if (!isCurrencyCodeValid(newCurrency)) { throw Exception("Invalid or unsupported currency code: $currency"); @@ -91,6 +103,10 @@ class Money implements Comparable { return amount == other.amount && currency == other.currency; } + bool get isNegative => amount.isNegative; + + Money abs() => Money(amount.abs(), currency); + @override int get hashCode => Object.hashAll([amount, currency]); } diff --git a/lib/data/money_flow.dart b/lib/data/money_flow.dart index aa33593..85944da 100644 --- a/lib/data/money_flow.dart +++ b/lib/data/money_flow.dart @@ -1,8 +1,11 @@ +import 'package:flow/data/money.dart'; import 'package:flow/entity/transaction.dart'; class MoneyFlow implements Comparable { final T? associatedData; + final String currency; + double totalExpense; double totalIncome; @@ -11,6 +14,7 @@ class MoneyFlow implements Comparable { bool get isEmpty => totalExpense.abs() == 0.0 && totalIncome.abs() == 0.0; MoneyFlow({ + required this.currency, this.associatedData, this.totalExpense = 0.0, this.totalIncome = 0.0, @@ -21,11 +25,23 @@ class MoneyFlow implements Comparable { return flow.compareTo(other.flow); } - void addExpense(double expense) => totalExpense += expense; - void addIncome(double income) => totalIncome += income; - void add(double amount) => - amount.isNegative ? addExpense(amount) : addIncome(amount); - void addAll(Iterable amounts) => amounts.forEach(add); + void addMoney(Money money) => add(money.amount, money.currency); + + void addExpense(double expense, String currency) => + totalExpense += Money.convertDouble( + currency, + this.currency, + expense, + ); + void addIncome(double income, String currency) => + totalIncome += Money.convertDouble( + currency, + this.currency, + income, + ); + void add(double amount, String currency) => amount.isNegative + ? addExpense(amount, currency) + : addIncome(amount, currency); double getTotalByType(TransactionType type) => switch (type) { TransactionType.expense => totalExpense, @@ -35,15 +51,21 @@ class MoneyFlow implements Comparable { operator +(MoneyFlow other) { return MoneyFlow( - totalExpense: totalExpense + other.totalExpense, - totalIncome: totalIncome + other.totalIncome, + totalExpense: totalExpense + + Money.convertDouble(other.currency, currency, other.totalExpense), + totalIncome: totalIncome + + Money.convertDouble(other.currency, currency, other.totalIncome), + currency: currency, ); } operator -(MoneyFlow other) { return MoneyFlow( - totalExpense: totalExpense - other.totalExpense, - totalIncome: totalIncome - other.totalIncome, + totalExpense: totalExpense - + Money.convertDouble(other.currency, currency, other.totalExpense), + totalIncome: totalIncome - + Money.convertDouble(other.currency, currency, other.totalIncome), + currency: currency, ); } @@ -51,6 +73,7 @@ class MoneyFlow implements Comparable { return MoneyFlow( totalExpense: -totalExpense, totalIncome: -totalIncome, + currency: currency, ); } } diff --git a/lib/entity/transaction.dart b/lib/entity/transaction.dart index e7ba7e2..41985c7 100644 --- a/lib/entity/transaction.dart +++ b/lib/entity/transaction.dart @@ -1,3 +1,4 @@ +import 'package:flow/data/money.dart'; import 'package:flow/entity/_base.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; @@ -36,6 +37,9 @@ class Transaction implements EntityBase { /// Currency code complying with [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) String currency; + @Transient() + Money get money => Money(amount, currency); + // Later, we might need to reference the parent transaction in order to // edit them as one. This can be useful, for example, in loan/savings with // interest. Then again, showing the interest and the base as two separate diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 578ed2c..241a694 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flow/data/flow_analytics.dart'; import 'package:flow/data/memo.dart'; +import 'package:flow/data/money.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/data/prefs/frecency_group.dart'; import 'package:flow/data/transactions_filter.dart'; @@ -116,9 +117,11 @@ extension MainActions on ObjectBox { final String categoryUuid = transaction.category.target?.uuid ?? Uuid.NAMESPACE_NIL; - flow[categoryUuid] ??= - MoneyFlow(associatedData: transaction.category.target); - flow[categoryUuid]!.add(transaction.amount); + flow[categoryUuid] ??= MoneyFlow( + associatedData: transaction.category.target, + currency: transaction.currency, + ); + flow[categoryUuid]!.addMoney(transaction.money); } if (omitZeroes) { @@ -153,9 +156,11 @@ extension MainActions on ObjectBox { final String accountUuid = transaction.account.target?.uuid ?? Uuid.NAMESPACE_NIL; - flow[accountUuid] ??= - MoneyFlow(associatedData: transaction.account.target); - flow[accountUuid]!.add(transaction.amount); + flow[accountUuid] ??= MoneyFlow( + associatedData: transaction.account.target, + currency: transaction.currency, + ); + flow[accountUuid]!.addMoney(transaction.money); } assert(!flow.containsKey(Uuid.NAMESPACE_NIL), @@ -392,6 +397,7 @@ extension TransactionListActions on Iterable { MoneyFlow get flow => MoneyFlow( totalExpense: expenseSum, totalIncome: incomeSum, + currency: firstOrNull?.currency ?? Money.invalidCurrency, ); /// If [mergeFutureTransactions] is set to true, transactions in future diff --git a/lib/routes/account_page.dart b/lib/routes/account_page.dart index f0e3a1e..b9723f0 100644 --- a/lib/routes/account_page.dart +++ b/lib/routes/account_page.dart @@ -5,6 +5,7 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/objectbox/objectbox.g.dart'; +import 'package:flow/prefs.dart'; import 'package:flow/routes/error_page.dart'; import 'package:flow/widgets/category/transactions_info.dart'; import 'package:flow/widgets/flow_card.dart'; @@ -86,7 +87,11 @@ class _AccountPageState extends State { final bool noTransactions = (transactions?.length ?? 0) == 0; - final MoneyFlow flow = transactions?.flow ?? MoneyFlow(); + final MoneyFlow flow = transactions?.flow ?? + MoneyFlow( + currency: transactions?.firstOrNull?.currency ?? + LocalPreferences().getPrimaryCurrency(), + ); const double firstHeaderTopPadding = 0.0; diff --git a/lib/routes/category_page.dart b/lib/routes/category_page.dart index c4bdc60..ea808c2 100644 --- a/lib/routes/category_page.dart +++ b/lib/routes/category_page.dart @@ -5,6 +5,7 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/objectbox/objectbox.g.dart'; +import 'package:flow/prefs.dart'; import 'package:flow/routes/error_page.dart'; import 'package:flow/widgets/category/transactions_info.dart'; import 'package:flow/widgets/flow_card.dart'; @@ -86,7 +87,11 @@ class _CategoryPageState extends State { final bool noTransactions = (transactions?.length ?? 0) == 0; - final MoneyFlow flow = transactions?.flow ?? MoneyFlow(); + final MoneyFlow flow = transactions?.flow ?? + MoneyFlow( + currency: transactions?.firstOrNull?.currency ?? + LocalPreferences().getPrimaryCurrency(), + ); const double firstHeaderTopPadding = 0.0; diff --git a/lib/widgets/home/stats/group_pie_chart_old.dart b/lib/widgets/home/stats/group_pie_chart_old.dart deleted file mode 100644 index 53c587b..0000000 --- a/lib/widgets/home/stats/group_pie_chart_old.dart +++ /dev/null @@ -1,334 +0,0 @@ -import 'dart:math' as math; - -import 'package:fl_chart/fl_chart.dart'; -import 'package:flow/data/flow_icon.dart'; -import 'package:flow/data/money_flow.dart'; -import 'package:flow/entity/account.dart'; -import 'package:flow/entity/category.dart'; -import 'package:flow/entity/transaction.dart'; -import 'package:flow/l10n/flow_localizations.dart'; -import 'package:flow/main.dart'; -import 'package:flow/theme/primary_colors.dart'; -import 'package:flow/theme/theme.dart'; -import 'package:flow/utils/utils.dart'; -import 'package:flow/widgets/general/flow_icon.dart'; -import 'package:flow/widgets/home/stats/legend_list_tile.dart'; -import 'package:flutter/material.dart' hide Flow; - -class GroupPieChart extends StatefulWidget { - final EdgeInsets chartPadding; - - final bool showSelectedSection; - - final bool showLegend; - final bool sortLegend; - - final bool scrollLegendWithin; - final EdgeInsets scrollPadding; - - final Map> data; - - final String? unresolvedDataTitle; - - final void Function(String key)? onReselect; - - final TransactionType type; - - const GroupPieChart({ - super.key, - required this.data, - required this.type, - this.chartPadding = const EdgeInsets.all(24.0), - this.scrollPadding = EdgeInsets.zero, - this.showLegend = true, - this.scrollLegendWithin = false, - this.showSelectedSection = true, - this.sortLegend = true, - this.unresolvedDataTitle, - this.onReselect, - }); - - @override - State> createState() => _GroupPieChartState(); -} - -class _GroupPieChartState extends State> { - late Map> data; - - double get totalValue => switch (widget.type) { - TransactionType.expense => data.values.fold(0.0, - (previousValue, element) => previousValue + element.totalExpense), - TransactionType.income => data.values.fold(0.0, - (previousValue, element) => previousValue + element.totalIncome), - _ => 0.0 - }; - - bool expense = true; - - String? selectedKey; - - @override - void initState() { - super.initState(); - - data = widget.data; - } - - @override - void didUpdateWidget(GroupPieChart oldWidget) { - data = widget.data; - - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - final MoneyFlow? selectedSection = - selectedKey == null ? null : data[selectedKey!]; - - // final double selectedSectionProc = selectedSection == null - // ? 0.0 - // : (selectedSection.totalExpense / totalValue); - - final double selectedSectionProc = switch (widget.type) { - TransactionType.expense when selectedSection != null => - selectedSection.totalExpense / totalValue, - TransactionType.income when selectedSection != null => - selectedSection.totalIncome / totalValue, - _ => 0.0 - }; - - final String selectedSectionTotal = switch (widget.type) { - TransactionType.expense when selectedSection != null => - selectedSection.totalExpense.abs().money, - TransactionType.income when selectedSection != null => - selectedSection.totalIncome.abs().money, - _ => "-" - }; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.showSelectedSection) ...[ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - selectedSection == null - ? "tabs.stats.chart.select.clickToSelect".t(context) - : resolveName(selectedSection.associatedData), - style: context.textTheme.headlineSmall, - ), - Text( - "$selectedSectionTotal • ${(100 * selectedSectionProc).toStringAsFixed(1)}%"), - ], - ), - const SizedBox(height: 8.0), - ], - Padding( - padding: widget.chartPadding, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 300.0, - maxWidth: 300.0, - ), - child: AspectRatio( - aspectRatio: 1.0, - child: LayoutBuilder(builder: (context, constraints) { - final double size = constraints.maxWidth; - - final double centerHoleDiameter = math.min(96.0, size * 0.25); - final double radius = (size - centerHoleDiameter) * 0.5; - - return PieChart( - PieChartData( - pieTouchData: - PieTouchData(touchCallback: (event, response) { - if (!event.isInterestedForInteractions || - response == null || - response.touchedSection == null) { - // setState(() { - // selectedKey = null; - // }); - return; - } - - final int index = - response.touchedSection!.touchedSectionIndex; - - if (index > -1) { - selectedKey = data.entries.elementAt(index).key; - setState(() {}); - } - }), - sectionsSpace: 2.0, - centerSpaceRadius: centerHoleDiameter / 2, - startDegreeOffset: -90.0, - sections: data.entries.indexed - .map( - (e) => sectionData( - data[e.$2.key]!, - selected: e.$2.key == selectedKey, - index: e.$1, - radius: radius, - ), - ) - .toList(), - ), - ); - }), - ), - ), - ), - if (widget.showLegend) buildLegend(context), - ], - ); - } - - Widget buildLegendItem( - BuildContext context, - int index, - MapEntry> entry, - ) { - final bool usingDarkTheme = Flow.of(context).useDarkTheme; - - final Color color = (usingDarkTheme - ? accentColors - : primaryColors)[index % primaryColors.length]; - final Color backgroundColor = (usingDarkTheme - ? primaryColors - : accentColors)[index % primaryColors.length]; - - return LegendListTile( - key: ValueKey(entry.key), - color: color, - leading: resolveBadgeWidget( - entry.value.associatedData, - color: color, - backgroundColor: backgroundColor, - ), - title: Text(resolveName(entry.value.associatedData)), - subtitle: Text((entry.value.totalExpense / totalValue).percent1), - trailing: Text( - entry.value.totalExpense.moneyCompact, - style: context.textTheme.bodyLarge, - ), - selected: entry.key == selectedKey, - onTap: () { - if (widget.onReselect != null && - selectedKey != null && - selectedKey == entry.key) { - widget.onReselect!(selectedKey!); - } else { - setState(() => selectedKey = entry.key); - } - }, - ); - } - - Widget buildLegend(BuildContext context) { - final indexed = data.entries.toList().indexed.toList(); - if (widget.sortLegend) { - if (widget.type == TransactionType.expense) { - indexed.sort( - (a, b) => a.$2.value.totalExpense.compareTo( - b.$2.value.totalExpense, - ), - ); - } else if (widget.type == TransactionType.income) { - indexed.sort( - (a, b) => b.$2.value.totalIncome.compareTo( - a.$2.value.totalIncome, - ), - ); - } - } - - if (widget.scrollLegendWithin) { - return Expanded( - child: ListView.builder( - itemBuilder: (context, index) => - buildLegendItem(context, indexed[index].$1, indexed[index].$2), - itemCount: indexed.length, - padding: widget.scrollPadding, - ), - ); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: - indexed.map((e) => buildLegendItem(context, e.$1, e.$2)).toList(), - ); - } - - PieChartSectionData sectionData( - MoneyFlow flow, { - required double radius, - bool selected = false, - int index = 0, - }) { - final bool usingDarkTheme = Flow.of(context).useDarkTheme; - - final Color color = (usingDarkTheme - ? accentColors - : primaryColors)[index % primaryColors.length]; - final Color backgroundColor = (usingDarkTheme - ? primaryColors - : accentColors)[index % primaryColors.length]; - - return PieChartSectionData( - color: color, - radius: radius, - value: widget.type == TransactionType.expense - ? flow.totalExpense.abs() - : flow.totalIncome, - title: resolveName(flow.associatedData), - showTitle: false, - badgeWidget: selected - ? resolveBadgeWidget( - flow.associatedData, - color: color, - backgroundColor: backgroundColor, - ) - : null, - badgePositionPercentageOffset: 0.8, - borderSide: selected - ? BorderSide( - color: context.colorScheme.primary, - width: 2.0, - strokeAlign: BorderSide.strokeAlignInside, - ) - : BorderSide.none, - ); - } - - String resolveName(Object? entity) => switch (entity) { - Category category => category.name, - Account account => account.name, - _ => widget.unresolvedDataTitle ?? "???" - }; - - Widget? resolveBadgeWidget(Object? entity, - {Color? color, Color? backgroundColor}) => - switch (entity) { - Category category => FlowIcon( - category.icon, - plated: true, - color: color, - plateColor: backgroundColor ?? color?.withAlpha(0x40), - ), - Account account => FlowIcon( - account.icon, - plated: true, - color: color, - plateColor: backgroundColor ?? color?.withAlpha(0x40), - ), - _ => FlowIcon( - FlowIconData.emoji("?"), - plated: true, - color: color, - plateColor: backgroundColor ?? color?.withAlpha(0x40), - ), - }; -} From d4a3cf1f35bcb3d85b414ad287d14eeb714096d0 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 5 Aug 2024 00:53:57 +0800 Subject: [PATCH 14/28] fix exchange rate cache --- lib/data/exchange_rates.dart | 21 +++++++++++---------- lib/data/exchange_rates_set.dart | 7 +++---- lib/data/money.dart | 9 ++++++--- lib/objectbox/actions.dart | 6 ++++-- lib/routes/home/stats_tab.dart | 13 +++++++++++-- lib/routes/preferences_page.dart | 4 ++++ 6 files changed, 39 insertions(+), 21 deletions(-) diff --git a/lib/data/exchange_rates.dart b/lib/data/exchange_rates.dart index ea023e7..6ea2fdd 100644 --- a/lib/data/exchange_rates.dart +++ b/lib/data/exchange_rates.dart @@ -20,10 +20,9 @@ class ExchangeRates { required this.rates, }); - factory ExchangeRates.fromJson( - String baseCurrency, - Map json, - ) { + factory ExchangeRates.fromJson(Map json) { + final String baseCurrency = json.keys.firstWhere((key) => key != "date"); + return ExchangeRates( date: DateTime.parse(json['date']), baseCurrency: baseCurrency, @@ -33,12 +32,15 @@ class ExchangeRates { Map toJson() { return { - "date": date.format(payload: "yyyy-MM-dd"), - "baseCurrency": baseCurrency, - "rates": rates, + "date": date.format(payload: "YYYY-MM-DD"), + baseCurrency: rates, }; } + double? getRate(String currency) { + return rates[currency.toLowerCase()]?.toDouble(); + } + static final ExchangeRatesSet _cache = ExchangeRatesSet({}); @@ -94,9 +96,8 @@ class ExchangeRates { throw Exception("Failed to fetch exchange rates"); } - final exchangeRates = - ExchangeRates.fromJson(normalizedCurrency, jsonResponse); - _cache.set(baseCurrency, exchangeRates); + final exchangeRates = ExchangeRates.fromJson(jsonResponse); + updateCache(baseCurrency, exchangeRates); return exchangeRates; } diff --git a/lib/data/exchange_rates_set.dart b/lib/data/exchange_rates_set.dart index c9d869e..edb89e0 100644 --- a/lib/data/exchange_rates_set.dart +++ b/lib/data/exchange_rates_set.dart @@ -13,12 +13,11 @@ class ExchangeRatesSet { return rates[baseCurrency]; } - factory ExchangeRatesSet.fromJson(Map json) { + factory ExchangeRatesSet.fromJson(Map json) { final Map rates = {}; for (final String baseCurrency in json.keys) { rates[baseCurrency] = ExchangeRates.fromJson( - baseCurrency, json[baseCurrency], ); } @@ -29,8 +28,8 @@ class ExchangeRatesSet { Map toJson() { final Map json = {}; - for (final String baseCurrency in rates.keys) { - json[baseCurrency] = rates[baseCurrency]!.toJson(); + for (final MapEntry entry in rates.entries) { + json[entry.key] = entry.value.toJson(); } return json; diff --git a/lib/data/money.dart b/lib/data/money.dart index fca8790..d060225 100644 --- a/lib/data/money.dart +++ b/lib/data/money.dart @@ -46,11 +46,14 @@ class Money implements Comparable { final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); final ExchangeRates rates = ExchangeRates.getPrimaryCurrencyRates()!; + if (newCurrency == primaryCurrency) { + return Money(amount / rates.getRate(currency)!, newCurrency); + } if (currency == primaryCurrency) { - return Money(amount * rates.rates[newCurrency]!, newCurrency); - } else { - return this & primaryCurrency & newCurrency; + return Money(amount * rates.getRate(newCurrency)!, newCurrency); } + + return this & primaryCurrency & newCurrency; } Money operator &(String currency) => convert(currency); diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 241a694..246f731 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -98,6 +98,7 @@ extension MainActions on ObjectBox { required DateTime to, bool ignoreTransfers = true, bool omitZeroes = true, + String? currencyOverride, }) async { final Condition dateFilter = Transaction_.transactionDate.betweenDate(from, to); @@ -119,7 +120,7 @@ extension MainActions on ObjectBox { flow[categoryUuid] ??= MoneyFlow( associatedData: transaction.category.target, - currency: transaction.currency, + currency: currencyOverride ?? transaction.currency, ); flow[categoryUuid]!.addMoney(transaction.money); } @@ -137,6 +138,7 @@ extension MainActions on ObjectBox { required DateTime to, bool ignoreTransfers = true, bool omitZeroes = true, + String? currencyOverride, }) async { final Condition dateFilter = Transaction_.transactionDate.betweenDate(from, to); @@ -158,7 +160,7 @@ extension MainActions on ObjectBox { flow[accountUuid] ??= MoneyFlow( associatedData: transaction.account.target, - currency: transaction.currency, + currency: currencyOverride ?? transaction.currency, ); flow[accountUuid]!.addMoney(transaction.money); } diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index b02fa61..868039d 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -5,6 +5,7 @@ import 'package:flow/l10n/flow_localizations.dart'; import 'package:flow/l10n/named_enum.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; +import 'package:flow/prefs.dart'; import 'package:flow/routes/home/stats_tab/pie_graph_view.dart'; import 'package:flow/widgets/general/spinner.dart'; import 'package:flow/widgets/time_range_selector.dart'; @@ -128,8 +129,16 @@ class _StatsTabState extends State try { analytics = byCategory - ? await ObjectBox().flowByCategories(from: range.from, to: range.to) - : await ObjectBox().flowByAccounts(from: range.from, to: range.to); + ? await ObjectBox().flowByCategories( + from: range.from, + to: range.to, + currencyOverride: LocalPreferences().getPrimaryCurrency(), + ) + : await ObjectBox().flowByAccounts( + from: range.from, + to: range.to, + currencyOverride: LocalPreferences().getPrimaryCurrency(), + ); } finally { busy = false; diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart index 2e8134a..3e15728 100644 --- a/lib/routes/preferences_page.dart +++ b/lib/routes/preferences_page.dart @@ -208,6 +208,10 @@ class _PreferencesPageState extends State { } } finally { _currencyBusy = false; + + if (mounted) { + setState(() {}); + } } } From 81edb3af60a0a87558637b864ce5ef9dd73b2c00 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 5 Aug 2024 01:01:26 +0800 Subject: [PATCH 15/28] update frecency once a day at max --- lib/data/exchange_rates.dart | 2 -- lib/prefs.dart | 5 ++++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/data/exchange_rates.dart b/lib/data/exchange_rates.dart index 6ea2fdd..b198e17 100644 --- a/lib/data/exchange_rates.dart +++ b/lib/data/exchange_rates.dart @@ -109,8 +109,6 @@ class ExchangeRates { final ExchangeRates exchangeRates = await fetchRates(baseCurrency, dateTime); - inspect(exchangeRates); - return exchangeRates; } catch (e) { log("Failed to fetch exchange rates", error: e); diff --git a/lib/prefs.dart b/lib/prefs.dart index 081f1c8..1361e2e 100644 --- a/lib/prefs.dart +++ b/lib/prefs.dart @@ -12,6 +12,7 @@ import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:local_settings/local_settings.dart'; +import 'package:moment_dart/moment_dart.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// This class contains everything that's stored on @@ -154,9 +155,11 @@ class LocalPreferences { log("[LocalPreferences] cannot update transitive properties due to: $e"); } - if (transitiveLastTimeFrecencyUpdated.get() == null) { + if (transitiveLastTimeFrecencyUpdated.get() == null || + !transitiveLastTimeFrecencyUpdated.get()!.isAtSameDayAs(Moment.now())) { unawaited(_reevaluateCategoryFrecency()); unawaited(_reevaluateAccountFrecency()); + unawaited(transitiveLastTimeFrecencyUpdated.set(DateTime.now())); } } From b55614c18d4789887adbcab71709a6edbc950988 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 5 Aug 2024 01:03:09 +0800 Subject: [PATCH 16/28] update changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c15afda..b48b524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,12 @@ * Updated theme to correct `activeColor` for radio/checkboxes and its lists * Added filters to home tab, and added preferences for home page * Added error builder for Image `FlowIcon`s when the image is missing -* Made Stats tab better, hopefully... * TimeRange selector now listens for mouse wheel scroll +* Added exchange rates +* Stats tab: + * Uses primary currency to show all the data + * Now separates income/expense +* Frecency data updates one per day max. (was updating at every launch before) ## Beta 0.5.5 From b5ca5d7f6117a318c7cd59862ed4c078dfa5a5d2 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 5 Aug 2024 01:05:04 +0800 Subject: [PATCH 17/28] added TODOs so I won't forget --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b48b524..a503ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ * Now separates income/expense * Frecency data updates one per day max. (was updating at every launch before) +TODO: +- [ ] Fallback when there's no exchange rate +- [ ] Optimize FlowAnalytics adding to group by currencies first, then adding +- [ ] Test, test, test + ## Beta 0.5.5 * Selecting icons should be slightly better From ddd71a9ba9f1697809c17720bf613b274b1b6ae1 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 16:22:04 +0800 Subject: [PATCH 18/28] migrate to Flutter 3.24 --- lib/routes/account/account_edit_page.dart | 2 +- lib/routes/home_page.dart | 12 ++++++---- .../new_transaction/select_account_sheet.dart | 2 +- .../select_category_sheet.dart | 2 +- .../preferences/language_selection_sheet.dart | 2 +- .../preferences/theme_selection_sheet.dart | 2 +- lib/routes/profile_page.dart | 2 +- lib/utils/utils.dart | 2 +- lib/widgets/month_selector_sheet.dart | 2 +- .../select_char_flow_icon_sheet.dart | 2 +- .../select_icon_flow_icon_sheet.dart | 2 +- .../select_image_flow_icon_sheet.dart | 2 +- lib/widgets/select_time_range_mode_sheet.dart | 2 +- .../select_multi_account_sheet.dart | 2 +- .../select_multi_category_sheet.dart | 2 +- .../transaction_search_sheet.dart | 2 +- lib/widgets/year_selector_sheet.dart | 2 +- pubspec.lock | 24 +++++++++---------- 18 files changed, 36 insertions(+), 32 deletions(-) diff --git a/lib/routes/account/account_edit_page.dart b/lib/routes/account/account_edit_page.dart index cf8c3f9..ffc38e0 100644 --- a/lib/routes/account/account_edit_page.dart +++ b/lib/routes/account/account_edit_page.dart @@ -349,7 +349,7 @@ class _AccountEditPageState extends State { (value) { value.updateBalanceAndSave( _balance, - title: "account.updateBalance.transactionTitle".t(context), + title: "account.updateBalance.transactionTitle".tr(), ); ObjectBox().box().putAsync(value); }, diff --git a/lib/routes/home_page.dart b/lib/routes/home_page.dart index 37e661b..0e535bb 100644 --- a/lib/routes/home_page.dart +++ b/lib/routes/home_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flow/entity/account.dart'; import 'package:flow/entity/transaction.dart'; import 'package:flow/main.dart'; @@ -50,10 +52,12 @@ class _HomePageState extends State }); Future.delayed(const Duration(milliseconds: 200)).then((_) { - if (!LocalPreferences().completedInitialSetup.get()) { - context.pushReplacement("/setup"); - LocalPreferences().completedInitialSetup.set(true); - } + if (!mounted) return; + + if (LocalPreferences().completedInitialSetup.get()) return; + + context.pushReplacement("/setup"); + unawaited(LocalPreferences().completedInitialSetup.set(true)); }); } diff --git a/lib/routes/new_transaction/select_account_sheet.dart b/lib/routes/new_transaction/select_account_sheet.dart index d925a20..848a9f6 100644 --- a/lib/routes/new_transaction/select_account_sheet.dart +++ b/lib/routes/new_transaction/select_account_sheet.dart @@ -27,7 +27,7 @@ class SelectAccountSheet extends StatelessWidget { title: Text(titleOverride ?? "transaction.edit.selectAccount".t(context)), scrollableContentMaxHeight: MediaQuery.of(context).size.height * .5, trailing: accounts.isEmpty - ? ButtonBar( + ? OverflowBar( children: [ Button( onTap: () => context.pop(false), diff --git a/lib/routes/new_transaction/select_category_sheet.dart b/lib/routes/new_transaction/select_category_sheet.dart index 2d7cf61..ede807e 100644 --- a/lib/routes/new_transaction/select_category_sheet.dart +++ b/lib/routes/new_transaction/select_category_sheet.dart @@ -22,7 +22,7 @@ class SelectCategorySheet extends StatelessWidget { Widget build(BuildContext context) { return ModalSheet.scrollable( title: Text("transaction.edit.selectCategory".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop(const Optional(null)), diff --git a/lib/routes/preferences/language_selection_sheet.dart b/lib/routes/preferences/language_selection_sheet.dart index bfa281d..cdee727 100644 --- a/lib/routes/preferences/language_selection_sheet.dart +++ b/lib/routes/preferences/language_selection_sheet.dart @@ -14,7 +14,7 @@ class LanguageSelectionSheet extends StatelessWidget { return ModalSheet.scrollable( scrollableContentMaxHeight: MediaQuery.of(context).size.height, title: Text("preferences.language.choose".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop(), diff --git a/lib/routes/preferences/theme_selection_sheet.dart b/lib/routes/preferences/theme_selection_sheet.dart index acaab67..6183fb7 100644 --- a/lib/routes/preferences/theme_selection_sheet.dart +++ b/lib/routes/preferences/theme_selection_sheet.dart @@ -15,7 +15,7 @@ class ThemeSelectionSheet extends StatelessWidget { return ModalSheet.scrollable( scrollableContentMaxHeight: MediaQuery.of(context).size.height, title: Text("preferences.themeMode.choose".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop(), diff --git a/lib/routes/profile_page.dart b/lib/routes/profile_page.dart index 7a23adb..246a863 100644 --- a/lib/routes/profile_page.dart +++ b/lib/routes/profile_page.dart @@ -56,7 +56,7 @@ class _ProfilePageState extends State { @override Widget build(BuildContext context) { return PopScope( - onPopInvoked: (_) => save(), + onPopInvokedWithResult: (_, __) => save(), child: Scaffold( appBar: AppBar( actions: [ diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index d5dde76..68e3944 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -67,7 +67,7 @@ extension CustomDialogs on BuildContext { context: this, builder: (context) => ModalSheet( title: Text(title ?? "general.areYouSure".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ Button( onTap: () => context.pop(false), diff --git a/lib/widgets/month_selector_sheet.dart b/lib/widgets/month_selector_sheet.dart index cd719fa..ce4b8d7 100644 --- a/lib/widgets/month_selector_sheet.dart +++ b/lib/widgets/month_selector_sheet.dart @@ -35,7 +35,7 @@ class _MonthSelectorSheetState extends State { Widget build(BuildContext context) { return ModalSheet( title: Text("general.timeSelector.select.month".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton( onPressed: () => setState(() { diff --git a/lib/widgets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart b/lib/widgets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart index f73ac25..a131e5b 100644 --- a/lib/widgets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart +++ b/lib/widgets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart @@ -48,7 +48,7 @@ class _SelectCharFlowIconSheetState extends State { return ModalSheet.scrollable( scrollableContentMaxHeight: scrollableContentMaxHeight, title: Text("flowIcon.type.character".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop(value), diff --git a/lib/widgets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart b/lib/widgets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart index b922cba..bdf1dd1 100644 --- a/lib/widgets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart +++ b/lib/widgets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart @@ -82,7 +82,7 @@ class _SelectIconFlowIconSheetState extends State ) ], ), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop(value), diff --git a/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart b/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart index 00862f1..201ad49 100644 --- a/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart +++ b/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart @@ -79,7 +79,7 @@ class _SelectImageFlowIconSheetState extends State { Widget build(BuildContext context) { return ModalSheet( title: Text("flowIcon.type.image".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop(value), diff --git a/lib/widgets/select_time_range_mode_sheet.dart b/lib/widgets/select_time_range_mode_sheet.dart index 60af828..e002a42 100644 --- a/lib/widgets/select_time_range_mode_sheet.dart +++ b/lib/widgets/select_time_range_mode_sheet.dart @@ -30,7 +30,7 @@ class SelectTimeRangeModeSheet extends StatelessWidget { return ModalSheet.scrollable( scrollableContentMaxHeight: scrollableContentMaxHeight, title: Text("tabs.stats.timeRange.select".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop(null), diff --git a/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart b/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart index b2f90be..d551600 100644 --- a/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart @@ -47,7 +47,7 @@ class _SelectMultiAccountSheetState extends State { title: Text( widget.titleOverride ?? "transaction.edit.selectAccount".t(context)), scrollableContentMaxHeight: MediaQuery.of(context).size.height * .5, - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop([]), diff --git a/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart b/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart index 1ecbe5c..4e684e0 100644 --- a/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart @@ -42,7 +42,7 @@ class _SelectMultiCategorySheetState extends State { Widget build(BuildContext context) { return ModalSheet.scrollable( title: Text("transaction.edit.selectCategory.multiple".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: () => context.pop([]), diff --git a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart index ba25170..2843c56 100644 --- a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart +++ b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart @@ -37,7 +37,7 @@ class _TransactionSearchSheetState extends State { Widget build(BuildContext context) { return ModalSheet.scrollable( title: Text('transactions.query.filter.keyword'.t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton.icon( onPressed: clear, diff --git a/lib/widgets/year_selector_sheet.dart b/lib/widgets/year_selector_sheet.dart index e320a62..5cbedaf 100644 --- a/lib/widgets/year_selector_sheet.dart +++ b/lib/widgets/year_selector_sheet.dart @@ -39,7 +39,7 @@ class _YearSelectorSheetState extends State { Widget build(BuildContext context) { return ModalSheet( title: Text("general.timeSelector.select.year".t(context)), - trailing: ButtonBar( + trailing: OverflowBar( children: [ TextButton( onPressed: () => setState(() { diff --git a/pubspec.lock b/pubspec.lock index 92a722e..8ab1eec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -689,18 +689,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -761,10 +761,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -777,10 +777,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" mime: dependency: transitive description: @@ -1206,10 +1206,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" timing: dependency: transitive description: @@ -1318,10 +1318,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.4" watcher: dependency: transitive description: From fcd787467c9f7f9637febbb7115529ae1a468e05 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 16:25:53 +0800 Subject: [PATCH 19/28] upgrade pkgs --- pubspec.lock | 131 ++++++++++++++++++++++++++++----------------------- pubspec.yaml | 6 +-- 2 files changed, 75 insertions(+), 62 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 8ab1eec..351e597 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "72.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.7.0" app_settings: dependency: "direct main" description: @@ -101,18 +106,18 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + sha256: dd09dd4e2b078992f42aac7f1a622f01882a8492fef08486b27ddde929c19f04 url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.12" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "7.3.2" built_collection: dependency: transitive description: @@ -197,10 +202,10 @@ packages: dependency: "direct main" description: name: cross_file - sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" url: "https://pub.dev" source: hosted - version: "0.3.4+1" + version: "0.3.4+2" crypto: dependency: transitive description: @@ -309,10 +314,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "824f5b9f389bfc4dddac3dea76cd70c51092d9dff0b2ece7ef4f53db8547d258" + sha256: "825aec673606875c33cd8d3c4083f1a3c3999015a84178b317b7ef396b7384f3" url: "https://pub.dev" source: hosted - version: "8.0.6" + version: "8.0.7" file_saver: dependency: "direct main" description: @@ -529,10 +534,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "39dd52168d6c59984454183148dc3a5776960c61083adfc708cc79a7b3ce1ba8" + sha256: ddc16d34b0d74cb313986918c0f0885a7ba2fc24d8fb8419de75f0015144ccfe url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.3" graphs: dependency: transitive description: @@ -593,18 +598,18 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "8c3168469b005a6dbf5ba01f795917ae4f4e71077d3d7f2049a0d25a4760393e" + sha256: c0e72ecd170b00a5590bb71238d57dc8ad22ee14c60c6b0d1a4e05cafbc5db4b url: "https://pub.dev" source: hosted - version: "0.8.12+8" + version: "0.8.12+11" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" + sha256: "65d94623e15372c5c51bebbcb820848d7bcb323836e12dfdba60b5d3a8b39e50" url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.0.5" image_picker_ios: dependency: transitive description: @@ -741,6 +746,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" mask_text_input_formatter: dependency: "direct main" description: @@ -769,10 +782,10 @@ packages: dependency: "direct main" description: name: material_symbols_icons - sha256: "37f88057af06224cd99242bd9b5ceda8c1ebddfff67bd5e8432521910a3d4598" + sha256: "8f4abdb6bc714526ccf66e825b7391d7ca65239484ad92be71980fe73a57521c" url: "https://pub.dev" source: hosted - version: "4.2771.0" + version: "4.2780.0" meta: dependency: transitive description: @@ -833,18 +846,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0 + sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "8.0.2" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" path: dependency: "direct main" description: @@ -873,18 +886,18 @@ packages: dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: e84c8a53fe1510ef4582f118c7b4bdf15b03002b51d7c2b66983c65843d61193 + sha256: "490539678396d4c3c0b06efdaab75ae60675c3e0c66f72bc04c2e2c1e0e2abeb" url: "https://pub.dev" source: hosted - version: "2.2.8" + version: "2.2.9" path_provider_foundation: dependency: transitive description: @@ -961,10 +974,10 @@ packages: dependency: transitive description: name: pointer_interceptor - sha256: d0a8e660d1204eaec5bd34b34cc92174690e076d2e4f893d9d68c486a13b07c4 + sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523" url: "https://pub.dev" source: hosted - version: "0.10.1+1" + version: "0.10.1+2" pointer_interceptor_ios: dependency: transitive description: @@ -985,10 +998,10 @@ packages: dependency: transitive description: name: pointer_interceptor_web - sha256: a6237528b46c411d8d55cdfad8fcb3269fc4cbb26060b14bff94879165887d1e + sha256: "7a7087782110f8c1827170660b09f8aa893e0e9a61431dbbe2ac3fc482e8c044" url: "https://pub.dev" source: hosted - version: "0.10.2" + version: "0.10.2+1" pool: dependency: transitive description: @@ -1025,74 +1038,74 @@ packages: dependency: "direct main" description: name: share_plus - sha256: ef3489a969683c4f3d0239010cc8b7a2a46543a8d139e111c06c558875083544 + sha256: "59dfd53f497340a0c3a81909b220cfdb9b8973a91055c4e5ab9b9b9ad7c513c0" url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "10.0.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "0f9e4418835d1b2c3ae78fdb918251959106cefdbc4dd43526e182f80e82f6d4" + sha256: "6ababf341050edff57da8b6990f11f4e99eaba837865e2e6defe16d039619db5" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + sha256: c272f9cabca5a81adc9b0894381e9c1def363e980f960fa903c604c471b22f68 url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.1" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "3d4571b3c5eb58ce52a419d86e655493d0bc3020672da79f72fa0c16ca3a8ec1" + sha256: a7e8467e9181cef109f601e3f65765685786c1a738a83d7fbbde377589c0d974 url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.1" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" + sha256: "776786cff96324851b656777648f36ac772d88bc4c669acff97b7fce5de3c849" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.5.1" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "034650b71e73629ca08a0bd789fd1d83cc63c2d1e405946f7cef7bc37432f93a" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.2" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shelf: dependency: transitive description: @@ -1246,10 +1259,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: c24484594a8dea685610569ab0f2547de9c7a1907500a9bc5e37e4c9a3cbfb23 + sha256: "94d8ad05f44c6d4e2ffe5567ab4d741b82d62e3c8e288cc1fcea45965edf47c9" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.3.8" url_launcher_ios: dependency: transitive description: @@ -1262,10 +1275,10 @@ packages: dependency: transitive description: name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.2.0" url_launcher_macos: dependency: transitive description: @@ -1286,10 +1299,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.3" url_launcher_windows: dependency: transitive description: @@ -1358,10 +1371,10 @@ packages: dependency: transitive description: name: win32 - sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 + sha256: "015002c060f1ae9f41a818f2d5640389cc05283e368be19dc8d77cecb43c40c9" url: "https://pub.dev" source: hosted - version: "5.5.1" + version: "5.5.3" xdg_directories: dependency: transitive description: @@ -1387,5 +1400,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.5.0-259.0.dev <4.0.0" flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 413e6e7..7e122c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: cupertino_icons: ^1.0.6 desktop_drop: ^0.4.4 dotted_border: ^2.1.0 - file_picker: ^8.0.3 + file_picker: ^8.0.7 file_saver: ^0.2.9 fl_chart: ^0.68.0 flutter: @@ -46,8 +46,8 @@ dependencies: path_provider: ^2.1.1 pie_menu: ^3.2.0 salomon_bottom_bar: ^3.3.2 - share_plus: ^9.0.0 - shared_preferences: ^2.2.2 + share_plus: ^10.0.0 + shared_preferences: ^2.3.1 simple_icons: ^10.1.3 smooth_page_indicator: ^1.1.0 toastification: ^2.1.0 From ab79aa0facf24930fa92727c266b752dffceace8 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 16:30:45 +0800 Subject: [PATCH 20/28] update objectbox-android-objectbrowser --- android/app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index fb9e01b..98d4902 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -84,5 +84,5 @@ configurations { } dependencies { - debugImplementation("io.objectbox:objectbox-android-objectbrowser:3.8.0") + debugImplementation("io.objectbox:objectbox-android-objectbrowser:4.0.1") } From 2a61c4627daaa3e6d0a5e08a09b22f6e7e8cb03a Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 19:18:29 +0800 Subject: [PATCH 21/28] fix alignment, money flow, chart changes --- lib/data/chart_data.dart | 24 +++ lib/data/exchange_rates.dart | 12 +- lib/data/money.dart | 70 +++++--- lib/data/money_flow.dart | 168 ++++++++++++------ lib/objectbox/actions.dart | 31 ++-- lib/routes/account_page.dart | 17 +- lib/routes/category_page.dart | 21 ++- lib/routes/home/stats_tab.dart | 75 +++++--- lib/routes/home/stats_tab/pie_graph_view.dart | 8 +- .../new_transaction/select_account_sheet.dart | 1 + .../select_category_sheet.dart | 1 + .../preferences/language_selection_sheet.dart | 1 + .../preferences/theme_selection_sheet.dart | 1 + lib/utils/utils.dart | 1 + lib/widgets/home/stats/group_pie_chart.dart | 42 +++-- lib/widgets/month_selector_sheet.dart | 1 + .../select_char_flow_icon_sheet.dart | 1 + .../select_icon_flow_icon_sheet.dart | 1 + .../select_image_flow_icon_sheet.dart | 1 + lib/widgets/select_time_range_mode_sheet.dart | 1 + .../select_multi_account_sheet.dart | 1 + .../select_multi_category_sheet.dart | 1 + .../transaction_search_sheet.dart | 1 + lib/widgets/year_selector_sheet.dart | 1 + pubspec.lock | 2 +- pubspec.yaml | 2 +- 26 files changed, 314 insertions(+), 172 deletions(-) create mode 100644 lib/data/chart_data.dart diff --git a/lib/data/chart_data.dart b/lib/data/chart_data.dart new file mode 100644 index 0000000..eb7e21d --- /dev/null +++ b/lib/data/chart_data.dart @@ -0,0 +1,24 @@ +import 'package:flow/data/exchange_rates.dart'; +import 'package:flow/data/money.dart'; + +class ChartData implements Comparable> { + final String key; + final Money money; + final T? associatedData; + + double get displayTotal => money.amount.abs(); + + ChartData({ + required this.key, + required this.money, + required this.associatedData, + }); + + @override + int compareTo(ChartData other) { + return money.tryCompareToWithExchange( + other.money, + ExchangeRates.getPrimaryCurrencyRates(), + ); + } +} diff --git a/lib/data/exchange_rates.dart b/lib/data/exchange_rates.dart index b198e17..b058802 100644 --- a/lib/data/exchange_rates.dart +++ b/lib/data/exchange_rates.dart @@ -68,7 +68,7 @@ class ExchangeRates { final String normalizedCurrency = baseCurrency.trim().toLowerCase(); if (!isCurrencyCodeValid(normalizedCurrency)) { - throw Exception("Invalid currency code: $baseCurrency"); + throw FormatException("Invalid currency code: $baseCurrency"); } final String dateParam = @@ -96,7 +96,8 @@ class ExchangeRates { throw Exception("Failed to fetch exchange rates"); } - final exchangeRates = ExchangeRates.fromJson(jsonResponse); + final ExchangeRates exchangeRates = ExchangeRates.fromJson(jsonResponse); + updateCache(baseCurrency, exchangeRates); return exchangeRates; } @@ -106,12 +107,17 @@ class ExchangeRates { DateTime? dateTime, ]) async { try { + log("[ExchangeRates] Fetching exchange rates for $baseCurrency"); + final ExchangeRates exchangeRates = await fetchRates(baseCurrency, dateTime); return exchangeRates; } catch (e) { - log("Failed to fetch exchange rates", error: e); + log( + "[ExchangeRates] Failed to fetch exchange rates ($baseCurrency)", + error: e, + ); return _cache.get(baseCurrency); } diff --git a/lib/data/money.dart b/lib/data/money.dart index d060225..12d0257 100644 --- a/lib/data/money.dart +++ b/lib/data/money.dart @@ -1,8 +1,9 @@ +import 'dart:developer'; + import 'package:flow/data/currencies.dart'; import 'package:flow/data/exchange_rates.dart'; -import 'package:flow/prefs.dart'; -class Money implements Comparable { +class Money { final double amount; final String currency; @@ -17,50 +18,56 @@ class Money implements Comparable { factory Money(double amount, String currency) { if (!isCurrencyCodeValid(currency)) { - throw Exception("Invalid or unsupported currency code: $currency"); + throw MoneyException("Invalid or unsupported currency code: $currency"); } return Money._(amount, currency.toUpperCase()); } - static double convertDouble(String from, String to, double amount) { + static double convertDouble( + String from, String to, double amount, ExchangeRates rates) { if (from == to) return amount; if (!isCurrencyCodeValid(from) || !isCurrencyCodeValid(to)) { - throw Exception("Invalid or unsupported currency code"); + throw const MoneyException("Invalid or unsupported currency code"); } - return Money(amount, from).convert(to).amount; + return Money(amount, from).convert(to, rates).amount; } /// Assumes primary currency rates exist - Money convert(String newCurrency) { + Money convert(String newCurrency, ExchangeRates rates) { if (!isCurrencyCodeValid(newCurrency)) { - throw Exception("Invalid or unsupported currency code: $currency"); + throw MoneyException("Invalid or unsupported currency code: $currency"); } if (currency == newCurrency) { return this; } - final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); - final ExchangeRates rates = ExchangeRates.getPrimaryCurrencyRates()!; + if (rates.getRate(currency) == null || rates.getRate(newCurrency) == null) { + throw MoneyException( + "Exchange rates for both $currency and $newCurrency are required", + ); + } + + final String currencyFranco = rates.baseCurrency; - if (newCurrency == primaryCurrency) { + if (newCurrency == currencyFranco) { return Money(amount / rates.getRate(currency)!, newCurrency); } - if (currency == primaryCurrency) { + if (currency == currencyFranco) { return Money(amount * rates.getRate(newCurrency)!, newCurrency); } - return this & primaryCurrency & newCurrency; + return convert(currencyFranco, rates).convert(newCurrency, rates); } - Money operator &(String currency) => convert(currency); - Money operator +(Money other) { if (currency != other.currency) { - return this + (other & currency); + throw const MoneyException( + "Cannot add Money of different currencies", + ); } return Money(amount + other.amount, currency); @@ -68,7 +75,9 @@ class Money implements Comparable { Money operator -(Money other) { if (currency != other.currency) { - return this - (other & currency); + throw const MoneyException( + "Cannot subtract Money of different currencies", + ); } return Money(amount - other.amount, currency); @@ -86,15 +95,27 @@ class Money implements Comparable { return Money(amount / divisor, currency); } - @override - int compareTo(Money other) { + /// Compare doesn't work for [Money]s of different currencies + int tryCompareTo(Money other) { if (currency != other.currency) { - return compareTo(other & currency); + log("Cannot compare Money of different currencies, returning 0"); + return 0; } return amount.compareTo(other.amount); } + /// If [rates] is given, converts [this] and [other] to + /// [rates.baseCurrency] before calling [tryCompareTo] + int tryCompareToWithExchange(Money other, ExchangeRates? rates) { + if (rates == null) { + return tryCompareTo(other); + } + + return convert(rates.baseCurrency, rates) + .tryCompareTo(other.convert(rates.baseCurrency, rates)); + } + @override bool operator ==(Object other) { if (identical(this, other)) { @@ -113,3 +134,12 @@ class Money implements Comparable { @override int get hashCode => Object.hashAll([amount, currency]); } + +class MoneyException implements Exception { + final String message; + + const MoneyException(this.message); + + @override + String toString() => message; +} diff --git a/lib/data/money_flow.dart b/lib/data/money_flow.dart index 85944da..c4261ce 100644 --- a/lib/data/money_flow.dart +++ b/lib/data/money_flow.dart @@ -1,79 +1,129 @@ +import 'dart:developer'; + +import 'package:flow/data/currencies.dart'; +import 'package:flow/data/exchange_rates.dart'; import 'package:flow/data/money.dart'; import 'package:flow/entity/transaction.dart'; +import 'package:flow/prefs.dart'; -class MoneyFlow implements Comparable { +class MoneyFlow { final T? associatedData; - final String currency; - - double totalExpense; - double totalIncome; + final Map _totalExpenseByCurrency = {}; + final Map _totalIncomeByCurrency = {}; - double get flow => totalExpense + totalIncome; + MoneyFlow({this.associatedData}); - bool get isEmpty => totalExpense.abs() == 0.0 && totalIncome.abs() == 0.0; + void add(Money money) { + final double amount = money.amount; + final String currency = money.currency.trim().toUpperCase(); - MoneyFlow({ - required this.currency, - this.associatedData, - this.totalExpense = 0.0, - this.totalIncome = 0.0, - }); - - @override - int compareTo(MoneyFlow other) { - return flow.compareTo(other.flow); - } + if (amount.abs() == 0.0) { + log("[MoneyFlow] Ignoring zero entry"); + return; + } - void addMoney(Money money) => add(money.amount, money.currency); + if (!isCurrencyCodeValid(currency)) { + throw FormatException( + "[MoneyFlow] Failed adding income, invalid currency code: $currency", + ); + } - void addExpense(double expense, String currency) => - totalExpense += Money.convertDouble( + if (amount.isNegative) { + _totalExpenseByCurrency.update( currency, - this.currency, - expense, + (value) => value + amount, + ifAbsent: () => amount, ); - void addIncome(double income, String currency) => - totalIncome += Money.convertDouble( + } else { + _totalIncomeByCurrency.update( currency, - this.currency, - income, + (value) => value + amount, + ifAbsent: () => amount, ); - void add(double amount, String currency) => amount.isNegative - ? addExpense(amount, currency) - : addIncome(amount, currency); - - double getTotalByType(TransactionType type) => switch (type) { - TransactionType.expense => totalExpense, - TransactionType.income => totalIncome, - TransactionType.transfer => 0, - }; - - operator +(MoneyFlow other) { - return MoneyFlow( - totalExpense: totalExpense + - Money.convertDouble(other.currency, currency, other.totalExpense), - totalIncome: totalIncome + - Money.convertDouble(other.currency, currency, other.totalIncome), - currency: currency, - ); + } + } + + void addAll(Iterable moneys) => moneys.forEach(add); + + /// Returns the expense for the given currency, excludes other currency expenses + Money getExpenseByCurrency(String currency) { + return Money(_totalExpenseByCurrency[currency] ?? 0.0, currency); + } + + /// Returns the income for the given currency, excludes other currency incomes + Money getIncomeByCurrency(String currency) { + return Money(_totalIncomeByCurrency[currency] ?? 0.0, currency); + } + + Money getFlowByCurrency(String currency) { + return getIncomeByCurrency(currency) + getExpenseByCurrency(currency); + } + + /// Calls [getExpenseByCurrency] or [getIncomeByCurrency] based on [type] + /// + /// If type is transfer, returns `Money(0.0, currency)` + Money getByTypeAndCurrency(String currency, TransactionType type) { + return switch (type) { + TransactionType.expense => getExpenseByCurrency(currency), + TransactionType.income => getIncomeByCurrency(currency), + _ => Money(0.0, currency), + }; } - operator -(MoneyFlow other) { - return MoneyFlow( - totalExpense: totalExpense - - Money.convertDouble(other.currency, currency, other.totalExpense), - totalIncome: totalIncome - - Money.convertDouble(other.currency, currency, other.totalIncome), - currency: currency, - ); + /// Returns the converted sum of all expenses in given [currency], + /// or rates.baseCurrency if null + Money getTotalExpense(ExchangeRates rates, String? currency) { + currency ??= rates.baseCurrency; + + double amount = 0.0; + + for (final entry in _totalExpenseByCurrency.entries) { + if (entry.key == currency) { + amount += entry.value; + } else { + amount += Money.convertDouble(entry.key, currency, entry.value, rates); + } + } + + return Money(amount, currency); } - operator -() { - return MoneyFlow( - totalExpense: -totalExpense, - totalIncome: -totalIncome, - currency: currency, - ); + /// Returns the converted sum of all incomes in given [currency], + /// or rates.baseCurrency if null + Money getTotalIncome(ExchangeRates rates, String? currency) { + currency ??= rates.baseCurrency; + + double amount = 0.0; + + for (final entry in _totalIncomeByCurrency.entries) { + if (entry.key == currency) { + amount += entry.value; + } else { + amount += Money.convertDouble(entry.key, currency, entry.value, rates); + } + } + + return Money(amount, currency); + } + + Money getTotalByType( + TransactionType type, + ExchangeRates rates, + String? currency, + ) { + currency ??= LocalPreferences().getPrimaryCurrency(); + + return switch (type) { + TransactionType.expense => getTotalExpense(rates, currency), + TransactionType.income => getTotalIncome(rates, currency), + _ => Money(0.0, currency), + }; + } + + Money getTotalFlow(ExchangeRates rates, String? currency) { + currency ??= LocalPreferences().getPrimaryCurrency(); + + return getTotalIncome(rates, currency) + getTotalExpense(rates, currency); } } diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 246f731..990f07d 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -4,7 +4,6 @@ import 'dart:math' as math; import 'package:flow/data/flow_analytics.dart'; import 'package:flow/data/memo.dart'; -import 'package:flow/data/money.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/data/prefs/frecency_group.dart'; import 'package:flow/data/transactions_filter.dart'; @@ -97,7 +96,6 @@ extension MainActions on ObjectBox { required DateTime from, required DateTime to, bool ignoreTransfers = true, - bool omitZeroes = true, String? currencyOverride, }) async { final Condition dateFilter = @@ -120,13 +118,8 @@ extension MainActions on ObjectBox { flow[categoryUuid] ??= MoneyFlow( associatedData: transaction.category.target, - currency: currencyOverride ?? transaction.currency, ); - flow[categoryUuid]!.addMoney(transaction.money); - } - - if (omitZeroes) { - flow.removeWhere((key, value) => value.isEmpty); + flow[categoryUuid]!.add(transaction.money); } return FlowAnalytics(flow: flow, from: from, to: to); @@ -160,17 +153,14 @@ extension MainActions on ObjectBox { flow[accountUuid] ??= MoneyFlow( associatedData: transaction.account.target, - currency: currencyOverride ?? transaction.currency, ); - flow[accountUuid]!.addMoney(transaction.money); + flow[accountUuid]!.add(transaction.money); } - assert(!flow.containsKey(Uuid.NAMESPACE_NIL), - "There is no way you've managed to make a transaction without an account"); - - if (omitZeroes) { - flow.removeWhere((key, value) => value.isEmpty); - } + assert( + !flow.containsKey(Uuid.NAMESPACE_NIL), + "There is no way you've managed to make a transaction without an account", + ); return FlowAnalytics(from: from, to: to, flow: flow); } @@ -396,11 +386,10 @@ extension TransactionListActions on Iterable { expenses.fold(0, (value, element) => value + element.amount); double get sum => fold(0, (value, element) => value + element.amount); - MoneyFlow get flow => MoneyFlow( - totalExpense: expenseSum, - totalIncome: incomeSum, - currency: firstOrNull?.currency ?? Money.invalidCurrency, - ); + MoneyFlow get flow => MoneyFlow() + ..addAll( + map((transaction) => transaction.money), + ); /// If [mergeFutureTransactions] is set to true, transactions in future /// relative to [anchor] will be grouped into the same group diff --git a/lib/routes/account_page.dart b/lib/routes/account_page.dart index b9723f0..8ad8c12 100644 --- a/lib/routes/account_page.dart +++ b/lib/routes/account_page.dart @@ -5,7 +5,6 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/objectbox/objectbox.g.dart'; -import 'package:flow/prefs.dart'; import 'package:flow/routes/error_page.dart'; import 'package:flow/widgets/category/transactions_info.dart'; import 'package:flow/widgets/flow_card.dart'; @@ -87,11 +86,11 @@ class _AccountPageState extends State { final bool noTransactions = (transactions?.length ?? 0) == 0; - final MoneyFlow flow = transactions?.flow ?? - MoneyFlow( - currency: transactions?.firstOrNull?.currency ?? - LocalPreferences().getPrimaryCurrency(), - ); + final MoneyFlow flow = transactions?.flow ?? MoneyFlow(); + final double totalIncome = + flow.getIncomeByCurrency(account.currency).amount; + final double totalExpense = + flow.getExpenseByCurrency(account.currency).amount; const double firstHeaderTopPadding = 0.0; @@ -105,7 +104,7 @@ class _AccountPageState extends State { const SizedBox(height: 8.0), TransactionsInfo( count: transactions?.length, - flow: flow.flow, + flow: totalIncome + totalExpense, icon: account.icon, ), const SizedBox(height: 12.0), @@ -113,14 +112,14 @@ class _AccountPageState extends State { children: [ Expanded( child: FlowCard( - flow: flow.totalIncome, + flow: totalIncome, type: TransactionType.income, ), ), const SizedBox(width: 12.0), Expanded( child: FlowCard( - flow: flow.totalExpense, + flow: totalExpense, type: TransactionType.expense, ), ), diff --git a/lib/routes/category_page.dart b/lib/routes/category_page.dart index ea808c2..ed45297 100644 --- a/lib/routes/category_page.dart +++ b/lib/routes/category_page.dart @@ -1,3 +1,4 @@ +import 'package:flow/data/exchange_rates.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/entity/category.dart'; import 'package:flow/entity/transaction.dart'; @@ -77,6 +78,8 @@ class _CategoryPageState extends State { if (this.category == null) return const ErrorPage(); final Category category = this.category!; + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + final ExchangeRates? rates = ExchangeRates.getPrimaryCurrencyRates(); return StreamBuilder>( stream: qb(range) @@ -87,11 +90,7 @@ class _CategoryPageState extends State { final bool noTransactions = (transactions?.length ?? 0) == 0; - final MoneyFlow flow = transactions?.flow ?? - MoneyFlow( - currency: transactions?.firstOrNull?.currency ?? - LocalPreferences().getPrimaryCurrency(), - ); + final MoneyFlow flow = transactions?.flow ?? MoneyFlow(); const double firstHeaderTopPadding = 0.0; @@ -105,7 +104,9 @@ class _CategoryPageState extends State { const SizedBox(height: 8.0), TransactionsInfo( count: transactions?.length, - flow: flow.flow, + flow: rates == null + ? flow.getFlowByCurrency(primaryCurrency).amount + : flow.getTotalFlow(rates, primaryCurrency).amount, icon: category.icon, ), const SizedBox(height: 12.0), @@ -113,14 +114,18 @@ class _CategoryPageState extends State { children: [ Expanded( child: FlowCard( - flow: flow.totalIncome, + flow: rates == null + ? flow.getIncomeByCurrency(primaryCurrency).amount + : flow.getTotalIncome(rates, primaryCurrency).amount, type: TransactionType.income, ), ), const SizedBox(width: 12.0), Expanded( child: FlowCard( - flow: flow.totalExpense, + flow: rates == null + ? flow.getExpenseByCurrency(primaryCurrency).amount + : flow.getTotalExpense(rates, primaryCurrency).amount, type: TransactionType.expense, ), ), diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 868039d..5b090fe 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,4 +1,7 @@ +import 'package:flow/data/chart_data.dart'; +import 'package:flow/data/exchange_rates.dart'; import 'package:flow/data/flow_analytics.dart'; +import 'package:flow/data/money.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/entity/transaction.dart'; import 'package:flow/l10n/flow_localizations.dart'; @@ -41,26 +44,11 @@ class _StatsTabState extends State @override Widget build(BuildContext context) { - final Map expenses = analytics == null - ? {} - : Map.fromEntries( - analytics!.flow.entries - .where((element) => element.value.totalExpense < 0) - .toList() - ..sort( - (a, b) => b.value.totalExpense.compareTo(a.value.totalExpense), - ), - ); - final Map incomes = analytics == null - ? {} - : Map.fromEntries( - analytics!.flow.entries - .where((element) => element.value.totalIncome > 0) - .toList() - ..sort( - (a, b) => a.value.totalIncome.compareTo(b.value.totalIncome), - ), - ); + final Map expenses = + _prepareChartData(analytics?.flow, TransactionType.expense); + + final Map incomes = + _prepareChartData(analytics?.flow, TransactionType.income); return Column( children: [ @@ -96,13 +84,11 @@ class _StatsTabState extends State data: expenses, changeMode: changeMode, range: range, - type: TransactionType.expense, ), PieGraphView( data: incomes, changeMode: changeMode, range: range, - type: TransactionType.income, ), ], ), @@ -160,4 +146,49 @@ class _StatsTabState extends State range = newRange; }); } + + Map> _prepareChartData( + Map>? raw, + TransactionType type, + ) { + if (raw == null || raw.isEmpty) return {}; + + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + final ExchangeRates? rates = ExchangeRates.getPrimaryCurrencyRates(); + + final Map cache = {}; + + final List>> filtered = + raw.entries.where((entry) { + if (rates != null) { + cache[entry.key] = + entry.value.getTotalByType(type, rates, primaryCurrency); + } else { + cache[entry.key] = + entry.value.getByTypeAndCurrency(primaryCurrency, type); + } + + if (type == TransactionType.expense) { + return cache[entry.key]!.amount < 0.0; + } else { + return cache[entry.key]!.amount > 0.0; + } + }).toList(); + + filtered.sort( + (a, b) => cache[b.key]!.tryCompareTo(cache[a.key]!), + ); + + return Map.fromEntries( + filtered.map( + (entry) => MapEntry>( + entry.key, + ChartData( + key: entry.key, + money: cache[entry.key]!, + associatedData: entry.value.associatedData), + ), + ), + ); + } } diff --git a/lib/routes/home/stats_tab/pie_graph_view.dart b/lib/routes/home/stats_tab/pie_graph_view.dart index 1f68263..03ea845 100644 --- a/lib/routes/home/stats_tab/pie_graph_view.dart +++ b/lib/routes/home/stats_tab/pie_graph_view.dart @@ -1,6 +1,5 @@ -import 'package:flow/data/money_flow.dart'; +import 'package:flow/data/chart_data.dart'; import 'package:flow/entity/category.dart'; -import 'package:flow/entity/transaction.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/widgets/home/stats/group_pie_chart.dart'; import 'package:flow/widgets/home/stats/no_data.dart'; @@ -9,17 +8,15 @@ import 'package:go_router/go_router.dart'; import 'package:moment_dart/moment_dart.dart'; class PieGraphView extends StatelessWidget { - final Map data; + final Map data; final TimeRange range; final void Function() changeMode; - final TransactionType type; const PieGraphView({ super.key, required this.data, required this.range, required this.changeMode, - required this.type, }); @override @@ -33,7 +30,6 @@ class PieGraphView extends StatelessWidget { return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 96.0, top: 8.0), child: GroupPieChart( - type: type, data: data, unresolvedDataTitle: "category.none".t(context), onReselect: (key) { diff --git a/lib/routes/new_transaction/select_account_sheet.dart b/lib/routes/new_transaction/select_account_sheet.dart index 848a9f6..70e7b9b 100644 --- a/lib/routes/new_transaction/select_account_sheet.dart +++ b/lib/routes/new_transaction/select_account_sheet.dart @@ -28,6 +28,7 @@ class SelectAccountSheet extends StatelessWidget { scrollableContentMaxHeight: MediaQuery.of(context).size.height * .5, trailing: accounts.isEmpty ? OverflowBar( + alignment: MainAxisAlignment.end, children: [ Button( onTap: () => context.pop(false), diff --git a/lib/routes/new_transaction/select_category_sheet.dart b/lib/routes/new_transaction/select_category_sheet.dart index ede807e..4ab2c05 100644 --- a/lib/routes/new_transaction/select_category_sheet.dart +++ b/lib/routes/new_transaction/select_category_sheet.dart @@ -23,6 +23,7 @@ class SelectCategorySheet extends StatelessWidget { return ModalSheet.scrollable( title: Text("transaction.edit.selectCategory".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop(const Optional(null)), diff --git a/lib/routes/preferences/language_selection_sheet.dart b/lib/routes/preferences/language_selection_sheet.dart index cdee727..30dbf2d 100644 --- a/lib/routes/preferences/language_selection_sheet.dart +++ b/lib/routes/preferences/language_selection_sheet.dart @@ -15,6 +15,7 @@ class LanguageSelectionSheet extends StatelessWidget { scrollableContentMaxHeight: MediaQuery.of(context).size.height, title: Text("preferences.language.choose".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop(), diff --git a/lib/routes/preferences/theme_selection_sheet.dart b/lib/routes/preferences/theme_selection_sheet.dart index 6183fb7..12764b3 100644 --- a/lib/routes/preferences/theme_selection_sheet.dart +++ b/lib/routes/preferences/theme_selection_sheet.dart @@ -16,6 +16,7 @@ class ThemeSelectionSheet extends StatelessWidget { scrollableContentMaxHeight: MediaQuery.of(context).size.height, title: Text("preferences.themeMode.choose".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop(), diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 68e3944..e93e938 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -68,6 +68,7 @@ extension CustomDialogs on BuildContext { builder: (context) => ModalSheet( title: Text(title ?? "general.areYouSure".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ Button( onTap: () => context.pop(false), diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index 3bbbc58..21bc97e 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -2,11 +2,11 @@ import 'dart:math' as math; import 'package:auto_size_text/auto_size_text.dart'; import 'package:fl_chart/fl_chart.dart'; +import 'package:flow/data/chart_data.dart'; +import 'package:flow/data/exchange_rates.dart'; import 'package:flow/data/flow_icon.dart'; -import 'package:flow/data/money_flow.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; -import 'package:flow/entity/transaction.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/main.dart'; import 'package:flow/theme/primary_colors.dart'; @@ -20,13 +20,13 @@ class GroupPieChart extends StatefulWidget { final bool scrollLegendWithin; - final Map> data; + final Map> data; final String? unresolvedDataTitle; final void Function(String key)? onReselect; - final TransactionType type; + final ExchangeRates? rates; static const double graphSizeMax = 320.0; static const double graphHoleSizeMin = 96.0; @@ -34,11 +34,11 @@ class GroupPieChart extends StatefulWidget { const GroupPieChart({ super.key, required this.data, - required this.type, this.chartPadding = const EdgeInsets.all(24.0), this.scrollLegendWithin = false, this.unresolvedDataTitle, this.onReselect, + this.rates, }); @override @@ -46,16 +46,14 @@ class GroupPieChart extends StatefulWidget { } class _GroupPieChartState extends State> { - late Map> data; - - double get totalValue => data.values.fold( - 0.0, - (previousValue, element) => - previousValue + - element.getTotalByType( - widget.type, - ), - ); + late Map> data; + + double get totalValue { + return data.values.fold( + 0.0, + (previousValue, element) => previousValue + element.money.amount, + ); + } String? selectedKey; @@ -77,11 +75,11 @@ class _GroupPieChartState extends State> { @override Widget build(BuildContext context) { - final MoneyFlow? selectedSection = + final ChartData? selectedSection = selectedKey == null ? null : data[selectedKey!]; final String selectedSectionTotal = - selectedSection?.getTotalByType(widget.type).abs().formatMoney() ?? "-"; + selectedSection?.money.amount.abs().formatMoney() ?? "-"; return MouseRegion( onHover: (event) { @@ -208,7 +206,7 @@ class _GroupPieChartState extends State> { } PieChartSectionData sectionData( - MoneyFlow flow, { + ChartData data, { required double radius, bool selected = false, int index = 0, @@ -225,15 +223,15 @@ class _GroupPieChartState extends State> { return PieChartSectionData( color: color, radius: radius, - value: flow.getTotalByType(widget.type).abs(), - title: resolveName(flow.associatedData), + value: data.displayTotal, + title: resolveName(data.associatedData), showTitle: false, badgeWidget: selected ? resolveBadgeWidget( - flow.associatedData, + data.associatedData, color: color, backgroundColor: backgroundColor, - percent: flow.getTotalByType(widget.type) / totalValue, + percent: data.displayTotal / totalValue, ) : null, badgePositionPercentageOffset: 0.8, diff --git a/lib/widgets/month_selector_sheet.dart b/lib/widgets/month_selector_sheet.dart index ce4b8d7..3f800f0 100644 --- a/lib/widgets/month_selector_sheet.dart +++ b/lib/widgets/month_selector_sheet.dart @@ -36,6 +36,7 @@ class _MonthSelectorSheetState extends State { return ModalSheet( title: Text("general.timeSelector.select.month".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => setState(() { diff --git a/lib/widgets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart b/lib/widgets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart index a131e5b..180acb2 100644 --- a/lib/widgets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart +++ b/lib/widgets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart @@ -49,6 +49,7 @@ class _SelectCharFlowIconSheetState extends State { scrollableContentMaxHeight: scrollableContentMaxHeight, title: Text("flowIcon.type.character".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop(value), diff --git a/lib/widgets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart b/lib/widgets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart index bdf1dd1..0c9d4ff 100644 --- a/lib/widgets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart +++ b/lib/widgets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart @@ -83,6 +83,7 @@ class _SelectIconFlowIconSheetState extends State ], ), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop(value), diff --git a/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart b/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart index 201ad49..0a23d7d 100644 --- a/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart +++ b/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart @@ -80,6 +80,7 @@ class _SelectImageFlowIconSheetState extends State { return ModalSheet( title: Text("flowIcon.type.image".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop(value), diff --git a/lib/widgets/select_time_range_mode_sheet.dart b/lib/widgets/select_time_range_mode_sheet.dart index e002a42..ce93a68 100644 --- a/lib/widgets/select_time_range_mode_sheet.dart +++ b/lib/widgets/select_time_range_mode_sheet.dart @@ -31,6 +31,7 @@ class SelectTimeRangeModeSheet extends StatelessWidget { scrollableContentMaxHeight: scrollableContentMaxHeight, title: Text("tabs.stats.timeRange.select".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop(null), diff --git a/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart b/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart index d551600..cd0d029 100644 --- a/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart @@ -48,6 +48,7 @@ class _SelectMultiAccountSheetState extends State { widget.titleOverride ?? "transaction.edit.selectAccount".t(context)), scrollableContentMaxHeight: MediaQuery.of(context).size.height * .5, trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop([]), diff --git a/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart b/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart index 4e684e0..51304da 100644 --- a/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart @@ -43,6 +43,7 @@ class _SelectMultiCategorySheetState extends State { return ModalSheet.scrollable( title: Text("transaction.edit.selectCategory.multiple".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => context.pop([]), diff --git a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart index 2843c56..57e57a5 100644 --- a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart +++ b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart @@ -38,6 +38,7 @@ class _TransactionSearchSheetState extends State { return ModalSheet.scrollable( title: Text('transactions.query.filter.keyword'.t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: clear, diff --git a/lib/widgets/year_selector_sheet.dart b/lib/widgets/year_selector_sheet.dart index 5cbedaf..ed9375d 100644 --- a/lib/widgets/year_selector_sheet.dart +++ b/lib/widgets/year_selector_sheet.dart @@ -40,6 +40,7 @@ class _YearSelectorSheetState extends State { return ModalSheet( title: Text("general.timeSelector.select.year".t(context)), trailing: OverflowBar( + alignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => setState(() { diff --git a/pubspec.lock b/pubspec.lock index 351e597..371a8d2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1400,5 +1400,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.5.0-259.0.dev <4.0.0" + dart: ">=3.5.0 <4.0.0" flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7e122c0..45b0578 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: "0.5.5+51" environment: - sdk: ">=3.4.0 <4.0.0" + sdk: ">=3.5.0 <4.0.0" dependencies: app_settings: ^5.1.1 From a6ee10d024e8e4fae7014d9a8eb2cc4e52dcfa4e Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 19:41:54 +0800 Subject: [PATCH 22/28] refactor: exchange service --- lib/data/chart_data.dart | 4 +- lib/data/exchange_rates.dart | 90 ----------------------- lib/main.dart | 10 +-- lib/routes/category_page.dart | 4 +- lib/routes/home/stats_tab.dart | 119 +++++++++++++++++------------- lib/services/exchange_rates.dart | 120 +++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 150 deletions(-) create mode 100644 lib/services/exchange_rates.dart diff --git a/lib/data/chart_data.dart b/lib/data/chart_data.dart index eb7e21d..b0f5bb7 100644 --- a/lib/data/chart_data.dart +++ b/lib/data/chart_data.dart @@ -1,5 +1,5 @@ -import 'package:flow/data/exchange_rates.dart'; import 'package:flow/data/money.dart'; +import 'package:flow/services/exchange_rates.dart'; class ChartData implements Comparable> { final String key; @@ -18,7 +18,7 @@ class ChartData implements Comparable> { int compareTo(ChartData other) { return money.tryCompareToWithExchange( other.money, - ExchangeRates.getPrimaryCurrencyRates(), + ExchangeRatesService().getPrimaryCurrencyRates(), ); } } diff --git a/lib/data/exchange_rates.dart b/lib/data/exchange_rates.dart index b058802..3d92003 100644 --- a/lib/data/exchange_rates.dart +++ b/lib/data/exchange_rates.dart @@ -1,11 +1,3 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:flow/data/currencies.dart'; -import 'package:flow/data/exchange_rates_set.dart'; -import 'package:flow/prefs.dart'; -import 'package:http/http.dart' as http; import 'package:moment_dart/moment_dart.dart'; /// Uses endpoints from here: @@ -40,86 +32,4 @@ class ExchangeRates { double? getRate(String currency) { return rates[currency.toLowerCase()]?.toDouble(); } - - static final ExchangeRatesSet _cache = - ExchangeRatesSet({}); - - static void updateCache(String baseCurrency, ExchangeRates exchangeRates) { - _cache.set(baseCurrency, exchangeRates); - - try { - unawaited(LocalPreferences().exchangeRatesCache.set(_cache)); - } catch (e) { - log("Failed to update exchange rates cache", error: e); - } - } - - static ExchangeRates? getCachedRates(String baseCurrency) => - _cache.get(baseCurrency); - - static ExchangeRates? getPrimaryCurrencyRates() { - return _cache.get(LocalPreferences().getPrimaryCurrency()); - } - - static Future fetchRates( - String baseCurrency, [ - DateTime? dateTime, - ]) async { - final String normalizedCurrency = baseCurrency.trim().toLowerCase(); - - if (!isCurrencyCodeValid(normalizedCurrency)) { - throw FormatException("Invalid currency code: $baseCurrency"); - } - - final String dateParam = - dateTime == null ? "latest" : dateTime.format(payload: "yyyy-MM-dd"); - - Map? jsonResponse; - - try { - final response = await http.get(Uri.parse( - "https://$dateParam.currency-api.pages.dev/v1/currencies/$normalizedCurrency.json")); - jsonResponse = jsonDecode(response.body); - } catch (e) { - log("Failed to fetch exchange rates from side source", error: e); - } - - try { - final response = await http.get(Uri.parse( - "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@$dateParam/v1/currencies/$normalizedCurrency.json")); - jsonResponse = jsonDecode(response.body); - } catch (e) { - log("Failed to fetch exchange rates from main source", error: e); - } - - if (jsonResponse == null) { - throw Exception("Failed to fetch exchange rates"); - } - - final ExchangeRates exchangeRates = ExchangeRates.fromJson(jsonResponse); - - updateCache(baseCurrency, exchangeRates); - return exchangeRates; - } - - static Future tryFetchRates( - String baseCurrency, [ - DateTime? dateTime, - ]) async { - try { - log("[ExchangeRates] Fetching exchange rates for $baseCurrency"); - - final ExchangeRates exchangeRates = - await fetchRates(baseCurrency, dateTime); - - return exchangeRates; - } catch (e) { - log( - "[ExchangeRates] Failed to fetch exchange rates ($baseCurrency)", - error: e, - ); - - return _cache.get(baseCurrency); - } - } } diff --git a/lib/main.dart b/lib/main.dart index 26a987f..9ac426d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,7 +20,6 @@ import 'dart:developer'; import 'dart:io'; import 'package:flow/constants.dart'; -import 'package:flow/data/exchange_rates.dart'; import 'package:flow/entity/profile.dart'; import 'package:flow/entity/transaction.dart'; import 'package:flow/l10n/flow_localizations.dart'; @@ -28,6 +27,7 @@ import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/prefs.dart'; import 'package:flow/routes.dart'; +import 'package:flow/services/exchange_rates.dart'; import 'package:flow/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -61,6 +61,8 @@ void main() async { /// Set `sortOrder` values if there are any unset (-1) values await ObjectBox().updateAccountOrderList(ignoreIfNoUnsetValue: true); + ExchangeRatesService().init(); + runApp(const Flow()); } @@ -105,12 +107,6 @@ class FlowState extends State { if (ObjectBox().box().count(limit: 1) == 0) { Profile.createDefaultProfile(); } - - unawaited( - ExchangeRates.tryFetchRates( - LocalPreferences().getPrimaryCurrency(), - ), - ); } @override diff --git a/lib/routes/category_page.dart b/lib/routes/category_page.dart index ed45297..9530a9d 100644 --- a/lib/routes/category_page.dart +++ b/lib/routes/category_page.dart @@ -8,6 +8,7 @@ import 'package:flow/objectbox/actions.dart'; import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/prefs.dart'; import 'package:flow/routes/error_page.dart'; +import 'package:flow/services/exchange_rates.dart'; import 'package:flow/widgets/category/transactions_info.dart'; import 'package:flow/widgets/flow_card.dart'; import 'package:flow/widgets/general/spinner.dart'; @@ -79,7 +80,8 @@ class _CategoryPageState extends State { final Category category = this.category!; final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); - final ExchangeRates? rates = ExchangeRates.getPrimaryCurrencyRates(); + final ExchangeRates? rates = + ExchangeRatesService().getPrimaryCurrencyRates(); return StreamBuilder>( stream: qb(range) diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 5b090fe..8f6c9dc 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -10,6 +10,7 @@ import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/prefs.dart'; import 'package:flow/routes/home/stats_tab/pie_graph_view.dart'; +import 'package:flow/services/exchange_rates.dart'; import 'package:flow/widgets/general/spinner.dart'; import 'package:flow/widgets/time_range_selector.dart'; import 'package:flow/widgets/utils/time_and_range.dart'; @@ -44,58 +45,76 @@ class _StatsTabState extends State @override Widget build(BuildContext context) { - final Map expenses = - _prepareChartData(analytics?.flow, TransactionType.expense); - - final Map incomes = - _prepareChartData(analytics?.flow, TransactionType.income); - - return Column( - children: [ - Material( - elevation: 1.0, - child: Container( - padding: const EdgeInsets.all(16.0).copyWith(bottom: 8.0), - width: double.infinity, - child: TimeRangeSelector( - initialValue: range, - onChanged: updateRange, - ), - ), - ), - if (busy) - const Padding( - padding: EdgeInsets.all(24.0), - child: Spinner(), - ) - else ...[ - TabBar( - controller: _tabController, - tabs: [ - Tab(text: TransactionType.expense.localizedTextKey.t(context)), - Tab(text: TransactionType.income.localizedTextKey.t(context)), - ], - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - PieGraphView( - data: expenses, - changeMode: changeMode, - range: range, + return ValueListenableBuilder( + valueListenable: ExchangeRatesService().exchangeRatesCache, + builder: (context, exchangeRatesCache, child) { + final ExchangeRates? rates = exchangeRatesCache?.get( + LocalPreferences().getPrimaryCurrency(), + ); + + final Map expenses = _prepareChartData( + analytics?.flow, + TransactionType.expense, + rates, + ); + + final Map incomes = _prepareChartData( + analytics?.flow, + TransactionType.income, + rates, + ); + + return Column( + children: [ + Material( + elevation: 1.0, + child: Container( + padding: const EdgeInsets.all(16.0).copyWith(bottom: 8.0), + width: double.infinity, + child: TimeRangeSelector( + initialValue: range, + onChanged: updateRange, + ), ), - PieGraphView( - data: incomes, - changeMode: changeMode, - range: range, + ), + if (busy) + const Padding( + padding: EdgeInsets.all(24.0), + child: Spinner(), + ) + else ...[ + TabBar( + controller: _tabController, + tabs: [ + Tab( + text: TransactionType.expense.localizedTextKey.t(context), + ), + Tab( + text: TransactionType.income.localizedTextKey.t(context), + ), + ], ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + PieGraphView( + data: expenses, + changeMode: changeMode, + range: range, + ), + PieGraphView( + data: incomes, + changeMode: changeMode, + range: range, + ), + ], + ), + ) ], - ), - ) - ], - ], - ); + ], + ); + }); } void updateRange(TimeRange newRange) { @@ -150,11 +169,11 @@ class _StatsTabState extends State Map> _prepareChartData( Map>? raw, TransactionType type, + ExchangeRates? rates, ) { if (raw == null || raw.isEmpty) return {}; final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); - final ExchangeRates? rates = ExchangeRates.getPrimaryCurrencyRates(); final Map cache = {}; diff --git a/lib/services/exchange_rates.dart b/lib/services/exchange_rates.dart new file mode 100644 index 0000000..6d50e35 --- /dev/null +++ b/lib/services/exchange_rates.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'package:flow/data/currencies.dart'; +import 'package:flow/data/exchange_rates.dart'; +import 'package:flow/data/exchange_rates_set.dart'; +import 'package:flow/prefs.dart'; +import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; +import 'package:moment_dart/moment_dart.dart'; + +class ExchangeRatesService { + final ValueNotifier exchangeRatesCache = + ValueNotifier(null); + + static ExchangeRatesService? _instance; + + factory ExchangeRatesService() => + _instance ??= ExchangeRatesService._internal(); + + ExchangeRatesService._internal(); + + void init() { + final ExchangeRatesSet? exchangeRates = + LocalPreferences().exchangeRatesCache.get(); + + if (exchangeRates != null) { + exchangeRatesCache.value = exchangeRates; + } + + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + tryFetchRates(primaryCurrency); + } + + ExchangeRates? getPrimaryCurrencyRates() { + return exchangeRatesCache.value + ?.get(LocalPreferences().getPrimaryCurrency()); + } + + Future fetchRates( + String baseCurrency, [ + DateTime? dateTime, + ]) async { + final String normalizedCurrency = baseCurrency.trim().toLowerCase(); + + if (!isCurrencyCodeValid(normalizedCurrency)) { + throw FormatException("Invalid currency code: $baseCurrency"); + } + + final String dateParam = + dateTime == null ? "latest" : dateTime.format(payload: "yyyy-MM-dd"); + + Map? jsonResponse; + + try { + final response = await http.get(Uri.parse( + "https://$dateParam.currency-api.pages.dev/v1/currencies/$normalizedCurrency.json")); + jsonResponse = jsonDecode(response.body); + } catch (e) { + log("Failed to fetch exchange rates from side source", error: e); + } + + try { + final response = await http.get(Uri.parse( + "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@$dateParam/v1/currencies/$normalizedCurrency.json")); + jsonResponse = jsonDecode(response.body); + } catch (e) { + log("Failed to fetch exchange rates from main source", error: e); + } + + if (jsonResponse == null) { + throw Exception("Failed to fetch exchange rates"); + } + + final ExchangeRates exchangeRates = ExchangeRates.fromJson(jsonResponse); + + updateCache(baseCurrency, exchangeRates); + + return exchangeRates; + } + + Future tryFetchRates( + String baseCurrency, [ + DateTime? dateTime, + ]) async { + try { + log("[ExchangeRates] Fetching exchange rates for $baseCurrency"); + + final ExchangeRates exchangeRates = + await fetchRates(baseCurrency, dateTime); + + return exchangeRates; + } catch (e) { + log( + "[ExchangeRates] Failed to fetch exchange rates ($baseCurrency)", + error: e, + ); + + return exchangeRatesCache.value?.get(baseCurrency); + } + } + + void updateCache(String baseCurrency, ExchangeRates exchangeRates) { + ExchangeRatesSet? current = exchangeRatesCache.value; + + if (current == null) { + current = ExchangeRatesSet({ + baseCurrency: exchangeRates, + }); + } else { + current.set(baseCurrency, exchangeRates); + exchangeRatesCache.value = current; + } + + try { + LocalPreferences().exchangeRatesCache.set(current); + } catch (e) { + log("Failed to update exchange rates cache", error: e); + } + } +} From d03e23547306b48fcdcbd77ab6ee598b156c8ba6 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 20:12:31 +0800 Subject: [PATCH 23/28] disable onReselect --- lib/widgets/home/stats/group_pie_chart.dart | 218 +++++++++----------- 1 file changed, 103 insertions(+), 115 deletions(-) diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index 21bc97e..0355c37 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -12,7 +12,6 @@ import 'package:flow/main.dart'; import 'package:flow/theme/primary_colors.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/home/stats/pie_percent_badge.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Flow; class GroupPieChart extends StatefulWidget { @@ -57,8 +56,6 @@ class _GroupPieChartState extends State> { String? selectedKey; - bool usingMouse = false; - @override void initState() { super.initState(); @@ -81,127 +78,118 @@ class _GroupPieChartState extends State> { final String selectedSectionTotal = selectedSection?.money.amount.abs().formatMoney() ?? "-"; - return MouseRegion( - onHover: (event) { - if (event.kind == PointerDeviceKind.mouse) { - setState(() { - usingMouse = true; - }); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16.0), - Text( - "tabs.stats.chart.total".t(context), - style: context.textTheme.labelMedium, - ), - Text( - totalValue.formatMoney(), - style: context.textTheme.headlineMedium, - ), - Padding( - padding: widget.chartPadding, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: GroupPieChart.graphSizeMax, - maxWidth: GroupPieChart.graphSizeMax, - ), - child: AspectRatio( - aspectRatio: 1.0, - child: LayoutBuilder( - builder: (context, constraints) { - final double size = constraints.maxWidth; - - final double centerHoleDiameter = - math.max(size * 0.5, GroupPieChart.graphHoleSizeMin); - final double radius = (size - centerHoleDiameter) * 0.5; - - return Stack( - children: [ - PieChart( - PieChartData( - pieTouchData: PieTouchData( - touchCallback: (event, response) { - if (!event.isInterestedForInteractions || - response == null || - response.touchedSection == null) { - return; - } - - final int index = response - .touchedSection!.touchedSectionIndex; - - if (index > -1) { - final String newSelectedKey = - data.entries.elementAt(index).key; - - if (!usingMouse && - newSelectedKey == selectedKey) { - widget.onReselect?.call(newSelectedKey); - } - - setState(() { - selectedKey = newSelectedKey; - }); - } - }, - ), - sectionsSpace: 0.0, - centerSpaceRadius: centerHoleDiameter / 2, - startDegreeOffset: -90.0, - sections: data.entries.indexed - .map( - (e) => sectionData( - data[e.$2.key]!, - selected: e.$2.key == selectedKey, - index: e.$1, - radius: radius, - ), - ) - .toList(), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16.0), + Text( + "tabs.stats.chart.total".t(context), + style: context.textTheme.labelMedium, + ), + Text( + totalValue.formatMoney(), + style: context.textTheme.headlineMedium, + ), + Padding( + padding: widget.chartPadding, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: GroupPieChart.graphSizeMax, + maxWidth: GroupPieChart.graphSizeMax, + ), + child: AspectRatio( + aspectRatio: 1.0, + child: LayoutBuilder( + builder: (context, constraints) { + final double size = constraints.maxWidth; + + final double centerHoleDiameter = + math.max(size * 0.5, GroupPieChart.graphHoleSizeMin); + final double radius = (size - centerHoleDiameter) * 0.5; + + return Stack( + children: [ + PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: (event, response) { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + return; + } + + final int index = + response.touchedSection!.touchedSectionIndex; + + if (index > -1) { + final String newSelectedKey = + data.entries.elementAt(index).key; + + // if (!usingMouse && + // newSelectedKey == selectedKey) { + // widget.onReselect?.call(newSelectedKey); + // } + + setState(() { + selectedKey = newSelectedKey; + }); + } + }, ), + sectionsSpace: 0.0, + centerSpaceRadius: centerHoleDiameter / 2, + startDegreeOffset: -90.0, + sections: data.entries.indexed + .map( + (e) => sectionData( + data[e.$2.key]!, + selected: e.$2.key == selectedKey, + index: e.$1, + radius: radius, + ), + ) + .toList(), ), - Positioned.fill( - child: Center( - child: ClipOval( - child: Container( - width: centerHoleDiameter, - height: centerHoleDiameter, - alignment: Alignment.center, - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - resolveName( - selectedSection?.associatedData, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - AutoSizeText( - selectedSectionTotal, - textAlign: TextAlign.center, - style: context.textTheme.headlineSmall, + ), + Positioned.fill( + child: Center( + child: ClipOval( + child: Container( + width: centerHoleDiameter, + height: centerHoleDiameter, + alignment: Alignment.center, + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + resolveName( + selectedSection?.associatedData, ), - ], - ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + AutoSizeText( + selectedSectionTotal, + textAlign: TextAlign.center, + style: context.textTheme.headlineSmall, + ), + ], ), ), ), - ) - ], - ); - }, - ), + ), + ) + ], + ); + }, ), ), ), - ], - ), + ), + ], ); } From f8dd042b8dbda1c6a35420092b0bec5e393136c3 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 20:12:36 +0800 Subject: [PATCH 24/28] refetch currencies --- assets/l10n/en_US.json | 2 + assets/l10n/it_IT.json | 2 + assets/l10n/mn_MN.json | 2 + lib/data/money_flow.dart | 5 ++ lib/routes/home/stats_tab.dart | 2 + .../home/stats/exchange_missing_notice.dart | 65 +++++++++++++++++++ 6 files changed, 78 insertions(+) create mode 100644 lib/widgets/home/stats/exchange_missing_notice.dart diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 2297c70..44837e4 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -194,6 +194,8 @@ "tabs.stats.chart.total": "Total", "tabs.stats.chart.noData": "No data to show", "tabs.stats.chart.select.clickToSelect": "Click to select", + "tabs.stats.chart.noExchangeRatesWarning": "Missing exchange rate data. Transactions in non-primary currencies are not displayed.", + "tabs.stats.chart.noExchangeRatesWarning.retry": "Retry", "tabs.accounts": "Accounts", "tabs.accounts.reorder": "Reorder accounts", "tabs.accounts.reorder.guide": "Long press and drag", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index b3b6fa0..24a9ba8 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -194,6 +194,8 @@ "tabs.stats.chart.total": "Totale", "tabs.stats.chart.noData": "Nessun dato da mostrare", "tabs.stats.chart.select.clickToSelect": "Clicca per selezionare", + "tabs.stats.chart.noExchangeRatesWarning": "Dati dei tassi di cambio mancanti. Le transazioni in valute diverse dalla principale non sono visualizzate.", + "tabs.stats.chart.noExchangeRatesWarning.retry": "Riprova", "tabs.accounts": "Conti", "tabs.accounts.reorder": "Riordina i conti", "tabs.accounts.reorder.guide": "Premi a lungo e trascina", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 9f6292c..cef89c4 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -194,6 +194,8 @@ "tabs.stats.chart.total": "Нийт", "tabs.stats.chart.noData": "Харуулах өгөгдөл байхгүй байна", "tabs.stats.chart.select.clickToSelect": "Товшиж сонгоно уу", + "tabs.stats.chart.noExchangeRatesWarning": "Валютын ханшийн мэдээлэл байхгүй учир үндсэн валютаас ({currency}) бусад гүйлгээнүүд харагдахгүй байна", + "tabs.stats.chart.noExchangeRatesWarning.retry": "Дахин оролдох", "tabs.accounts": "Данснууд", "tabs.accounts.reorder": "Дараалал өөрчлөх", "tabs.accounts.reorder.guide": "Удаан дарж чирнэ үү", diff --git a/lib/data/money_flow.dart b/lib/data/money_flow.dart index c4261ce..e986ae1 100644 --- a/lib/data/money_flow.dart +++ b/lib/data/money_flow.dart @@ -12,6 +12,9 @@ class MoneyFlow { final Map _totalExpenseByCurrency = {}; final Map _totalIncomeByCurrency = {}; + int expenseCount = 0; + int incomeCount = 0; + MoneyFlow({this.associatedData}); void add(Money money) { @@ -35,12 +38,14 @@ class MoneyFlow { (value) => value + amount, ifAbsent: () => amount, ); + expenseCount++; } else { _totalIncomeByCurrency.update( currency, (value) => value + amount, ifAbsent: () => amount, ); + incomeCount++; } } diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 8f6c9dc..8c237cf 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -12,6 +12,7 @@ import 'package:flow/prefs.dart'; import 'package:flow/routes/home/stats_tab/pie_graph_view.dart'; import 'package:flow/services/exchange_rates.dart'; import 'package:flow/widgets/general/spinner.dart'; +import 'package:flow/widgets/home/stats/exchange_missing_notice.dart'; import 'package:flow/widgets/time_range_selector.dart'; import 'package:flow/widgets/utils/time_and_range.dart'; import 'package:flutter/material.dart'; @@ -94,6 +95,7 @@ class _StatsTabState extends State ), ], ), + if (rates == null) const ExchangeMissingNotice(), Expanded( child: TabBarView( controller: _tabController, diff --git a/lib/widgets/home/stats/exchange_missing_notice.dart b/lib/widgets/home/stats/exchange_missing_notice.dart new file mode 100644 index 0000000..110d8f7 --- /dev/null +++ b/lib/widgets/home/stats/exchange_missing_notice.dart @@ -0,0 +1,65 @@ +import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/prefs.dart'; +import 'package:flow/services/exchange_rates.dart'; +import 'package:flow/theme/helpers.dart'; +import 'package:flow/widgets/general/button.dart'; +import 'package:flutter/material.dart'; + +class ExchangeMissingNotice extends StatefulWidget { + const ExchangeMissingNotice({super.key}); + + @override + State createState() => _ExchangeMissingNoticeState(); +} + +class _ExchangeMissingNoticeState extends State { + bool busy = false; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + color: context.flowColors.expense.withAlpha(0x80), + child: Row( + children: [ + Flexible( + child: Text( + "tabs.stats.chart.noExchangeRatesWarning".t(context), + ), + ), + const SizedBox(width: 8.0), + Button( + onTap: busy ? null : fetchDefaultExchange, + child: Text( + "tabs.stats.chart.noExchangeRatesWarning.retry".t(context), + ), + ), + ], + ), + ); + } + + Future fetchDefaultExchange() async { + if (busy) { + return; + } + + setState(() { + busy = true; + }); + + try { + await ExchangeRatesService().tryFetchRates( + LocalPreferences().getPrimaryCurrency(), + ); + } finally { + setState(() { + busy = false; + }); + } + } +} From af6486f63cc5ea032490907e0169e9c9eb971e7c Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 20:13:23 +0800 Subject: [PATCH 25/28] fix value notifier bug --- lib/services/exchange_rates.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/services/exchange_rates.dart b/lib/services/exchange_rates.dart index 6d50e35..b2c2df3 100644 --- a/lib/services/exchange_rates.dart +++ b/lib/services/exchange_rates.dart @@ -108,9 +108,10 @@ class ExchangeRatesService { }); } else { current.set(baseCurrency, exchangeRates); - exchangeRatesCache.value = current; } + exchangeRatesCache.value = current; + try { LocalPreferences().exchangeRatesCache.set(current); } catch (e) { From a10b0cf198250c4da9c6f0ab2cbb8a9bd640a8f0 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 20:30:28 +0800 Subject: [PATCH 26/28] exchange data fetch retry --- lib/routes/home/profile_tab.dart | 10 ++++++++++ lib/routes/home/stats_tab.dart | 7 ++++--- lib/services/exchange_rates.dart | 5 +++++ lib/widgets/general/button.dart | 4 +++- lib/widgets/home/stats/exchange_missing_notice.dart | 1 + 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index 66fa730..e01d970 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -1,6 +1,7 @@ import 'package:flow/constants.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; +import 'package:flow/services/exchange_rates.dart'; import 'package:flow/sync/import.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/utils/toast.dart'; @@ -79,6 +80,11 @@ class _ProfileTabState extends State { leading: const Icon(Symbols.adb_rounded), onTap: () => ObjectBox().createAndPutDebugData(), ), + ListTile( + title: const Text("Clear exchange rates cache"), + onTap: () => clearExchangeRatesCache(), + leading: const Icon(Symbols.adb_rounded), + ), ListTile( title: Text(_debugDbBusy ? "Clearing database" : "Clear objectbox"), @@ -155,6 +161,10 @@ class _ProfileTabState extends State { } } + void clearExchangeRatesCache() { + ExchangeRatesService().debugClearCache(); + } + void import() async { try { await importBackupV1(); diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 8c237cf..afa7352 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -205,9 +205,10 @@ class _StatsTabState extends State (entry) => MapEntry>( entry.key, ChartData( - key: entry.key, - money: cache[entry.key]!, - associatedData: entry.value.associatedData), + key: entry.key, + money: cache[entry.key]!, + associatedData: entry.value.associatedData, + ), ), ), ); diff --git a/lib/services/exchange_rates.dart b/lib/services/exchange_rates.dart index b2c2df3..5ad2cb4 100644 --- a/lib/services/exchange_rates.dart +++ b/lib/services/exchange_rates.dart @@ -118,4 +118,9 @@ class ExchangeRatesService { log("Failed to update exchange rates cache", error: e); } } + + void debugClearCache() { + LocalPreferences().exchangeRatesCache.remove(); + exchangeRatesCache.value = null; + } } diff --git a/lib/widgets/general/button.dart b/lib/widgets/general/button.dart index a219eaf..8483bbb 100644 --- a/lib/widgets/general/button.dart +++ b/lib/widgets/general/button.dart @@ -61,7 +61,9 @@ class Button extends StatelessWidget { return Surface( shape: RoundedRectangleBorder(borderRadius: borderRadius), - // color: backgroundColor ?? context.colorScheme.primary, + color: onTap == null && onLongPress == null + ? context.colorScheme.onSurface.withOpacity(0.38) + : null, builder: (context) => InkWell( onTap: onTap, onLongPress: onLongPress, diff --git a/lib/widgets/home/stats/exchange_missing_notice.dart b/lib/widgets/home/stats/exchange_missing_notice.dart index 110d8f7..c9b3ed2 100644 --- a/lib/widgets/home/stats/exchange_missing_notice.dart +++ b/lib/widgets/home/stats/exchange_missing_notice.dart @@ -56,6 +56,7 @@ class _ExchangeMissingNoticeState extends State { await ExchangeRatesService().tryFetchRates( LocalPreferences().getPrimaryCurrency(), ); + await Future.delayed(const Duration(milliseconds: 1000)); } finally { setState(() { busy = false; From baa6b710501c8455784f184bfd3f2927a6fdb2bc Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 20:32:37 +0800 Subject: [PATCH 27/28] bump ver --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 45b0578..9dc7e17 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.5.5+51" +version: "0.6.0+52" environment: sdk: ">=3.5.0 <4.0.0" From 6ba565924bb22b457d500e9ddfa61b5c854506a6 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 10 Aug 2024 20:41:08 +0800 Subject: [PATCH 28/28] update changelog --- CHANGELOG.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a503ddb..cce7070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,20 @@ ## Beta 0.6.0 -* Updated theme to correct `activeColor` for radio/checkboxes and its lists -* Added filters to home tab, and added preferences for home page -* Added error builder for Image `FlowIcon`s when the image is missing -* TimeRange selector now listens for mouse wheel scroll -* Added exchange rates +* Added exchange rates, currently only works in Stats tab * Stats tab: - * Uses primary currency to show all the data + * Converts all money to the primary currency * Now separates income/expense -* Frecency data updates one per day max. (was updating at every launch before) - -TODO: -- [ ] Fallback when there's no exchange rate -- [ ] Optimize FlowAnalytics adding to group by currencies first, then adding -- [ ] Test, test, test + * Fallback when there's no exchange rate +* Home tab + * Search, filter transactions + * Set planned transaction preferences @ preferences page +* Minor, QoL + * Added error builder for Image `FlowIcon`s when the image is missing + * TimeRange selector now listens for mouse wheel scroll + * Frecency data updates one per day max. (was updating at every launch before) + * Updated theme to correct `activeColor` for radio/checkboxes and its lists +* Flutter upgraded to 3.24.0 +* Dart upgraded to 3.5 ## Beta 0.5.5