From aa0bcd92cd245ae09319827b2be0eebd4575f062 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 16 Jan 2025 23:20:35 +0800 Subject: [PATCH 01/19] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ede60eb1..ce74befb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Improvements -* Date, time translations now support Turkish, fixes [#259](https://github.com/flow-mn/flow/issues/259) +* Date, time translations now support Turkish, fixes [#266](https://github.com/flow-mn/flow/issues/266) ## Beta 0.10.1 From 36248e9f0992d0f050b10fbec17010ef95d05a1a Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 18 Jan 2025 13:36:49 +0800 Subject: [PATCH 02/19] Update deps --- pubspec.lock | 76 ++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 342b70f4..23c53815 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -226,10 +226,10 @@ packages: dependency: transitive description: name: dart_earcut - sha256: "41b493147e30a051efb2da1e3acb7f38fe0db60afba24ac1ea5684cee272721e" + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" dart_style: dependency: transitive description: @@ -346,10 +346,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: c724234b05e378383e958f3e82ca84a3e1e3c06a0898462044dd8a24b1ee9864 + sha256: "5276944c6ffc975ae796569a826c38a62d2abcf264e26b88fa6f482e107f4237" url: "https://pub.dev" source: hosted - version: "0.70.0" + version: "0.70.2" flat_buffers: dependency: transitive description: @@ -431,10 +431,10 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "31cd0885738e87c72d6f055564d37fabcdacee743b396b78c7636c169cac64f5" + sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c url: "https://pub.dev" source: hosted - version: "0.14.2" + version: "0.14.3" flutter_lints: dependency: "direct dev" description: @@ -460,10 +460,10 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: "255b00afa1a7bad19727da6a7780cf3db6c3c12e68d302d85e0ff1fdf173db9e" + sha256: e37f4c69a07b07bb92622ef6b131a53c9aae48f64b176340af9e8e5238718487 url: "https://pub.dev" source: hosted - version: "0.7.4+3" + version: "0.7.5" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -582,10 +582,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "2fd11229f59e23e967b0775df8d5948a519cd7e1e8b6e849729e010587b46539" + sha256: "7c2d40b59890a929824f30d442e810116caf5088482629c894b9e4478c67472d" url: "https://pub.dev" source: hosted - version: "14.6.2" + version: "14.6.3" graphs: dependency: transitive description: @@ -614,10 +614,10 @@ packages: dependency: transitive description: name: http_parser - sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" iconsax_flutter: dependency: transitive description: @@ -646,10 +646,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: aa6f1280b670861ac45220cc95adc59bb6ae130259d36f980ccb62220dc5e59f + sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c url: "https://pub.dev" source: hosted - version: "0.8.12+19" + version: "0.8.12+20" image_picker_for_web: dependency: transitive description: @@ -662,10 +662,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "4f0568120c6fcc0aaa04511cb9f9f4d29fc3d0139884b1d06be88dcec7641d6b" + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" url: "https://pub.dev" source: hosted - version: "0.8.12+1" + version: "0.8.12+2" image_picker_linux: dependency: transitive description: @@ -686,10 +686,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.10.1" image_picker_windows: dependency: transitive description: @@ -830,10 +830,10 @@ packages: dependency: transitive description: name: markdown - sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "7.3.0" matcher: dependency: transitive description: @@ -854,10 +854,10 @@ packages: dependency: "direct main" description: name: material_symbols_icons - sha256: "64404f47f8e0a9d20478468e5decef867a688660bad7173adcd20418d7f892c9" + sha256: "89aac72d25dd49303f71b3b1e70f8374791846729365b25bebc2a2531e5b86cd" url: "https://pub.dev" source: hosted - version: "4.2801.0" + version: "4.2801.1" meta: dependency: transitive description: @@ -926,10 +926,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "70c421fe9d9cc1a9a7f3b05ae56befd469fe4f8daa3b484823141a55442d858d" + sha256: "739e0a5c3c4055152520fa321d0645ee98e932718b4c8efeeb51451968fe0790" url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "8.1.3" package_info_plus_platform_interface: dependency: transitive description: @@ -1134,18 +1134,18 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" share_plus: dependency: "direct main" description: name: share_plus - sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400" + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da url: "https://pub.dev" source: hosted - version: "10.1.3" + version: "10.1.4" share_plus_platform_interface: dependency: transitive description: @@ -1158,18 +1158,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "3c7e73920c694a436afaf65ab60ce3453d91f84208d761fbd83fc21182134d93" + sha256: a752ce92ea7540fc35a0d19722816e04d0e72828a4200e83a98cf1a1eb524c9a url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "02a7d8a9ef346c9af715811b01fbd8e27845ad2c41148eefd31321471b41863d" + sha256: "138b7bbbc7f59c56236e426c37afb8f78cbc57b094ac64c440e0bb90e380a4f5" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.2" shared_preferences_foundation: dependency: transitive description: @@ -1411,18 +1411,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" uuid: dependency: "direct main" description: @@ -1483,10 +1483,10 @@ packages: dependency: transitive description: name: win32 - sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69" + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.10.0" wkt_parser: dependency: transitive description: From 0206c2500d442eccacc336d9dc8b3af8aa3bf057 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 18 Jan 2025 16:45:53 +0800 Subject: [PATCH 03/19] draft 1 --- lib/data/flow_analytics.dart | 7 +- lib/data/flow_report.dart | 92 +++++++ lib/objectbox/actions.dart | 129 ++++++---- lib/routes.dart | 19 ++ lib/routes/home/stats_tab.dart | 290 ++++++++++------------ lib/routes/stats/stats_by_group_page.dart | 222 +++++++++++++++++ 6 files changed, 551 insertions(+), 208 deletions(-) create mode 100644 lib/data/flow_report.dart create mode 100644 lib/routes/stats/stats_by_group_page.dart diff --git a/lib/data/flow_analytics.dart b/lib/data/flow_analytics.dart index 62ac1f9b..bae7ec58 100644 --- a/lib/data/flow_analytics.dart +++ b/lib/data/flow_analytics.dart @@ -1,14 +1,13 @@ import "package:flow/data/money_flow.dart"; +import "package:moment_dart/moment_dart.dart"; class FlowAnalytics { - final DateTime from; - final DateTime to; + final TimeRange range; final Map> flow; const FlowAnalytics({ - required this.from, - required this.to, + required this.range, required this.flow, }); } diff --git a/lib/data/flow_report.dart b/lib/data/flow_report.dart new file mode 100644 index 00000000..d3016910 --- /dev/null +++ b/lib/data/flow_report.dart @@ -0,0 +1,92 @@ +import "package:flow/data/money.dart"; +import "package:flow/data/money_flow.dart"; +import "package:flow/prefs.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Only capable of working with the primary currency +class FlowStandardReport { + final MonthTimeRange current; + final MonthTimeRange? previous; + + final Map> currentFlowByDay; + final Map>? previousFlowByDay; + + late final Map dailyExpenditure; + late final Map? previousDailyExpenditure; + + late final Money expenseSum; + late final Money incomeSum; + late final Money flow; + + late final Money dailyAvgExpenditure; + late final Money dailyAvgIncome; + late final Money dailyAvgFlow; + + late final Money dailyMaxExpenditure; + late final Money dailyMinExpenditure; + + late final Money? previousExpenseSum; + late final Money? currentExpenseSumForecast; + + FlowStandardReport({ + required this.current, + required this.previous, + required this.currentFlowByDay, + required this.previousFlowByDay, + }) { + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + + dailyExpenditure = currentFlowByDay.map( + (key, value) => MapEntry( + key, + value.getExpenseByCurrency(primaryCurrency).amount.abs(), + ), + ); + previousDailyExpenditure = previousFlowByDay?.map( + (key, value) => MapEntry( + key, + value.getExpenseByCurrency(primaryCurrency).amount.abs(), + ), + ); + + expenseSum = -Money( + currentFlowByDay.values + .map((flow) => + dailyExpenditure[flow.associatedData?.from.date.day] ?? 0) + .reduce((a, b) => a + b), + primaryCurrency, + ); + incomeSum = currentFlowByDay.values + .map((flow) => flow.getIncomeByCurrency(primaryCurrency)) + .reduce((a, b) => a + b); + flow = incomeSum + expenseSum; + + dailyAvgExpenditure = expenseSum / current.duration.inDays.toDouble(); + dailyAvgIncome = incomeSum / current.duration.inDays.toDouble(); + dailyAvgFlow = dailyAvgExpenditure + dailyAvgIncome; + + dailyMaxExpenditure = currentFlowByDay.values + .map((flow) => flow.getExpenseByCurrency(primaryCurrency)) + .reduce((a, b) => a.amount < b.amount ? a : b); + dailyMinExpenditure = currentFlowByDay.values + .map((flow) => flow.getExpenseByCurrency(primaryCurrency)) + .reduce((a, b) => a.amount > b.amount ? a : b); + + if (previousFlowByDay != null && previousDailyExpenditure != null) { + previousExpenseSum = -Money( + previousFlowByDay!.values + .map((flow) => + previousDailyExpenditure![flow.associatedData?.from.date.day] ?? + 0) + .reduce((a, b) => a + b), + primaryCurrency, + ); + } + + final int daysLeft = current.duration.inDays - + current.from.difference(DateTime.now()).inDays; + + currentExpenseSumForecast = + expenseSum + (dailyAvgExpenditure * daysLeft.toDouble()); + } +} diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index d817e6dd..d7d1229e 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -4,6 +4,7 @@ import "dart:math" as math; import "package:flow/data/exchange_rates.dart"; import "package:flow/data/flow_analytics.dart"; +import "package:flow/data/flow_report.dart"; import "package:flow/data/memo.dart"; import "package:flow/data/money.dart"; import "package:flow/data/money_flow.dart"; @@ -142,15 +143,10 @@ extension MainActions on ObjectBox { await ObjectBox().box().putManyAsync(accounts); } - /// Returns a map of category uuid -> [MoneyFlow] - Future> flowByCategories({ - required DateTime from, - required DateTime to, - bool ignoreTransfers = true, - String? currencyOverride, - }) async { + /// Returns all non-pending transactions in given [range] + Future> transcationsByRange(TimeRange range) async { final Condition dateFilter = Transaction_.transactionDate - .betweenDate(from, to) + .betweenDate(range.from, range.to) .and(Transaction_.isPending .isNull() .or(Transaction_.isPending.notEquals(true))); @@ -162,64 +158,113 @@ extension MainActions on ObjectBox { transactionsQuery.close(); - final Map> flow = {}; + return transactions; + } + + Future>> flowBy( + List transactions, + T? Function(Transaction t) keyBy, + K Function(Transaction t)? associateBy) async { + final Map> flow = {}; for (final transaction in transactions) { - if (ignoreTransfers && transaction.isTransfer) continue; + final T? key = keyBy(transaction); - final String categoryUuid = - transaction.category.target?.uuid ?? Namespace.nil.value; + if (key == null) continue; - flow[categoryUuid] ??= MoneyFlow( - associatedData: transaction.category.target, - ); - flow[categoryUuid]!.add(transaction.money); + final K? associatedData = associateBy?.call(transaction); + + flow[key] ??= MoneyFlow(associatedData: associatedData); + flow[key]!.add(transaction.money); } - return FlowAnalytics(flow: flow, from: from, to: to); + return flow; } /// Returns a map of category uuid -> [MoneyFlow] - Future> flowByAccounts({ - required DateTime from, - required DateTime to, + Future> flowByCategories({ + required TimeRange range, bool ignoreTransfers = true, - bool omitZeroes = true, String? currencyOverride, }) async { - final Condition dateFilter = Transaction_.transactionDate - .betweenDate(from, to) - .and(Transaction_.isPending - .isNull() - .or(Transaction_.isPending.notEquals(true))); - - final Query transactionsQuery = - ObjectBox().box().query(dateFilter).build(); + final List transactions = await transcationsByRange(range); - final List transactions = await transactionsQuery.findAsync(); + final flow = await flowBy(transactions, (t) { + if (ignoreTransfers && t.isTransfer) return null; - transactionsQuery.close(); + return t.category.target?.uuid ?? Namespace.nil.value; + }, (t) => t.category.target); - final Map> flow = {}; + return FlowAnalytics(flow: flow, range: range); + } - for (final transaction in transactions) { - if (ignoreTransfers && transaction.isTransfer) continue; + /// Returns a map of category uuid -> [MoneyFlow] + Future> flowByAccounts({ + required TimeRange range, + bool ignoreTransfers = true, + bool omitZeroes = true, + String? currencyOverride, + }) async { + final List transactions = await transcationsByRange(range); - final String accountUuid = - transaction.account.target?.uuid ?? Namespace.nil.value; + final Map> flow = + await flowBy(transactions, (t) { + if (ignoreTransfers && t.isTransfer) return null; - flow[accountUuid] ??= MoneyFlow( - associatedData: transaction.account.target, - ); - flow[accountUuid]!.add(transaction.money); - } + return t.account.target?.uuid ?? Namespace.nil.value; + }, (t) => t.account.target!); assert( !flow.containsKey(Namespace.nil.value), "There is no way you've managed to make a transaction without an account", ); - return FlowAnalytics(from: from, to: to, flow: flow); + return FlowAnalytics(flow: flow, range: range); + } + + Future>> + _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(TimeRange range) async { + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + final ExchangeRates? rates = + ExchangeRatesService().getPrimaryCurrencyRates(); + + final List transactions = await transcationsByRange(range); + + final Map> result = {}; + + for (final Transaction transaction in transactions) { + if (transaction.isTransfer) continue; + + final DayTimeRange day = + DayTimeRange.fromDateTime(transaction.transactionDate); + + result[day.day] ??= MoneyFlow(associatedData: day); + + if (transaction.currency == primaryCurrency) { + result[day.day]!.add(transaction.money); + } else if (rates != null) { + result[day.day]!.add(transaction.money.convert(primaryCurrency, rates)); + } + } + + return result; + } + + Future generateReport() async { + final MonthTimeRange current = TimeRange.thisMonth(); + final MonthTimeRange previous = TimeRange.lastMonth(); + + final Map> currentFlowByDay = + await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(current); + final Map> previousFlowByDay = + await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(previous); + + return FlowStandardReport( + current: current, + previous: previous, + currentFlowByDay: currentFlowByDay, + previousFlowByDay: previousFlowByDay, + ); } Future> transactionTitleSuggestions({ diff --git a/lib/routes.dart b/lib/routes.dart index 5c5c5d98..21d981cc 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -30,6 +30,7 @@ import "package:flow/routes/setup/setup_onboarding_page.dart"; import "package:flow/routes/setup/setup_profile_page.dart"; import "package:flow/routes/setup/setup_profile_picture_page.dart"; import "package:flow/routes/setup_page.dart"; +import "package:flow/routes/stats/stats_by_group_page.dart"; import "package:flow/routes/support_page.dart"; import "package:flow/routes/transaction_page.dart"; import "package:flow/routes/transactions_page.dart"; @@ -326,5 +327,23 @@ final router = GoRouter( path: "/support", builder: (context, state) => const SupportPage(), ), + GoRoute( + path: "/stats/category", + builder: (context, state) { + final TimeRange? initialRange = + TimeRange.tryParse(state.uri.queryParameters["range"] ?? ""); + + return StatsByGroupPage(byCategory: true, initialRange: initialRange); + }, + ), + GoRoute( + path: "/stats/account", + builder: (context, state) { + final TimeRange? initialRange = + TimeRange.tryParse(state.uri.queryParameters["range"] ?? ""); + + return StatsByGroupPage(byCategory: false, initialRange: initialRange); + }, + ), ], ); diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 13edb2a6..e16ba4f3 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,20 +1,13 @@ -import "package:flow/data/chart_data.dart"; -import "package:flow/data/exchange_rates.dart"; -import "package:flow/data/flow_analytics.dart"; +import "dart:math" as math; + +import "package:fl_chart/fl_chart.dart"; +import "package:flow/data/flow_report.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"; -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/services/exchange_rates.dart"; +import "package:flow/theme/theme.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"; import "package:moment_dart/moment_dart.dart"; @@ -25,13 +18,9 @@ class StatsTab extends StatefulWidget { State createState() => _StatsTabState(); } -class _StatsTabState extends State - with SingleTickerProviderStateMixin { - late final TabController _tabController; - - TimeRange range = TimeRange.thisMonth(); - - FlowAnalytics? analytics; +class _StatsTabState extends State { + FlowStandardReport? report; + LineChartData? dailyExpenditureChartData; bool busy = false; @@ -39,95 +28,31 @@ class _StatsTabState extends State void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); - - fetch(true); + fetch(); } @override Widget build(BuildContext context) { - 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, - ), - ), - ), - 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), - ), - ], - ), - if (rates == null) const ExchangeMissingNotice(), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - PieGraphView( - data: expenses, - changeMode: changeMode, - range: range, - ), - PieGraphView( - data: incomes, - changeMode: changeMode, - range: range, - ), - ], - ), - ) - ], - ], - ); - }); - } - - void updateRange(TimeRange newRange) { - setState(() { - range = newRange; - }); + if (report == null) { + return const Spinner.center(); + } - fetch(true); + return SingleChildScrollView( + primary: true, + child: Column( + children: [ + if (dailyExpenditureChartData != null) + Container( + height: 300.0, + padding: EdgeInsets.all(16.0), + child: LineChart(dailyExpenditureChartData!), + ), + ], + ), + ); } - Future fetch(bool byCategory) async { + Future fetch() async { if (busy) return; setState(() { @@ -135,17 +60,8 @@ class _StatsTabState extends State }); try { - analytics = byCategory - ? await ObjectBox().flowByCategories( - from: range.from, - to: range.to, - currencyOverride: LocalPreferences().getPrimaryCurrency(), - ) - : await ObjectBox().flowByAccounts( - from: range.from, - to: range.to, - currencyOverride: LocalPreferences().getPrimaryCurrency(), - ); + report = await ObjectBox().generateReport(); + dailyExpenditureChartData = prepareDailyExpenseChartData(report); } finally { busy = false; @@ -155,63 +71,113 @@ class _StatsTabState extends State } } - Future changeMode() async { - final TimeRange? newRange = await showTimeRangePickerSheet( - context, - initialValue: range, - ); + LineChartData? prepareDailyExpenseChartData(FlowStandardReport? report) { + if (report == null) return null; - if (!mounted || newRange == null) return; - - setState(() { - range = newRange; - }); - } - - Map> _prepareChartData( - Map>? raw, - TransactionType type, - ExchangeRates? rates, - ) { - if (raw == null || raw.isEmpty) return {}; + final int maxDays = math.max(report.current.from.endOfMonth().day, + report.previous?.from.endOfMonth().day ?? 0); + final bool hasPrevious = report.previousFlowByDay != null; final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); - 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]!, - currency: primaryCurrency, - associatedData: entry.value.associatedData, + return LineChartData( + minX: 1.0, + maxX: maxDays.toDouble(), + minY: 0.0, + maxY: report.dailyMaxExpenditure.amount.abs(), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (touchedSpots) { + return touchedSpots.map((touchedSpot) { + final textStyle = TextStyle( + color: touchedSpot.bar.color, + fontWeight: FontWeight.bold, + fontSize: 14, + ); + final amount = + Money(touchedSpot.y, primaryCurrency).formattedCompact; + return LineTooltipItem(amount, textStyle); + }).toList(); + }, + ), + ), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => Text( + (1 + value.toInt()).toString(), + ), + interval: 5.0, + minIncluded: true, + maxIncluded: false, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => Text( + Money(value, primaryCurrency).formattedCompact, + ), + reservedSize: 48.0, ), ), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ), + gridData: FlGridData( + show: true, + horizontalInterval: 5.0, + verticalInterval: report.dailyAvgExpenditure.amount.abs(), + ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: context.colorScheme.onSurface.withAlpha(0x40), + width: 2.0, + ), + left: BorderSide( + color: context.colorScheme.onSurface.withAlpha(0x40), + width: 2.0, + ), + right: const BorderSide(color: Colors.transparent), + top: const BorderSide(color: Colors.transparent), + ), + ), + lineBarsData: [ + LineChartBarData( + isCurved: true, + barWidth: 2.0, + color: context.colorScheme.primary, + dotData: FlDotData(show: false), + isStrokeCapRound: true, + spots: List.generate( + maxDays, + (index) { + return FlSpot( + index.toDouble(), + report.dailyExpenditure[index + 1]?.abs() ?? 0.0, + ); + }, + ), + ), + if (hasPrevious) + LineChartBarData( + isCurved: true, + barWidth: 2.0, + color: context.colorScheme.primary.withAlpha(0x40), + dotData: FlDotData(show: false), + isStrokeCapRound: true, + spots: List.generate( + maxDays, + (index) => FlSpot( + index.toDouble(), + report.previousDailyExpenditure?[index + 1]?.abs() ?? 0, + ), + ), + ), + ], ); } } diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart new file mode 100644 index 00000000..a72fdda8 --- /dev/null +++ b/lib/routes/stats/stats_by_group_page.dart @@ -0,0 +1,222 @@ +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"; +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/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"; +import "package:moment_dart/moment_dart.dart"; + +class StatsByGroupPage extends StatefulWidget { + final TimeRange? initialRange; + final bool byCategory; + + const StatsByGroupPage({ + super.key, + this.initialRange, + this.byCategory = true, + }); + + @override + State createState() => StatsByGroupPageState(); +} + +class StatsByGroupPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late TimeRange range; + + FlowAnalytics? analytics; + + bool busy = false; + + @override + void initState() { + super.initState(); + + range = widget.initialRange ?? TimeRange.thisMonth(); + _tabController = TabController(length: 2, vsync: this); + + fetch(); + } + + @override + Widget build(BuildContext context) { + 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, + ), + ), + ), + 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), + ), + ], + ), + if (rates == null) const ExchangeMissingNotice(), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + PieGraphView( + data: expenses, + changeMode: changeMode, + range: range, + ), + PieGraphView( + data: incomes, + changeMode: changeMode, + range: range, + ), + ], + ), + ) + ], + ], + ); + }); + } + + void updateRange(TimeRange newRange) { + setState(() { + range = newRange; + }); + + fetch(); + } + + Future fetch() async { + if (busy) return; + + setState(() { + busy = true; + }); + + try { + analytics = widget.byCategory + ? await ObjectBox().flowByCategories( + range: range, + currencyOverride: LocalPreferences().getPrimaryCurrency(), + ) + : await ObjectBox().flowByAccounts( + range: range, + currencyOverride: LocalPreferences().getPrimaryCurrency(), + ); + } finally { + busy = false; + + if (mounted) { + setState(() {}); + } + } + } + + Future changeMode() async { + final TimeRange? newRange = await showTimeRangePickerSheet( + context, + initialValue: range, + ); + + if (!mounted || newRange == null) return; + + setState(() { + range = newRange; + }); + } + + Map> _prepareChartData( + Map>? raw, + TransactionType type, + ExchangeRates? rates, + ) { + if (raw == null || raw.isEmpty) return {}; + + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + + 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]!, + currency: primaryCurrency, + associatedData: entry.value.associatedData, + ), + ), + ), + ); + } +} From 914ccf7b8865d25cfdb40f1f567571ca30f919f3 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 23 Jan 2025 15:49:15 +0800 Subject: [PATCH 04/19] stats tab draft --- CHANGELOG.md | 4 + lib/data/flow_report.dart | 205 +++++++++++-- lib/objectbox/actions.dart | 46 --- lib/routes/home/stats_tab.dart | 257 ++++++++-------- lib/widgets/chart_legend.dart | 35 +++ lib/widgets/home/stats/range_daily_chart.dart | 283 ++++++++++++++++++ pubspec.lock | 4 +- pubspec.yaml | 4 +- 8 files changed, 622 insertions(+), 216 deletions(-) create mode 100644 lib/widgets/chart_legend.dart create mode 100644 lib/widgets/home/stats/range_daily_chart.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ce74befb..23b2259e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Beta 0.11.0 (next) + +* Reworked stats tab (ongoing) + ## Beta 0.10.2 ### Improvements diff --git a/lib/data/flow_report.dart b/lib/data/flow_report.dart index d3016910..242d80f0 100644 --- a/lib/data/flow_report.dart +++ b/lib/data/flow_report.dart @@ -1,18 +1,32 @@ +import "dart:math" as math; + +import "package:flow/data/exchange_rates.dart"; import "package:flow/data/money.dart"; import "package:flow/data/money_flow.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/services/exchange_rates.dart"; import "package:moment_dart/moment_dart.dart"; /// Only capable of working with the primary currency class FlowStandardReport { - final MonthTimeRange current; - final MonthTimeRange? previous; + final TimeRange current; + TimeRange? get previous { + if (current case PageableRange pageable) { + return pageable.last; + } + + return null; + } final Map> currentFlowByDay; final Map>? previousFlowByDay; + // current range stuff + late final Map dailyExpenditure; - late final Map? previousDailyExpenditure; late final Money expenseSum; late final Money incomeSum; @@ -25,15 +39,34 @@ class FlowStandardReport { late final Money dailyMaxExpenditure; late final Money dailyMinExpenditure; - late final Money? previousExpenseSum; late final Money? currentExpenseSumForecast; - FlowStandardReport({ + // [previous] range stuff + + late final Map? previousDailyExpenditure; + + late final Money? previousDailyAvgExpenditure; + late final Money? previousDailyAvgIncome; + late final Money? previousDailyAvgFlow; + + late final Money? previousExpenseSum; + late final Money? previousIncomeSum; + late final Money? previousFlow; + + FlowStandardReport._internal({ required this.current, - required this.previous, required this.currentFlowByDay, required this.previousFlowByDay, }) { + _init(); + } + + void _init() { + _initCurrent(); + _initPrev(); + } + + void _initCurrent() { final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); dailyExpenditure = currentFlowByDay.map( @@ -42,46 +75,43 @@ class FlowStandardReport { value.getExpenseByCurrency(primaryCurrency).amount.abs(), ), ); - previousDailyExpenditure = previousFlowByDay?.map( - (key, value) => MapEntry( - key, - value.getExpenseByCurrency(primaryCurrency).amount.abs(), - ), - ); expenseSum = -Money( - currentFlowByDay.values - .map((flow) => - dailyExpenditure[flow.associatedData?.from.date.day] ?? 0) - .reduce((a, b) => a + b), + currentFlowByDay.values.map((flow) { + final int dayOffset = + flow.associatedData!.from.difference(current.from).inDays; + return dailyExpenditure[dayOffset] ?? 0; + }).fold(0, (a, b) => a + b), primaryCurrency, ); incomeSum = currentFlowByDay.values .map((flow) => flow.getIncomeByCurrency(primaryCurrency)) - .reduce((a, b) => a + b); + .fold(Money(0, primaryCurrency), (a, b) => a + b); flow = incomeSum + expenseSum; - dailyAvgExpenditure = expenseSum / current.duration.inDays.toDouble(); - dailyAvgIncome = incomeSum / current.duration.inDays.toDouble(); + int uncountableDays = 0; + + for (int offset = current.duration.inDays; offset > 0; offset--) { + if (dailyExpenditure[offset] == null || dailyExpenditure[offset] == 0.0) { + uncountableDays++; + } else { + break; + } + } + + final int countableDays = + math.max(1, current.duration.inDays - uncountableDays); + + dailyAvgExpenditure = expenseSum / countableDays.toDouble(); + dailyAvgIncome = incomeSum / countableDays.toDouble(); dailyAvgFlow = dailyAvgExpenditure + dailyAvgIncome; dailyMaxExpenditure = currentFlowByDay.values .map((flow) => flow.getExpenseByCurrency(primaryCurrency)) - .reduce((a, b) => a.amount < b.amount ? a : b); + .fold(Money(0, primaryCurrency), (a, b) => a.amount < b.amount ? a : b); dailyMinExpenditure = currentFlowByDay.values .map((flow) => flow.getExpenseByCurrency(primaryCurrency)) - .reduce((a, b) => a.amount > b.amount ? a : b); - - if (previousFlowByDay != null && previousDailyExpenditure != null) { - previousExpenseSum = -Money( - previousFlowByDay!.values - .map((flow) => - previousDailyExpenditure![flow.associatedData?.from.date.day] ?? - 0) - .reduce((a, b) => a + b), - primaryCurrency, - ); - } + .fold(Money(0, primaryCurrency), (a, b) => a.amount > b.amount ? a : b); final int daysLeft = current.duration.inDays - current.from.difference(DateTime.now()).inDays; @@ -89,4 +119,115 @@ class FlowStandardReport { currentExpenseSumForecast = expenseSum + (dailyAvgExpenditure * daysLeft.toDouble()); } + + void _initPrev() { + final TimeRange? previous = this.previous; + + final bool canInit = previous != null && previousFlowByDay != null; + + if (!canInit) { + previousDailyAvgExpenditure = null; + previousDailyAvgIncome = null; + previousDailyAvgFlow = null; + previousExpenseSum = null; + previousIncomeSum = null; + previousFlow = null; + return; + } + + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + + previousDailyExpenditure = previousFlowByDay?.map( + (key, value) => MapEntry( + key, + value.getExpenseByCurrency(primaryCurrency).amount.abs(), + ), + ); + + previousExpenseSum = -Money( + previousFlowByDay!.values.map((flow) { + final int? dayOffset = + flow.associatedData?.from.difference(previous.from).inDays; + return previousDailyExpenditure![dayOffset] ?? 0; + }).fold(0, (a, b) => a + b), + primaryCurrency, + ); + previousIncomeSum = previousFlowByDay!.values + .map((flow) => flow.getIncomeByCurrency(primaryCurrency)) + .fold(Money(0, primaryCurrency), (a, b) => a == null ? b : (a + b)); + previousFlow = previousIncomeSum! + previousExpenseSum!; + + int uncountableDays = 0; + + for (int offset = previous.duration.inDays; offset > 0; offset--) { + if (previousDailyExpenditure![offset] == null || + previousDailyExpenditure![offset] == 0.0) { + uncountableDays++; + } else { + break; + } + } + + final int previousCountableDays = previousDailyExpenditure == null + ? 1 + : math.max( + 1, + previous.duration.inDays - uncountableDays, + ); + + previousDailyAvgExpenditure = + previousExpenseSum! / previousCountableDays.toDouble(); + previousDailyAvgIncome = + previousIncomeSum! / previousCountableDays.toDouble(); + previousDailyAvgFlow = + previousDailyAvgExpenditure! + previousDailyAvgIncome!; + } + + static Future generate(TimeRange range) async { + final Map> currentFlowByDay = + await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(range); + final Map>? previousFlowByDay = + switch (range) { + PageableRange pageable => + await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(pageable.last), + _ => null, + }; + + return FlowStandardReport._internal( + current: range, + currentFlowByDay: currentFlowByDay, + previousFlowByDay: previousFlowByDay, + ); + } + + static Future>> + _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(TimeRange range) async { + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + final ExchangeRates? rates = + ExchangeRatesService().getPrimaryCurrencyRates(); + + final List transactions = + await ObjectBox().transcationsByRange(range); + + final Map> result = {}; + + for (final Transaction transaction in transactions) { + if (transaction.isTransfer) continue; + + final DayTimeRange day = + DayTimeRange.fromDateTime(transaction.transactionDate); + final int dayOffset = day.from.difference(range.from).inDays.abs(); + + result[dayOffset] ??= MoneyFlow(associatedData: day); + + if (transaction.currency == primaryCurrency) { + result[dayOffset]!.add(transaction.money); + } else if (rates != null) { + result[dayOffset]! + .add(transaction.money.convert(primaryCurrency, rates)); + } + } + + return result; + } } diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index d7d1229e..65be63b3 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -4,7 +4,6 @@ import "dart:math" as math; import "package:flow/data/exchange_rates.dart"; import "package:flow/data/flow_analytics.dart"; -import "package:flow/data/flow_report.dart"; import "package:flow/data/memo.dart"; import "package:flow/data/money.dart"; import "package:flow/data/money_flow.dart"; @@ -222,51 +221,6 @@ extension MainActions on ObjectBox { return FlowAnalytics(flow: flow, range: range); } - Future>> - _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(TimeRange range) async { - final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); - final ExchangeRates? rates = - ExchangeRatesService().getPrimaryCurrencyRates(); - - final List transactions = await transcationsByRange(range); - - final Map> result = {}; - - for (final Transaction transaction in transactions) { - if (transaction.isTransfer) continue; - - final DayTimeRange day = - DayTimeRange.fromDateTime(transaction.transactionDate); - - result[day.day] ??= MoneyFlow(associatedData: day); - - if (transaction.currency == primaryCurrency) { - result[day.day]!.add(transaction.money); - } else if (rates != null) { - result[day.day]!.add(transaction.money.convert(primaryCurrency, rates)); - } - } - - return result; - } - - Future generateReport() async { - final MonthTimeRange current = TimeRange.thisMonth(); - final MonthTimeRange previous = TimeRange.lastMonth(); - - final Map> currentFlowByDay = - await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(current); - final Map> previousFlowByDay = - await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(previous); - - return FlowStandardReport( - current: current, - previous: previous, - currentFlowByDay: currentFlowByDay, - previousFlowByDay: previousFlowByDay, - ); - } - Future> transactionTitleSuggestions({ String? currentInput, int? accountId, diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index e16ba4f3..d7fe0a55 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,13 +1,13 @@ -import "dart:math" as math; +import "dart:ui"; -import "package:fl_chart/fl_chart.dart"; +import "package:auto_size_text/auto_size_text.dart"; import "package:flow/data/flow_report.dart"; -import "package:flow/data/money.dart"; -import "package:flow/objectbox.dart"; -import "package:flow/objectbox/actions.dart"; -import "package:flow/prefs.dart"; -import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/home/home/info_card.dart"; +import "package:flow/widgets/home/stats/range_daily_chart.dart"; +import "package:flow/widgets/time_range_selector.dart"; import "package:flutter/material.dart"; import "package:moment_dart/moment_dart.dart"; @@ -18,9 +18,12 @@ class StatsTab extends StatefulWidget { State createState() => _StatsTabState(); } -class _StatsTabState extends State { +class _StatsTabState extends State + with AutomaticKeepAliveClientMixin { + TimeRange range = TimeRange.thisMonth(); FlowStandardReport? report; - LineChartData? dailyExpenditureChartData; + + final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); bool busy = false; @@ -33,25 +36,119 @@ class _StatsTabState extends State { @override Widget build(BuildContext context) { - if (report == null) { - return const Spinner.center(); + super.build(context); + + if (busy && report == null) { + return Spinner.center(); } - return SingleChildScrollView( - primary: true, - child: Column( - children: [ - if (dailyExpenditureChartData != null) - Container( - height: 300.0, - padding: EdgeInsets.all(16.0), - child: LineChart(dailyExpenditureChartData!), - ), - ], - ), + final bool hasData = report != null && report!.currentFlowByDay.isNotEmpty; + + return Column( + children: [ + Frame.standalone( + child: TimeRangeSelector( + initialValue: range, + onChanged: updateRange, + ), + ), + Expanded( + child: hasData + ? SingleChildScrollView( + primary: true, + child: Column( + children: [ + ClipRect( + child: Stack( + children: [ + RangeDailyChart(report: report!), + if (busy) + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 2.0, + sigmaY: 2.0, + ), + child: Container(), + ), + ), + ], + ), + ), + const SizedBox(height: 96.0), + Frame( + child: Row( + children: [ + Expanded( + child: InfoCard( + title: "Avg. daily expense", + moneyText: MoneyText( + report!.dailyAvgExpenditure, + tapToToggleAbbreviation: true, + autoSizeGroup: autoSizeGroup, + ), + ), + ), + const SizedBox(width: 16.0), + Expanded( + child: InfoCard( + title: "Avg. daily income", + moneyText: MoneyText( + report!.dailyAvgIncome, + tapToToggleAbbreviation: true, + autoSizeGroup: autoSizeGroup, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16.0), + Frame( + child: Row( + children: [ + Expanded( + child: InfoCard( + title: + "Forecast for ${report!.current.format()}", + moneyText: MoneyText( + report!.currentExpenseSumForecast, + tapToToggleAbbreviation: true, + autoSizeGroup: autoSizeGroup, + ), + ), + ), + const SizedBox(width: 16.0), + Expanded( + child: InfoCard( + title: "Avg. daily flow", + moneyText: MoneyText( + report!.dailyAvgFlow, + tapToToggleAbbreviation: true, + autoSizeGroup: autoSizeGroup, + ), + ), + ), + ], + ), + ), + ], + ), + ) + : Text("No data to show"), + ), + ], ); } + void updateRange(TimeRange value) { + range = value; + fetch(); + + if (!mounted) return; + setState(() {}); + } + Future fetch() async { if (busy) return; @@ -60,8 +157,7 @@ class _StatsTabState extends State { }); try { - report = await ObjectBox().generateReport(); - dailyExpenditureChartData = prepareDailyExpenseChartData(report); + report = await FlowStandardReport.generate(range); } finally { busy = false; @@ -71,113 +167,6 @@ class _StatsTabState extends State { } } - LineChartData? prepareDailyExpenseChartData(FlowStandardReport? report) { - if (report == null) return null; - - final int maxDays = math.max(report.current.from.endOfMonth().day, - report.previous?.from.endOfMonth().day ?? 0); - final bool hasPrevious = report.previousFlowByDay != null; - - final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); - - return LineChartData( - minX: 1.0, - maxX: maxDays.toDouble(), - minY: 0.0, - maxY: report.dailyMaxExpenditure.amount.abs(), - lineTouchData: LineTouchData( - touchTooltipData: LineTouchTooltipData( - getTooltipItems: (touchedSpots) { - return touchedSpots.map((touchedSpot) { - final textStyle = TextStyle( - color: touchedSpot.bar.color, - fontWeight: FontWeight.bold, - fontSize: 14, - ); - final amount = - Money(touchedSpot.y, primaryCurrency).formattedCompact; - return LineTooltipItem(amount, textStyle); - }).toList(); - }, - ), - ), - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) => Text( - (1 + value.toInt()).toString(), - ), - interval: 5.0, - minIncluded: true, - maxIncluded: false, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) => Text( - Money(value, primaryCurrency).formattedCompact, - ), - reservedSize: 48.0, - ), - ), - rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), - ), - gridData: FlGridData( - show: true, - horizontalInterval: 5.0, - verticalInterval: report.dailyAvgExpenditure.amount.abs(), - ), - borderData: FlBorderData( - show: true, - border: Border( - bottom: BorderSide( - color: context.colorScheme.onSurface.withAlpha(0x40), - width: 2.0, - ), - left: BorderSide( - color: context.colorScheme.onSurface.withAlpha(0x40), - width: 2.0, - ), - right: const BorderSide(color: Colors.transparent), - top: const BorderSide(color: Colors.transparent), - ), - ), - lineBarsData: [ - LineChartBarData( - isCurved: true, - barWidth: 2.0, - color: context.colorScheme.primary, - dotData: FlDotData(show: false), - isStrokeCapRound: true, - spots: List.generate( - maxDays, - (index) { - return FlSpot( - index.toDouble(), - report.dailyExpenditure[index + 1]?.abs() ?? 0.0, - ); - }, - ), - ), - if (hasPrevious) - LineChartBarData( - isCurved: true, - barWidth: 2.0, - color: context.colorScheme.primary.withAlpha(0x40), - dotData: FlDotData(show: false), - isStrokeCapRound: true, - spots: List.generate( - maxDays, - (index) => FlSpot( - index.toDouble(), - report.previousDailyExpenditure?[index + 1]?.abs() ?? 0, - ), - ), - ), - ], - ); - } + @override + bool get wantKeepAlive => true; } diff --git a/lib/widgets/chart_legend.dart b/lib/widgets/chart_legend.dart new file mode 100644 index 00000000..c8d3f7d5 --- /dev/null +++ b/lib/widgets/chart_legend.dart @@ -0,0 +1,35 @@ +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +class ChartLegend extends StatelessWidget { + final Color color; + final String label; + + const ChartLegend({super.key, required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 16.0, + height: 16.0, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4.0), + border: Border.all( + color: context.colorScheme.onSurface, + width: 1.0, + ), + ), + ), + const SizedBox(width: 8.0), + Text( + label, + style: context.textTheme.bodyMedium, + ), + ], + ); + } +} diff --git a/lib/widgets/home/stats/range_daily_chart.dart b/lib/widgets/home/stats/range_daily_chart.dart new file mode 100644 index 00000000..730cd69b --- /dev/null +++ b/lib/widgets/home/stats/range_daily_chart.dart @@ -0,0 +1,283 @@ +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_report.dart"; +import "package:flow/data/money.dart"; +import "package:flow/prefs.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/chart_legend.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/general/spinner.dart"; +import "package:flutter/material.dart"; +import "package:flutter/scheduler.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Shows daily expenes for the given [FlowStandardReport]. +/// +/// Recommended time range is weeks, months. Not tested for anything else. +class RangeDailyChart extends StatefulWidget { + final FlowStandardReport report; + + final double height; + + final bool showLegend; + + const RangeDailyChart({ + super.key, + required this.report, + this.height = 300.0, + this.showLegend = true, + }); + + @override + State createState() => _RangeDailyChartState(); +} + +class _RangeDailyChartState extends State { + final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); + + LineChartData? dailyExpenditureChartData; + + @override + void initState() { + super.initState(); + + SchedulerBinding.instance.addPostFrameCallback((_) { + dailyExpenditureChartData = prepareDailyExpenseChartData(widget.report); + if (mounted) { + setState(() {}); + } + }); + } + + @override + void didUpdateWidget(RangeDailyChart oldWidget) { + if (oldWidget.report != widget.report) { + dailyExpenditureChartData = prepareDailyExpenseChartData(widget.report); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + final Widget child = Container( + height: widget.height, + padding: EdgeInsets.all(16.0), + child: dailyExpenditureChartData == null + ? Spinner.center() + : LineChart(dailyExpenditureChartData!), + ); + + final String? previousLabel = widget.report.previous?.format(); + + if (!widget.showLegend || previousLabel == null) { + return child; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + child, + const SizedBox(height: 12.0), + Wrap( + spacing: 12.0, + runSpacing: 12.0, + children: [ + ChartLegend( + color: context.colorScheme.primary, + label: widget.report.current.format(), + ), + ChartLegend( + color: context.colorScheme.primary.withAlpha(0x40), + label: previousLabel, + ), + ], + ), + ], + ); + } + + LineChartData? prepareDailyExpenseChartData(FlowStandardReport? report) { + if (report == null) return null; + + final int maxDays = calculateMaxDays(report.current); + final bool hasPrevious = report.previousFlowByDay != null; + + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + + final Color currentPeriod = context.colorScheme.primary; + final Color previousPeriod = context.colorScheme.primary.withAlpha(0x40); + + final Color textColor = context.colorScheme.onPrimary; + + return LineChartData( + minX: 0.0, + maxX: maxDays.toDouble(), + minY: 0.0, + // maxY: report.dailyMaxExpenditure.amount.abs(), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (touchedSpot) => textColor, + getTooltipItems: (touchedSpots) { + return touchedSpots.map((touchedSpot) { + final TextStyle textStyle = TextStyle( + color: touchedSpot.bar.color, + fontWeight: FontWeight.bold, + fontSize: 14.0, + ); + final String amount = + Money(touchedSpot.y, primaryCurrency).formattedCompact; + return LineTooltipItem(amount, textStyle); + }).toList(); + }, + ), + ), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: bottomTitles(report), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => MoneyText( + Money(value, primaryCurrency), + initiallyAbbreviated: true, + tapToToggleAbbreviation: false, + autoSize: true, + autoSizeGroup: autoSizeGroup, + displayAbsoluteAmount: true, + ), + reservedSize: 48.0, + ), + ), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: gridData(report), + extraLinesData: ExtraLinesData(horizontalLines: [ + HorizontalLine( + y: report.dailyAvgExpenditure.amount.abs(), + color: context.colorScheme.primary.withAlpha(0x40), + label: HorizontalLineLabel( + style: TextStyle( + color: context.colorScheme.primary.withAlpha(0xc0), + fontSize: 12.0, + ), + alignment: Alignment.topRight, + labelResolver: (p0) => + Money(p0.y, primaryCurrency).formattedCompact, + show: true, + ), + ), + ]), + borderData: borderData, + lineBarsData: [ + LineChartBarData( + barWidth: 2.0, + color: currentPeriod, + dotData: FlDotData(show: false), + isStrokeCapRound: true, + spots: List.generate( + maxDays, + (index) { + return FlSpot( + index.toDouble(), + report.dailyExpenditure[index + 1]?.abs() ?? 0.0, + ); + }, + ), + ), + if (hasPrevious) + LineChartBarData( + barWidth: 2.0, + color: previousPeriod, + dotData: FlDotData(show: false), + isStrokeCapRound: true, + spots: List.generate( + maxDays, + (index) => FlSpot( + index.toDouble(), + report.previousDailyExpenditure?[index + 1]?.abs() ?? 0, + ), + ), + ), + ], + ); + } + + FlBorderData get borderData => FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: context.colorScheme.onSurface.withAlpha(0x40), + width: 2.0, + ), + left: BorderSide( + color: context.colorScheme.onSurface.withAlpha(0x40), + width: 2.0, + ), + right: BorderSide.none, + top: BorderSide.none, + ), + ); + + int calculateMaxDays(TimeRange range) => switch (range) { + DayTimeRange() => 1, + MonthTimeRange() => math.max( + range.from.endOfMonth().day, + range.last.from.endOfMonth().day, + ), + TimeRange other => other.duration.inDays + }; + + FlGridData gridData(FlowStandardReport report) { + final double verticalInterval = switch (report.current) { + DayTimeRange() => 1.0, + MonthTimeRange() => 5.0, + YearTimeRange() => 30.0, + _ => math.max((report.current.duration.inDays / 7.0).floorToDouble(), 1), + }; + + // final double horizontalInterval = report.dailyAvgExpenditure.amount.abs(); + + return FlGridData( + show: true, + // horizontalInterval: horizontalInterval > 0 ? horizontalInterval : null, + verticalInterval: verticalInterval, + ); + } + + SideTitles bottomTitles(FlowStandardReport report) { + return switch (report.current) { + MonthTimeRange() => SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => + Text((value + 1.0).toStringAsFixed(0)), + interval: 3, + minIncluded: true, + maxIncluded: false, + ), + YearTimeRange yearTimeRange => SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final int month = yearTimeRange.from.isLeapYear + ? [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 303, 333] + .indexOf(value.toInt()) + : [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 302, 332] + .indexOf(value.toInt()); + + if (month < 0) return const SizedBox.shrink(); + + return Text(DateTime(1970, month + 1).toMoment().format("MMM")); + }, + interval: 1, + minIncluded: true, + maxIncluded: true, + ), + _ => SideTitles(showTitles: false), + }; + } +} diff --git a/pubspec.lock b/pubspec.lock index 23c53815..d71af99e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -886,10 +886,10 @@ packages: dependency: "direct main" description: name: moment_dart - sha256: "35b99c62689613e84880dac9d0e81d1a624e4edbdf79897cdb3d444e964bf25b" + sha256: "11dc05bb1be5a84dcab564ccecbfa1e7d4668551771ffe2aeff2fe4cf3ebcb3a" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.3.0" objectbox: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 263cd030..c33fc537 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.10.2+102" +version: "0.11.0+103" environment: sdk: ">=3.5.0 <4.0.0" @@ -42,7 +42,7 @@ dependencies: local_hero: ^0.3.0 local_settings: ^0.5.0 material_symbols_icons: ^4.2799.0 - moment_dart: ^3.2.1 + moment_dart: ^3.3.0 objectbox: ^4.0.3 objectbox_flutter_libs: ^4.0.3 package_info_plus: ^8.0.0 From 97b8fc9059f4a553e51090c44854b9a63ae64658 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 23 Jan 2025 17:54:41 +0800 Subject: [PATCH 05/19] draft 2 --- lib/routes/home/stats_tab.dart | 114 ++++++++++-------- .../home/stats_tab/info_card_with_delta.dart | 78 ++++++++++++ lib/widgets/home/home/info_card.dart | 5 +- 3 files changed, 145 insertions(+), 52 deletions(-) create mode 100644 lib/routes/home/stats_tab/info_card_with_delta.dart diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index d7fe0a55..33ad8aaa 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -2,10 +2,11 @@ import "dart:ui"; import "package:auto_size_text/auto_size_text.dart"; import "package:flow/data/flow_report.dart"; +import "package:flow/prefs.dart"; +import "package:flow/routes/home/stats_tab/info_card_with_delta.dart"; +import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/frame.dart"; -import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/general/spinner.dart"; -import "package:flow/widgets/home/home/info_card.dart"; import "package:flow/widgets/home/stats/range_daily_chart.dart"; import "package:flow/widgets/time_range_selector.dart"; import "package:flutter/material.dart"; @@ -25,6 +26,9 @@ class _StatsTabState extends State final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); + late final bool initiallyAbbreviated = + !LocalPreferences().preferFullAmounts.get(); + bool busy = false; @override @@ -75,63 +79,71 @@ class _StatsTabState extends State ], ), ), - const SizedBox(height: 96.0), - Frame( - child: Row( - children: [ - Expanded( - child: InfoCard( - title: "Avg. daily expense", - moneyText: MoneyText( - report!.dailyAvgExpenditure, - tapToToggleAbbreviation: true, - autoSizeGroup: autoSizeGroup, - ), - ), - ), - const SizedBox(width: 16.0), - Expanded( - child: InfoCard( - title: "Avg. daily income", - moneyText: MoneyText( - report!.dailyAvgIncome, - tapToToggleAbbreviation: true, - autoSizeGroup: autoSizeGroup, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16.0), - Frame( - child: Row( + const SizedBox(height: 24.0), + DefaultTextStyle( + style: context.textTheme.displaySmall!, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: InfoCard( - title: - "Forecast for ${report!.current.format()}", - moneyText: MoneyText( - report!.currentExpenseSumForecast, - tapToToggleAbbreviation: true, - autoSizeGroup: autoSizeGroup, - ), + Frame( + child: Row( + children: [ + Expanded( + child: InfoCardWithDelta( + title: "Avg. daily expense", + autoSizeGroup: autoSizeGroup, + money: report!.dailyAvgExpenditure, + previousMoney: + report!.previousDailyAvgExpenditure, + invertDelta: true, + ), + ), + const SizedBox(width: 16.0), + Expanded( + child: InfoCardWithDelta( + title: "Avg. daily income", + autoSizeGroup: autoSizeGroup, + money: report!.dailyAvgIncome, + previousMoney: + report!.previousDailyAvgIncome, + ), + ), + ], ), ), - const SizedBox(width: 16.0), - Expanded( - child: InfoCard( - title: "Avg. daily flow", - moneyText: MoneyText( - report!.dailyAvgFlow, - tapToToggleAbbreviation: true, - autoSizeGroup: autoSizeGroup, - ), + const SizedBox(height: 16.0), + Frame( + child: Row( + children: [ + if (report!.currentExpenseSumForecast != null) + Expanded( + child: InfoCardWithDelta( + title: + "Forecast for ${report!.current.format()}", + autoSizeGroup: autoSizeGroup, + money: + report!.currentExpenseSumForecast!, + previousMoney: + report!.previousExpenseSum, + ), + ), + const SizedBox(width: 16.0), + Expanded( + child: InfoCardWithDelta( + title: "Avg. daily flow", + autoSizeGroup: autoSizeGroup, + money: report!.dailyAvgFlow, + previousMoney: + report!.previousDailyAvgFlow, + ), + ), + ], ), ), ], ), ), + const SizedBox(height: 96.0), ], ), ) diff --git a/lib/routes/home/stats_tab/info_card_with_delta.dart b/lib/routes/home/stats_tab/info_card_with_delta.dart new file mode 100644 index 00000000..8ac51089 --- /dev/null +++ b/lib/routes/home/stats_tab/info_card_with_delta.dart @@ -0,0 +1,78 @@ +import "package:auto_size_text/auto_size_text.dart"; +import "package:flow/data/money.dart"; +import "package:flow/prefs.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/home/home/info_card.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons/symbols.dart"; + +class InfoCardWithDelta extends StatelessWidget { + final Money money; + final Money? previousMoney; + + final bool invertDelta; + + final AutoSizeGroup? autoSizeGroup; + + final String title; + + const InfoCardWithDelta({ + super.key, + required this.money, + required this.previousMoney, + required this.autoSizeGroup, + required this.title, + this.invertDelta = false, + }); + + @override + Widget build(BuildContext context) { + final double hundredPercent = previousMoney?.amount ?? 0; + final double delta = (hundredPercent == 0 || + hundredPercent.isNaN || + hundredPercent.isInfinite) + ? 0 + : (money.amount - hundredPercent) / hundredPercent; + + final String deltaString = "${(delta.abs() * 100).toStringAsFixed(1)}%"; + + final bool downtrend = invertDelta ^ delta.isNegative; + + final Color color = + downtrend ? context.flowColors.expense : context.flowColors.income; + + return InfoCard( + title: title, + moneyText: MoneyText( + money, + tapToToggleAbbreviation: true, + initiallyAbbreviated: !LocalPreferences().preferFullAmounts.get(), + autoSize: true, + autoSizeGroup: autoSizeGroup, + style: context.textTheme.displaySmall, + ), + delta: delta != 0.0 + ? Row( + mainAxisSize: MainAxisSize.min, + spacing: 4.0, + children: [ + Icon( + delta.isNegative + ? Symbols.arrow_downward + : Symbols.arrow_upward, + size: context.textTheme.titleSmall!.fontSize, + color: color, + ), + Text( + deltaString, + style: context.textTheme.titleSmall!.copyWith( + color: color, + ), + ) + ], + ) + : null, + ); + } +} diff --git a/lib/widgets/home/home/info_card.dart b/lib/widgets/home/home/info_card.dart index bad59467..5622b1a8 100644 --- a/lib/widgets/home/home/info_card.dart +++ b/lib/widgets/home/home/info_card.dart @@ -6,6 +6,7 @@ class InfoCard extends StatelessWidget { final String title; final Widget? moneyText; + final Widget? delta; final Widget? icon; @@ -14,6 +15,7 @@ class InfoCard extends StatelessWidget { required this.title, this.icon, this.moneyText, + this.delta, }); @override @@ -44,7 +46,8 @@ class InfoCard extends StatelessWidget { ], ], ), - if (moneyText != null) moneyText! + if (moneyText != null) moneyText!, + if (delta != null) delta! ], ), ), From 8982eac175ef71de876739f2419c34c664f6eb82 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 23 Jan 2025 17:54:51 +0800 Subject: [PATCH 06/19] bump build no --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index c33fc537..4f814386 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.11.0+103" +version: "0.11.0+104" environment: sdk: ">=3.5.0 <4.0.0" From 48fd6b62cfc997ea2ab838dcc9b9b8dd7fbda4e1 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 24 Jan 2025 09:39:46 +0800 Subject: [PATCH 07/19] fix forecast, update translations --- assets/l10n/en_IN.json | 7 +++++ assets/l10n/en_US.json | 7 +++++ assets/l10n/it_IT.json | 7 +++++ assets/l10n/mn_MN.json | 7 +++++ assets/l10n/tr_TR.json | 31 ++++++++++++++++++- lib/data/flow_report.dart | 3 +- lib/routes/home/profile_tab.dart | 2 +- lib/routes/home/stats_tab.dart | 23 ++++++++++---- .../button_order_preferences_page.dart | 2 +- .../preferences/numpad_preferences_page.dart | 2 +- .../transfer_preferences_page.dart | 2 +- lib/routes/stats/stats_by_group_page.dart | 2 +- .../transaction_type_button.dart | 0 .../numpad_selector_radio.dart | 0 .../{prefs => preferences}/profile_card.dart | 0 .../combine_transfer_radio.dart.dart | 2 +- .../demo_transaction_list_tile.dart | 0 .../home/stats}/info_card_with_delta.dart | 0 lib/widgets/home/stats/no_data.dart | 19 ++++++------ .../home/stats}/pie_graph_view.dart | 2 +- pubspec.yaml | 2 +- 21 files changed, 94 insertions(+), 26 deletions(-) rename lib/{routes => widgets/home}/preferences/button_order_preferences/transaction_type_button.dart (100%) rename lib/{routes => widgets/home}/preferences/numpad_preferences/numpad_selector_radio.dart (100%) rename lib/widgets/home/{prefs => preferences}/profile_card.dart (100%) rename lib/{routes => widgets/home}/preferences/transfer_preferences/combine_transfer_radio.dart.dart (96%) rename lib/{routes => widgets/home}/preferences/transfer_preferences/demo_transaction_list_tile.dart (100%) rename lib/{routes/home/stats_tab => widgets/home/stats}/info_card_with_delta.dart (100%) rename lib/{routes/home/stats_tab => widgets/home/stats}/pie_graph_view.dart (97%) diff --git a/assets/l10n/en_IN.json b/assets/l10n/en_IN.json index 8c5cad3a..8abe690e 100644 --- a/assets/l10n/en_IN.json +++ b/assets/l10n/en_IN.json @@ -240,6 +240,13 @@ "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.stats.dailyReport.dailyAvgExpense": "Avg. daily expense", + "tabs.stats.dailyReport.dailyAvgIncome": "Avg. daily income", + "tabs.stats.dailyReport.dailyAvgFlow": "Avg. daily flow", + "tabs.stats.dailyReport.dailyAvgFlow.positive": "You gain ~{} daily", + "tabs.stats.dailyReport.dailyAvgFlow.negative": "You lose ~{} daily", + "tabs.stats.dailyReport.forecastFor": "Expense forecast for {}", + "tabs.stats.dailyReport.comparedTo": "Compared to {}", "tabs.accounts": "Accounts", "tabs.accounts.reorder": "Reorder accounts", "tabs.accounts.reorder.guide": "Long press and drag", diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 1eaf3e5e..1bef3555 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -240,6 +240,13 @@ "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.stats.dailyReport.dailyAvgExpense": "Avg. daily expense", + "tabs.stats.dailyReport.dailyAvgIncome": "Avg. daily income", + "tabs.stats.dailyReport.dailyAvgFlow": "Avg. daily flow", + "tabs.stats.dailyReport.dailyAvgFlow.positive": "You gain ~{} daily", + "tabs.stats.dailyReport.dailyAvgFlow.negative": "You lose ~{} daily", + "tabs.stats.dailyReport.forecastFor": "Expense forecast for {}", + "tabs.stats.dailyReport.comparedTo": "Compared to {}", "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 820dea03..0320363d 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -240,6 +240,13 @@ "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.stats.dailyReport.dailyAvgExpense": "Spesa media giornaliera", + "tabs.stats.dailyReport.dailyAvgIncome": "Entrata media giornaliera", + "tabs.stats.dailyReport.dailyAvgFlow": "Flusso medio giornaliero", + "tabs.stats.dailyReport.dailyAvgFlow.positive": "Guadagni ~{} al giorno", + "tabs.stats.dailyReport.dailyAvgFlow.negative": "Perdi ~{} al giorno", + "tabs.stats.dailyReport.forecastFor": "Previsione di spesa per {}", + "tabs.stats.dailyReport.comparedTo": "Rispetto a {}", "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 696e68c8..f2f70a4d 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -240,6 +240,13 @@ "tabs.stats.chart.select.clickToSelect": "Товшиж сонгоно уу", "tabs.stats.chart.noExchangeRatesWarning": "Валютын ханшийн мэдээлэл байхгүй учир үндсэн валютаас ({currency}) бусад гүйлгээнүүд харагдахгүй байна", "tabs.stats.chart.noExchangeRatesWarning.retry": "Дахин оролдох", + "tabs.stats.dailyReport.dailyAvgExpense": "Дундаж зарлага (өдөр)", + "tabs.stats.dailyReport.dailyAvgIncome": "Дундаж орлого (өдөр)", + "tabs.stats.dailyReport.dailyAvgFlow": "Дундаж урсгал", + "tabs.stats.dailyReport.dailyAvgFlow.positive": "Та өдөрт ~{} олж байна", + "tabs.stats.dailyReport.dailyAvgFlow.negative": "Та өдөрт ~{} алдаж байна", + "tabs.stats.dailyReport.forecastFor": "Урьдчилсан зарлагын тооцоо ({})", + "tabs.stats.dailyReport.comparedTo": "{}-тай харьцуулахад", "tabs.accounts": "Данснууд", "tabs.accounts.reorder": "Дараалал өөрчлөх", "tabs.accounts.reorder.guide": "Удаан дарж чирнэ үү", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index db5fcaaf..048e226a 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -1,7 +1,9 @@ { "appName": "Flow", "appShortDesc": "Kişisel finans takipçiniz", + "visitGitHubRepo": "GitHub'da depoyu ziyaret edin", + "general.back": "Geri Git", "general.delete": "Silmek", "general.delete.permanentWarning": "Bu eylem geri alınamaz", @@ -28,6 +30,7 @@ "general.disabled": "Devredışı", "general.selectLocation": "Konum seçin", "general.nextNDays": "Sonraki {} gün", + "setup.getStarted": "Başlayın", "setup.next": "Sonraki", "setup.slides.foss.title": "Ücretsiz ve açık kaynak", @@ -70,6 +73,7 @@ "setup.onboarding.freshStart.description": "Flow'u ilk kez kullanıyorum", "setup.onboarding.importExisting": "Bir yedekten içeri aktarma", "setup.onboarding.importExisting.description": "Önceki bir Flow yedeklemesinden verileri geri yükleme", + "account": "Hesap", "account.name": "Hesap adı", "account.balance": "Bakiye", @@ -92,6 +96,7 @@ "account.thisMonth": "Bu ay", "account.postTransactionBalance": "Bu işlemden sonraki bakiye", "accounts": "Hesap", + "transaction": "İşlem", "transaction.new": "Yeni işlem", "transaction.edit": "İşlemi düzenle", @@ -120,6 +125,7 @@ "transaction.location.edit": "Düzenlemek için haritaya dokunun", "transaction.pending": "Beklemede", "transaction.pending.preapproved": "Ön Onaylı", + "transactions.all": "Tüm işlemler", "transactions.pending": "Bekleyen işlemler", "transactions.query.noResult": "Gösterilecek işlem yok", @@ -139,6 +145,7 @@ "transactions.query.filter.categories.n": "{} kategoriler", "transactions.query.filter.categories.all": "Tüm Kategoriler", "transactions.count": "{} İşlemler", + "category": "Kategori", "category.name": "Kategori adı", "category.new": "Kategori ekleme", @@ -148,9 +155,12 @@ "category.none": "Kategori yok", "categories": "Kategori", "categories.noCategories": "Herhangi bir kategoriniz yok", + "profile.name": "Ad", + "currency": "Para birimi", "currency.searchHint": "Aramak... (ülke, para birimi, kod)", + "preferences": "Tercihler", "preferences.primaryCurrency": "Birincil para birimi", "preferences.language": "Dil", @@ -199,6 +209,7 @@ "preferences.moneyFormatting.preferFull.description": "Mümkün olduğunca sayıları kısaltmayın", "preferences.moneyFormatting.useCurrencySymbol": "Para birimi simgesini kullan", "preferences.moneyFormatting.useCurrencySymbol.description": "örneğin, \"5$\" yerine \"5 ABD Doları\"", + "tabs.home": "Ev", "tabs.home.greetings": "Merhaba, {name}!", "tabs.home.noTransactions": "Kriterlere uyan işlem yok", @@ -211,6 +222,7 @@ "tabs.home.last7days": "Son 7 gün", "tabs.home.totalBalance": "Toplam bilanço", "tabs.home.flow": "Flow", + "tabs.stats": "İstatistik", "tabs.stats.timeRange.select": "Aralık seç", "tabs.stats.timeRange.changeMode": "Daha fazla seçenek", @@ -228,6 +240,13 @@ "tabs.stats.chart.select.clickToSelect": "Seçmek için tıklayın", "tabs.stats.chart.noExchangeRatesWarning": "Eksik döviz kuru verileri. Birincil olmayan para birimlerindeki işlemler görüntülenmez.", "tabs.stats.chart.noExchangeRatesWarning.retry": "Tekrar dene", + "tabs.stats.dailyReport.dailyAvgExpense": "Günlük ortalama gider", + "tabs.stats.dailyReport.dailyAvgIncome": "Günlük ortalama gelir", + "tabs.stats.dailyReport.dailyAvgFlow": "Günlük ortalama akış", + "tabs.stats.dailyReport.dailyAvgFlow.positive": "Günlük ~{} kazanıyorsunuz", + "tabs.stats.dailyReport.dailyAvgFlow.negative": "Günlük ~{} kaybediyorsunuz", + "tabs.stats.dailyReport.forecastFor": "{} için gider tahmini", + "tabs.stats.dailyReport.comparedTo": "{} ile karşılaştırıldığında", "tabs.accounts": "Hesap", "tabs.accounts.reorder": "Hesapları yeniden sıralama", "tabs.accounts.reorder.guide": "Uzun basın ve sürükleyin", @@ -238,7 +257,8 @@ "tabs.profile.joinDiscord": "Flow Discord'a Katılın", "tabs.profile.backup": "Yedek", "tabs.profile.import": "İçe aktarmak", - "tabs.profile.withLoveFromTheCreator": "Sadespresso'dan 🤍", + "tabs.profile.withLoveFromTheCreator": "sadespresso'dan 🤍", + "support": "Destek", "support.description": "Flow, özgür ve herkese açık bir sevgi emeğidir. Flow'u değerli buluyorsanız, projenin büyümesine yardımcı olmayı düşünün! Bunu yapmanın bazı yolları şunlardır:", "support.requestFeatures": "Bize fikir verin", @@ -249,6 +269,7 @@ "support.donateDeveloper": "İçerik oluşturucuya destek verin", "support.donateDeveloper.description": "Flow'un tüm işlevleri ücretsiz olarak sunulur ve geliştiriciye bahşiş vermek herhangi bir ek özelliğin kilidini açmaz", "support.donateDeveloper.action": "Yaratıcıya bir kahve ısmarla", + "flowIcon.change": "Simgeyi değiştir", "flowIcon.type.icon": "İkon", "flowIcon.type.icon.brands": "Markalar & Logolar", @@ -259,6 +280,7 @@ "flowIcon.type.image.description": "Simge olarak kullanmak için bir resim seçin", "flowIcon.type.character": "Karakter", "flowIcon.type.character.description": "Simge olarak kullanmak için bir emoji veya harf girin", + "sync.import": "İçe aktarmak", "sync.import.pickFile": "Bir dosya seçin", "sync.import.pickFile.pickOrDrop": "Bir dosyayı seçin veya bırakın", @@ -276,6 +298,7 @@ "sync.import.start": "İçe aktarmaya başla", "sync.import.zipWarning": "Flow uygulaması tarafından üretilen ZIP dosyasını içe aktardığınızdan emin olun!", "sync.import.success": "İçe aktarma başarılı!", + "sync.export": "Dışa aktarma", "sync.export.type": "Dışa aktarma ({type})", "sync.export.asCSV": "CSV olarak", @@ -297,6 +320,7 @@ "sync.export.save": "Yedeklemeyi kaydet", "sync.export.save.shareTitle": "Flow yedekleme ({type}, {date})", "sync.export.fileDeleted": "Dosya bulunamadı", + "enum.TransactionSubtype": "Tür", "enum.TransactionSubtype#null": "Varsayılan", "enum.TransactionSubtype@transactionFee": "İşlem ücreti", @@ -306,6 +330,7 @@ "enum.TransactionType@income": "Gelir", "enum.TransactionType@expense": "Gider", "enum.TransactionType@transfer": "Aktarmak", + "enum.CSVHeadersV1": "CSV Başlıkları", "enum.CSVHeadersV1@uuid": "ID", "enum.CSVHeadersV1@title": "Başlık", @@ -323,6 +348,7 @@ "enum.CSVHeadersV1@latitude": "Enlem", "enum.CSVHeadersV1@longitude": "Boylam", "enum.CSVHeadersV1@extra": "Ekstra (JSON)", + "enum.ImportV1Progress@waitingConfirmation": "Onay bekleniyor", "enum.ImportV1Progress@erasing": "Mevcut verilerin silinmesi", "enum.ImportV1Progress@writingCategories": "Yazma kategorileri", @@ -331,6 +357,7 @@ "enum.ImportV1Progress@writingTransactions": "İşlemlerin yazılması", "enum.ImportV1Progress@success": "Başarılı", "enum.ImportV1Progress@error": "Bir şeyler ters gitti ({error})", + "enum.ImportV2Progress@waitingConfirmation": "Onay bekleniyor", "enum.ImportV2Progress@erasing": "Mevcut verilerin silinmesi", "enum.ImportV2Progress@writingCategories": "Yazma kategorileri", @@ -342,6 +369,7 @@ "enum.ImportV2Progress@copyingImages": "Görüntüleri kopyalama", "enum.ImportV2Progress@success": "Başarılı", "enum.ImportV2Progress@error": "Bir şeyler ters gitti ({error})", + "enum.BackupEntryType@manual": "El ile", "enum.BackupEntryType@manual.description": "Kullanıcı tarafından oluşturulan yedekleme", "enum.BackupEntryType@automated": "Otomatik yedekleme", @@ -352,6 +380,7 @@ "enum.BackupEntryType@preImport.description": "Önceki yedeklemeden içe aktarmadan önce önlem olarak oluşturulan yedekleme", "enum.BackupEntryType@other": "Diğer yedekleme", "enum.BackupEntryType@other.description": "Diğer yedekleme", + "error.route.404": "Sayfa bulunamadı", "error.route.400": "Sayfa yüklenemedi", "error.input.mustBeNotEmpty": "Lütfen bu alanı doldurun", diff --git a/lib/data/flow_report.dart b/lib/data/flow_report.dart index 242d80f0..7624e631 100644 --- a/lib/data/flow_report.dart +++ b/lib/data/flow_report.dart @@ -113,8 +113,7 @@ class FlowStandardReport { .map((flow) => flow.getExpenseByCurrency(primaryCurrency)) .fold(Money(0, primaryCurrency), (a, b) => a.amount > b.amount ? a : b); - final int daysLeft = current.duration.inDays - - current.from.difference(DateTime.now()).inDays; + final int daysLeft = current.duration.inDays - countableDays; currentExpenseSumForecast = expenseSum + (dailyAvgExpenditure * daysLeft.toDouble()); diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index b0af1a3e..8acf34ed 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -10,7 +10,7 @@ import "package:flow/theme/theme.dart"; import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/button.dart"; import "package:flow/widgets/general/list_header.dart"; -import "package:flow/widgets/home/prefs/profile_card.dart"; +import "package:flow/widgets/home/preferences/profile_card.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:material_symbols_icons/symbols.dart"; diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 33ad8aaa..9f3009c3 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -2,11 +2,13 @@ import "dart:ui"; import "package:auto_size_text/auto_size_text.dart"; import "package:flow/data/flow_report.dart"; +import "package:flow/l10n/extensions.dart"; import "package:flow/prefs.dart"; -import "package:flow/routes/home/stats_tab/info_card_with_delta.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/home/stats/info_card_with_delta.dart"; +import "package:flow/widgets/home/stats/no_data.dart"; import "package:flow/widgets/home/stats/range_daily_chart.dart"; import "package:flow/widgets/time_range_selector.dart"; import "package:flutter/material.dart"; @@ -90,7 +92,9 @@ class _StatsTabState extends State children: [ Expanded( child: InfoCardWithDelta( - title: "Avg. daily expense", + title: + "tabs.stats.dailyReport.dailyAvgExpense" + .t(context), autoSizeGroup: autoSizeGroup, money: report!.dailyAvgExpenditure, previousMoney: @@ -101,7 +105,9 @@ class _StatsTabState extends State const SizedBox(width: 16.0), Expanded( child: InfoCardWithDelta( - title: "Avg. daily income", + title: + "tabs.stats.dailyReport.dailyAvgIncome" + .t(context), autoSizeGroup: autoSizeGroup, money: report!.dailyAvgIncome, previousMoney: @@ -119,7 +125,10 @@ class _StatsTabState extends State Expanded( child: InfoCardWithDelta( title: - "Forecast for ${report!.current.format()}", + "tabs.stats.dailyReport.forecastFor" + .t(context, [ + report!.current.format(), + ]), autoSizeGroup: autoSizeGroup, money: report!.currentExpenseSumForecast!, @@ -130,7 +139,9 @@ class _StatsTabState extends State const SizedBox(width: 16.0), Expanded( child: InfoCardWithDelta( - title: "Avg. daily flow", + title: + "tabs.stats.dailyReport.dailyAvgFlow" + .t(context), autoSizeGroup: autoSizeGroup, money: report!.dailyAvgFlow, previousMoney: @@ -147,7 +158,7 @@ class _StatsTabState extends State ], ), ) - : Text("No data to show"), + : NoData(), ), ], ); diff --git a/lib/routes/preferences/button_order_preferences_page.dart b/lib/routes/preferences/button_order_preferences_page.dart index 8d7a5d14..185ab218 100644 --- a/lib/routes/preferences/button_order_preferences_page.dart +++ b/lib/routes/preferences/button_order_preferences_page.dart @@ -4,7 +4,7 @@ import "package:dotted_border/dotted_border.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/prefs.dart"; -import "package:flow/routes/preferences/button_order_preferences/transaction_type_button.dart"; +import "package:flow/widgets/home/preferences/button_order_preferences/transaction_type_button.dart"; import "package:flow/widgets/general/info_text.dart"; import "package:flutter/material.dart"; diff --git a/lib/routes/preferences/numpad_preferences_page.dart b/lib/routes/preferences/numpad_preferences_page.dart index 23e8b50c..d4792038 100644 --- a/lib/routes/preferences/numpad_preferences_page.dart +++ b/lib/routes/preferences/numpad_preferences_page.dart @@ -1,6 +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/widgets/home/preferences/numpad_preferences/numpad_selector_radio.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flutter/material.dart"; diff --git a/lib/routes/preferences/transfer_preferences_page.dart b/lib/routes/preferences/transfer_preferences_page.dart index 2c7d50f4..f97765cd 100644 --- a/lib/routes/preferences/transfer_preferences_page.dart +++ b/lib/routes/preferences/transfer_preferences_page.dart @@ -1,6 +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/widgets/home/preferences/transfer_preferences/combine_transfer_radio.dart.dart"; import "package:flow/widgets/general/info_text.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flutter/material.dart"; diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart index a72fdda8..8a6eece3 100644 --- a/lib/routes/stats/stats_by_group_page.dart +++ b/lib/routes/stats/stats_by_group_page.dart @@ -9,7 +9,7 @@ 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/home/stats/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"; diff --git a/lib/routes/preferences/button_order_preferences/transaction_type_button.dart b/lib/widgets/home/preferences/button_order_preferences/transaction_type_button.dart similarity index 100% rename from lib/routes/preferences/button_order_preferences/transaction_type_button.dart rename to lib/widgets/home/preferences/button_order_preferences/transaction_type_button.dart diff --git a/lib/routes/preferences/numpad_preferences/numpad_selector_radio.dart b/lib/widgets/home/preferences/numpad_preferences/numpad_selector_radio.dart similarity index 100% rename from lib/routes/preferences/numpad_preferences/numpad_selector_radio.dart rename to lib/widgets/home/preferences/numpad_preferences/numpad_selector_radio.dart diff --git a/lib/widgets/home/prefs/profile_card.dart b/lib/widgets/home/preferences/profile_card.dart similarity index 100% rename from lib/widgets/home/prefs/profile_card.dart rename to lib/widgets/home/preferences/profile_card.dart diff --git a/lib/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart b/lib/widgets/home/preferences/transfer_preferences/combine_transfer_radio.dart.dart similarity index 96% rename from lib/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart rename to lib/widgets/home/preferences/transfer_preferences/combine_transfer_radio.dart.dart index e0885e72..1dca96ca 100644 --- a/lib/routes/preferences/transfer_preferences/combine_transfer_radio.dart.dart +++ b/lib/widgets/home/preferences/transfer_preferences/combine_transfer_radio.dart.dart @@ -1,6 +1,6 @@ import "package:flow/entity/transaction.dart"; import "package:flow/l10n/flow_localizations.dart"; -import "package:flow/routes/preferences/transfer_preferences/demo_transaction_list_tile.dart"; +import "package:flow/widgets/home/preferences/transfer_preferences/demo_transaction_list_tile.dart"; import "package:flow/theme/theme.dart"; import "package:flutter/material.dart"; diff --git a/lib/routes/preferences/transfer_preferences/demo_transaction_list_tile.dart b/lib/widgets/home/preferences/transfer_preferences/demo_transaction_list_tile.dart similarity index 100% rename from lib/routes/preferences/transfer_preferences/demo_transaction_list_tile.dart rename to lib/widgets/home/preferences/transfer_preferences/demo_transaction_list_tile.dart diff --git a/lib/routes/home/stats_tab/info_card_with_delta.dart b/lib/widgets/home/stats/info_card_with_delta.dart similarity index 100% rename from lib/routes/home/stats_tab/info_card_with_delta.dart rename to lib/widgets/home/stats/info_card_with_delta.dart diff --git a/lib/widgets/home/stats/no_data.dart b/lib/widgets/home/stats/no_data.dart index b69fd687..8a03c3c7 100644 --- a/lib/widgets/home/stats/no_data.dart +++ b/lib/widgets/home/stats/no_data.dart @@ -7,9 +7,9 @@ import "package:flutter/material.dart"; import "package:material_symbols_icons/symbols.dart"; class NoData extends StatelessWidget { - final VoidCallback onTap; + final VoidCallback? selectTimeRange; - const NoData({super.key, required this.onTap}); + const NoData({super.key, this.selectTimeRange}); @override Widget build(BuildContext context) { @@ -31,14 +31,15 @@ class NoData extends StatelessWidget { color: context.colorScheme.primary, ), const SizedBox(height: 8.0), - Button( - trailing: const Icon( - Symbols.history_rounded, - weight: 600.0, + if (selectTimeRange != null) + Button( + trailing: const Icon( + Symbols.history_rounded, + weight: 600.0, + ), + onTap: selectTimeRange, + child: Text("tabs.stats.timeRange.select".t(context)), ), - onTap: onTap, - child: Text("tabs.stats.timeRange.select".t(context)), - ), ], ), ), diff --git a/lib/routes/home/stats_tab/pie_graph_view.dart b/lib/widgets/home/stats/pie_graph_view.dart similarity index 97% rename from lib/routes/home/stats_tab/pie_graph_view.dart rename to lib/widgets/home/stats/pie_graph_view.dart index bf3777f2..2af0c213 100644 --- a/lib/routes/home/stats_tab/pie_graph_view.dart +++ b/lib/widgets/home/stats/pie_graph_view.dart @@ -23,7 +23,7 @@ class PieGraphView extends StatelessWidget { Widget build(BuildContext context) { if (data.isEmpty) { return NoData( - onTap: changeMode, + selectTimeRange: changeMode, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 4f814386..f1f6e520 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.11.0+104" +version: "0.11.0+105" environment: sdk: ">=3.5.0 <4.0.0" From 237aa5c9a0cb52fce301d0540a86c453c10528fa Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 24 Jan 2025 10:40:39 +0800 Subject: [PATCH 08/19] fix translation arg --- lib/routes/home/stats_tab.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 9f3009c3..49e971a2 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -126,9 +126,10 @@ class _StatsTabState extends State child: InfoCardWithDelta( title: "tabs.stats.dailyReport.forecastFor" - .t(context, [ + .t( + context, report!.current.format(), - ]), + ), autoSizeGroup: autoSizeGroup, money: report!.currentExpenseSumForecast!, From 33eb2c5a2b4448dfa84c461f45f40021f41632d7 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 25 Jan 2025 18:08:58 +0800 Subject: [PATCH 09/19] draft 2 --- assets/l10n/en_IN.json | 8 +- assets/l10n/en_US.json | 8 +- assets/l10n/it_IT.json | 3 + assets/l10n/mn_MN.json | 8 +- assets/l10n/tr_TR.json | 3 + ..._report.dart => flow_standard_report.dart} | 0 lib/routes/export_options_page.dart | 122 ++------------ lib/routes/home/stats_tab.dart | 152 ++++++++++-------- lib/routes/setup/setup_onboarding_page.dart | 64 +------- lib/routes/stats/stats_by_group_page.dart | 131 +++++++-------- lib/widgets/action_card.dart | 44 ++++- lib/widgets/general/money_text.dart | 8 + .../home/stats/info_card_with_delta.dart | 42 +---- lib/widgets/home/stats/range_daily_chart.dart | 2 +- lib/widgets/trend.dart | 72 +++++++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- 17 files changed, 320 insertions(+), 353 deletions(-) rename lib/data/{flow_report.dart => flow_standard_report.dart} (100%) create mode 100644 lib/widgets/trend.dart diff --git a/assets/l10n/en_IN.json b/assets/l10n/en_IN.json index 8abe690e..3a12bb8d 100644 --- a/assets/l10n/en_IN.json +++ b/assets/l10n/en_IN.json @@ -30,6 +30,7 @@ "general.disabled": "Disabled", "general.selectLocation": "Choose location", "general.nextNDays": "Next {} day(s)", + "general.flow": "Flow", "setup.getStarted": "Get started", "setup.next": "Next", @@ -242,11 +243,10 @@ "tabs.stats.chart.noExchangeRatesWarning.retry": "Retry", "tabs.stats.dailyReport.dailyAvgExpense": "Avg. daily expense", "tabs.stats.dailyReport.dailyAvgIncome": "Avg. daily income", - "tabs.stats.dailyReport.dailyAvgFlow": "Avg. daily flow", - "tabs.stats.dailyReport.dailyAvgFlow.positive": "You gain ~{} daily", - "tabs.stats.dailyReport.dailyAvgFlow.negative": "You lose ~{} daily", "tabs.stats.dailyReport.forecastFor": "Expense forecast for {}", - "tabs.stats.dailyReport.comparedTo": "Compared to {}", + "tabs.stats.dailyReport.totalExpenseFor": "{} total expense", + "tabs.stats.summaryByAccount": "Summary by account", + "tabs.stats.summaryByCategory": "Summary by category", "tabs.accounts": "Accounts", "tabs.accounts.reorder": "Reorder accounts", "tabs.accounts.reorder.guide": "Long press and drag", diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 1bef3555..e951f71b 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -30,6 +30,7 @@ "general.disabled": "Disabled", "general.selectLocation": "Choose location", "general.nextNDays": "Next {} day(s)", + "general.flow": "Flow", "setup.getStarted": "Get started", "setup.next": "Next", @@ -242,11 +243,10 @@ "tabs.stats.chart.noExchangeRatesWarning.retry": "Retry", "tabs.stats.dailyReport.dailyAvgExpense": "Avg. daily expense", "tabs.stats.dailyReport.dailyAvgIncome": "Avg. daily income", - "tabs.stats.dailyReport.dailyAvgFlow": "Avg. daily flow", - "tabs.stats.dailyReport.dailyAvgFlow.positive": "You gain ~{} daily", - "tabs.stats.dailyReport.dailyAvgFlow.negative": "You lose ~{} daily", "tabs.stats.dailyReport.forecastFor": "Expense forecast for {}", - "tabs.stats.dailyReport.comparedTo": "Compared to {}", + "tabs.stats.dailyReport.totalExpenseFor": "{} total expense", + "tabs.stats.summaryByAccount": "Summary by account", + "tabs.stats.summaryByCategory": "Summary by category", "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 0320363d..696887af 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -30,6 +30,7 @@ "general.disabled": "Disabilitato", "general.selectLocation": "Scegli la posizione", "general.nextNDays": "Prossimi {} giorni", + "general.flow": "Flusso", "setup.getStarted": "Iniziare", "setup.next": "Avanti", @@ -247,6 +248,8 @@ "tabs.stats.dailyReport.dailyAvgFlow.negative": "Perdi ~{} al giorno", "tabs.stats.dailyReport.forecastFor": "Previsione di spesa per {}", "tabs.stats.dailyReport.comparedTo": "Rispetto a {}", + "tabs.stats.summaryByAccount": "Riepilogo per conto", + "tabs.stats.summaryByCategory": "Riepilogo per categoria", "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 f2f70a4d..c192baf6 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -30,6 +30,7 @@ "general.disabled": "Идэвхгүй", "general.selectLocation": "Байршил сонгох", "general.nextNDays": "Ирэх {} хоног", + "general.flow": "Урсгал", "setup.getStarted": "Эхэлцгээе", "setup.next": "Үргэлжлүүлэх", @@ -242,11 +243,10 @@ "tabs.stats.chart.noExchangeRatesWarning.retry": "Дахин оролдох", "tabs.stats.dailyReport.dailyAvgExpense": "Дундаж зарлага (өдөр)", "tabs.stats.dailyReport.dailyAvgIncome": "Дундаж орлого (өдөр)", - "tabs.stats.dailyReport.dailyAvgFlow": "Дундаж урсгал", - "tabs.stats.dailyReport.dailyAvgFlow.positive": "Та өдөрт ~{} олж байна", - "tabs.stats.dailyReport.dailyAvgFlow.negative": "Та өдөрт ~{} алдаж байна", "tabs.stats.dailyReport.forecastFor": "Урьдчилсан зарлагын тооцоо ({})", - "tabs.stats.dailyReport.comparedTo": "{}-тай харьцуулахад", + "tabs.stats.dailyReport.totalExpenseFor": "{}-н нийт зарлага", + "tabs.stats.seeStatsByAccount": "Данс бүрээр харах", + "tabs.stats.seeStatsByCategory": "Ангилал бүрээр харах", "tabs.accounts": "Данснууд", "tabs.accounts.reorder": "Дараалал өөрчлөх", "tabs.accounts.reorder.guide": "Удаан дарж чирнэ үү", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index 048e226a..df493f7d 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -30,6 +30,7 @@ "general.disabled": "Devredışı", "general.selectLocation": "Konum seçin", "general.nextNDays": "Sonraki {} gün", + "general.flow": "Akış", "setup.getStarted": "Başlayın", "setup.next": "Sonraki", @@ -247,6 +248,8 @@ "tabs.stats.dailyReport.dailyAvgFlow.negative": "Günlük ~{} kaybediyorsunuz", "tabs.stats.dailyReport.forecastFor": "{} için gider tahmini", "tabs.stats.dailyReport.comparedTo": "{} ile karşılaştırıldığında", + "tabs.stats.summaryByAccount": "Hesaba göre özet", + "tabs.stats.summaryByCategory": "Kategoriye göre özet", "tabs.accounts": "Hesap", "tabs.accounts.reorder": "Hesapları yeniden sıralama", "tabs.accounts.reorder.guide": "Uzun basın ve sürükleyin", diff --git a/lib/data/flow_report.dart b/lib/data/flow_standard_report.dart similarity index 100% rename from lib/data/flow_report.dart rename to lib/data/flow_standard_report.dart diff --git a/lib/routes/export_options_page.dart b/lib/routes/export_options_page.dart index 94ea7524..6070f06b 100644 --- a/lib/routes/export_options_page.dart +++ b/lib/routes/export_options_page.dart @@ -1,8 +1,6 @@ import "package:flow/data/flow_icon.dart"; import "package:flow/l10n/extensions.dart"; -import "package:flow/theme/theme.dart"; import "package:flow/widgets/action_card.dart"; -import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:material_symbols_icons/symbols.dart"; @@ -26,126 +24,30 @@ class _ExportOptionsPageState extends State { children: [ ActionCard( onTap: () => context.push("/export/csv"), - builder: (context) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FlowIcon( - FlowIconData.icon(Symbols.table_rounded), - size: 80.0, - plated: true, - ), - const SizedBox(height: 8.0), - Text( - "sync.export.asCSV".t(context), - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: 16.0), - Text( - "sync.export.asCSV.description".t(context), - style: context.textTheme.bodySmall, - ), - ], - ), - ), + icon: FlowIconData.icon(Symbols.table_rounded), + title: "sync.export.asCSV".t(context), + subtitle: "sync.export.asCSV.description".t(context), ), const SizedBox(height: 16.0), ActionCard( onTap: () => context.push("/export/zip"), - builder: (context) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FlowIcon( - FlowIconData.icon(Symbols.folder_zip_rounded), - size: 80.0, - plated: true, - ), - const SizedBox(height: 8.0), - Text( - "sync.export.asZIP".t(context), - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: 8.0), - Text( - "sync.export.asZIP.description".t(context), - style: context.textTheme.bodySmall, - ), - ], - ), - ), + icon: FlowIconData.icon(Symbols.folder_zip_rounded), + title: "sync.export.asZIP".t(context), + subtitle: "sync.export.asZIP.description".t(context), ), const SizedBox(height: 16.0), ActionCard( onTap: () => context.push("/export/json"), - builder: (context) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FlowIcon( - FlowIconData.icon(Symbols.database_rounded), - size: 80.0, - plated: true, - ), - const SizedBox(height: 8.0), - Text( - "sync.export.asJSON".t(context), - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: 8.0), - Text( - "sync.export.asJSON.description".t(context), - style: context.textTheme.bodySmall, - ), - ], - ), - ), + icon: FlowIconData.icon(Symbols.database_rounded), + title: "sync.export.asJSON".t(context), + subtitle: "sync.export.asJSON.description".t(context), ), const SizedBox(height: 16.0), ActionCard( onTap: () => context.push("/export/history"), - builder: (context) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FlowIcon( - FlowIconData.icon(Symbols.history_rounded), - size: 80.0, - plated: true, - ), - const SizedBox(height: 8.0), - Text( - "sync.export.history".t(context), - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: 8.0), - Text( - "sync.export.history.description".t(context), - style: context.textTheme.bodySmall, - ), - ], - ), - ), + icon: FlowIconData.icon(Symbols.history_rounded), + title: "sync.export.history".t(context), + subtitle: "sync.export.history.description".t(context), ), ], ), diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 49e971a2..dfef1d0c 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,17 +1,23 @@ import "dart:ui"; import "package:auto_size_text/auto_size_text.dart"; -import "package:flow/data/flow_report.dart"; +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/flow_standard_report.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/prefs.dart"; -import "package:flow/theme/theme.dart"; +import "package:flow/theme/helpers.dart"; +import "package:flow/widgets/action_card.dart"; import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/home/stats/info_card_with_delta.dart"; import "package:flow/widgets/home/stats/no_data.dart"; import "package:flow/widgets/home/stats/range_daily_chart.dart"; import "package:flow/widgets/time_range_selector.dart"; +import "package:flow/widgets/trend.dart"; import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class StatsTab extends StatefulWidget { @@ -50,6 +56,10 @@ class _StatsTabState extends State final bool hasData = report != null && report!.currentFlowByDay.isNotEmpty; + final bool showForecast = + report?.current.contains(DateTime.now()) == true && + report!.currentExpenseSumForecast != null; + return Column( children: [ Frame.standalone( @@ -61,7 +71,6 @@ class _StatsTabState extends State Expanded( child: hasData ? SingleChildScrollView( - primary: true, child: Column( children: [ ClipRect( @@ -82,79 +91,86 @@ class _StatsTabState extends State ), ), const SizedBox(height: 24.0), - DefaultTextStyle( - style: context.textTheme.displaySmall!, - child: Column( - mainAxisSize: MainAxisSize.min, + Frame( + child: Row( children: [ - Frame( - child: Row( - children: [ - Expanded( - child: InfoCardWithDelta( - title: - "tabs.stats.dailyReport.dailyAvgExpense" - .t(context), - autoSizeGroup: autoSizeGroup, - money: report!.dailyAvgExpenditure, - previousMoney: - report!.previousDailyAvgExpenditure, - invertDelta: true, - ), - ), - const SizedBox(width: 16.0), - Expanded( - child: InfoCardWithDelta( - title: - "tabs.stats.dailyReport.dailyAvgIncome" - .t(context), - autoSizeGroup: autoSizeGroup, - money: report!.dailyAvgIncome, - previousMoney: - report!.previousDailyAvgIncome, - ), - ), - ], + Expanded( + child: InfoCardWithDelta( + title: "tabs.stats.dailyReport.dailyAvgExpense" + .t(context), + autoSizeGroup: autoSizeGroup, + money: report!.dailyAvgExpenditure, + previousMoney: + report!.previousDailyAvgExpenditure, + invertDelta: true, ), ), - const SizedBox(height: 16.0), - Frame( - child: Row( - children: [ - if (report!.currentExpenseSumForecast != null) - Expanded( - child: InfoCardWithDelta( - title: - "tabs.stats.dailyReport.forecastFor" - .t( - context, - report!.current.format(), - ), - autoSizeGroup: autoSizeGroup, - money: - report!.currentExpenseSumForecast!, - previousMoney: - report!.previousExpenseSum, - ), - ), - const SizedBox(width: 16.0), - Expanded( - child: InfoCardWithDelta( - title: - "tabs.stats.dailyReport.dailyAvgFlow" - .t(context), - autoSizeGroup: autoSizeGroup, - money: report!.dailyAvgFlow, - previousMoney: - report!.previousDailyAvgFlow, - ), - ), - ], + const SizedBox(width: 16.0), + Expanded( + child: InfoCardWithDelta( + title: "tabs.stats.dailyReport.dailyAvgIncome" + .t(context), + autoSizeGroup: autoSizeGroup, + money: report!.dailyAvgIncome, + previousMoney: report!.previousDailyAvgIncome, ), ), ], ), ), + const SizedBox(height: 16.0), + Frame( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + showForecast + ? "tabs.stats.dailyReport.forecastFor" + .t(context) + : "tabs.stats.dailyReport.totalExpenseFor" + .t(context), + style: context.textTheme.titleMedium, + ), + Row( + children: [ + MoneyText( + showForecast + ? report!.currentExpenseSumForecast + : report!.expenseSum, + style: context.textTheme.displaySmall, + ), + Trend.fromMoney( + current: showForecast + ? report!.currentExpenseSumForecast + : report!.expenseSum, + previous: report!.previousExpenseSum, + invertDelta: true, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24.0), + Frame( + child: Expanded( + child: ActionCard( + icon: FlowIconData.icon(Symbols.category_rounded), + title: "tabs.stats.seeStatsByCategory".t(context), + onTap: () => context.push("/stats/category"), + ), + ), + ), + const SizedBox(height: 16.0), + Frame( + child: Expanded( + child: ActionCard( + icon: FlowIconData.icon(Symbols.wallet_rounded), + title: "tabs.stats.seeStatsByAccount".t(context), + onTap: () => context.push("/stats/account"), + ), + ), + ), const SizedBox(height: 96.0), ], ), diff --git a/lib/routes/setup/setup_onboarding_page.dart b/lib/routes/setup/setup_onboarding_page.dart index 9dba9b0b..8526454c 100644 --- a/lib/routes/setup/setup_onboarding_page.dart +++ b/lib/routes/setup/setup_onboarding_page.dart @@ -1,8 +1,6 @@ import "package:flow/data/flow_icon.dart"; import "package:flow/l10n/extensions.dart"; -import "package:flow/theme/theme.dart"; import "package:flow/widgets/action_card.dart"; -import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:material_symbols_icons/symbols.dart"; @@ -23,65 +21,17 @@ class SetupOnboardingPage extends StatelessWidget { children: [ ActionCard( onTap: () => context.push("/setup/profile"), - builder: (context) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FlowIcon( - FlowIconData.icon(Symbols.book_4_spark_rounded), - size: 80.0, - plated: true, - ), - const SizedBox(height: 8.0), - Text( - "setup.onboarding.freshStart".t(context), - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: 8.0), - Text( - "setup.onboarding.freshStart.description".t(context), - style: context.textTheme.bodySmall, - ), - ], - ), - ), + icon: FlowIconData.icon(Symbols.book_4_spark_rounded), + title: "setup.onboarding.freshStart".t(context), + subtitle: "setup.onboarding.freshStart.description".t(context), ), const SizedBox(height: 16.0), ActionCard( onTap: () => context.push("/import?setupMode=true"), - builder: (context) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FlowIcon( - FlowIconData.icon(Symbols.restore_page_rounded), - size: 80.0, - plated: true, - ), - const SizedBox(height: 8.0), - Text( - "setup.onboarding.importExisting".t(context), - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: 8.0), - Text( - "setup.onboarding.importExisting.description" - .t(context), - style: context.textTheme.bodySmall, - ), - ], - ), - ), + icon: FlowIconData.icon(Symbols.restore_page_rounded), + title: "setup.onboarding.importExisting".t(context), + subtitle: + "setup.onboarding.importExisting.description".t(context), ), ], ), diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart index 8a6eece3..d4d500da 100644 --- a/lib/routes/stats/stats_by_group_page.dart +++ b/lib/routes/stats/stats_by_group_page.dart @@ -53,77 +53,82 @@ class StatsByGroupPageState extends State @override Widget build(BuildContext context) { - 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, - ), - ), - ), - 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), + return Scaffold( + appBar: AppBar(), + body: 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, ), - ], + ), ), - if (rates == null) const ExchangeMissingNotice(), - Expanded( - child: TabBarView( + if (busy) + const Padding( + padding: EdgeInsets.all(24.0), + child: Spinner(), + ) + else ...[ + TabBar( controller: _tabController, - children: [ - PieGraphView( - data: expenses, - changeMode: changeMode, - range: range, + tabs: [ + Tab( + text: + TransactionType.expense.localizedTextKey.t(context), ), - PieGraphView( - data: incomes, - changeMode: changeMode, - range: range, + Tab( + text: + TransactionType.income.localizedTextKey.t(context), ), ], ), - ) + if (rates == null) const ExchangeMissingNotice(), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + PieGraphView( + data: expenses, + changeMode: changeMode, + range: range, + ), + PieGraphView( + data: incomes, + changeMode: changeMode, + range: range, + ), + ], + ), + ) + ], ], - ], - ); - }); + ); + }), + ); } void updateRange(TimeRange newRange) { diff --git a/lib/widgets/action_card.dart b/lib/widgets/action_card.dart index 3e88b2b5..0f823984 100644 --- a/lib/widgets/action_card.dart +++ b/lib/widgets/action_card.dart @@ -1,3 +1,6 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/theme/helpers.dart"; +import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/surface.dart"; import "package:flutter/material.dart"; @@ -5,7 +8,10 @@ class ActionCard extends StatelessWidget { final VoidCallback? onTap; final VoidCallback? onLongPress; - final Widget Function(BuildContext context) builder; + final FlowIconData? icon; + final String title; + final String? subtitle; + final Widget? trailing; final BorderRadius borderRadius; @@ -14,7 +20,10 @@ class ActionCard extends StatelessWidget { this.onTap, this.onLongPress, this.borderRadius = const BorderRadius.all(Radius.circular(16.0)), - required this.builder, + required this.title, + this.icon, + this.subtitle, + this.trailing, }); @override @@ -29,7 +38,36 @@ class ActionCard extends StatelessWidget { borderRadius: borderRadius, onTap: onTap, onLongPress: onLongPress, - child: builder(context), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 16.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[ + FlowIcon(icon!, size: 80.0, plated: true), + const SizedBox(height: 8.0), + ], + Text( + title, + style: context.textTheme.headlineSmall, + ), + if (subtitle != null) ...[ + const SizedBox(height: 4.0), + Text( + subtitle!, + style: context.textTheme.bodyMedium, + ), + ], + if (trailing != null) ...[ + const SizedBox(height: 8.0), + trailing!, + ], + ], + ), + ), ), ), ); diff --git a/lib/widgets/general/money_text.dart b/lib/widgets/general/money_text.dart index 7be0cf78..ce53c299 100644 --- a/lib/widgets/general/money_text.dart +++ b/lib/widgets/general/money_text.dart @@ -87,6 +87,14 @@ class _MoneyTextState extends State { abbreviate = widget.initiallyAbbreviated; } + @override + void didUpdateWidget(covariant MoneyText oldWidget) { + if (widget.initiallyAbbreviated != oldWidget.initiallyAbbreviated) { + abbreviate = widget.initiallyAbbreviated; + } + super.didUpdateWidget(oldWidget); + } + @override Widget build(BuildContext context) { return MoneyTextBuilder( diff --git a/lib/widgets/home/stats/info_card_with_delta.dart b/lib/widgets/home/stats/info_card_with_delta.dart index 8ac51089..2349bf92 100644 --- a/lib/widgets/home/stats/info_card_with_delta.dart +++ b/lib/widgets/home/stats/info_card_with_delta.dart @@ -4,8 +4,8 @@ import "package:flow/prefs.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/home/home/info_card.dart"; +import "package:flow/widgets/trend.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; class InfoCardWithDelta extends StatelessWidget { final Money money; @@ -28,20 +28,6 @@ class InfoCardWithDelta extends StatelessWidget { @override Widget build(BuildContext context) { - final double hundredPercent = previousMoney?.amount ?? 0; - final double delta = (hundredPercent == 0 || - hundredPercent.isNaN || - hundredPercent.isInfinite) - ? 0 - : (money.amount - hundredPercent) / hundredPercent; - - final String deltaString = "${(delta.abs() * 100).toStringAsFixed(1)}%"; - - final bool downtrend = invertDelta ^ delta.isNegative; - - final Color color = - downtrend ? context.flowColors.expense : context.flowColors.income; - return InfoCard( title: title, moneyText: MoneyText( @@ -52,27 +38,11 @@ class InfoCardWithDelta extends StatelessWidget { autoSizeGroup: autoSizeGroup, style: context.textTheme.displaySmall, ), - delta: delta != 0.0 - ? Row( - mainAxisSize: MainAxisSize.min, - spacing: 4.0, - children: [ - Icon( - delta.isNegative - ? Symbols.arrow_downward - : Symbols.arrow_upward, - size: context.textTheme.titleSmall!.fontSize, - color: color, - ), - Text( - deltaString, - style: context.textTheme.titleSmall!.copyWith( - color: color, - ), - ) - ], - ) - : null, + delta: Trend.fromMoney( + current: money, + previous: previousMoney, + invertDelta: invertDelta, + ), ); } } diff --git a/lib/widgets/home/stats/range_daily_chart.dart b/lib/widgets/home/stats/range_daily_chart.dart index 730cd69b..3498ceae 100644 --- a/lib/widgets/home/stats/range_daily_chart.dart +++ b/lib/widgets/home/stats/range_daily_chart.dart @@ -2,7 +2,7 @@ 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_report.dart"; +import "package:flow/data/flow_standard_report.dart"; import "package:flow/data/money.dart"; import "package:flow/prefs.dart"; import "package:flow/theme/theme.dart"; diff --git a/lib/widgets/trend.dart b/lib/widgets/trend.dart new file mode 100644 index 00000000..bdcaa4ce --- /dev/null +++ b/lib/widgets/trend.dart @@ -0,0 +1,72 @@ +import "package:flow/data/money.dart"; +import "package:flow/theme/helpers.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons/symbols.dart"; + +class Trend extends StatelessWidget { + final TextStyle? style; + + final double delta; + final bool invertDelta; + + const Trend({ + super.key, + required this.delta, + required this.invertDelta, + this.style, + }); + + factory Trend.fromMoney({ + Key? key, + Money? current, + Money? previous, + bool invertDelta = false, + TextStyle? style, + }) { + final double hundredPercent = previous?.amount ?? 0; + final double delta = (hundredPercent == 0 || + hundredPercent.isNaN || + hundredPercent.isInfinite) + ? 0 + : ((current?.amount ?? 0) - hundredPercent) / hundredPercent.abs(); + + return Trend( + key: key, + delta: delta, + invertDelta: invertDelta, + style: style, + ); + } + + @override + Widget build(BuildContext context) { + final bool downtrend = delta.isNegative; + + final Color color = + downtrend ? context.flowColors.expense : context.flowColors.income; + + final String deltaString = "${(delta.abs() * 100).toStringAsFixed(1)}%"; + + final TextStyle style = this.style ?? context.textTheme.titleSmall!; + + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 4.0, + children: [ + Icon( + (invertDelta ^ downtrend) + ? Symbols.stat_minus_1_rounded + : Symbols.stat_1_rounded, + size: style.fontSize, + color: color, + ), + Text( + deltaString, + style: style.copyWith( + color: color, + ), + ) + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index d71af99e..18fbdc9d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -886,10 +886,10 @@ packages: dependency: "direct main" description: name: moment_dart - sha256: "11dc05bb1be5a84dcab564ccecbfa1e7d4668551771ffe2aeff2fe4cf3ebcb3a" + sha256: "79678cfca4cacd7d94439c2cac8bc24a260295527c47ff53d6d147948807ed1a" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.3.1" objectbox: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f1f6e520..b30cf320 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: local_hero: ^0.3.0 local_settings: ^0.5.0 material_symbols_icons: ^4.2799.0 - moment_dart: ^3.3.0 + moment_dart: ^3.3.1 objectbox: ^4.0.3 objectbox_flutter_libs: ^4.0.3 package_info_plus: ^8.0.0 From edd1b0fde097bb71fbaa33cc0a73014457b41e94 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 25 Jan 2025 18:24:20 +0800 Subject: [PATCH 10/19] draft 3 --- assets/l10n/it_IT.json | 7 +-- assets/l10n/mn_MN.json | 4 +- assets/l10n/tr_TR.json | 5 +- lib/routes/home/stats_tab.dart | 89 +++++++++++++++++----------------- 4 files changed, 49 insertions(+), 56 deletions(-) diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 696887af..0e62d153 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -243,11 +243,8 @@ "tabs.stats.chart.noExchangeRatesWarning.retry": "Riprova", "tabs.stats.dailyReport.dailyAvgExpense": "Spesa media giornaliera", "tabs.stats.dailyReport.dailyAvgIncome": "Entrata media giornaliera", - "tabs.stats.dailyReport.dailyAvgFlow": "Flusso medio giornaliero", - "tabs.stats.dailyReport.dailyAvgFlow.positive": "Guadagni ~{} al giorno", - "tabs.stats.dailyReport.dailyAvgFlow.negative": "Perdi ~{} al giorno", "tabs.stats.dailyReport.forecastFor": "Previsione di spesa per {}", - "tabs.stats.dailyReport.comparedTo": "Rispetto a {}", + "tabs.stats.dailyReport.totalExpenseFor": "Spesa totale {}", "tabs.stats.summaryByAccount": "Riepilogo per conto", "tabs.stats.summaryByCategory": "Riepilogo per categoria", "tabs.accounts": "Conti", @@ -331,7 +328,7 @@ "enum.TransactionSubtype@receivedLoan": "Prestito (ricevuto)", "enum.TransactionType": "Tipo di transazione", "enum.TransactionType@income": "Entrata", - "enum.TransactionType@expense": "Uscita", + "enum.TransactionType@expense": "Spesa", "enum.TransactionType@transfer": "Trasferimento", "enum.CSVHeadersV1": "Intestazioni CSV", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index c192baf6..e1edf047 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -245,8 +245,8 @@ "tabs.stats.dailyReport.dailyAvgIncome": "Дундаж орлого (өдөр)", "tabs.stats.dailyReport.forecastFor": "Урьдчилсан зарлагын тооцоо ({})", "tabs.stats.dailyReport.totalExpenseFor": "{}-н нийт зарлага", - "tabs.stats.seeStatsByAccount": "Данс бүрээр харах", - "tabs.stats.seeStatsByCategory": "Ангилал бүрээр харах", + "tabs.stats.summaryByAccount": "Данс бүрээр харах", + "tabs.stats.summaryByCategory": "Ангилал бүрээр харах", "tabs.accounts": "Данснууд", "tabs.accounts.reorder": "Дараалал өөрчлөх", "tabs.accounts.reorder.guide": "Удаан дарж чирнэ үү", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index df493f7d..9287cc5b 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -243,11 +243,8 @@ "tabs.stats.chart.noExchangeRatesWarning.retry": "Tekrar dene", "tabs.stats.dailyReport.dailyAvgExpense": "Günlük ortalama gider", "tabs.stats.dailyReport.dailyAvgIncome": "Günlük ortalama gelir", - "tabs.stats.dailyReport.dailyAvgFlow": "Günlük ortalama akış", - "tabs.stats.dailyReport.dailyAvgFlow.positive": "Günlük ~{} kazanıyorsunuz", - "tabs.stats.dailyReport.dailyAvgFlow.negative": "Günlük ~{} kaybediyorsunuz", "tabs.stats.dailyReport.forecastFor": "{} için gider tahmini", - "tabs.stats.dailyReport.comparedTo": "{} ile karşılaştırıldığında", + "tabs.stats.dailyReport.totalExpenseFor": "{} toplam gider", "tabs.stats.summaryByAccount": "Hesaba göre özet", "tabs.stats.summaryByCategory": "Kategoriye göre özet", "tabs.accounts": "Hesap", diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index dfef1d0c..0acde3ab 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -73,6 +73,42 @@ class _StatsTabState extends State ? SingleChildScrollView( child: Column( children: [ + Frame( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showForecast + ? "tabs.stats.dailyReport.forecastFor" + .t(context, report!.current.format()) + : "tabs.stats.dailyReport.totalExpenseFor" + .t(context, report!.current.format()), + style: + context.textTheme.titleSmall?.semi(context), + ), + Row( + children: [ + MoneyText( + showForecast + ? report!.currentExpenseSumForecast + : report!.expenseSum, + style: context.textTheme.displaySmall, + ), + const SizedBox(width: 8.0), + Trend.fromMoney( + current: showForecast + ? report!.currentExpenseSumForecast + : report!.expenseSum, + previous: report!.previousExpenseSum, + invertDelta: true, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16.0), ClipRect( child: Stack( children: [ @@ -118,57 +154,20 @@ class _StatsTabState extends State ], ), ), - const SizedBox(height: 16.0), - Frame( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - showForecast - ? "tabs.stats.dailyReport.forecastFor" - .t(context) - : "tabs.stats.dailyReport.totalExpenseFor" - .t(context), - style: context.textTheme.titleMedium, - ), - Row( - children: [ - MoneyText( - showForecast - ? report!.currentExpenseSumForecast - : report!.expenseSum, - style: context.textTheme.displaySmall, - ), - Trend.fromMoney( - current: showForecast - ? report!.currentExpenseSumForecast - : report!.expenseSum, - previous: report!.previousExpenseSum, - invertDelta: true, - ), - ], - ), - ], - ), - ), const SizedBox(height: 24.0), Frame( - child: Expanded( - child: ActionCard( - icon: FlowIconData.icon(Symbols.category_rounded), - title: "tabs.stats.seeStatsByCategory".t(context), - onTap: () => context.push("/stats/category"), - ), + child: ActionCard( + icon: FlowIconData.icon(Symbols.category_rounded), + title: "tabs.stats.summaryByCategory".t(context), + onTap: () => context.push("/stats/category"), ), ), const SizedBox(height: 16.0), Frame( - child: Expanded( - child: ActionCard( - icon: FlowIconData.icon(Symbols.wallet_rounded), - title: "tabs.stats.seeStatsByAccount".t(context), - onTap: () => context.push("/stats/account"), - ), + child: ActionCard( + icon: FlowIconData.icon(Symbols.wallet_rounded), + title: "tabs.stats.summaryByAccount".t(context), + onTap: () => context.push("/stats/account"), ), ), const SizedBox(height: 96.0), From 970cb89fa4661e6b59ea7192d39ed5a273027275 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 25 Jan 2025 18:27:52 +0800 Subject: [PATCH 11/19] stats tab draft --- lib/routes/home/stats_tab.dart | 26 ++++++++++++++++++++++++-- pubspec.yaml | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 0acde3ab..5b8d83fb 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -34,8 +34,7 @@ class _StatsTabState extends State final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); - late final bool initiallyAbbreviated = - !LocalPreferences().preferFullAmounts.get(); + late bool initiallyAbbreviated; bool busy = false; @@ -44,6 +43,19 @@ class _StatsTabState extends State super.initState(); fetch(); + + initiallyAbbreviated = !LocalPreferences().preferFullAmounts.get(); + LocalPreferences() + .preferFullAmounts + .addListener(_updateInitiallyAbbreviated); + } + + @override + void dispose() { + LocalPreferences() + .preferFullAmounts + .removeListener(_updateInitiallyAbbreviated); + super.dispose(); } @override @@ -94,6 +106,9 @@ class _StatsTabState extends State ? report!.currentExpenseSumForecast : report!.expenseSum, style: context.textTheme.displaySmall, + autoSize: true, + tapToToggleAbbreviation: true, + initiallyAbbreviated: initiallyAbbreviated, ), const SizedBox(width: 8.0), Trend.fromMoney( @@ -206,6 +221,13 @@ class _StatsTabState extends State } } + void _updateInitiallyAbbreviated() { + initiallyAbbreviated = !LocalPreferences().preferFullAmounts.get(); + if (mounted) { + setState(() {}); + } + } + @override bool get wantKeepAlive => true; } diff --git a/pubspec.yaml b/pubspec.yaml index b30cf320..6a136297 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.11.0+105" +version: "0.11.0+106" environment: sdk: ">=3.5.0 <4.0.0" From d6348b0c8baebecc3e68ad92b3b2e1b445483e57 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 25 Jan 2025 19:22:49 +0800 Subject: [PATCH 12/19] Working draft, fix #269 --- CHANGELOG.md | 3 + assets/l10n/en_IN.json | 6 + assets/l10n/en_US.json | 6 + assets/l10n/it_IT.json | 6 + assets/l10n/mn_MN.json | 6 + assets/l10n/tr_TR.json | 6 + lib/data/transactions_filter/search_data.dart | 140 ++++++++++++------ lib/objectbox/actions.dart | 5 +- .../transaction_search_sheet.dart | 56 ++++++- pubspec.yaml | 2 +- 10 files changed, 187 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b2259e..84588461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Beta 0.11.0 (next) * Reworked stats tab (ongoing) +* Enhanced search options (ongoing) + * Added partial and exact match mode + * Added option to include description, closes [#269](https://github.com/flow-mn/flow/issues/269) ## Beta 0.10.2 diff --git a/assets/l10n/en_IN.json b/assets/l10n/en_IN.json index 3a12bb8d..ef76ac6e 100644 --- a/assets/l10n/en_IN.json +++ b/assets/l10n/en_IN.json @@ -137,6 +137,7 @@ "transactions.query.filter.keyword.all": "Search", "transactions.query.filter.keyword.hint": "Search by title...", "transactions.query.filter.keyword.clear": "Clear", + "transactions.query.filter.keyword.includeDescription": "Include description", "transactions.query.filter.timeRange": "Time Range", "transactions.query.filter.timeRange.all": "All Time", "transactions.query.filter.accounts": "Accounts", @@ -381,6 +382,11 @@ "enum.BackupEntryType@other": "Other backup", "enum.BackupEntryType@other.description": "Other backup", + "enum.TransactionSearchMode": "Search mode", + "enum.TransactionSearchMode@smart": "Smart", + "enum.TransactionSearchMode@substring": "Partial match", + "enum.TransactionSearchMode@exact": "Exact match", + "error.route.404": "Page not found", "error.route.400": "Failed to load the page", "error.input.mustBeNotEmpty": "Please fill out this field", diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index e951f71b..14f32da3 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -137,6 +137,7 @@ "transactions.query.filter.keyword.all": "Search", "transactions.query.filter.keyword.hint": "Search by title...", "transactions.query.filter.keyword.clear": "Clear", + "transactions.query.filter.keyword.includeDescription": "Include description", "transactions.query.filter.timeRange": "Time Range", "transactions.query.filter.timeRange.all": "All Time", "transactions.query.filter.accounts": "Accounts", @@ -381,6 +382,11 @@ "enum.BackupEntryType@other": "Other backup", "enum.BackupEntryType@other.description": "Other backup", + "enum.TransactionSearchMode": "Search mode", + "enum.TransactionSearchMode@smart": "Smart", + "enum.TransactionSearchMode@substring": "Partial match", + "enum.TransactionSearchMode@exact": "Exact match", + "error.route.404": "Page not found", "error.route.400": "Failed to load the page", "error.input.mustBeNotEmpty": "Please fill out this field", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 0e62d153..c0e896ca 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -137,6 +137,7 @@ "transactions.query.filter.keyword.all": "Cerca", "transactions.query.filter.keyword.hint": "Cerca per titolo...", "transactions.query.filter.keyword.clear": "Cancella cerca", + "transactions.query.filter.keyword.includeDescription": "Includi note", "transactions.query.filter.timeRange": "Periodo", "transactions.query.filter.timeRange.all": "Tutto il tempo", "transactions.query.filter.accounts": "Conti", @@ -381,6 +382,11 @@ "enum.BackupEntryType@other": "Altro", "enum.BackupEntryType@other.description": "Altro tipo di backup", + "enum.TransactionSearchMode": "Modalità di ricerca", + "enum.TransactionSearchMode@smart": "Intelligente", + "enum.TransactionSearchMode@substring": "Corrispondenza parziale", + "enum.TransactionSearchMode@exact": "Corrispondenza esatta", + "error.route.404": "Pagina non trovata", "error.route.400": "Impossibile caricare la pagina", "error.input.mustBeNotEmpty": "Si prega di compilare questo campo", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index e1edf047..c73c6f20 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -137,6 +137,7 @@ "transactions.query.filter.keyword.all": "Хайх", "transactions.query.filter.keyword.hint": "Гарчгаар хайх...", "transactions.query.filter.keyword.clear": "Цэвэрлэх", + "transactions.query.filter.keyword.includeDescription": "Тэмдэглэлээс хайх", "transactions.query.filter.timeRange": "Хугацаа", "transactions.query.filter.timeRange.all": "Бүх цаг үе", "transactions.query.filter.accounts": "Данс", @@ -381,6 +382,11 @@ "enum.BackupEntryType@other": "Бусад нөөц", "enum.BackupEntryType@other.description": "Бусад нөөц", + "enum.TransactionSearchMode": "Хайх горим", + "enum.TransactionSearchMode@smart": "Ухаалаг", + "enum.TransactionSearchMode@substring": "Хэсэгчлэн таарах", + "enum.TransactionSearchMode@exact": "Яг таарах", + "error.route.404": "Хуудас олдсонгүй", "error.route.400": "Хуудас ачаалахад алдаа гарлаа", "error.input.mustBeNotEmpty": "Энэ талбарыг бөглөнө үү", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index 9287cc5b..5a027675 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -137,6 +137,7 @@ "transactions.query.filter.keyword.all": "Aramak", "transactions.query.filter.keyword.hint": "Başlığa göre ara...", "transactions.query.filter.keyword.clear": "Temiz", + "transactions.query.filter.keyword.includeDescription": "Notlar ekle", "transactions.query.filter.timeRange": "Zaman aralığı", "transactions.query.filter.timeRange.all": "Tüm Zamanlar", "transactions.query.filter.accounts": "Hesap", @@ -381,6 +382,11 @@ "enum.BackupEntryType@other": "Diğer yedekleme", "enum.BackupEntryType@other.description": "Diğer yedekleme", + "enum.TransactionSearchMode": "Arama modu", + "enum.TransactionSearchMode@smart": "Akıllı", + "enum.TransactionSearchMode@substring": "Kısmi eşleşme", + "enum.TransactionSearchMode@exact": "Tam eşleşme", + "error.route.404": "Sayfa bulunamadı", "error.route.400": "Sayfa yüklenemedi", "error.input.mustBeNotEmpty": "Lütfen bu alanı doldurun", diff --git a/lib/data/transactions_filter/search_data.dart b/lib/data/transactions_filter/search_data.dart index e98a688a..147ea726 100644 --- a/lib/data/transactions_filter/search_data.dart +++ b/lib/data/transactions_filter/search_data.dart @@ -1,8 +1,25 @@ import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/named_enum.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/objectbox/objectbox.g.dart"; import "package:flow/utils/optional.dart"; +enum TransactionSearchMode implements LocalizedEnum { + /// Fuzzy matching, allows for little error + smart, + + /// Text must contain the keyword, no room for error + substring, + + /// Text must be exactly the keyword + exact; + + @override + String get localizationEnumValue => name; + @override + String get localizationEnumName => "TransactionSearchMode"; +} + /// Fuzzy finding is case insensitive regardless of [caseInsensitive] class TransactionSearchData { /// Recomend using normalizedKeyword. @@ -17,19 +34,12 @@ class TransactionSearchData { return null; } - if (caseInsensitive) { - return trimmed.toLowerCase(); - } - - return trimmed; + return trimmed.toLowerCase(); } - /// When [true], uses fuzzy matching - /// else, exact matching - final bool smartMatch; + final TransactionSearchMode mode; - /// Fuzzy finding is case insensitive regardless of this - final bool caseInsensitive; + final bool includeDescription; /// Base score is [10.0] /// @@ -40,72 +50,83 @@ class TransactionSearchData { const TransactionSearchData({ this.keyword, - this.smartMatch = true, - this.caseInsensitive = true, + this.mode = TransactionSearchMode.smart, this.smartMatchThreshold = 80.0, + this.includeDescription = true, }); bool predicate(Transaction t) { - if (!smartMatch) { - return _stupidMatching(t); + if (includeDescription && + normalizedKeyword != null && + t.description?.toLowerCase().contains(normalizedKeyword!) == true) { + return true; } - if (normalizedKeyword == null) return true; - - final double score = t.titleSuggestionScore( - query: normalizedKeyword, - fuzzyPartial: true, - ); - - return score >= smartMatchThreshold; + return switch (mode) { + TransactionSearchMode.smart => _smartMatching(t), + TransactionSearchMode.substring => _substringMatching(t), + TransactionSearchMode.exact => _exactMatching(t), + }; } - bool _stupidMatching(Transaction t) { - if (normalizedKeyword == null) return true; - - final String? normalizedTitle = - caseInsensitive ? t.title?.trim().toLowerCase() : t.title?.trim(); + /// Filter is not available when smart match isn't enabled + Condition? get filter { + if (normalizedKeyword == null) { + return null; + } - if (normalizedTitle == null) return false; + if (!includeDescription) { + return _titleFilter; + } - return normalizedTitle.contains( + final Condition descriptionFilter = + Transaction_.description.contains( normalizedKeyword!, + caseSensitive: false, ); + + if (_titleFilter != null) { + return _titleFilter!.or(descriptionFilter); + } + + return Transaction_.title.notNull().or(descriptionFilter); } - /// Filter is not available when smart match isn't enabled - Condition? get filter { - if (smartMatch) { + Condition? get _titleFilter { + if (mode == TransactionSearchMode.smart) { return null; } - if (normalizedKeyword == null) { - return null; + if (mode == TransactionSearchMode.exact) { + return Transaction_.title.equals( + normalizedKeyword!, + caseSensitive: false, + ); } return Transaction_.title.contains( normalizedKeyword!, - caseSensitive: !caseInsensitive, + caseSensitive: false, ); } TransactionSearchData copyWithOptional({ Optional? keyword, - bool? smartMatch, - bool? caseInsensitive, + TransactionSearchMode? mode, + bool? includeDescription, double? smartMatchThreshold, }) { return TransactionSearchData( keyword: keyword == null ? this.keyword : keyword.value, - smartMatch: smartMatch ?? this.smartMatch, - caseInsensitive: caseInsensitive ?? this.caseInsensitive, + mode: mode ?? this.mode, + includeDescription: includeDescription ?? this.includeDescription, smartMatchThreshold: smartMatchThreshold ?? this.smartMatchThreshold, ); } @override - int get hashCode => Object.hashAll( - [keyword, smartMatch, caseInsensitive, smartMatchThreshold]); + int get hashCode => + Object.hashAll([keyword, mode, includeDescription, smartMatchThreshold]); @override bool operator ==(Object other) { @@ -118,8 +139,41 @@ class TransactionSearchData { } return keyword == other.keyword && - smartMatch == other.smartMatch && - caseInsensitive == other.caseInsensitive && + mode == other.mode && + includeDescription == other.includeDescription && smartMatchThreshold == other.smartMatchThreshold; } + + bool _smartMatching(Transaction t) { + if (normalizedKeyword == null) return true; + + final double score = t.titleSuggestionScore( + query: normalizedKeyword, + fuzzyPartial: true, + ); + + return score >= smartMatchThreshold; + } + + bool _substringMatching(Transaction t) { + if (normalizedKeyword == null) return true; + + final String? normalizedTitle = t.title?.trim().toLowerCase(); + + if (normalizedTitle == null) return false; + + return normalizedTitle.contains( + normalizedKeyword!, + ); + } + + bool _exactMatching(Transaction t) { + if (normalizedKeyword == null) return true; + + final String? normalizedTitle = t.title?.trim().toLowerCase(); + + if (normalizedTitle == null) return false; + + return normalizedTitle == normalizedKeyword!; + } } diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 65be63b3..4c6b06ca 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -588,8 +588,9 @@ extension TransactionListActions on Iterable { final matches = where(data.predicate).toList(); - if (data.smartMatch && matches.isEmpty) { - return search(data.copyWithOptional(smartMatch: false)); + if (data.mode == TransactionSearchMode.smart && matches.isEmpty) { + return search( + data.copyWithOptional(mode: TransactionSearchMode.substring)); } return matches; diff --git a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart index 0ad96fd7..a3bb9da6 100644 --- a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart +++ b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart @@ -1,6 +1,8 @@ import "package:flow/data/transactions_filter.dart"; import "package:flow/l10n/extensions.dart"; +import "package:flow/l10n/named_enum.dart"; import "package:flow/utils/optional.dart"; +import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/modal_overflow_bar.dart"; import "package:flow/widgets/general/modal_sheet.dart"; import "package:flutter/material.dart"; @@ -57,9 +59,25 @@ class _TransactionSearchSheetState extends State { child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + Frame( + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: TransactionSearchMode.values + .map( + (mode) => ChoiceChip( + label: Text(mode.localizedTextKey.t(context)), + selected: mode == _searchData.mode, + onSelected: (bool selected) => + _updateMode(selected ? mode : null)), + ) + .toList(), + ), + ), + const SizedBox(height: 16.0), + Frame( child: TextField( autofocus: true, controller: _controller, @@ -69,7 +87,16 @@ class _TransactionSearchSheetState extends State { prefixIcon: const Icon(Symbols.search_rounded), ), ), - ) + ), + const SizedBox(height: 16.0), + CheckboxListTile.adaptive( + title: Text( + "transactions.query.filter.keyword.includeDescription" + .t(context), + ), + value: _searchData.includeDescription, + onChanged: _updateIncludeDescription, + ), ], ), ), @@ -86,6 +113,29 @@ class _TransactionSearchSheetState extends State { setState(() {}); } + void _updateMode(TransactionSearchMode? mode) { + if (mode == null) return; + + _searchData = _searchData.copyWithOptional( + mode: mode, + ); + + if (!mounted) return; + + setState(() {}); + } + + void _updateIncludeDescription(bool? includeDescription) { + if (includeDescription == null) return; + + _searchData = + _searchData.copyWithOptional(includeDescription: includeDescription); + + if (!mounted) return; + + setState(() {}); + } + void clear() { _updateText(); context.pop(const TransactionSearchData()); diff --git a/pubspec.yaml b/pubspec.yaml index 6a136297..c4841754 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.11.0+106" +version: "0.11.0+107" environment: sdk: ">=3.5.0 <4.0.0" From 8136d9fbba42d5417861ab058dfbbeb8d2f3ee89 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 26 Jan 2025 03:53:43 +0800 Subject: [PATCH 13/19] #256 draft 1 --- assets/l10n/en_IN.json | 9 ++ assets/l10n/en_US.json | 9 ++ assets/l10n/it_IT.json | 9 ++ assets/l10n/mn_MN.json | 9 ++ assets/l10n/tr_TR.json | 9 ++ lib/data/transactions_filter.dart | 38 +++++++++ lib/routes/home/home_tab.dart | 30 ++++--- .../default_transaction_filter_head.dart | 25 ++++++ .../select_group_range_sheet.dart | 85 +++++++++++++++++++ .../transaction_filter_chip.dart | 5 ++ 10 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 lib/widgets/transaction_filter_head/select_group_range_sheet.dart diff --git a/assets/l10n/en_IN.json b/assets/l10n/en_IN.json index 3a12bb8d..26f9ca1f 100644 --- a/assets/l10n/en_IN.json +++ b/assets/l10n/en_IN.json @@ -145,6 +145,8 @@ "transactions.query.filter.categories": "Categories", "transactions.query.filter.categories.n": "{} categories", "transactions.query.filter.categories.all": "All Categories", + "transactions.query.filter.groupBy": "Group by", + "transactions.query.filter.sort": "Sort", "transactions.count": "{} transactions", "category": "Category", @@ -381,6 +383,13 @@ "enum.BackupEntryType@other": "Other backup", "enum.BackupEntryType@other.description": "Other backup", + "enum.TransactionGroupRange": "Group unit", + "enum.TransactionGroupRange@hour": "Hour", + "enum.TransactionGroupRange@day": "Day", + "enum.TransactionGroupRange@week": "Week", + "enum.TransactionGroupRange@month": "Month", + "enum.TransactionGroupRange@year": "Year", + "error.route.404": "Page not found", "error.route.400": "Failed to load the page", "error.input.mustBeNotEmpty": "Please fill out this field", diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index e951f71b..8dcf06ae 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -145,6 +145,8 @@ "transactions.query.filter.categories": "Categories", "transactions.query.filter.categories.n": "{} categories", "transactions.query.filter.categories.all": "All Categories", + "transactions.query.filter.groupBy": "Group by", + "transactions.query.filter.sort": "Sort", "transactions.count": "{} transactions", "category": "Category", @@ -381,6 +383,13 @@ "enum.BackupEntryType@other": "Other backup", "enum.BackupEntryType@other.description": "Other backup", + "enum.TransactionGroupRange": "Group unit", + "enum.TransactionGroupRange@hour": "Hour", + "enum.TransactionGroupRange@day": "Day", + "enum.TransactionGroupRange@week": "Week", + "enum.TransactionGroupRange@month": "Month", + "enum.TransactionGroupRange@year": "Year", + "error.route.404": "Page not found", "error.route.400": "Failed to load the page", "error.input.mustBeNotEmpty": "Please fill out this field", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 0e62d153..a223d0a5 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -145,6 +145,8 @@ "transactions.query.filter.categories": "Categorie", "transactions.query.filter.categories.n": "{} categorie", "transactions.query.filter.categories.all": "Tutte le categorie", + "transactions.query.filter.groupBy": "Raggruppa per", + "transactions.query.filter.sort": "Ordina", "transactions.count": "{count} transazioni", "category": "Categoria", @@ -381,6 +383,13 @@ "enum.BackupEntryType@other": "Altro", "enum.BackupEntryType@other.description": "Altro tipo di backup", + "enum.TransactionGroupRange": "Unità di gruppo", + "enum.TransactionGroupRange@hour": "Ora", + "enum.TransactionGroupRange@day": "Giorno", + "enum.TransactionGroupRange@week": "Settimana", + "enum.TransactionGroupRange@month": "Mese", + "enum.TransactionGroupRange@year": "Anno", + "error.route.404": "Pagina non trovata", "error.route.400": "Impossibile caricare la pagina", "error.input.mustBeNotEmpty": "Si prega di compilare questo campo", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index e1edf047..d4500293 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -145,6 +145,8 @@ "transactions.query.filter.categories": "Ангилал", "transactions.query.filter.categories.n": "{} ангилал", "transactions.query.filter.categories.all": "Бүх ангилал", + "transactions.query.filter.groupBy": "Бүлэглэх нэгж", + "transactions.query.filter.sort": "Эрэмбэ", "transactions.count": "{} гүйлгээ", "category": "Ангилал", @@ -381,6 +383,13 @@ "enum.BackupEntryType@other": "Бусад нөөц", "enum.BackupEntryType@other.description": "Бусад нөөц", + "enum.TransactionGroupRange": "Бүлэглэх нэгж", + "enum.TransactionGroupRange@hour": "Цаг", + "enum.TransactionGroupRange@day": "Өдөр", + "enum.TransactionGroupRange@week": "Долоо хоног", + "enum.TransactionGroupRange@month": "Сар", + "enum.TransactionGroupRange@year": "Жил", + "error.route.404": "Хуудас олдсонгүй", "error.route.400": "Хуудас ачаалахад алдаа гарлаа", "error.input.mustBeNotEmpty": "Энэ талбарыг бөглөнө үү", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index 9287cc5b..8def9da3 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -145,6 +145,8 @@ "transactions.query.filter.categories": "Kategori", "transactions.query.filter.categories.n": "{} kategoriler", "transactions.query.filter.categories.all": "Tüm Kategoriler", + "transactions.query.filter.groupBy": "Şuna göre grupla", + "transactions.query.filter.sort": "Sırala", "transactions.count": "{} İşlemler", "category": "Kategori", @@ -381,6 +383,13 @@ "enum.BackupEntryType@other": "Diğer yedekleme", "enum.BackupEntryType@other.description": "Diğer yedekleme", + "enum.TransactionGroupRange": "Grup birimi", + "enum.TransactionGroupRange@hour": "Saat", + "enum.TransactionGroupRange@day": "Gün", + "enum.TransactionGroupRange@week": "Hafta", + "enum.TransactionGroupRange@month": "Ay", + "enum.TransactionGroupRange@year": "Yıl", + "error.route.404": "Sayfa bulunamadı", "error.route.400": "Sayfa yüklenemedi", "error.input.mustBeNotEmpty": "Lütfen bu alanı doldurun", diff --git a/lib/data/transactions_filter.dart b/lib/data/transactions_filter.dart index 228a565b..68a5159a 100644 --- a/lib/data/transactions_filter.dart +++ b/lib/data/transactions_filter.dart @@ -2,6 +2,7 @@ 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/l10n/named_enum.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/objectbox.g.dart"; import "package:flow/utils/optional.dart"; @@ -19,6 +20,34 @@ enum TransactionSortField { createdDate; } +enum TransactionGroupRange implements LocalizedEnum { + hour, + + /// Default + day, + week, + month, + year; + + @override + String get localizationEnumName => "TransactionGroupRange"; + + @override + String get localizationEnumValue => name; + + TimeRange fromTransaction(Transaction t) => switch (this) { + TransactionGroupRange.hour => + HourTimeRange.fromDateTime(t.transactionDate), + TransactionGroupRange.day => + DayTimeRange.fromDateTime(t.transactionDate), + TransactionGroupRange.week => LocalWeekTimeRange(t.transactionDate), + TransactionGroupRange.month => + MonthTimeRange.fromDateTime(t.transactionDate), + TransactionGroupRange.year => + YearTimeRange.fromDateTime(t.transactionDate), + }; +} + /// For all fields, disabled if it's null. /// /// All values must be wrapped by [Optional] @@ -36,6 +65,8 @@ class TransactionFilter { final bool sortDescending; final TransactionSortField sortBy; + final TransactionGroupRange groupBy; + final bool? isPending; final double? minAmount; @@ -55,6 +86,7 @@ class TransactionFilter { this.sortDescending = true, this.searchData = const TransactionSearchData(), this.sortBy = TransactionSortField.transactionDate, + this.groupBy = TransactionGroupRange.day, }); static const empty = TransactionFilter(); @@ -186,6 +218,7 @@ class TransactionFilter { Optional>? accounts, bool? sortDescending, TransactionSortField? sortBy, + Optional? groupBy, Optional? isPending, Optional? minAmount, Optional? maxAmount, @@ -198,6 +231,9 @@ class TransactionFilter { categories: categories == null ? this.categories : categories.value, accounts: accounts == null ? this.accounts : accounts.value, sortBy: sortBy ?? this.sortBy, + groupBy: (groupBy == null || groupBy.value == null) + ? this.groupBy + : groupBy.value!, sortDescending: sortDescending ?? this.sortDescending, isPending: isPending == null ? this.isPending : isPending.value, minAmount: minAmount == null ? this.minAmount : minAmount.value, @@ -215,6 +251,7 @@ class TransactionFilter { accounts, sortDescending, sortBy, + groupBy, isPending, minAmount, maxAmount, @@ -232,6 +269,7 @@ class TransactionFilter { return other.range == range && other.sortDescending == sortDescending && other.sortBy == sortBy && + other.groupBy == groupBy && other.searchData == searchData && other.isPending == isPending && other.minAmount == minAmount && diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 2013d3d1..3251bb4c 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -36,18 +36,10 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { late int _plannedTransactionsNextNDays; - DateTime dateKey = Moment.startOfToday(); - - void refreshDateKey() { - if (!mounted) return; - setState(() { - dateKey = Moment.startOfToday(); - }); - } - - final TransactionFilter defaultFilter = TransactionFilter( + TransactionFilter defaultFilter = TransactionFilter( range: last30Days(), ); + DateTime dateKey = Moment.startOfToday(); late TransactionFilter currentFilter = defaultFilter.copyWithOptional(); @@ -83,11 +75,11 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { .addListener(_updatePlannedTransactionDays); _listener = AppLifecycleListener( - onShow: () => refreshDateKey(), + onShow: () => refreshDateKeyAndDefaultFilter(), ); - _timer = - Timer.periodic(const Duration(seconds: 30), (_) => refreshDateKey()); + _timer = Timer.periodic( + const Duration(seconds: 30), (_) => refreshDateKeyAndDefaultFilter()); } @override @@ -176,7 +168,7 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { .where((transaction) => !transaction.transactionDate.isAfter(now) && transaction.isPending != true) - .groupByDate(); + .groupByRange(rangeFn: currentFilter.groupBy.fromTransaction); final List pendingTransactions = transactions .where((transaction) => @@ -250,6 +242,16 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { setState(() {}); } + void refreshDateKeyAndDefaultFilter() { + if (!mounted) return; + setState(() { + dateKey = Moment.startOfToday(); + defaultFilter = TransactionFilter( + range: last30Days(), + ); + }); + } + @override bool get wantKeepAlive => true; } diff --git a/lib/widgets/default_transaction_filter_head.dart b/lib/widgets/default_transaction_filter_head.dart index 820c2837..b3010a59 100644 --- a/lib/widgets/default_transaction_filter_head.dart +++ b/lib/widgets/default_transaction_filter_head.dart @@ -5,6 +5,7 @@ import "package:flow/objectbox.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/utils/optional.dart"; import "package:flow/widgets/transaction_filter_head.dart"; +import "package:flow/widgets/transaction_filter_head/select_group_range_sheet.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"; @@ -95,6 +96,13 @@ class _DefaultTransactionsFilterHeadState ? _filter.categories : null, ), + TransactionFilterChip( + translationKey: "transactions.query.filter.groupBy", + avatar: const Icon(Symbols.atr_rounded), + onSelect: onSelectGroupBy, + defaultValue: widget.defaultFilter.groupBy, + value: _filter.groupBy, + ), ], ); } @@ -152,6 +160,23 @@ class _DefaultTransactionsFilterHeadState } } + void onSelectGroupBy() async { + final TransactionGroupRange? newGroupBy = + await showModalBottomSheet( + context: context, + builder: (context) => SelectGroupRangeSheet( + selected: filter.groupBy, + ), + isScrollControlled: true, + ); + + if (newGroupBy != null) { + setState(() { + filter = filter.copyWithOptional(groupBy: Optional(newGroupBy)); + }); + } + } + void onSelectRange() async { final TimeRange? newRange = await showTimeRangePickerSheet(context, initialValue: _filter.range); diff --git a/lib/widgets/transaction_filter_head/select_group_range_sheet.dart b/lib/widgets/transaction_filter_head/select_group_range_sheet.dart new file mode 100644 index 00000000..53095c23 --- /dev/null +++ b/lib/widgets/transaction_filter_head/select_group_range_sheet.dart @@ -0,0 +1,85 @@ +import "package:flow/data/transactions_filter.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/l10n/named_enum.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/modal_overflow_bar.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 SelectGroupRangeSheet extends StatefulWidget { + final TransactionGroupRange? selected; + + const SelectGroupRangeSheet({super.key, this.selected}); + + @override + State createState() => _SelectGroupRangeSheetState(); +} + +class _SelectGroupRangeSheetState extends State { + late TransactionGroupRange _selected; + + @override + void initState() { + _selected = widget.selected ?? TransactionGroupRange.day; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ModalSheet.scrollable( + title: Text("transactions.query.filter.groupBy".t(context)), + trailing: ModalOverflowBar( + alignment: MainAxisAlignment.end, + children: [ + 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( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Frame( + child: Wrap( + spacing: 12.0, + runSpacing: 12.0, + children: TransactionGroupRange.values + .map( + (range) => ChoiceChip( + label: Text(range.localizedNameContext(context)), + selected: _selected == range, + onSelected: (value) => _updateRange(value ? range : null), + ), + ) + .toList(), + )), + ], + ), + ), + ); + } + + void _updateRange(TransactionGroupRange? value) { + if (value == null) { + return; + } + + _selected = value; + + if (!mounted) return; + + setState(() {}); + } + + void pop() { + context.pop(_selected); + } +} diff --git a/lib/widgets/transaction_filter_head/transaction_filter_chip.dart b/lib/widgets/transaction_filter_head/transaction_filter_chip.dart index 43fb6f55..fd6bf84b 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/l10n/named_enum.dart"; import "package:flow/widgets/utils/time_and_range.dart"; import "package:flutter/material.dart"; import "package:moment_dart/moment_dart.dart"; @@ -97,6 +98,10 @@ class TransactionFilterChip extends StatelessWidget { } } + if (value case LocalizedEnum localizedEnum) { + return localizedEnum.localizedNameContext(context); + } + if (value case List list) { if (list.length > 2) { if (list.first is Account) { From c5d3bdddd18cf40d2dc0b2a588201a634c1e030c Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 26 Jan 2025 19:43:56 +0800 Subject: [PATCH 14/19] fix #256 --- lib/routes/home/home_tab.dart | 1 + lib/widgets/grouped_transaction_list.dart | 4 ++++ lib/widgets/transaction_list_tile.dart | 20 +++++++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 3251bb4c..a523654e 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -207,6 +207,7 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { ), controller: widget.scrollController, transactions: grouped, + groupBy: currentFilter.groupBy, pendingTransactions: pendingTransactionsGrouped, shouldCombineTransferIfNeeded: shouldCombineTransferIfNeeded, pendingDivider: const WavyDivider(), diff --git a/lib/widgets/grouped_transaction_list.dart b/lib/widgets/grouped_transaction_list.dart index 4aec4cf9..1f3a6631 100644 --- a/lib/widgets/grouped_transaction_list.dart +++ b/lib/widgets/grouped_transaction_list.dart @@ -49,6 +49,8 @@ class GroupedTransactionList extends StatefulWidget { final TransactionFilter? filter; + final TransactionGroupRange? groupBy; + /// Set this to [true] to make it always unobscured /// /// Set this to [false] to make it always obscured @@ -70,6 +72,7 @@ class GroupedTransactionList extends StatefulWidget { this.anchor, this.headerPadding, this.filter, + this.groupBy, this.listPadding = const EdgeInsets.symmetric(vertical: 16.0), this.itemPadding = const EdgeInsets.symmetric( horizontal: 16.0, @@ -151,6 +154,7 @@ class _GroupedTransactionListState extends State { context.confirmTransaction(transaction, confirm), duplicateFn: () => context.duplicateTransaction(transaction), overrideObscure: widget.overrideObscure, + groupRange: widget.groupBy, ), (_) => Container(), }; diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index 1d1d6004..9a9251bb 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -1,4 +1,5 @@ import "package:flow/data/flow_icon.dart"; +import "package:flow/data/transactions_filter.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction/extensions/default/transfer.dart"; import "package:flow/l10n/extensions.dart"; @@ -27,11 +28,23 @@ class TransactionListTile extends StatelessWidget { final bool? overrideObscure; + /// Determines what date/time to show. i.e.: + /// + /// * [TransactionGroupRange.hour] - Hour and minute + /// * [TransactionGroupRange.day] - Hour and minute + /// * [TransactionGroupRange.week] - Calendar date with hour and minute + /// * [TransactionGroupRange.month] - Calendar date with hour and minute + /// * [TransactionGroupRange.year] - Calendar date with hour and minute + /// + /// Defaults to [TransactionGroupRange.day] + final TransactionGroupRange? groupRange; + const TransactionListTile({ super.key, required this.transaction, required this.deleteFn, required this.combineTransfers, + this.groupRange = TransactionGroupRange.day, this.padding = EdgeInsets.zero, this.confirmFn, this.duplicateFn, @@ -217,6 +230,11 @@ class TransactionListTile extends StatelessWidget { if (pending) return transaction.transactionDate.toMoment().calendar(); - return transaction.transactionDate.toMoment().LT; + return switch (groupRange) { + TransactionGroupRange.hour || + TransactionGroupRange.day => + transaction.transactionDate.toMoment().LT, + _ => transaction.transactionDate.toMoment().lll + }; } } From 742bf2fa4a11331ce87986648a34c56acd96377b Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 26 Jan 2025 19:44:42 +0800 Subject: [PATCH 15/19] removed unused field --- lib/widgets/grouped_transaction_list.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/widgets/grouped_transaction_list.dart b/lib/widgets/grouped_transaction_list.dart index 1f3a6631..f525bd4b 100644 --- a/lib/widgets/grouped_transaction_list.dart +++ b/lib/widgets/grouped_transaction_list.dart @@ -47,8 +47,6 @@ class GroupedTransactionList extends StatefulWidget { final Widget? header; - final TransactionFilter? filter; - final TransactionGroupRange? groupBy; /// Set this to [true] to make it always unobscured @@ -71,7 +69,6 @@ class GroupedTransactionList extends StatefulWidget { this.pendingTrailing, this.anchor, this.headerPadding, - this.filter, this.groupBy, this.listPadding = const EdgeInsets.symmetric(vertical: 16.0), this.itemPadding = const EdgeInsets.symmetric( From 61a941500925f9ac1058131510559f0292a6ad1c Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 26 Jan 2025 20:53:48 +0800 Subject: [PATCH 16/19] Release beta 0.11.0 candidate 1, final touches --- assets/l10n/en_IN.json | 2 + assets/l10n/en_US.json | 2 + assets/l10n/it_IT.json | 2 + assets/l10n/mn_MN.json | 2 + assets/l10n/tr_TR.json | 2 + lib/data/flow_standard_report.dart | 19 ++- lib/data/money_flow.dart | 2 +- lib/objectbox/actions.dart | 3 - lib/routes/home/accounts_tab.dart | 37 ++--- lib/routes/home/stats_tab.dart | 68 +++++---- lib/routes/stats/stats_by_group_page.dart | 22 +-- lib/theme/theme.dart | 7 +- lib/widgets/general/list_header.dart | 2 +- .../home/stats/exchange_missing_notice.dart | 66 -------- .../home/stats/most_spending_category.dart | 144 ++++++++++++++++++ lib/widgets/rates_missing_warning.dart | 69 +++++---- pubspec.yaml | 2 +- 17 files changed, 280 insertions(+), 171 deletions(-) delete mode 100644 lib/widgets/home/stats/exchange_missing_notice.dart create mode 100644 lib/widgets/home/stats/most_spending_category.dart diff --git a/assets/l10n/en_IN.json b/assets/l10n/en_IN.json index 9d8f3d15..ff66dd95 100644 --- a/assets/l10n/en_IN.json +++ b/assets/l10n/en_IN.json @@ -250,6 +250,8 @@ "tabs.stats.dailyReport.totalExpenseFor": "{} total expense", "tabs.stats.summaryByAccount": "Summary by account", "tabs.stats.summaryByCategory": "Summary by category", + "tabs.stats.topSpendingCategory": "Top spending category", + "tabs.stats.otherStats": "Other stats", "tabs.accounts": "Accounts", "tabs.accounts.reorder": "Reorder accounts", "tabs.accounts.reorder.guide": "Long press and drag", diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index caf470c3..7ffa72ed 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -250,6 +250,8 @@ "tabs.stats.dailyReport.totalExpenseFor": "{} total expense", "tabs.stats.summaryByAccount": "Summary by account", "tabs.stats.summaryByCategory": "Summary by category", + "tabs.stats.topSpendingCategory": "Top spending category", + "tabs.stats.otherStats": "Other stats", "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 bff431fd..c558b5ca 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -250,6 +250,8 @@ "tabs.stats.dailyReport.totalExpenseFor": "Spesa totale {}", "tabs.stats.summaryByAccount": "Riepilogo per conto", "tabs.stats.summaryByCategory": "Riepilogo per categoria", + "tabs.stats.topSpendingCategory": "Categoria di spesa principale", + "tabs.stats.otherStats": "Altre statistiche", "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 8b218e5c..1bcb8ae5 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -250,6 +250,8 @@ "tabs.stats.dailyReport.totalExpenseFor": "{}-н нийт зарлага", "tabs.stats.summaryByAccount": "Данс бүрээр харах", "tabs.stats.summaryByCategory": "Ангилал бүрээр харах", + "tabs.stats.topSpendingCategory": "Хамгийн зарлагатай ангилал", + "tabs.stats.otherStats": "Бусад тоо", "tabs.accounts": "Данснууд", "tabs.accounts.reorder": "Дараалал өөрчлөх", "tabs.accounts.reorder.guide": "Удаан дарж чирнэ үү", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index df775289..ecda0da4 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -250,6 +250,8 @@ "tabs.stats.dailyReport.totalExpenseFor": "{} toplam gider", "tabs.stats.summaryByAccount": "Hesaba göre özet", "tabs.stats.summaryByCategory": "Kategoriye göre özet", + "tabs.stats.topSpendingCategory": "En çok harcama yapılan kategori", + "tabs.stats.otherStats": "Diğer istatistikler", "tabs.accounts": "Hesap", "tabs.accounts.reorder": "Hesapları yeniden sıralama", "tabs.accounts.reorder.guide": "Uzun basın ve sürükleyin", diff --git a/lib/data/flow_standard_report.dart b/lib/data/flow_standard_report.dart index 7624e631..5cfb9d4d 100644 --- a/lib/data/flow_standard_report.dart +++ b/lib/data/flow_standard_report.dart @@ -7,7 +7,6 @@ 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/services/exchange_rates.dart"; import "package:moment_dart/moment_dart.dart"; /// Only capable of working with the primary currency @@ -182,13 +181,20 @@ class FlowStandardReport { previousDailyAvgExpenditure! + previousDailyAvgIncome!; } - static Future generate(TimeRange range) async { + /// When [rates] is not available, ignores all other currencies. + /// + /// It's a good idea to show a notice to the user that the report is not accurate. + static Future generate( + TimeRange range, + ExchangeRates? rates, + ) async { final Map> currentFlowByDay = - await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(range); + await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(range, rates); final Map>? previousFlowByDay = switch (range) { PageableRange pageable => - await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(pageable.last), + await _reportMonthRangeFlowByDayInPrimaryCurrencyOnly( + pageable.last, rates), _ => null, }; @@ -200,10 +206,9 @@ class FlowStandardReport { } static Future>> - _reportMonthRangeFlowByDayInPrimaryCurrencyOnly(TimeRange range) async { + _reportMonthRangeFlowByDayInPrimaryCurrencyOnly( + TimeRange range, ExchangeRates? rates) async { final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); - final ExchangeRates? rates = - ExchangeRatesService().getPrimaryCurrencyRates(); final List transactions = await ObjectBox().transcationsByRange(range); diff --git a/lib/data/money_flow.dart b/lib/data/money_flow.dart index 88b0004b..1063f839 100644 --- a/lib/data/money_flow.dart +++ b/lib/data/money_flow.dart @@ -78,7 +78,7 @@ class MoneyFlow { /// Returns the converted sum of all expenses in given [currency], /// or rates.baseCurrency if null - Money getTotalExpense(ExchangeRates rates, String? currency) { + Money getTotalExpense(ExchangeRates rates, [String? currency]) { currency ??= rates.baseCurrency; double amount = 0.0; diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 4c6b06ca..cf0272e2 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -184,7 +184,6 @@ extension MainActions on ObjectBox { Future> flowByCategories({ required TimeRange range, bool ignoreTransfers = true, - String? currencyOverride, }) async { final List transactions = await transcationsByRange(range); @@ -201,8 +200,6 @@ extension MainActions on ObjectBox { Future> flowByAccounts({ required TimeRange range, bool ignoreTransfers = true, - bool omitZeroes = true, - String? currencyOverride, }) async { final List transactions = await transcationsByRange(range); diff --git a/lib/routes/home/accounts_tab.dart b/lib/routes/home/accounts_tab.dart index 5dbf9dc5..c7a8ba4b 100644 --- a/lib/routes/home/accounts_tab.dart +++ b/lib/routes/home/accounts_tab.dart @@ -67,26 +67,29 @@ class _AccountsTabState extends State builder: (context, excludeTransfersInTotal, child) { return Expanded( child: _reordering - ? ReorderableListView.builder( - padding: const EdgeInsets.all(16.0) - .copyWith(bottom: 96.0), - itemBuilder: (context, index) => - Padding( - key: ValueKey(accounts[index].uuid), + ? Frame( + child: ReorderableListView.builder( padding: const EdgeInsets.only( - bottom: 16.0), - child: AccountCard( - account: accounts[index], - useCupertinoContextMenu: false, - excludeTransfersInTotal: - excludeTransfersInTotal == true, + bottom: 96.0), + itemBuilder: (context, index) => + Padding( + key: ValueKey(accounts[index].uuid), + padding: const EdgeInsets.only( + bottom: 16.0), + child: AccountCard( + account: accounts[index], + useCupertinoContextMenu: false, + excludeTransfersInTotal: + excludeTransfersInTotal == + true, + ), ), + proxyDecorator: proxyDecorator, + itemCount: accounts.length, + onReorder: (oldIndex, newIndex) => + onReorder( + accounts, oldIndex, newIndex), ), - proxyDecorator: proxyDecorator, - itemCount: accounts.length, - onReorder: (oldIndex, newIndex) => - onReorder( - accounts, oldIndex, newIndex), ) : ListView( padding: const EdgeInsets.all(16.0), diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 5b8d83fb..22306ece 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,18 +1,22 @@ import "dart:ui"; import "package:auto_size_text/auto_size_text.dart"; +import "package:flow/data/exchange_rates.dart"; import "package:flow/data/flow_icon.dart"; import "package:flow/data/flow_standard_report.dart"; import "package:flow/l10n/extensions.dart"; -import "package:flow/prefs.dart"; +import "package:flow/services/exchange_rates.dart"; import "package:flow/theme/helpers.dart"; -import "package:flow/widgets/action_card.dart"; +import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/home/stats/info_card_with_delta.dart"; +import "package:flow/widgets/home/stats/most_spending_category.dart"; import "package:flow/widgets/home/stats/no_data.dart"; import "package:flow/widgets/home/stats/range_daily_chart.dart"; +import "package:flow/widgets/rates_missing_warning.dart"; import "package:flow/widgets/time_range_selector.dart"; import "package:flow/widgets/trend.dart"; import "package:flutter/material.dart"; @@ -34,27 +38,23 @@ class _StatsTabState extends State final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); - late bool initiallyAbbreviated; - bool busy = false; + ExchangeRates? rates; + @override void initState() { super.initState(); fetch(); - initiallyAbbreviated = !LocalPreferences().preferFullAmounts.get(); - LocalPreferences() - .preferFullAmounts - .addListener(_updateInitiallyAbbreviated); + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + ExchangeRatesService().exchangeRatesCache.addListener(_updateRates); } @override void dispose() { - LocalPreferences() - .preferFullAmounts - .removeListener(_updateInitiallyAbbreviated); + ExchangeRatesService().exchangeRatesCache.removeListener(_updateRates); super.dispose(); } @@ -80,10 +80,13 @@ class _StatsTabState extends State onChanged: updateRange, ), ), + if (rates == null) RatesMissingWarning(), + if (rates == null) const SizedBox(height: 12.0), Expanded( child: hasData ? SingleChildScrollView( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Frame( child: Column( @@ -108,7 +111,6 @@ class _StatsTabState extends State style: context.textTheme.displaySmall, autoSize: true, tapToToggleAbbreviation: true, - initiallyAbbreviated: initiallyAbbreviated, ), const SizedBox(width: 8.0), Trend.fromMoney( @@ -170,20 +172,32 @@ class _StatsTabState extends State ), ), const SizedBox(height: 24.0), - Frame( - child: ActionCard( - icon: FlowIconData.icon(Symbols.category_rounded), - title: "tabs.stats.summaryByCategory".t(context), - onTap: () => context.push("/stats/category"), + ListHeader("tabs.stats.topSpendingCategory".t(context)), + const SizedBox(height: 8.0), + Frame(child: MostSpendingCategory(range: range)), + const SizedBox(height: 24.0), + ListHeader("tabs.stats.otherStats".t(context)), + ListTile( + title: Text("tabs.stats.summaryByCategory".t(context)), + onTap: () => context.push( + "/stats/category?range=${Uri.encodeQueryComponent(range.encodeShort())}", + ), + leading: FlowIcon( + FlowIconData.icon(Symbols.category_rounded), + size: 24.0, ), + trailing: Icon(Symbols.chevron_right_rounded), ), - const SizedBox(height: 16.0), - Frame( - child: ActionCard( - icon: FlowIconData.icon(Symbols.wallet_rounded), - title: "tabs.stats.summaryByAccount".t(context), - onTap: () => context.push("/stats/account"), + ListTile( + title: Text("tabs.stats.summaryByAccount".t(context)), + onTap: () => context.push( + "/stats/account?range=${Uri.encodeQueryComponent(range.encodeShort())}", ), + leading: FlowIcon( + FlowIconData.icon(Symbols.wallet_rounded), + size: 24.0, + ), + trailing: Icon(Symbols.chevron_right_rounded), ), const SizedBox(height: 96.0), ], @@ -204,14 +218,12 @@ class _StatsTabState extends State } Future fetch() async { - if (busy) return; - setState(() { busy = true; }); try { - report = await FlowStandardReport.generate(range); + report = await FlowStandardReport.generate(range, rates); } finally { busy = false; @@ -221,8 +233,8 @@ class _StatsTabState extends State } } - void _updateInitiallyAbbreviated() { - initiallyAbbreviated = !LocalPreferences().preferFullAmounts.get(); + void _updateRates() { + rates = ExchangeRatesService().getPrimaryCurrencyRates(); if (mounted) { setState(() {}); } diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart index d4d500da..d1acda79 100644 --- a/lib/routes/stats/stats_by_group_page.dart +++ b/lib/routes/stats/stats_by_group_page.dart @@ -12,7 +12,7 @@ import "package:flow/prefs.dart"; import "package:flow/widgets/home/stats/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/rates_missing_warning.dart"; import "package:flow/widgets/time_range_selector.dart"; import "package:flow/widgets/utils/time_and_range.dart"; import "package:flutter/material.dart"; @@ -54,7 +54,13 @@ class StatsByGroupPageState extends State @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(), + appBar: AppBar( + title: Text( + widget.byCategory + ? "tabs.stats.summaryByCategory".t(context) + : "tabs.stats.summaryByAccount".t(context), + ), + ), body: ValueListenableBuilder( valueListenable: ExchangeRatesService().exchangeRatesCache, builder: (context, exchangeRatesCache, child) { @@ -106,7 +112,7 @@ class StatsByGroupPageState extends State ), ], ), - if (rates == null) const ExchangeMissingNotice(), + if (rates == null) const RatesMissingWarning(), Expanded( child: TabBarView( controller: _tabController, @@ -148,14 +154,8 @@ class StatsByGroupPageState extends State try { analytics = widget.byCategory - ? await ObjectBox().flowByCategories( - range: range, - currencyOverride: LocalPreferences().getPrimaryCurrency(), - ) - : await ObjectBox().flowByAccounts( - range: range, - currencyOverride: LocalPreferences().getPrimaryCurrency(), - ); + ? await ObjectBox().flowByCategories(range: range) + : await ObjectBox().flowByAccounts(range: range); } finally { busy = false; diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 8cbd3905..30114b7d 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -100,9 +100,10 @@ class ThemeFactory { highlightColor: colorScheme.onSurface.withAlpha(0x16), splashColor: colorScheme.onSurface.withAlpha(0x12), listTileTheme: ListTileThemeData( - iconColor: colorScheme.primary, - selectedTileColor: colorScheme.secondary, - selectedColor: isDark ? colorScheme.primary : null), + iconColor: colorScheme.primary, + selectedTileColor: colorScheme.secondary, + selectedColor: isDark ? colorScheme.primary : null, + ), radioTheme: RadioThemeData( fillColor: WidgetStateProperty.resolveWith( (states) { diff --git a/lib/widgets/general/list_header.dart b/lib/widgets/general/list_header.dart index d5b6f0b9..60b4f822 100644 --- a/lib/widgets/general/list_header.dart +++ b/lib/widgets/general/list_header.dart @@ -11,7 +11,7 @@ class ListHeader extends StatelessWidget { this.title, { super.key, this.style, - this.padding = const EdgeInsets.symmetric(horizontal: 12.0), + this.padding = const EdgeInsets.symmetric(horizontal: 16.0), }); @override diff --git a/lib/widgets/home/stats/exchange_missing_notice.dart b/lib/widgets/home/stats/exchange_missing_notice.dart deleted file mode 100644 index 316de44d..00000000 --- a/lib/widgets/home/stats/exchange_missing_notice.dart +++ /dev/null @@ -1,66 +0,0 @@ -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(), - ); - await Future.delayed(const Duration(milliseconds: 1000)); - } finally { - setState(() { - busy = false; - }); - } - } -} diff --git a/lib/widgets/home/stats/most_spending_category.dart b/lib/widgets/home/stats/most_spending_category.dart new file mode 100644 index 00000000..1c22f0be --- /dev/null +++ b/lib/widgets/home/stats/most_spending_category.dart @@ -0,0 +1,144 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/flow_analytics.dart"; +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/prefs.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/theme/helpers.dart"; +import "package:flow/widgets/general/flow_icon.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +class MostSpendingCategory extends StatefulWidget { + final TimeRange range; + + final BorderRadius? borderRadius; + + const MostSpendingCategory( + {super.key, + required this.range, + this.borderRadius = const BorderRadius.all(Radius.circular(16.0))}); + + @override + State createState() => _MostSpendingCategoryState(); +} + +class _MostSpendingCategoryState extends State { + late TimeRange range; + + Category? category; + Money? expense; + + bool busy = false; + + @override + void initState() { + super.initState(); + range = widget.range; + fetch(); + } + + @override + void didUpdateWidget(MostSpendingCategory oldWidget) { + if (widget.range != oldWidget.range) { + setState(() { + range = widget.range; + fetch(); + }); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: widget.borderRadius, + onTap: category == null + ? null + : (() => context.push( + "/category/${category?.id}?range=${Uri.encodeQueryComponent(range.encodeShort())}")), + child: Surface( + shape: RoundedRectangleBorder( + borderRadius: widget.borderRadius as BorderRadiusGeometry, + ), + builder: (context) => Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + spacing: 16.0, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8.0, + children: [ + FlowIcon( + category?.icon ?? + FlowIconData.icon(Symbols.category_rounded), + ), + Text(category?.name ?? "category.none".t(context)) + ], + ), + MoneyText( + expense, + autoSize: true, + style: context.textTheme.displaySmall, + ), + ], + ), + ), + Icon(Symbols.chevron_right_rounded), + ], + ), + ), + ), + ); + } + + void fetch() async { + setState(() { + busy = true; + }); + + try { + final FlowAnalytics result = + await ObjectBox().flowByCategories(range: range); + final String primaryCurrency = LocalPreferences().getPrimaryCurrency(); + final ExchangeRates? rates = + ExchangeRatesService().getPrimaryCurrencyRates(); + + Money? mostExpense; + Category? mostExpensedCategory; + + for (final flow in result.flow.values) { + final Money flowTotalExpense = rates == null + ? flow.getExpenseByCurrency(primaryCurrency) + : flow.getTotalExpense(rates, primaryCurrency); + + if (mostExpense == null || + flowTotalExpense.amount.abs() > mostExpense.amount.abs()) { + mostExpense = flowTotalExpense; + mostExpensedCategory = flow.associatedData; + } + } + + category = mostExpensedCategory; + expense = mostExpense; + } finally { + busy = false; + + if (mounted) { + setState(() {}); + } + } + } +} diff --git a/lib/widgets/rates_missing_warning.dart b/lib/widgets/rates_missing_warning.dart index c285ca78..eb8ea7a2 100644 --- a/lib/widgets/rates_missing_warning.dart +++ b/lib/widgets/rates_missing_warning.dart @@ -3,6 +3,7 @@ import "package:flow/prefs.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/toast.dart"; +import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons/symbols.dart"; @@ -20,41 +21,43 @@ class _RatesMissingWarningState extends State { @override Widget build(BuildContext context) { return InkWell( - borderRadius: BorderRadius.all(Radius.circular(8.0)), onTap: fetch, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Symbols.error_circle_rounded, - fill: 0, - color: context.colorScheme.error, - size: 24.0, - ), - const SizedBox(width: 12.0), - Expanded( - child: DefaultTextStyle( - style: context.textTheme.bodyMedium! - .semi(context) - .copyWith(color: context.colorScheme.error), - child: Text("error.exchangeRates.inaccurateDataDueToMissingRates" - .t(context)), + child: Frame.standalone( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Symbols.error_circle_rounded, + fill: 0, + color: context.colorScheme.error, + size: 24.0, ), - ), - const SizedBox(width: 12.0), - busy - ? SizedBox( - width: 24.0, - height: 24.0, - child: Spinner(), - ) - : Icon( - Symbols.refresh_rounded, - fill: 0, - size: 24.0, - color: context.colorScheme.error, - ), - ], + const SizedBox(width: 12.0), + Expanded( + child: DefaultTextStyle( + style: context.textTheme.bodyMedium! + .semi(context) + .copyWith(color: context.colorScheme.error), + child: Text( + "error.exchangeRates.inaccurateDataDueToMissingRates" + .t(context)), + ), + ), + const SizedBox(width: 12.0), + busy + ? SizedBox( + width: 24.0, + height: 24.0, + child: Spinner(), + ) + : Icon( + Symbols.refresh_rounded, + fill: 0, + size: 24.0, + color: context.colorScheme.error, + ), + ], + ), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index c4841754..68544c05 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.11.0+107" +version: "0.11.0+108" environment: sdk: ">=3.5.0 <4.0.0" From 31aa51e5c916d94ae5415991f0e3fad0ef01b149 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 26 Jan 2025 21:00:04 +0800 Subject: [PATCH 17/19] refactor --- lib/routes/home/stats_tab.dart | 145 +++++++++--------- lib/widgets/general/blur_on_busy.dart | 40 +++++ .../home/stats/most_spending_category.dart | 79 +++++----- 3 files changed, 151 insertions(+), 113 deletions(-) create mode 100644 lib/widgets/general/blur_on_busy.dart diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 22306ece..60cb277c 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,5 +1,3 @@ -import "dart:ui"; - import "package:auto_size_text/auto_size_text.dart"; import "package:flow/data/exchange_rates.dart"; import "package:flow/data/flow_icon.dart"; @@ -7,6 +5,7 @@ import "package:flow/data/flow_standard_report.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/theme/helpers.dart"; +import "package:flow/widgets/general/blur_on_busy.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/list_header.dart"; @@ -88,87 +87,81 @@ class _StatsTabState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Frame( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - showForecast - ? "tabs.stats.dailyReport.forecastFor" - .t(context, report!.current.format()) - : "tabs.stats.dailyReport.totalExpenseFor" - .t(context, report!.current.format()), - style: - context.textTheme.titleSmall?.semi(context), - ), - Row( - children: [ - MoneyText( - showForecast - ? report!.currentExpenseSumForecast - : report!.expenseSum, - style: context.textTheme.displaySmall, - autoSize: true, - tapToToggleAbbreviation: true, - ), - const SizedBox(width: 8.0), - Trend.fromMoney( - current: showForecast - ? report!.currentExpenseSumForecast - : report!.expenseSum, - previous: report!.previousExpenseSum, - invertDelta: true, - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16.0), - ClipRect( - child: Stack( - children: [ - RangeDailyChart(report: report!), - if (busy) - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 2.0, - sigmaY: 2.0, + BlurOnBusy( + busy: busy, + child: Frame( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showForecast + ? "tabs.stats.dailyReport.forecastFor" + .t(context, report!.current.format()) + : "tabs.stats.dailyReport.totalExpenseFor" + .t(context, report!.current.format()), + style: + context.textTheme.titleSmall?.semi(context), + ), + Row( + children: [ + MoneyText( + showForecast + ? report!.currentExpenseSumForecast + : report!.expenseSum, + style: context.textTheme.displaySmall, + autoSize: true, + tapToToggleAbbreviation: true, ), - child: Container(), - ), + const SizedBox(width: 8.0), + Trend.fromMoney( + current: showForecast + ? report!.currentExpenseSumForecast + : report!.expenseSum, + previous: report!.previousExpenseSum, + invertDelta: true, + ), + ], ), - ], + ], + ), ), ), + const SizedBox(height: 16.0), + BlurOnBusy( + busy: busy, + child: RangeDailyChart(report: report!), + ), const SizedBox(height: 24.0), - Frame( - child: Row( - children: [ - Expanded( - child: InfoCardWithDelta( - title: "tabs.stats.dailyReport.dailyAvgExpense" - .t(context), - autoSizeGroup: autoSizeGroup, - money: report!.dailyAvgExpenditure, - previousMoney: - report!.previousDailyAvgExpenditure, - invertDelta: true, + BlurOnBusy( + busy: busy, + child: Frame( + child: Row( + children: [ + Expanded( + child: InfoCardWithDelta( + title: + "tabs.stats.dailyReport.dailyAvgExpense" + .t(context), + autoSizeGroup: autoSizeGroup, + money: report!.dailyAvgExpenditure, + previousMoney: + report!.previousDailyAvgExpenditure, + invertDelta: true, + ), ), - ), - const SizedBox(width: 16.0), - Expanded( - child: InfoCardWithDelta( - title: "tabs.stats.dailyReport.dailyAvgIncome" - .t(context), - autoSizeGroup: autoSizeGroup, - money: report!.dailyAvgIncome, - previousMoney: report!.previousDailyAvgIncome, + const SizedBox(width: 16.0), + Expanded( + child: InfoCardWithDelta( + title: "tabs.stats.dailyReport.dailyAvgIncome" + .t(context), + autoSizeGroup: autoSizeGroup, + money: report!.dailyAvgIncome, + previousMoney: report!.previousDailyAvgIncome, + ), ), - ), - ], + ], + ), ), ), const SizedBox(height: 24.0), diff --git a/lib/widgets/general/blur_on_busy.dart b/lib/widgets/general/blur_on_busy.dart new file mode 100644 index 00000000..73f4ab27 --- /dev/null +++ b/lib/widgets/general/blur_on_busy.dart @@ -0,0 +1,40 @@ +import "dart:ui"; + +import "package:flutter/material.dart"; + +class BlurOnBusy extends StatelessWidget { + final bool busy; + final Widget child; + + final double sigmaX; + final double sigmaY; + + const BlurOnBusy({ + super.key, + required this.busy, + required this.child, + this.sigmaX = 2.0, + this.sigmaY = 2.0, + }); + + @override + Widget build(BuildContext context) { + return ClipRect( + child: Stack( + children: [ + child, + if (busy) + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: sigmaX, + sigmaY: sigmaY, + ), + child: Container(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/home/stats/most_spending_category.dart b/lib/widgets/home/stats/most_spending_category.dart index 1c22f0be..019d42e6 100644 --- a/lib/widgets/home/stats/most_spending_category.dart +++ b/lib/widgets/home/stats/most_spending_category.dart @@ -9,6 +9,7 @@ import "package:flow/objectbox/actions.dart"; import "package:flow/prefs.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/theme/helpers.dart"; +import "package:flow/widgets/general/blur_on_busy.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/general/surface.dart"; @@ -22,10 +23,11 @@ class MostSpendingCategory extends StatefulWidget { final BorderRadius? borderRadius; - const MostSpendingCategory( - {super.key, - required this.range, - this.borderRadius = const BorderRadius.all(Radius.circular(16.0))}); + const MostSpendingCategory({ + super.key, + required this.range, + this.borderRadius = const BorderRadius.all(Radius.circular(16.0)), + }); @override State createState() => _MostSpendingCategoryState(); @@ -61,43 +63,46 @@ class _MostSpendingCategoryState extends State { Widget build(BuildContext context) { return InkWell( borderRadius: widget.borderRadius, - onTap: category == null + onTap: (busy || category == null) ? null : (() => context.push( "/category/${category?.id}?range=${Uri.encodeQueryComponent(range.encodeShort())}")), - child: Surface( - shape: RoundedRectangleBorder( - borderRadius: widget.borderRadius as BorderRadiusGeometry, - ), - builder: (context) => Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - spacing: 16.0, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: 8.0, - children: [ - FlowIcon( - category?.icon ?? - FlowIconData.icon(Symbols.category_rounded), - ), - Text(category?.name ?? "category.none".t(context)) - ], - ), - MoneyText( - expense, - autoSize: true, - style: context.textTheme.displaySmall, - ), - ], + child: BlurOnBusy( + busy: busy, + child: Surface( + shape: RoundedRectangleBorder( + borderRadius: widget.borderRadius as BorderRadiusGeometry, + ), + builder: (context) => Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + spacing: 16.0, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8.0, + children: [ + FlowIcon( + category?.icon ?? + FlowIconData.icon(Symbols.category_rounded), + ), + Text(category?.name ?? "category.none".t(context)) + ], + ), + MoneyText( + expense, + autoSize: true, + style: context.textTheme.displaySmall, + ), + ], + ), ), - ), - Icon(Symbols.chevron_right_rounded), - ], + Icon(Symbols.chevron_right_rounded), + ], + ), ), ), ), From 1e888010bd93cfa3ed21ca1fc20b4b083e893ed1 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 26 Jan 2025 21:11:28 +0800 Subject: [PATCH 18/19] Update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84588461..72b92b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ # Changelog -## Beta 0.11.0 (next) +## Beta 0.11.0 * Reworked stats tab (ongoing) * Enhanced search options (ongoing) * Added partial and exact match mode * Added option to include description, closes [#269](https://github.com/flow-mn/flow/issues/269) + At the time, it will only do substring (partial) matching. +* Now you can group transcations by hour, day, week, month, and year, closes [#256](https://github.com/flow-mn/flow/issues/256) ## Beta 0.10.2 From a9bf4d80370a1c5b64401248c08fd7cb5d8a7044 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 26 Jan 2025 21:17:54 +0800 Subject: [PATCH 19/19] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b92b30..2c681f38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Added option to include description, closes [#269](https://github.com/flow-mn/flow/issues/269) At the time, it will only do substring (partial) matching. * Now you can group transcations by hour, day, week, month, and year, closes [#256](https://github.com/flow-mn/flow/issues/256) +* Fixed that the default filters weren't updating when the day changes (at 00:00) ## Beta 0.10.2