From abe3ecea77656918bfb83989cebc448add604304 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 6 Jun 2026 23:00:50 +0800 Subject: [PATCH 1/6] analytics overhaul test --- .fvmrc | 3 + .gitignore | 5 +- .vscode/settings.json | 13 +- ios/Podfile.lock | 14 +- ios/Runner.xcodeproj/project.pbxproj | 18 - .../actionable_notification.dart | 4 +- lib/data/flow_button_type.dart | 2 +- lib/data/flow_icon.dart | 3 + lib/data/icons.dart | 15 +- lib/data/setup/default_accounts.dart | 2 +- lib/data/setup/default_categories.dart | 2 +- lib/entity/account.dart | 2 +- lib/entity/category.dart | 2 +- lib/entity/goal.dart | 2 +- lib/main.dart | 2 +- lib/objectbox.dart | 2 +- lib/routes.dart | 30 + lib/routes/account/account_edit_page.dart | 2 +- lib/routes/account_page.dart | 2 +- lib/routes/categories_page.dart | 2 +- lib/routes/category/category_edit_page.dart | 2 +- lib/routes/category_page.dart | 2 +- lib/routes/community/contributors_page.dart | 2 +- .../debug/analytics/debug_cash_flow_page.dart | 357 ++++++++++ .../debug/analytics/debug_net_worth_page.dart | 552 +++++++++++++++ .../debug/analytics/debug_recurring_page.dart | 395 +++++++++++ .../debug_spending_calendar_page.dart | 275 ++++++++ .../analytics/debug_spending_map_page.dart | 427 ++++++++++++ .../debug/analytics/debug_wrapped_page.dart | 636 ++++++++++++++++++ lib/routes/debug/debug_icloud_page.dart | 2 +- lib/routes/debug/debug_logs_page.dart | 2 +- lib/routes/debug/debug_theme_page.dart | 2 +- lib/routes/error_page.dart | 2 +- lib/routes/export/export_history_page.dart | 2 +- lib/routes/export/export_pdf_page.dart | 2 +- lib/routes/export_options_page.dart | 2 +- lib/routes/home/accounts_tab.dart | 12 +- lib/routes/home/profile_tab.dart | 36 +- lib/routes/import_page.dart | 4 +- lib/routes/integrate/integrate_eny_page.dart | 2 +- lib/routes/integrations/eny_page.dart | 2 +- .../preferences/change_preferences_page.dart | 2 +- .../integrations/eny_preferences_page.dart | 2 +- .../preferences/language_selection_sheet.dart | 2 +- lib/routes/preferences/sections/haptics.dart | 2 +- lib/routes/preferences/sections/icloud.dart | 2 +- lib/routes/preferences/sections/lock_app.dart | 2 +- lib/routes/preferences/sections/privacy.dart | 2 +- .../preferences/theme_preferences_page.dart | 2 +- ...ansaction_entry_flow_preferences_page.dart | 6 +- ...list_item_appearance_preferences_page.dart | 4 +- .../trash_bin_preferences_page.dart | 2 +- lib/routes/preferences_page.dart | 2 +- lib/routes/profile_page.dart | 2 +- lib/routes/setup/setup_accounts_page.dart | 2 +- lib/routes/setup/setup_categories_page.dart | 2 +- lib/routes/setup/setup_currency_page.dart | 2 +- lib/routes/setup/setup_onboarding_page.dart | 4 +- lib/routes/setup/setup_profile_page.dart | 2 +- .../setup/setup_profile_picture_page.dart | 2 +- lib/routes/setup_page.dart | 2 +- lib/routes/stats/stats_by_group_page.dart | 2 +- lib/routes/support_page.dart | 2 +- lib/routes/transaction_batch_import_page.dart | 2 +- lib/routes/transaction_page.dart | 2 +- .../transaction_page/input_amount_sheet.dart | 2 +- .../input_amount_sheet/calculator_button.dart | 2 +- .../sections/description_section.dart | 4 +- .../transaction_page/select_recurrence.dart | 2 +- .../input_occurrences_sheet.dart | 2 +- .../select_recurrence_sheet.dart | 2 +- lib/routes/transaction_tag_page.dart | 2 +- lib/routes/transactions_page.dart | 2 +- lib/routes/utils/crop_square_image_page.dart | 2 +- lib/routes/utils/edit_markdown_page.dart | 2 +- lib/sync/import/external/ivy_wallet_csv.dart | 2 +- lib/sync/import/import_csv.dart | 2 +- lib/theme/color_themes/flow/flow_darks.dart | 2 +- lib/theme/color_themes/flow/flow_lights.dart | 2 +- lib/theme/color_themes/flow/flow_oleds.dart | 2 +- lib/theme/helpers.dart | 2 +- lib/utils/extensions/custom_popups.dart | 2 +- lib/utils/extensions/file_attachment.dart | 2 +- lib/utils/extensions/string.dart | 2 +- lib/utils/extensions/toast.dart | 2 +- .../extensions/transaction_tag_type.dart | 2 +- lib/utils/guess_preset_icon.dart | 2 +- .../account/update_balance_options_sheet.dart | 2 +- lib/widgets/account_card.dart | 2 +- lib/widgets/account_card_skeleton.dart | 2 +- lib/widgets/add_category_card.dart | 2 +- lib/widgets/categories/no_categories.dart | 2 +- lib/widgets/debug/analytics/bullet_chart.dart | 97 +++ lib/widgets/debug/analytics/insight_card.dart | 111 +++ .../debug/analytics/sankey_diagram.dart | 205 ++++++ .../debug/analytics/spending_heatmap.dart | 301 +++++++++ lib/widgets/debug/analytics/weekday_bars.dart | 78 +++ .../default_transaction_filter_head.dart | 2 +- lib/widgets/delete_button.dart | 2 +- .../export_history/backup_entry_card.dart | 2 +- lib/widgets/export/export_success.dart | 2 +- .../file_attachment_add_list_tile.dart | 2 +- lib/widgets/file_attachment_list_tile.dart | 2 +- lib/widgets/general/directional_chevron.dart | 2 +- lib/widgets/general/flow_icon.dart | 2 +- lib/widgets/general/form_close_button.dart | 2 +- lib/widgets/general/info_text.dart | 2 +- .../general/pending_transactions_header.dart | 2 +- lib/widgets/general/profile_picture.dart | 2 +- .../geo_permission_missing_reminder.dart | 2 +- .../home/home/account/no_accounts.dart | 2 +- lib/widgets/home/home/no_transactions.dart | 2 +- lib/widgets/home/navbar.dart | 2 +- lib/widgets/home/navbar/navbar_button.dart | 2 +- .../home/navbar/new_transaction_button.dart | 2 +- .../demo_transaction_list_tile.dart | 2 +- lib/widgets/home/privacy_toggler.dart | 2 +- lib/widgets/home/stats/group_list_tile.dart | 2 +- .../home/stats/most_spending_category.dart | 2 +- lib/widgets/home/stats/no_data.dart | 2 +- lib/widgets/icloud_failed_error_box.dart | 2 +- lib/widgets/image_drop_zone.dart | 2 +- lib/widgets/import/file_select_area.dart | 2 +- .../csv/account_currency_list_tile.dart | 2 +- .../import_wizard/csv/backup_info_csv.dart | 2 +- lib/widgets/import_wizard/import_success.dart | 2 +- .../import_wizard/ivy_wallet/backup_info.dart | 2 +- .../import_wizard/v1/backup_info_v1.dart | 2 +- .../import_wizard/v2/backup_info_v2.dart | 2 +- .../eny_page/eny_privacy_notice.dart | 2 +- .../auto_backup_reminder.dart | 2 +- .../internal_notification_list_tile.dart | 2 +- .../star_on_github_notification.dart | 2 +- .../turn_on_icloud_sync_reminder.dart | 2 +- lib/widgets/location_picker_sheet.dart | 2 +- ...fications_permission_missing_reminder.dart | 2 +- lib/widgets/rates_missing_error_box.dart | 2 +- ...ification_permission_missing_reminder.dart | 2 +- ...select_bulk_transactions_action_sheet.dart | 2 +- .../select_color_scheme_list_tile.dart | 2 +- .../setup/accounts/account_preset_card.dart | 2 +- .../setup/accounts/add_account_card.dart | 2 +- .../categories/category_preset_card.dart | 2 +- lib/widgets/setup/foss_slide.dart | 4 +- lib/widgets/setup/privacy_slide.dart | 2 +- lib/widgets/sheets/select_account_sheet.dart | 2 +- lib/widgets/sheets/select_category_sheet.dart | 2 +- .../sheets/select_color_scheme_sheet.dart | 2 +- lib/widgets/sheets/select_contact_sheet.dart | 2 +- .../select_contact_sheet/no_contacts.dart | 2 +- .../sheets/select_currency_icu_pattern.dart | 2 +- lib/widgets/sheets/select_currency_sheet.dart | 2 +- .../sheets/select_file_attachment_sheet.dart | 2 +- .../sheets/select_flow_icon_sheet.dart | 2 +- .../select_char_flow_icon_sheet.dart | 2 +- .../select_icon_flow_icon_sheet.dart | 2 +- .../select_image_flow_icon_sheet.dart | 2 +- .../sheets/select_multi_currency_sheet.dart | 2 +- .../select_multi_transaction_type_sheet.dart | 2 +- .../sheets/select_time_range_mode_sheet.dart | 2 +- .../sheets/select_transaction_tags_sheet.dart | 2 +- .../sheets/select_transaction_type_sheet.dart | 2 +- lib/widgets/time_range_selector.dart | 2 +- lib/widgets/transaction/type_selector.dart | 2 +- .../create_filter_preset_sheet.dart | 2 +- .../select_filter_preset_sheet.dart | 2 +- .../default_filter_preset_list_tile.dart | 2 +- .../filter_preset_list_tile.dart | 2 +- .../select_group_range_sheet.dart | 2 +- .../select_has_attachment_sheet.dart | 2 +- .../select_is_pending_sheet.dart | 2 +- .../select_multi_account_sheet.dart | 2 +- .../select_multi_category_sheet.dart | 2 +- .../transaction_search_sheet.dart | 2 +- lib/widgets/transaction_list_tile.dart | 2 +- lib/widgets/transaction_tag_add_chip.dart | 2 +- lib/widgets/transaction_tag_chip.dart | 4 +- lib/widgets/transactions_selection_bar.dart | 2 +- lib/widgets/trend.dart | 2 +- lib/widgets/year_selector_bar.dart | 2 +- linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 342 +++++----- pubspec.yaml | 6 +- test/backup/v1_populate.dart | 2 +- windows/flutter/generated_plugins.cmake | 1 + 185 files changed, 3876 insertions(+), 395 deletions(-) create mode 100644 .fvmrc create mode 100644 lib/routes/debug/analytics/debug_cash_flow_page.dart create mode 100644 lib/routes/debug/analytics/debug_net_worth_page.dart create mode 100644 lib/routes/debug/analytics/debug_recurring_page.dart create mode 100644 lib/routes/debug/analytics/debug_spending_calendar_page.dart create mode 100644 lib/routes/debug/analytics/debug_spending_map_page.dart create mode 100644 lib/routes/debug/analytics/debug_wrapped_page.dart create mode 100644 lib/widgets/debug/analytics/bullet_chart.dart create mode 100644 lib/widgets/debug/analytics/insight_card.dart create mode 100644 lib/widgets/debug/analytics/sankey_diagram.dart create mode 100644 lib/widgets/debug/analytics/spending_heatmap.dart create mode 100644 lib/widgets/debug/analytics/weekday_bars.dart diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..c300356c --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "stable" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2200e7c3..f264ca09 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,7 @@ app.*.map.json /download lib/libobjectbox.dylib -lib/libobjectbox.so \ No newline at end of file +lib/libobjectbox.so + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 1eee6b6c..b7dab120 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,8 @@ { - "cmake.configureOnOpen": false, - "IDX.aI.enableInlineCompletion": true, - "IDX.aI.enableCodebaseIndexing": true, - "editor.tabSize": 2, - "swift.swiftSDK": "arm64-apple-ios" -} + "cmake.configureOnOpen": false, + "IDX.aI.enableInlineCompletion": true, + "IDX.aI.enableCodebaseIndexing": true, + "editor.tabSize": 2, + "swift.swiftSDK": "arm64-apple-ios", + "dart.flutterSdkPath": ".fvm/versions/stable" +} \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cc31d222..bd1b76c0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,14 +6,12 @@ PODS: - Flutter - icloud_storage (0.0.1): - Flutter - - ObjectBox (5.2.0) + - ObjectBox (5.3.0) - objectbox_flutter_libs (0.0.1): - Flutter - - ObjectBox (= 5.2.0) + - ObjectBox (= 5.3.0) - open_app_file (3.2.2): - Flutter - - permission_handler_apple (9.3.0): - - Flutter DEPENDENCIES: - file_saver (from `.symlinks/plugins/file_saver/ios`) @@ -22,7 +20,6 @@ DEPENDENCIES: - icloud_storage (from `.symlinks/plugins/icloud_storage/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - open_app_file (from `.symlinks/plugins/open_app_file/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) SPEC REPOS: trunk: @@ -41,18 +38,15 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/objectbox_flutter_libs/ios" open_app_file: :path: ".symlinks/plugins/open_app_file/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" SPEC CHECKSUMS: file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_dynamic_icon_plus: a7c81df360708aa1c9538f89cd96958fdda608ea icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2 - ObjectBox: 946c1e8586aaa61e21b6661bab4948d06fdd51c4 - objectbox_flutter_libs: 4d609454f39d002fc850b3019e180fc4760cdf36 + ObjectBox: 2aae8ad350012cb53c6e7848064307cf68617f73 + objectbox_flutter_libs: 42b57158b8c4869b731487f220e07d6edc6616e0 open_app_file: ba67d2bf6cdddfb654b13b713d66bea4974a5adb - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PODFILE CHECKSUM: 1ac52b241a59f6195eb0dcd62320a80726757816 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f3ea6235..d6d72121 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -316,7 +316,6 @@ 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 0D52986EDC5668018869DEC5 /* [CP] Embed Pods Frameworks */, - 99C9619A788D79EF200F7C8F /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -482,23 +481,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - 99C9619A788D79EF200F7C8F /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; B1B71E527DF91869E2F79A60 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/lib/data/actionable_nofications/actionable_notification.dart b/lib/data/actionable_nofications/actionable_notification.dart index 41dba87c..66862dd0 100644 --- a/lib/data/actionable_nofications/actionable_notification.dart +++ b/lib/data/actionable_nofications/actionable_notification.dart @@ -1,7 +1,7 @@ import "package:flow/data/flow_icon.dart"; import "package:flow/entity/backup_entry.dart"; -import "package:material_symbols_icons/symbols.dart"; -import "package:simple_icons/simple_icons.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; enum ActionableNotificationPriority { low(0), diff --git a/lib/data/flow_button_type.dart b/lib/data/flow_button_type.dart index 3e0fcec8..a5cc38ec 100644 --- a/lib/data/flow_button_type.dart +++ b/lib/data/flow_button_type.dart @@ -2,7 +2,7 @@ import "package:flow/l10n/named_enum.dart"; import "package:flow/theme/helpers.dart"; import "package:flutter/material.dart"; import "package:json_annotation/json_annotation.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; @JsonEnum(valueField: "value") enum FlowButtonType with LocalizedEnum { diff --git a/lib/data/flow_icon.dart b/lib/data/flow_icon.dart index ac2ff4da..52eec14e 100644 --- a/lib/data/flow_icon.dart +++ b/lib/data/flow_icon.dart @@ -84,8 +84,11 @@ class IconFlowIcon extends FlowIconData { return FlowIconData.icon( IconData( + // ignore: non_const_argument_for_const_parameter int.parse(codePointHex, radix: 16), + // ignore: non_const_argument_for_const_parameter fontFamily: fontFamily, + // ignore: non_const_argument_for_const_parameter fontPackage: fontPackage, ), ); diff --git a/lib/data/icons.dart b/lib/data/icons.dart index 9ce5ceb0..137a34ff 100644 --- a/lib/data/icons.dart +++ b/lib/data/icons.dart @@ -1,8 +1,8 @@ import "package:flutter/material.dart"; import "package:fuzzywuzzy/fuzzywuzzy.dart"; -import "package:material_symbols_icons/iconname_to_unicode_map.dart"; -import "package:material_symbols_icons/symbols.dart"; -import "package:simple_icons/simple_icons.dart"; +import "package:material_symbols_icons_flow/iconname_to_unicode_map.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; List querySimpleIcons(String query) { final String trimmed = query.trim(); @@ -18,8 +18,12 @@ List querySimpleIcons(String query) { return queryResults.map((key) => SimpleIcons.values[key]!).toList(); } -IconData _getMaterialSymbolsForCodepoint(int codepoint) => - IconDataRounded(codepoint); +IconData _getMaterialSymbolsForCodepoint(int codepoint) => IconData( + // ignore: non_const_argument_for_const_parameter + codepoint, + fontFamily: "MaterialSymbolsRounded", + fontPackage: "material_symbols_icons_flow", +); List queryMaterialSymbols(String query) { final String trimmed = query.trim(); @@ -72,7 +76,6 @@ const List fSimpleIcons = [ SimpleIcons.googleadmob, SimpleIcons.googleadsense, SimpleIcons.googleads, - SimpleIcons.amazonpay, SimpleIcons.samsungpay, ]; const List fMaterialSymbols = [ diff --git a/lib/data/setup/default_accounts.dart b/lib/data/setup/default_accounts.dart index 6ccb0b94..35c35f1a 100644 --- a/lib/data/setup/default_accounts.dart +++ b/lib/data/setup/default_accounts.dart @@ -1,7 +1,7 @@ import "package:flow/data/flow_icon.dart"; import "package:flow/entity/account.dart"; import "package:flow/l10n/extensions.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; List getAccountPresets(String currency) { return [ diff --git a/lib/data/setup/default_categories.dart b/lib/data/setup/default_categories.dart index 5bcd02f8..6c6a6ef5 100644 --- a/lib/data/setup/default_categories.dart +++ b/lib/data/setup/default_categories.dart @@ -1,7 +1,7 @@ import "package:flow/data/flow_icon.dart"; import "package:flow/entity/category.dart"; import "package:flow/l10n/extensions.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; List getCategoryPresets() { return [ diff --git a/lib/entity/account.dart b/lib/entity/account.dart index cbd36b2d..070f443a 100644 --- a/lib/entity/account.dart +++ b/lib/entity/account.dart @@ -8,7 +8,7 @@ import "package:flow/theme/color_themes/registry.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/utils/json/utc_datetime_converter.dart"; import "package:json_annotation/json_annotation.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:objectbox/objectbox.dart"; import "package:uuid/uuid.dart"; diff --git a/lib/entity/category.dart b/lib/entity/category.dart index c7207bce..c11524c0 100644 --- a/lib/entity/category.dart +++ b/lib/entity/category.dart @@ -5,7 +5,7 @@ import "package:flow/theme/color_themes/registry.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/utils/json/utc_datetime_converter.dart"; import "package:json_annotation/json_annotation.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:objectbox/objectbox.dart"; import "package:uuid/uuid.dart"; diff --git a/lib/entity/goal.dart b/lib/entity/goal.dart index 9e6f8c63..5229364d 100644 --- a/lib/entity/goal.dart +++ b/lib/entity/goal.dart @@ -4,7 +4,7 @@ import "package:flow/entity/account.dart"; import "package:flow/utils/json/time_range_converter.dart"; import "package:flow/utils/json/utc_datetime_converter.dart"; import "package:json_annotation/json_annotation.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:objectbox/objectbox.dart"; import "package:uuid/uuid.dart"; diff --git a/lib/main.dart b/lib/main.dart index e860b7be..d9c108d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -56,7 +56,7 @@ import "package:flutter_quill/flutter_quill.dart"; import "package:intl/intl.dart"; import "package:logging/logging.dart"; import "package:logging_appenders/logging_appenders.dart"; -import "package:material_symbols_icons/material_symbols_icons.dart"; +import "package:material_symbols_icons_flow/material_symbols_icons.dart"; import "package:moment_dart/moment_dart.dart"; import "package:package_info_plus/package_info_plus.dart"; import "package:path/path.dart" as path; diff --git a/lib/objectbox.dart b/lib/objectbox.dart index d0a34127..0e60775e 100644 --- a/lib/objectbox.dart +++ b/lib/objectbox.dart @@ -18,7 +18,7 @@ import "package:flow/entity/user_preferences.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/objectbox/objectbox.g.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:path/path.dart" as path; import "package:path_provider/path_provider.dart"; diff --git a/lib/routes.dart b/lib/routes.dart index 18d95a55..65188ccf 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -8,6 +8,12 @@ import "package:flow/routes/categories_page.dart"; import "package:flow/routes/category/category_edit_page.dart"; import "package:flow/routes/category_page.dart"; import "package:flow/routes/community/contributors_page.dart"; +import "package:flow/routes/debug/analytics/debug_cash_flow_page.dart"; +import "package:flow/routes/debug/analytics/debug_net_worth_page.dart"; +import "package:flow/routes/debug/analytics/debug_recurring_page.dart"; +import "package:flow/routes/debug/analytics/debug_spending_calendar_page.dart"; +import "package:flow/routes/debug/analytics/debug_spending_map_page.dart"; +import "package:flow/routes/debug/analytics/debug_wrapped_page.dart"; import "package:flow/routes/debug/debug_icloud_page.dart"; import "package:flow/routes/debug/debug_log_page.dart"; import "package:flow/routes/debug/debug_logs_page.dart"; @@ -489,6 +495,30 @@ final GoRouter router = GoRouter( path: "/_debug/theme", builder: (context, state) => DebugThemePage(), ), + GoRoute( + path: "/_debug/analytics/net-worth", + builder: (context, state) => const DebugNetWorthPage(), + ), + GoRoute( + path: "/_debug/analytics/wrapped", + builder: (context, state) => const DebugWrappedPage(), + ), + GoRoute( + path: "/_debug/analytics/recurring", + builder: (context, state) => const DebugRecurringPage(), + ), + GoRoute( + path: "/_debug/analytics/calendar", + builder: (context, state) => const DebugSpendingCalendarPage(), + ), + GoRoute( + path: "/_debug/analytics/cash-flow", + builder: (context, state) => const DebugCashFlowPage(), + ), + GoRoute( + path: "/_debug/analytics/map", + builder: (context, state) => const DebugSpendingMapPage(), + ), GoRoute( path: "/_debug/scheduledNotifications", builder: (context, state) => DebugScheduledNotificationsPage(), diff --git a/lib/routes/account/account_edit_page.dart b/lib/routes/account/account_edit_page.dart index 0c0dac30..abf60870 100644 --- a/lib/routes/account/account_edit_page.dart +++ b/lib/routes/account/account_edit_page.dart @@ -35,7 +35,7 @@ import "package:flow/widgets/sheets/select_currency_sheet.dart"; import "package:flow/widgets/sheets/select_flow_icon_sheet.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class AccountEditPage extends StatefulWidget { /// Account Object ID diff --git a/lib/routes/account_page.dart b/lib/routes/account_page.dart index d6bff117..57f531e8 100644 --- a/lib/routes/account_page.dart +++ b/lib/routes/account_page.dart @@ -31,7 +31,7 @@ import "package:flow/widgets/transactions_selection_controller.dart"; import "package:flow/widgets/transactions_selection_scope.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class AccountPage extends StatefulWidget { diff --git a/lib/routes/categories_page.dart b/lib/routes/categories_page.dart index bfd795aa..9c4a1d02 100644 --- a/lib/routes/categories_page.dart +++ b/lib/routes/categories_page.dart @@ -12,7 +12,7 @@ import "package:flow/widgets/general/button.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class CategoriesPage extends StatefulWidget { const CategoriesPage({super.key}); diff --git a/lib/routes/category/category_edit_page.dart b/lib/routes/category/category_edit_page.dart index 48fc8c88..1533f1bc 100644 --- a/lib/routes/category/category_edit_page.dart +++ b/lib/routes/category/category_edit_page.dart @@ -19,7 +19,7 @@ import "package:flow/widgets/select_color_scheme_list_tile.dart"; import "package:flow/widgets/sheets/select_flow_icon_sheet.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class CategoryEditPage extends StatefulWidget { final int categoryId; diff --git a/lib/routes/category_page.dart b/lib/routes/category_page.dart index 2ec08687..5168a694 100644 --- a/lib/routes/category_page.dart +++ b/lib/routes/category_page.dart @@ -31,7 +31,7 @@ import "package:flow/widgets/transactions_selection_controller.dart"; import "package:flow/widgets/transactions_selection_scope.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class CategoryPage extends StatefulWidget { diff --git a/lib/routes/community/contributors_page.dart b/lib/routes/community/contributors_page.dart index 21a3276c..c804291f 100644 --- a/lib/routes/community/contributors_page.dart +++ b/lib/routes/community/contributors_page.dart @@ -6,7 +6,7 @@ import "package:flow/theme/helpers.dart"; import "package:flow/widgets/community/contributors/contributor_card.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class ContributorsPage extends StatefulWidget { const ContributorsPage({super.key}); diff --git a/lib/routes/debug/analytics/debug_cash_flow_page.dart b/lib/routes/debug/analytics/debug_cash_flow_page.dart new file mode 100644 index 00000000..1b6325c5 --- /dev/null +++ b/lib/routes/debug/analytics/debug_cash_flow_page.dart @@ -0,0 +1,357 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/money.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/primary_colors.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/debug/analytics/sankey_diagram.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:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// [dev] Cash-flow Sankey. +/// +/// Income categories flow through a single total hub into spending categories +/// (plus a balancing "Saved" / "From reserves" node) for the current month. +/// Built from category-flow aggregation (`flowByCategories`) over existing +/// data. +class DebugCashFlowPage extends StatefulWidget { + const DebugCashFlowPage({super.key}); + + @override + State createState() => _DebugCashFlowPageState(); +} + +class _DebugCashFlowPageState extends State { + static const int _maxIncomeNodes = 4; + static const int _maxExpenseNodes = 6; + + bool busy = false; + bool missingRates = false; + + late String primaryCurrency; + ExchangeRates? rates; + + List sources = []; + List targets = []; + double totalIncome = 0.0; + double totalExpense = 0.0; + + @override + void initState() { + super.initState(); + + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + fetch(); + } + + @override + Widget build(BuildContext context) { + final String month = DateTime.now().toMoment().format("MMMM"); + final bool hasData = sources.isNotEmpty && targets.isNotEmpty; + final double net = totalIncome - totalExpense; + + return Scaffold( + appBar: AppBar( + title: Text("Cash flow · $month (dev)"), + elevation: 0.0, + scrolledUnderElevation: 1.0, + centerTitle: false, + shadowColor: context.colorScheme.onSurface.withAlpha(0x40), + backgroundColor: context.colorScheme.surface, + surfaceTintColor: kTransparent, + ), + body: SafeArea( + child: busy && sources.isEmpty + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16.0), + _Summary( + income: Money(totalIncome, primaryCurrency), + expense: Money(totalExpense, primaryCurrency), + net: Money(net, primaryCurrency), + ), + const SizedBox(height: 16.0), + if (hasData) ...[ + Frame( + child: SankeyDiagram( + sources: sources, + targets: targets, + ), + ), + const SizedBox(height: 24.0), + const ListHeader("Income"), + const SizedBox(height: 8.0), + _Legend(data: sources, currency: primaryCurrency), + const SizedBox(height: 16.0), + const ListHeader("Spending"), + const SizedBox(height: 8.0), + _Legend(data: targets, currency: primaryCurrency), + ] else + const Frame( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.0), + child: Center( + child: Text("No cash flow this month."), + ), + ), + ), + if (missingRates) ...[ + const SizedBox(height: 12.0), + Frame( + child: Text( + "Some non-primary currency amounts were skipped " + "(missing exchange rates).", + style: context.textTheme.bodySmall?.copyWith( + color: context.flowColors.expense, + ), + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + bool missing = false; + + try { + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + // Resolve theme colors before the await so we never read [context] + // across an async gap. + final Color otherColor = context.colorScheme.onSurface.withAlpha(0x66); + final Color incomeColor = context.flowColors.income; + final Color expenseColor = context.flowColors.expense; + + final analytics = await ObjectBox().flowByCategories( + range: TimeRange.thisMonth(), + ); + + final List incomeNodes = []; + final List expenseNodes = []; + double income = 0.0; + double expense = 0.0; + int colorIndex = 0; + + for (final entry in analytics.flow.entries) { + final flow = entry.value; + final single = flow.merge(primaryCurrency, rates); + missing = missing || single.hasMissingData; + + final String name = flow.associatedData?.name ?? "Uncategorized"; + final Color color = + flow.associatedData?.colorScheme?.primary ?? + accentColors[colorIndex++ % accentColors.length]; + + final double incomeAmount = single.totalIncome.amount; + final double expenseAmount = single.totalExpense.amount.abs(); + + if (incomeAmount > 0) { + incomeNodes.add( + SankeyDatum(label: name, value: incomeAmount, color: color), + ); + income += incomeAmount; + } + if (expenseAmount > 0) { + expenseNodes.add( + SankeyDatum(label: name, value: expenseAmount, color: color), + ); + expense += expenseAmount; + } + } + + final List nextSources = _bucket( + incomeNodes, + _maxIncomeNodes, + otherColor, + ); + final List nextTargets = _bucket( + expenseNodes, + _maxExpenseNodes, + otherColor, + ); + + // Balance the two sides so the hub is fully covered: surplus becomes a + // "Saved" target, a deficit becomes a "From reserves" source. + final double net = income - expense; + final double threshold = (income > expense ? income : expense) * 0.001; + if (net > threshold) { + nextTargets.add( + SankeyDatum(label: "Saved", value: net, color: incomeColor), + ); + } else if (net < -threshold) { + nextSources.add( + SankeyDatum(label: "From reserves", value: -net, color: expenseColor), + ); + } + + sources = nextSources; + targets = nextTargets; + totalIncome = income; + totalExpense = expense; + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + /// Keeps the top [max] nodes by value and rolls the rest into "Other". + List _bucket( + List nodes, + int max, + Color otherColor, + ) { + final List sorted = [...nodes] + ..sort((a, b) => b.value.compareTo(a.value)); + + if (sorted.length <= max) return sorted; + + final List top = sorted.take(max - 1).toList(); + final double otherSum = sorted + .skip(max - 1) + .fold(0.0, (sum, node) => sum + node.value); + + return [ + ...top, + SankeyDatum(label: "Other", value: otherSum, color: otherColor), + ]; + } +} + +class _Summary extends StatelessWidget { + final Money income; + final Money expense; + final Money net; + + const _Summary({ + required this.income, + required this.expense, + required this.net, + }); + + @override + Widget build(BuildContext context) { + final bool saved = net.amount >= 0; + + return Frame( + child: Wrap( + spacing: 20.0, + runSpacing: 8.0, + children: [ + _SummaryItem( + label: "In", + money: income, + color: context.flowColors.income, + ), + _SummaryItem( + label: "Out", + money: expense, + color: context.flowColors.expense, + ), + _SummaryItem( + label: saved ? "Saved" : "Overspent", + money: saved ? net : -net, + color: saved + ? context.flowColors.income + : context.flowColors.expense, + ), + ], + ), + ); + } +} + +class _SummaryItem extends StatelessWidget { + final String label; + final Money money; + final Color color; + + const _SummaryItem({ + required this.label, + required this.money, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: context.textTheme.labelMedium?.semi(context)), + MoneyText( + money, + style: context.textTheme.titleMedium?.copyWith(color: color), + ), + ], + ); + } +} + +class _Legend extends StatelessWidget { + final List data; + final String currency; + + const _Legend({required this.data, required this.currency}); + + @override + Widget build(BuildContext context) { + return Frame( + child: Column( + children: data.map((datum) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + Container( + width: 12.0, + height: 12.0, + decoration: BoxDecoration( + color: datum.color, + borderRadius: const BorderRadius.all(Radius.circular(3.0)), + ), + ), + const SizedBox(width: 10.0), + Expanded( + child: Text( + datum.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 8.0), + MoneyText( + Money(datum.value, currency), + style: context.textTheme.bodyMedium?.semi(context), + ), + ], + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/routes/debug/analytics/debug_net_worth_page.dart b/lib/routes/debug/analytics/debug_net_worth_page.dart new file mode 100644 index 00000000..e3f3b152 --- /dev/null +++ b/lib/routes/debug/analytics/debug_net_worth_page.dart @@ -0,0 +1,552 @@ +import "dart:math" as math; + +import "package:fl_chart/fl_chart.dart"; +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.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:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// [dev] Net worth over time. +/// +/// Samples [Account.balanceAt] at the end of each month for the selected +/// window, converting non-primary currency balances into the primary +/// currency. Below the trend, balances are grouped into net-worth buckets +/// (cash / savings / investments / debt). +/// +/// Buildable entirely from existing data: account types + [Account.balanceAt]. +class DebugNetWorthPage extends StatefulWidget { + const DebugNetWorthPage({super.key}); + + @override + State createState() => _DebugNetWorthPageState(); +} + +enum _Period { + m3("3M", 3), + m6("6M", 6), + y1("1Y", 12), + all("All", null); + + final String label; + + /// Number of months to look back, or `null` for "all". + final int? months; + + const _Period(this.label, this.months); +} + +enum _NetWorthBucket { + cash("Cash", Symbols.account_balance_wallet_rounded), + savings("Savings", Symbols.savings_rounded), + investments("Investments", Symbols.trending_up_rounded), + debt("Debt", Symbols.credit_card_rounded), + other("Other", Symbols.account_balance_rounded); + + final String label; + final IconData icon; + + const _NetWorthBucket(this.label, this.icon); + + static _NetWorthBucket of(AccountType type) => switch (type) { + AccountType.debit => _NetWorthBucket.cash, + AccountType.savings => _NetWorthBucket.savings, + AccountType.asset => _NetWorthBucket.investments, + AccountType.creditLine || AccountType.loan => _NetWorthBucket.debt, + AccountType.other => _NetWorthBucket.other, + }; +} + +class _NetWorthSample { + final DateTime anchor; + final double amount; + + const _NetWorthSample(this.anchor, this.amount); +} + +class _DebugNetWorthPageState extends State { + _Period period = _Period.y1; + + bool busy = false; + + /// Whether any non-primary currency balance couldn't be converted. + bool missingRates = false; + + List accounts = []; + List<_NetWorthSample> samples = []; + Map<_NetWorthBucket, double> breakdown = {}; + + late String primaryCurrency; + ExchangeRates? rates; + + @override + void initState() { + super.initState(); + + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + fetch(); + } + + @override + Widget build(BuildContext context) { + final bool hasData = samples.length >= 2; + + final Money current = Money( + samples.isEmpty ? 0.0 : samples.last.amount, + primaryCurrency, + ); + final Money first = Money( + samples.isEmpty ? 0.0 : samples.first.amount, + primaryCurrency, + ); + final Money delta = current - first; + + return Scaffold( + appBar: AppBar( + title: const Text("Net worth (dev)"), + elevation: 0.0, + scrolledUnderElevation: 1.0, + centerTitle: false, + shadowColor: context.colorScheme.onSurface.withAlpha(0x40), + backgroundColor: context.colorScheme.surface, + surfaceTintColor: kTransparent, + ), + body: SafeArea( + child: busy && samples.isEmpty + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16.0), + Frame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Net worth", + style: context.textTheme.titleSmall?.semi(context), + ), + const SizedBox(height: 2.0), + MoneyText( + current, + style: context.textTheme.displaySmall, + autoSize: true, + tapToToggleAbbreviation: true, + ), + const SizedBox(height: 4.0), + _DeltaLabel(delta: delta, windowLabel: period.label), + ], + ), + ), + const SizedBox(height: 16.0), + Frame( + child: Wrap( + spacing: 8.0, + children: _Period.values + .map( + (p) => FilterChip( + label: Text(p.label), + selected: p == period, + onSelected: busy ? null : (_) => _setPeriod(p), + ), + ) + .toList(), + ), + ), + const SizedBox(height: 16.0), + if (hasData) + Frame( + child: SizedBox( + height: 220.0, + child: _NetWorthChart( + samples: samples, + primaryCurrency: primaryCurrency, + ), + ), + ) + else + const Frame( + child: SizedBox( + height: 120.0, + child: Center( + child: Text("Not enough history to draw a trend."), + ), + ), + ), + if (missingRates) ...[ + const SizedBox(height: 8.0), + Frame( + child: Text( + "Some non-primary currency balances were skipped " + "(missing exchange rates).", + style: context.textTheme.bodySmall?.copyWith( + color: context.flowColors.expense, + ), + ), + ), + ], + const SizedBox(height: 32.0), + const ListHeader("Composition"), + const SizedBox(height: 8.0), + ..._buildBreakdownRows(context), + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + List _buildBreakdownRows(BuildContext context) { + if (breakdown.isEmpty) { + return [const Frame(child: Text("No accounts to summarize."))]; + } + + // Stable, meaningful order rather than insertion order. + final List<_NetWorthBucket> order = _NetWorthBucket.values + .where(breakdown.containsKey) + .toList(); + + return order.map((bucket) { + final double amount = breakdown[bucket] ?? 0.0; + final bool isDebt = bucket == _NetWorthBucket.debt; + final Color color = isDebt + ? context.flowColors.expense + : context.colorScheme.primary; + + return ListTile( + leading: FlowIcon( + FlowIconData.icon(bucket.icon), + plated: true, + color: color, + ), + title: Text(bucket.label), + trailing: MoneyText( + Money(amount, primaryCurrency), + style: context.textTheme.titleSmall?.copyWith( + color: isDebt ? context.flowColors.expense : null, + ), + ), + ); + }).toList(); + } + + void _setPeriod(_Period value) { + if (value == period) return; + period = value; + fetch(); + } + + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + try { + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + accounts = ObjectBox() + .getAccounts(false) + .where((account) => account.excludeFromTotalBalance != true) + .toList(); + + final int months = period.months ?? _allMonths(accounts); + final List anchors = _monthAnchors(months); + + bool missing = false; + + final List<_NetWorthSample> nextSamples = anchors.map((anchor) { + double total = 0.0; + for (final Account account in accounts) { + final ({double value, bool missing}) result = _convertedBalance( + account.balanceAt(anchor), + ); + total += result.value; + missing = missing || result.missing; + } + return _NetWorthSample(anchor, total); + }).toList(); + + final Map<_NetWorthBucket, double> nextBreakdown = {}; + for (final Account account in accounts) { + final ({double value, bool missing}) result = _convertedBalance( + account.balance, + ); + missing = missing || result.missing; + + final _NetWorthBucket bucket = _NetWorthBucket.of(account.accountType); + nextBreakdown[bucket] = (nextBreakdown[bucket] ?? 0.0) + result.value; + } + + samples = nextSamples; + breakdown = nextBreakdown; + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + /// Converts [money] into the primary currency, flagging when conversion is + /// impossible (missing rates) so the UI can warn instead of lying. + ({double value, bool missing}) _convertedBalance(Money money) { + if (money.currency == primaryCurrency) { + return (value: money.amount, missing: false); + } + + final ExchangeRates? rates = this.rates; + if (rates == null) { + return (value: 0.0, missing: true); + } + + try { + return ( + value: money.convert(primaryCurrency, rates).amount, + missing: false, + ); + } catch (_) { + return (value: 0.0, missing: true); + } + } + + /// End-of-month anchors for the trailing [months] months, with the most + /// recent point anchored to "now" so the latest figure is live. + List _monthAnchors(int months) { + final DateTime now = DateTime.now(); + final List anchors = []; + + for (int i = months - 1; i >= 0; i--) { + if (i == 0) { + anchors.add(now); + } else { + // Day 0 of (month - i + 1) == last day of (month - i). + anchors.add(DateTime(now.year, now.month - i + 1, 0, 23, 59, 59)); + } + } + + return anchors; + } + + int _allMonths(List accounts) { + if (accounts.isEmpty) return 12; + + final DateTime earliest = accounts + .map((a) => a.createdDate) + .reduce((a, b) => a.isBefore(b) ? a : b); + final DateTime now = DateTime.now(); + final int months = + (now.year - earliest.year) * 12 + (now.month - earliest.month) + 1; + + return months.clamp(3, 60); + } +} + +class _DeltaLabel extends StatelessWidget { + final Money delta; + final String windowLabel; + + const _DeltaLabel({required this.delta, required this.windowLabel}); + + @override + Widget build(BuildContext context) { + final bool up = delta.amount >= 0; + final Color color = up + ? context.flowColors.income + : context.flowColors.expense; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + up ? Symbols.trending_up_rounded : Symbols.trending_down_rounded, + color: color, + size: 18.0, + ), + const SizedBox(width: 4.0), + MoneyText( + delta, + displayAbsoluteAmount: true, + style: context.textTheme.bodyMedium?.copyWith(color: color), + ), + const SizedBox(width: 6.0), + Text( + "in $windowLabel", + style: context.textTheme.bodyMedium?.semi(context), + ), + ], + ); + } +} + +class _NetWorthChart extends StatelessWidget { + final List<_NetWorthSample> samples; + final String primaryCurrency; + + const _NetWorthChart({required this.samples, required this.primaryCurrency}); + + @override + Widget build(BuildContext context) { + final Color line = context.colorScheme.primary; + + final double maxY = samples + .map((s) => s.amount) + .reduce((a, b) => a > b ? a : b); + final double minY = samples + .map((s) => s.amount) + .reduce((a, b) => a < b ? a : b); + + // Frame the chart to the actual data range (plus a little padding) so a + // net worth that stays positive still shows its variation instead of + // being flattened against a 0 baseline. The 0 line is drawn separately + // only when the range actually crosses zero. + final double span = (maxY - minY).abs(); + final double pad = span == 0 ? maxY.abs() * 0.1 + 1 : span * 0.12; + final double resolvedMinY = minY - pad; + final double resolvedMaxY = maxY + pad; + + return LineChart( + LineChartData( + minX: 0.0, + maxX: (samples.length - 1).toDouble(), + minY: resolvedMinY, + maxY: resolvedMaxY, + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => context.colorScheme.onPrimary, + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + final _NetWorthSample sample = samples[spot.x.toInt()]; + return LineTooltipItem( + "${sample.anchor.toMoment().format("MMM yyyy")}\n" + "${Money(sample.amount, primaryCurrency).formattedCompact}", + TextStyle( + color: line, + fontWeight: FontWeight.bold, + fontSize: 13.0, + ), + ); + }).toList(); + }, + ), + ), + titlesData: FlTitlesData( + bottomTitles: AxisTitles(sideTitles: _bottomTitles()), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 48.0, + getTitlesWidget: (value, meta) { + if (value != meta.min && value != meta.max) { + return const SizedBox.shrink(); + } + return MoneyText( + Money(value, primaryCurrency), + initiallyAbbreviated: true, + autoSize: true, + style: context.textTheme.labelSmall, + ); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData(show: true, drawVerticalLine: false), + extraLinesData: ExtraLinesData( + horizontalLines: [ + if (resolvedMinY < 0) + HorizontalLine( + y: 0.0, + color: context.colorScheme.onSurface.withAlpha(0x30), + strokeWidth: 1.0, + ), + ], + ), + 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, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + barWidth: 2.5, + color: line, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + spots: samples + .asMap() + .entries + .map((e) => FlSpot(e.key.toDouble(), e.value.amount)) + .toList(), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [line.withAlpha(0x40), line.withAlpha(0x00)], + ), + ), + ), + ], + ), + ); + } + + SideTitles _bottomTitles() { + // Aim for ~4 labels regardless of window length. + final int step = math.max(1, (samples.length / 4).floor()); + + return SideTitles( + showTitles: true, + interval: 1.0, + reservedSize: 28.0, + getTitlesWidget: (value, meta) { + final int index = value.round(); + if (index < 0 || index >= samples.length) { + return const SizedBox.shrink(); + } + if (index % step != 0 && index != samples.length - 1) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Text( + samples[index].anchor.toMoment().format("MMM"), + style: const TextStyle(fontSize: 11.0), + ), + ); + }, + ); + } +} diff --git a/lib/routes/debug/analytics/debug_recurring_page.dart b/lib/routes/debug/analytics/debug_recurring_page.dart new file mode 100644 index 00000000..7e631b58 --- /dev/null +++ b/lib/routes/debug/analytics/debug_recurring_page.dart @@ -0,0 +1,395 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/services/accounts.dart"; +import "package:flow/services/categories.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/flow_icon.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:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// [dev] Subscriptions & recurring radar. +/// +/// Projects every active [RecurringTransaction] forward over the next 30 days +/// using its [Recurrence] rules, then lists the upcoming charges and sums the +/// committed outflow. Built entirely from data Flow already stores. +class DebugRecurringPage extends StatefulWidget { + const DebugRecurringPage({super.key}); + + @override + State createState() => _DebugRecurringPageState(); +} + +/// One projected occurrence of a recurring transaction within the window. +class _Upcoming { + final DateTime date; + final Transaction template; + + /// The template's amount, pre-validated so the tile never touches the + /// throwing [Transaction.money] getter. + final Money money; + final Category? category; + final Account? account; + + const _Upcoming({ + required this.date, + required this.template, + required this.money, + this.category, + this.account, + }); +} + +class _DebugRecurringPageState extends State { + static const int _windowDays = 30; + static const int _maxRows = 60; + + bool busy = false; + bool missingRates = false; + + late String primaryCurrency; + ExchangeRates? rates; + + List<_Upcoming> upcoming = []; + int activeCount = 0; + double outflow = 0.0; + + @override + void initState() { + super.initState(); + + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + fetch(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Recurring (dev)"), + elevation: 0.0, + scrolledUnderElevation: 1.0, + centerTitle: false, + shadowColor: context.colorScheme.onSurface.withAlpha(0x40), + backgroundColor: context.colorScheme.surface, + surfaceTintColor: kTransparent, + ), + body: SafeArea( + child: busy && upcoming.isEmpty + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16.0), + _Header( + outflow: Money(outflow, primaryCurrency), + activeCount: activeCount, + windowDays: _windowDays, + ), + const SizedBox(height: 16.0), + if (activeCount == 0) + const Frame( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.0), + child: Center( + child: Text("No recurring transactions set up."), + ), + ), + ) + else if (upcoming.isEmpty) + const Frame( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.0), + child: Center( + child: Text("Nothing due in the next 30 days."), + ), + ), + ) + else + ..._buildRows(context), + if (missingRates) ...[ + const SizedBox(height: 8.0), + Frame( + child: Text( + "Some non-primary currency amounts were skipped " + "(missing exchange rates).", + style: context.textTheme.bodySmall?.copyWith( + color: context.flowColors.expense, + ), + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + List _buildRows(BuildContext context) { + final List rows = upcoming + .take(_maxRows) + .map( + (item) => _UpcomingTile(item: item, primaryCurrency: primaryCurrency), + ) + .toList(); + + if (upcoming.length > _maxRows) { + rows.add( + Frame( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Text( + "+ ${upcoming.length - _maxRows} more not shown", + style: context.textTheme.bodySmall?.semi(context), + ), + ), + ), + ); + } + + return rows; + } + + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + bool missing = false; + + try { + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + final DateTime now = DateTime.now(); + final TimeRange window = CustomTimeRange( + now, + now.add(const Duration(days: _windowDays)), + ); + + final query = RecurringTransactionsService().activeRecurringsQb().build(); + final List recurrings = query.find(); + query.close(); + + final List<_Upcoming> result = []; + double totalOutflow = 0.0; + int active = 0; + + for (final RecurringTransaction recurring in recurrings) { + final Transaction? template = _decodeTemplate(recurring); + if (template == null) continue; + + // Money(...) throws on an unknown currency code; skip the recurring + // rather than letting one stale template break the whole page. + final Money? money = _templateMoney(template); + if (money == null) continue; + + active++; + + final Category? category = template.categoryUuid == null + ? null + : CategoriesService().findOneSync(template.categoryUuid); + final Account? account = template.accountUuid == null + ? null + : AccountsService().findOneSync(template.accountUuid); + + final List occurrences = recurring.recurrence.occurrences( + subrange: window, + ); + + for (final DateTime date in occurrences) { + result.add( + _Upcoming( + date: date, + template: template, + money: money, + category: category, + account: account, + ), + ); + + if (template.type == TransactionType.expense) { + final double? converted = _convert(money, primaryCurrency); + if (converted == null) { + missing = true; + } else { + totalOutflow += converted.abs(); + } + } + } + } + + result.sort((a, b) => a.date.compareTo(b.date)); + + upcoming = result; + activeCount = active; + outflow = totalOutflow; + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + Transaction? _decodeTemplate(RecurringTransaction recurring) { + try { + return recurring.template; + } catch (_) { + // A malformed template shouldn't take down the whole list. + return null; + } + } + + Money? _templateMoney(Transaction template) { + try { + return template.money; + } catch (_) { + return null; + } + } + + double? _convert(Money money, String currency) { + if (money.currency == currency) return money.amount; + + final ExchangeRates? rates = this.rates; + if (rates == null) return null; + + try { + return money.convert(currency, rates).amount; + } catch (_) { + return null; + } + } +} + +class _Header extends StatelessWidget { + final Money outflow; + final int activeCount; + final int windowDays; + + const _Header({ + required this.outflow, + required this.activeCount, + required this.windowDays, + }); + + @override + Widget build(BuildContext context) { + return Frame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Committed outflow", + style: context.textTheme.titleSmall?.semi(context), + ), + const SizedBox(height: 2.0), + MoneyText( + outflow, + style: context.textTheme.displaySmall, + autoSize: true, + tapToToggleAbbreviation: true, + ), + const SizedBox(height: 4.0), + Text( + "$activeCount recurring · next $windowDays days", + style: context.textTheme.bodyMedium?.semi(context), + ), + ], + ), + ); + } +} + +class _UpcomingTile extends StatelessWidget { + final _Upcoming item; + final String primaryCurrency; + + const _UpcomingTile({required this.item, required this.primaryCurrency}); + + @override + Widget build(BuildContext context) { + final Transaction template = item.template; + final Color typeColor = template.type.color(context); + + final FlowIconData iconData = + item.category?.icon ?? + item.account?.icon ?? + FlowIconData.icon(Symbols.autorenew_rounded); + + final String title = + template.title ?? item.account?.name ?? "Recurring transaction"; + final String subtitle = + "${item.date.toMoment().fromNow()} · " + "${item.category?.name ?? item.account?.name ?? "Recurring"}"; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: Row( + children: [ + SizedBox( + width: 40.0, + child: Column( + children: [ + Text( + item.date.toMoment().format("D"), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + item.date.toMoment().format("MMM").toUpperCase(), + style: context.textTheme.labelSmall?.semi(context), + ), + ], + ), + ), + const SizedBox(width: 12.0), + FlowIcon(iconData, plated: true, color: typeColor), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyLarge, + ), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.semi(context), + ), + ], + ), + ), + const SizedBox(width: 8.0), + MoneyText( + item.money, + style: context.textTheme.titleSmall?.copyWith(color: typeColor), + ), + ], + ), + ); + } +} diff --git a/lib/routes/debug/analytics/debug_spending_calendar_page.dart b/lib/routes/debug/analytics/debug_spending_calendar_page.dart new file mode 100644 index 00000000..b032fd48 --- /dev/null +++ b/lib/routes/debug/analytics/debug_spending_calendar_page.dart @@ -0,0 +1,275 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/debug/analytics/insight_card.dart"; +import "package:flow/widgets/debug/analytics/spending_heatmap.dart"; +import "package:flow/widgets/debug/analytics/weekday_bars.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:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// [dev] Spending calendar — a heatmap of daily spend intensity. +/// +/// Bins expenses by [Transaction.transactionDate] and renders a GitHub-style +/// grid, plus a weekday breakdown. The weekday rhythm is computed here because +/// `TrendsReport.expenseByWeekday` is never populated upstream. +class DebugSpendingCalendarPage extends StatefulWidget { + const DebugSpendingCalendarPage({super.key}); + + @override + State createState() => + _DebugSpendingCalendarPageState(); +} + +enum _Period { + m3("3M", 13), + m6("6M", 26), + y1("1Y", 53); + + final String label; + final int weeks; + + const _Period(this.label, this.weeks); +} + +class _DebugSpendingCalendarPageState extends State { + _Period period = _Period.m6; + + bool busy = false; + bool missingRates = false; + + late String primaryCurrency; + ExchangeRates? rates; + + Map dailyExpense = {}; + Map weekdayExpense = {}; + double total = 0.0; + DateTime from = DateTime.now(); + DateTime to = DateTime.now(); + + @override + void initState() { + super.initState(); + + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + fetch(); + } + + @override + Widget build(BuildContext context) { + final bool hasData = dailyExpense.isNotEmpty; + + return Scaffold( + appBar: AppBar( + title: const Text("Spending calendar (dev)"), + elevation: 0.0, + scrolledUnderElevation: 1.0, + centerTitle: false, + shadowColor: context.colorScheme.onSurface.withAlpha(0x40), + backgroundColor: context.colorScheme.surface, + surfaceTintColor: kTransparent, + ), + body: SafeArea( + child: busy && dailyExpense.isEmpty + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16.0), + Frame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Spent in ${period.label}", + style: context.textTheme.titleSmall?.semi(context), + ), + const SizedBox(height: 2.0), + MoneyText( + Money(total, primaryCurrency), + style: context.textTheme.displaySmall, + autoSize: true, + tapToToggleAbbreviation: true, + ), + ], + ), + ), + const SizedBox(height: 16.0), + Frame( + child: Wrap( + spacing: 8.0, + children: _Period.values + .map( + (p) => FilterChip( + label: Text(p.label), + selected: p == period, + onSelected: busy ? null : (_) => _setPeriod(p), + ), + ) + .toList(), + ), + ), + const SizedBox(height: 16.0), + if (hasData) + Frame( + child: SpendingHeatmap( + dailyExpense: dailyExpense, + from: from, + to: to, + currency: primaryCurrency, + ), + ) + else + const Frame( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.0), + child: Center( + child: Text("No spending in this window."), + ), + ), + ), + if (weekdayExpense.isNotEmpty) ...[ + const SizedBox(height: 16.0), + _buildWeekdayInsight(context), + ], + if (missingRates) ...[ + const SizedBox(height: 8.0), + Frame( + child: Text( + "Some non-primary currency amounts were skipped " + "(missing exchange rates).", + style: context.textTheme.bodySmall?.copyWith( + color: context.flowColors.expense, + ), + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + Widget _buildWeekdayInsight(BuildContext context) { + final int topWeekday = weekdayExpense.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + + return InsightCard( + icon: Symbols.calendar_month_rounded, + label: "Rhythm", + title: Text.rich( + TextSpan( + children: [ + const TextSpan(text: "Your priciest day is "), + TextSpan( + text: _weekdayName(topWeekday), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: "."), + ], + ), + ), + child: WeekdayBars( + byWeekday: weekdayExpense, + topWeekday: topWeekday, + accent: context.colorScheme.primary, + ), + ); + } + + void _setPeriod(_Period value) { + if (value == period) return; + period = value; + fetch(); + } + + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + bool missing = false; + + try { + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + final DateTime now = DateTime.now(); + to = now; + from = now.subtract(Duration(days: period.weeks * 7)); + + final List transactions = await ObjectBox() + .transcationsByRange( + CustomTimeRange(from, to), + includeTransfers: false, + ); + + final Map daily = {}; + final Map weekday = {}; + double sum = 0.0; + + for (final Transaction transaction in transactions) { + if (transaction.type != TransactionType.expense) continue; + + final double? converted = _convert(transaction.money, primaryCurrency); + if (converted == null) { + missing = true; + continue; + } + + final double magnitude = converted.abs(); + final DateTime day = DateTime( + transaction.transactionDate.year, + transaction.transactionDate.month, + transaction.transactionDate.day, + ); + + daily[day] = (daily[day] ?? 0.0) + magnitude; + weekday[transaction.transactionDate.weekday] = + (weekday[transaction.transactionDate.weekday] ?? 0.0) + magnitude; + sum += magnitude; + } + + dailyExpense = daily; + weekdayExpense = weekday; + total = sum; + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + double? _convert(Money money, String currency) { + if (money.currency == currency) return money.amount; + + final ExchangeRates? rates = this.rates; + if (rates == null) return null; + + try { + return money.convert(currency, rates).amount; + } catch (_) { + return null; + } + } + + String _weekdayName(int weekday) { + // 1 == Monday .. 7 == Sunday (DateTime.weekday). + return DateTime(2024, 1, weekday).toMoment().format("dddd"); + } +} diff --git a/lib/routes/debug/analytics/debug_spending_map_page.dart b/lib/routes/debug/analytics/debug_spending_map_page.dart new file mode 100644 index 00000000..554a8608 --- /dev/null +++ b/lib/routes/debug/analytics/debug_spending_map_page.dart @@ -0,0 +1,427 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/utils.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:flutter/material.dart"; +import "package:flutter_map/flutter_map.dart"; +import "package:latlong2/latlong.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// [dev] Spending map. +/// +/// Clusters geo-bearing expenses (from `Transaction.location` / the geo +/// extension) into ~100 m places, sizes a marker by total spend, and ranks +/// the places. Reads location data already stored on-device. +class DebugSpendingMapPage extends StatefulWidget { + const DebugSpendingMapPage({super.key}); + + @override + State createState() => _DebugSpendingMapPageState(); +} + +enum _Period { + m1("1M", 30), + m3("3M", 90), + y1("1Y", 365); + + final String label; + final int days; + + const _Period(this.label, this.days); +} + +class _Place { + final LatLng center; + final double total; + final int count; + final String name; + + const _Place({ + required this.center, + required this.total, + required this.count, + required this.name, + }); +} + +class _PlaceAccumulator { + double sumLat = 0.0; + double sumLng = 0.0; + double total = 0.0; + int count = 0; + final Map titleFrequency = {}; + + void add(LatLng point, double amount, String? title) { + sumLat += point.latitude; + sumLng += point.longitude; + total += amount; + count++; + + final String? key = title?.trim(); + if (key != null && key.isNotEmpty) { + titleFrequency[key] = (titleFrequency[key] ?? 0) + 1; + } + } + + String get topTitle { + if (titleFrequency.isEmpty) return "Pinned location"; + return titleFrequency.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + } + + _Place toPlace() => _Place( + center: LatLng(sumLat / count, sumLng / count), + total: total, + count: count, + name: topTitle, + ); +} + +class _DebugSpendingMapPageState extends State { + /// Caps how many place markers are drawn so a dense window stays smooth; + /// places are sorted by spend, so the most significant ones win. + static const int _maxMarkers = 150; + + _Period period = _Period.m3; + + bool busy = false; + bool missingRates = false; + + late String primaryCurrency; + ExchangeRates? rates; + + List<_Place> places = []; + double mappedTotal = 0.0; + int locatedCount = 0; + int totalExpenseCount = 0; + + @override + void initState() { + super.initState(); + + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + fetch(); + } + + @override + Widget build(BuildContext context) { + final bool hasData = places.isNotEmpty; + + return Scaffold( + appBar: AppBar( + title: const Text("Spending map (dev)"), + elevation: 0.0, + scrolledUnderElevation: 1.0, + centerTitle: false, + shadowColor: context.colorScheme.onSurface.withAlpha(0x40), + backgroundColor: context.colorScheme.surface, + surfaceTintColor: kTransparent, + ), + body: SafeArea( + child: busy && places.isEmpty + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16.0), + Frame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Mapped spend", + style: context.textTheme.titleSmall?.semi(context), + ), + const SizedBox(height: 2.0), + MoneyText( + Money(mappedTotal, primaryCurrency), + style: context.textTheme.displaySmall, + autoSize: true, + tapToToggleAbbreviation: true, + ), + const SizedBox(height: 4.0), + Text( + "$locatedCount of $totalExpenseCount expenses have " + "a location", + style: context.textTheme.bodyMedium?.semi(context), + ), + ], + ), + ), + const SizedBox(height: 16.0), + Frame( + child: Wrap( + spacing: 8.0, + children: _Period.values + .map( + (p) => FilterChip( + label: Text(p.label), + selected: p == period, + onSelected: busy ? null : (_) => _setPeriod(p), + ), + ) + .toList(), + ), + ), + const SizedBox(height: 16.0), + if (hasData) ...[ + Frame(child: _buildMap(context)), + const SizedBox(height: 24.0), + const ListHeader("Top places"), + const SizedBox(height: 8.0), + ..._buildPlaceRows(context), + ] else + const Frame( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.0), + child: Center( + child: Text("No located spending in this window."), + ), + ), + ), + if (missingRates) ...[ + const SizedBox(height: 8.0), + Frame( + child: Text( + "Some non-primary currency amounts were skipped " + "(missing exchange rates).", + style: context.textTheme.bodySmall?.copyWith( + color: context.flowColors.expense, + ), + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + Widget _buildMap(BuildContext context) { + // Places are sorted by spend, so the first is the maximum. + final double maxTotal = places.first.total; + final Color marker = context.colorScheme.primary; + + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + child: SizedBox( + height: 320.0, + child: FlutterMap( + options: MapOptions( + initialCenter: places.first.center, + initialZoom: 12.0, + ), + children: [ + TileLayer( + urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + fallbackUrl: + "http://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + userAgentPackageName: "mn.flow.flow", + ), + MarkerLayer( + markers: places.take(_maxMarkers).map((place) { + final double factor = maxTotal <= 0 + ? 0.0 + : place.total / maxTotal; + final double size = 16.0 + 34.0 * factor; + final String label = + "${place.name}: " + "${Money(place.total, primaryCurrency).formatted}"; + + return Marker( + point: place.center, + width: size, + height: size, + child: Tooltip( + message: label, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: marker.withAlpha(0x59), + border: Border.all(color: marker, width: 1.5), + ), + ), + ), + ); + }).toList(), + ), + RichAttributionWidget( + attributions: [ + TextSourceAttribution( + "OpenStreetMap contributors", + onTap: () => + openUrl(Uri.parse("https://openstreetmap.org/copyright")), + ), + ], + popupBackgroundColor: const Color(0xC0FFFFFF), + ), + ], + ), + ), + ); + } + + List _buildPlaceRows(BuildContext context) { + return places.take(12).toList().asMap().entries.map((entry) { + final int rank = entry.key + 1; + final _Place place = entry.value; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: Row( + children: [ + SizedBox( + width: 24.0, + child: Text( + "$rank", + style: context.textTheme.titleSmall?.semi(context), + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + place.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyLarge, + ), + Text( + "${place.count} ${place.count == 1 ? "visit" : "visits"}", + style: context.textTheme.bodySmall?.semi(context), + ), + ], + ), + ), + const SizedBox(width: 8.0), + MoneyText( + Money(place.total, primaryCurrency), + style: context.textTheme.titleSmall, + ), + ], + ), + ); + }).toList(); + } + + void _setPeriod(_Period value) { + if (value == period) return; + period = value; + fetch(); + } + + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + bool missing = false; + + try { + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + final DateTime now = DateTime.now(); + final TimeRange window = CustomTimeRange( + now.subtract(Duration(days: period.days)), + now, + ); + + final List transactions = await ObjectBox() + .transcationsByRange(window, includeTransfers: false); + + final Map clusters = {}; + double mapped = 0.0; + int located = 0; + int expenses = 0; + + for (final Transaction transaction in transactions) { + if (transaction.type != TransactionType.expense) continue; + expenses++; + + final LatLng? point = _latLngOf(transaction); + if (point == null) continue; + + final double? converted = _convert(transaction.money, primaryCurrency); + if (converted == null) { + missing = true; + continue; + } + + located++; + final double magnitude = converted.abs(); + mapped += magnitude; + + // ~100 m grid (3 decimal places) keeps repeat visits in one place. + final String key = + "${(point.latitude * 1000).round()}:" + "${(point.longitude * 1000).round()}"; + (clusters[key] ??= _PlaceAccumulator()).add( + point, + magnitude, + transaction.title, + ); + } + + final List<_Place> result = + clusters.values.map((accumulator) => accumulator.toPlace()).toList() + ..sort((a, b) => b.total.compareTo(a.total)); + + places = result; + mappedTotal = mapped; + locatedCount = located; + totalExpenseCount = expenses; + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + LatLng? _latLngOf(Transaction transaction) { + final List? location = transaction.location; + if (location != null && location.length == 2) { + final double lat = location[0]; + final double lng = location[1]; + if (lat.isFinite && lng.isFinite) return LatLng(lat, lng); + } + + final LatLng? geo = transaction.extensions.geo?.toLatLngPosition(); + if (geo != null && geo.latitude.isFinite && geo.longitude.isFinite) { + return geo; + } + + return null; + } + + double? _convert(Money money, String currency) { + if (money.currency == currency) return money.amount; + + final ExchangeRates? rates = this.rates; + if (rates == null) return null; + + try { + return money.convert(currency, rates).amount; + } catch (_) { + return null; + } + } +} diff --git a/lib/routes/debug/analytics/debug_wrapped_page.dart b/lib/routes/debug/analytics/debug_wrapped_page.dart new file mode 100644 index 00000000..84a6dde0 --- /dev/null +++ b/lib/routes/debug/analytics/debug_wrapped_page.dart @@ -0,0 +1,636 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/flow_analytics.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/budget.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/reports/trends_report.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/debug/analytics/bullet_chart.dart"; +import "package:flow/widgets/debug/analytics/insight_card.dart"; +import "package:flow/widgets/debug/analytics/weekday_bars.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:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// [dev] Monthly "wrapped" — narrative insight cards instead of raw charts. +/// +/// Activates two dormant pieces of Flow: [TrendsReport] (median spend, top +/// titles) and the unused [Budget] entity (rendered as a bullet chart). The +/// remaining cards are period-over-period category comparison and a +/// locally-computed weekday breakdown. +class DebugWrappedPage extends StatefulWidget { + const DebugWrappedPage({super.key}); + + @override + State createState() => _DebugWrappedPageState(); +} + +class _BudgetProgress { + final Budget budget; + final double actual; + + const _BudgetProgress(this.budget, this.actual); +} + +class _DebugWrappedPageState extends State { + bool busy = false; + bool missingRates = false; + + late String primaryCurrency; + ExchangeRates? rates; + + List thisMonthTransactions = []; + TrendsReport? trends; + + Category? topCategory; + double topCategoryCurrent = 0.0; + double topCategoryAverage = 0.0; + List topCategoryHistory = []; + + /// Weekday (1 = Mon .. 7 = Sun) -> summed expense, computed locally. + /// + /// [TrendsReport.expenseByWeekday] is declared but never populated, so its + /// `topSpendingWeekday` always returns null; we compute weekday spend here. + Map weekdayExpense = {}; + Transaction? biggestExpense; + double biggestExpenseConverted = 0.0; + + List<_BudgetProgress> budgets = []; + + @override + void initState() { + super.initState(); + + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + fetch(); + } + + @override + Widget build(BuildContext context) { + final String month = DateTime.now().toMoment().format("MMMM"); + + return Scaffold( + appBar: AppBar( + title: Text("$month, wrapped (dev)"), + elevation: 0.0, + scrolledUnderElevation: 1.0, + centerTitle: false, + shadowColor: context.colorScheme.onSurface.withAlpha(0x40), + backgroundColor: context.colorScheme.surface, + surfaceTintColor: kTransparent, + ), + body: SafeArea( + child: busy && trends == null + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8.0), + _DevToolbar( + canCreate: topCategory != null, + onCreate: _createSampleBudget, + onClear: _clearDevBudgets, + ), + // Budgets stand on their own range, so they show even when + // the current month has no transactions yet. + ..._buildBudgetCards(context), + if (thisMonthTransactions.isEmpty) + const Frame( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.0), + child: Center( + child: Text("No transactions yet this month."), + ), + ), + ) + else + ..._buildInsightCards(context), + if (missingRates) ...[ + const SizedBox(height: 8.0), + Frame( + child: Text( + "Some non-primary currency amounts were skipped " + "(missing exchange rates).", + style: context.textTheme.bodySmall?.copyWith( + color: context.flowColors.expense, + ), + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + List _buildInsightCards(BuildContext context) { + return [ + if (topCategory != null || topCategoryCurrent > 0) + _buildCategoryTrendCard(context), + if (trends?.sortedTitlesByFrequency.isNotEmpty == true) + _buildTopMerchantCard(context), + if (weekdayExpense.isNotEmpty) _buildWeekdayCard(context), + _buildSpendShapeCard(context), + ]; + } + + List _buildBudgetCards(BuildContext context) { + if (budgets.isEmpty) { + return [ + InsightCard( + icon: Symbols.savings_rounded, + label: "Budget", + title: const Text("No budgets yet"), + subtitle: + "The Budget entity is modeled but unused. Create a sample " + "budget above to see budget-vs-actual.", + ), + ]; + } + + return budgets.map((progress) { + final Budget budget = progress.budget; + final Money actual = Money(progress.actual, budget.currency); + final Money limit = Money(budget.amount, budget.currency); + final bool over = progress.actual > budget.amount; + final double remaining = budget.amount - progress.actual; + + return InsightCard( + icon: Symbols.savings_rounded, + label: "Budget", + accent: over ? context.flowColors.expense : context.flowColors.income, + title: Row( + children: [ + Expanded(child: Text(budget.name)), + MoneyText(actual, style: context.textTheme.titleSmall), + Text( + " / ", + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSecondary.withAlpha(0x80), + ), + ), + MoneyText( + limit, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSecondary.withAlpha(0x80), + ), + ), + ], + ), + subtitle: over + ? "Over by ${Money(-remaining, budget.currency).formatted}" + : "${Money(remaining, budget.currency).formatted} left", + child: BulletChart(value: progress.actual, target: budget.amount), + ); + }).toList(); + } + + Widget _buildCategoryTrendCard(BuildContext context) { + final double avg = topCategoryAverage; + final double current = topCategoryCurrent; + final bool up = current >= avg; + final double deltaPct = avg <= 0 ? 0.0 : ((current - avg) / avg) * 100.0; + final Color accent = up + ? context.flowColors.expense + : context.flowColors.income; + + final String name = topCategory?.name ?? "Uncategorized"; + final String direction = up ? "up" : "down"; + + return InsightCard( + icon: Symbols.lunch_dining_rounded, + label: "Category", + accent: accent, + title: Text.rich( + TextSpan( + children: [ + TextSpan(text: "$name is $direction "), + TextSpan( + text: "${deltaPct.abs().toStringAsFixed(0)}%", + style: TextStyle(color: accent, fontWeight: FontWeight.bold), + ), + const TextSpan(text: " vs your 3-month average."), + ], + ), + ), + subtitle: + "${Money(current, primaryCurrency).formatted} this month vs " + "${Money(avg, primaryCurrency).formatted} typical", + child: _MiniBars(values: topCategoryHistory, highlightColor: accent), + ); + } + + Widget _buildTopMerchantCard(BuildContext context) { + final MapEntry top = trends!.sortedTitlesByFrequency.first; + + return InsightCard( + icon: Symbols.storefront_rounded, + label: "Frequent", + title: Text.rich( + TextSpan( + children: [ + const TextSpan(text: "Your most frequent entry: "), + TextSpan( + text: top.key, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + subtitle: "Logged ${top.value} times this month", + ); + } + + Widget _buildWeekdayCard(BuildContext context) { + final int topWeekday = weekdayExpense.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + final String weekdayName = _weekdayName(topWeekday); + + return InsightCard( + icon: Symbols.calendar_month_rounded, + label: "Rhythm", + title: Text.rich( + TextSpan( + children: [ + const TextSpan(text: "You spend most on "), + TextSpan( + text: weekdayName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: "."), + ], + ), + ), + child: WeekdayBars( + byWeekday: weekdayExpense, + topWeekday: topWeekday, + accent: context.colorScheme.primary, + ), + ); + } + + Widget _buildSpendShapeCard(BuildContext context) { + final Money median = + trends?.medianExpensePerTransaction ?? Money(0.0, primaryCurrency); + + final String biggestLine = biggestExpense == null + ? "No expenses recorded." + : "Biggest: ${biggestExpense!.title ?? "Untitled"} · " + "${Money(biggestExpenseConverted, primaryCurrency).formatted} · " + "${biggestExpense!.transactionDate.toMoment().format("MMM D")}"; + + return InsightCard( + icon: Symbols.bar_chart_rounded, + label: "Shape", + title: Text.rich( + TextSpan( + children: [ + const TextSpan(text: "Your median purchase is "), + TextSpan( + text: median.formatted, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: "."), + ], + ), + ), + subtitle: biggestLine, + ); + } + + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + bool missing = false; + + try { + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + + final List months = _recentMonths(4); + + final FlowAnalytics current = await ObjectBox() + .flowByCategories(range: months.first); + final List> previous = []; + for (final TimeRange range in months.skip(1)) { + previous.add(await ObjectBox().flowByCategories(range: range)); + } + + thisMonthTransactions = await ObjectBox().transcationsByRange( + months.first, + includeTransfers: false, + ); + + trends = TrendsReport( + rates: rates, + primaryCurrency: primaryCurrency, + transactions: thisMonthTransactions, + ); + + missing = missing || _computeTopCategory(current, previous); + missing = missing || _computeWeekdayAndBiggest(); + missing = missing || await _computeBudgets(); + + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + /// Picks the biggest expense category this month and its 3-month average. + /// + /// Returns whether any currency conversion was skipped. + bool _computeTopCategory( + FlowAnalytics current, + List> previous, + ) { + String? topUuid; + double topExpense = 0.0; + Category? category; + + for (final MapEntry entry in current.flow.entries) { + final double expense = _categoryExpense(current, entry.key); + if (expense > topExpense) { + topExpense = expense; + topUuid = entry.key; + category = current.flow[entry.key]?.associatedData; + } + } + + topCategory = category; + topCategoryCurrent = topExpense; + + // Merging swallows unconvertible foreign currency (sets hasMissingData + // rather than throwing), so check every month's flows to surface the + // "amounts were skipped" banner when the numbers are under-counted. + final bool missing = [current, ...previous].any( + (analytics) => analytics.flow.values.any( + (flow) => flow.merge(primaryCurrency, rates).hasMissingData, + ), + ); + + if (topUuid == null) { + topCategoryAverage = 0.0; + topCategoryHistory = []; + return missing; + } + + final List history = previous.reversed + .map((flow) => _categoryExpense(flow, topUuid!)) + .toList(); + history.add(topExpense); + + topCategoryHistory = history; + topCategoryAverage = previous.isEmpty + ? 0.0 + : previous + .map((flow) => _categoryExpense(flow, topUuid!)) + .fold(0.0, (a, b) => a + b) / + previous.length; + + return missing; + } + + double _categoryExpense(FlowAnalytics analytics, String uuid) { + final flow = analytics.flow[uuid]; + if (flow == null) return 0.0; + return flow.merge(primaryCurrency, rates).totalExpense.amount.abs(); + } + + bool _computeWeekdayAndBiggest() { + bool missing = false; + final Map byWeekday = {}; + + Transaction? biggest; + double biggestAmount = 0.0; + + for (final Transaction transaction in thisMonthTransactions) { + if (transaction.type != TransactionType.expense) continue; + + final double? converted = _convert(transaction.money, primaryCurrency); + if (converted == null) { + missing = true; + continue; + } + + final double magnitude = converted.abs(); + final int weekday = transaction.transactionDate.weekday; + byWeekday[weekday] = (byWeekday[weekday] ?? 0.0) + magnitude; + + if (magnitude > biggestAmount) { + biggestAmount = magnitude; + biggest = transaction; + } + } + + weekdayExpense = byWeekday; + biggestExpense = biggest; + biggestExpenseConverted = biggestAmount; + + return missing; + } + + Future _computeBudgets() async { + bool missing = false; + final List all = ObjectBox().box().getAll(); + final List<_BudgetProgress> result = []; + + for (final Budget budget in all) { + final List transactions = await ObjectBox() + .transcationsByRange(budget.timeRange, includeTransfers: false); + final Set categoryUuids = + budget.categoriesUuids?.toSet() ?? {}; + + double actual = 0.0; + for (final Transaction transaction in transactions) { + if (transaction.type != TransactionType.expense) continue; + // A budget with no categories tracks nothing rather than silently + // summing every expense in the range. + if (categoryUuids.isEmpty || + !categoryUuids.contains(transaction.categoryUuid)) { + continue; + } + + final double? converted = _convert(transaction.money, budget.currency); + if (converted == null) { + missing = true; + continue; + } + actual += converted.abs(); + } + + result.add(_BudgetProgress(budget, actual)); + } + + budgets = result; + return missing; + } + + double? _convert(Money money, String currency) { + if (money.currency == currency) return money.amount; + + final ExchangeRates? rates = this.rates; + if (rates == null) return null; + + try { + return money.convert(currency, rates).amount; + } catch (_) { + return null; + } + } + + List _recentMonths(int count) { + final List months = [TimeRange.thisMonth()]; + for (int i = 1; i < count; i++) { + final TimeRange previous = months.last; + months.add(previous is PageableRange ? previous.last : previous); + } + return months; + } + + void _createSampleBudget() { + final Category? category = topCategory; + if (category == null) return; + + final double base = topCategoryCurrent <= 0 ? 100000.0 : topCategoryCurrent; + final String name = "[dev] ${category.name} budget"; + + final Box box = ObjectBox().box(); + + // Avoid the unique-name constraint on repeated taps. + final List existing = box + .getAll() + .where((budget) => budget.name == name) + .toList(); + for (final Budget budget in existing) { + box.remove(budget.id); + } + + final Budget budget = Budget( + name: name, + amount: (base * 1.2).roundToDouble(), + currency: primaryCurrency, + range: TimeRange.thisMonth().toString(), + )..setCategories([category]); + + box.put(budget); + + fetch(); + } + + void _clearDevBudgets() { + final Box box = ObjectBox().box(); + final List devBudgets = box + .getAll() + .where((budget) => budget.name.startsWith("[dev]")) + .toList(); + for (final Budget budget in devBudgets) { + box.remove(budget.id); + } + + fetch(); + } + + String _weekdayName(int weekday) { + // 1 == Monday .. 7 == Sunday (DateTime.weekday). + return DateTime(2024, 1, weekday).toMoment().format("dddd"); + } +} + +class _DevToolbar extends StatelessWidget { + final bool canCreate; + final VoidCallback onCreate; + final VoidCallback onClear; + + const _DevToolbar({ + required this.canCreate, + required this.onCreate, + required this.onClear, + }); + + @override + Widget build(BuildContext context) { + return Frame( + child: Wrap( + spacing: 8.0, + children: [ + TextButton.icon( + onPressed: canCreate ? onCreate : null, + icon: const Icon(Symbols.add_rounded), + label: const Text("Create sample budget"), + ), + TextButton.icon( + onPressed: onClear, + icon: const Icon(Symbols.delete_rounded), + label: const Text("Clear [dev] budgets"), + ), + ], + ), + ); + } +} + +class _MiniBars extends StatelessWidget { + final List values; + final Color highlightColor; + + const _MiniBars({required this.values, required this.highlightColor}); + + @override + Widget build(BuildContext context) { + if (values.isEmpty) return const SizedBox.shrink(); + + final double max = values.reduce((a, b) => a > b ? a : b); + final Color base = context.colorScheme.onSurface.withAlpha(0x33); + + return SizedBox( + height: 44.0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: values.asMap().entries.map((entry) { + final bool isLast = entry.key == values.length - 1; + final double factor = max <= 0 ? 0.0 : entry.value / max; + + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + heightFactor: factor.clamp(0.05, 1.0), + child: Container( + decoration: BoxDecoration( + color: isLast ? highlightColor : base, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4.0), + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/routes/debug/debug_icloud_page.dart b/lib/routes/debug/debug_icloud_page.dart index 082334fc..42e709a8 100644 --- a/lib/routes/debug/debug_icloud_page.dart +++ b/lib/routes/debug/debug_icloud_page.dart @@ -6,7 +6,7 @@ import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/directional_slidable.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:share_plus/share_plus.dart"; class DebugICloudPage extends StatefulWidget { diff --git a/lib/routes/debug/debug_logs_page.dart b/lib/routes/debug/debug_logs_page.dart index 192929d9..0ea72f47 100644 --- a/lib/routes/debug/debug_logs_page.dart +++ b/lib/routes/debug/debug_logs_page.dart @@ -8,7 +8,7 @@ import "package:flow/widgets/general/directional_slidable.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:path/path.dart" as path; diff --git a/lib/routes/debug/debug_theme_page.dart b/lib/routes/debug/debug_theme_page.dart index 211e2250..510c9fbc 100644 --- a/lib/routes/debug/debug_theme_page.dart +++ b/lib/routes/debug/debug_theme_page.dart @@ -14,7 +14,7 @@ import "package:flow/widgets/transaction_list_tile.dart"; import "package:flow/widgets/transaction_tag_chip.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class DebugThemePage extends StatelessWidget { diff --git a/lib/routes/error_page.dart b/lib/routes/error_page.dart index 930bd576..fe6b09a7 100644 --- a/lib/routes/error_page.dart +++ b/lib/routes/error_page.dart @@ -5,7 +5,7 @@ import "package:flow/widgets/general/button.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"; +import "package:material_symbols_icons_flow/symbols.dart"; class ErrorPage extends StatelessWidget { final String? error; diff --git a/lib/routes/export/export_history_page.dart b/lib/routes/export/export_history_page.dart index b47c28b3..f9a095d3 100644 --- a/lib/routes/export/export_history_page.dart +++ b/lib/routes/export/export_history_page.dart @@ -16,7 +16,7 @@ import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/icloud_failed_error_box.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:path/path.dart" as path; class ExportHistoryPage extends StatefulWidget { diff --git a/lib/routes/export/export_pdf_page.dart b/lib/routes/export/export_pdf_page.dart index 209be8b8..cd2217ea 100644 --- a/lib/routes/export/export_pdf_page.dart +++ b/lib/routes/export/export_pdf_page.dart @@ -18,7 +18,7 @@ import "package:flow/widgets/transaction_filter_head/select_multi_category_sheet import "package:flutter/foundation.dart" hide Category; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class ExportPdfPage extends StatefulWidget { diff --git a/lib/routes/export_options_page.dart b/lib/routes/export_options_page.dart index da249b5c..d3665b15 100644 --- a/lib/routes/export_options_page.dart +++ b/lib/routes/export_options_page.dart @@ -3,7 +3,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/widgets/action_card.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class ExportOptionsPage extends StatefulWidget { const ExportOptionsPage({super.key}); diff --git a/lib/routes/home/accounts_tab.dart b/lib/routes/home/accounts_tab.dart index 692bcd6d..d859211f 100644 --- a/lib/routes/home/accounts_tab.dart +++ b/lib/routes/home/accounts_tab.dart @@ -17,7 +17,7 @@ import "package:flow/widgets/home/home/account/total_balance.dart"; import "package:flow/widgets/home/privacy_toggler.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class AccountsTab extends StatefulWidget { const AccountsTab({super.key}); @@ -104,8 +104,8 @@ class _AccountsTabState extends State ), proxyDecorator: proxyDecorator, itemCount: accounts.length, - onReorder: (oldIndex, newIndex) => - onReorder(accounts, oldIndex, newIndex), + onReorderItem: (oldIndex, newIndex) => + onReorderItem(accounts, oldIndex, newIndex), ), ) : ListView( @@ -207,7 +207,11 @@ class _AccountsTabState extends State }); } - void onReorder(List currentAccounts, int oldIndex, int newIndex) { + void onReorderItem( + List currentAccounts, + int oldIndex, + int newIndex, + ) { if (oldIndex < newIndex) { newIndex -= 1; } diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index 8a88481f..cc0a9a80 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -15,10 +15,10 @@ import "package:flow/widgets/general/spinner.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"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:shared_preferences/shared_preferences.dart"; -import "package:simple_icons/simple_icons.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; class ProfileTab extends StatefulWidget { const ProfileTab({super.key}); @@ -126,6 +126,38 @@ class _ProfileTabState extends State { onTap: () => context.push("/preferences"), ), if (flowDebugMode) ...[ + const SizedBox(height: 32.0), + const ListHeader("Analytics lab"), + ListTile( + title: const Text("Net worth over time"), + leading: const Icon(Symbols.trending_up_rounded), + onTap: () => context.push("/_debug/analytics/net-worth"), + ), + ListTile( + title: const Text("Monthly wrapped"), + leading: const Icon(Symbols.bar_chart_rounded), + onTap: () => context.push("/_debug/analytics/wrapped"), + ), + ListTile( + title: const Text("Subscriptions & recurring"), + leading: const Icon(Symbols.autorenew_rounded), + onTap: () => context.push("/_debug/analytics/recurring"), + ), + ListTile( + title: const Text("Spending calendar"), + leading: const Icon(Symbols.calendar_month_rounded), + onTap: () => context.push("/_debug/analytics/calendar"), + ), + ListTile( + title: const Text("Cash flow (Sankey)"), + leading: const Icon(Symbols.alt_route_rounded), + onTap: () => context.push("/_debug/analytics/cash-flow"), + ), + ListTile( + title: const Text("Spending map"), + leading: const Icon(Symbols.map_rounded), + onTap: () => context.push("/_debug/analytics/map"), + ), const SizedBox(height: 32.0), const ListHeader("Debug options"), ListTile( diff --git a/lib/routes/import_page.dart b/lib/routes/import_page.dart index 46a95f70..c882f961 100644 --- a/lib/routes/import_page.dart +++ b/lib/routes/import_page.dart @@ -12,8 +12,8 @@ import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/import/file_select_area.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; -import "package:simple_icons/simple_icons.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; final Logger _log = Logger("ImportPage"); diff --git a/lib/routes/integrate/integrate_eny_page.dart b/lib/routes/integrate/integrate_eny_page.dart index c676fd69..9a2db727 100644 --- a/lib/routes/integrate/integrate_eny_page.dart +++ b/lib/routes/integrate/integrate_eny_page.dart @@ -9,7 +9,7 @@ import "package:flow/widgets/integrations/eny_page/eny_privacy_notice.dart"; import "package:flow/widgets/scaffold_actions.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class IntegrateEnyPage extends StatefulWidget { final String apiKey; diff --git a/lib/routes/integrations/eny_page.dart b/lib/routes/integrations/eny_page.dart index d031739c..5da3cc50 100644 --- a/lib/routes/integrations/eny_page.dart +++ b/lib/routes/integrations/eny_page.dart @@ -22,7 +22,7 @@ import "package:flutter/material.dart"; import "package:flutter/scheduler.dart"; import "package:flutter/services.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class EnyPage extends StatefulWidget { const EnyPage({super.key}); diff --git a/lib/routes/preferences/change_preferences_page.dart b/lib/routes/preferences/change_preferences_page.dart index 724d45b4..4335c68a 100644 --- a/lib/routes/preferences/change_preferences_page.dart +++ b/lib/routes/preferences/change_preferences_page.dart @@ -12,7 +12,7 @@ import "package:flow/widgets/trend.dart"; import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class ChangeVisualsPreferencesPage extends StatefulWidget { const ChangeVisualsPreferencesPage({super.key}); diff --git a/lib/routes/preferences/integrations/eny_preferences_page.dart b/lib/routes/preferences/integrations/eny_preferences_page.dart index 4a086bcd..195e3a2d 100644 --- a/lib/routes/preferences/integrations/eny_preferences_page.dart +++ b/lib/routes/preferences/integrations/eny_preferences_page.dart @@ -13,7 +13,7 @@ import "package:flow/widgets/general/wavy_divider.dart"; import "package:flow/widgets/integrations/eny_page/eny_privacy_notice.dart"; import "package:flutter/material.dart"; import "package:flutter/scheduler.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class EnyPreferencesPage extends StatefulWidget { const EnyPreferencesPage({super.key}); diff --git a/lib/routes/preferences/language_selection_sheet.dart b/lib/routes/preferences/language_selection_sheet.dart index c56da2f5..9480bdf0 100644 --- a/lib/routes/preferences/language_selection_sheet.dart +++ b/lib/routes/preferences/language_selection_sheet.dart @@ -3,7 +3,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; class LanguageSelectionSheet extends StatelessWidget { final Locale? currentLocale; diff --git a/lib/routes/preferences/sections/haptics.dart b/lib/routes/preferences/sections/haptics.dart index 1ba211a4..5179b68d 100644 --- a/lib/routes/preferences/sections/haptics.dart +++ b/lib/routes/preferences/sections/haptics.dart @@ -2,7 +2,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/prefs/local_preferences.dart"; import "package:flow/routes/preferences_page.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class Haptics extends StatefulWidget { const Haptics({super.key}); diff --git a/lib/routes/preferences/sections/icloud.dart b/lib/routes/preferences/sections/icloud.dart index 947f73e6..f9052991 100644 --- a/lib/routes/preferences/sections/icloud.dart +++ b/lib/routes/preferences/sections/icloud.dart @@ -10,7 +10,7 @@ import "package:flow/widgets/general/info_text.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/icloud_failed_error_box.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; /// This widget expects [LocalAuthService] to be initialized diff --git a/lib/routes/preferences/sections/lock_app.dart b/lib/routes/preferences/sections/lock_app.dart index 0db9f4b4..adcd6bc9 100644 --- a/lib/routes/preferences/sections/lock_app.dart +++ b/lib/routes/preferences/sections/lock_app.dart @@ -9,7 +9,7 @@ import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/info_text.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; final Logger _log = Logger("LockApp"); diff --git a/lib/routes/preferences/sections/privacy.dart b/lib/routes/preferences/sections/privacy.dart index 22ac643c..aa2a6b63 100644 --- a/lib/routes/preferences/sections/privacy.dart +++ b/lib/routes/preferences/sections/privacy.dart @@ -2,7 +2,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/routes/preferences_page.dart"; import "package:flow/services/user_preferences.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class Privacy extends StatefulWidget { const Privacy({super.key}); diff --git a/lib/routes/preferences/theme_preferences_page.dart b/lib/routes/preferences/theme_preferences_page.dart index 7df4e053..96c00222 100644 --- a/lib/routes/preferences/theme_preferences_page.dart +++ b/lib/routes/preferences/theme_preferences_page.dart @@ -10,7 +10,7 @@ import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/theme_petal_selector.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class ThemePreferencesPage extends StatefulWidget { const ThemePreferencesPage({super.key}); diff --git a/lib/routes/preferences/transaction_entry_flow_preferences_page.dart b/lib/routes/preferences/transaction_entry_flow_preferences_page.dart index c3844105..ef45407f 100644 --- a/lib/routes/preferences/transaction_entry_flow_preferences_page.dart +++ b/lib/routes/preferences/transaction_entry_flow_preferences_page.dart @@ -7,7 +7,7 @@ import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/info_text.dart"; import "package:flow/widgets/general/wavy_divider.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class TransactionEntryFlowPreferencesPage extends StatefulWidget { const TransactionEntryFlowPreferencesPage({super.key}); @@ -105,7 +105,7 @@ class _TransactionEntryFlowPreferencesPageState ), ReorderableListView( shrinkWrap: true, - onReorder: onReorder, + onReorderItem: onReorderItem, proxyDecorator: proxyDecorator, physics: NeverScrollableScrollPhysics(), children: _actions @@ -170,7 +170,7 @@ class _TransactionEntryFlowPreferencesPageState ); } - void onReorder(int oldIndex, int newIndex) { + void onReorderItem(int oldIndex, int newIndex) { if (oldIndex < newIndex) { newIndex -= 1; } diff --git a/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart b/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart index 3b0d6f7e..0f630374 100644 --- a/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart +++ b/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart @@ -10,9 +10,9 @@ import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/transaction_list_tile.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; -import "package:simple_icons/simple_icons.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; class TransactionListItemAppearancePreferencesPage extends StatefulWidget { const TransactionListItemAppearancePreferencesPage({super.key}); diff --git a/lib/routes/preferences/trash_bin_preferences_page.dart b/lib/routes/preferences/trash_bin_preferences_page.dart index adb3c235..71f80579 100644 --- a/lib/routes/preferences/trash_bin_preferences_page.dart +++ b/lib/routes/preferences/trash_bin_preferences_page.dart @@ -8,7 +8,7 @@ import "package:flow/utils/extensions.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class TrashBinPreferencesPage extends StatefulWidget { diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart index cb39626a..35436126 100644 --- a/lib/routes/preferences_page.dart +++ b/lib/routes/preferences_page.dart @@ -22,7 +22,7 @@ import "package:flow/widgets/sheets/select_currency_sheet.dart"; import "package:flutter/material.dart" hide Flow; import "package:go_router/go_router.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:permission_handler/permission_handler.dart"; final Logger _log = Logger("PreferencesPage"); diff --git a/lib/routes/profile_page.dart b/lib/routes/profile_page.dart index 7c97daf1..12bb909f 100644 --- a/lib/routes/profile_page.dart +++ b/lib/routes/profile_page.dart @@ -11,7 +11,7 @@ import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/profile_picture.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:path/path.dart" as path; class ProfilePage extends StatefulWidget { diff --git a/lib/routes/setup/setup_accounts_page.dart b/lib/routes/setup/setup_accounts_page.dart index 5c3a32df..33f720f2 100644 --- a/lib/routes/setup/setup_accounts_page.dart +++ b/lib/routes/setup/setup_accounts_page.dart @@ -12,7 +12,7 @@ import "package:flow/widgets/setup/accounts/add_account_card.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:local_hero/local_hero.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class SetupAccountsPage extends StatefulWidget { const SetupAccountsPage({super.key}); diff --git a/lib/routes/setup/setup_categories_page.dart b/lib/routes/setup/setup_categories_page.dart index 96dd4bb0..fd3e2555 100644 --- a/lib/routes/setup/setup_categories_page.dart +++ b/lib/routes/setup/setup_categories_page.dart @@ -14,7 +14,7 @@ import "package:flow/widgets/setup/categories/category_preset_card.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:local_hero/local_hero.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class SetupCategoriesPage extends StatefulWidget { /// When [true], the page will close itself upon completion. diff --git a/lib/routes/setup/setup_currency_page.dart b/lib/routes/setup/setup_currency_page.dart index 398a84a7..8244c322 100644 --- a/lib/routes/setup/setup_currency_page.dart +++ b/lib/routes/setup/setup_currency_page.dart @@ -7,7 +7,7 @@ import "package:flow/widgets/sheets/select_currency_sheet.dart"; import "package:flutter/material.dart"; import "package:flutter/scheduler.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class SetupCurrencyPage extends StatefulWidget { const SetupCurrencyPage({super.key}); diff --git a/lib/routes/setup/setup_onboarding_page.dart b/lib/routes/setup/setup_onboarding_page.dart index 1aeec01d..e3b192e9 100644 --- a/lib/routes/setup/setup_onboarding_page.dart +++ b/lib/routes/setup/setup_onboarding_page.dart @@ -17,9 +17,9 @@ import "package:flow/widgets/setup/icloud_backup_picker_sheet.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; -import "package:simple_icons/simple_icons.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; final Logger _log = Logger("SetupOnboardingPage"); diff --git a/lib/routes/setup/setup_profile_page.dart b/lib/routes/setup/setup_profile_page.dart index fe4744c8..48a8757b 100644 --- a/lib/routes/setup/setup_profile_page.dart +++ b/lib/routes/setup/setup_profile_page.dart @@ -10,7 +10,7 @@ import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/button.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class SetupProfilePage extends StatefulWidget { const SetupProfilePage({super.key}); diff --git a/lib/routes/setup/setup_profile_picture_page.dart b/lib/routes/setup/setup_profile_picture_page.dart index 925ef92a..ccd03c05 100644 --- a/lib/routes/setup/setup_profile_picture_page.dart +++ b/lib/routes/setup/setup_profile_picture_page.dart @@ -12,7 +12,7 @@ import "package:flow/widgets/general/button.dart"; import "package:flow/widgets/general/profile_picture.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:path/path.dart" as path; class SetupProfilePhotoPage extends StatefulWidget { diff --git a/lib/routes/setup_page.dart b/lib/routes/setup_page.dart index 1049ecac..d4b3c459 100644 --- a/lib/routes/setup_page.dart +++ b/lib/routes/setup_page.dart @@ -6,7 +6,7 @@ import "package:flow/widgets/setup/privacy_slide.dart"; import "package:flow/widgets/setup/welcome_slide.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:smooth_page_indicator/smooth_page_indicator.dart"; class SetupPage extends StatefulWidget { diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart index 349062e6..821fb142 100644 --- a/lib/routes/stats/stats_by_group_page.dart +++ b/lib/routes/stats/stats_by_group_page.dart @@ -17,7 +17,7 @@ import "package:flow/widgets/home/stats/pie_graph_view.dart"; import "package:flow/widgets/rates_missing_error_box.dart"; import "package:flow/widgets/time_range_selector.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class StatsByGroupPage extends StatefulWidget { diff --git a/lib/routes/support_page.dart b/lib/routes/support_page.dart index 5d578524..3ce5408c 100644 --- a/lib/routes/support_page.dart +++ b/lib/routes/support_page.dart @@ -11,7 +11,7 @@ import "package:flow/widgets/action_card.dart"; import "package:flow/widgets/general/button.dart"; import "package:flutter/material.dart"; import "package:in_app_review/in_app_review.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class SupportPage extends StatelessWidget { diff --git a/lib/routes/transaction_batch_import_page.dart b/lib/routes/transaction_batch_import_page.dart index bf84b710..a8f55ecd 100644 --- a/lib/routes/transaction_batch_import_page.dart +++ b/lib/routes/transaction_batch_import_page.dart @@ -21,7 +21,7 @@ import "package:flow/widgets/transaction_batch_import_page/tpo_preview_list_item import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class TransactionBatchImportPage extends StatefulWidget { final TransactionMultiProgrammableObject? params; diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index 9152577c..766ac2f8 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -64,7 +64,7 @@ import "package:geolocator/geolocator.dart"; import "package:go_router/go_router.dart"; import "package:latlong2/latlong.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:recurrence/recurrence.dart"; import "package:uuid/uuid.dart"; diff --git a/lib/routes/transaction_page/input_amount_sheet.dart b/lib/routes/transaction_page/input_amount_sheet.dart index fb980aa7..1800973c 100644 --- a/lib/routes/transaction_page/input_amount_sheet.dart +++ b/lib/routes/transaction_page/input_amount_sheet.dart @@ -14,7 +14,7 @@ import "package:flow/widgets/numpad.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; enum CalculatorOperation { add, subtract, multiply, divide } diff --git a/lib/routes/transaction_page/input_amount_sheet/calculator_button.dart b/lib/routes/transaction_page/input_amount_sheet/calculator_button.dart index 04031c4f..f14245a7 100644 --- a/lib/routes/transaction_page/input_amount_sheet/calculator_button.dart +++ b/lib/routes/transaction_page/input_amount_sheet/calculator_button.dart @@ -4,7 +4,7 @@ import "package:flow/routes/transaction_page/input_amount_sheet.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/numpad_button.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class CalculatorButton extends StatelessWidget { final CalculatorOperation operation; diff --git a/lib/routes/transaction_page/sections/description_section.dart b/lib/routes/transaction_page/sections/description_section.dart index 65a61767..48e6232c 100644 --- a/lib/routes/transaction_page/sections/description_section.dart +++ b/lib/routes/transaction_page/sections/description_section.dart @@ -7,8 +7,8 @@ import "package:flow/widgets/general/directional_chevron.dart"; import "package:flow/widgets/general/markdown_view.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; -import "package:simple_icons/simple_icons.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; class DescriptionSection extends StatefulWidget { final String? value; diff --git a/lib/routes/transaction_page/select_recurrence.dart b/lib/routes/transaction_page/select_recurrence.dart index 8658cd1f..45ffde9f 100644 --- a/lib/routes/transaction_page/select_recurrence.dart +++ b/lib/routes/transaction_page/select_recurrence.dart @@ -6,7 +6,7 @@ import "package:flow/routes/transaction_page/select_recurrence/select_until_mode import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/custom_popups.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:recurrence/recurrence.dart"; diff --git a/lib/routes/transaction_page/select_recurrence/input_occurrences_sheet.dart b/lib/routes/transaction_page/select_recurrence/input_occurrences_sheet.dart index eb454a38..a720e7b4 100644 --- a/lib/routes/transaction_page/select_recurrence/input_occurrences_sheet.dart +++ b/lib/routes/transaction_page/select_recurrence/input_occurrences_sheet.dart @@ -5,7 +5,7 @@ import "package:flow/widgets/general/modal_sheet.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class InputOccurrencesSheet extends StatefulWidget { final int? initialValue; diff --git a/lib/routes/transaction_page/select_recurrence_sheet.dart b/lib/routes/transaction_page/select_recurrence_sheet.dart index c14cb5c1..6babc868 100644 --- a/lib/routes/transaction_page/select_recurrence_sheet.dart +++ b/lib/routes/transaction_page/select_recurrence_sheet.dart @@ -4,7 +4,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:recurrence/recurrence.dart"; diff --git a/lib/routes/transaction_tag_page.dart b/lib/routes/transaction_tag_page.dart index 208bd152..2f365526 100644 --- a/lib/routes/transaction_tag_page.dart +++ b/lib/routes/transaction_tag_page.dart @@ -36,7 +36,7 @@ import "package:flutter_map/flutter_map.dart"; import "package:geolocator/geolocator.dart"; import "package:go_router/go_router.dart"; import "package:latlong2/latlong.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:permission_handler/permission_handler.dart"; class TransactionTagPage extends StatefulWidget { diff --git a/lib/routes/transactions_page.dart b/lib/routes/transactions_page.dart index 12b66766..4190f446 100644 --- a/lib/routes/transactions_page.dart +++ b/lib/routes/transactions_page.dart @@ -21,7 +21,7 @@ import "package:flow/widgets/transactions_date_header.dart"; import "package:flow/widgets/transactions_selection_controller.dart"; import "package:flow/widgets/transactions_selection_scope.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; /// Generic transactions page that can be used to display list of transactions diff --git a/lib/routes/utils/crop_square_image_page.dart b/lib/routes/utils/crop_square_image_page.dart index b9dfff36..375cc904 100644 --- a/lib/routes/utils/crop_square_image_page.dart +++ b/lib/routes/utils/crop_square_image_page.dart @@ -7,7 +7,7 @@ import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class CropSquareImagePageProps { final File? file; diff --git a/lib/routes/utils/edit_markdown_page.dart b/lib/routes/utils/edit_markdown_page.dart index 9e8cad28..50fa1db7 100644 --- a/lib/routes/utils/edit_markdown_page.dart +++ b/lib/routes/utils/edit_markdown_page.dart @@ -9,7 +9,7 @@ import "package:flutter_quill/flutter_quill.dart"; import "package:go_router/go_router.dart"; import "package:markdown/markdown.dart" as md; import "package:markdown_quill/markdown_quill.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class EditMarkdownPageProps { final String? initialValue; diff --git a/lib/sync/import/external/ivy_wallet_csv.dart b/lib/sync/import/external/ivy_wallet_csv.dart index e5dae565..6ca5bc04 100644 --- a/lib/sync/import/external/ivy_wallet_csv.dart +++ b/lib/sync/import/external/ivy_wallet_csv.dart @@ -19,7 +19,7 @@ import "package:flow/utils/extensions/iterables.dart"; import "package:flow/utils/guess_preset_icon.dart"; import "package:flutter/foundation.dart" hide Category; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:uuid/v4.dart"; final Logger _log = Logger("IvyWalletCsvImporter"); diff --git a/lib/sync/import/import_csv.dart b/lib/sync/import/import_csv.dart index 51b806e2..42047a34 100644 --- a/lib/sync/import/import_csv.dart +++ b/lib/sync/import/import_csv.dart @@ -19,7 +19,7 @@ import "package:flow/utils/extensions/iterables.dart"; import "package:flow/utils/guess_preset_icon.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:uuid/uuid.dart"; import "package:uuid/v4.dart"; diff --git a/lib/theme/color_themes/flow/flow_darks.dart b/lib/theme/color_themes/flow/flow_darks.dart index 43e1005d..dfa9ae20 100644 --- a/lib/theme/color_themes/flow/flow_darks.dart +++ b/lib/theme/color_themes/flow/flow_darks.dart @@ -4,7 +4,7 @@ import "package:flow/theme/flow_theme_group.dart"; import "dart:ui"; import "package:flow/theme/flow_color_scheme.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; final FlowColorScheme _defaultDarkBase = FlowColorScheme( name: "defaultDarkBase", diff --git a/lib/theme/color_themes/flow/flow_lights.dart b/lib/theme/color_themes/flow/flow_lights.dart index 0256dcd3..350ca6b7 100644 --- a/lib/theme/color_themes/flow/flow_lights.dart +++ b/lib/theme/color_themes/flow/flow_lights.dart @@ -4,7 +4,7 @@ import "package:flow/data/flow_icon.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/flow_theme_group.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; final FlowColorScheme _defaultLightBase = FlowColorScheme( name: "defaultLightBase", diff --git a/lib/theme/color_themes/flow/flow_oleds.dart b/lib/theme/color_themes/flow/flow_oleds.dart index 379649a1..10b10c94 100644 --- a/lib/theme/color_themes/flow/flow_oleds.dart +++ b/lib/theme/color_themes/flow/flow_oleds.dart @@ -3,7 +3,7 @@ import "dart:ui"; import "package:flow/data/flow_icon.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/flow_theme_group.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; final FlowColorScheme _defaultOledBase = FlowColorScheme( name: "defaultOledBase", diff --git a/lib/theme/helpers.dart b/lib/theme/helpers.dart index c94c2275..c561f2cd 100644 --- a/lib/theme/helpers.dart +++ b/lib/theme/helpers.dart @@ -2,7 +2,7 @@ import "package:flow/entity/transaction.dart"; import "package:flow/theme/flow_custom_colors.dart"; import "package:flow/theme/pie_theme_extension.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:pie_menu/pie_menu.dart"; extension ThemeAccessor on BuildContext { diff --git a/lib/utils/extensions/custom_popups.dart b/lib/utils/extensions/custom_popups.dart index e21cdd9c..a01cb9d7 100644 --- a/lib/utils/extensions/custom_popups.dart +++ b/lib/utils/extensions/custom_popups.dart @@ -9,7 +9,7 @@ import "package:flow/widgets/general/modal_sheet.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:path/path.dart"; import "package:share_plus/share_plus.dart"; diff --git a/lib/utils/extensions/file_attachment.dart b/lib/utils/extensions/file_attachment.dart index 61976567..dee1dacd 100644 --- a/lib/utils/extensions/file_attachment.dart +++ b/lib/utils/extensions/file_attachment.dart @@ -1,6 +1,6 @@ import "package:flow/entity/file_attachment.dart"; import "package:flutter/widgets.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:path/path.dart" as path; /// Extensions for [FileAttachment] diff --git a/lib/utils/extensions/string.dart b/lib/utils/extensions/string.dart index 2934b9f9..db784de3 100644 --- a/lib/utils/extensions/string.dart +++ b/lib/utils/extensions/string.dart @@ -1,5 +1,5 @@ import "package:flow/data/flow_icon.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:path/path.dart" as path; extension Casings on String { diff --git a/lib/utils/extensions/toast.dart b/lib/utils/extensions/toast.dart index 5f8075b1..2720366a 100644 --- a/lib/utils/extensions/toast.dart +++ b/lib/utils/extensions/toast.dart @@ -3,7 +3,7 @@ import "package:flow/prefs/local_preferences.dart"; import "package:flow/theme/theme.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:toastification/toastification.dart"; extension ToastHelper on BuildContext { diff --git a/lib/utils/extensions/transaction_tag_type.dart b/lib/utils/extensions/transaction_tag_type.dart index 463250da..576d66ce 100644 --- a/lib/utils/extensions/transaction_tag_type.dart +++ b/lib/utils/extensions/transaction_tag_type.dart @@ -1,6 +1,6 @@ import "package:flow/entity/transaction/tag_type.dart"; import "package:flutter/widgets.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; extension TransactionTagTypeExtension on TransactionTagType { IconData get icon { diff --git a/lib/utils/guess_preset_icon.dart b/lib/utils/guess_preset_icon.dart index 1449ff30..d20e1bc6 100644 --- a/lib/utils/guess_preset_icon.dart +++ b/lib/utils/guess_preset_icon.dart @@ -3,7 +3,7 @@ import "package:flow/data/setup/default_accounts.dart"; import "package:flow/data/setup/default_categories.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/category.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Falls back to [fallback] FlowIconData guessPresetIcon( diff --git a/lib/widgets/account/update_balance_options_sheet.dart b/lib/widgets/account/update_balance_options_sheet.dart index df695297..d947c4b3 100644 --- a/lib/widgets/account/update_balance_options_sheet.dart +++ b/lib/widgets/account/update_balance_options_sheet.dart @@ -4,7 +4,7 @@ import "package:flow/utils/optional.dart"; import "package:flow/widgets/general/modal_sheet.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with [ValueOr] class UpdateBalanceOptionsSheet extends StatelessWidget { diff --git a/lib/widgets/account_card.dart b/lib/widgets/account_card.dart index cddf52b0..b16aa617 100644 --- a/lib/widgets/account_card.dart +++ b/lib/widgets/account_card.dart @@ -13,7 +13,7 @@ import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; import "package:flutter/scheduler.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class AccountCard extends StatelessWidget { diff --git a/lib/widgets/account_card_skeleton.dart b/lib/widgets/account_card_skeleton.dart index 5cc0f8c6..4039c285 100644 --- a/lib/widgets/account_card_skeleton.dart +++ b/lib/widgets/account_card_skeleton.dart @@ -2,7 +2,7 @@ import "package:flow/l10n/flow_localizations.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/surface.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class AccountCardSkeleton extends StatelessWidget { final VoidCallback? onTap; diff --git a/lib/widgets/add_category_card.dart b/lib/widgets/add_category_card.dart index caa69498..e3f58b4b 100644 --- a/lib/widgets/add_category_card.dart +++ b/lib/widgets/add_category_card.dart @@ -4,7 +4,7 @@ import "package:flow/widgets/general/flow_icon.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:material_symbols_icons_flow/symbols.dart"; class AddCategoryCard extends StatelessWidget { final VoidCallback? onTapOverride; diff --git a/lib/widgets/categories/no_categories.dart b/lib/widgets/categories/no_categories.dart index 368e3503..4334c282 100644 --- a/lib/widgets/categories/no_categories.dart +++ b/lib/widgets/categories/no_categories.dart @@ -4,7 +4,7 @@ import "package:flow/widgets/general/button.dart"; import "package:flow/widgets/general/empty_state.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class NoCategories extends StatelessWidget { const NoCategories({super.key}); diff --git a/lib/widgets/debug/analytics/bullet_chart.dart b/lib/widgets/debug/analytics/bullet_chart.dart new file mode 100644 index 00000000..ff80ab85 --- /dev/null +++ b/lib/widgets/debug/analytics/bullet_chart.dart @@ -0,0 +1,97 @@ +import "dart:math" as math; + +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +/// A compact bullet chart for budget-vs-actual style comparisons. +/// +/// Draws a horizontal track with a qualitative band up to [target], a measure +/// bar for [value], and a target tick at [target]. The recommended encoding +/// for a single KPI against a goal on a dense screen. +class BulletChart extends StatelessWidget { + final double value; + final double target; + + /// Bar color; defaults to a sensible "over/under target" choice. + final Color? barColor; + + final double height; + + const BulletChart({ + super.key, + required this.value, + required this.target, + this.barColor, + this.height = 16.0, + }); + + @override + Widget build(BuildContext context) { + // Always leave headroom past whichever is larger so the bar/tick never + // pin to the very edge. + final double max = math.max(math.max(value, target), 1.0) * 1.1; + final bool over = value > target; + + final Color bar = + barColor ?? + (over ? context.flowColors.expense : context.flowColors.income); + final Color track = context.colorScheme.onSurface.withAlpha(0x1f); + final Color band = context.colorScheme.onSurface.withAlpha(0x14); + + return LayoutBuilder( + builder: (context, constraints) { + final double width = constraints.maxWidth; + final double valueWidth = (value / max).clamp(0.0, 1.0) * width; + final double targetX = (target / max).clamp(0.0, 1.0) * width; + + return SizedBox( + height: height, + width: width, + child: Stack( + children: [ + // Full track. + Container( + decoration: BoxDecoration( + color: track, + borderRadius: BorderRadius.all(Radius.circular(height / 2)), + ), + ), + // Qualitative band up to the target. + Container( + width: targetX, + decoration: BoxDecoration( + color: band, + borderRadius: BorderRadius.all(Radius.circular(height / 2)), + ), + ), + // Measure bar. + Padding( + padding: EdgeInsets.symmetric(vertical: height * 0.28), + child: Container( + width: valueWidth, + decoration: BoxDecoration( + color: bar, + borderRadius: BorderRadius.all(Radius.circular(height / 2)), + ), + ), + ), + // Target tick. + Positioned( + left: math.max(0.0, targetX - 1.0), + top: -1.0, + bottom: -1.0, + child: Container( + width: 2.5, + decoration: BoxDecoration( + color: context.colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(2.0)), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/widgets/debug/analytics/insight_card.dart b/lib/widgets/debug/analytics/insight_card.dart new file mode 100644 index 00000000..d79d76a9 --- /dev/null +++ b/lib/widgets/debug/analytics/insight_card.dart @@ -0,0 +1,111 @@ +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flutter/material.dart"; + +/// A narrative "insight" card used by the analytics lab pages. +/// +/// Renders a Flow [Surface] with an optional pill label + icon, a rich +/// [title] line, an optional [subtitle], and an optional [child] for a small +/// inline visualization (mini bars, a bullet chart, etc.). +class InsightCard extends StatelessWidget { + final IconData? icon; + + /// Short uppercase pill text, e.g. "Budget". + final String? label; + + /// Tints the icon and pill. Defaults to the theme primary color. + final Color? accent; + + final Widget title; + final String? subtitle; + final Widget? child; + + final VoidCallback? onTap; + + const InsightCard({ + super.key, + this.icon, + this.label, + this.accent, + required this.title, + this.subtitle, + this.child, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final Color resolvedAccent = accent ?? context.colorScheme.primary; + final bool hasHeader = icon != null || label != null; + + return Surface( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + builder: (context) => InkWell( + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasHeader) ...[ + Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: resolvedAccent, size: 20.0), + const SizedBox(width: 8.0), + ], + if (label != null) + _Pill(label: label!, accent: resolvedAccent), + ], + ), + const SizedBox(height: 10.0), + ], + DefaultTextStyle.merge( + style: context.textTheme.titleSmall ?? const TextStyle(), + child: title, + ), + if (subtitle != null) ...[ + const SizedBox(height: 4.0), + Text( + subtitle!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSecondary.withAlpha(0xb0), + ), + ), + ], + if (child != null) ...[const SizedBox(height: 12.0), child!], + ], + ), + ), + ), + ); + } +} + +class _Pill extends StatelessWidget { + final String label; + final Color accent; + + const _Pill({required this.label, required this.accent}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), + decoration: BoxDecoration( + color: accent.withAlpha(0x28), + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + ), + child: Text( + label.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: accent, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/widgets/debug/analytics/sankey_diagram.dart b/lib/widgets/debug/analytics/sankey_diagram.dart new file mode 100644 index 00000000..3a1954e0 --- /dev/null +++ b/lib/widgets/debug/analytics/sankey_diagram.dart @@ -0,0 +1,205 @@ +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +/// One node on either side of a [SankeyDiagram]. +class SankeyDatum { + final String label; + + /// Positive magnitude in the diagram's currency. + final double value; + + final Color color; + + const SankeyDatum({ + required this.label, + required this.value, + required this.color, + }); +} + +/// A two-sided cash-flow Sankey: income [sources] on the left flow through a +/// single total hub into spending [targets] on the right. +/// +/// The caller is expected to balance the two sides (e.g. add a "Saved" target +/// or a "From reserves" source) so both sum to the same total; the painter +/// scales each side independently and tolerates a small mismatch. +class SankeyDiagram extends StatelessWidget { + final List sources; + final List targets; + final double height; + + const SankeyDiagram({ + super.key, + required this.sources, + required this.targets, + this.height = 280.0, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + width: double.infinity, + child: CustomPaint( + painter: _SankeyPainter( + sources: sources, + targets: targets, + hubColor: context.colorScheme.onSurface.withAlpha(0x33), + ), + ), + ); + } +} + +class _SankeyPainter extends CustomPainter { + final List sources; + final List targets; + final Color hubColor; + + static const double _nodeWidth = 12.0; + static const double _gap = 6.0; + static const int _ribbonAlpha = 0x4d; + + _SankeyPainter({ + required this.sources, + required this.targets, + required this.hubColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final double sourceSum = _sum(sources); + final double targetSum = _sum(targets); + final double total = sourceSum > targetSum ? sourceSum : targetSum; + + if (total <= 0 || sources.isEmpty || targets.isEmpty) return; + + final double height = size.height; + final double hubScale = height / total; + + final double leftScale = + (height - (sources.length - 1) * _gap).clamp(0.0, height) / total; + final double rightScale = + (height - (targets.length - 1) * _gap).clamp(0.0, height) / total; + + final double leftRight = _nodeWidth; + final double hubLeft = size.width / 2 - _nodeWidth / 2; + final double hubRight = hubLeft + _nodeWidth; + final double rightLeft = size.width - _nodeWidth; + + // Left ribbons: each source node -> its contiguous slice of the hub. + double leftCursor = 0.0; + double hubLeftCursor = 0.0; + for (final SankeyDatum source in sources) { + final double nodeTop = leftCursor; + final double nodeBottom = nodeTop + source.value * leftScale; + final double hubTop = hubLeftCursor; + final double hubBottom = hubTop + source.value * hubScale; + + _drawRibbon( + canvas, + leftRight, + hubLeft, + nodeTop, + nodeBottom, + hubTop, + hubBottom, + source.color, + ); + + leftCursor = nodeBottom + _gap; + hubLeftCursor = hubBottom; + } + + // Right ribbons: each contiguous slice of the hub -> its target node. + double rightCursor = 0.0; + double hubRightCursor = 0.0; + for (final SankeyDatum target in targets) { + final double hubTop = hubRightCursor; + final double hubBottom = hubTop + target.value * hubScale; + final double nodeTop = rightCursor; + final double nodeBottom = nodeTop + target.value * rightScale; + + _drawRibbon( + canvas, + hubRight, + rightLeft, + hubTop, + hubBottom, + nodeTop, + nodeBottom, + target.color, + ); + + rightCursor = nodeBottom + _gap; + hubRightCursor = hubBottom; + } + + // Nodes drawn on top of the ribbons. + _drawNodes(canvas, 0.0, sources, leftScale); + _drawNodes(canvas, rightLeft, targets, rightScale); + _drawHub(canvas, hubLeft, height); + } + + void _drawRibbon( + Canvas canvas, + double xLeft, + double xRight, + double topLeft, + double bottomLeft, + double topRight, + double bottomRight, + Color color, + ) { + final double midX = (xLeft + xRight) / 2; + final Path path = Path() + ..moveTo(xLeft, topLeft) + ..cubicTo(midX, topLeft, midX, topRight, xRight, topRight) + ..lineTo(xRight, bottomRight) + ..cubicTo(midX, bottomRight, midX, bottomLeft, xLeft, bottomLeft) + ..close(); + + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.fill + ..color = color.withAlpha(_ribbonAlpha), + ); + } + + void _drawNodes( + Canvas canvas, + double x, + List nodes, + double scale, + ) { + double cursor = 0.0; + for (final SankeyDatum node in nodes) { + final double nodeHeight = node.value * scale; + final RRect rect = RRect.fromRectAndRadius( + Rect.fromLTWH(x, cursor, _nodeWidth, nodeHeight), + const Radius.circular(3.0), + ); + canvas.drawRRect(rect, Paint()..color = node.color); + cursor += nodeHeight + _gap; + } + } + + void _drawHub(Canvas canvas, double x, double height) { + final RRect rect = RRect.fromRectAndRadius( + Rect.fromLTWH(x, 0.0, _nodeWidth, height), + const Radius.circular(3.0), + ); + canvas.drawRRect(rect, Paint()..color = hubColor); + } + + double _sum(List data) => + data.fold(0.0, (sum, datum) => sum + datum.value); + + @override + bool shouldRepaint(covariant _SankeyPainter oldDelegate) { + return oldDelegate.sources != sources || + oldDelegate.targets != targets || + oldDelegate.hubColor != hubColor; + } +} diff --git a/lib/widgets/debug/analytics/spending_heatmap.dart b/lib/widgets/debug/analytics/spending_heatmap.dart new file mode 100644 index 00000000..0837f8ae --- /dev/null +++ b/lib/widgets/debug/analytics/spending_heatmap.dart @@ -0,0 +1,301 @@ +import "package:flow/data/money.dart"; +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// A GitHub-style calendar heatmap of daily spend intensity. +/// +/// Buckets non-zero days into quartiles so one outlier day doesn't wash out +/// the rest. Every cell carries a [Semantics] label and a [Tooltip] so the +/// value is reachable without relying on color alone. +class SpendingHeatmap extends StatelessWidget { + /// Date (at day resolution) -> summed expense magnitude in [currency]. + final Map dailyExpense; + + final DateTime from; + final DateTime to; + final String currency; + + final double cellSize; + final double gap; + + const SpendingHeatmap({ + super.key, + required this.dailyExpense, + required this.from, + required this.to, + required this.currency, + this.cellSize = 15.0, + this.gap = 4.0, + }); + + /// Fixed height reserved for the month-label row, shared between the grid + /// and the weekday rail so the two stay vertically aligned regardless of + /// font metrics or text scaling. + static const double _headerHeight = 16.0; + static const double _headerGap = 2.0; + + @override + Widget build(BuildContext context) { + final List sorted = + dailyExpense.values.where((value) => value > 0).toList()..sort(); + + final List weeks = _weekStarts(); + final double columnWidth = cellSize + gap; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _WeekdayLabels( + cellSize: cellSize, + gap: gap, + topOffset: _headerHeight + _headerGap, + ), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: _headerHeight, + child: _MonthLabels( + weeks: weeks, + columnWidth: columnWidth, + ), + ), + const SizedBox(height: _headerGap), + Row( + children: weeks + .map( + (monday) => Padding( + padding: EdgeInsets.only(right: gap), + child: _WeekColumn( + monday: monday, + dailyExpense: dailyExpense, + thresholds: sorted, + from: from, + to: to, + currency: currency, + cellSize: cellSize, + gap: gap, + ), + ), + ) + .toList(), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 12.0), + _Legend(), + ], + ); + } + + List _weekStarts() { + final List weeks = []; + DateTime cursor = _mondayOf(from); + final DateTime last = _mondayOf(to); + while (!cursor.isAfter(last)) { + weeks.add(cursor); + cursor = cursor.add(const Duration(days: 7)); + } + return weeks; + } +} + +DateTime _mondayOf(DateTime date) { + final DateTime day = DateTime(date.year, date.month, date.day); + return day.subtract(Duration(days: day.weekday - 1)); +} + +/// Quartile bucket (0 == none, 1..4 increasing) for [value]. +int _levelFor(double value, List sortedNonZero) { + if (value <= 0 || sortedNonZero.isEmpty) return 0; + + double quantile(double p) { + final int index = (p * (sortedNonZero.length - 1)).floor(); + return sortedNonZero[index]; + } + + if (value <= quantile(0.25)) return 1; + if (value <= quantile(0.5)) return 2; + if (value <= quantile(0.75)) return 3; + return 4; +} + +Color _levelColor(BuildContext context, int level) { + final Color primary = context.colorScheme.primary; + return switch (level) { + 0 => context.colorScheme.onSurface.withAlpha(0x14), + 1 => primary.withAlpha(0x45), + 2 => primary.withAlpha(0x80), + 3 => primary.withAlpha(0xc0), + _ => primary, + }; +} + +class _WeekColumn extends StatelessWidget { + final DateTime monday; + final Map dailyExpense; + final List thresholds; + final DateTime from; + final DateTime to; + final String currency; + final double cellSize; + final double gap; + + const _WeekColumn({ + required this.monday, + required this.dailyExpense, + required this.thresholds, + required this.from, + required this.to, + required this.currency, + required this.cellSize, + required this.gap, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: List.generate(7, (index) { + final DateTime day = monday.add(Duration(days: index)); + final bool outOfRange = day.isBefore(_dayOnly(from)) || day.isAfter(to); + + if (outOfRange) { + return Padding( + padding: EdgeInsets.only(bottom: gap), + child: SizedBox.square(dimension: cellSize), + ); + } + + final double value = dailyExpense[_dayOnly(day)] ?? 0.0; + final int level = _levelFor(value, thresholds); + final String label = + "${day.toMoment().format("dddd, MMM D")}: " + "${Money(value, currency).formatted}"; + + return Padding( + padding: EdgeInsets.only(bottom: gap), + child: Tooltip( + message: label, + child: Semantics( + label: label, + button: false, + child: Container( + width: cellSize, + height: cellSize, + decoration: BoxDecoration( + color: _levelColor(context, level), + borderRadius: const BorderRadius.all(Radius.circular(3.0)), + ), + ), + ), + ), + ); + }), + ); + } + + DateTime _dayOnly(DateTime date) => DateTime(date.year, date.month, date.day); +} + +class _WeekdayLabels extends StatelessWidget { + final double cellSize; + final double gap; + final double topOffset; + + const _WeekdayLabels({ + required this.cellSize, + required this.gap, + required this.topOffset, + }); + + @override + Widget build(BuildContext context) { + // Only label Mon / Wed / Fri to keep the rail uncluttered. + const Map labels = {0: "M", 2: "W", 4: "F"}; + final TextStyle? style = context.textTheme.labelSmall?.semi(context); + + return Padding( + padding: EdgeInsets.only(right: gap, top: topOffset), + child: Column( + children: List.generate(7, (index) { + return Container( + height: cellSize, + margin: EdgeInsets.only(bottom: gap), + alignment: Alignment.centerRight, + child: Text(labels[index] ?? "", style: style), + ); + }), + ), + ); + } +} + +class _MonthLabels extends StatelessWidget { + final List weeks; + final double columnWidth; + + const _MonthLabels({required this.weeks, required this.columnWidth}); + + @override + Widget build(BuildContext context) { + final TextStyle? style = context.textTheme.labelSmall?.semi(context); + + return Row( + children: weeks.asMap().entries.map((entry) { + final int index = entry.key; + final DateTime monday = entry.value; + final bool newMonth = + index == 0 || weeks[index - 1].month != monday.month; + + return SizedBox( + width: columnWidth, + child: newMonth + ? Text(monday.toMoment().format("MMM"), style: style) + : const SizedBox.shrink(), + ); + }).toList(), + ); + } +} + +class _Legend extends StatelessWidget { + const _Legend(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text("Less", style: context.textTheme.labelSmall?.semi(context)), + const SizedBox(width: 6.0), + ...List.generate(5, (level) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.0), + child: Container( + width: 12.0, + height: 12.0, + decoration: BoxDecoration( + color: _levelColor(context, level), + borderRadius: const BorderRadius.all(Radius.circular(3.0)), + ), + ), + ); + }), + const SizedBox(width: 6.0), + Text("More", style: context.textTheme.labelSmall?.semi(context)), + ], + ); + } +} diff --git a/lib/widgets/debug/analytics/weekday_bars.dart b/lib/widgets/debug/analytics/weekday_bars.dart new file mode 100644 index 00000000..18c14426 --- /dev/null +++ b/lib/widgets/debug/analytics/weekday_bars.dart @@ -0,0 +1,78 @@ +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +/// A small 7-bar weekday spend strip (Mon .. Sun), highlighting [topWeekday]. +/// +/// Shared by the analytics-lab pages that surface a weekday rhythm. Expects +/// [byWeekday] keyed by `DateTime.weekday` (1 = Monday .. 7 = Sunday). +class WeekdayBars extends StatelessWidget { + final Map byWeekday; + final int topWeekday; + final Color accent; + + const WeekdayBars({ + super.key, + required this.byWeekday, + required this.topWeekday, + required this.accent, + }); + + @override + Widget build(BuildContext context) { + final double max = byWeekday.values.isEmpty + ? 0.0 + : byWeekday.values.reduce((a, b) => a > b ? a : b); + final Color base = context.colorScheme.onSurface.withAlpha(0x33); + + const List labels = ["M", "T", "W", "T", "F", "S", "S"]; + + return SizedBox( + height: 56.0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(7, (index) { + final int weekday = index + 1; + final double value = byWeekday[weekday] ?? 0.0; + final double factor = max <= 0 ? 0.0 : value / max; + final bool isTop = weekday == topWeekday; + + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: FractionallySizedBox( + heightFactor: factor.clamp(0.04, 1.0), + child: Container( + decoration: BoxDecoration( + color: isTop ? accent : base, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4.0), + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 4.0), + Text( + labels[index], + style: context.textTheme.labelSmall?.copyWith( + color: isTop + ? accent + : context.colorScheme.onSecondary.withAlpha(0x80), + ), + ), + ], + ), + ); + }), + ), + ); + } +} diff --git a/lib/widgets/default_transaction_filter_head.dart b/lib/widgets/default_transaction_filter_head.dart index 6e150109..b7915728 100644 --- a/lib/widgets/default_transaction_filter_head.dart +++ b/lib/widgets/default_transaction_filter_head.dart @@ -32,7 +32,7 @@ import "package:flow/widgets/transaction_filter_head/transaction_filter_chip.dar import "package:flow/widgets/transaction_filter_head/transaction_search_sheet.dart"; import "package:flutter/material.dart"; import "package:flutter/scheduler.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class DefaultTransactionsFilterHead extends StatefulWidget { final TransactionFilter current; diff --git a/lib/widgets/delete_button.dart b/lib/widgets/delete_button.dart index 5bcf2ca6..1c02eba9 100644 --- a/lib/widgets/delete_button.dart +++ b/lib/widgets/delete_button.dart @@ -1,7 +1,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/theme/theme.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class DeleteButton extends StatelessWidget { final Widget? label; diff --git a/lib/widgets/export/export_history/backup_entry_card.dart b/lib/widgets/export/export_history/backup_entry_card.dart index a3c6d78e..68b867a8 100644 --- a/lib/widgets/export/export_history/backup_entry_card.dart +++ b/lib/widgets/export/export_history/backup_entry_card.dart @@ -14,7 +14,7 @@ import "package:flow/widgets/general/directional_slidable.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class BackupEntryCard extends StatefulWidget { diff --git a/lib/widgets/export/export_success.dart b/lib/widgets/export/export_success.dart index 74d4ea7f..04928b30 100644 --- a/lib/widgets/export/export_success.dart +++ b/lib/widgets/export/export_success.dart @@ -13,7 +13,7 @@ import "package:flutter/gestures.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; final Logger _log = Logger("ExportSuccess"); diff --git a/lib/widgets/file_attachment_add_list_tile.dart b/lib/widgets/file_attachment_add_list_tile.dart index e3ebb1d8..28299f0c 100644 --- a/lib/widgets/file_attachment_add_list_tile.dart +++ b/lib/widgets/file_attachment_add_list_tile.dart @@ -2,7 +2,7 @@ import "package:flow/data/flow_icon.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class FileAttachmentAddListTile extends StatelessWidget { final VoidCallback onTap; diff --git a/lib/widgets/file_attachment_list_tile.dart b/lib/widgets/file_attachment_list_tile.dart index cbcfecc3..7a45a8ee 100644 --- a/lib/widgets/file_attachment_list_tile.dart +++ b/lib/widgets/file_attachment_list_tile.dart @@ -12,7 +12,7 @@ import "package:flow/widgets/general/directional_slidable.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:open_app_file/open_app_file.dart"; diff --git a/lib/widgets/general/directional_chevron.dart b/lib/widgets/general/directional_chevron.dart index b408bc8c..46b224a1 100644 --- a/lib/widgets/general/directional_chevron.dart +++ b/lib/widgets/general/directional_chevron.dart @@ -1,5 +1,5 @@ import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class LeChevron extends StatelessWidget { const LeChevron({super.key}); diff --git a/lib/widgets/general/flow_icon.dart b/lib/widgets/general/flow_icon.dart index f8d71378..254e1b58 100644 --- a/lib/widgets/general/flow_icon.dart +++ b/lib/widgets/general/flow_icon.dart @@ -6,7 +6,7 @@ import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/surface.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:path/path.dart"; class FlowIcon extends StatelessWidget { diff --git a/lib/widgets/general/form_close_button.dart b/lib/widgets/general/form_close_button.dart index 04cfba37..85f34599 100644 --- a/lib/widgets/general/form_close_button.dart +++ b/lib/widgets/general/form_close_button.dart @@ -2,7 +2,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/utils/utils.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// A simple [IconButton] that pops the current route if [canPop] is true. /// diff --git a/lib/widgets/general/info_text.dart b/lib/widgets/general/info_text.dart index a95dffdc..6995d389 100644 --- a/lib/widgets/general/info_text.dart +++ b/lib/widgets/general/info_text.dart @@ -1,6 +1,6 @@ import "package:flow/theme/theme.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class InfoText extends StatelessWidget { final Widget child; diff --git a/lib/widgets/general/pending_transactions_header.dart b/lib/widgets/general/pending_transactions_header.dart index 629dfe49..fa4d55ca 100644 --- a/lib/widgets/general/pending_transactions_header.dart +++ b/lib/widgets/general/pending_transactions_header.dart @@ -5,7 +5,7 @@ import "package:flow/widgets/general/rtl_flipper.dart"; import "package:flow/widgets/transactions_date_header.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class PendingTransactionsHeader extends StatelessWidget { diff --git a/lib/widgets/general/profile_picture.dart b/lib/widgets/general/profile_picture.dart index 2c22492b..ee41e284 100644 --- a/lib/widgets/general/profile_picture.dart +++ b/lib/widgets/general/profile_picture.dart @@ -5,7 +5,7 @@ import "package:flow/objectbox.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:path/path.dart" as path; class ProfilePicture extends StatefulWidget { diff --git a/lib/widgets/geo_permission_missing_reminder.dart b/lib/widgets/geo_permission_missing_reminder.dart index b1094879..d5def903 100644 --- a/lib/widgets/geo_permission_missing_reminder.dart +++ b/lib/widgets/geo_permission_missing_reminder.dart @@ -4,7 +4,7 @@ import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:permission_handler/permission_handler.dart"; final Logger _log = Logger("GeoPermissionMissingReminder"); diff --git a/lib/widgets/home/home/account/no_accounts.dart b/lib/widgets/home/home/account/no_accounts.dart index 18bf8cf9..0eac5c5b 100644 --- a/lib/widgets/home/home/account/no_accounts.dart +++ b/lib/widgets/home/home/account/no_accounts.dart @@ -4,7 +4,7 @@ import "package:flow/widgets/general/button.dart"; import "package:flow/widgets/general/empty_state.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class NoAccounts extends StatelessWidget { const NoAccounts({super.key}); diff --git a/lib/widgets/home/home/no_transactions.dart b/lib/widgets/home/home/no_transactions.dart index b7137a89..188c99e8 100644 --- a/lib/widgets/home/home/no_transactions.dart +++ b/lib/widgets/home/home/no_transactions.dart @@ -3,7 +3,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/empty_state.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class NoTransactions extends StatelessWidget { final bool isFilterModified; diff --git a/lib/widgets/home/navbar.dart b/lib/widgets/home/navbar.dart index 7b006d00..d0481b48 100644 --- a/lib/widgets/home/navbar.dart +++ b/lib/widgets/home/navbar.dart @@ -2,7 +2,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/theme/navbar_theme.dart"; import "package:flow/widgets/home/navbar/navbar_button.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class Navbar extends StatelessWidget { final Function(int i) onTap; diff --git a/lib/widgets/home/navbar/navbar_button.dart b/lib/widgets/home/navbar/navbar_button.dart index 73095f50..8e5b3c77 100644 --- a/lib/widgets/home/navbar/navbar_button.dart +++ b/lib/widgets/home/navbar/navbar_button.dart @@ -1,6 +1,6 @@ import "package:flow/theme/navbar_theme.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class NavbarButton extends StatelessWidget { final String tooltip; diff --git a/lib/widgets/home/navbar/new_transaction_button.dart b/lib/widgets/home/navbar/new_transaction_button.dart index 0815447e..775aa296 100644 --- a/lib/widgets/home/navbar/new_transaction_button.dart +++ b/lib/widgets/home/navbar/new_transaction_button.dart @@ -9,7 +9,7 @@ import "package:flow/theme/navbar_theme.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/directionality.dart"; import "package:flutter/material.dart" hide Flow; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:pie_menu/pie_menu.dart"; class NewTransactionButton extends StatefulWidget { diff --git a/lib/widgets/home/preferences/transfer_preferences/demo_transaction_list_tile.dart b/lib/widgets/home/preferences/transfer_preferences/demo_transaction_list_tile.dart index 3fcbbc32..c6c09489 100644 --- a/lib/widgets/home/preferences/transfer_preferences/demo_transaction_list_tile.dart +++ b/lib/widgets/home/preferences/transfer_preferences/demo_transaction_list_tile.dart @@ -3,7 +3,7 @@ import "package:flow/entity/transaction.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class DemoTransactionListTile extends StatelessWidget { final TransactionType type; diff --git a/lib/widgets/home/privacy_toggler.dart b/lib/widgets/home/privacy_toggler.dart index 259e016e..63cfa796 100644 --- a/lib/widgets/home/privacy_toggler.dart +++ b/lib/widgets/home/privacy_toggler.dart @@ -1,6 +1,6 @@ import "package:flow/prefs/transitive.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class PrivacyToggler extends StatelessWidget { const PrivacyToggler({super.key}); diff --git a/lib/widgets/home/stats/group_list_tile.dart b/lib/widgets/home/stats/group_list_tile.dart index d37d527f..b9962498 100644 --- a/lib/widgets/home/stats/group_list_tile.dart +++ b/lib/widgets/home/stats/group_list_tile.dart @@ -8,7 +8,7 @@ import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class GroupListTile extends StatelessWidget { final ChartData chartData; diff --git a/lib/widgets/home/stats/most_spending_category.dart b/lib/widgets/home/stats/most_spending_category.dart index a158b585..6f376c1c 100644 --- a/lib/widgets/home/stats/most_spending_category.dart +++ b/lib/widgets/home/stats/most_spending_category.dart @@ -16,7 +16,7 @@ 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:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class MostSpendingCategory extends StatefulWidget { diff --git a/lib/widgets/home/stats/no_data.dart b/lib/widgets/home/stats/no_data.dart index 91ecccde..32e1fd78 100644 --- a/lib/widgets/home/stats/no_data.dart +++ b/lib/widgets/home/stats/no_data.dart @@ -3,7 +3,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/widgets/general/button.dart"; import "package:flow/widgets/general/empty_state.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class NoData extends StatelessWidget { final VoidCallback? selectTimeRange; diff --git a/lib/widgets/icloud_failed_error_box.dart b/lib/widgets/icloud_failed_error_box.dart index 12cc4353..5ba07fad 100644 --- a/lib/widgets/icloud_failed_error_box.dart +++ b/lib/widgets/icloud_failed_error_box.dart @@ -3,7 +3,7 @@ import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/modal_sheet.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class ICloudFailedErrorBox extends StatefulWidget { const ICloudFailedErrorBox({super.key}); diff --git a/lib/widgets/image_drop_zone.dart b/lib/widgets/image_drop_zone.dart index ec6c825d..d8791b64 100644 --- a/lib/widgets/image_drop_zone.dart +++ b/lib/widgets/image_drop_zone.dart @@ -6,7 +6,7 @@ import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/surface.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class ImageDropZone extends StatefulWidget { final Function(XFile? file)? onFileDropped; diff --git a/lib/widgets/import/file_select_area.dart b/lib/widgets/import/file_select_area.dart index fa8ee6be..916e3fec 100644 --- a/lib/widgets/import/file_select_area.dart +++ b/lib/widgets/import/file_select_area.dart @@ -7,7 +7,7 @@ import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/surface.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class FileSelectArea extends StatefulWidget { final Function(XFile? file)? onFileDropped; diff --git a/lib/widgets/import_wizard/csv/account_currency_list_tile.dart b/lib/widgets/import_wizard/csv/account_currency_list_tile.dart index cdb06503..3a50cc70 100644 --- a/lib/widgets/import_wizard/csv/account_currency_list_tile.dart +++ b/lib/widgets/import_wizard/csv/account_currency_list_tile.dart @@ -1,6 +1,6 @@ import "package:flow/theme/helpers.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class AccountCurrencyListTile extends StatelessWidget { final String name; diff --git a/lib/widgets/import_wizard/csv/backup_info_csv.dart b/lib/widgets/import_wizard/csv/backup_info_csv.dart index 9b4e5cb9..8614bf59 100644 --- a/lib/widgets/import_wizard/csv/backup_info_csv.dart +++ b/lib/widgets/import_wizard/csv/backup_info_csv.dart @@ -12,7 +12,7 @@ import "package:flow/widgets/import_wizard/import_item_list_tile.dart"; import "package:flow/widgets/scaffold_actions.dart"; import "package:flow/widgets/sheets/select_currency_sheet.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class BackupInfoCSV extends StatefulWidget { final VoidCallback onClickStart; diff --git a/lib/widgets/import_wizard/import_success.dart b/lib/widgets/import_wizard/import_success.dart index 47615eed..a5a30102 100644 --- a/lib/widgets/import_wizard/import_success.dart +++ b/lib/widgets/import_wizard/import_success.dart @@ -13,7 +13,7 @@ import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/scaffold_actions.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:objectbox/objectbox.dart"; class ImportSuccess extends StatelessWidget { diff --git a/lib/widgets/import_wizard/ivy_wallet/backup_info.dart b/lib/widgets/import_wizard/ivy_wallet/backup_info.dart index cb23daa5..db12b9f7 100644 --- a/lib/widgets/import_wizard/ivy_wallet/backup_info.dart +++ b/lib/widgets/import_wizard/ivy_wallet/backup_info.dart @@ -8,7 +8,7 @@ import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/import_wizard/import_item_list_tile.dart"; import "package:flow/widgets/scaffold_actions.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class BackupInfoIvyWalletCsv extends StatefulWidget { final VoidCallback onClickStart; diff --git a/lib/widgets/import_wizard/v1/backup_info_v1.dart b/lib/widgets/import_wizard/v1/backup_info_v1.dart index 41c93cfc..2b6efe43 100644 --- a/lib/widgets/import_wizard/v1/backup_info_v1.dart +++ b/lib/widgets/import_wizard/v1/backup_info_v1.dart @@ -8,7 +8,7 @@ import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/import_wizard/import_item_list_tile.dart"; import "package:flow/widgets/scaffold_actions.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class BackupInfoV1 extends StatelessWidget { final VoidCallback onClickStart; diff --git a/lib/widgets/import_wizard/v2/backup_info_v2.dart b/lib/widgets/import_wizard/v2/backup_info_v2.dart index f808cd15..fd4229a9 100644 --- a/lib/widgets/import_wizard/v2/backup_info_v2.dart +++ b/lib/widgets/import_wizard/v2/backup_info_v2.dart @@ -8,7 +8,7 @@ import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/import_wizard/import_item_list_tile.dart"; import "package:flow/widgets/scaffold_actions.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class BackupInfoV2 extends StatelessWidget { final VoidCallback onClickStart; diff --git a/lib/widgets/integrations/eny_page/eny_privacy_notice.dart b/lib/widgets/integrations/eny_page/eny_privacy_notice.dart index 567b5120..f74957c1 100644 --- a/lib/widgets/integrations/eny_page/eny_privacy_notice.dart +++ b/lib/widgets/integrations/eny_page/eny_privacy_notice.dart @@ -7,7 +7,7 @@ import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flutter/gestures.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class EnyPrivacyNotice extends StatelessWidget { const EnyPrivacyNotice({super.key}); diff --git a/lib/widgets/internal_notifications/auto_backup_reminder.dart b/lib/widgets/internal_notifications/auto_backup_reminder.dart index 9e1bbd48..65d91213 100644 --- a/lib/widgets/internal_notifications/auto_backup_reminder.dart +++ b/lib/widgets/internal_notifications/auto_backup_reminder.dart @@ -5,7 +5,7 @@ import "package:flow/utils/utils.dart"; import "package:flow/widgets/internal_notifications/internal_notification_list_tile.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; final Logger _log = Logger("AutoBackupReminder"); diff --git a/lib/widgets/internal_notifications/internal_notification_list_tile.dart b/lib/widgets/internal_notifications/internal_notification_list_tile.dart index 4fba8623..1985bdc4 100644 --- a/lib/widgets/internal_notifications/internal_notification_list_tile.dart +++ b/lib/widgets/internal_notifications/internal_notification_list_tile.dart @@ -4,7 +4,7 @@ import "package:flow/widgets/general/directional_slidable.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class ActionableNotificationListTile extends StatelessWidget { final FlowIconData icon; diff --git a/lib/widgets/internal_notifications/star_on_github_notification.dart b/lib/widgets/internal_notifications/star_on_github_notification.dart index 94d83dc3..d500f572 100644 --- a/lib/widgets/internal_notifications/star_on_github_notification.dart +++ b/lib/widgets/internal_notifications/star_on_github_notification.dart @@ -4,7 +4,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/utils/utils.dart"; import "package:flow/widgets/internal_notifications/internal_notification_list_tile.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/material_symbols_icons.dart"; +import "package:material_symbols_icons_flow/material_symbols_icons.dart"; class StarOnGithubNotification extends StatelessWidget { final StarOnGitHub notification; diff --git a/lib/widgets/internal_notifications/turn_on_icloud_sync_reminder.dart b/lib/widgets/internal_notifications/turn_on_icloud_sync_reminder.dart index 3ced5e04..48b010d6 100644 --- a/lib/widgets/internal_notifications/turn_on_icloud_sync_reminder.dart +++ b/lib/widgets/internal_notifications/turn_on_icloud_sync_reminder.dart @@ -3,7 +3,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/widgets/internal_notifications/internal_notification_list_tile.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class TurnOnICloudSyncNotification extends StatelessWidget { final TurnOnICloudNotification notification; diff --git a/lib/widgets/location_picker_sheet.dart b/lib/widgets/location_picker_sheet.dart index d5f1b9f7..5a895a68 100644 --- a/lib/widgets/location_picker_sheet.dart +++ b/lib/widgets/location_picker_sheet.dart @@ -12,7 +12,7 @@ import "package:geolocator/geolocator.dart"; import "package:go_router/go_router.dart"; import "package:latlong2/latlong.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; final Logger _log = Logger("LocationPickerSheet"); diff --git a/lib/widgets/notifications_permission_missing_reminder.dart b/lib/widgets/notifications_permission_missing_reminder.dart index fd99aaf9..7517487a 100644 --- a/lib/widgets/notifications_permission_missing_reminder.dart +++ b/lib/widgets/notifications_permission_missing_reminder.dart @@ -4,7 +4,7 @@ import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:permission_handler/permission_handler.dart"; final Logger _log = Logger("NotificationPermissionMissingReminder"); diff --git a/lib/widgets/rates_missing_error_box.dart b/lib/widgets/rates_missing_error_box.dart index d9c21696..a09b0060 100644 --- a/lib/widgets/rates_missing_error_box.dart +++ b/lib/widgets/rates_missing_error_box.dart @@ -6,7 +6,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; class RatesMissingErrorBox extends StatefulWidget { const RatesMissingErrorBox({super.key}); diff --git a/lib/widgets/schdeuled_notification_permission_missing_reminder.dart b/lib/widgets/schdeuled_notification_permission_missing_reminder.dart index f62804a0..181b447d 100644 --- a/lib/widgets/schdeuled_notification_permission_missing_reminder.dart +++ b/lib/widgets/schdeuled_notification_permission_missing_reminder.dart @@ -8,7 +8,7 @@ import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/schdeuled_notification_permission_builder.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:permission_handler/permission_handler.dart"; final Logger _log = Logger("SchdeuledNotificationPermissionMissingReminder"); diff --git a/lib/widgets/select_bulk_transactions_action_sheet.dart b/lib/widgets/select_bulk_transactions_action_sheet.dart index 37743595..41e93c2f 100644 --- a/lib/widgets/select_bulk_transactions_action_sheet.dart +++ b/lib/widgets/select_bulk_transactions_action_sheet.dart @@ -4,7 +4,7 @@ import "package:flow/widgets/general/modal_sheet.dart"; import "package:flow/widgets/transactions_selection_controller.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; enum TransactionsBulkAction { confirmAll, diff --git a/lib/widgets/select_color_scheme_list_tile.dart b/lib/widgets/select_color_scheme_list_tile.dart index 79b0e066..d121ce4e 100644 --- a/lib/widgets/select_color_scheme_list_tile.dart +++ b/lib/widgets/select_color_scheme_list_tile.dart @@ -9,7 +9,7 @@ import "package:flow/utils/optional.dart"; import "package:flow/widgets/general/directional_chevron.dart"; import "package:flow/widgets/sheets/select_color_scheme_sheet.dart"; import "package:flutter/material.dart" hide Flow; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class SelectColorSchemeListTile extends StatefulWidget { final bool inferLeading; diff --git a/lib/widgets/setup/accounts/account_preset_card.dart b/lib/widgets/setup/accounts/account_preset_card.dart index 68a60fca..ee86b70f 100644 --- a/lib/widgets/setup/accounts/account_preset_card.dart +++ b/lib/widgets/setup/accounts/account_preset_card.dart @@ -3,7 +3,7 @@ import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/surface.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class AccountPresetCard extends StatelessWidget { final Function(bool)? onSelect; diff --git a/lib/widgets/setup/accounts/add_account_card.dart b/lib/widgets/setup/accounts/add_account_card.dart index 917e288f..e555bf2a 100644 --- a/lib/widgets/setup/accounts/add_account_card.dart +++ b/lib/widgets/setup/accounts/add_account_card.dart @@ -5,7 +5,7 @@ import "package:flow/widgets/general/flow_icon.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:material_symbols_icons_flow/symbols.dart"; class AddAccountCard extends StatelessWidget { final BorderRadius borderRadius; diff --git a/lib/widgets/setup/categories/category_preset_card.dart b/lib/widgets/setup/categories/category_preset_card.dart index d6e4402d..72507a5d 100644 --- a/lib/widgets/setup/categories/category_preset_card.dart +++ b/lib/widgets/setup/categories/category_preset_card.dart @@ -2,7 +2,7 @@ import "package:flow/entity/category.dart"; import "package:flow/utils/optional.dart"; import "package:flow/widgets/category_card.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class CategoryPresetCard extends StatelessWidget { final Function(bool) onSelect; diff --git a/lib/widgets/setup/foss_slide.dart b/lib/widgets/setup/foss_slide.dart index 5119a5ea..bfddfef2 100644 --- a/lib/widgets/setup/foss_slide.dart +++ b/lib/widgets/setup/foss_slide.dart @@ -6,8 +6,8 @@ import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/gestures.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; -import "package:simple_icons/simple_icons.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; class FossSlide extends StatelessWidget { const FossSlide({super.key}); diff --git a/lib/widgets/setup/privacy_slide.dart b/lib/widgets/setup/privacy_slide.dart index 82948f38..f20eaa34 100644 --- a/lib/widgets/setup/privacy_slide.dart +++ b/lib/widgets/setup/privacy_slide.dart @@ -3,7 +3,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class PrivacySlide extends StatelessWidget { const PrivacySlide({super.key}); diff --git a/lib/widgets/sheets/select_account_sheet.dart b/lib/widgets/sheets/select_account_sheet.dart index fbeb90b6..d8b83623 100644 --- a/lib/widgets/sheets/select_account_sheet.dart +++ b/lib/widgets/sheets/select_account_sheet.dart @@ -10,7 +10,7 @@ import "package:flow/widgets/general/modal_sheet.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with [Account] class SelectAccountSheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_category_sheet.dart b/lib/widgets/sheets/select_category_sheet.dart index 978e9a7d..e919ef55 100644 --- a/lib/widgets/sheets/select_category_sheet.dart +++ b/lib/widgets/sheets/select_category_sheet.dart @@ -11,7 +11,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with [ValueOr] class SelectCategorySheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_color_scheme_sheet.dart b/lib/widgets/sheets/select_color_scheme_sheet.dart index c9cdfb87..8f0e43f7 100644 --- a/lib/widgets/sheets/select_color_scheme_sheet.dart +++ b/lib/widgets/sheets/select_color_scheme_sheet.dart @@ -7,7 +7,7 @@ import "package:flow/widgets/general/modal_sheet.dart"; import "package:flow/widgets/theme_petal_selector.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with a [Optional]. class SelectColorSchemeSheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_contact_sheet.dart b/lib/widgets/sheets/select_contact_sheet.dart index 2b0c6492..207f38da 100644 --- a/lib/widgets/sheets/select_contact_sheet.dart +++ b/lib/widgets/sheets/select_contact_sheet.dart @@ -12,7 +12,7 @@ import "package:flutter_contacts/flutter_contacts.dart"; import "package:fuzzywuzzy/fuzzywuzzy.dart"; import "package:go_router/go_router.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; final Logger _log = Logger("SelectContactSheet"); diff --git a/lib/widgets/sheets/select_contact_sheet/no_contacts.dart b/lib/widgets/sheets/select_contact_sheet/no_contacts.dart index be77a826..1add0430 100644 --- a/lib/widgets/sheets/select_contact_sheet/no_contacts.dart +++ b/lib/widgets/sheets/select_contact_sheet/no_contacts.dart @@ -2,7 +2,7 @@ import "package:flow/data/flow_icon.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/widgets/general/empty_state.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:permission_handler/permission_handler.dart"; class NoContacts extends StatelessWidget { diff --git a/lib/widgets/sheets/select_currency_icu_pattern.dart b/lib/widgets/sheets/select_currency_icu_pattern.dart index 05aeaa4b..9614d7b4 100644 --- a/lib/widgets/sheets/select_currency_icu_pattern.dart +++ b/lib/widgets/sheets/select_currency_icu_pattern.dart @@ -8,7 +8,7 @@ import "package:flow/widgets/general/modal_sheet.dart"; import "package:flow/widgets/general/wavy_divider.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with a [Optional] ICU pattern number formatter, or [Optional] null /// from a pre-defined list of patterns. diff --git a/lib/widgets/sheets/select_currency_sheet.dart b/lib/widgets/sheets/select_currency_sheet.dart index 823a46e4..4c2a08fc 100644 --- a/lib/widgets/sheets/select_currency_sheet.dart +++ b/lib/widgets/sheets/select_currency_sheet.dart @@ -7,7 +7,7 @@ import "package:flow/widgets/general/modal_sheet.dart"; import "package:flutter/material.dart"; import "package:fuzzywuzzy/fuzzywuzzy.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with a valid [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code [String] class SelectCurrencySheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_file_attachment_sheet.dart b/lib/widgets/sheets/select_file_attachment_sheet.dart index 68584b8a..0264c322 100644 --- a/lib/widgets/sheets/select_file_attachment_sheet.dart +++ b/lib/widgets/sheets/select_file_attachment_sheet.dart @@ -7,7 +7,7 @@ import "package:flutter/material.dart"; import "package:flutter/scheduler.dart"; import "package:go_router/go_router.dart"; import "package:image_picker/image_picker.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with a List of [XFile] class SelectFileAttachmentSheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_flow_icon_sheet.dart b/lib/widgets/sheets/select_flow_icon_sheet.dart index b1612900..7fa41431 100644 --- a/lib/widgets/sheets/select_flow_icon_sheet.dart +++ b/lib/widgets/sheets/select_flow_icon_sheet.dart @@ -7,7 +7,7 @@ import "package:flow/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon import "package:flow/widgets/sheets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with [FlowIconData] or [null] class SelectFlowIconSheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart b/lib/widgets/sheets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart index 641c2cfb..52cab6c5 100644 --- a/lib/widgets/sheets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart +++ b/lib/widgets/sheets/select_flow_icon_sheet/select_char_flow_icon_sheet.dart @@ -6,7 +6,7 @@ import "package:flow/widgets/general/modal_sheet.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:material_symbols_icons_flow/symbols.dart"; class SelectCharFlowIconSheet extends StatefulWidget { final FlowIconData? initialValue; diff --git a/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart b/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart index 6592062a..cb27ab6d 100644 --- a/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart +++ b/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart @@ -5,7 +5,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with [IconFlowIcon] or [null] class SelectIconFlowIconSheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart b/lib/widgets/sheets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart index b46b5d5d..3574e4ef 100644 --- a/lib/widgets/sheets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart +++ b/lib/widgets/sheets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart @@ -14,7 +14,7 @@ import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:go_router/go_router.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:pasteboard/pasteboard.dart"; import "package:path/path.dart" as path; diff --git a/lib/widgets/sheets/select_multi_currency_sheet.dart b/lib/widgets/sheets/select_multi_currency_sheet.dart index f8362f10..56cd4c3b 100644 --- a/lib/widgets/sheets/select_multi_currency_sheet.dart +++ b/lib/widgets/sheets/select_multi_currency_sheet.dart @@ -7,7 +7,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with a list of valid [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code [List] class SelectMultiCurrencySheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_multi_transaction_type_sheet.dart b/lib/widgets/sheets/select_multi_transaction_type_sheet.dart index 9203ab4c..150cfd65 100644 --- a/lib/widgets/sheets/select_multi_transaction_type_sheet.dart +++ b/lib/widgets/sheets/select_multi_transaction_type_sheet.dart @@ -5,7 +5,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with a list of selected [TransactionType]s. class SelectMultiTransactionTypeSheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_time_range_mode_sheet.dart b/lib/widgets/sheets/select_time_range_mode_sheet.dart index 01f72ee3..35108aa9 100644 --- a/lib/widgets/sheets/select_time_range_mode_sheet.dart +++ b/lib/widgets/sheets/select_time_range_mode_sheet.dart @@ -5,7 +5,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; enum TimeRangeMode { diff --git a/lib/widgets/sheets/select_transaction_tags_sheet.dart b/lib/widgets/sheets/select_transaction_tags_sheet.dart index f2dfdc6a..44378e4c 100644 --- a/lib/widgets/sheets/select_transaction_tags_sheet.dart +++ b/lib/widgets/sheets/select_transaction_tags_sheet.dart @@ -8,7 +8,7 @@ import "package:flow/widgets/transaction_tag_add_chip.dart"; import "package:flow/widgets/transaction_tag_chip.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with a [List] class SelectTransactionTagsSheet extends StatefulWidget { diff --git a/lib/widgets/sheets/select_transaction_type_sheet.dart b/lib/widgets/sheets/select_transaction_type_sheet.dart index 0ae5f0c2..8089ca8f 100644 --- a/lib/widgets/sheets/select_transaction_type_sheet.dart +++ b/lib/widgets/sheets/select_transaction_type_sheet.dart @@ -6,7 +6,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; class SelectTransactionTypeSheet extends StatelessWidget { final TransactionType? currentlySelected; diff --git a/lib/widgets/time_range_selector.dart b/lib/widgets/time_range_selector.dart index 22c4388a..c6c12020 100644 --- a/lib/widgets/time_range_selector.dart +++ b/lib/widgets/time_range_selector.dart @@ -4,7 +4,7 @@ import "package:flow/widgets/general/button.dart"; import "package:flow/utils/time_and_range.dart"; import "package:flutter/gestures.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; /// Defaults to the current month diff --git a/lib/widgets/transaction/type_selector.dart b/lib/widgets/transaction/type_selector.dart index 4c417e18..753bc585 100644 --- a/lib/widgets/transaction/type_selector.dart +++ b/lib/widgets/transaction/type_selector.dart @@ -3,7 +3,7 @@ import "package:flow/l10n/named_enum.dart"; import "package:flow/prefs/local_preferences.dart"; import "package:flow/theme/theme.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class TypeSelector extends StatelessWidget { final TransactionType current; diff --git a/lib/widgets/transaction_filter_head/create_filter_preset_sheet.dart b/lib/widgets/transaction_filter_head/create_filter_preset_sheet.dart index 374afdc0..6b43228b 100644 --- a/lib/widgets/transaction_filter_head/create_filter_preset_sheet.dart +++ b/lib/widgets/transaction_filter_head/create_filter_preset_sheet.dart @@ -9,7 +9,7 @@ import "package:flow/widgets/general/modal_sheet.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:logging/logging.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; final Logger _log = Logger("CreateFilterPresetSheet"); diff --git a/lib/widgets/transaction_filter_head/select_filter_preset_sheet.dart b/lib/widgets/transaction_filter_head/select_filter_preset_sheet.dart index 005d6a3e..ecfff392 100644 --- a/lib/widgets/transaction_filter_head/select_filter_preset_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_filter_preset_sheet.dart @@ -20,7 +20,7 @@ import "package:flow/widgets/transaction_filter_head/select_filter_preset_sheet/ import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:objectbox/objectbox.dart"; /// Pops with an [Optional] when a preset is selected. diff --git a/lib/widgets/transaction_filter_head/select_filter_preset_sheet/default_filter_preset_list_tile.dart b/lib/widgets/transaction_filter_head/select_filter_preset_sheet/default_filter_preset_list_tile.dart index 5c2a926f..2e87fdf4 100644 --- a/lib/widgets/transaction_filter_head/select_filter_preset_sheet/default_filter_preset_list_tile.dart +++ b/lib/widgets/transaction_filter_head/select_filter_preset_sheet/default_filter_preset_list_tile.dart @@ -5,7 +5,7 @@ import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/directional_slidable.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class DefaultFilterPresetListTile extends StatefulWidget { final bool selected; diff --git a/lib/widgets/transaction_filter_head/select_filter_preset_sheet/filter_preset_list_tile.dart b/lib/widgets/transaction_filter_head/select_filter_preset_sheet/filter_preset_list_tile.dart index d6be2aec..f708a201 100644 --- a/lib/widgets/transaction_filter_head/select_filter_preset_sheet/filter_preset_list_tile.dart +++ b/lib/widgets/transaction_filter_head/select_filter_preset_sheet/filter_preset_list_tile.dart @@ -5,7 +5,7 @@ import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/directional_slidable.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class FilterPresetListTile extends StatefulWidget { final TransactionFilterPreset preset; diff --git a/lib/widgets/transaction_filter_head/select_group_range_sheet.dart b/lib/widgets/transaction_filter_head/select_group_range_sheet.dart index d50f41e6..e08659c6 100644 --- a/lib/widgets/transaction_filter_head/select_group_range_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_group_range_sheet.dart @@ -6,7 +6,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with [TransactionSearchData] class SelectGroupRangeSheet extends StatefulWidget { diff --git a/lib/widgets/transaction_filter_head/select_has_attachment_sheet.dart b/lib/widgets/transaction_filter_head/select_has_attachment_sheet.dart index e35b31f7..8c82e036 100644 --- a/lib/widgets/transaction_filter_head/select_has_attachment_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_has_attachment_sheet.dart @@ -4,7 +4,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with an [Optional]\ indicating whether to filter for transactions class SelectHasAttachmentSheet extends StatefulWidget { diff --git a/lib/widgets/transaction_filter_head/select_is_pending_sheet.dart b/lib/widgets/transaction_filter_head/select_is_pending_sheet.dart index e2962faa..0f6417c7 100644 --- a/lib/widgets/transaction_filter_head/select_is_pending_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_is_pending_sheet.dart @@ -4,7 +4,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with an [Optional]\ indicating whether to filter for transactions class SelectIsPendingSheet extends StatefulWidget { diff --git a/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart b/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart index 9d07009b..66e9a93c 100644 --- a/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_multi_account_sheet.dart @@ -8,7 +8,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with an [Optional] of [List] of selected [Account]s class SelectMultiAccountSheet extends StatefulWidget { diff --git a/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart b/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart index 1d8a976b..110b5e51 100644 --- a/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart +++ b/lib/widgets/transaction_filter_head/select_multi_category_sheet.dart @@ -8,7 +8,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with an [Optional] of [List] of selected [Category]s class SelectMultiCategorySheet extends StatefulWidget { diff --git a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart index a29e91cb..3c7599b0 100644 --- a/lib/widgets/transaction_filter_head/transaction_search_sheet.dart +++ b/lib/widgets/transaction_filter_head/transaction_search_sheet.dart @@ -6,7 +6,7 @@ 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"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Pops with [TransactionSearchData] class TransactionSearchSheet extends StatefulWidget { diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index f6b6f22a..22838452 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -17,7 +17,7 @@ import "package:flow/widgets/transaction_list_tile_theme.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class TransactionListTile extends StatelessWidget { diff --git a/lib/widgets/transaction_tag_add_chip.dart b/lib/widgets/transaction_tag_add_chip.dart index f409028b..f119d04d 100644 --- a/lib/widgets/transaction_tag_add_chip.dart +++ b/lib/widgets/transaction_tag_add_chip.dart @@ -3,7 +3,7 @@ import "package:flow/entity/transaction_tag.dart"; import "package:flow/l10n/flow_localizations.dart"; import "package:flow/widgets/transaction_tag_chip.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class TransactionTagAddChip extends StatelessWidget { final String? title; diff --git a/lib/widgets/transaction_tag_chip.dart b/lib/widgets/transaction_tag_chip.dart index cd3de39b..6d8f4c6e 100644 --- a/lib/widgets/transaction_tag_chip.dart +++ b/lib/widgets/transaction_tag_chip.dart @@ -1,12 +1,10 @@ -import "dart:math"; - import "package:dashed_border/dashed_border.dart"; import "package:flow/entity/transaction_tag.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/material_symbols_icons.dart"; +import "package:material_symbols_icons_flow/material_symbols_icons.dart"; class TransactionTagChip extends StatelessWidget { final TransactionTag tag; diff --git a/lib/widgets/transactions_selection_bar.dart b/lib/widgets/transactions_selection_bar.dart index 460fb704..4aab6059 100644 --- a/lib/widgets/transactions_selection_bar.dart +++ b/lib/widgets/transactions_selection_bar.dart @@ -2,7 +2,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/theme/helpers.dart"; import "package:flow/widgets/transactions_selection_controller.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// Persistent bottom overlay shown while [controller.active]. class TransactionsSelectionBottomBar extends StatelessWidget { diff --git a/lib/widgets/trend.dart b/lib/widgets/trend.dart index b16de2f0..be3a5b6d 100644 --- a/lib/widgets/trend.dart +++ b/lib/widgets/trend.dart @@ -3,7 +3,7 @@ import "package:flow/data/prefs/change_visuals.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/theme/helpers.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; /// A widget with little up/down arrow at the end class Trend extends StatelessWidget { diff --git a/lib/widgets/year_selector_bar.dart b/lib/widgets/year_selector_bar.dart index 9d66a9d9..39bb2fcb 100644 --- a/lib/widgets/year_selector_bar.dart +++ b/lib/widgets/year_selector_bar.dart @@ -1,7 +1,7 @@ import "package:flow/widgets/general/button.dart"; import "package:flow/utils/time_and_range.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; class YearSelectorBar extends StatelessWidget { /// If specified, used instead of `DateTime.now` diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f8076115..fbfe43f0 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -16,6 +16,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index 1795bd52..df6e5507 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc" url: "https://pub.dev" source: hosted - version: "93.0.0" + version: "96.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0" url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "10.2.0" app_links: dependency: "direct main" description: name: app_links - sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" + sha256: a350a5b37579b7227aaf9a59c07114617cd4283852e193f743b2b3d2d7483c18 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.1.1" app_links_linux: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" auto_size_text: dependency: "direct main" description: @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.0.6" build_config: dependency: transitive description: @@ -133,10 +133,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462" + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" url: "https://pub.dev" source: hosted - version: "2.12.2" + version: "2.15.0" built_collection: dependency: transitive description: @@ -149,26 +149,26 @@ packages: dependency: transitive description: name: built_value - sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" url: "https://pub.dev" source: hosted - version: "8.12.4" + version: "8.12.6" camera: dependency: "direct main" description: name: camera - sha256: "46f391e9bbdaa373d15e296abc5de8bfb0dd0d0c7487592dd8f20e8ef980429f" + sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777" url: "https://pub.dev" source: hosted - version: "0.12.0" + version: "0.12.0+1" camera_android_camerax: dependency: transitive description: name: camera_android_camerax - sha256: c0be4298e3888ba6cf5c1fb1ae1203f08dcbb14d4f545ce5262f473cf8c33e28 + sha256: e20c1e92ce6797d9ae9b1db1e09a4c1039a04827d0b24985f5da3840b96948ac url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2+1" camera_avfoundation: dependency: transitive description: @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: camera_platform_interface - sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" camera_web: dependency: transitive description: @@ -253,18 +253,10 @@ packages: dependency: transitive description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "4.11.1" + version: "1.2.1" collection: dependency: transitive description: @@ -277,18 +269,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" + sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.1.1" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" convert: dependency: transitive description: @@ -389,18 +381,18 @@ packages: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.14" desktop_drop: dependency: "direct main" description: name: desktop_drop - sha256: e70b46b2d61f1af7a81a40d1f79b43c28a879e30a4ef31e87e9c27bea4d784e8 + sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.1" diff_match_patch: dependency: transitive description: @@ -517,10 +509,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" flat_buffers: dependency: transitive description: @@ -554,10 +546,10 @@ packages: dependency: "direct main" description: name: flutter_contacts - sha256: "2000fd88f216928ae47055cc5648976f431484015c2507accb2b68396ef1fa6b" + sha256: fae4ff556cded9c5f91a6f9d204f796d0fcda897a79175a39bbdf223c73682dd url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.2.1" flutter_dynamic_icon_plus: dependency: "direct main" description: @@ -664,26 +656,26 @@ packages: dependency: "direct main" description: name: flutter_map - sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" + sha256: "03b71c02806ff20c3718d108cbbb3638142ebafe368d8ce2dd22a33344bcb02b" url: "https://pub.dev" source: hosted - version: "8.2.2" + version: "8.3.0" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.33" + version: "2.0.35" flutter_quill: dependency: "direct main" description: name: flutter_quill - sha256: b96bb8525afdeaaea52f5d02f525e05cc34acd176467ab6d6f35d434cf14fde2 + sha256: "3ee7125b2dd3f3bce3ebdaac722a72f0c8aff3db9aa19053a9d777db12d71c98" url: "https://pub.dev" source: hosted - version: "11.5.0" + version: "11.5.1" flutter_quill_delta_from_html: dependency: transitive description: @@ -717,10 +709,10 @@ packages: dependency: "direct main" description: name: flutter_timezone - sha256: "978192f2f9ea6d019a4de4f0211d76a9af955ca24865828fa98ca4e20cf0cb3c" + sha256: "869677426fde92dbe170fb7d2d4929f2a8343c2f5f62f08b0bb64f908630b073" url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -818,10 +810,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896" + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" url: "https://pub.dev" source: hosted - version: "17.1.0" + version: "17.3.0" graphs: dependency: transitive description: @@ -842,26 +834,26 @@ packages: dependency: transitive description: name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" home_widget: dependency: "direct main" description: name: home_widget - sha256: d794a73894012459a4c63b94a6dc2cb3ccaa6eb08fb15b974aa7ac642594aed5 + sha256: "7a32f7d6a3afd542126fb0004acba939a41ee57d874172926212774fbce684d3" url: "https://pub.dev" source: hosted - version: "0.9.0" + version: "0.9.2" hooks: dependency: transitive description: name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "2.0.2" html: dependency: transitive description: @@ -902,38 +894,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - iconsax_flutter: - dependency: transitive - description: - name: iconsax_flutter - sha256: d14b4cec8586025ac15276bdd40f6eea308cb85748135965bb6255f14beb2564 - url: "https://pub.dev" - source: hosted - version: "1.0.1" image: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" image_picker: dependency: "direct main" description: name: image_picker - sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" url: "https://pub.dev" source: hosted - version: "0.8.13+14" + version: "0.8.13+19" image_picker_for_web: dependency: transitive description: @@ -986,10 +970,10 @@ packages: dependency: "direct main" description: name: in_app_review - sha256: ab26ac54dbd802896af78c670b265eaeab7ecddd6af4d0751e9604b60574817f + sha256: "364db0c160b37fe7fad9cdaa18f968473924b17368869010af902760421b6bf8" url: "https://pub.dev" source: hosted - version: "2.0.11" + version: "2.0.12" in_app_review_platform_interface: dependency: transitive description: @@ -1014,22 +998,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: "direct main" description: name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.12.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 url: "https://pub.dev" source: hosted - version: "6.13.0" + version: "6.14.0" latlong2: dependency: "direct main" description: @@ -1070,14 +1070,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - lists: - dependency: transitive - description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" - url: "https://pub.dev" - source: hosted - version: "1.0.1" local_auth: dependency: "direct main" description: @@ -1090,10 +1082,10 @@ packages: dependency: transitive description: name: local_auth_android - sha256: dc9663a7bc8ac33d7d988e63901974f63d527ebef260eabd19c479447cc9c911 + sha256: fdb936d59ab945c7af297defd67bd1ed87b11b6db1bc16d01e94677a8f1c38ec url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.9" local_auth_darwin: dependency: transitive description: @@ -1134,14 +1126,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - logger: - dependency: transitive - description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 - url: "https://pub.dev" - source: hosted - version: "2.6.2" logging: dependency: "direct main" description: @@ -1162,10 +1146,10 @@ packages: dependency: "direct main" description: name: lottie - sha256: "8ae0be46dbd9e19641791dc12ee480d34e1fd3f84c749adc05f3ad9342b71b95" + sha256: "8b6359a7422167014aa73ce763fa133fb832065dcc0ac4d1dec1f603a5cef7d0" url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.3.3" markdown: dependency: "direct main" description: @@ -1186,10 +1170,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1198,11 +1182,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" - material_symbols_icons: + material_symbols_icons_flow: dependency: "direct main" description: - name: material_symbols_icons - sha256: "10a74aaa9e566c92f8aa14809d2dd78156fb93743348ebffec0345c38eb35706" + name: material_symbols_icons_flow + sha256: "31f66ea83ac9f2a73ac9b04ae5f75696a71c4bc754d31172082e127fc1a9b8c2" url: "https://pub.dev" source: hosted version: "4.2928.1" @@ -1210,18 +1194,18 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.18.3" mgrs_dart: dependency: transitive description: name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" mime: dependency: transitive description: @@ -1234,18 +1218,10 @@ packages: dependency: "direct main" description: name: moment_dart - sha256: "50e11480f7ec5b9fd4d7441ab383b3c8b169989e95db4089c83835780fa4b0fc" - url: "https://pub.dev" - source: hosted - version: "5.3.2" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + sha256: "93c3cd05b69b1a5435d62bff2efc0cec19896a9a431a4a98263c8d870e5c52f0" url: "https://pub.dev" source: hosted - version: "0.17.5" + version: "5.3.4" nm: dependency: transitive description: @@ -1266,34 +1242,34 @@ packages: dependency: "direct main" description: name: objectbox - sha256: ac1e1ef852f60ea0af01fddb28811c8aa169f2b1ae9284e91e7b822abe5ed603 + sha256: f68d74bfdfa6758cebf4cd319db1f4a4386c79ba8896d19e2e999faf041bac78 url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.3.2" objectbox_flutter_libs: dependency: "direct main" description: name: objectbox_flutter_libs - sha256: "928aeabb2c246db225866cd931f2fea6c7684cc7046d54c02355a7b227ae5ba5" + sha256: "604d26fac4548693449c9ea802e007d4adf7f31e361ae3ebf362ac142295078d" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.3.2" objectbox_generator: dependency: "direct dev" description: name: objectbox_generator - sha256: "28628fa65d4fe8ade868cd50aa323ecdfd6d435fcda2cc7484071b84031f8c5e" + sha256: b5f6b9e6062635689d777ba2b3a16548991eb1e61007baf607022537e3bc0436 url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.3.2" objective_c: dependency: transitive description: name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" url: "https://pub.dev" source: hosted - version: "9.3.0" + version: "9.4.1" open_app_file: dependency: "direct main" description: @@ -1306,10 +1282,10 @@ packages: dependency: "direct dev" description: name: openai_dart - sha256: "8f9ffda6586d48b4956b55c28b949e8796c9f691b619e642966eb07bd6ed8272" + sha256: "4731f94ea9a09cd9e92333debcfb87efda2764718e506538206df9f540e9ab60" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" package_config: dependency: transitive description: @@ -1322,10 +1298,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1370,10 +1346,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.3.1" path_provider_foundation: dependency: transitive description: @@ -1426,10 +1402,10 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "12.0.3" permission_handler_android: dependency: transitive description: @@ -1442,10 +1418,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.4.9" permission_handler_html: dependency: transitive description: @@ -1482,10 +1458,10 @@ packages: dependency: "direct main" description: name: pie_menu - sha256: "62a4a27361d8feb9491af1526711222e61b0dae2ceb1180ecd965aa3a3bf2468" + sha256: "7da24ee13b51f7ab5a7f8f59bf32f47fb716bcae1009fcd8a4a941962b856926" url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "3.7.0" platform: dependency: transitive description: @@ -1530,10 +1506,10 @@ packages: dependency: transitive description: name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.0" pub_semver: dependency: transitive description: @@ -1622,6 +1598,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" recurrence: dependency: "direct main" description: @@ -1698,10 +1682,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "12.0.2" share_plus_platform_interface: dependency: transitive description: @@ -1714,18 +1698,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0 url: "https://pub.dev" source: hosted - version: "2.4.21" + version: "2.4.25" shared_preferences_foundation: dependency: transitive description: @@ -1746,10 +1730,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1798,14 +1782,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - simple_icons: + simple_icons_flow: dependency: "direct main" description: - name: simple_icons - sha256: "2ca3cd79c9f12e97a8588cae0f342609f19fd2e82315356cb09b5c4987ad0808" + name: simple_icons_flow + sha256: "929fffba2872fb1687b467f28a91d82ad4117468314f776ac799ca04eac164ea" url: "https://pub.dev" source: hosted - version: "14.6.1" + version: "16.20.0" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" sky_engine: dependency: transitive description: flutter @@ -1823,18 +1815,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.2.3" source_helper: dependency: transitive description: name: source_helper - sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" url: "https://pub.dev" source: hosted - version: "1.3.10" + version: "1.3.12" source_map_stack_trace: dependency: transitive description: @@ -1903,26 +1895,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -1935,10 +1927,10 @@ packages: dependency: "direct main" description: name: toastification - sha256: "69db2bff425b484007409650d8bcd5ed1ce2e9666293ece74dcd917dacf23112" + sha256: "66c96678e3dece8ba24de3ea31634bd65a80aaecb8105f9bafe946e5f0d7590a" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.2.0" typed_data: dependency: transitive description: @@ -1951,10 +1943,10 @@ packages: dependency: transitive description: name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "1.1.9" universal_platform: dependency: transitive description: @@ -1975,10 +1967,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.32" url_launcher_ios: dependency: transitive description: @@ -2015,10 +2007,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" url_launcher_windows: dependency: transitive description: @@ -2039,18 +2031,18 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.0" vm_service: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" watcher: dependency: transitive description: @@ -2140,5 +2132,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1a032e9e..515e8dc2 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.22.0+344" +version: "0.23.0+345" environment: sdk: ">=3.10.0 <4.0.0" @@ -57,7 +57,7 @@ dependencies: lottie: ^3.3.2 markdown: ^7.3.1 markdown_quill: ^4.3.0 - material_symbols_icons: ^4.2928.1 + material_symbols_icons_flow: ^4.2928.1 moment_dart: ^5.3.2 objectbox: ^5.1.0 objectbox_flutter_libs: ^5.1.0 @@ -73,7 +73,7 @@ dependencies: shake: ^3.0.0 share_plus: ^12.0.1 shared_preferences: ^2.5.4 - simple_icons: ^14.6.1 + simple_icons_flow: ^16.20.0 smooth_page_indicator: ^2.0.1 timezone: ^0.11.0 toastification: ^3.0.3 diff --git a/test/backup/v1_populate.dart b/test/backup/v1_populate.dart index c86fe090..a61ddc42 100644 --- a/test/backup/v1_populate.dart +++ b/test/backup/v1_populate.dart @@ -6,7 +6,7 @@ import "package:flow/entity/category.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/objectbox/objectbox.g.dart"; -import "package:material_symbols_icons/symbols.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; Future populateDummyData([int entryCount = 100]) async { diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index cbb902b8..5793690e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -22,6 +22,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES) From a0c5eac846557a7649ae61ca9d7ec829205f6d45 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sat, 6 Jun 2026 23:01:24 +0800 Subject: [PATCH 2/6] expose analytics page --- lib/routes/home/profile_tab.dart | 64 ++++++++++++++++---------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index cc0a9a80..9c4260a5 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -125,39 +125,39 @@ class _ProfileTabState extends State { leading: const Icon(Symbols.settings_rounded), onTap: () => context.push("/preferences"), ), + const SizedBox(height: 32.0), + const ListHeader("Analytics lab"), + ListTile( + title: const Text("Net worth over time"), + leading: const Icon(Symbols.trending_up_rounded), + onTap: () => context.push("/_debug/analytics/net-worth"), + ), + ListTile( + title: const Text("Monthly wrapped"), + leading: const Icon(Symbols.bar_chart_rounded), + onTap: () => context.push("/_debug/analytics/wrapped"), + ), + ListTile( + title: const Text("Subscriptions & recurring"), + leading: const Icon(Symbols.autorenew_rounded), + onTap: () => context.push("/_debug/analytics/recurring"), + ), + ListTile( + title: const Text("Spending calendar"), + leading: const Icon(Symbols.calendar_month_rounded), + onTap: () => context.push("/_debug/analytics/calendar"), + ), + ListTile( + title: const Text("Cash flow (Sankey)"), + leading: const Icon(Symbols.alt_route_rounded), + onTap: () => context.push("/_debug/analytics/cash-flow"), + ), + ListTile( + title: const Text("Spending map"), + leading: const Icon(Symbols.map_rounded), + onTap: () => context.push("/_debug/analytics/map"), + ), if (flowDebugMode) ...[ - const SizedBox(height: 32.0), - const ListHeader("Analytics lab"), - ListTile( - title: const Text("Net worth over time"), - leading: const Icon(Symbols.trending_up_rounded), - onTap: () => context.push("/_debug/analytics/net-worth"), - ), - ListTile( - title: const Text("Monthly wrapped"), - leading: const Icon(Symbols.bar_chart_rounded), - onTap: () => context.push("/_debug/analytics/wrapped"), - ), - ListTile( - title: const Text("Subscriptions & recurring"), - leading: const Icon(Symbols.autorenew_rounded), - onTap: () => context.push("/_debug/analytics/recurring"), - ), - ListTile( - title: const Text("Spending calendar"), - leading: const Icon(Symbols.calendar_month_rounded), - onTap: () => context.push("/_debug/analytics/calendar"), - ), - ListTile( - title: const Text("Cash flow (Sankey)"), - leading: const Icon(Symbols.alt_route_rounded), - onTap: () => context.push("/_debug/analytics/cash-flow"), - ), - ListTile( - title: const Text("Spending map"), - leading: const Icon(Symbols.map_rounded), - onTap: () => context.push("/_debug/analytics/map"), - ), const SizedBox(height: 32.0), const ListHeader("Debug options"), ListTile( From a303ede7e13f38391324727fc688775a1b810df5 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 7 Jun 2026 15:15:16 +0800 Subject: [PATCH 3/6] analytics overhaul test 2 --- assets/l10n/ar.json | 77 + assets/l10n/be_BY.json | 77 + assets/l10n/cs_CZ.json | 77 + assets/l10n/de_DE.json | 77 + assets/l10n/en.json | 77 + assets/l10n/es_ES.json | 77 + assets/l10n/fa_IR.json | 77 + assets/l10n/fr_FR.json | 77 + assets/l10n/it_IT.json | 77 + assets/l10n/mn_MN.json | 77 + assets/l10n/pl_PL.json | 77 + assets/l10n/ru_RU.json | 77 + assets/l10n/tr_TR.json | 77 + assets/l10n/uk_UA.json | 77 + assets/l10n/zh_TW.json | 77 + ios/Runner/Info.plist | 3 + lib/data/flow_icon.dart | 49 +- lib/data/icons.dart | 10 +- lib/data/legacy_simple_icons_codepoints.dart | 3155 +++++++++++++++++ lib/data/single_currency_flow.dart | 31 +- lib/graceful_migrations.dart | 90 + lib/main.dart | 1 + lib/objectbox/actions.dart | 17 +- lib/routes.dart | 36 +- .../debug/analytics/debug_cash_flow_page.dart | 357 -- .../debug/analytics/debug_net_worth_page.dart | 552 --- .../debug/analytics/debug_recurring_page.dart | 395 --- .../debug/analytics/debug_wrapped_page.dart | 636 ---- lib/routes/home/profile_tab.dart | 26 +- lib/routes/home/stats_tab.dart | 26 +- lib/routes/stats/cash_flow_page.dart | 259 ++ lib/routes/stats/net_worth_page.dart | 278 ++ lib/routes/stats/recurring_page.dart | 260 ++ .../spending_calendar_page.dart} | 162 +- .../spending_map_page.dart} | 245 +- lib/routes/stats/wrapped_page.dart | 355 ++ lib/utils/extensions.dart | 1 + lib/utils/extensions/money.dart | 22 + .../primary_currency_dependent_state.dart | 58 + .../{debug => }/analytics/bullet_chart.dart | 0 .../{debug => }/analytics/insight_card.dart | 0 .../{debug => }/analytics/sankey_diagram.dart | 0 .../analytics/spending_heatmap.dart | 0 .../{debug => }/analytics/weekday_bars.dart | 0 lib/widgets/general/flow_icon.dart | 8 + .../home/stats/bento/analytics_bento.dart | 70 + lib/widgets/home/stats/bento/bento_tile.dart | 99 + .../home/stats/bento/calendar_heatmap.dart | 75 + .../home/stats/bento/calendar_tile.dart | 119 + .../home/stats/bento/cash_flow_tile.dart | 156 + .../home/stats/bento/category_slice_row.dart | 65 + lib/widgets/home/stats/bento/map_tile.dart | 126 + .../home/stats/bento/net_worth_sparkline.dart | 56 + .../home/stats/bento/net_worth_tile.dart | 124 + .../home/stats/bento/recurring_tile.dart | 138 + .../home/stats/bento/top_categories_tile.dart | 119 + .../home/stats/bento/wrapped_tile.dart | 130 + .../select_icon_flow_icon_sheet.dart | 25 +- .../stats/cash_flow/cash_flow_figure.dart | 69 + .../stats/cash_flow/cash_flow_flow_bar.dart | 42 + .../stats/cash_flow/cash_flow_legend.dart | 53 + .../stats/cash_flow/cash_flow_summary.dart | 117 + lib/widgets/stats/emphasized_text.dart | 46 + lib/widgets/stats/missing_rates_notice.dart | 23 + lib/widgets/stats/money_delta_label.dart | 62 + .../net_worth/account_balance_share.dart | 9 + .../stats/net_worth/account_share_tile.dart | 86 + .../stats/net_worth/net_worth_chart.dart | 200 ++ .../stats/net_worth/net_worth_sample.dart | 7 + .../recurring/recurring_summary_header.dart | 65 + lib/widgets/stats/stats_app_bar.dart | 29 + lib/widgets/stats/stats_empty_state.dart | 25 + lib/widgets/stats/wrapped/mini_bars.dart | 50 + pubspec.yaml | 2 +- test/l10n/json_integrity_test.dart | 25 +- 75 files changed, 8094 insertions(+), 2305 deletions(-) create mode 100644 lib/data/legacy_simple_icons_codepoints.dart delete mode 100644 lib/routes/debug/analytics/debug_cash_flow_page.dart delete mode 100644 lib/routes/debug/analytics/debug_net_worth_page.dart delete mode 100644 lib/routes/debug/analytics/debug_recurring_page.dart delete mode 100644 lib/routes/debug/analytics/debug_wrapped_page.dart create mode 100644 lib/routes/stats/cash_flow_page.dart create mode 100644 lib/routes/stats/net_worth_page.dart create mode 100644 lib/routes/stats/recurring_page.dart rename lib/routes/{debug/analytics/debug_spending_calendar_page.dart => stats/spending_calendar_page.dart} (55%) rename lib/routes/{debug/analytics/debug_spending_map_page.dart => stats/spending_map_page.dart} (51%) create mode 100644 lib/routes/stats/wrapped_page.dart create mode 100644 lib/utils/extensions/money.dart create mode 100644 lib/utils/primary_currency_dependent_state.dart rename lib/widgets/{debug => }/analytics/bullet_chart.dart (100%) rename lib/widgets/{debug => }/analytics/insight_card.dart (100%) rename lib/widgets/{debug => }/analytics/sankey_diagram.dart (100%) rename lib/widgets/{debug => }/analytics/spending_heatmap.dart (100%) rename lib/widgets/{debug => }/analytics/weekday_bars.dart (100%) create mode 100644 lib/widgets/home/stats/bento/analytics_bento.dart create mode 100644 lib/widgets/home/stats/bento/bento_tile.dart create mode 100644 lib/widgets/home/stats/bento/calendar_heatmap.dart create mode 100644 lib/widgets/home/stats/bento/calendar_tile.dart create mode 100644 lib/widgets/home/stats/bento/cash_flow_tile.dart create mode 100644 lib/widgets/home/stats/bento/category_slice_row.dart create mode 100644 lib/widgets/home/stats/bento/map_tile.dart create mode 100644 lib/widgets/home/stats/bento/net_worth_sparkline.dart create mode 100644 lib/widgets/home/stats/bento/net_worth_tile.dart create mode 100644 lib/widgets/home/stats/bento/recurring_tile.dart create mode 100644 lib/widgets/home/stats/bento/top_categories_tile.dart create mode 100644 lib/widgets/home/stats/bento/wrapped_tile.dart create mode 100644 lib/widgets/stats/cash_flow/cash_flow_figure.dart create mode 100644 lib/widgets/stats/cash_flow/cash_flow_flow_bar.dart create mode 100644 lib/widgets/stats/cash_flow/cash_flow_legend.dart create mode 100644 lib/widgets/stats/cash_flow/cash_flow_summary.dart create mode 100644 lib/widgets/stats/emphasized_text.dart create mode 100644 lib/widgets/stats/missing_rates_notice.dart create mode 100644 lib/widgets/stats/money_delta_label.dart create mode 100644 lib/widgets/stats/net_worth/account_balance_share.dart create mode 100644 lib/widgets/stats/net_worth/account_share_tile.dart create mode 100644 lib/widgets/stats/net_worth/net_worth_chart.dart create mode 100644 lib/widgets/stats/net_worth/net_worth_sample.dart create mode 100644 lib/widgets/stats/recurring/recurring_summary_header.dart create mode 100644 lib/widgets/stats/stats_app_bar.dart create mode 100644 lib/widgets/stats/stats_empty_state.dart create mode 100644 lib/widgets/stats/wrapped/mini_bars.dart diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e9ada935..ef70cbe3 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -631,6 +631,13 @@ "tabs.home.transactionsCount.two": "{count} معاملتين", "tabs.home.transactionsCount.zero": "{count} معاملات", "tabs.profile": "الملف الشخصي", + "tabs.profile.analytics": "التحليلات", + "tabs.profile.analytics.calendar": "تقويم النفقات", + "tabs.profile.analytics.cashFlow": "التدفق النقدي (سانكي)", + "tabs.profile.analytics.map": "خريطة النفقات", + "tabs.profile.analytics.netWorth": "صافي الأصول عبر الزمن", + "tabs.profile.analytics.recurring": "الاشتراكات والمعاملات المتكررة", + "tabs.profile.analytics.wrapped": "ملخص شهري", "tabs.profile.backup": "النسخ الاحتياطي", "tabs.profile.community": "المجتمع", "tabs.profile.guide": "دليل الاستخدام", @@ -642,12 +649,82 @@ "tabs.profile.support": "دعم Flow", "tabs.profile.withLoveFromTheCreator": "مع 🤍 من sadespresso", "tabs.stats": "الإحصائيات", + "tabs.stats.analytics.calendar": "التقويم", + "tabs.stats.analytics.calendar.priciestDay": "أغلى يوم لديك هو {value}.", + "tabs.stats.analytics.calendar.spentIn": "المنفق في {}", + "tabs.stats.analytics.cashFlow": "التدفق النقدي", + "tabs.stats.analytics.cashFlow.empty": "لا يوجد تدفق نقدي في هذه الفترة.", + "tabs.stats.analytics.cashFlow.fromReserves": "من الاحتياطي", + "tabs.stats.analytics.cashFlow.loadFailed": "تعذر تحميل التدفق النقدي.", + "tabs.stats.analytics.cashFlow.noMovement": "لم يتحرك أي نقود في هذه الفترة.", + "tabs.stats.analytics.down": "انخفاض", + "tabs.stats.analytics.in": "وارد", + "tabs.stats.analytics.inRange": "في {}", + "tabs.stats.analytics.income": "الدخل", + "tabs.stats.analytics.map.empty": "لا توجد نفقات محددة بموقع في هذه النافذة.", + "tabs.stats.analytics.map.locatedCount": "{located} من أصل {total} نفقات لديها موقع", + "tabs.stats.analytics.map.mappedShort": "محددة على الخريطة · {days} يومًا", + "tabs.stats.analytics.map.mappedSpend": "النفقات المحددة على الخريطة", + "tabs.stats.analytics.map.noneYet": "لا توجد نفقات محددة بموقع حتى الآن.", + "tabs.stats.analytics.map.pinnedLocation": "موقع مثبت", + "tabs.stats.analytics.map.topPlaces": "أهم الأماكن", + "tabs.stats.analytics.map.visits": "{count} زيارات", + "tabs.stats.analytics.map.visits.one": "{count} زيارة", + "tabs.stats.analytics.missingRatesAmounts": "تم تجاهل بعض المبالغ بعملات غير أساسية (تفتقد أسعار صرف).", + "tabs.stats.analytics.missingRatesBalances": "تم تجاهل بعض الأرصدة بعملات غير أساسية (تفتقد أسعار صرف).", + "tabs.stats.analytics.netWorth": "صافي الأصول", + "tabs.stats.analytics.netWorth.byAccount": "حسب الحساب", + "tabs.stats.analytics.netWorth.noAccounts": "لا توجد حسابات للتلخيص.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "لا يوجد تاريخ كافٍ لرسم اتجاه.", + "tabs.stats.analytics.noSpendingRange": "لا توجد نفقات في هذا النطاق.", + "tabs.stats.analytics.noSpendingWindow": "لا توجد نفقات في هذه النافذة.", + "tabs.stats.analytics.other": "أخرى", + "tabs.stats.analytics.out": "صادر", + "tabs.stats.analytics.overspent": "تجاوز الإنفاق", + "tabs.stats.analytics.recurring": "دورية", + "tabs.stats.analytics.recurring.activeSummary": "{count} متكررة · خلال {days} يومًا قادمًا", + "tabs.stats.analytics.recurring.committedOutflow": "التدفقات الخارجة الملتزمة", + "tabs.stats.analytics.recurring.committedShort": "ملتزم · {days} يومًا", + "tabs.stats.analytics.recurring.defaultTitle": "معاملة دورية", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} أخرى غير معروضة", + "tabs.stats.analytics.recurring.none": "لم يتم إعداد معاملات دورية.", + "tabs.stats.analytics.recurring.nothingDue": "لا شيء مستحق في الأيام الـ {days} القادمة.", + "tabs.stats.analytics.recurring.nothingUpcoming": "لا شيء قادم", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} مدفوعات قادمة", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} مدفوعات قادمة", + "tabs.stats.analytics.rhythm": "الإيقاع", + "tabs.stats.analytics.saved": "المدخرات", + "tabs.stats.analytics.spending": "النفقات", + "tabs.stats.analytics.spendingCalendar": "تقويم النفقات", + "tabs.stats.analytics.spendingMap": "خريطة النفقات", + "tabs.stats.analytics.topCategories": "أهم الفئات", + "tabs.stats.analytics.uncategorized": "غير مصنّف", + "tabs.stats.analytics.untitled": "بلا عنوان", + "tabs.stats.analytics.up": "ارتفاع", + "tabs.stats.analytics.wrapped.biggest": "الأكبر: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} هذا الشهر مقابل {typical} المعتاد", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} هو {direction} بمقدار {value} مقارنة بمتوسط الثلاثة أشهر.", + "tabs.stats.analytics.wrapped.frequentEntry": "أكثر إدخالاتك تكرارًا: {value}", + "tabs.stats.analytics.wrapped.label.category": "الفئة", + "tabs.stats.analytics.wrapped.label.frequent": "متكرر", + "tabs.stats.analytics.wrapped.label.shape": "الشكل", + "tabs.stats.analytics.wrapped.loggedTimes": "تم تسجيله {count} مرات هذا الشهر", + "tabs.stats.analytics.wrapped.medianPurchase": "الشراء الوسيط لديك هو {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "لم تُسجل نفقات.", + "tabs.stats.analytics.wrapped.noTransactions": "لا توجد معاملات حتى الآن هذا الشهر.", + "tabs.stats.analytics.wrapped.spendMostOn": "أنت تنفق أكثر على {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} إدخالات · الأكبر {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} إدخال · الأكبر {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "اطلع على مراجعة شهرك", + "tabs.stats.analytics.wrapped.tileTitle": "ملخص {month} الخاص بك", + "tabs.stats.analytics.wrapped.title": "{month}، ملخص", "tabs.stats.categories": "الفئات", "tabs.stats.categories.seeAll": "عرض جميع الفئات", "tabs.stats.categories.top": "أعلى المصروفات", "tabs.stats.chart.noData": "لا توجد بيانات للعرض", "tabs.stats.chart.select.clickToSelect": "انقر للاختيار", "tabs.stats.chart.total": "الإجمالي", + "tabs.stats.insights": "التحليلات", "tabs.stats.intervalReport.averages.expense": "المصروفات", "tabs.stats.intervalReport.averages.flow": "التدفق", "tabs.stats.intervalReport.averages.income": "الإيرادات", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 2b1000bd..a13d7806 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -629,6 +629,13 @@ "tabs.home.transactionsCount.many": "{count} транзакцый", "tabs.home.transactionsCount.one": "{count} транзакцыя", "tabs.profile": "Профіль", + "tabs.profile.analytics": "Аналітыка", + "tabs.profile.analytics.calendar": "Каляндар расходаў", + "tabs.profile.analytics.cashFlow": "Паток грашовых сродкаў (Sankey)", + "tabs.profile.analytics.map": "Карта расходаў", + "tabs.profile.analytics.netWorth": "Дынаміка чыстай вартасці", + "tabs.profile.analytics.recurring": "Падпіскі і паўторныя плацяжы", + "tabs.profile.analytics.wrapped": "Штомесячнае рэзюмэ", "tabs.profile.backup": "Рэзервовая копія", "tabs.profile.community": "Супольнасць", "tabs.profile.guide": "Кіраўніцтва карыстальніка", @@ -640,12 +647,82 @@ "tabs.profile.support": "Падтрымаць Flow", "tabs.profile.withLoveFromTheCreator": "з 🤍 ад sadespresso", "tabs.stats": "Статыстыка", + "tabs.stats.analytics.calendar": "Каляндар", + "tabs.stats.analytics.calendar.priciestDay": "Ваш самы дарагі дзень — {value}.", + "tabs.stats.analytics.calendar.spentIn": "Патрачана ў {}", + "tabs.stats.analytics.cashFlow": "Паток грашовых сродкаў", + "tabs.stats.analytics.cashFlow.empty": "За гэты перыяд руху грашовых сродкаў не было.", + "tabs.stats.analytics.cashFlow.fromReserves": "З рэзерваў", + "tabs.stats.analytics.cashFlow.loadFailed": "Не ўдалося загрузіць паток грашовых сродкаў.", + "tabs.stats.analytics.cashFlow.noMovement": "За гэты перыяд руху сродкаў не было.", + "tabs.stats.analytics.down": "зніжэнне", + "tabs.stats.analytics.in": "Прыход", + "tabs.stats.analytics.inRange": "у {}", + "tabs.stats.analytics.income": "Даход", + "tabs.stats.analytics.map.empty": "У гэтым інтэрвале няма расходаў з пазначанымі месцамі.", + "tabs.stats.analytics.map.locatedCount": "{located} з {total} расходаў маюць пазначанае месца", + "tabs.stats.analytics.map.mappedShort": "Адлюстравана · {days}д", + "tabs.stats.analytics.map.mappedSpend": "Адлюстраваныя выдаткі", + "tabs.stats.analytics.map.noneYet": "Пакуль няма расходаў з пазначанымі месцамі.", + "tabs.stats.analytics.map.pinnedLocation": "Прыкрэпленае месца", + "tabs.stats.analytics.map.topPlaces": "Асноўныя месцы", + "tabs.stats.analytics.map.visits": "{count} наведванняў", + "tabs.stats.analytics.map.visits.one": "{count} наведванне", + "tabs.stats.analytics.missingRatesAmounts": "Некаторыя сумы ў неасноўнай валюце былі прапушчаныя (адсутнічаюць абменныя курсы).", + "tabs.stats.analytics.missingRatesBalances": "Некаторыя балансы ў неасноўнай валюце былі прапушчаныя (адсутнічаюць абменныя курсы).", + "tabs.stats.analytics.netWorth": "Чыстая вартасць", + "tabs.stats.analytics.netWorth.byAccount": "Па рахунках", + "tabs.stats.analytics.netWorth.noAccounts": "Няма рахункаў для падсумавання.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Недастаткова дадзеных для пабудовы тэндэнцыі.", + "tabs.stats.analytics.noSpendingRange": "У гэтым дыяпазоне няма расходаў.", + "tabs.stats.analytics.noSpendingWindow": "У гэтым інтэрвале расходаў не было.", + "tabs.stats.analytics.other": "Іншае", + "tabs.stats.analytics.out": "Расход", + "tabs.stats.analytics.overspent": "Ператрачана", + "tabs.stats.analytics.recurring": "Паўторныя", + "tabs.stats.analytics.recurring.activeSummary": "{count} паўторных · на наступныя {days} дзён", + "tabs.stats.analytics.recurring.committedOutflow": "Фіксаваныя выдаткі", + "tabs.stats.analytics.recurring.committedShort": "Фіксавана · {days}д", + "tabs.stats.analytics.recurring.defaultTitle": "Паўторная транзакцыя", + "tabs.stats.analytics.recurring.moreNotShown": "+ яшчэ {count} не паказана", + "tabs.stats.analytics.recurring.none": "Не наладжана ніводнай паўторнай транзакцыі.", + "tabs.stats.analytics.recurring.nothingDue": "У бліжэйшыя {days} дзён нічога не патрабуецца.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Нічога не запланавана", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} бліжэйшых плацяжоў", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} бліжэйшы плацёж", + "tabs.stats.analytics.rhythm": "Рытм", + "tabs.stats.analytics.saved": "Зэканомлена", + "tabs.stats.analytics.spending": "Выдаткі", + "tabs.stats.analytics.spendingCalendar": "Каляндар расходаў", + "tabs.stats.analytics.spendingMap": "Карта расходаў", + "tabs.stats.analytics.topCategories": "Асноўныя катэгорыі", + "tabs.stats.analytics.uncategorized": "Некатэгарызавана", + "tabs.stats.analytics.untitled": "Без назвы", + "tabs.stats.analytics.up": "рост", + "tabs.stats.analytics.wrapped.biggest": "Найбуйнейшая: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} у гэтым месяцы супраць звычайных {typical}", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} {direction} на {value} у параўнанні з вашым 3-месячным сярэднім.", + "tabs.stats.analytics.wrapped.frequentEntry": "Ваш самы часты запіс: {value}", + "tabs.stats.analytics.wrapped.label.category": "Катэгорыя", + "tabs.stats.analytics.wrapped.label.frequent": "Часта", + "tabs.stats.analytics.wrapped.label.shape": "Форма", + "tabs.stats.analytics.wrapped.loggedTimes": "Занесена {count} разоў у гэтым месяцы", + "tabs.stats.analytics.wrapped.medianPurchase": "Медыяна пакупак — {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Выдаткаў не зафіксавана.", + "tabs.stats.analytics.wrapped.noTransactions": "У гэтым месяцы яшчэ няма транзакцый.", + "tabs.stats.analytics.wrapped.spendMostOn": "Вы выдаткоўваеце найбольш на {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} запісаў · найбольшы {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} запіс · найбольшы {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Паглядзіце агляд вашага месяца", + "tabs.stats.analytics.wrapped.tileTitle": "Ваша рэзюмэ за {month}", + "tabs.stats.analytics.wrapped.title": "{month}: падсумаванне", "tabs.stats.categories": "Катэгорыі", "tabs.stats.categories.seeAll": "Глядзець усе катэгорыі", "tabs.stats.categories.top": "Галоўныя выдаткі", "tabs.stats.chart.noData": "Няма даных для паказу", "tabs.stats.chart.select.clickToSelect": "Націсніце, каб выбраць", "tabs.stats.chart.total": "Усяго", + "tabs.stats.insights": "Аналітыка", "tabs.stats.intervalReport.averages.expense": "Выдаткі", "tabs.stats.intervalReport.averages.flow": "Flow", "tabs.stats.intervalReport.averages.income": "Даходы", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index c51b5594..1abbcd6b 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -628,6 +628,13 @@ "tabs.home.transactionsCount.few": "{count} transakce", "tabs.home.transactionsCount.one": "{count} transakce", "tabs.profile": "Profil", + "tabs.profile.analytics": "Analytika", + "tabs.profile.analytics.calendar": "Kalendář výdajů", + "tabs.profile.analytics.cashFlow": "Peněžní tok (Sankey)", + "tabs.profile.analytics.map": "Mapa výdajů", + "tabs.profile.analytics.netWorth": "Čisté jmění v čase", + "tabs.profile.analytics.recurring": "Předplatné a opakované platby", + "tabs.profile.analytics.wrapped": "Měsíční shrnutí", "tabs.profile.backup": "Zálohování a synchronizace", "tabs.profile.community": "Komunita", "tabs.profile.guide": "Uživatelská příručka", @@ -639,12 +646,82 @@ "tabs.profile.support": "Podpořit Flow", "tabs.profile.withLoveFromTheCreator": "s 🤍 od sadespresso", "tabs.stats": "Statistiky", + "tabs.stats.analytics.calendar": "Kalendář", + "tabs.stats.analytics.calendar.priciestDay": "Váš nejdražší den je {value}.", + "tabs.stats.analytics.calendar.spentIn": "Utraceno v {}", + "tabs.stats.analytics.cashFlow": "Peněžní tok", + "tabs.stats.analytics.cashFlow.empty": "V tomto období žádný peněžní tok.", + "tabs.stats.analytics.cashFlow.fromReserves": "Z rezerv", + "tabs.stats.analytics.cashFlow.loadFailed": "Nepodařilo se načíst peněžní tok.", + "tabs.stats.analytics.cashFlow.noMovement": "V tomto období se žádné peníze nepohnuly.", + "tabs.stats.analytics.down": "dolů", + "tabs.stats.analytics.in": "Příjmy", + "tabs.stats.analytics.inRange": "v {}", + "tabs.stats.analytics.income": "Příjmy", + "tabs.stats.analytics.map.empty": "V tomto období žádné lokalizované výdaje.", + "tabs.stats.analytics.map.locatedCount": "{located} z {total} výdajů má lokaci", + "tabs.stats.analytics.map.mappedShort": "Zmapováno · {days}d", + "tabs.stats.analytics.map.mappedSpend": "Zmapované výdaje", + "tabs.stats.analytics.map.noneYet": "Ještě žádné lokalizované výdaje.", + "tabs.stats.analytics.map.pinnedLocation": "Připnuté místo", + "tabs.stats.analytics.map.topPlaces": "Nejlepší místa", + "tabs.stats.analytics.map.visits": "{count} návštěv", + "tabs.stats.analytics.map.visits.one": "{count} návštěva", + "tabs.stats.analytics.missingRatesAmounts": "Některé částky v nepodstatných měnách byly vynechány (chybějící směnné kurzy).", + "tabs.stats.analytics.missingRatesBalances": "Některé zůstatky v nepodstatných měnách byly vynechány (chybějící směnné kurzy).", + "tabs.stats.analytics.netWorth": "Čisté jmění", + "tabs.stats.analytics.netWorth.byAccount": "Podle účtu", + "tabs.stats.analytics.netWorth.noAccounts": "Žádné účty k zobrazení.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Není dostatek historie pro zobrazení trendu.", + "tabs.stats.analytics.noSpendingRange": "V tomto rozsahu žádné výdaje.", + "tabs.stats.analytics.noSpendingWindow": "V tomto období žádné výdaje.", + "tabs.stats.analytics.other": "Jiné", + "tabs.stats.analytics.out": "Výdaje", + "tabs.stats.analytics.overspent": "Překročeno", + "tabs.stats.analytics.recurring": "Opakující se", + "tabs.stats.analytics.recurring.activeSummary": "{count} opakujících se · příštích {days} dnů", + "tabs.stats.analytics.recurring.committedOutflow": "Závazné výdaje", + "tabs.stats.analytics.recurring.committedShort": "Závazné · {days}d", + "tabs.stats.analytics.recurring.defaultTitle": "Opakující se transakce", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} dalších není zobrazeno", + "tabs.stats.analytics.recurring.none": "Nebyly nastaveny žádné opakující se transakce.", + "tabs.stats.analytics.recurring.nothingDue": "V příštích {days} dnech není nic splatné.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Nic v blízké době", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} nadcházejících plateb", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} nadcházející platba", + "tabs.stats.analytics.rhythm": "Rytmus", + "tabs.stats.analytics.saved": "Ušetřeno", + "tabs.stats.analytics.spending": "Výdaje", + "tabs.stats.analytics.spendingCalendar": "Kalendář výdajů", + "tabs.stats.analytics.spendingMap": "Mapa výdajů", + "tabs.stats.analytics.topCategories": "Nejlepší kategorie", + "tabs.stats.analytics.uncategorized": "Nezařazeno", + "tabs.stats.analytics.untitled": "Bez názvu", + "tabs.stats.analytics.up": "nahoru", + "tabs.stats.analytics.wrapped.biggest": "Největší: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} tento měsíc vs obvyklé {typical}", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} je {direction} o {value} oproti vašemu průměru za 3 měsíce.", + "tabs.stats.analytics.wrapped.frequentEntry": "Vaše nejčastější položka: {value}", + "tabs.stats.analytics.wrapped.label.category": "Kategorie", + "tabs.stats.analytics.wrapped.label.frequent": "Časté", + "tabs.stats.analytics.wrapped.label.shape": "Tvar", + "tabs.stats.analytics.wrapped.loggedTimes": "Zaznamenáno {count} krát tento měsíc", + "tabs.stats.analytics.wrapped.medianPurchase": "Váš medián nákupu je {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Nejsou zaznamenány žádné výdaje.", + "tabs.stats.analytics.wrapped.noTransactions": "V tomto měsíci zatím žádné transakce.", + "tabs.stats.analytics.wrapped.spendMostOn": "Nejvíce utrácíte za {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} položek · největší {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} položka · největší {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Podívejte se na přehled měsíce", + "tabs.stats.analytics.wrapped.tileTitle": "Váš {month} v kostce", + "tabs.stats.analytics.wrapped.title": "{month}, shrnutí", "tabs.stats.categories": "Kategorie", "tabs.stats.categories.seeAll": "Zobrazit všechny kategorie", "tabs.stats.categories.top": "Největší výdaje", "tabs.stats.chart.noData": "Žádná data k zobrazení", "tabs.stats.chart.select.clickToSelect": "Kliknutím vyberte", "tabs.stats.chart.total": "Celkem", + "tabs.stats.insights": "Analytika", "tabs.stats.intervalReport.averages.expense": "Výdaje", "tabs.stats.intervalReport.averages.flow": "Tok", "tabs.stats.intervalReport.averages.income": "Příjmy", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index b18c4fff..3570a4a0 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} Buchungen", "tabs.home.transactionsCount.one": "{count} Buchung", "tabs.profile": "Profil", + "tabs.profile.analytics": "Analysen", + "tabs.profile.analytics.calendar": "Ausgabenkalender", + "tabs.profile.analytics.cashFlow": "Cashflow (Sankey)", + "tabs.profile.analytics.map": "Ausgabenkarte", + "tabs.profile.analytics.netWorth": "Vermögensentwicklung", + "tabs.profile.analytics.recurring": "Abonnements & Wiederkehrendes", + "tabs.profile.analytics.wrapped": "Monatlicher Rückblick", "tabs.profile.backup": "Sicherung", "tabs.profile.community": "Community", "tabs.profile.guide": "Nutzungsleitfaden", @@ -638,12 +645,82 @@ "tabs.profile.support": "Flow unterstützen", "tabs.profile.withLoveFromTheCreator": "mit 🤍 von sadespresso", "tabs.stats": "Statistiken", + "tabs.stats.analytics.calendar": "Kalender", + "tabs.stats.analytics.calendar.priciestDay": "Ihr teuerster Tag ist {value}.", + "tabs.stats.analytics.calendar.spentIn": "Ausgegeben in {}", + "tabs.stats.analytics.cashFlow": "Cashflow", + "tabs.stats.analytics.cashFlow.empty": "Kein Cashflow in diesem Zeitraum.", + "tabs.stats.analytics.cashFlow.fromReserves": "Aus Rücklagen", + "tabs.stats.analytics.cashFlow.loadFailed": "Cashflow konnte nicht geladen werden.", + "tabs.stats.analytics.cashFlow.noMovement": "In diesem Zeitraum wurden keine Geldbewegungen registriert.", + "tabs.stats.analytics.down": "gesunken", + "tabs.stats.analytics.in": "Eingang", + "tabs.stats.analytics.inRange": "in {}", + "tabs.stats.analytics.income": "Einnahmen", + "tabs.stats.analytics.map.empty": "Keine Ausgaben mit Standortangabe in diesem Zeitraum.", + "tabs.stats.analytics.map.locatedCount": "{located} von {total} Ausgaben haben einen Standort", + "tabs.stats.analytics.map.mappedShort": "Kartiert · {days}d", + "tabs.stats.analytics.map.mappedSpend": "Zugeordnete Ausgaben", + "tabs.stats.analytics.map.noneYet": "Noch keine Ausgaben mit Standortangabe.", + "tabs.stats.analytics.map.pinnedLocation": "Angehefteter Ort", + "tabs.stats.analytics.map.topPlaces": "Top-Orte", + "tabs.stats.analytics.map.visits": "{count} Besuche", + "tabs.stats.analytics.map.visits.one": "{count} Besuch", + "tabs.stats.analytics.missingRatesAmounts": "Einige Beträge in Nicht-Hauptwährung wurden übersprungen (fehlende Wechselkurse).", + "tabs.stats.analytics.missingRatesBalances": "Einige Guthaben in Nicht-Hauptwährung wurden übersprungen (fehlende Wechselkurse).", + "tabs.stats.analytics.netWorth": "Vermögen", + "tabs.stats.analytics.netWorth.byAccount": "Nach Konto", + "tabs.stats.analytics.netWorth.noAccounts": "Keine Konten zur Zusammenfassung.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Nicht genug Verlauf, um einen Trend darzustellen.", + "tabs.stats.analytics.noSpendingRange": "Keine Ausgaben in diesem Bereich.", + "tabs.stats.analytics.noSpendingWindow": "Keine Ausgaben in diesem Zeitraum.", + "tabs.stats.analytics.other": "Sonstige", + "tabs.stats.analytics.out": "Ausgang", + "tabs.stats.analytics.overspent": "Überzogen", + "tabs.stats.analytics.recurring": "Wiederkehrend", + "tabs.stats.analytics.recurring.activeSummary": "{count} wiederkehrend · nächste {days} Tage", + "tabs.stats.analytics.recurring.committedOutflow": "Verpflichtete Ausgaben", + "tabs.stats.analytics.recurring.committedShort": "Verpflichtet · {days}d", + "tabs.stats.analytics.recurring.defaultTitle": "Wiederkehrende Transaktion", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} weitere nicht angezeigt", + "tabs.stats.analytics.recurring.none": "Keine wiederkehrenden Transaktionen eingerichtet.", + "tabs.stats.analytics.recurring.nothingDue": "In den nächsten {days} Tagen nichts fällig.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Nichts anstehend", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} anstehende Belastungen", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} anstehende Belastung", + "tabs.stats.analytics.rhythm": "Rhythmus", + "tabs.stats.analytics.saved": "Gespart", + "tabs.stats.analytics.spending": "Ausgaben", + "tabs.stats.analytics.spendingCalendar": "Ausgabenkalender", + "tabs.stats.analytics.spendingMap": "Ausgabenkarte", + "tabs.stats.analytics.topCategories": "Top‑Kategorien", + "tabs.stats.analytics.uncategorized": "Nicht kategorisiert", + "tabs.stats.analytics.untitled": "Unbenannt", + "tabs.stats.analytics.up": "gestiegen", + "tabs.stats.analytics.wrapped.biggest": "Größter: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} in diesem Monat vs {typical} üblich", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} ist {direction} {value} im Vergleich zu Ihrem 3‑Monats‑Durchschnitt.", + "tabs.stats.analytics.wrapped.frequentEntry": "Ihr häufigster Eintrag: {value}", + "tabs.stats.analytics.wrapped.label.category": "Kategorie", + "tabs.stats.analytics.wrapped.label.frequent": "Häufig", + "tabs.stats.analytics.wrapped.label.shape": "Muster", + "tabs.stats.analytics.wrapped.loggedTimes": "Diesen Monat {count} Mal erfasst", + "tabs.stats.analytics.wrapped.medianPurchase": "Ihr Median‑Kauf liegt bei {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Keine Ausgaben erfasst.", + "tabs.stats.analytics.wrapped.noTransactions": "Noch keine Transaktionen in diesem Monat.", + "tabs.stats.analytics.wrapped.spendMostOn": "Sie geben am meisten für {value} aus.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} Einträge · größter {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} Eintrag · größter {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Sehen Sie Ihren Monat im Rückblick.", + "tabs.stats.analytics.wrapped.tileTitle": "Ihr {month}-Rückblick", + "tabs.stats.analytics.wrapped.title": "{month}, Rückblick", "tabs.stats.categories": "Kategorien", "tabs.stats.categories.seeAll": "Alle Kategorien anzeigen", "tabs.stats.categories.top": "Top-Ausgaben", "tabs.stats.chart.noData": "Keine Daten zum Anzeigen.", "tabs.stats.chart.select.clickToSelect": "Klicken zum Auswählen", "tabs.stats.chart.total": "Gesamt", + "tabs.stats.insights": "Analysen", "tabs.stats.intervalReport.averages.expense": "Ausgaben", "tabs.stats.intervalReport.averages.flow": "Flow", "tabs.stats.intervalReport.averages.income": "Einnahmen", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index ff0339ff..c2ed95f0 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} transactions", "tabs.home.transactionsCount.one": "{count} transaction", "tabs.profile": "Profile", + "tabs.profile.analytics": "Analytics", + "tabs.profile.analytics.calendar": "Spending calendar", + "tabs.profile.analytics.cashFlow": "Cash flow (Sankey)", + "tabs.profile.analytics.map": "Spending map", + "tabs.profile.analytics.netWorth": "Net worth over time", + "tabs.profile.analytics.recurring": "Subscriptions & recurring", + "tabs.profile.analytics.wrapped": "Monthly wrapped", "tabs.profile.backup": "Backup", "tabs.profile.community": "Community", "tabs.profile.guide": "Usage guide", @@ -638,12 +645,82 @@ "tabs.profile.support": "Support Flow", "tabs.profile.withLoveFromTheCreator": "with 🤍 from sadespresso", "tabs.stats": "Stats", + "tabs.stats.analytics.calendar": "Calendar", + "tabs.stats.analytics.calendar.priciestDay": "Your priciest day is {value}.", + "tabs.stats.analytics.calendar.spentIn": "Spent in {}", + "tabs.stats.analytics.cashFlow": "Cash flow", + "tabs.stats.analytics.cashFlow.empty": "No cash flow in this range.", + "tabs.stats.analytics.cashFlow.fromReserves": "From reserves", + "tabs.stats.analytics.cashFlow.loadFailed": "Couldn't load cash flow.", + "tabs.stats.analytics.cashFlow.noMovement": "No money moved in this range.", + "tabs.stats.analytics.down": "down", + "tabs.stats.analytics.in": "In", + "tabs.stats.analytics.inRange": "in {}", + "tabs.stats.analytics.income": "Income", + "tabs.stats.analytics.map.empty": "No located spending in this window.", + "tabs.stats.analytics.map.locatedCount": "{located} of {total} expenses have a location", + "tabs.stats.analytics.map.mappedShort": "Mapped · {days}d", + "tabs.stats.analytics.map.mappedSpend": "Mapped spend", + "tabs.stats.analytics.map.noneYet": "No located spending yet.", + "tabs.stats.analytics.map.pinnedLocation": "Pinned location", + "tabs.stats.analytics.map.topPlaces": "Top places", + "tabs.stats.analytics.map.visits": "{count} visits", + "tabs.stats.analytics.map.visits.one": "{count} visit", + "tabs.stats.analytics.missingRatesAmounts": "Some non-primary currency amounts were skipped (missing exchange rates).", + "tabs.stats.analytics.missingRatesBalances": "Some non-primary currency balances were skipped (missing exchange rates).", + "tabs.stats.analytics.netWorth": "Net worth", + "tabs.stats.analytics.netWorth.byAccount": "By account", + "tabs.stats.analytics.netWorth.noAccounts": "No accounts to summarize.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Not enough history to draw a trend.", + "tabs.stats.analytics.noSpendingRange": "No spending in this range.", + "tabs.stats.analytics.noSpendingWindow": "No spending in this window.", + "tabs.stats.analytics.other": "Other", + "tabs.stats.analytics.out": "Out", + "tabs.stats.analytics.overspent": "Overspent", + "tabs.stats.analytics.recurring": "Recurring", + "tabs.stats.analytics.recurring.activeSummary": "{count} recurring · next {days} days", + "tabs.stats.analytics.recurring.committedOutflow": "Committed outflow", + "tabs.stats.analytics.recurring.committedShort": "Committed · {days}d", + "tabs.stats.analytics.recurring.defaultTitle": "Recurring transaction", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} more not shown", + "tabs.stats.analytics.recurring.none": "No recurring transactions set up.", + "tabs.stats.analytics.recurring.nothingDue": "Nothing due in the next {days} days.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Nothing upcoming", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} upcoming charges", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} upcoming charge", + "tabs.stats.analytics.rhythm": "Rhythm", + "tabs.stats.analytics.saved": "Saved", + "tabs.stats.analytics.spending": "Spending", + "tabs.stats.analytics.spendingCalendar": "Spending calendar", + "tabs.stats.analytics.spendingMap": "Spending map", + "tabs.stats.analytics.topCategories": "Top categories", + "tabs.stats.analytics.uncategorized": "Uncategorized", + "tabs.stats.analytics.untitled": "Untitled", + "tabs.stats.analytics.up": "up", + "tabs.stats.analytics.wrapped.biggest": "Biggest: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} this month vs {typical} typical", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} is {direction} {value} vs your 3-month average.", + "tabs.stats.analytics.wrapped.frequentEntry": "Your most frequent entry: {value}", + "tabs.stats.analytics.wrapped.label.category": "Category", + "tabs.stats.analytics.wrapped.label.frequent": "Frequent", + "tabs.stats.analytics.wrapped.label.shape": "Shape", + "tabs.stats.analytics.wrapped.loggedTimes": "Logged {count} times this month", + "tabs.stats.analytics.wrapped.medianPurchase": "Your median purchase is {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "No expenses recorded.", + "tabs.stats.analytics.wrapped.noTransactions": "No transactions yet this month.", + "tabs.stats.analytics.wrapped.spendMostOn": "You spend most on {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} entries · biggest {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} entry · biggest {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "See your month in review", + "tabs.stats.analytics.wrapped.tileTitle": "Your {month}, wrapped", + "tabs.stats.analytics.wrapped.title": "{month}, wrapped", "tabs.stats.categories": "Categories", "tabs.stats.categories.seeAll": "See all categories", "tabs.stats.categories.top": "Top spending", "tabs.stats.chart.noData": "No data to show", "tabs.stats.chart.select.clickToSelect": "Click to select", "tabs.stats.chart.total": "Total", + "tabs.stats.insights": "Insights", "tabs.stats.intervalReport.averages.expense": "Expense", "tabs.stats.intervalReport.averages.flow": "Flow", "tabs.stats.intervalReport.averages.income": "Income", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 24a1476a..15fd2920 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} transacciones", "tabs.home.transactionsCount.one": "{count} transacción", "tabs.profile": "Perfil", + "tabs.profile.analytics": "Analítica", + "tabs.profile.analytics.calendar": "Calendario de gastos", + "tabs.profile.analytics.cashFlow": "Flujo de caja (Sankey)", + "tabs.profile.analytics.map": "Mapa de gastos", + "tabs.profile.analytics.netWorth": "Evolución del patrimonio neto", + "tabs.profile.analytics.recurring": "Suscripciones y recurrentes", + "tabs.profile.analytics.wrapped": "Resumen mensual", "tabs.profile.backup": "Copia de seguridad", "tabs.profile.community": "Comunidad", "tabs.profile.guide": "Guía de uso", @@ -638,12 +645,82 @@ "tabs.profile.support": "Apoyar Flow", "tabs.profile.withLoveFromTheCreator": "con 🤍 de sadespresso", "tabs.stats": "Estadísticas", + "tabs.stats.analytics.calendar": "Calendario", + "tabs.stats.analytics.calendar.priciestDay": "Tu día más caro es {value}.", + "tabs.stats.analytics.calendar.spentIn": "Gastado en {}", + "tabs.stats.analytics.cashFlow": "Flujo de caja", + "tabs.stats.analytics.cashFlow.empty": "No hay flujo de caja en este intervalo.", + "tabs.stats.analytics.cashFlow.fromReserves": "De reservas", + "tabs.stats.analytics.cashFlow.loadFailed": "No se pudo cargar el flujo de caja.", + "tabs.stats.analytics.cashFlow.noMovement": "No hubo movimientos de dinero en este intervalo.", + "tabs.stats.analytics.down": "a la baja", + "tabs.stats.analytics.in": "Entradas", + "tabs.stats.analytics.inRange": "en {}", + "tabs.stats.analytics.income": "Ingresos", + "tabs.stats.analytics.map.empty": "No hay gastos localizados en este intervalo.", + "tabs.stats.analytics.map.locatedCount": "{located} de {total} gastos tienen ubicación", + "tabs.stats.analytics.map.mappedShort": "Mapeado · {days}d", + "tabs.stats.analytics.map.mappedSpend": "Gastos mapeados", + "tabs.stats.analytics.map.noneYet": "Aún no hay gastos localizados.", + "tabs.stats.analytics.map.pinnedLocation": "Ubicación fijada", + "tabs.stats.analytics.map.topPlaces": "Lugares principales", + "tabs.stats.analytics.map.visits": "{count} visitas", + "tabs.stats.analytics.map.visits.one": "{count} visita", + "tabs.stats.analytics.missingRatesAmounts": "Se omitieron algunos importes en monedas no primarias (faltan tipos de cambio).", + "tabs.stats.analytics.missingRatesBalances": "Se omitieron algunos saldos en monedas no primarias (faltan tipos de cambio).", + "tabs.stats.analytics.netWorth": "Patrimonio neto", + "tabs.stats.analytics.netWorth.byAccount": "Por cuenta", + "tabs.stats.analytics.netWorth.noAccounts": "No hay cuentas para resumir.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "No hay suficiente historial para trazar una tendencia.", + "tabs.stats.analytics.noSpendingRange": "No hay gastos en este rango.", + "tabs.stats.analytics.noSpendingWindow": "No hay gastos en este intervalo.", + "tabs.stats.analytics.other": "Otros", + "tabs.stats.analytics.out": "Salidas", + "tabs.stats.analytics.overspent": "Gasto excesivo", + "tabs.stats.analytics.recurring": "Recurrentes", + "tabs.stats.analytics.recurring.activeSummary": "{count} recurrentes · próximos {days} días", + "tabs.stats.analytics.recurring.committedOutflow": "Salida comprometida", + "tabs.stats.analytics.recurring.committedShort": "Comprometido · {days}d", + "tabs.stats.analytics.recurring.defaultTitle": "Transacción recurrente", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} más no mostrados", + "tabs.stats.analytics.recurring.none": "No se han configurado transacciones recurrentes.", + "tabs.stats.analytics.recurring.nothingDue": "No hay pagos pendientes en los próximos {days} días.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Nada programado", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} cobros próximos", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} cobro próximo", + "tabs.stats.analytics.rhythm": "Ritmo", + "tabs.stats.analytics.saved": "Ahorrado", + "tabs.stats.analytics.spending": "Gastos", + "tabs.stats.analytics.spendingCalendar": "Calendario de gastos", + "tabs.stats.analytics.spendingMap": "Mapa de gastos", + "tabs.stats.analytics.topCategories": "Categorías principales", + "tabs.stats.analytics.uncategorized": "Sin categorizar", + "tabs.stats.analytics.untitled": "Sin título", + "tabs.stats.analytics.up": "al alza", + "tabs.stats.analytics.wrapped.biggest": "Mayor: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} este mes frente a {typical} habitual", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} está {direction} {value} respecto a tu media de 3 meses.", + "tabs.stats.analytics.wrapped.frequentEntry": "Tu entrada más frecuente: {value}", + "tabs.stats.analytics.wrapped.label.category": "Categoría", + "tabs.stats.analytics.wrapped.label.frequent": "Frecuente", + "tabs.stats.analytics.wrapped.label.shape": "Forma", + "tabs.stats.analytics.wrapped.loggedTimes": "Registrado {count} veces este mes", + "tabs.stats.analytics.wrapped.medianPurchase": "Tu compra mediana es {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "No hay gastos registrados.", + "tabs.stats.analytics.wrapped.noTransactions": "Aún no hay transacciones este mes.", + "tabs.stats.analytics.wrapped.spendMostOn": "Gastas más en {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} entradas · la mayor {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} entrada · la mayor {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Ver tu mes en resumen", + "tabs.stats.analytics.wrapped.tileTitle": "Tu {month}, en resumen", + "tabs.stats.analytics.wrapped.title": "{month}, resumen", "tabs.stats.categories": "Categorías", "tabs.stats.categories.seeAll": "Ver todas las categorías", "tabs.stats.categories.top": "Gastos principales", "tabs.stats.chart.noData": "No hay datos para mostrar", "tabs.stats.chart.select.clickToSelect": "Haz clic para seleccionar", "tabs.stats.chart.total": "Total", + "tabs.stats.insights": "Analítica", "tabs.stats.intervalReport.averages.expense": "Gasto", "tabs.stats.intervalReport.averages.flow": "Flujo", "tabs.stats.intervalReport.averages.income": "Ingreso", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 66915f0a..5dd9d6d0 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} تراکنش", "tabs.home.transactionsCount.one": "{count} تراکنش", "tabs.profile": "پروفایل", + "tabs.profile.analytics": "تحلیل‌ها", + "tabs.profile.analytics.calendar": "تقویم هزینه‌ها", + "tabs.profile.analytics.cashFlow": "جریان نقدی (سانکی)", + "tabs.profile.analytics.map": "نقشه هزینه‌ها", + "tabs.profile.analytics.netWorth": "ارزش خالص در طول زمان", + "tabs.profile.analytics.recurring": "اشتراک‌ها و تراکنش‌های دوره‌ای", + "tabs.profile.analytics.wrapped": "خلاصهٔ ماهانه", "tabs.profile.backup": "پشتیبان", "tabs.profile.community": "جامعه", "tabs.profile.guide": "راهنمای استفاده", @@ -638,12 +645,82 @@ "tabs.profile.support": "حمایت از Flow", "tabs.profile.withLoveFromTheCreator": "با 🤍 از sadespresso", "tabs.stats": "آمار", + "tabs.stats.analytics.calendar": "تقویم", + "tabs.stats.analytics.calendar.priciestDay": "گران‌ترین روز شما {value} است.", + "tabs.stats.analytics.calendar.spentIn": "هزینه شده در {}", + "tabs.stats.analytics.cashFlow": "جریان نقدی", + "tabs.stats.analytics.cashFlow.empty": "در این بازه هیچ جریان نقدی وجود ندارد.", + "tabs.stats.analytics.cashFlow.fromReserves": "از ذخایر", + "tabs.stats.analytics.cashFlow.loadFailed": "بارگذاری جریان نقدی ممکن نشد.", + "tabs.stats.analytics.cashFlow.noMovement": "در این بازه هیچ پولی جابه‌جا نشده است.", + "tabs.stats.analytics.down": "کاهش", + "tabs.stats.analytics.in": "ورودی", + "tabs.stats.analytics.inRange": "در {}", + "tabs.stats.analytics.income": "درآمد", + "tabs.stats.analytics.map.empty": "در این بازه هیچ هزینه‌ای با مکان ثبت‌شده وجود ندارد.", + "tabs.stats.analytics.map.locatedCount": "{located} از {total} هزینه‌ها دارای مکان هستند", + "tabs.stats.analytics.map.mappedShort": "نقشه‌دار · {days} روز", + "tabs.stats.analytics.map.mappedSpend": "هزینه‌های مکان‌یابی‌شده", + "tabs.stats.analytics.map.noneYet": "هنوز هیچ هزینه‌ای با مکان ثبت نشده است.", + "tabs.stats.analytics.map.pinnedLocation": "مکان سنجاق‌شده", + "tabs.stats.analytics.map.topPlaces": "مکان‌های برتر", + "tabs.stats.analytics.map.visits": "{count} بازدید", + "tabs.stats.analytics.map.visits.one": "{count} بازدید", + "tabs.stats.analytics.missingRatesAmounts": "برخی مبالغ با ارز غیر اصلی نادیده گرفته شدند (نرخ تبدیل موجود نیست).", + "tabs.stats.analytics.missingRatesBalances": "برخی مانده‌حساب‌ها با ارز غیر اصلی نادیده گرفته شدند (نرخ تبدیل موجود نیست).", + "tabs.stats.analytics.netWorth": "ارزش خالص", + "tabs.stats.analytics.netWorth.byAccount": "بر حسب حساب", + "tabs.stats.analytics.netWorth.noAccounts": "هیچ حسابی برای خلاصه‌سازی وجود ندارد.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "تاریخچهٔ کافی برای رسم روند وجود ندارد.", + "tabs.stats.analytics.noSpendingRange": "در این بازه هیچ هزینه‌ای وجود ندارد.", + "tabs.stats.analytics.noSpendingWindow": "در این بازه هیچ هزینه‌ای وجود ندارد.", + "tabs.stats.analytics.other": "سایر", + "tabs.stats.analytics.out": "خروجی", + "tabs.stats.analytics.overspent": "بیش‌خرجی", + "tabs.stats.analytics.recurring": "تراکنش‌های دوره‌ای", + "tabs.stats.analytics.recurring.activeSummary": "{count} مورد دوره‌ای · {days} روز آینده", + "tabs.stats.analytics.recurring.committedOutflow": "پرداخت‌های متعهد شده", + "tabs.stats.analytics.recurring.committedShort": "متعهد · {days} روز", + "tabs.stats.analytics.recurring.defaultTitle": "تراکنش دوره‌ای", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} مورد دیگر نمایش داده نشده", + "tabs.stats.analytics.recurring.none": "هیچ تراکنش دوره‌ای تنظیم نشده است.", + "tabs.stats.analytics.recurring.nothingDue": "هیچ موردی در {days} روز آینده موعد ندارد.", + "tabs.stats.analytics.recurring.nothingUpcoming": "هیچ موردی در پیش رو نیست", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} هزینهٔ پیش رو", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} هزینهٔ پیش رو", + "tabs.stats.analytics.rhythm": "ریتم", + "tabs.stats.analytics.saved": "پس‌انداز", + "tabs.stats.analytics.spending": "هزینه‌ها", + "tabs.stats.analytics.spendingCalendar": "تقویم هزینه‌ها", + "tabs.stats.analytics.spendingMap": "نقشه هزینه‌ها", + "tabs.stats.analytics.topCategories": "دسته‌های برتر", + "tabs.stats.analytics.uncategorized": "دسته‌بندی نشده", + "tabs.stats.analytics.untitled": "بدون عنوان", + "tabs.stats.analytics.up": "افزایش", + "tabs.stats.analytics.wrapped.biggest": "بزرگ‌ترین: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} این ماه در مقایسه با معمولِ {typical}", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} نسبت به میانگین سه‌ماههٔ شما {direction} {value} است.", + "tabs.stats.analytics.wrapped.frequentEntry": "پرتکرارترین مورد شما: {value}", + "tabs.stats.analytics.wrapped.label.category": "دسته‌بندی", + "tabs.stats.analytics.wrapped.label.frequent": "پرتکرار", + "tabs.stats.analytics.wrapped.label.shape": "الگو", + "tabs.stats.analytics.wrapped.loggedTimes": "این ماه {count} بار ثبت شده است", + "tabs.stats.analytics.wrapped.medianPurchase": "میانهٔ خریدهای شما {value} است.", + "tabs.stats.analytics.wrapped.noExpenses": "هیچ هزینه‌ای ثبت نشده است.", + "tabs.stats.analytics.wrapped.noTransactions": "هنوز تراکنشی در این ماه ثبت نشده است.", + "tabs.stats.analytics.wrapped.spendMostOn": "بیشترین هزینه‌های شما مربوط به {value} است.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} مورد · بزرگ‌ترین {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} مورد · بزرگ‌ترین {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "مرور ماه خود را ببینید", + "tabs.stats.analytics.wrapped.tileTitle": "خلاصهٔ {month} شما", + "tabs.stats.analytics.wrapped.title": "{month}، خلاصه", "tabs.stats.categories": "دسته‌بندی‌ها", "tabs.stats.categories.seeAll": "مشاهده همه دسته‌بندی‌ها", "tabs.stats.categories.top": "بیشترین هزینه", "tabs.stats.chart.noData": "داده‌ای برای نمایش نیست", "tabs.stats.chart.select.clickToSelect": "برای انتخاب کلیک کنید", "tabs.stats.chart.total": "جمع", + "tabs.stats.insights": "تحلیل‌ها", "tabs.stats.intervalReport.averages.expense": "هزینه", "tabs.stats.intervalReport.averages.flow": "Flow", "tabs.stats.intervalReport.averages.income": "درآمد", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 379891ef..34aa6aa3 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} transactions", "tabs.home.transactionsCount.one": "{count} transaction", "tabs.profile": "Profil", + "tabs.profile.analytics": "Analyses", + "tabs.profile.analytics.calendar": "Calendrier des dépenses", + "tabs.profile.analytics.cashFlow": "Flux de trésorerie (Sankey)", + "tabs.profile.analytics.map": "Carte des dépenses", + "tabs.profile.analytics.netWorth": "Évolution du patrimoine net", + "tabs.profile.analytics.recurring": "Abonnements et récurrents", + "tabs.profile.analytics.wrapped": "Récapitulatif mensuel", "tabs.profile.backup": "Sauvegarde", "tabs.profile.community": "Communauté", "tabs.profile.guide": "Guide d’utilisation", @@ -638,12 +645,82 @@ "tabs.profile.support": "Soutenir Flow", "tabs.profile.withLoveFromTheCreator": "avec 🤍 de sadespresso", "tabs.stats": "Statistiques", + "tabs.stats.analytics.calendar": "Calendrier", + "tabs.stats.analytics.calendar.priciestDay": "Votre jour le plus coûteux est {value}.", + "tabs.stats.analytics.calendar.spentIn": "Dépensé en {}", + "tabs.stats.analytics.cashFlow": "Flux de trésorerie", + "tabs.stats.analytics.cashFlow.empty": "Aucun flux de trésorerie dans cette plage.", + "tabs.stats.analytics.cashFlow.fromReserves": "Depuis les réserves", + "tabs.stats.analytics.cashFlow.loadFailed": "Impossible de charger le flux de trésorerie.", + "tabs.stats.analytics.cashFlow.noMovement": "Aucun mouvement d'argent dans cette période.", + "tabs.stats.analytics.down": "en baisse", + "tabs.stats.analytics.in": "Entrées", + "tabs.stats.analytics.inRange": "dans {}", + "tabs.stats.analytics.income": "Revenus", + "tabs.stats.analytics.map.empty": "Aucune dépense localisée dans cette période.", + "tabs.stats.analytics.map.locatedCount": "{located} sur {total} dépenses ont un emplacement", + "tabs.stats.analytics.map.mappedShort": "Cartographié · {days}j", + "tabs.stats.analytics.map.mappedSpend": "Dépenses cartographiées", + "tabs.stats.analytics.map.noneYet": "Aucune dépense localisée pour le moment.", + "tabs.stats.analytics.map.pinnedLocation": "Emplacement épinglé", + "tabs.stats.analytics.map.topPlaces": "Lieux principaux", + "tabs.stats.analytics.map.visits": "{count} visites", + "tabs.stats.analytics.map.visits.one": "{count} visite", + "tabs.stats.analytics.missingRatesAmounts": "Certaines sommes en devises non principales ont été ignorées (taux de change manquants).", + "tabs.stats.analytics.missingRatesBalances": "Certains soldes en devises non principales ont été ignorés (taux de change manquants).", + "tabs.stats.analytics.netWorth": "Patrimoine net", + "tabs.stats.analytics.netWorth.byAccount": "Par compte", + "tabs.stats.analytics.netWorth.noAccounts": "Aucun compte à résumer.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Pas assez d'historique pour tracer une tendance.", + "tabs.stats.analytics.noSpendingRange": "Aucune dépense dans cette plage.", + "tabs.stats.analytics.noSpendingWindow": "Aucune dépense dans cette période.", + "tabs.stats.analytics.other": "Autre", + "tabs.stats.analytics.out": "Sorties", + "tabs.stats.analytics.overspent": "Dépassement", + "tabs.stats.analytics.recurring": "Récurrent", + "tabs.stats.analytics.recurring.activeSummary": "{count} récurrences · dans les {days} prochains jours", + "tabs.stats.analytics.recurring.committedOutflow": "Sortie engagée", + "tabs.stats.analytics.recurring.committedShort": "Engagé · {days}j", + "tabs.stats.analytics.recurring.defaultTitle": "Transaction récurrente", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} de plus non affichés", + "tabs.stats.analytics.recurring.none": "Aucune transaction récurrente configurée.", + "tabs.stats.analytics.recurring.nothingDue": "Aucun paiement dû dans les {days} prochains jours.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Rien à venir", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} paiements à venir", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} paiement à venir", + "tabs.stats.analytics.rhythm": "Rythme", + "tabs.stats.analytics.saved": "Épargné", + "tabs.stats.analytics.spending": "Dépenses", + "tabs.stats.analytics.spendingCalendar": "Calendrier des dépenses", + "tabs.stats.analytics.spendingMap": "Carte des dépenses", + "tabs.stats.analytics.topCategories": "Catégories principales", + "tabs.stats.analytics.uncategorized": "Non catégorisé", + "tabs.stats.analytics.untitled": "Sans titre", + "tabs.stats.analytics.up": "en hausse", + "tabs.stats.analytics.wrapped.biggest": "Plus gros : {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} ce mois-ci vs {typical} habituellement", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} est {direction} de {value} par rapport à votre moyenne sur 3 mois.", + "tabs.stats.analytics.wrapped.frequentEntry": "Votre entrée la plus fréquente : {value}", + "tabs.stats.analytics.wrapped.label.category": "Catégorie", + "tabs.stats.analytics.wrapped.label.frequent": "Fréquent", + "tabs.stats.analytics.wrapped.label.shape": "Forme", + "tabs.stats.analytics.wrapped.loggedTimes": "Enregistré {count} fois ce mois-ci", + "tabs.stats.analytics.wrapped.medianPurchase": "Votre achat médian est {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Aucune dépense enregistrée.", + "tabs.stats.analytics.wrapped.noTransactions": "Aucune transaction ce mois-ci.", + "tabs.stats.analytics.wrapped.spendMostOn": "Vous dépensez le plus pour {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} entrées · plus gros {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} entrée · plus gros {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Voir le résumé du mois", + "tabs.stats.analytics.wrapped.tileTitle": "Votre récapitulatif de {month}", + "tabs.stats.analytics.wrapped.title": "Récapitulatif de {month}", "tabs.stats.categories": "Catégories", "tabs.stats.categories.seeAll": "Voir toutes les catégories", "tabs.stats.categories.top": "Dépenses principales", "tabs.stats.chart.noData": "Aucune donnée à afficher", "tabs.stats.chart.select.clickToSelect": "Cliquez pour sélectionner", "tabs.stats.chart.total": "Total", + "tabs.stats.insights": "Analyses", "tabs.stats.intervalReport.averages.expense": "Dépenses", "tabs.stats.intervalReport.averages.flow": "Flux", "tabs.stats.intervalReport.averages.income": "Revenus", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 1e8ca72e..eb604650 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} transazioni", "tabs.home.transactionsCount.one": "{count} transazione", "tabs.profile": "Profilo", + "tabs.profile.analytics": "Analisi", + "tabs.profile.analytics.calendar": "Calendario delle spese", + "tabs.profile.analytics.cashFlow": "Flusso di cassa (Sankey)", + "tabs.profile.analytics.map": "Mappa delle spese", + "tabs.profile.analytics.netWorth": "Patrimonio netto nel tempo", + "tabs.profile.analytics.recurring": "Abbonamenti e ricorrenti", + "tabs.profile.analytics.wrapped": "Riepilogo mensile", "tabs.profile.backup": "Backup", "tabs.profile.community": "Comunità", "tabs.profile.guide": "Guida all'uso", @@ -638,12 +645,82 @@ "tabs.profile.support": "Supporta Flow", "tabs.profile.withLoveFromTheCreator": "con 🤍 da sadespresso", "tabs.stats": "Statistiche", + "tabs.stats.analytics.calendar": "Calendario", + "tabs.stats.analytics.calendar.priciestDay": "Il giorno più costoso è {value}.", + "tabs.stats.analytics.calendar.spentIn": "Speso in {}", + "tabs.stats.analytics.cashFlow": "Flusso di cassa", + "tabs.stats.analytics.cashFlow.empty": "Nessun flusso di cassa in questo intervallo.", + "tabs.stats.analytics.cashFlow.fromReserves": "Dalle riserve", + "tabs.stats.analytics.cashFlow.loadFailed": "Impossibile caricare il flusso di cassa.", + "tabs.stats.analytics.cashFlow.noMovement": "Nessun movimento di denaro in questo intervallo.", + "tabs.stats.analytics.down": "in calo", + "tabs.stats.analytics.in": "Entrata", + "tabs.stats.analytics.inRange": "in {}", + "tabs.stats.analytics.income": "Entrate", + "tabs.stats.analytics.map.empty": "Nessuna spesa localizzata in questa finestra.", + "tabs.stats.analytics.map.locatedCount": "{located} di {total} spese hanno una posizione", + "tabs.stats.analytics.map.mappedShort": "Mappato · {days}g", + "tabs.stats.analytics.map.mappedSpend": "Spese mappate", + "tabs.stats.analytics.map.noneYet": "Ancora nessuna spesa localizzata.", + "tabs.stats.analytics.map.pinnedLocation": "Posizione appuntata", + "tabs.stats.analytics.map.topPlaces": "Luoghi principali", + "tabs.stats.analytics.map.visits": "{count} visite", + "tabs.stats.analytics.map.visits.one": "{count} visita", + "tabs.stats.analytics.missingRatesAmounts": "Alcuni importi in valuta non primaria sono stati saltati (mancano i tassi di cambio).", + "tabs.stats.analytics.missingRatesBalances": "Alcuni saldi in valuta non primaria sono stati saltati (mancano i tassi di cambio).", + "tabs.stats.analytics.netWorth": "Patrimonio netto", + "tabs.stats.analytics.netWorth.byAccount": "Per conto", + "tabs.stats.analytics.netWorth.noAccounts": "Nessun conto da riepilogare.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Non c'è abbastanza storico per tracciare una tendenza.", + "tabs.stats.analytics.noSpendingRange": "Nessuna spesa in questo intervallo.", + "tabs.stats.analytics.noSpendingWindow": "Nessuna spesa in questa finestra.", + "tabs.stats.analytics.other": "Altro", + "tabs.stats.analytics.out": "Uscita", + "tabs.stats.analytics.overspent": "Sforato", + "tabs.stats.analytics.recurring": "Ricorrenti", + "tabs.stats.analytics.recurring.activeSummary": "{count} ricorrenti · prossimi {days} giorni", + "tabs.stats.analytics.recurring.committedOutflow": "Uscite impegnate", + "tabs.stats.analytics.recurring.committedShort": "Impegnato · {days}g", + "tabs.stats.analytics.recurring.defaultTitle": "Transazione ricorrente", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} in più non mostrati", + "tabs.stats.analytics.recurring.none": "Nessuna transazione ricorrente impostata.", + "tabs.stats.analytics.recurring.nothingDue": "Niente in scadenza nei prossimi {days} giorni.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Niente in arrivo", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} addebiti in arrivo", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} addebito in arrivo", + "tabs.stats.analytics.rhythm": "Ritmo", + "tabs.stats.analytics.saved": "Risparmiato", + "tabs.stats.analytics.spending": "Spese", + "tabs.stats.analytics.spendingCalendar": "Calendario delle spese", + "tabs.stats.analytics.spendingMap": "Mappa delle spese", + "tabs.stats.analytics.topCategories": "Categorie principali", + "tabs.stats.analytics.uncategorized": "Non categorizzato", + "tabs.stats.analytics.untitled": "Senza titolo", + "tabs.stats.analytics.up": "in aumento", + "tabs.stats.analytics.wrapped.biggest": "Il più grande: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} questo mese vs {typical} tipico", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} è {direction} di {value} rispetto alla tua media su 3 mesi.", + "tabs.stats.analytics.wrapped.frequentEntry": "La tua voce più frequente: {value}", + "tabs.stats.analytics.wrapped.label.category": "Categoria", + "tabs.stats.analytics.wrapped.label.frequent": "Frequente", + "tabs.stats.analytics.wrapped.label.shape": "Andamento", + "tabs.stats.analytics.wrapped.loggedTimes": "Registrata {count} volte questo mese", + "tabs.stats.analytics.wrapped.medianPurchase": "Il tuo acquisto mediano è {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Nessuna spesa registrata.", + "tabs.stats.analytics.wrapped.noTransactions": "Ancora nessuna transazione questo mese.", + "tabs.stats.analytics.wrapped.spendMostOn": "Spendi di più per {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} voci · la più grande {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} voce · la più grande {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Guarda il riepilogo del mese", + "tabs.stats.analytics.wrapped.tileTitle": "Il tuo {month}, in sintesi", + "tabs.stats.analytics.wrapped.title": "{month}, riepilogo", "tabs.stats.categories": "Categorie", "tabs.stats.categories.seeAll": "Vedi tutte le categorie", "tabs.stats.categories.top": "Spese principali", "tabs.stats.chart.noData": "Nessun dato da mostrare", "tabs.stats.chart.select.clickToSelect": "Clicca per selezionare", "tabs.stats.chart.total": "Totale", + "tabs.stats.insights": "Analisi", "tabs.stats.intervalReport.averages.expense": "Spesa", "tabs.stats.intervalReport.averages.flow": "Flusso", "tabs.stats.intervalReport.averages.income": "Entrate", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 4f03d505..ad5533f7 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} гүйлгээ", "tabs.home.transactionsCount.one": "{count} гүйлгээ", "tabs.profile": "Бүртгэл", + "tabs.profile.analytics": "Аналитик", + "tabs.profile.analytics.calendar": "Зарцуулалтын календарь", + "tabs.profile.analytics.cashFlow": "Мөнгөний урсгал (Sankey)", + "tabs.profile.analytics.map": "Зарцуулалтын газрын зураг", + "tabs.profile.analytics.netWorth": "Цаг хугацааны явцад нийт хөрөнгө", + "tabs.profile.analytics.recurring": "Бүртгэл ба давтагдах", + "tabs.profile.analytics.wrapped": "Сарын тойм", "tabs.profile.backup": "Нөөцлөх", "tabs.profile.community": "Холбоо", "tabs.profile.guide": "Ашиглах заавар", @@ -638,12 +645,82 @@ "tabs.profile.support": "Flow-г дэмжих", "tabs.profile.withLoveFromTheCreator": "sadespresso хайр шингээж бүтээв 🤍", "tabs.stats": "Тоо", + "tabs.stats.analytics.calendar": "Календар", + "tabs.stats.analytics.calendar.priciestDay": "Таны хамгийн их зарцуулалттай өдөр нь {value}.", + "tabs.stats.analytics.calendar.spentIn": "{} дотор зарцуулсан", + "tabs.stats.analytics.cashFlow": "Мөнгөний урсгал", + "tabs.stats.analytics.cashFlow.empty": "Энэ хугацаанд мөнгөний урсгал алга.", + "tabs.stats.analytics.cashFlow.fromReserves": "Нөөцөөс", + "tabs.stats.analytics.cashFlow.loadFailed": "Мөнгөний урсгалыг ачаалж чадсангүй.", + "tabs.stats.analytics.cashFlow.noMovement": "Энэ хугацаанд мөнгө шилжээгүй.", + "tabs.stats.analytics.down": "буурсан", + "tabs.stats.analytics.in": "Ирсэн", + "tabs.stats.analytics.inRange": "{} дотор", + "tabs.stats.analytics.income": "Орлого", + "tabs.stats.analytics.map.empty": "Энэ хугацаанд байршилтай зарцуулалт байхгүй.", + "tabs.stats.analytics.map.locatedCount": "{total}-аас {located} зарцуулалт нь байршилтай", + "tabs.stats.analytics.map.mappedShort": "Байршилтай · {days} хоног", + "tabs.stats.analytics.map.mappedSpend": "Байршилтай зарцуулалт", + "tabs.stats.analytics.map.noneYet": "Одоогоор байршилтай зарцуулалт алга.", + "tabs.stats.analytics.map.pinnedLocation": "Тогтоосон байршил", + "tabs.stats.analytics.map.topPlaces": "Тэргүүлэх байршлууд", + "tabs.stats.analytics.map.visits": "{count} удаа", + "tabs.stats.analytics.map.visits.one": "{count} удаа", + "tabs.stats.analytics.missingRatesAmounts": "Зарим үндсэн бус валютын дүнгүүдийг (харьцуулах ханш байхгүй) алгасаcан.", + "tabs.stats.analytics.missingRatesBalances": "Зарим үндсэн бус валютын үлдэгдлүүдийг (харьцуулах ханш байхгүй) алгассан.", + "tabs.stats.analytics.netWorth": "Нийт хөрөнгө", + "tabs.stats.analytics.netWorth.byAccount": "Данс тус бүрээр", + "tabs.stats.analytics.netWorth.noAccounts": "Хураангуйлах данс олдсонгүй.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Хандлагыг тодорхойлоход хангалттай түүх мэдээлэл алга байна.", + "tabs.stats.analytics.noSpendingRange": "Энэ хүрээнд зарцуулалт байхгүй.", + "tabs.stats.analytics.noSpendingWindow": "Энэ хугацаанд зарцуулалт байхгүй.", + "tabs.stats.analytics.other": "Бусад", + "tabs.stats.analytics.out": "Гарсан", + "tabs.stats.analytics.overspent": "Хэт зарцуулсан", + "tabs.stats.analytics.recurring": "Давтагдах", + "tabs.stats.analytics.recurring.activeSummary": "{count} давтагдах · дараах {days} хоногт", + "tabs.stats.analytics.recurring.committedOutflow": "Баталгаажсан зарлага", + "tabs.stats.analytics.recurring.committedShort": "Үүрэгдсэн · {days} хоног", + "tabs.stats.analytics.recurring.defaultTitle": "Давтагдах гүйлгээ", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} бусад нь үзүүлээгүй", + "tabs.stats.analytics.recurring.none": "Давтагдах гүйлгээ тохируулгаагүй.", + "tabs.stats.analytics.recurring.nothingDue": "Дараах {days} хоногт төлөх зүйл байхгүй.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Ойрын төлөвлөгдсөн зүйл алга.", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} ойрын төлбөр", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} ойрын төлбөр", + "tabs.stats.analytics.rhythm": "Хэмнэл", + "tabs.stats.analytics.saved": "Хэмнэсэн", + "tabs.stats.analytics.spending": "Зарцуулалт", + "tabs.stats.analytics.spendingCalendar": "Зарцуулалтын календарь", + "tabs.stats.analytics.spendingMap": "Зарцуулалтын газрын зураг", + "tabs.stats.analytics.topCategories": "Топ ангилал", + "tabs.stats.analytics.uncategorized": "Ангилгагүй", + "tabs.stats.analytics.untitled": "Гарчиггүй", + "tabs.stats.analytics.up": "өссөн", + "tabs.stats.analytics.wrapped.biggest": "Хамгийн их: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} энэ сар vs ердийн {typical}", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} нь таны 3 сарын дундажтай харьцуулахад {direction} {value}.", + "tabs.stats.analytics.wrapped.frequentEntry": "Таны хамгийн их давтагдсан бүртгэл: {value}", + "tabs.stats.analytics.wrapped.label.category": "Ангилал", + "tabs.stats.analytics.wrapped.label.frequent": "Түгээмэл", + "tabs.stats.analytics.wrapped.label.shape": "Хэлбэр", + "tabs.stats.analytics.wrapped.loggedTimes": "Энэ сард {count} удаа бүртгэгдсэн", + "tabs.stats.analytics.wrapped.medianPurchase": "Таны дундаж худалдан авалт {value} байна.", + "tabs.stats.analytics.wrapped.noExpenses": "Зарлага бүртгэгдээгүй.", + "tabs.stats.analytics.wrapped.noTransactions": "Энэ сар одоогоор гүйлгээ бүртгэгдээгүй.", + "tabs.stats.analytics.wrapped.spendMostOn": "Та хамгийн ихийг {value}-д зарцуулдаг.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} бичлэг · хамгийн их {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} бичлэг · хамгийн их {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Сарынхаа тоймыг үзэх", + "tabs.stats.analytics.wrapped.tileTitle": "Таны {month}-ын тойм", + "tabs.stats.analytics.wrapped.title": "{month}-ын тойм", "tabs.stats.categories": "Ангилал", "tabs.stats.categories.seeAll": "Бүх ангиллыг харах", "tabs.stats.categories.top": "Хамгийн зарлагатай", "tabs.stats.chart.noData": "Харуулах өгөгдөл байхгүй байна", "tabs.stats.chart.select.clickToSelect": "Товшиж сонгоно уу", "tabs.stats.chart.total": "Нийт", + "tabs.stats.insights": "Аналитик", "tabs.stats.intervalReport.averages.expense": "Зарлага", "tabs.stats.intervalReport.averages.flow": "Урсгал", "tabs.stats.intervalReport.averages.income": "Орлого", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index 80066851..2817100d 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -629,6 +629,13 @@ "tabs.home.transactionsCount.many": "{count} transakcji", "tabs.home.transactionsCount.one": "{count} transakcja", "tabs.profile": "Profil", + "tabs.profile.analytics": "Analityka", + "tabs.profile.analytics.calendar": "Kalendarz wydatków", + "tabs.profile.analytics.cashFlow": "Przepływy pieniężne (Sankey)", + "tabs.profile.analytics.map": "Mapa wydatków", + "tabs.profile.analytics.netWorth": "Majątek netto w czasie", + "tabs.profile.analytics.recurring": "Subskrypcje i transakcje cykliczne", + "tabs.profile.analytics.wrapped": "Comiesięczne podsumowanie", "tabs.profile.backup": "Kopie zapasowe", "tabs.profile.community": "Społeczność", "tabs.profile.guide": "Przewodnik", @@ -640,12 +647,82 @@ "tabs.profile.support": "Wsparcie Flow", "tabs.profile.withLoveFromTheCreator": "z 🤍 od sadespresso", "tabs.stats": "Statystyki", + "tabs.stats.analytics.calendar": "Kalendarz", + "tabs.stats.analytics.calendar.priciestDay": "Twój najdroższy dzień to {value}.", + "tabs.stats.analytics.calendar.spentIn": "Wydano w {}", + "tabs.stats.analytics.cashFlow": "Przepływy pieniężne", + "tabs.stats.analytics.cashFlow.empty": "Brak przepływów pieniężnych w tym okresie.", + "tabs.stats.analytics.cashFlow.fromReserves": "Z rezerw", + "tabs.stats.analytics.cashFlow.loadFailed": "Nie udało się załadować przepływu pieniężnego.", + "tabs.stats.analytics.cashFlow.noMovement": "W tym okresie nie było ruchu środków.", + "tabs.stats.analytics.down": "niżej", + "tabs.stats.analytics.in": "Wpływy", + "tabs.stats.analytics.inRange": "w {}", + "tabs.stats.analytics.income": "Przychody", + "tabs.stats.analytics.map.empty": "Brak wydatków z lokalizacją w tym okresie.", + "tabs.stats.analytics.map.locatedCount": "{located} z {total} wydatków ma lokalizację", + "tabs.stats.analytics.map.mappedShort": "Zmapowane · {days}d", + "tabs.stats.analytics.map.mappedSpend": "Zmapowane wydatki", + "tabs.stats.analytics.map.noneYet": "Jeszcze brak wydatków z lokalizacją.", + "tabs.stats.analytics.map.pinnedLocation": "Przypięta lokalizacja", + "tabs.stats.analytics.map.topPlaces": "Najpopularniejsze miejsca", + "tabs.stats.analytics.map.visits": "{count} wizyt", + "tabs.stats.analytics.map.visits.one": "{count} wizyta", + "tabs.stats.analytics.missingRatesAmounts": "Niektóre kwoty w walutach innych niż główna pominięto (brak kursów wymiany).", + "tabs.stats.analytics.missingRatesBalances": "Niektóre salda w walutach innych niż główna pominięto (brak kursów wymiany).", + "tabs.stats.analytics.netWorth": "Majątek netto", + "tabs.stats.analytics.netWorth.byAccount": "Według konta", + "tabs.stats.analytics.netWorth.noAccounts": "Brak kont do podsumowania.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Za mało danych historycznych, aby przedstawić trend.", + "tabs.stats.analytics.noSpendingRange": "Brak wydatków w tym zakresie.", + "tabs.stats.analytics.noSpendingWindow": "Brak wydatków w tym okresie.", + "tabs.stats.analytics.other": "Inne", + "tabs.stats.analytics.out": "Wypływy", + "tabs.stats.analytics.overspent": "Przekroczono budżet", + "tabs.stats.analytics.recurring": "Cykliczne", + "tabs.stats.analytics.recurring.activeSummary": "{count} cyklicznych · następne {days} dni", + "tabs.stats.analytics.recurring.committedOutflow": "Zobowiązane wydatki", + "tabs.stats.analytics.recurring.committedShort": "Zobowiązane · {days}d", + "tabs.stats.analytics.recurring.defaultTitle": "Transakcja cykliczna", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} więcej nie pokazano", + "tabs.stats.analytics.recurring.none": "Brak ustawionych transakcji cyklicznych.", + "tabs.stats.analytics.recurring.nothingDue": "W ciągu następnych {days} dni nic nie jest należne.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Brak nadchodzących", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} nadchodzących obciążeń", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} nadchodzące obciążenie", + "tabs.stats.analytics.rhythm": "Rytm", + "tabs.stats.analytics.saved": "Zaoszczędzone", + "tabs.stats.analytics.spending": "Wydatki", + "tabs.stats.analytics.spendingCalendar": "Kalendarz wydatków", + "tabs.stats.analytics.spendingMap": "Mapa wydatków", + "tabs.stats.analytics.topCategories": "Najważniejsze kategorie", + "tabs.stats.analytics.uncategorized": "Bez kategorii", + "tabs.stats.analytics.untitled": "Bez tytułu", + "tabs.stats.analytics.up": "wyżej", + "tabs.stats.analytics.wrapped.biggest": "Największe: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} w tym miesiącu vs {typical} typowo", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} jest {direction} o {value} w porównaniu z 3-miesięczną średnią.", + "tabs.stats.analytics.wrapped.frequentEntry": "Twój najczęstszy wpis: {value}", + "tabs.stats.analytics.wrapped.label.category": "Kategoria", + "tabs.stats.analytics.wrapped.label.frequent": "Częste", + "tabs.stats.analytics.wrapped.label.shape": "Kształt", + "tabs.stats.analytics.wrapped.loggedTimes": "Zarejestrowano {count} razy w tym miesiącu", + "tabs.stats.analytics.wrapped.medianPurchase": "Mediana zakupów to {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Brak zarejestrowanych wydatków.", + "tabs.stats.analytics.wrapped.noTransactions": "W tym miesiącu jeszcze brak transakcji.", + "tabs.stats.analytics.wrapped.spendMostOn": "Najwięcej wydajesz na {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} wpisów · największy {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} wpis · największy {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Zobacz podsumowanie miesiąca", + "tabs.stats.analytics.wrapped.tileTitle": "Twoje podsumowanie za {month}", + "tabs.stats.analytics.wrapped.title": "{month} — podsumowanie", "tabs.stats.categories": "Kategorie", "tabs.stats.categories.seeAll": "Pokaż wszystkie kategorie", "tabs.stats.categories.top": "Największe wydatki", "tabs.stats.chart.noData": "Brak danych do wyświetlenia", "tabs.stats.chart.select.clickToSelect": "Kliknij, aby wybrać", "tabs.stats.chart.total": "Łącznie", + "tabs.stats.insights": "Analityka", "tabs.stats.intervalReport.averages.expense": "Wydatki", "tabs.stats.intervalReport.averages.flow": "Przepływ", "tabs.stats.intervalReport.averages.income": "Przychody", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index b2e273f7..f997fe96 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -629,6 +629,13 @@ "tabs.home.transactionsCount.many": "{count} транзакций", "tabs.home.transactionsCount.one": "{count} транзакция", "tabs.profile": "Профиль", + "tabs.profile.analytics": "Аналитика", + "tabs.profile.analytics.calendar": "Календарь расходов", + "tabs.profile.analytics.cashFlow": "Денежный поток (Sankey)", + "tabs.profile.analytics.map": "Карта расходов", + "tabs.profile.analytics.netWorth": "Динамика чистой стоимости", + "tabs.profile.analytics.recurring": "Подписки и регулярные платежи", + "tabs.profile.analytics.wrapped": "Итоги месяца", "tabs.profile.backup": "Резервное копирование", "tabs.profile.community": "Сообщество", "tabs.profile.guide": "Руководство по использованию", @@ -640,12 +647,82 @@ "tabs.profile.support": "Поддержать Flow", "tabs.profile.withLoveFromTheCreator": "с 🤍 от sadespresso", "tabs.stats": "Статистика", + "tabs.stats.analytics.calendar": "Календарь", + "tabs.stats.analytics.calendar.priciestDay": "Ваш самый дорогой день — {value}.", + "tabs.stats.analytics.calendar.spentIn": "Потрачено в {}", + "tabs.stats.analytics.cashFlow": "Денежный поток", + "tabs.stats.analytics.cashFlow.empty": "Нет движения денежных средств в этом диапазоне.", + "tabs.stats.analytics.cashFlow.fromReserves": "Из резервов", + "tabs.stats.analytics.cashFlow.loadFailed": "Не удалось загрузить денежный поток.", + "tabs.stats.analytics.cashFlow.noMovement": "Денежные операции отсутствуют в этом диапазоне.", + "tabs.stats.analytics.down": "вниз", + "tabs.stats.analytics.in": "Входящие", + "tabs.stats.analytics.inRange": "в {}", + "tabs.stats.analytics.income": "Доход", + "tabs.stats.analytics.map.empty": "Нет расходов с местоположением в этом периоде.", + "tabs.stats.analytics.map.locatedCount": "{located} из {total} расходов имеют местоположение", + "tabs.stats.analytics.map.mappedShort": "Картировано · {days}д", + "tabs.stats.analytics.map.mappedSpend": "Картированные расходы", + "tabs.stats.analytics.map.noneYet": "Пока нет расходов с местоположением.", + "tabs.stats.analytics.map.pinnedLocation": "Закреплённое место", + "tabs.stats.analytics.map.topPlaces": "Популярные места", + "tabs.stats.analytics.map.visits": "{count} посещений", + "tabs.stats.analytics.map.visits.one": "{count} посещение", + "tabs.stats.analytics.missingRatesAmounts": "Некоторые суммы в непервичной валюте пропущены (отсутствуют обменные курсы).", + "tabs.stats.analytics.missingRatesBalances": "Некоторые балансы в непервичных валютах пропущены (отсутствуют обменные курсы).", + "tabs.stats.analytics.netWorth": "Чистая стоимость", + "tabs.stats.analytics.netWorth.byAccount": "По счетам", + "tabs.stats.analytics.netWorth.noAccounts": "Нет счетов для суммирования.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Недостаточно данных, чтобы построить тренд.", + "tabs.stats.analytics.noSpendingRange": "Нет расходов в этом диапазоне.", + "tabs.stats.analytics.noSpendingWindow": "Нет расходов в этом периоде.", + "tabs.stats.analytics.other": "Другое", + "tabs.stats.analytics.out": "Исходящие", + "tabs.stats.analytics.overspent": "Перерасход", + "tabs.stats.analytics.recurring": "Повторяющиеся", + "tabs.stats.analytics.recurring.activeSummary": "{count} повторяющихся · ближайшие {days} дней", + "tabs.stats.analytics.recurring.committedOutflow": "Обязательные выплаты", + "tabs.stats.analytics.recurring.committedShort": "Обяз. · {days}д", + "tabs.stats.analytics.recurring.defaultTitle": "Повторяющаяся транзакция", + "tabs.stats.analytics.recurring.moreNotShown": "+ ещё {count} не показано", + "tabs.stats.analytics.recurring.none": "Нет настроенных повторяющихся транзакций.", + "tabs.stats.analytics.recurring.nothingDue": "Ничего не запланировано в ближайшие {days} дней.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Ничего не запланировано", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} предстоящих списаний", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} предстоящее списание", + "tabs.stats.analytics.rhythm": "Ритм", + "tabs.stats.analytics.saved": "Сбережения", + "tabs.stats.analytics.spending": "Расходы", + "tabs.stats.analytics.spendingCalendar": "Календарь расходов", + "tabs.stats.analytics.spendingMap": "Карта расходов", + "tabs.stats.analytics.topCategories": "Главные категории", + "tabs.stats.analytics.uncategorized": "Без категории", + "tabs.stats.analytics.untitled": "Без названия", + "tabs.stats.analytics.up": "вверх", + "tabs.stats.analytics.wrapped.biggest": "Крупнейшая: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} в этом месяце против типичных {typical}", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} {direction} на {value} по сравнению с вашим 3-месячным средним.", + "tabs.stats.analytics.wrapped.frequentEntry": "Ваша самая частая запись: {value}", + "tabs.stats.analytics.wrapped.label.category": "Категория", + "tabs.stats.analytics.wrapped.label.frequent": "Частые", + "tabs.stats.analytics.wrapped.label.shape": "Форма", + "tabs.stats.analytics.wrapped.loggedTimes": "Зарегистрировано {count} раз в этом месяце", + "tabs.stats.analytics.wrapped.medianPurchase": "Медианная сумма покупки: {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Нет зарегистрированных расходов.", + "tabs.stats.analytics.wrapped.noTransactions": "Ещё нет транзакций в этом месяце.", + "tabs.stats.analytics.wrapped.spendMostOn": "Вы тратите больше всего на {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} записей · самая крупная {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} запись · самая крупная {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Посмотреть обзор месяца", + "tabs.stats.analytics.wrapped.tileTitle": "Ваш {month}: итоги", + "tabs.stats.analytics.wrapped.title": "{month}: итоги", "tabs.stats.categories": "Категории", "tabs.stats.categories.seeAll": "Посмотреть все категории", "tabs.stats.categories.top": "Наибольшие расходы", "tabs.stats.chart.noData": "Нет данных для отображения", "tabs.stats.chart.select.clickToSelect": "Нажмите, чтобы выбрать", "tabs.stats.chart.total": "Итого", + "tabs.stats.insights": "Аналитика", "tabs.stats.intervalReport.averages.expense": "Расход", "tabs.stats.intervalReport.averages.flow": "Поток", "tabs.stats.intervalReport.averages.income": "Доход", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index f73ebec5..94de6430 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} Hareket", "tabs.home.transactionsCount.one": "{count} işlem", "tabs.profile": "Profil", + "tabs.profile.analytics": "Analitik", + "tabs.profile.analytics.calendar": "Harcamalar takvimi", + "tabs.profile.analytics.cashFlow": "Nakit akışı (Sankey)", + "tabs.profile.analytics.map": "Harcamalar haritası", + "tabs.profile.analytics.netWorth": "Zamana göre net varlık", + "tabs.profile.analytics.recurring": "Abonelikler ve yinelenen işlemler", + "tabs.profile.analytics.wrapped": "Aylık özet", "tabs.profile.backup": "Yedek", "tabs.profile.community": "Topluluk", "tabs.profile.guide": "Kullanım kılavuzu", @@ -638,12 +645,82 @@ "tabs.profile.support": "Flow Destek", "tabs.profile.withLoveFromTheCreator": "sadespresso'dan 🤍", "tabs.stats": "İstatistik", + "tabs.stats.analytics.calendar": "Takvim", + "tabs.stats.analytics.calendar.priciestDay": "En pahalı gününüz {value}.", + "tabs.stats.analytics.calendar.spentIn": "{} içinde harcandı", + "tabs.stats.analytics.cashFlow": "Nakit akışı", + "tabs.stats.analytics.cashFlow.empty": "Bu aralıkta nakit akışı yok.", + "tabs.stats.analytics.cashFlow.fromReserves": "Rezervlerden", + "tabs.stats.analytics.cashFlow.loadFailed": "Nakit akışı yüklenemedi.", + "tabs.stats.analytics.cashFlow.noMovement": "Bu aralıkta para hareketi yok.", + "tabs.stats.analytics.down": "azaldı", + "tabs.stats.analytics.in": "Giren", + "tabs.stats.analytics.inRange": "{} içinde", + "tabs.stats.analytics.income": "Gelir", + "tabs.stats.analytics.map.empty": "Bu aralıkta konumlu harcama yok.", + "tabs.stats.analytics.map.locatedCount": "{total} harcamadan {located} tanesinin konumu var", + "tabs.stats.analytics.map.mappedShort": "Haritalandı · {days} gün", + "tabs.stats.analytics.map.mappedSpend": "Haritalanan harcama", + "tabs.stats.analytics.map.noneYet": "Henüz konumlu harcama yok.", + "tabs.stats.analytics.map.pinnedLocation": "Sabitlenen konum", + "tabs.stats.analytics.map.topPlaces": "En popüler yerler", + "tabs.stats.analytics.map.visits": "{count} ziyaret", + "tabs.stats.analytics.map.visits.one": "{count} ziyaret", + "tabs.stats.analytics.missingRatesAmounts": "Ana para birimi olmayan bazı tutarlar atlandı (döviz kurları eksik).", + "tabs.stats.analytics.missingRatesBalances": "Ana para birimi olmayan bazı bakiyeler atlandı (döviz kurları eksik).", + "tabs.stats.analytics.netWorth": "Net varlık", + "tabs.stats.analytics.netWorth.byAccount": "Hesaba göre", + "tabs.stats.analytics.netWorth.noAccounts": "Özetlenecek hesap yok.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Bir eğilim çizmek için yeterli geçmiş yok.", + "tabs.stats.analytics.noSpendingRange": "Bu aralıkta harcama yok.", + "tabs.stats.analytics.noSpendingWindow": "Bu zaman aralığında harcama yok.", + "tabs.stats.analytics.other": "Diğer", + "tabs.stats.analytics.out": "Çıkan", + "tabs.stats.analytics.overspent": "Aşırı harcandı", + "tabs.stats.analytics.recurring": "Yinelenen", + "tabs.stats.analytics.recurring.activeSummary": "{count} yinelenen · önümüzdeki {days} gün", + "tabs.stats.analytics.recurring.committedOutflow": "Taahhüt edilmiş çıkış", + "tabs.stats.analytics.recurring.committedShort": "Taahhüt edilmiş · {days} gün", + "tabs.stats.analytics.recurring.defaultTitle": "Yinelenen işlem", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} daha gösterilmiyor", + "tabs.stats.analytics.recurring.none": "Hiç yinelenen işlem ayarlanmadı.", + "tabs.stats.analytics.recurring.nothingDue": "Önümüzdeki {days} gün içinde ödenecek bir şey yok.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Yaklaşan yok", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} yaklaşan işlem", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} yaklaşan işlem", + "tabs.stats.analytics.rhythm": "Ritim", + "tabs.stats.analytics.saved": "Biriktirilen", + "tabs.stats.analytics.spending": "Gider", + "tabs.stats.analytics.spendingCalendar": "Harcamalar takvimi", + "tabs.stats.analytics.spendingMap": "Harcamalar haritası", + "tabs.stats.analytics.topCategories": "En popüler kategoriler", + "tabs.stats.analytics.uncategorized": "Kategorize edilmemiş", + "tabs.stats.analytics.untitled": "Adsız", + "tabs.stats.analytics.up": "arttı", + "tabs.stats.analytics.wrapped.biggest": "En büyük: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} bu ay · tipik {typical}", + "tabs.stats.analytics.wrapped.categoryTrend": "{name}, 3 aylık ortalamanıza göre {direction} {value}.", + "tabs.stats.analytics.wrapped.frequentEntry": "En sık yaptığınız işlem: {value}", + "tabs.stats.analytics.wrapped.label.category": "Kategori", + "tabs.stats.analytics.wrapped.label.frequent": "Sık", + "tabs.stats.analytics.wrapped.label.shape": "Şekil", + "tabs.stats.analytics.wrapped.loggedTimes": "Bu ay {count} kez kaydedildi", + "tabs.stats.analytics.wrapped.medianPurchase": "Medyan satın alma tutarınız {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Kayıtlı gider yok.", + "tabs.stats.analytics.wrapped.noTransactions": "Bu ay henüz işlem yok.", + "tabs.stats.analytics.wrapped.spendMostOn": "En çok {value} harcıyorsunuz.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} kayıt · en büyük {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} kayıt · en büyük {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Ayınızın özetini görün", + "tabs.stats.analytics.wrapped.tileTitle": "{month} özetiniz", + "tabs.stats.analytics.wrapped.title": "{month} özeti", "tabs.stats.categories": "Kategoriler", "tabs.stats.categories.seeAll": "Tüm kategorileri gör", "tabs.stats.categories.top": "En fazla harcama yapılan", "tabs.stats.chart.noData": "Gösterilecek veri yok", "tabs.stats.chart.select.clickToSelect": "Seçmek için tıklayın", "tabs.stats.chart.total": "Toplam", + "tabs.stats.insights": "Analitik", "tabs.stats.intervalReport.averages.expense": "Gider", "tabs.stats.intervalReport.averages.flow": "Akış", "tabs.stats.intervalReport.averages.income": "Gelir", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index a96f9928..8a013fce 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -629,6 +629,13 @@ "tabs.home.transactionsCount.many": "{count} транзакцій", "tabs.home.transactionsCount.one": "{count} транзакція", "tabs.profile": "Профіль", + "tabs.profile.analytics": "Аналітика", + "tabs.profile.analytics.calendar": "Календар витрат", + "tabs.profile.analytics.cashFlow": "Грошовий потік (Sankey)", + "tabs.profile.analytics.map": "Карта витрат", + "tabs.profile.analytics.netWorth": "Динаміка чистих активів", + "tabs.profile.analytics.recurring": "Підписки та повторювані платежі", + "tabs.profile.analytics.wrapped": "Місячний підсумок", "tabs.profile.backup": "Резервне копіювання", "tabs.profile.community": "Спільнота", "tabs.profile.guide": "Посібник з користування", @@ -640,12 +647,82 @@ "tabs.profile.support": "Підтримати Flow", "tabs.profile.withLoveFromTheCreator": "з 🤍 від sadespresso", "tabs.stats": "Статистика", + "tabs.stats.analytics.calendar": "Календар", + "tabs.stats.analytics.calendar.priciestDay": "Ваш найдорожчий день — {value}.", + "tabs.stats.analytics.calendar.spentIn": "Витрачено в {}", + "tabs.stats.analytics.cashFlow": "Грошовий потік", + "tabs.stats.analytics.cashFlow.empty": "Немає руху коштів у цьому проміжку.", + "tabs.stats.analytics.cashFlow.fromReserves": "Із резервів", + "tabs.stats.analytics.cashFlow.loadFailed": "Не вдалося завантажити грошовий потік.", + "tabs.stats.analytics.cashFlow.noMovement": "У цьому проміжку не було руху коштів.", + "tabs.stats.analytics.down": "вниз", + "tabs.stats.analytics.in": "Надходження", + "tabs.stats.analytics.inRange": "в {}", + "tabs.stats.analytics.income": "Дохід", + "tabs.stats.analytics.map.empty": "У цьому проміжку немає витрат з вказаною локацією.", + "tabs.stats.analytics.map.locatedCount": "{located} з {total} витрат мають локацію", + "tabs.stats.analytics.map.mappedShort": "На мапі · {days}д", + "tabs.stats.analytics.map.mappedSpend": "Витрати на мапі", + "tabs.stats.analytics.map.noneYet": "Ще немає витрат з локаціями.", + "tabs.stats.analytics.map.pinnedLocation": "Закріплена локація", + "tabs.stats.analytics.map.topPlaces": "Найпопулярніші місця", + "tabs.stats.analytics.map.visits": "{count} відвідувань", + "tabs.stats.analytics.map.visits.one": "{count} відвідування", + "tabs.stats.analytics.missingRatesAmounts": "Деякі суми в неосновних валютах були пропущені (відсутні курси обміну).", + "tabs.stats.analytics.missingRatesBalances": "Деякі залишки в неосновних валютах були пропущені (відсутні курси обміну).", + "tabs.stats.analytics.netWorth": "Чисті активи", + "tabs.stats.analytics.netWorth.byAccount": "За рахунком", + "tabs.stats.analytics.netWorth.noAccounts": "Немає рахунків для підсумування.", + "tabs.stats.analytics.netWorth.notEnoughHistory": "Недостатньо даних для побудови тренду.", + "tabs.stats.analytics.noSpendingRange": "У цьому діапазоні немає витрат.", + "tabs.stats.analytics.noSpendingWindow": "За цей період витрат немає.", + "tabs.stats.analytics.other": "Інше", + "tabs.stats.analytics.out": "Витрати", + "tabs.stats.analytics.overspent": "Перевитрачено", + "tabs.stats.analytics.recurring": "Повторювані", + "tabs.stats.analytics.recurring.activeSummary": "{count} повторюваних · наступні {days} дні", + "tabs.stats.analytics.recurring.committedOutflow": "Запланований відтік", + "tabs.stats.analytics.recurring.committedShort": "Заплановано · {days}д", + "tabs.stats.analytics.recurring.defaultTitle": "Повторювана транзакція", + "tabs.stats.analytics.recurring.moreNotShown": "+ ще {count} не показано", + "tabs.stats.analytics.recurring.none": "Не налаштовано повторюваних транзакцій.", + "tabs.stats.analytics.recurring.nothingDue": "Нічого не потрібно сплачувати в наступні {days} днів.", + "tabs.stats.analytics.recurring.nothingUpcoming": "Нічого не заплановано", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} майбутніх списань", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} майбутнє списання", + "tabs.stats.analytics.rhythm": "Ритм", + "tabs.stats.analytics.saved": "Заощаджено", + "tabs.stats.analytics.spending": "Витрати", + "tabs.stats.analytics.spendingCalendar": "Календар витрат", + "tabs.stats.analytics.spendingMap": "Карта витрат", + "tabs.stats.analytics.topCategories": "Топ-категорії", + "tabs.stats.analytics.uncategorized": "Без категорії", + "tabs.stats.analytics.untitled": "Без назви", + "tabs.stats.analytics.up": "вгору", + "tabs.stats.analytics.wrapped.biggest": "Найбільша: {title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current} цього місяця проти типових {typical}.", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} {direction} на {value} порівняно з вашим 3-місячним середнім.", + "tabs.stats.analytics.wrapped.frequentEntry": "Ваш найчастіший запис: {value}", + "tabs.stats.analytics.wrapped.label.category": "Категорія", + "tabs.stats.analytics.wrapped.label.frequent": "Часті", + "tabs.stats.analytics.wrapped.label.shape": "Форма", + "tabs.stats.analytics.wrapped.loggedTimes": "Зареєстровано {count} разів цього місяця", + "tabs.stats.analytics.wrapped.medianPurchase": "Медіанна покупка становить {value}.", + "tabs.stats.analytics.wrapped.noExpenses": "Витрат не зафіксовано.", + "tabs.stats.analytics.wrapped.noTransactions": "У цьому місяці ще немає транзакцій.", + "tabs.stats.analytics.wrapped.spendMostOn": "Ви витрачаєте найбільше на {value}.", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} записів · найбільше {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} запис · найбільше {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Перегляньте підсумки місяця", + "tabs.stats.analytics.wrapped.tileTitle": "Ваш підсумок за {month}", + "tabs.stats.analytics.wrapped.title": "{month}: підсумок", "tabs.stats.categories": "Категорії", "tabs.stats.categories.seeAll": "Переглянути всі категорії", "tabs.stats.categories.top": "Найбільші витрати", "tabs.stats.chart.noData": "Немає даних для відображення", "tabs.stats.chart.select.clickToSelect": "Натисніть, щоб вибрати", "tabs.stats.chart.total": "Разом", + "tabs.stats.insights": "Аналітика", "tabs.stats.intervalReport.averages.expense": "Витрата", "tabs.stats.intervalReport.averages.flow": "Потік", "tabs.stats.intervalReport.averages.income": "Дохід", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 7d3d2b54..4a6d8855 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -627,6 +627,13 @@ "tabs.home.transactionsCount": "{count} 筆交易", "tabs.home.transactionsCount.one": "{count} 筆交易", "tabs.profile": "個人", + "tabs.profile.analytics": "分析", + "tabs.profile.analytics.calendar": "支出日曆", + "tabs.profile.analytics.cashFlow": "現金流(Sankey)", + "tabs.profile.analytics.map": "支出地圖", + "tabs.profile.analytics.netWorth": "淨資產變動", + "tabs.profile.analytics.recurring": "訂閱與定期交易", + "tabs.profile.analytics.wrapped": "月度回顧", "tabs.profile.backup": "備份", "tabs.profile.community": "社群", "tabs.profile.guide": "使用指南", @@ -638,12 +645,82 @@ "tabs.profile.support": "支持 Flow", "tabs.profile.withLoveFromTheCreator": "來自 sadespresso 🤍 的作品", "tabs.stats": "統計", + "tabs.stats.analytics.calendar": "日曆", + "tabs.stats.analytics.calendar.priciestDay": "你花費最高的一天是 {value}。", + "tabs.stats.analytics.calendar.spentIn": "在 {} 的支出", + "tabs.stats.analytics.cashFlow": "現金流", + "tabs.stats.analytics.cashFlow.empty": "此範圍內無現金流。", + "tabs.stats.analytics.cashFlow.fromReserves": "從儲備金", + "tabs.stats.analytics.cashFlow.loadFailed": "無法載入現金流。", + "tabs.stats.analytics.cashFlow.noMovement": "此範圍內沒有資金流動。", + "tabs.stats.analytics.down": "下降", + "tabs.stats.analytics.in": "流入", + "tabs.stats.analytics.inRange": "在 {} 內", + "tabs.stats.analytics.income": "收入", + "tabs.stats.analytics.map.empty": "此時間範圍內沒有已定位的支出。", + "tabs.stats.analytics.map.locatedCount": "{located} / {total} 筆支出有位置資訊", + "tabs.stats.analytics.map.mappedShort": "已定位 · {days}天", + "tabs.stats.analytics.map.mappedSpend": "已定位支出", + "tabs.stats.analytics.map.noneYet": "尚無已定位的支出。", + "tabs.stats.analytics.map.pinnedLocation": "釘選位置", + "tabs.stats.analytics.map.topPlaces": "熱門地點", + "tabs.stats.analytics.map.visits": "{count} 次造訪", + "tabs.stats.analytics.map.visits.one": "{count} 次造訪", + "tabs.stats.analytics.missingRatesAmounts": "某些非主要貨幣的金額已被略過(缺少匯率)。", + "tabs.stats.analytics.missingRatesBalances": "某些非主要貨幣的餘額已被略過(缺少匯率)。", + "tabs.stats.analytics.netWorth": "淨資產", + "tabs.stats.analytics.netWorth.byAccount": "按帳戶", + "tabs.stats.analytics.netWorth.noAccounts": "沒有帳戶可供彙總。", + "tabs.stats.analytics.netWorth.notEnoughHistory": "沒有足夠的歷史資料來繪製趨勢。", + "tabs.stats.analytics.noSpendingRange": "此範圍內無支出。", + "tabs.stats.analytics.noSpendingWindow": "此時間範圍內無支出。", + "tabs.stats.analytics.other": "其他", + "tabs.stats.analytics.out": "流出", + "tabs.stats.analytics.overspent": "超支", + "tabs.stats.analytics.recurring": "定期交易", + "tabs.stats.analytics.recurring.activeSummary": "{count} 個定期項目 · 未來 {days} 天", + "tabs.stats.analytics.recurring.committedOutflow": "已承諾支出", + "tabs.stats.analytics.recurring.committedShort": "已承諾 · {days}天", + "tabs.stats.analytics.recurring.defaultTitle": "定期交易", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} 項未顯示", + "tabs.stats.analytics.recurring.none": "未設定任何定期交易。", + "tabs.stats.analytics.recurring.nothingDue": "未來 {days} 天內沒有到期項目。", + "tabs.stats.analytics.recurring.nothingUpcoming": "近期無項目", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} 預定扣款", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} 預定扣款", + "tabs.stats.analytics.rhythm": "節奏", + "tabs.stats.analytics.saved": "已儲蓄", + "tabs.stats.analytics.spending": "支出", + "tabs.stats.analytics.spendingCalendar": "支出日曆", + "tabs.stats.analytics.spendingMap": "支出地圖", + "tabs.stats.analytics.topCategories": "熱門類別", + "tabs.stats.analytics.uncategorized": "未分類", + "tabs.stats.analytics.untitled": "未命名", + "tabs.stats.analytics.up": "上升", + "tabs.stats.analytics.wrapped.biggest": "最大:{title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current}(本月) vs 典型值 {typical}", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} 相較於你過去 3 個月的平均 {direction} {value}。", + "tabs.stats.analytics.wrapped.frequentEntry": "你最常記錄的項目:{value}", + "tabs.stats.analytics.wrapped.label.category": "類別", + "tabs.stats.analytics.wrapped.label.frequent": "常見", + "tabs.stats.analytics.wrapped.label.shape": "型態", + "tabs.stats.analytics.wrapped.loggedTimes": "本月記錄 {count} 次", + "tabs.stats.analytics.wrapped.medianPurchase": "你的中位數消費為 {value}。", + "tabs.stats.analytics.wrapped.noExpenses": "尚未記錄支出。", + "tabs.stats.analytics.wrapped.noTransactions": "本月尚無交易。", + "tabs.stats.analytics.wrapped.spendMostOn": "你最多花在 {value}。", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} 筆紀錄 · 最大 {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} 筆紀錄 · 最大 {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "查看你的月度回顧", + "tabs.stats.analytics.wrapped.tileTitle": "你的 {month} 回顧", + "tabs.stats.analytics.wrapped.title": "{month} 回顧", "tabs.stats.categories": "分類", "tabs.stats.categories.seeAll": "查看所有分類", "tabs.stats.categories.top": "最高支出", "tabs.stats.chart.noData": "沒有資料可顯示", "tabs.stats.chart.select.clickToSelect": "點擊以選擇", "tabs.stats.chart.total": "總計", + "tabs.stats.insights": "分析", "tabs.stats.intervalReport.averages.expense": "平均支出", "tabs.stats.intervalReport.averages.flow": "平均總收支", "tabs.stats.intervalReport.averages.income": "平均收入", diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9fec558e..59d91f27 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -401,6 +401,9 @@ transactions. NSFaceIDUsageDescription Flow uses Face ID if you choose to require Face ID to open the app for authentication + NSLocationAlwaysAndWhenInUseUsageDescription + Location is used if you choose to auto-attach your current location to your + transactions. NSLocationWhenInUseUsageDescription Location is used if you choose to auto-attach your current location to your transactions. diff --git a/lib/data/flow_icon.dart b/lib/data/flow_icon.dart index 52eec14e..427c47f3 100644 --- a/lib/data/flow_icon.dart +++ b/lib/data/flow_icon.dart @@ -6,6 +6,7 @@ import "package:cross_file/cross_file.dart"; import "package:flow/objectbox.dart"; import "package:flutter/material.dart"; import "package:path/path.dart" as path; +import "package:simple_icons_flow/simple_icons_flow.dart"; import "package:uuid/uuid.dart"; /// An icon, emoji, or image used for [Account] or [Category] @@ -13,6 +14,7 @@ abstract class FlowIconData { const FlowIconData(); factory FlowIconData.icon(IconData iconData) => IconFlowIcon(iconData); + factory FlowIconData.simpleIcon(String slug) => SimpleIconFlowIcon(slug); factory FlowIconData.emoji(String char) => CharacterFlowIcon(char); factory FlowIconData.image(String path) => ImageFlowIcon(path); @@ -21,6 +23,7 @@ abstract class FlowIconData { return switch (type) { "IconFlowIcon" => IconFlowIcon.parse(serialized), + "SimpleIconFlowIcon" => SimpleIconFlowIcon.parse(serialized), "ImageFlowIcon" => ImageFlowIcon.parse(serialized), "CharacterFlowIcon" => CharacterFlowIcon.parse(serialized), _ => throw UnimplementedError(), @@ -72,6 +75,19 @@ class IconFlowIcon extends FlowIconData { const IconFlowIcon(this.iconData); + /// Legacy [IconData.fontPackage] values that predate Flow's own forks of + /// the icon packages. Icons saved before the rename still carry the + /// original package name, so we remap on parse to keep their glyphs + /// resolvable. The font families and code points are unchanged. + /// + /// Note: `simple_icons` brand icons are migrated to [SimpleIconFlowIcon] + /// (slug-based) by `migrateSimpleIconsToSlug`; the entry below is only a + /// best-effort fallback for any un-migrated legacy/backup data. + static const Map _fontPackageMigration = { + "material_symbols_icons": "material_symbols_icons_flow", + "simple_icons": "simple_icons_flow", + }; + @override String toString() { return "IconFlowIcon:${iconData.fontFamily},${iconData.fontPackage},${iconData.codePoint.toRadixString(16)}"; @@ -89,7 +105,7 @@ class IconFlowIcon extends FlowIconData { // ignore: non_const_argument_for_const_parameter fontFamily: fontFamily, // ignore: non_const_argument_for_const_parameter - fontPackage: fontPackage, + fontPackage: _fontPackageMigration[fontPackage] ?? fontPackage, ), ); } @@ -103,6 +119,37 @@ class IconFlowIcon extends FlowIconData { } } +/// A Simple Icons brand glyph, stored by its **slug** (the stable key in +/// [SimpleIcons.values], e.g. `paypal`) rather than a code point. +/// +/// Simple Icons reassigns code points sequentially every release, so a stored +/// code point silently points at a different brand after a package bump. The +/// slug is stable across releases, so we persist that and resolve the glyph at +/// render time. +class SimpleIconFlowIcon extends FlowIconData { + final String slug; + + const SimpleIconFlowIcon(this.slug); + + /// The resolved glyph, or `null` when the slug is no longer present in the + /// bundled Simple Icons version (removed or renamed upstream). + IconData? get iconData => SimpleIcons.values[slug]; + + @override + String toString() => "SimpleIconFlowIcon:$slug"; + + static FlowIconData parse(String serialized) => + FlowIconData.simpleIcon(serialized.split(":").last); + + static FlowIconData? tryParse(String serialized) { + try { + return parse(serialized); + } catch (e) { + return null; + } + } +} + class ImageFlowIcon extends FlowIconData { /// Ideally, image is stored in data direcotry of the app. /// diff --git a/lib/data/icons.dart b/lib/data/icons.dart index 137a34ff..1c200f4e 100644 --- a/lib/data/icons.dart +++ b/lib/data/icons.dart @@ -4,10 +4,12 @@ import "package:material_symbols_icons_flow/iconname_to_unicode_map.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:simple_icons_flow/simple_icons_flow.dart"; -List querySimpleIcons(String query) { +/// Returns slug → glyph entries so the picker can persist the stable Simple +/// Icons slug (see [SimpleIconFlowIcon]) rather than a drift-prone code point. +List> querySimpleIcons(String query) { final String trimmed = query.trim(); - if (trimmed.isEmpty) return SimpleIcons.values.values.toList(); + if (trimmed.isEmpty) return SimpleIcons.values.entries.toList(); final List queryResults = extractTop( query: trimmed.startsWith(RegExp(r"\d")) ? "n$trimmed" : trimmed, @@ -15,7 +17,9 @@ List querySimpleIcons(String query) { limit: 50, ).map((extractedResult) => extractedResult.choice).toList(); - return queryResults.map((key) => SimpleIcons.values[key]!).toList(); + return queryResults + .map((key) => MapEntry(key, SimpleIcons.values[key]!)) + .toList(); } IconData _getMaterialSymbolsForCodepoint(int codepoint) => IconData( diff --git a/lib/data/legacy_simple_icons_codepoints.dart b/lib/data/legacy_simple_icons_codepoints.dart new file mode 100644 index 00000000..569b4255 --- /dev/null +++ b/lib/data/legacy_simple_icons_codepoints.dart @@ -0,0 +1,3155 @@ +// GENERATED — do not edit by hand. +// +// simple_icons assigns code points sequentially by icon slug, so they +// shift between releases. Flow shipped simple_icons 14.6.1; the fork +// simple_icons_flow is built from 16.20.0. This table remaps a legacy +// (14.6.1) code point to its 16.20.0 equivalent, matched by slug, so +// brand icons saved before the migration keep pointing at the same +// glyph. Slugs removed upstream have no entry and fall back unchanged. +library; + +const Map legacySimpleIconsCodepoints = { + 0xea17: 0xea18, + 0xea18: 0xea19, + 0xea19: 0xea1a, + 0xea1a: 0xea1b, + 0xea1b: 0xea1c, + 0xea1c: 0xea1d, + 0xea1d: 0xea1e, + 0xea1e: 0xea1f, + 0xea1f: 0xea20, + 0xea20: 0xea21, + 0xea21: 0xea22, + 0xea22: 0xea24, + 0xea23: 0xea26, + 0xea24: 0xea27, + 0xea25: 0xea28, + 0xea26: 0xea29, + 0xea27: 0xea2a, + 0xea28: 0xea2c, + 0xea29: 0xea2e, + 0xea2a: 0xea2f, + 0xea2b: 0xea30, + 0xea2c: 0xea31, + 0xea2d: 0xea32, + 0xea2e: 0xea33, + 0xea2f: 0xea34, + 0xea30: 0xea35, + 0xea31: 0xea36, + 0xea32: 0xea37, + 0xea33: 0xea38, + 0xea34: 0xea39, + 0xea36: 0xea3a, + 0xea37: 0xea3b, + 0xea3a: 0xea3c, + 0xea3b: 0xea3d, + 0xea40: 0xea3e, + 0xea41: 0xea3f, + 0xea42: 0xea40, + 0xea43: 0xea41, + 0xea44: 0xea42, + 0xea45: 0xea43, + 0xea46: 0xea44, + 0xea47: 0xea45, + 0xea48: 0xea46, + 0xea49: 0xea47, + 0xea4a: 0xea48, + 0xea4b: 0xea49, + 0xea4c: 0xea4a, + 0xea4d: 0xea4b, + 0xea4e: 0xea4c, + 0xea4f: 0xea4d, + 0xea50: 0xea4e, + 0xea51: 0xea4f, + 0xea52: 0xea50, + 0xea53: 0xea51, + 0xea54: 0xea52, + 0xea55: 0xea53, + 0xea56: 0xea54, + 0xea57: 0xea55, + 0xea58: 0xea56, + 0xea59: 0xea57, + 0xea5a: 0xea58, + 0xea5b: 0xea59, + 0xea5c: 0xea5a, + 0xea5d: 0xea5b, + 0xea5e: 0xea5c, + 0xea5f: 0xea5d, + 0xea60: 0xea5e, + 0xea61: 0xea5f, + 0xea62: 0xea60, + 0xea64: 0xea61, + 0xea65: 0xea62, + 0xea66: 0xea63, + 0xea67: 0xea64, + 0xea68: 0xea65, + 0xea69: 0xea66, + 0xea6a: 0xea67, + 0xea6b: 0xea68, + 0xea6c: 0xea6a, + 0xea6d: 0xea6b, + 0xea6f: 0xea6c, + 0xea70: 0xea6d, + 0xea71: 0xea6e, + 0xea72: 0xea6f, + 0xea73: 0xea70, + 0xea76: 0xea71, + 0xea92: 0xea72, + 0xea93: 0xea73, + 0xea94: 0xea74, + 0xea95: 0xea75, + 0xea96: 0xea76, + 0xea97: 0xea77, + 0xea98: 0xea78, + 0xea99: 0xea79, + 0xea9a: 0xea7a, + 0xea9b: 0xea7b, + 0xea9c: 0xea7c, + 0xea9d: 0xea7d, + 0xea9e: 0xea7e, + 0xea9f: 0xea7f, + 0xeaa0: 0xea80, + 0xeaa1: 0xea82, + 0xeaa2: 0xea83, + 0xeaa3: 0xea85, + 0xeaa4: 0xea86, + 0xeaa5: 0xea87, + 0xeaa6: 0xea88, + 0xeaa7: 0xea89, + 0xeaa8: 0xea8a, + 0xeaa9: 0xea8b, + 0xeaaa: 0xea8c, + 0xeaab: 0xea8e, + 0xeaac: 0xea8f, + 0xeaad: 0xea90, + 0xeaae: 0xea91, + 0xeaaf: 0xea92, + 0xeab1: 0xea93, + 0xeab2: 0xea94, + 0xeab3: 0xea95, + 0xeab4: 0xea97, + 0xeab5: 0xea98, + 0xeab6: 0xea99, + 0xeab7: 0xea9a, + 0xeab8: 0xea9b, + 0xeab9: 0xea9d, + 0xeaba: 0xea9e, + 0xeabb: 0xea9f, + 0xeabc: 0xeaa0, + 0xeabd: 0xeaa1, + 0xeabe: 0xeaa2, + 0xeabf: 0xeaa3, + 0xeac0: 0xeaa4, + 0xeac1: 0xeaa5, + 0xeac2: 0xeaa6, + 0xeac3: 0xeaa7, + 0xeac4: 0xeaa8, + 0xeac5: 0xeaa9, + 0xeac6: 0xeaaa, + 0xeac7: 0xeaab, + 0xeac8: 0xeaac, + 0xeac9: 0xeaad, + 0xeaca: 0xeaae, + 0xeacb: 0xeab0, + 0xeacc: 0xeab1, + 0xeacd: 0xeab2, + 0xeace: 0xeab3, + 0xeacf: 0xeab4, + 0xead0: 0xeab5, + 0xead1: 0xeab6, + 0xead2: 0xeab7, + 0xead3: 0xeab8, + 0xead4: 0xeab9, + 0xead5: 0xeaba, + 0xead6: 0xeabb, + 0xead7: 0xeabc, + 0xead8: 0xeabd, + 0xead9: 0xeabe, + 0xeada: 0xeac0, + 0xeadb: 0xeac1, + 0xeadc: 0xeac2, + 0xeadd: 0xeac3, + 0xeade: 0xeac4, + 0xeadf: 0xeac5, + 0xeae0: 0xeac6, + 0xeae1: 0xeac7, + 0xeae2: 0xeac9, + 0xeae3: 0xeaca, + 0xeae4: 0xeacb, + 0xeae5: 0xeacc, + 0xeae6: 0xeacd, + 0xeae7: 0xeace, + 0xeae8: 0xeacf, + 0xeae9: 0xead0, + 0xeaea: 0xead1, + 0xeaeb: 0xead2, + 0xeaec: 0xead3, + 0xeaed: 0xead4, + 0xeaee: 0xead5, + 0xeaef: 0xead6, + 0xeaf0: 0xead7, + 0xeaf1: 0xead8, + 0xeaf2: 0xead9, + 0xeaf3: 0xeada, + 0xeaf4: 0xeadb, + 0xeaf5: 0xeadc, + 0xeaf6: 0xeadd, + 0xeaf7: 0xeade, + 0xeaf8: 0xeadf, + 0xeaf9: 0xeae0, + 0xeafa: 0xeae1, + 0xeafb: 0xeae2, + 0xeafc: 0xeae3, + 0xeafd: 0xeae4, + 0xeafe: 0xeae5, + 0xeaff: 0xeae6, + 0xeb00: 0xeae7, + 0xeb01: 0xeae8, + 0xeb02: 0xeae9, + 0xeb03: 0xeaea, + 0xeb04: 0xeaeb, + 0xeb05: 0xeaec, + 0xeb06: 0xeaed, + 0xeb07: 0xeaee, + 0xeb08: 0xeaef, + 0xeb09: 0xeaf0, + 0xeb0a: 0xeaf1, + 0xeb0b: 0xeaf2, + 0xeb0c: 0xeaf3, + 0xeb0d: 0xeaf4, + 0xeb0e: 0xeaf5, + 0xeb0f: 0xeaf6, + 0xeb10: 0xeaf7, + 0xeb11: 0xeaf8, + 0xeb12: 0xeaf9, + 0xeb13: 0xeafa, + 0xeb14: 0xeafb, + 0xeb15: 0xeafc, + 0xeb16: 0xeafe, + 0xeb17: 0xeaff, + 0xeb18: 0xeb00, + 0xeb1a: 0xeb01, + 0xeb1b: 0xeb02, + 0xeb1c: 0xeb03, + 0xeb1d: 0xeb04, + 0xeb1e: 0xeb05, + 0xeb1f: 0xeb06, + 0xeb20: 0xeb07, + 0xeb21: 0xeb08, + 0xeb22: 0xeb09, + 0xeb23: 0xeb0a, + 0xeb24: 0xeb0b, + 0xeb25: 0xeb0d, + 0xeb26: 0xeb0e, + 0xeb27: 0xeb0f, + 0xeb28: 0xeb11, + 0xeb29: 0xeb12, + 0xeb30: 0xeb13, + 0xeb31: 0xeb14, + 0xeb32: 0xeb16, + 0xeb33: 0xeb18, + 0xeb34: 0xeb19, + 0xeb35: 0xeb1a, + 0xeb36: 0xeb1b, + 0xeb37: 0xeb1c, + 0xeb38: 0xeb1d, + 0xeb39: 0xeb1e, + 0xeb3a: 0xeb1f, + 0xeb3b: 0xeb21, + 0xeb3c: 0xeb22, + 0xeb3d: 0xeb23, + 0xeb3e: 0xeb24, + 0xeb3f: 0xeb25, + 0xeb40: 0xeb26, + 0xeb41: 0xeb27, + 0xeb42: 0xeb28, + 0xeb43: 0xeb29, + 0xeb44: 0xeb2a, + 0xeb45: 0xeb2b, + 0xeb46: 0xeb2c, + 0xeb47: 0xeb2e, + 0xeb48: 0xeb2f, + 0xeb49: 0xeb30, + 0xeb4a: 0xeb31, + 0xeb4b: 0xeb32, + 0xeb4c: 0xeb33, + 0xeb4d: 0xeb34, + 0xeb4e: 0xeb35, + 0xeb4f: 0xeb36, + 0xeb50: 0xeb37, + 0xeb51: 0xeb38, + 0xeb52: 0xeb39, + 0xeb53: 0xeb3a, + 0xeb54: 0xeb3b, + 0xeb55: 0xeb3c, + 0xeb56: 0xeb3d, + 0xeb57: 0xeb3e, + 0xeb58: 0xeb3f, + 0xeb59: 0xeb40, + 0xeb5a: 0xeb41, + 0xeb5b: 0xeb42, + 0xeb5c: 0xeb43, + 0xeb5d: 0xeb45, + 0xeb5e: 0xeb46, + 0xeb5f: 0xeb47, + 0xeb60: 0xeb48, + 0xeb61: 0xeb49, + 0xeb62: 0xeb4a, + 0xeb63: 0xeb4b, + 0xeb64: 0xeb4c, + 0xeb65: 0xeb4d, + 0xeb66: 0xeb4e, + 0xeb67: 0xeb4f, + 0xeb68: 0xeb50, + 0xeb69: 0xeb52, + 0xeb6a: 0xeb53, + 0xeb6b: 0xeb54, + 0xeb6c: 0xeb55, + 0xeb6d: 0xeb56, + 0xeb6e: 0xeb57, + 0xeb6f: 0xeb58, + 0xeb70: 0xeb59, + 0xeb71: 0xeb5a, + 0xeb72: 0xeb5b, + 0xeb73: 0xeb5c, + 0xeb74: 0xeb5e, + 0xeb75: 0xeb5f, + 0xeb76: 0xeb60, + 0xeb77: 0xeb62, + 0xeb78: 0xeb63, + 0xeb79: 0xeb64, + 0xeb7a: 0xeb65, + 0xeb7b: 0xeb66, + 0xeb7c: 0xeb68, + 0xeb7d: 0xeb69, + 0xeb7e: 0xeb6a, + 0xeb7f: 0xeb6b, + 0xeb80: 0xeb6c, + 0xeb81: 0xeb6d, + 0xeb82: 0xeb6e, + 0xeb83: 0xeb6f, + 0xeb84: 0xeb70, + 0xeb85: 0xeb71, + 0xeb86: 0xeb72, + 0xeb87: 0xeb73, + 0xeb88: 0xeb74, + 0xeb89: 0xeb75, + 0xeb8a: 0xeb76, + 0xeb8b: 0xeb78, + 0xeb8c: 0xeb79, + 0xeb8d: 0xeb7a, + 0xeb8e: 0xeb7c, + 0xeb8f: 0xeb7d, + 0xeb90: 0xeb7e, + 0xeb91: 0xeb7f, + 0xeb92: 0xeb80, + 0xeb93: 0xeb81, + 0xeb94: 0xeb82, + 0xeb95: 0xeb83, + 0xeb96: 0xeb84, + 0xeb97: 0xeb85, + 0xeb98: 0xeb86, + 0xeb99: 0xeb87, + 0xeb9a: 0xeb88, + 0xeb9b: 0xeb89, + 0xeb9c: 0xeb8a, + 0xeb9d: 0xeb8b, + 0xeb9e: 0xeb8e, + 0xeb9f: 0xeb8f, + 0xeba0: 0xeb90, + 0xeba1: 0xeb91, + 0xeba2: 0xeb92, + 0xeba3: 0xeb93, + 0xeba4: 0xeb94, + 0xeba5: 0xeb95, + 0xeba6: 0xeb96, + 0xeba7: 0xeb97, + 0xeba8: 0xeb98, + 0xeba9: 0xeb99, + 0xebaa: 0xeb9a, + 0xebab: 0xeb9b, + 0xebac: 0xeb9c, + 0xebad: 0xeb9d, + 0xebae: 0xeb9e, + 0xebaf: 0xeb9f, + 0xebb0: 0xeba0, + 0xebb2: 0xeba1, + 0xebb3: 0xeba2, + 0xebb4: 0xeba3, + 0xebb5: 0xeba4, + 0xebb6: 0xeba5, + 0xebb7: 0xeba6, + 0xebb8: 0xeba7, + 0xebb9: 0xeba9, + 0xebba: 0xebaa, + 0xebbb: 0xebab, + 0xebbc: 0xebac, + 0xebbd: 0xebad, + 0xebbe: 0xebae, + 0xebbf: 0xebaf, + 0xebc0: 0xebb0, + 0xebc1: 0xebb1, + 0xebc2: 0xebb2, + 0xebc3: 0xebb3, + 0xebc4: 0xebb4, + 0xebc5: 0xebb5, + 0xebc6: 0xebb6, + 0xebc7: 0xebb8, + 0xebc8: 0xebb9, + 0xebc9: 0xebba, + 0xebca: 0xebbb, + 0xebcb: 0xebbc, + 0xebcc: 0xebbd, + 0xebcd: 0xebbe, + 0xebce: 0xebbf, + 0xebcf: 0xebc0, + 0xebd0: 0xebc1, + 0xebd1: 0xebc2, + 0xebd2: 0xebc3, + 0xebd3: 0xebc4, + 0xebd5: 0xebc5, + 0xebd6: 0xebc6, + 0xebd7: 0xebc7, + 0xebd8: 0xebc8, + 0xebd9: 0xebc9, + 0xebda: 0xebcb, + 0xebdb: 0xebcc, + 0xebdc: 0xebcd, + 0xebdd: 0xebce, + 0xebde: 0xebcf, + 0xebdf: 0xebd0, + 0xebe0: 0xebd1, + 0xebe1: 0xebd2, + 0xebe2: 0xebd3, + 0xebe3: 0xebd4, + 0xebe4: 0xebd5, + 0xebe5: 0xebd6, + 0xebe6: 0xebd7, + 0xebe7: 0xebd8, + 0xebe8: 0xebd9, + 0xebe9: 0xebda, + 0xebea: 0xebdb, + 0xebeb: 0xebdc, + 0xebec: 0xebdd, + 0xebed: 0xebde, + 0xebee: 0xebdf, + 0xebef: 0xebe0, + 0xebf0: 0xebe1, + 0xebf1: 0xebe2, + 0xebf2: 0xebe4, + 0xebf3: 0xebe5, + 0xebf4: 0xebe6, + 0xebf5: 0xebe7, + 0xebf6: 0xebe8, + 0xebf7: 0xebe9, + 0xebf8: 0xebea, + 0xebf9: 0xebeb, + 0xebfa: 0xebec, + 0xebfb: 0xebed, + 0xebfc: 0xebee, + 0xebfd: 0xebef, + 0xebfe: 0xebf0, + 0xebff: 0xebf1, + 0xec00: 0xebf2, + 0xec01: 0xebf3, + 0xec02: 0xebf4, + 0xec03: 0xebf5, + 0xec04: 0xebf7, + 0xec05: 0xebf8, + 0xec06: 0xebf9, + 0xec07: 0xebfa, + 0xec09: 0xebfb, + 0xec0a: 0xebfc, + 0xec0b: 0xebfd, + 0xec0c: 0xebfe, + 0xec0d: 0xebff, + 0xec0e: 0xec01, + 0xec0f: 0xec02, + 0xec10: 0xec03, + 0xec11: 0xec04, + 0xec12: 0xec05, + 0xec13: 0xec06, + 0xec14: 0xec07, + 0xec15: 0xec08, + 0xec16: 0xec09, + 0xec17: 0xec0a, + 0xec18: 0xec0b, + 0xec19: 0xec0c, + 0xec1a: 0xec0d, + 0xec1b: 0xec0e, + 0xec1c: 0xec0f, + 0xec1d: 0xec10, + 0xec1e: 0xec11, + 0xec1f: 0xec13, + 0xec20: 0xec14, + 0xec21: 0xec15, + 0xec22: 0xec16, + 0xec23: 0xec17, + 0xec24: 0xec19, + 0xec25: 0xec1a, + 0xec26: 0xec1b, + 0xec27: 0xec1c, + 0xec28: 0xec1d, + 0xec29: 0xec1e, + 0xec2a: 0xec1f, + 0xec2b: 0xec20, + 0xec2c: 0xec21, + 0xec2d: 0xec22, + 0xec2e: 0xec23, + 0xec2f: 0xec24, + 0xec30: 0xec25, + 0xec31: 0xec26, + 0xec32: 0xec27, + 0xec33: 0xec28, + 0xec34: 0xec29, + 0xec35: 0xec2a, + 0xec36: 0xec2b, + 0xec37: 0xec2d, + 0xec38: 0xec2e, + 0xec39: 0xec2f, + 0xec3a: 0xec30, + 0xec3b: 0xec31, + 0xec3c: 0xec32, + 0xec3d: 0xec33, + 0xec3e: 0xec34, + 0xec3f: 0xec35, + 0xec40: 0xec36, + 0xec41: 0xec37, + 0xec42: 0xec38, + 0xec43: 0xec39, + 0xec44: 0xec3a, + 0xec45: 0xec3b, + 0xec46: 0xec3c, + 0xec47: 0xec3d, + 0xec48: 0xec3e, + 0xec49: 0xec3f, + 0xec4b: 0xec40, + 0xec4c: 0xec41, + 0xec4d: 0xec42, + 0xec4e: 0xec43, + 0xec50: 0xec44, + 0xec51: 0xec45, + 0xec52: 0xec47, + 0xec53: 0xec48, + 0xec54: 0xec49, + 0xec55: 0xec4a, + 0xec56: 0xec4b, + 0xec57: 0xec4c, + 0xec58: 0xec4d, + 0xec59: 0xec4e, + 0xec5a: 0xec4f, + 0xec5b: 0xec50, + 0xec5c: 0xec51, + 0xec5d: 0xec52, + 0xec60: 0xec53, + 0xec61: 0xec54, + 0xec62: 0xec55, + 0xec63: 0xec56, + 0xec64: 0xec57, + 0xec65: 0xec58, + 0xec66: 0xec59, + 0xec67: 0xec5a, + 0xec68: 0xec5b, + 0xec69: 0xec5c, + 0xec6a: 0xec5d, + 0xec6b: 0xec5e, + 0xec6c: 0xec5f, + 0xec6d: 0xec60, + 0xec6e: 0xec61, + 0xec6f: 0xec62, + 0xec70: 0xec63, + 0xec71: 0xec64, + 0xec72: 0xec65, + 0xec73: 0xec66, + 0xec74: 0xec67, + 0xec75: 0xec68, + 0xec76: 0xec69, + 0xec77: 0xec6a, + 0xec78: 0xec6b, + 0xec79: 0xec6d, + 0xec7a: 0xec6e, + 0xec7b: 0xec6f, + 0xec7c: 0xec70, + 0xec7d: 0xec71, + 0xec7e: 0xec72, + 0xec7f: 0xec74, + 0xec80: 0xec75, + 0xec81: 0xec77, + 0xec82: 0xec78, + 0xec83: 0xec79, + 0xec84: 0xec7b, + 0xec85: 0xec7c, + 0xec86: 0xec7d, + 0xec87: 0xec7e, + 0xec88: 0xec7f, + 0xec89: 0xec80, + 0xec8a: 0xec81, + 0xec8b: 0xec82, + 0xec8c: 0xec83, + 0xec8d: 0xec84, + 0xec8e: 0xec85, + 0xec8f: 0xec86, + 0xec90: 0xec87, + 0xec91: 0xec88, + 0xec92: 0xec89, + 0xec93: 0xec8a, + 0xec94: 0xec8b, + 0xec95: 0xec8c, + 0xec96: 0xec8d, + 0xec97: 0xec8e, + 0xec98: 0xec8f, + 0xec99: 0xec90, + 0xec9a: 0xec91, + 0xec9b: 0xec92, + 0xec9c: 0xec93, + 0xec9d: 0xec94, + 0xec9e: 0xec95, + 0xec9f: 0xec96, + 0xeca0: 0xec97, + 0xeca1: 0xec98, + 0xeca2: 0xec99, + 0xeca3: 0xec9a, + 0xeca4: 0xec9b, + 0xeca5: 0xec9c, + 0xeca6: 0xec9d, + 0xeca7: 0xec9e, + 0xeca8: 0xec9f, + 0xecaa: 0xeca0, + 0xecab: 0xeca1, + 0xecac: 0xeca2, + 0xecad: 0xeca3, + 0xecae: 0xeca4, + 0xecaf: 0xeca6, + 0xecb0: 0xeca7, + 0xecb1: 0xeca8, + 0xecb2: 0xeca9, + 0xecb3: 0xecaa, + 0xecb4: 0xecab, + 0xecb5: 0xecac, + 0xecb6: 0xecad, + 0xecb7: 0xecae, + 0xecb8: 0xecaf, + 0xecb9: 0xecb0, + 0xecba: 0xecb1, + 0xecbb: 0xecb2, + 0xecbc: 0xecb3, + 0xecbd: 0xecb4, + 0xecbe: 0xecb5, + 0xecbf: 0xecb6, + 0xecc0: 0xecb7, + 0xecc1: 0xecb8, + 0xecc2: 0xecb9, + 0xecc3: 0xecbb, + 0xecc4: 0xecbc, + 0xecc5: 0xecbd, + 0xecc6: 0xecbe, + 0xecc7: 0xecbf, + 0xecc8: 0xecc0, + 0xecc9: 0xecc1, + 0xecca: 0xecc2, + 0xeccb: 0xecc3, + 0xeccc: 0xecc4, + 0xeccd: 0xecc5, + 0xecce: 0xecc6, + 0xeccf: 0xecc7, + 0xecd0: 0xecc8, + 0xecd1: 0xecc9, + 0xecd2: 0xecca, + 0xecd3: 0xeccb, + 0xecd4: 0xeccc, + 0xecd5: 0xeccd, + 0xecd7: 0xecce, + 0xecd8: 0xeccf, + 0xecd9: 0xecd0, + 0xecda: 0xecd1, + 0xecdb: 0xecd2, + 0xecdc: 0xecd3, + 0xecdd: 0xecd4, + 0xecde: 0xecd5, + 0xecdf: 0xecd6, + 0xece0: 0xecd7, + 0xece1: 0xecd9, + 0xece2: 0xecdc, + 0xece3: 0xecdd, + 0xece4: 0xecde, + 0xece5: 0xecdf, + 0xece6: 0xece0, + 0xece7: 0xece1, + 0xece8: 0xece3, + 0xece9: 0xece4, + 0xecea: 0xece5, + 0xeceb: 0xece6, + 0xecec: 0xece7, + 0xeced: 0xece8, + 0xecee: 0xece9, + 0xecef: 0xecea, + 0xecf0: 0xeceb, + 0xecf1: 0xecec, + 0xecf2: 0xeced, + 0xecf3: 0xecef, + 0xecf4: 0xecf0, + 0xecf5: 0xecf1, + 0xecf6: 0xecf2, + 0xecf7: 0xecf4, + 0xecf8: 0xecf5, + 0xecf9: 0xecf6, + 0xecfa: 0xecf7, + 0xecfb: 0xecf8, + 0xecfc: 0xecfa, + 0xecfd: 0xecfc, + 0xecfe: 0xecfd, + 0xecff: 0xecfe, + 0xed00: 0xecff, + 0xed01: 0xed00, + 0xed02: 0xed01, + 0xed03: 0xed02, + 0xed04: 0xed03, + 0xed09: 0xed0a, + 0xed0a: 0xed0b, + 0xed0b: 0xed0c, + 0xed0c: 0xed0d, + 0xed0d: 0xed0e, + 0xed0e: 0xed10, + 0xed0f: 0xed12, + 0xed10: 0xed13, + 0xed11: 0xed14, + 0xed12: 0xed15, + 0xed13: 0xed16, + 0xed14: 0xed18, + 0xed15: 0xed19, + 0xed16: 0xed1a, + 0xed17: 0xed1d, + 0xed18: 0xed1e, + 0xed19: 0xed1f, + 0xed1a: 0xed20, + 0xed1b: 0xed21, + 0xed1c: 0xed22, + 0xed1d: 0xed23, + 0xed1e: 0xed24, + 0xed1f: 0xed25, + 0xed20: 0xed26, + 0xed21: 0xed27, + 0xed22: 0xed28, + 0xed23: 0xed29, + 0xed24: 0xed2a, + 0xed25: 0xed2b, + 0xed26: 0xed2c, + 0xed27: 0xed2d, + 0xed28: 0xed2e, + 0xed29: 0xed2f, + 0xed2a: 0xed30, + 0xed2b: 0xed31, + 0xed2c: 0xed32, + 0xed2d: 0xed33, + 0xed2e: 0xed34, + 0xed2f: 0xed35, + 0xed30: 0xed36, + 0xed31: 0xed37, + 0xed32: 0xed38, + 0xed33: 0xed39, + 0xed34: 0xed3a, + 0xed35: 0xed3b, + 0xed36: 0xed3c, + 0xed37: 0xed3d, + 0xed38: 0xed3e, + 0xed39: 0xed3f, + 0xed3a: 0xed40, + 0xed3b: 0xed41, + 0xed3c: 0xed42, + 0xed3d: 0xed43, + 0xed3e: 0xed44, + 0xed3f: 0xed45, + 0xed40: 0xed46, + 0xed41: 0xed47, + 0xed42: 0xed48, + 0xed43: 0xed49, + 0xed44: 0xed4a, + 0xed45: 0xed4b, + 0xed46: 0xed4c, + 0xed47: 0xed4d, + 0xed48: 0xed4e, + 0xed49: 0xed4f, + 0xed4a: 0xed50, + 0xed4b: 0xed51, + 0xed4c: 0xed52, + 0xed4d: 0xed54, + 0xed4e: 0xed55, + 0xed4f: 0xed56, + 0xed50: 0xed57, + 0xed51: 0xed58, + 0xed52: 0xed59, + 0xed53: 0xed5a, + 0xed54: 0xed5b, + 0xed55: 0xed5c, + 0xed56: 0xed5d, + 0xed57: 0xed5e, + 0xed58: 0xed5f, + 0xed59: 0xed60, + 0xed5a: 0xed61, + 0xed5b: 0xed62, + 0xed5c: 0xed63, + 0xed5d: 0xed64, + 0xed5e: 0xed65, + 0xed5f: 0xed66, + 0xed60: 0xed67, + 0xed61: 0xed68, + 0xed62: 0xed6a, + 0xed63: 0xed6b, + 0xed64: 0xed6c, + 0xed65: 0xed6d, + 0xed66: 0xed6e, + 0xed67: 0xed6f, + 0xed68: 0xed70, + 0xed69: 0xed71, + 0xed6b: 0xed72, + 0xed6d: 0xed73, + 0xed6e: 0xed76, + 0xed6f: 0xed77, + 0xed70: 0xed78, + 0xed71: 0xed79, + 0xed72: 0xed7a, + 0xed73: 0xed7b, + 0xed74: 0xed7c, + 0xed75: 0xed7d, + 0xed76: 0xed7e, + 0xed77: 0xed7f, + 0xed78: 0xed80, + 0xed79: 0xed81, + 0xed7a: 0xed82, + 0xed7b: 0xed83, + 0xed7c: 0xed84, + 0xed7d: 0xed85, + 0xed7e: 0xed86, + 0xed7f: 0xed87, + 0xed80: 0xed88, + 0xed81: 0xed89, + 0xed82: 0xed8a, + 0xed83: 0xed8b, + 0xed84: 0xed8c, + 0xed85: 0xed8d, + 0xed86: 0xed8e, + 0xed87: 0xed8f, + 0xed88: 0xed90, + 0xed8a: 0xed91, + 0xed8b: 0xed93, + 0xed8c: 0xed94, + 0xed8d: 0xed95, + 0xed8e: 0xed96, + 0xed8f: 0xed97, + 0xed90: 0xed98, + 0xed91: 0xed99, + 0xed92: 0xed9a, + 0xed93: 0xed9b, + 0xed94: 0xed9c, + 0xed95: 0xed9d, + 0xed96: 0xed9e, + 0xed97: 0xed9f, + 0xed99: 0xeda0, + 0xed9a: 0xeda1, + 0xed9b: 0xeda2, + 0xed9c: 0xeda3, + 0xed9d: 0xeda4, + 0xed9e: 0xeda5, + 0xed9f: 0xeda6, + 0xeda0: 0xeda7, + 0xeda1: 0xeda8, + 0xeda2: 0xedaa, + 0xeda3: 0xedab, + 0xeda4: 0xedac, + 0xeda5: 0xedad, + 0xeda6: 0xedae, + 0xeda7: 0xedaf, + 0xeda8: 0xedb0, + 0xeda9: 0xedb1, + 0xedaa: 0xedb2, + 0xedab: 0xedb3, + 0xedac: 0xedb4, + 0xedad: 0xedb5, + 0xedae: 0xedb6, + 0xedaf: 0xedb7, + 0xedb0: 0xedb8, + 0xedb1: 0xedb9, + 0xedb2: 0xedba, + 0xedb3: 0xedbb, + 0xedb4: 0xedbc, + 0xedb5: 0xedbd, + 0xedb6: 0xedbe, + 0xedb7: 0xedbf, + 0xedb8: 0xedc0, + 0xedb9: 0xedc1, + 0xedba: 0xedc2, + 0xedbb: 0xedc3, + 0xedbc: 0xedc4, + 0xedbd: 0xedc5, + 0xedbe: 0xedc6, + 0xedbf: 0xedc7, + 0xedc0: 0xedc8, + 0xedc1: 0xedc9, + 0xedc2: 0xedca, + 0xedc3: 0xedcb, + 0xedc4: 0xedcc, + 0xedc5: 0xedcd, + 0xedc6: 0xedce, + 0xedc7: 0xedcf, + 0xedc8: 0xedd1, + 0xedc9: 0xedd2, + 0xedca: 0xedd3, + 0xedcb: 0xedd4, + 0xedcc: 0xedd5, + 0xedcd: 0xedd6, + 0xedce: 0xedd7, + 0xedcf: 0xedd8, + 0xedd0: 0xedd9, + 0xedd1: 0xedda, + 0xedd2: 0xeddc, + 0xedd3: 0xeddd, + 0xedd4: 0xedde, + 0xedd5: 0xeddf, + 0xedd6: 0xede0, + 0xedd7: 0xede1, + 0xedd8: 0xede2, + 0xedd9: 0xede3, + 0xedda: 0xede4, + 0xeddb: 0xede5, + 0xeddc: 0xede6, + 0xeddd: 0xede7, + 0xedde: 0xede8, + 0xede0: 0xede9, + 0xede1: 0xedea, + 0xede2: 0xeded, + 0xede3: 0xedee, + 0xede4: 0xedef, + 0xede5: 0xedf0, + 0xede6: 0xedf1, + 0xede7: 0xedf3, + 0xede8: 0xedf4, + 0xede9: 0xedf5, + 0xedea: 0xedf6, + 0xedeb: 0xedf7, + 0xedec: 0xedf9, + 0xeded: 0xedfa, + 0xedee: 0xedfb, + 0xedef: 0xedfc, + 0xedf0: 0xedfd, + 0xedf1: 0xedfe, + 0xedf2: 0xedff, + 0xedf3: 0xee00, + 0xedf4: 0xee02, + 0xedf5: 0xee03, + 0xedf6: 0xee04, + 0xedf7: 0xee05, + 0xedf8: 0xee07, + 0xedf9: 0xee08, + 0xedfa: 0xee09, + 0xedfb: 0xee0a, + 0xedfc: 0xee0b, + 0xedfd: 0xee0c, + 0xedfe: 0xee0d, + 0xedff: 0xee0e, + 0xee00: 0xee0f, + 0xee01: 0xee10, + 0xee02: 0xee11, + 0xee03: 0xee12, + 0xee04: 0xee13, + 0xee05: 0xee14, + 0xee06: 0xee15, + 0xee07: 0xee16, + 0xee08: 0xee17, + 0xee09: 0xee18, + 0xee0a: 0xee19, + 0xee0b: 0xee1a, + 0xee0c: 0xee1b, + 0xee0d: 0xee1c, + 0xee0e: 0xee1d, + 0xee0f: 0xee1f, + 0xee10: 0xee23, + 0xee11: 0xee24, + 0xee12: 0xee25, + 0xee13: 0xee26, + 0xee14: 0xee27, + 0xee15: 0xee28, + 0xee16: 0xee29, + 0xee17: 0xee2a, + 0xee18: 0xee2b, + 0xee19: 0xee2c, + 0xee1a: 0xee2d, + 0xee1b: 0xee2e, + 0xee1c: 0xee2f, + 0xee1d: 0xee30, + 0xee1e: 0xee31, + 0xee1f: 0xee32, + 0xee20: 0xee33, + 0xee21: 0xee34, + 0xee22: 0xee35, + 0xee23: 0xee36, + 0xee24: 0xee37, + 0xee25: 0xee38, + 0xee26: 0xee3a, + 0xee27: 0xee3c, + 0xee28: 0xee3d, + 0xee29: 0xee3e, + 0xee2a: 0xee3f, + 0xee2b: 0xee40, + 0xee2c: 0xee41, + 0xee2d: 0xee42, + 0xee2e: 0xee43, + 0xee2f: 0xee44, + 0xee30: 0xee45, + 0xee31: 0xee46, + 0xee32: 0xee47, + 0xee33: 0xee48, + 0xee34: 0xee49, + 0xee35: 0xee4a, + 0xee36: 0xee4b, + 0xee37: 0xee4e, + 0xee38: 0xee4f, + 0xee39: 0xee50, + 0xee3a: 0xee51, + 0xee3b: 0xee52, + 0xee3c: 0xee53, + 0xee3d: 0xee54, + 0xee3e: 0xee55, + 0xee3f: 0xee57, + 0xee40: 0xee58, + 0xee41: 0xee59, + 0xee42: 0xee5a, + 0xee43: 0xee5b, + 0xee44: 0xee5c, + 0xee45: 0xee5d, + 0xee46: 0xee5e, + 0xee47: 0xee5f, + 0xee48: 0xee60, + 0xee49: 0xee61, + 0xee4a: 0xee62, + 0xee4b: 0xee63, + 0xee4c: 0xee64, + 0xee4d: 0xee66, + 0xee4e: 0xee68, + 0xee4f: 0xee69, + 0xee50: 0xee6a, + 0xee51: 0xee6b, + 0xee52: 0xee6c, + 0xee53: 0xee6d, + 0xee54: 0xee6e, + 0xee55: 0xee6f, + 0xee56: 0xee70, + 0xee57: 0xee71, + 0xee58: 0xee72, + 0xee59: 0xee73, + 0xee5a: 0xee74, + 0xee5b: 0xee75, + 0xee5c: 0xee76, + 0xee5d: 0xee77, + 0xee5e: 0xee78, + 0xee5f: 0xee79, + 0xee60: 0xee7a, + 0xee61: 0xee7b, + 0xee62: 0xee7c, + 0xee63: 0xee7d, + 0xee64: 0xee7e, + 0xee66: 0xee7f, + 0xee67: 0xee80, + 0xee68: 0xee81, + 0xee69: 0xee82, + 0xee6a: 0xee83, + 0xee6b: 0xee84, + 0xee6c: 0xee85, + 0xee6d: 0xee86, + 0xee6e: 0xee87, + 0xee6f: 0xee88, + 0xee70: 0xee89, + 0xee71: 0xee8a, + 0xee72: 0xee8b, + 0xee73: 0xee8c, + 0xee74: 0xee8d, + 0xee75: 0xee8f, + 0xee76: 0xee90, + 0xee77: 0xee91, + 0xee78: 0xee92, + 0xee79: 0xee93, + 0xee7a: 0xee94, + 0xee7b: 0xee95, + 0xee7c: 0xee96, + 0xee7d: 0xee97, + 0xee7e: 0xee98, + 0xee80: 0xee99, + 0xee81: 0xee9a, + 0xee82: 0xee9b, + 0xee83: 0xee9c, + 0xee84: 0xee9d, + 0xee85: 0xee9e, + 0xee86: 0xee9f, + 0xee88: 0xeea0, + 0xee89: 0xeea1, + 0xee8a: 0xeea2, + 0xee8b: 0xeea3, + 0xee8c: 0xeea5, + 0xee8d: 0xeea6, + 0xee8e: 0xeea7, + 0xee8f: 0xeea8, + 0xee90: 0xeea9, + 0xee91: 0xeeaa, + 0xee92: 0xeeab, + 0xee93: 0xeeac, + 0xee94: 0xeead, + 0xee95: 0xeeae, + 0xee96: 0xeeaf, + 0xee97: 0xeeb0, + 0xee98: 0xeeb1, + 0xee99: 0xeeb2, + 0xee9a: 0xeeb3, + 0xee9b: 0xeeb4, + 0xee9c: 0xeeb5, + 0xee9d: 0xeeb7, + 0xee9e: 0xeeb8, + 0xee9f: 0xeeb9, + 0xeea0: 0xeebb, + 0xeea1: 0xeebd, + 0xeea2: 0xeebe, + 0xeea3: 0xeebf, + 0xeea4: 0xeec0, + 0xeea5: 0xeec1, + 0xeea6: 0xeec2, + 0xeea7: 0xeec3, + 0xeea8: 0xeec4, + 0xeea9: 0xeec5, + 0xeeaa: 0xeec7, + 0xeeab: 0xeec8, + 0xeeac: 0xeec9, + 0xeead: 0xeeca, + 0xeeae: 0xeecb, + 0xeeaf: 0xeecc, + 0xeeb0: 0xeecd, + 0xeeb1: 0xeece, + 0xeeb2: 0xeed0, + 0xeeb3: 0xeed1, + 0xeeb4: 0xeed2, + 0xeeb5: 0xeed3, + 0xeeb6: 0xeed4, + 0xeeb8: 0xeed5, + 0xeeb9: 0xeed7, + 0xeeba: 0xeed9, + 0xeebb: 0xeeda, + 0xeebc: 0xeedb, + 0xeebd: 0xeedc, + 0xeebe: 0xeedd, + 0xeebf: 0xeede, + 0xeec0: 0xeedf, + 0xeec1: 0xeee0, + 0xeec2: 0xeee1, + 0xeec3: 0xeee2, + 0xeec4: 0xeee3, + 0xeec5: 0xeee4, + 0xeec6: 0xeee5, + 0xeec7: 0xeee6, + 0xeec8: 0xeee8, + 0xeec9: 0xeee9, + 0xeeca: 0xeeea, + 0xeecb: 0xeeeb, + 0xeecc: 0xeeec, + 0xeecd: 0xeeed, + 0xeece: 0xeeee, + 0xeecf: 0xeeef, + 0xeed0: 0xeef0, + 0xeed1: 0xeef2, + 0xeed2: 0xeef3, + 0xeed3: 0xeef4, + 0xeed4: 0xeef5, + 0xeed5: 0xeef6, + 0xeed6: 0xeef7, + 0xeed7: 0xeef8, + 0xeed8: 0xeef9, + 0xeed9: 0xeefb, + 0xeeda: 0xeefc, + 0xeedb: 0xeefd, + 0xeedc: 0xeefe, + 0xeedd: 0xeeff, + 0xeede: 0xef00, + 0xeedf: 0xef02, + 0xeee0: 0xef04, + 0xeee1: 0xef06, + 0xeee2: 0xef07, + 0xeee3: 0xef08, + 0xeee4: 0xef09, + 0xeee5: 0xef0a, + 0xeee6: 0xef0b, + 0xeee7: 0xef0c, + 0xeee8: 0xef0d, + 0xeee9: 0xef0f, + 0xeeea: 0xef11, + 0xeeeb: 0xef12, + 0xeeec: 0xef13, + 0xeeed: 0xef14, + 0xeeee: 0xef15, + 0xeeef: 0xef16, + 0xeef0: 0xef17, + 0xeef1: 0xef18, + 0xeef2: 0xef19, + 0xeef4: 0xef1c, + 0xeef5: 0xef1d, + 0xeef6: 0xef1e, + 0xeef7: 0xef1f, + 0xeef8: 0xef20, + 0xeef9: 0xef21, + 0xeefa: 0xef22, + 0xeefb: 0xef23, + 0xeefc: 0xef24, + 0xeefd: 0xef25, + 0xeefe: 0xef26, + 0xeeff: 0xef27, + 0xef00: 0xef28, + 0xef01: 0xef29, + 0xef02: 0xef2a, + 0xef03: 0xef2b, + 0xef04: 0xef2c, + 0xef05: 0xef2d, + 0xef06: 0xef2e, + 0xef07: 0xef2f, + 0xef08: 0xef30, + 0xef09: 0xef31, + 0xef0a: 0xef32, + 0xef0b: 0xef33, + 0xef0c: 0xef34, + 0xef0d: 0xef35, + 0xef0e: 0xef36, + 0xef0f: 0xef37, + 0xef10: 0xef38, + 0xef11: 0xef39, + 0xef12: 0xef3a, + 0xef13: 0xef3b, + 0xef14: 0xef3c, + 0xef15: 0xef3d, + 0xef16: 0xef3e, + 0xef17: 0xef3f, + 0xef18: 0xef40, + 0xef19: 0xef41, + 0xef1a: 0xef42, + 0xef1b: 0xef43, + 0xef1c: 0xef44, + 0xef1d: 0xef45, + 0xef1e: 0xef46, + 0xef1f: 0xef47, + 0xef20: 0xef48, + 0xef21: 0xef49, + 0xef22: 0xef4a, + 0xef23: 0xef4b, + 0xef24: 0xef4c, + 0xef25: 0xef4d, + 0xef26: 0xef4e, + 0xef27: 0xef4f, + 0xef28: 0xef50, + 0xef29: 0xef51, + 0xef2a: 0xef52, + 0xef2b: 0xef53, + 0xef2c: 0xef54, + 0xef2d: 0xef55, + 0xef2e: 0xef56, + 0xef2f: 0xef57, + 0xef30: 0xef58, + 0xef31: 0xef59, + 0xef32: 0xef5a, + 0xef33: 0xef5b, + 0xef34: 0xef5c, + 0xef35: 0xef5d, + 0xef36: 0xef5e, + 0xef37: 0xef5f, + 0xef38: 0xef60, + 0xef39: 0xef61, + 0xef3a: 0xef62, + 0xef3b: 0xef63, + 0xef3c: 0xef64, + 0xef3d: 0xef65, + 0xef3e: 0xef66, + 0xef3f: 0xef67, + 0xef40: 0xef68, + 0xef41: 0xef69, + 0xef42: 0xef6a, + 0xef43: 0xef6b, + 0xef44: 0xef6c, + 0xef45: 0xef6d, + 0xef46: 0xef6e, + 0xef47: 0xef6f, + 0xef48: 0xef72, + 0xef49: 0xef73, + 0xef4a: 0xef74, + 0xef4b: 0xef75, + 0xef4c: 0xef76, + 0xef4d: 0xef77, + 0xef4e: 0xef79, + 0xef4f: 0xef7a, + 0xef50: 0xef7b, + 0xef51: 0xef7c, + 0xef52: 0xef7d, + 0xef53: 0xef7e, + 0xef54: 0xef80, + 0xef55: 0xef81, + 0xef56: 0xef82, + 0xef57: 0xef83, + 0xef58: 0xef85, + 0xef59: 0xef86, + 0xef5a: 0xef87, + 0xef5c: 0xef88, + 0xef5d: 0xef89, + 0xef5e: 0xef8b, + 0xef5f: 0xef8c, + 0xef60: 0xef8d, + 0xef61: 0xef8e, + 0xef62: 0xef90, + 0xef63: 0xef91, + 0xef64: 0xef92, + 0xef65: 0xef93, + 0xef66: 0xef94, + 0xef67: 0xef95, + 0xef68: 0xef96, + 0xef69: 0xef97, + 0xef6a: 0xef98, + 0xef6b: 0xef99, + 0xef6c: 0xef9a, + 0xef6d: 0xef9b, + 0xef6e: 0xef9c, + 0xef6f: 0xef9d, + 0xef70: 0xef9e, + 0xef71: 0xef9f, + 0xef72: 0xefa0, + 0xef73: 0xefa1, + 0xef74: 0xefa2, + 0xef75: 0xefa3, + 0xef77: 0xefa5, + 0xef78: 0xefa6, + 0xef79: 0xefa7, + 0xef7a: 0xefa8, + 0xef7b: 0xefa9, + 0xef7c: 0xefaa, + 0xef7d: 0xefab, + 0xef7e: 0xefac, + 0xef7f: 0xefad, + 0xef80: 0xefae, + 0xef81: 0xefb0, + 0xef82: 0xefb1, + 0xef83: 0xefb2, + 0xef84: 0xefb3, + 0xef85: 0xefb4, + 0xef86: 0xefb5, + 0xef87: 0xefb6, + 0xef88: 0xefb7, + 0xef89: 0xefb8, + 0xef8c: 0xefb9, + 0xef8d: 0xefba, + 0xef8e: 0xefbb, + 0xef8f: 0xefbc, + 0xef90: 0xefbd, + 0xef91: 0xefbe, + 0xef92: 0xefbf, + 0xef93: 0xefc1, + 0xef94: 0xefc2, + 0xef95: 0xefc3, + 0xef96: 0xefc4, + 0xef97: 0xefc5, + 0xef98: 0xefc6, + 0xef99: 0xefc7, + 0xef9a: 0xefc8, + 0xef9b: 0xefc9, + 0xef9c: 0xefca, + 0xef9d: 0xefcb, + 0xef9e: 0xefcc, + 0xef9f: 0xefcd, + 0xefa0: 0xefce, + 0xefa1: 0xefcf, + 0xefa2: 0xefd0, + 0xefa3: 0xefd1, + 0xefa4: 0xefd2, + 0xefa5: 0xefd3, + 0xefa6: 0xefd4, + 0xefa7: 0xefd5, + 0xefa8: 0xefd6, + 0xefa9: 0xefd7, + 0xefaa: 0xefd8, + 0xefab: 0xefd9, + 0xefac: 0xefda, + 0xefad: 0xefdb, + 0xefae: 0xefdc, + 0xefaf: 0xefdd, + 0xefb0: 0xefde, + 0xefb1: 0xefdf, + 0xefb2: 0xefe0, + 0xefb3: 0xefe1, + 0xefb4: 0xefe2, + 0xefb5: 0xefe3, + 0xefb6: 0xefe4, + 0xefb7: 0xefe5, + 0xefb8: 0xefe6, + 0xefb9: 0xefe7, + 0xefba: 0xefe8, + 0xefbb: 0xefea, + 0xefbc: 0xefeb, + 0xefbd: 0xefec, + 0xefbe: 0xefed, + 0xefbf: 0xefee, + 0xefc0: 0xefef, + 0xefc1: 0xeff0, + 0xefc2: 0xeff1, + 0xefc3: 0xeff2, + 0xefc4: 0xeff3, + 0xefc5: 0xeff4, + 0xefc6: 0xeff5, + 0xefc7: 0xeff7, + 0xefc8: 0xeff9, + 0xefc9: 0xeffa, + 0xefca: 0xeffb, + 0xefcb: 0xeffc, + 0xefcc: 0xeffd, + 0xefcd: 0xeffe, + 0xefce: 0xefff, + 0xefcf: 0xf001, + 0xefd0: 0xf002, + 0xefd1: 0xf003, + 0xefd2: 0xf004, + 0xefd3: 0xf005, + 0xefd4: 0xf006, + 0xefd5: 0xf007, + 0xefd6: 0xf008, + 0xefd7: 0xf00a, + 0xefd8: 0xf00c, + 0xefd9: 0xf00d, + 0xefda: 0xf00e, + 0xefdb: 0xf00f, + 0xefdc: 0xf010, + 0xefdd: 0xf011, + 0xefde: 0xf012, + 0xefdf: 0xf013, + 0xefe0: 0xf014, + 0xefe1: 0xf015, + 0xefe2: 0xf016, + 0xefe3: 0xf017, + 0xefe4: 0xf018, + 0xefe5: 0xf019, + 0xefe6: 0xf01a, + 0xefe7: 0xf01b, + 0xefe8: 0xf01c, + 0xefe9: 0xf01d, + 0xefea: 0xf01e, + 0xefeb: 0xf01f, + 0xefec: 0xf021, + 0xefed: 0xf022, + 0xefee: 0xf023, + 0xefef: 0xf024, + 0xeff0: 0xf025, + 0xeff1: 0xf026, + 0xeff2: 0xf027, + 0xeff3: 0xf028, + 0xeff4: 0xf029, + 0xeff5: 0xf02a, + 0xeff6: 0xf02b, + 0xeff7: 0xf02c, + 0xeff8: 0xf02d, + 0xeff9: 0xf02e, + 0xeffa: 0xf02f, + 0xeffb: 0xf031, + 0xeffc: 0xf032, + 0xeffd: 0xf033, + 0xeffe: 0xf034, + 0xefff: 0xf035, + 0xf000: 0xf036, + 0xf001: 0xf037, + 0xf002: 0xf039, + 0xf003: 0xf03a, + 0xf004: 0xf03c, + 0xf005: 0xf03d, + 0xf006: 0xf03e, + 0xf007: 0xf03f, + 0xf008: 0xf040, + 0xf009: 0xf042, + 0xf00a: 0xf043, + 0xf00b: 0xf044, + 0xf00c: 0xf045, + 0xf00d: 0xf046, + 0xf00e: 0xf047, + 0xf00f: 0xf048, + 0xf010: 0xf049, + 0xf011: 0xf04a, + 0xf012: 0xf04c, + 0xf013: 0xf04d, + 0xf014: 0xf04e, + 0xf016: 0xf04f, + 0xf017: 0xf051, + 0xf018: 0xf052, + 0xf019: 0xf053, + 0xf01a: 0xf054, + 0xf01b: 0xf055, + 0xf01c: 0xf056, + 0xf01d: 0xf057, + 0xf01e: 0xf058, + 0xf01f: 0xf059, + 0xf020: 0xf05a, + 0xf021: 0xf05b, + 0xf022: 0xf05c, + 0xf023: 0xf05d, + 0xf024: 0xf05e, + 0xf025: 0xf05f, + 0xf026: 0xf060, + 0xf027: 0xf061, + 0xf028: 0xf062, + 0xf029: 0xf063, + 0xf02a: 0xf064, + 0xf02b: 0xf065, + 0xf02c: 0xf066, + 0xf02d: 0xf067, + 0xf02e: 0xf068, + 0xf02f: 0xf069, + 0xf030: 0xf06a, + 0xf031: 0xf06b, + 0xf032: 0xf06c, + 0xf033: 0xf06d, + 0xf034: 0xf06e, + 0xf035: 0xf06f, + 0xf036: 0xf070, + 0xf037: 0xf071, + 0xf038: 0xf072, + 0xf039: 0xf073, + 0xf03a: 0xf074, + 0xf03b: 0xf075, + 0xf03c: 0xf076, + 0xf03d: 0xf077, + 0xf03e: 0xf078, + 0xf03f: 0xf079, + 0xf040: 0xf07a, + 0xf041: 0xf07b, + 0xf042: 0xf07c, + 0xf043: 0xf07d, + 0xf044: 0xf07e, + 0xf045: 0xf07f, + 0xf046: 0xf080, + 0xf047: 0xf081, + 0xf048: 0xf082, + 0xf049: 0xf083, + 0xf04a: 0xf084, + 0xf04b: 0xf085, + 0xf04c: 0xf086, + 0xf04d: 0xf087, + 0xf04e: 0xf088, + 0xf04f: 0xf089, + 0xf050: 0xf08a, + 0xf051: 0xf08b, + 0xf052: 0xf08c, + 0xf053: 0xf08d, + 0xf054: 0xf08e, + 0xf055: 0xf08f, + 0xf056: 0xf090, + 0xf057: 0xf091, + 0xf058: 0xf092, + 0xf059: 0xf094, + 0xf05a: 0xf095, + 0xf05b: 0xf096, + 0xf05c: 0xf097, + 0xf05d: 0xf098, + 0xf05e: 0xf099, + 0xf05f: 0xf09a, + 0xf060: 0xf09b, + 0xf061: 0xf09c, + 0xf062: 0xf09d, + 0xf063: 0xf09f, + 0xf064: 0xf0a0, + 0xf065: 0xf0a1, + 0xf066: 0xf0a2, + 0xf067: 0xf0a3, + 0xf068: 0xf0a4, + 0xf069: 0xf0a5, + 0xf06a: 0xf0a7, + 0xf06b: 0xf0a8, + 0xf06c: 0xf0a9, + 0xf06d: 0xf0aa, + 0xf06e: 0xf0ab, + 0xf06f: 0xf0af, + 0xf072: 0xf0b0, + 0xf073: 0xf0b1, + 0xf074: 0xf0b2, + 0xf075: 0xf0b4, + 0xf076: 0xf0b5, + 0xf077: 0xf0b6, + 0xf078: 0xf0b7, + 0xf079: 0xf0b9, + 0xf07a: 0xf0ba, + 0xf07b: 0xf0bb, + 0xf07c: 0xf0bc, + 0xf07d: 0xf0bd, + 0xf07e: 0xf0be, + 0xf07f: 0xf0c1, + 0xf080: 0xf0c2, + 0xf081: 0xf0c3, + 0xf082: 0xf0c4, + 0xf083: 0xf0c5, + 0xf084: 0xf0c6, + 0xf085: 0xf0c7, + 0xf086: 0xf0c8, + 0xf087: 0xf0ca, + 0xf088: 0xf0cb, + 0xf089: 0xf0cc, + 0xf08a: 0xf0cd, + 0xf08b: 0xf0ce, + 0xf08c: 0xf0cf, + 0xf08d: 0xf0d0, + 0xf08e: 0xf0d2, + 0xf08f: 0xf0d3, + 0xf090: 0xf0d4, + 0xf091: 0xf0d5, + 0xf092: 0xf0d6, + 0xf093: 0xf0d7, + 0xf094: 0xf0d8, + 0xf096: 0xf0da, + 0xf097: 0xf0db, + 0xf098: 0xf0dc, + 0xf099: 0xf0dd, + 0xf09a: 0xf0de, + 0xf09b: 0xf0df, + 0xf09c: 0xf0e0, + 0xf09d: 0xf0e1, + 0xf09e: 0xf0e2, + 0xf09f: 0xf0e3, + 0xf0a0: 0xf0e4, + 0xf0a1: 0xf0e5, + 0xf0a2: 0xf0e6, + 0xf0a3: 0xf0e9, + 0xf0a4: 0xf0ea, + 0xf0a5: 0xf0eb, + 0xf0a6: 0xf0ec, + 0xf0a7: 0xf0ed, + 0xf0a8: 0xf0ee, + 0xf0a9: 0xf0ef, + 0xf0aa: 0xf0f0, + 0xf0ab: 0xf0f1, + 0xf0ac: 0xf0f2, + 0xf0ad: 0xf0f3, + 0xf0ae: 0xf0f4, + 0xf0af: 0xf0f5, + 0xf0b0: 0xf0f6, + 0xf0b1: 0xf0f7, + 0xf0b2: 0xf0f8, + 0xf0b3: 0xf0f9, + 0xf0b4: 0xf0fa, + 0xf0b5: 0xf0fb, + 0xf0b6: 0xf0fc, + 0xf0b7: 0xf0fd, + 0xf0b8: 0xf0fe, + 0xf0b9: 0xf0ff, + 0xf0ba: 0xf100, + 0xf0bb: 0xf101, + 0xf0bc: 0xf102, + 0xf0bd: 0xf103, + 0xf0be: 0xf104, + 0xf0bf: 0xf105, + 0xf0c0: 0xf106, + 0xf0c1: 0xf107, + 0xf0c2: 0xf108, + 0xf0c3: 0xf109, + 0xf0c4: 0xf10a, + 0xf0c5: 0xf10c, + 0xf0c6: 0xf10d, + 0xf0c7: 0xf10e, + 0xf0c8: 0xf10f, + 0xf0c9: 0xf110, + 0xf0ca: 0xf111, + 0xf0cb: 0xf112, + 0xf0cc: 0xf113, + 0xf0cd: 0xf114, + 0xf0ce: 0xf115, + 0xf0cf: 0xf116, + 0xf0d0: 0xf117, + 0xf0d1: 0xf118, + 0xf0d2: 0xf119, + 0xf0d3: 0xf11a, + 0xf0d4: 0xf11b, + 0xf0d5: 0xf11c, + 0xf0d6: 0xf11d, + 0xf0d7: 0xf11e, + 0xf0d9: 0xf11f, + 0xf0da: 0xf120, + 0xf0db: 0xf121, + 0xf0dc: 0xf122, + 0xf0dd: 0xf123, + 0xf0de: 0xf124, + 0xf0df: 0xf125, + 0xf0e0: 0xf126, + 0xf0e1: 0xf128, + 0xf0e2: 0xf129, + 0xf0e3: 0xf12a, + 0xf0e4: 0xf12b, + 0xf0e5: 0xf12c, + 0xf0e6: 0xf12d, + 0xf0e7: 0xf12e, + 0xf0e8: 0xf130, + 0xf0e9: 0xf131, + 0xf0ea: 0xf132, + 0xf0eb: 0xf133, + 0xf0ec: 0xf134, + 0xf0ed: 0xf135, + 0xf0ee: 0xf136, + 0xf0ef: 0xf137, + 0xf0f0: 0xf138, + 0xf0f1: 0xf13a, + 0xf0f2: 0xf13b, + 0xf0f3: 0xf13c, + 0xf0f4: 0xf13d, + 0xf0f5: 0xf13e, + 0xf0f7: 0xf13f, + 0xf0f8: 0xf140, + 0xf0f9: 0xf142, + 0xf0fa: 0xf143, + 0xf0fb: 0xf144, + 0xf0fc: 0xf145, + 0xf0fd: 0xf146, + 0xf0fe: 0xf147, + 0xf0ff: 0xf149, + 0xf100: 0xf14a, + 0xf101: 0xf14b, + 0xf102: 0xf14c, + 0xf103: 0xf14d, + 0xf104: 0xf14e, + 0xf105: 0xf14f, + 0xf106: 0xf150, + 0xf107: 0xf151, + 0xf108: 0xf152, + 0xf109: 0xf153, + 0xf10a: 0xf156, + 0xf10b: 0xf157, + 0xf10c: 0xf158, + 0xf10d: 0xf159, + 0xf10e: 0xf15a, + 0xf10f: 0xf15b, + 0xf110: 0xf15c, + 0xf111: 0xf15d, + 0xf112: 0xf15e, + 0xf113: 0xf15f, + 0xf114: 0xf160, + 0xf115: 0xf161, + 0xf116: 0xf162, + 0xf117: 0xf163, + 0xf118: 0xf164, + 0xf119: 0xf165, + 0xf11a: 0xf166, + 0xf11b: 0xf167, + 0xf11c: 0xf168, + 0xf11d: 0xf169, + 0xf11e: 0xf16a, + 0xf11f: 0xf16c, + 0xf120: 0xf16d, + 0xf121: 0xf16e, + 0xf122: 0xf16f, + 0xf123: 0xf170, + 0xf124: 0xf171, + 0xf125: 0xf172, + 0xf126: 0xf173, + 0xf127: 0xf174, + 0xf128: 0xf175, + 0xf129: 0xf176, + 0xf12a: 0xf177, + 0xf12b: 0xf178, + 0xf12c: 0xf179, + 0xf12d: 0xf17a, + 0xf12f: 0xf17b, + 0xf130: 0xf17c, + 0xf131: 0xf17d, + 0xf132: 0xf17e, + 0xf133: 0xf17f, + 0xf134: 0xf180, + 0xf136: 0xf181, + 0xf137: 0xf182, + 0xf138: 0xf183, + 0xf139: 0xf184, + 0xf13a: 0xf185, + 0xf13b: 0xf187, + 0xf13c: 0xf188, + 0xf13d: 0xf189, + 0xf13e: 0xf18a, + 0xf13f: 0xf18b, + 0xf140: 0xf18c, + 0xf141: 0xf18d, + 0xf142: 0xf18e, + 0xf143: 0xf18f, + 0xf144: 0xf190, + 0xf145: 0xf191, + 0xf146: 0xf192, + 0xf147: 0xf193, + 0xf148: 0xf194, + 0xf149: 0xf195, + 0xf14a: 0xf196, + 0xf14b: 0xf197, + 0xf14c: 0xf198, + 0xf14d: 0xf199, + 0xf14e: 0xf19a, + 0xf14f: 0xf19b, + 0xf150: 0xf19d, + 0xf151: 0xf19e, + 0xf152: 0xf19f, + 0xf153: 0xf1a0, + 0xf154: 0xf1a2, + 0xf155: 0xf1a4, + 0xf156: 0xf1a5, + 0xf157: 0xf1a6, + 0xf158: 0xf1a7, + 0xf159: 0xf1a8, + 0xf15a: 0xf1a9, + 0xf15b: 0xf1aa, + 0xf15c: 0xf1ab, + 0xf15d: 0xf1ac, + 0xf15e: 0xf1ad, + 0xf15f: 0xf1af, + 0xf160: 0xf1b0, + 0xf161: 0xf1b1, + 0xf162: 0xf1b2, + 0xf163: 0xf1b3, + 0xf164: 0xf1b4, + 0xf165: 0xf1b5, + 0xf166: 0xf1b6, + 0xf167: 0xf1b7, + 0xf168: 0xf1ba, + 0xf169: 0xf1bb, + 0xf16a: 0xf1bd, + 0xf16b: 0xf1be, + 0xf16c: 0xf1bf, + 0xf16d: 0xf1c0, + 0xf16e: 0xf1c1, + 0xf16f: 0xf1c2, + 0xf172: 0xf1c3, + 0xf173: 0xf1c4, + 0xf174: 0xf1c5, + 0xf175: 0xf1c6, + 0xf176: 0xf1c7, + 0xf177: 0xf1c8, + 0xf178: 0xf1c9, + 0xf179: 0xf1cb, + 0xf17a: 0xf1cc, + 0xf17b: 0xf1cd, + 0xf17c: 0xf1ce, + 0xf17d: 0xf1cf, + 0xf17e: 0xf1d1, + 0xf17f: 0xf1d2, + 0xf180: 0xf1d4, + 0xf181: 0xf1d5, + 0xf182: 0xf1d8, + 0xf183: 0xf1d9, + 0xf184: 0xf1da, + 0xf185: 0xf1db, + 0xf186: 0xf1dc, + 0xf187: 0xf1dd, + 0xf188: 0xf1de, + 0xf189: 0xf1df, + 0xf18a: 0xf1e0, + 0xf18b: 0xf1e3, + 0xf18c: 0xf1e4, + 0xf18d: 0xf1e5, + 0xf18e: 0xf1e6, + 0xf18f: 0xf1e7, + 0xf190: 0xf1e8, + 0xf191: 0xf1e9, + 0xf192: 0xf1ea, + 0xf193: 0xf1eb, + 0xf194: 0xf1ec, + 0xf195: 0xf1ed, + 0xf196: 0xf1ee, + 0xf197: 0xf1ef, + 0xf198: 0xf1f0, + 0xf199: 0xf1f1, + 0xf19a: 0xf1f2, + 0xf19b: 0xf1f3, + 0xf19c: 0xf1f4, + 0xf19d: 0xf1f5, + 0xf19e: 0xf1f6, + 0xf19f: 0xf1f7, + 0xf1a0: 0xf1f8, + 0xf1a1: 0xf1f9, + 0xf1a2: 0xf1fa, + 0xf1a3: 0xf1fb, + 0xf1a4: 0xf1fc, + 0xf1a5: 0xf1fd, + 0xf1a6: 0xf1fe, + 0xf1a7: 0xf1ff, + 0xf1a8: 0xf200, + 0xf1a9: 0xf201, + 0xf1aa: 0xf202, + 0xf1ab: 0xf203, + 0xf1ac: 0xf204, + 0xf1ad: 0xf205, + 0xf1ae: 0xf206, + 0xf1af: 0xf207, + 0xf1b0: 0xf208, + 0xf1b1: 0xf209, + 0xf1b2: 0xf20a, + 0xf1b3: 0xf20b, + 0xf1b4: 0xf20d, + 0xf1b5: 0xf20e, + 0xf1b6: 0xf20f, + 0xf1b7: 0xf210, + 0xf1b8: 0xf211, + 0xf1b9: 0xf212, + 0xf1ba: 0xf213, + 0xf1bb: 0xf214, + 0xf1bc: 0xf215, + 0xf1bd: 0xf216, + 0xf1be: 0xf218, + 0xf1bf: 0xf21a, + 0xf1c0: 0xf21b, + 0xf1c1: 0xf21c, + 0xf1c2: 0xf21d, + 0xf1c3: 0xf21e, + 0xf1c4: 0xf21f, + 0xf1c5: 0xf220, + 0xf1c6: 0xf221, + 0xf1c7: 0xf222, + 0xf1c8: 0xf223, + 0xf1c9: 0xf224, + 0xf1ca: 0xf225, + 0xf1cb: 0xf226, + 0xf1cd: 0xf227, + 0xf1ce: 0xf228, + 0xf1cf: 0xf22a, + 0xf1d0: 0xf22d, + 0xf1d1: 0xf22e, + 0xf1d2: 0xf22f, + 0xf1d3: 0xf230, + 0xf1d4: 0xf231, + 0xf1d5: 0xf232, + 0xf1d6: 0xf233, + 0xf1d7: 0xf234, + 0xf1d8: 0xf235, + 0xf1d9: 0xf236, + 0xf1da: 0xf237, + 0xf1db: 0xf238, + 0xf1dc: 0xf23a, + 0xf1dd: 0xf23b, + 0xf1de: 0xf23c, + 0xf1df: 0xf23d, + 0xf1e0: 0xf23e, + 0xf1e1: 0xf23f, + 0xf1e2: 0xf240, + 0xf1e3: 0xf241, + 0xf1e4: 0xf242, + 0xf1e5: 0xf243, + 0xf1e6: 0xf244, + 0xf1e7: 0xf245, + 0xf1e8: 0xf246, + 0xf1e9: 0xf247, + 0xf1ea: 0xf248, + 0xf1eb: 0xf249, + 0xf1ec: 0xf24a, + 0xf1ed: 0xf24b, + 0xf1ee: 0xf24c, + 0xf1ef: 0xf24d, + 0xf1f0: 0xf24e, + 0xf1f1: 0xf24f, + 0xf1f2: 0xf251, + 0xf1f3: 0xf253, + 0xf1f4: 0xf254, + 0xf1f5: 0xf255, + 0xf1f6: 0xf256, + 0xf1f7: 0xf258, + 0xf1f8: 0xf259, + 0xf1f9: 0xf25a, + 0xf1fa: 0xf25b, + 0xf1fb: 0xf25d, + 0xf1fc: 0xf25e, + 0xf1fd: 0xf25f, + 0xf1fe: 0xf260, + 0xf1ff: 0xf261, + 0xf200: 0xf262, + 0xf201: 0xf263, + 0xf202: 0xf264, + 0xf203: 0xf265, + 0xf204: 0xf266, + 0xf205: 0xf268, + 0xf206: 0xf269, + 0xf207: 0xf26a, + 0xf208: 0xf26b, + 0xf209: 0xf26c, + 0xf20a: 0xf26d, + 0xf20b: 0xf26e, + 0xf20c: 0xf26f, + 0xf20d: 0xf270, + 0xf20e: 0xf272, + 0xf20f: 0xf273, + 0xf210: 0xf274, + 0xf211: 0xf275, + 0xf212: 0xf276, + 0xf213: 0xf277, + 0xf214: 0xf278, + 0xf215: 0xf279, + 0xf216: 0xf27a, + 0xf217: 0xf27c, + 0xf218: 0xf27e, + 0xf219: 0xf27f, + 0xf21a: 0xf280, + 0xf21b: 0xf281, + 0xf21c: 0xf282, + 0xf21d: 0xf283, + 0xf21e: 0xf284, + 0xf21f: 0xf285, + 0xf220: 0xf286, + 0xf221: 0xf288, + 0xf222: 0xf289, + 0xf223: 0xf28a, + 0xf224: 0xf28c, + 0xf225: 0xf28d, + 0xf226: 0xf28e, + 0xf227: 0xf28f, + 0xf228: 0xf290, + 0xf229: 0xf292, + 0xf22a: 0xf293, + 0xf22b: 0xf294, + 0xf22c: 0xf295, + 0xf22d: 0xf296, + 0xf22e: 0xf297, + 0xf22f: 0xf298, + 0xf230: 0xf299, + 0xf231: 0xf29a, + 0xf232: 0xf29b, + 0xf233: 0xf29c, + 0xf234: 0xf29d, + 0xf235: 0xf29e, + 0xf236: 0xf29f, + 0xf237: 0xf2a0, + 0xf238: 0xf2a1, + 0xf239: 0xf2a2, + 0xf23a: 0xf2a3, + 0xf23b: 0xf2a4, + 0xf23c: 0xf2a5, + 0xf23d: 0xf2a6, + 0xf23e: 0xf2a7, + 0xf23f: 0xf2a8, + 0xf240: 0xf2a9, + 0xf241: 0xf2aa, + 0xf242: 0xf2ab, + 0xf243: 0xf2ac, + 0xf244: 0xf2ad, + 0xf245: 0xf2ae, + 0xf246: 0xf2af, + 0xf247: 0xf2b0, + 0xf248: 0xf2b1, + 0xf249: 0xf2b2, + 0xf24a: 0xf2b3, + 0xf24b: 0xf2b4, + 0xf24c: 0xf2b5, + 0xf24d: 0xf2b6, + 0xf24e: 0xf2b7, + 0xf24f: 0xf2b8, + 0xf250: 0xf2b9, + 0xf251: 0xf2ba, + 0xf252: 0xf2bb, + 0xf253: 0xf2bc, + 0xf254: 0xf2bd, + 0xf255: 0xf2be, + 0xf256: 0xf2bf, + 0xf257: 0xf2c0, + 0xf258: 0xf2c1, + 0xf259: 0xf2c2, + 0xf25a: 0xf2c3, + 0xf25b: 0xf2c4, + 0xf25c: 0xf2c6, + 0xf25d: 0xf2c7, + 0xf25e: 0xf2c8, + 0xf25f: 0xf2ca, + 0xf260: 0xf2cb, + 0xf261: 0xf2cc, + 0xf262: 0xf2cd, + 0xf263: 0xf2ce, + 0xf264: 0xf2cf, + 0xf265: 0xf2d0, + 0xf266: 0xf2d1, + 0xf267: 0xf2d2, + 0xf268: 0xf2d3, + 0xf269: 0xf2d5, + 0xf26a: 0xf2d6, + 0xf26b: 0xf2d7, + 0xf26c: 0xf2d8, + 0xf26d: 0xf2d9, + 0xf26e: 0xf2da, + 0xf26f: 0xf2db, + 0xf270: 0xf2dc, + 0xf271: 0xf2dd, + 0xf272: 0xf2de, + 0xf273: 0xf2df, + 0xf274: 0xf2e0, + 0xf275: 0xf2e1, + 0xf276: 0xf2e2, + 0xf277: 0xf2e3, + 0xf278: 0xf2e4, + 0xf279: 0xf2e5, + 0xf27a: 0xf2e6, + 0xf27b: 0xf2e7, + 0xf27c: 0xf2e8, + 0xf27d: 0xf2e9, + 0xf27e: 0xf2ea, + 0xf27f: 0xf2eb, + 0xf280: 0xf2ec, + 0xf282: 0xf2ed, + 0xf283: 0xf2ee, + 0xf285: 0xf2ef, + 0xf286: 0xf2f0, + 0xf287: 0xf2f1, + 0xf288: 0xf2f2, + 0xf289: 0xf2f3, + 0xf28a: 0xf2f4, + 0xf28b: 0xf2f5, + 0xf28d: 0xf2f6, + 0xf28e: 0xf2f7, + 0xf28f: 0xf2f8, + 0xf290: 0xf2f9, + 0xf291: 0xf2fa, + 0xf292: 0xf2fb, + 0xf293: 0xf2fc, + 0xf294: 0xf2fe, + 0xf295: 0xf2ff, + 0xf296: 0xf300, + 0xf297: 0xf301, + 0xf298: 0xf303, + 0xf299: 0xf304, + 0xf29a: 0xf305, + 0xf29b: 0xf306, + 0xf29c: 0xf307, + 0xf29d: 0xf308, + 0xf29e: 0xf30a, + 0xf29f: 0xf30b, + 0xf2a0: 0xf30c, + 0xf2a1: 0xf30d, + 0xf2a2: 0xf30e, + 0xf2a3: 0xf30f, + 0xf2a4: 0xf310, + 0xf2a5: 0xf313, + 0xf2a6: 0xf314, + 0xf2a7: 0xf315, + 0xf2a8: 0xf316, + 0xf2a9: 0xf317, + 0xf2aa: 0xf318, + 0xf2ab: 0xf319, + 0xf2ac: 0xf31a, + 0xf2af: 0xf31b, + 0xf2b0: 0xf31c, + 0xf2b1: 0xf31d, + 0xf2b2: 0xf31e, + 0xf2b3: 0xf31f, + 0xf2b4: 0xf320, + 0xf2b5: 0xf321, + 0xf2b6: 0xf322, + 0xf2b7: 0xf323, + 0xf2b8: 0xf324, + 0xf2b9: 0xf325, + 0xf2ba: 0xf326, + 0xf2bb: 0xf328, + 0xf2bc: 0xf329, + 0xf2bd: 0xf32a, + 0xf2be: 0xf32b, + 0xf2bf: 0xf32c, + 0xf2c0: 0xf32d, + 0xf2c1: 0xf32f, + 0xf2c2: 0xf330, + 0xf2c3: 0xf331, + 0xf2c4: 0xf332, + 0xf2c5: 0xf333, + 0xf2c6: 0xf334, + 0xf2c7: 0xf335, + 0xf2c8: 0xf336, + 0xf2c9: 0xf337, + 0xf2ca: 0xf338, + 0xf2cb: 0xf339, + 0xf2cc: 0xf33a, + 0xf2cd: 0xf33b, + 0xf2ce: 0xf33c, + 0xf2cf: 0xf33d, + 0xf2d0: 0xf33e, + 0xf2d1: 0xf33f, + 0xf2d2: 0xf340, + 0xf2d3: 0xf341, + 0xf2d4: 0xf342, + 0xf2d5: 0xf343, + 0xf2d6: 0xf344, + 0xf2d7: 0xf345, + 0xf2d8: 0xf346, + 0xf2d9: 0xf347, + 0xf2da: 0xf348, + 0xf2db: 0xf349, + 0xf2dc: 0xf34a, + 0xf2dd: 0xf34b, + 0xf2de: 0xf34c, + 0xf2df: 0xf34d, + 0xf2e0: 0xf34e, + 0xf2e1: 0xf34f, + 0xf2e2: 0xf350, + 0xf2e3: 0xf351, + 0xf2e4: 0xf352, + 0xf2e5: 0xf353, + 0xf2e6: 0xf354, + 0xf2e7: 0xf355, + 0xf2e8: 0xf356, + 0xf2e9: 0xf357, + 0xf2ea: 0xf358, + 0xf2eb: 0xf359, + 0xf2ec: 0xf35a, + 0xf2ed: 0xf35c, + 0xf2ee: 0xf35d, + 0xf2ef: 0xf35e, + 0xf2f0: 0xf35f, + 0xf2f1: 0xf360, + 0xf2f2: 0xf361, + 0xf2f3: 0xf362, + 0xf2f4: 0xf364, + 0xf2f5: 0xf365, + 0xf2f6: 0xf367, + 0xf2f7: 0xf368, + 0xf2f8: 0xf36a, + 0xf2f9: 0xf36b, + 0xf2fa: 0xf36c, + 0xf2fb: 0xf36d, + 0xf2fc: 0xf36e, + 0xf2fd: 0xf36f, + 0xf2fe: 0xf370, + 0xf2ff: 0xf371, + 0xf300: 0xf372, + 0xf301: 0xf373, + 0xf302: 0xf374, + 0xf303: 0xf375, + 0xf304: 0xf376, + 0xf305: 0xf377, + 0xf307: 0xf378, + 0xf308: 0xf379, + 0xf309: 0xf37a, + 0xf30a: 0xf37c, + 0xf30b: 0xf37d, + 0xf30c: 0xf37e, + 0xf30d: 0xf37f, + 0xf30e: 0xf380, + 0xf30f: 0xf381, + 0xf310: 0xf382, + 0xf311: 0xf383, + 0xf312: 0xf384, + 0xf313: 0xf385, + 0xf314: 0xf387, + 0xf315: 0xf388, + 0xf316: 0xf389, + 0xf317: 0xf38b, + 0xf318: 0xf38d, + 0xf319: 0xf38e, + 0xf31a: 0xf38f, + 0xf31b: 0xf390, + 0xf31c: 0xf391, + 0xf31d: 0xf392, + 0xf31e: 0xf394, + 0xf31f: 0xf395, + 0xf320: 0xf396, + 0xf321: 0xf397, + 0xf322: 0xf398, + 0xf323: 0xf399, + 0xf324: 0xf39a, + 0xf325: 0xf39b, + 0xf326: 0xf39c, + 0xf327: 0xf39d, + 0xf328: 0xf39e, + 0xf329: 0xf39f, + 0xf32a: 0xf3a0, + 0xf32b: 0xf3a1, + 0xf32c: 0xf3a2, + 0xf32d: 0xf3a3, + 0xf32e: 0xf3a4, + 0xf32f: 0xf3a5, + 0xf330: 0xf3a6, + 0xf331: 0xf3a7, + 0xf332: 0xf3a8, + 0xf333: 0xf3a9, + 0xf334: 0xf3aa, + 0xf335: 0xf3ab, + 0xf336: 0xf3ac, + 0xf337: 0xf3ad, + 0xf338: 0xf3ae, + 0xf339: 0xf3af, + 0xf33a: 0xf3b0, + 0xf33b: 0xf3b1, + 0xf33c: 0xf3b2, + 0xf33d: 0xf3b3, + 0xf33e: 0xf3b4, + 0xf33f: 0xf3b5, + 0xf340: 0xf3b6, + 0xf341: 0xf3b7, + 0xf342: 0xf3b8, + 0xf343: 0xf3b9, + 0xf344: 0xf3ba, + 0xf345: 0xf3bb, + 0xf346: 0xf3bc, + 0xf347: 0xf3bf, + 0xf348: 0xf3c0, + 0xf349: 0xf3c1, + 0xf34a: 0xf3c2, + 0xf34b: 0xf3c3, + 0xf34c: 0xf3c4, + 0xf34d: 0xf3c5, + 0xf34e: 0xf3c6, + 0xf34f: 0xf3c7, + 0xf350: 0xf3c8, + 0xf351: 0xf3c9, + 0xf352: 0xf3ca, + 0xf353: 0xf3cb, + 0xf354: 0xf3cc, + 0xf355: 0xf3cd, + 0xf356: 0xf3ce, + 0xf357: 0xf3cf, + 0xf358: 0xf3d0, + 0xf359: 0xf3d1, + 0xf35a: 0xf3d2, + 0xf35b: 0xf3d3, + 0xf35c: 0xf3d5, + 0xf35d: 0xf3d6, + 0xf35e: 0xf3d7, + 0xf35f: 0xf3d8, + 0xf360: 0xf3da, + 0xf361: 0xf3db, + 0xf362: 0xf3dc, + 0xf363: 0xf3dd, + 0xf364: 0xf3de, + 0xf365: 0xf3df, + 0xf366: 0xf3e0, + 0xf367: 0xf3e1, + 0xf368: 0xf3e2, + 0xf369: 0xf3e3, + 0xf36a: 0xf3e4, + 0xf36b: 0xf3e5, + 0xf36c: 0xf3e6, + 0xf36d: 0xf3e7, + 0xf36e: 0xf3e8, + 0xf36f: 0xf3e9, + 0xf370: 0xf3ea, + 0xf371: 0xf3eb, + 0xf372: 0xf3ec, + 0xf373: 0xf3ed, + 0xf374: 0xf3ee, + 0xf375: 0xf3ef, + 0xf376: 0xf3f0, + 0xf377: 0xf3f1, + 0xf378: 0xf3f2, + 0xf379: 0xf3f3, + 0xf37a: 0xf3f4, + 0xf37b: 0xf3f5, + 0xf37c: 0xf3f6, + 0xf37d: 0xf3f7, + 0xf37e: 0xf3f8, + 0xf37f: 0xf3f9, + 0xf380: 0xf3fc, + 0xf381: 0xf3fd, + 0xf382: 0xf3fe, + 0xf383: 0xf3ff, + 0xf384: 0xf401, + 0xf385: 0xf402, + 0xf386: 0xf403, + 0xf387: 0xf404, + 0xf388: 0xf405, + 0xf389: 0xf406, + 0xf38a: 0xf407, + 0xf38b: 0xf408, + 0xf38c: 0xf409, + 0xf38d: 0xf40a, + 0xf38e: 0xf40b, + 0xf38f: 0xf40c, + 0xf390: 0xf40d, + 0xf391: 0xf40e, + 0xf392: 0xf40f, + 0xf393: 0xf410, + 0xf394: 0xf411, + 0xf395: 0xf412, + 0xf396: 0xf413, + 0xf397: 0xf414, + 0xf398: 0xf415, + 0xf399: 0xf416, + 0xf39a: 0xf417, + 0xf39b: 0xf418, + 0xf39c: 0xf419, + 0xf39d: 0xf41a, + 0xf39e: 0xf41b, + 0xf39f: 0xf41c, + 0xf3a0: 0xf41d, + 0xf3a1: 0xf41e, + 0xf3a2: 0xf41f, + 0xf3a3: 0xf421, + 0xf3a4: 0xf422, + 0xf3a5: 0xf423, + 0xf3a6: 0xf424, + 0xf3a7: 0xf425, + 0xf3a8: 0xf426, + 0xf3a9: 0xf427, + 0xf3aa: 0xf429, + 0xf3ab: 0xf42a, + 0xf3ac: 0xf42b, + 0xf3ae: 0xf42c, + 0xf3af: 0xf42d, + 0xf3b0: 0xf42e, + 0xf3b1: 0xf42f, + 0xf3b2: 0xf430, + 0xf3b3: 0xf431, + 0xf3b5: 0xf432, + 0xf3b6: 0xf433, + 0xf3b7: 0xf434, + 0xf3b8: 0xf435, + 0xf3b9: 0xf436, + 0xf3ba: 0xf437, + 0xf3bb: 0xf438, + 0xf3bc: 0xf439, + 0xf3bd: 0xf43a, + 0xf3be: 0xf43b, + 0xf3bf: 0xf43c, + 0xf3c0: 0xf43d, + 0xf3c1: 0xf43e, + 0xf3c2: 0xf43f, + 0xf3c3: 0xf441, + 0xf3c4: 0xf442, + 0xf3c5: 0xf443, + 0xf3c6: 0xf444, + 0xf3c7: 0xf445, + 0xf3c8: 0xf446, + 0xf3c9: 0xf447, + 0xf3ca: 0xf448, + 0xf3cb: 0xf449, + 0xf3cc: 0xf44a, + 0xf3cd: 0xf44b, + 0xf3cf: 0xf44c, + 0xf3d0: 0xf44d, + 0xf3d1: 0xf44e, + 0xf3d2: 0xf44f, + 0xf3d3: 0xf450, + 0xf3d4: 0xf452, + 0xf3d5: 0xf453, + 0xf3d6: 0xf454, + 0xf3d7: 0xf455, + 0xf3d8: 0xf456, + 0xf3d9: 0xf457, + 0xf3da: 0xf458, + 0xf3db: 0xf459, + 0xf3dc: 0xf45a, + 0xf3dd: 0xf45b, + 0xf3de: 0xf45c, + 0xf3df: 0xf45d, + 0xf3e0: 0xf45e, + 0xf3e1: 0xf45f, + 0xf3e2: 0xf460, + 0xf3e3: 0xf461, + 0xf3e4: 0xf462, + 0xf3e5: 0xf463, + 0xf3e7: 0xf464, + 0xf3e8: 0xf465, + 0xf3e9: 0xf466, + 0xf3ea: 0xf467, + 0xf3eb: 0xf468, + 0xf3ec: 0xf46a, + 0xf3ed: 0xf46b, + 0xf3ee: 0xf46c, + 0xf3ef: 0xf46d, + 0xf3f0: 0xf46e, + 0xf3f1: 0xf470, + 0xf3f2: 0xf471, + 0xf3f3: 0xf472, + 0xf3f4: 0xf473, + 0xf3f5: 0xf474, + 0xf3f6: 0xf475, + 0xf3f7: 0xf476, + 0xf3f8: 0xf477, + 0xf3f9: 0xf478, + 0xf3fa: 0xf479, + 0xf3fb: 0xf47a, + 0xf3fc: 0xf47b, + 0xf3fd: 0xf47c, + 0xf3fe: 0xf47d, + 0xf3ff: 0xf47e, + 0xf400: 0xf47f, + 0xf401: 0xf480, + 0xf402: 0xf481, + 0xf403: 0xf482, + 0xf405: 0xf483, + 0xf406: 0xf484, + 0xf407: 0xf485, + 0xf408: 0xf486, + 0xf409: 0xf487, + 0xf40a: 0xf488, + 0xf40b: 0xf489, + 0xf40c: 0xf48a, + 0xf40d: 0xf48b, + 0xf40e: 0xf48c, + 0xf40f: 0xf48e, + 0xf410: 0xf48f, + 0xf411: 0xf490, + 0xf412: 0xf491, + 0xf413: 0xf492, + 0xf414: 0xf493, + 0xf415: 0xf494, + 0xf416: 0xf495, + 0xf417: 0xf496, + 0xf418: 0xf497, + 0xf419: 0xf499, + 0xf41a: 0xf49a, + 0xf41b: 0xf49b, + 0xf41c: 0xf49c, + 0xf41d: 0xf49d, + 0xf41e: 0xf49e, + 0xf41f: 0xf49f, + 0xf421: 0xf4a0, + 0xf422: 0xf4a1, + 0xf423: 0xf4a2, + 0xf424: 0xf4a3, + 0xf425: 0xf4a4, + 0xf426: 0xf4a5, + 0xf427: 0xf4a6, + 0xf428: 0xf4a7, + 0xf429: 0xf4a8, + 0xf42a: 0xf4aa, + 0xf42b: 0xf4ab, + 0xf42c: 0xf4ac, + 0xf42d: 0xf4ad, + 0xf42e: 0xf4ae, + 0xf42f: 0xf4af, + 0xf430: 0xf4b0, + 0xf431: 0xf4b1, + 0xf432: 0xf4b2, + 0xf433: 0xf4b3, + 0xf434: 0xf4b4, + 0xf435: 0xf4b5, + 0xf436: 0xf4b6, + 0xf437: 0xf4b7, + 0xf438: 0xf4b8, + 0xf439: 0xf4b9, + 0xf43a: 0xf4ba, + 0xf43b: 0xf4bb, + 0xf43c: 0xf4bc, + 0xf43d: 0xf4bd, + 0xf43e: 0xf4be, + 0xf43f: 0xf4bf, + 0xf440: 0xf4c1, + 0xf444: 0xf4c5, + 0xf445: 0xf4c6, + 0xf446: 0xf4c7, + 0xf447: 0xf4c8, + 0xf448: 0xf4c9, + 0xf449: 0xf4ca, + 0xf44a: 0xf4cb, + 0xf44b: 0xf4cc, + 0xf44c: 0xf4cd, + 0xf44d: 0xf4ce, + 0xf44e: 0xf4cf, + 0xf44f: 0xf4d0, + 0xf450: 0xf4d1, + 0xf451: 0xf4d2, + 0xf452: 0xf4d3, + 0xf453: 0xf4d4, + 0xf454: 0xf4d5, + 0xf455: 0xf4d6, + 0xf456: 0xf4d7, + 0xf457: 0xf4d8, + 0xf458: 0xf4d9, + 0xf459: 0xf4da, + 0xf45a: 0xf4db, + 0xf45b: 0xf4dc, + 0xf45c: 0xf4dd, + 0xf45d: 0xf4de, + 0xf45e: 0xf4df, + 0xf45f: 0xf4e0, + 0xf460: 0xf4e1, + 0xf461: 0xf4e2, + 0xf462: 0xf4e4, + 0xf463: 0xf4e5, + 0xf464: 0xf4e6, + 0xf465: 0xf4e7, + 0xf466: 0xf4e8, + 0xf467: 0xf4e9, + 0xf468: 0xf4ea, + 0xf469: 0xf4eb, + 0xf46a: 0xf4ec, + 0xf46b: 0xf4ed, + 0xf46c: 0xf4ee, + 0xf46d: 0xf4ef, + 0xf46e: 0xf4f0, + 0xf46f: 0xf4f1, + 0xf470: 0xf4f2, + 0xf471: 0xf4f3, + 0xf472: 0xf4f4, + 0xf473: 0xf4f5, + 0xf474: 0xf4f6, + 0xf475: 0xf4f7, + 0xf476: 0xf4f8, + 0xf477: 0xf4f9, + 0xf478: 0xf4fa, + 0xf479: 0xf4fb, + 0xf47a: 0xf4fc, + 0xf47b: 0xf4fd, + 0xf47c: 0xf4fe, + 0xf47d: 0xf4ff, + 0xf47e: 0xf500, + 0xf47f: 0xf501, + 0xf480: 0xf502, + 0xf481: 0xf503, + 0xf482: 0xf504, + 0xf483: 0xf505, + 0xf484: 0xf506, + 0xf485: 0xf507, + 0xf486: 0xf508, + 0xf487: 0xf509, + 0xf488: 0xf50a, + 0xf489: 0xf50b, + 0xf48a: 0xf50c, + 0xf48b: 0xf50d, + 0xf48c: 0xf50e, + 0xf48d: 0xf50f, + 0xf48e: 0xf510, + 0xf48f: 0xf511, + 0xf490: 0xf512, + 0xf491: 0xf513, + 0xf492: 0xf514, + 0xf493: 0xf515, + 0xf494: 0xf516, + 0xf495: 0xf517, + 0xf496: 0xf518, + 0xf497: 0xf519, + 0xf498: 0xf51a, + 0xf499: 0xf51b, + 0xf49a: 0xf51c, + 0xf49b: 0xf51d, + 0xf49c: 0xf51e, + 0xf49d: 0xf51f, + 0xf49e: 0xf520, + 0xf49f: 0xf521, + 0xf4a0: 0xf522, + 0xf4a1: 0xf523, + 0xf4a2: 0xf524, + 0xf4a3: 0xf525, + 0xf4a4: 0xf526, + 0xf4a5: 0xf527, + 0xf4a6: 0xf528, + 0xf4a7: 0xf529, + 0xf4a8: 0xf52a, + 0xf4a9: 0xf52b, + 0xf4aa: 0xf52c, + 0xf4ab: 0xf52d, + 0xf4ac: 0xf52e, + 0xf4ad: 0xf52f, + 0xf4ae: 0xf530, + 0xf4af: 0xf531, + 0xf4b0: 0xf532, + 0xf4b1: 0xf533, + 0xf4b2: 0xf534, + 0xf4b3: 0xf535, + 0xf4b4: 0xf536, + 0xf4b5: 0xf537, + 0xf4b6: 0xf538, + 0xf4b7: 0xf53a, + 0xf4b8: 0xf53b, + 0xf4b9: 0xf53c, + 0xf4ba: 0xf53d, + 0xf4bb: 0xf53e, + 0xf4bc: 0xf540, + 0xf4bd: 0xf541, + 0xf4be: 0xf542, + 0xf4bf: 0xf543, + 0xf4c0: 0xf544, + 0xf4c1: 0xf545, + 0xf4c2: 0xf546, + 0xf4c3: 0xf547, + 0xf4c4: 0xf548, + 0xf4c5: 0xf549, + 0xf4c6: 0xf54a, + 0xf4c7: 0xf54b, + 0xf4c8: 0xf54c, + 0xf4c9: 0xf54d, + 0xf4ca: 0xf54e, + 0xf4cb: 0xf54f, + 0xf4cc: 0xf550, + 0xf4cd: 0xf551, + 0xf4ce: 0xf552, + 0xf4cf: 0xf554, + 0xf4d0: 0xf555, + 0xf4d1: 0xf556, + 0xf4d2: 0xf557, + 0xf4d3: 0xf558, + 0xf4d4: 0xf559, + 0xf4d5: 0xf55a, + 0xf4d6: 0xf55b, + 0xf4d7: 0xf55c, + 0xf4d8: 0xf55d, + 0xf4d9: 0xf55e, + 0xf4da: 0xf55f, + 0xf4db: 0xf560, + 0xf4dc: 0xf561, + 0xf4dd: 0xf562, + 0xf4de: 0xf563, + 0xf4df: 0xf564, + 0xf4e0: 0xf565, + 0xf4e1: 0xf566, + 0xf4e2: 0xf567, + 0xf4e3: 0xf568, + 0xf4e4: 0xf569, + 0xf4e5: 0xf56a, + 0xf4e6: 0xf56b, + 0xf4e7: 0xf56c, + 0xf4e8: 0xf56e, + 0xf4e9: 0xf56f, + 0xf4ea: 0xf570, + 0xf4eb: 0xf571, + 0xf4ec: 0xf573, + 0xf4ed: 0xf574, + 0xf4ee: 0xf575, + 0xf4ef: 0xf576, + 0xf4f0: 0xf577, + 0xf4f1: 0xf578, + 0xf4f2: 0xf579, + 0xf4f3: 0xf57a, + 0xf4f4: 0xf57b, + 0xf4f5: 0xf57c, + 0xf4f6: 0xf57d, + 0xf4f7: 0xf57e, + 0xf4f8: 0xf57f, + 0xf4f9: 0xf580, + 0xf4fa: 0xf581, + 0xf4fb: 0xf582, + 0xf4fc: 0xf583, + 0xf4fd: 0xf584, + 0xf4fe: 0xf585, + 0xf4ff: 0xf586, + 0xf500: 0xf588, + 0xf501: 0xf589, + 0xf502: 0xf58a, + 0xf503: 0xf58b, + 0xf504: 0xf58c, + 0xf505: 0xf58d, + 0xf506: 0xf58e, + 0xf507: 0xf58f, + 0xf508: 0xf590, + 0xf509: 0xf591, + 0xf50a: 0xf592, + 0xf50b: 0xf593, + 0xf50c: 0xf594, + 0xf50d: 0xf595, + 0xf50e: 0xf597, + 0xf50f: 0xf598, + 0xf510: 0xf599, + 0xf511: 0xf59a, + 0xf512: 0xf59b, + 0xf513: 0xf59c, + 0xf514: 0xf59d, + 0xf515: 0xf59e, + 0xf516: 0xf59f, + 0xf517: 0xf5a0, + 0xf518: 0xf5a1, + 0xf519: 0xf5a2, + 0xf51a: 0xf5a3, + 0xf51b: 0xf5a4, + 0xf51c: 0xf5a6, + 0xf51d: 0xf5a7, + 0xf51e: 0xf5a8, + 0xf51f: 0xf5aa, + 0xf520: 0xf5ab, + 0xf521: 0xf5ac, + 0xf522: 0xf5ae, + 0xf523: 0xf5af, + 0xf524: 0xf5b0, + 0xf525: 0xf5b1, + 0xf526: 0xf5b2, + 0xf527: 0xf5b3, + 0xf528: 0xf5b4, + 0xf529: 0xf5b5, + 0xf52a: 0xf5b6, + 0xf52b: 0xf5b7, + 0xf52c: 0xf5b8, + 0xf52d: 0xf5ba, + 0xf52e: 0xf5bb, + 0xf52f: 0xf5bc, + 0xf530: 0xf5be, + 0xf531: 0xf5bf, + 0xf532: 0xf5c0, + 0xf533: 0xf5c1, + 0xf534: 0xf5c2, + 0xf535: 0xf5c3, + 0xf536: 0xf5c4, + 0xf537: 0xf5c5, + 0xf538: 0xf5c6, + 0xf539: 0xf5c7, + 0xf53a: 0xf5c8, + 0xf53b: 0xf5c9, + 0xf53c: 0xf5ca, + 0xf53d: 0xf5cb, + 0xf53e: 0xf5cc, + 0xf53f: 0xf5cd, + 0xf540: 0xf5ce, + 0xf541: 0xf5cf, + 0xf542: 0xf5d0, + 0xf543: 0xf5d1, + 0xf544: 0xf5d3, + 0xf545: 0xf5d4, + 0xf546: 0xf5d5, + 0xf547: 0xf5d6, + 0xf548: 0xf5d7, + 0xf549: 0xf5d8, + 0xf54a: 0xf5d9, + 0xf54b: 0xf5da, + 0xf54c: 0xf5db, + 0xf54d: 0xf5dc, + 0xf54e: 0xf5dd, + 0xf54f: 0xf5de, + 0xf550: 0xf5df, + 0xf551: 0xf5e0, + 0xf552: 0xf5e1, + 0xf553: 0xf5e2, + 0xf554: 0xf5e3, + 0xf555: 0xf5e4, + 0xf556: 0xf5e5, + 0xf557: 0xf5e6, + 0xf558: 0xf5e7, + 0xf559: 0xf5e8, + 0xf55a: 0xf5e9, + 0xf55b: 0xf5ea, + 0xf55c: 0xf5eb, + 0xf55d: 0xf5ec, + 0xf55e: 0xf5ed, + 0xf55f: 0xf5ee, + 0xf560: 0xf5ef, + 0xf561: 0xf5f0, + 0xf562: 0xf5f1, + 0xf563: 0xf5f2, + 0xf564: 0xf5f3, + 0xf565: 0xf5f4, + 0xf566: 0xf5f6, + 0xf567: 0xf5f8, + 0xf568: 0xf5f9, + 0xf569: 0xf5fa, + 0xf56a: 0xf5fb, + 0xf56b: 0xf5fc, + 0xf56c: 0xf5fd, + 0xf56d: 0xf5fe, + 0xf56e: 0xf5ff, + 0xf56f: 0xf600, + 0xf570: 0xf601, + 0xf571: 0xf603, + 0xf572: 0xf604, + 0xf573: 0xf605, + 0xf574: 0xf606, + 0xf575: 0xf607, + 0xf576: 0xf608, + 0xf577: 0xf609, + 0xf578: 0xf60a, + 0xf579: 0xf60b, + 0xf57a: 0xf60c, + 0xf57b: 0xf60d, + 0xf57c: 0xf60e, + 0xf57d: 0xf60f, + 0xf57f: 0xf610, + 0xf580: 0xf611, + 0xf581: 0xf612, + 0xf582: 0xf613, + 0xf583: 0xf614, + 0xf584: 0xf615, + 0xf585: 0xf617, + 0xf586: 0xf618, + 0xf588: 0xf61a, + 0xf589: 0xf61b, + 0xf58a: 0xf61c, + 0xf58b: 0xf61e, + 0xf58c: 0xf61f, + 0xf58d: 0xf620, + 0xf58e: 0xf621, + 0xf58f: 0xf622, + 0xf590: 0xf623, + 0xf591: 0xf624, + 0xf592: 0xf625, + 0xf593: 0xf626, + 0xf594: 0xf627, + 0xf595: 0xf628, + 0xf596: 0xf629, + 0xf597: 0xf62a, + 0xf598: 0xf62b, + 0xf599: 0xf62c, + 0xf59a: 0xf62d, + 0xf59b: 0xf62e, + 0xf59c: 0xf62f, + 0xf59d: 0xf630, + 0xf59e: 0xf631, + 0xf59f: 0xf632, + 0xf5a0: 0xf633, + 0xf5a1: 0xf634, + 0xf5a2: 0xf635, + 0xf5a3: 0xf637, + 0xf5a4: 0xf638, + 0xf5a5: 0xf639, + 0xf5a6: 0xf63a, + 0xf5a7: 0xf63b, + 0xf5a8: 0xf63c, + 0xf5a9: 0xf63d, + 0xf5aa: 0xf63e, + 0xf5ab: 0xf63f, + 0xf5ac: 0xf640, + 0xf5ad: 0xf641, + 0xf5ae: 0xf642, + 0xf5af: 0xf643, + 0xf5b0: 0xf644, + 0xf5b1: 0xf645, + 0xf5b2: 0xf646, + 0xf5b3: 0xf647, + 0xf5b4: 0xf648, + 0xf5b5: 0xf649, + 0xf5b6: 0xf64b, + 0xf5b7: 0xf64c, + 0xf5b8: 0xf64d, + 0xf5b9: 0xf64e, + 0xf5ba: 0xf64f, + 0xf5bb: 0xf650, + 0xf5bc: 0xf651, + 0xf5bd: 0xf652, + 0xf5be: 0xf653, + 0xf5bf: 0xf655, + 0xf5c0: 0xf656, + 0xf5c1: 0xf657, + 0xf5c2: 0xf658, + 0xf5c3: 0xf659, + 0xf5c4: 0xf65a, + 0xf5c5: 0xf65b, + 0xf5c6: 0xf65c, + 0xf5c7: 0xf65d, + 0xf5c8: 0xf65e, + 0xf5c9: 0xf65f, + 0xf5ca: 0xf660, + 0xf5cb: 0xf662, + 0xf5cc: 0xf663, + 0xf5cd: 0xf664, + 0xf5ce: 0xf665, + 0xf5cf: 0xf666, + 0xf5d0: 0xf667, + 0xf5d1: 0xf668, + 0xf5d2: 0xf669, + 0xf5d3: 0xf66a, + 0xf5d4: 0xf66b, + 0xf5d5: 0xf66c, + 0xf5d6: 0xf66d, + 0xf5d7: 0xf66e, + 0xf5d8: 0xf66f, + 0xf5d9: 0xf670, + 0xf5da: 0xf671, + 0xf5db: 0xf672, + 0xf5dc: 0xf673, + 0xf5dd: 0xf674, + 0xf5de: 0xf675, + 0xf5df: 0xf676, + 0xf5e0: 0xf677, + 0xf5e1: 0xf678, + 0xf5e2: 0xf679, + 0xf5e3: 0xf67a, + 0xf5e4: 0xf67b, + 0xf5e5: 0xf67c, + 0xf5e6: 0xf67d, + 0xf5e7: 0xf67e, + 0xf5e8: 0xf67f, + 0xf5e9: 0xf680, + 0xf5ea: 0xf682, + 0xf5eb: 0xf683, + 0xf5ec: 0xf684, + 0xf5ed: 0xf686, + 0xf5ee: 0xf687, + 0xf5ef: 0xf688, + 0xf5f0: 0xf689, + 0xf5f1: 0xf68a, + 0xf5f2: 0xf68b, + 0xf5f3: 0xf68d, + 0xf5f4: 0xf68e, + 0xf5f5: 0xf68f, + 0xf5f6: 0xf690, + 0xf5f7: 0xf691, + 0xf5f8: 0xf692, + 0xf5f9: 0xf693, + 0xf5fa: 0xf694, + 0xf5fb: 0xf695, + 0xf5fc: 0xf696, + 0xf5fd: 0xf697, + 0xf5fe: 0xf698, + 0xf5ff: 0xf69a, + 0xf600: 0xf69b, + 0xf601: 0xf69d, + 0xf602: 0xf69e, + 0xf603: 0xf69f, + 0xf604: 0xf6a0, + 0xf605: 0xf6a1, + 0xf606: 0xf6a2, + 0xf607: 0xf6a3, + 0xf608: 0xf6a4, + 0xf609: 0xf6a5, + 0xf60a: 0xf6a6, + 0xf60b: 0xf6a7, + 0xf60c: 0xf6a8, + 0xf60d: 0xf6a9, + 0xf60e: 0xf6aa, + 0xf60f: 0xf6ac, + 0xf610: 0xf6ad, + 0xf611: 0xf6ae, + 0xf612: 0xf6af, + 0xf613: 0xf6b0, + 0xf614: 0xf6b1, + 0xf615: 0xf6b2, + 0xf616: 0xf6b3, + 0xf617: 0xf6b4, + 0xf618: 0xf6b5, + 0xf619: 0xf6b6, + 0xf61a: 0xf6b7, + 0xf61c: 0xf6b8, + 0xf61d: 0xf6b9, + 0xf61f: 0xf6ba, + 0xf620: 0xf6bb, + 0xf621: 0xf6bc, + 0xf622: 0xf6bd, + 0xf623: 0xf6be, + 0xf624: 0xf6bf, + 0xf625: 0xf6c0, + 0xf626: 0xf6c1, + 0xf627: 0xf6c2, + 0xf628: 0xf6c3, + 0xf629: 0xf6c4, + 0xf62a: 0xf6c6, + 0xf62b: 0xf6c7, + 0xf62c: 0xf6c8, + 0xf62d: 0xf6c9, + 0xf62e: 0xf6ca, + 0xf62f: 0xf6cb, + 0xf630: 0xf6cc, + 0xf631: 0xf6cd, + 0xf632: 0xf6ce, + 0xf633: 0xf6cf, + 0xf634: 0xf6d0, + 0xf635: 0xf6d1, + 0xf636: 0xf6d2, + 0xf637: 0xf6d3, + 0xf638: 0xf6d4, + 0xf639: 0xf6d5, + 0xf63a: 0xf6d6, + 0xf63b: 0xf6d7, + 0xf63c: 0xf6d8, + 0xf63d: 0xf6d9, + 0xf63e: 0xf6da, + 0xf63f: 0xf6db, + 0xf640: 0xf6dc, + 0xf641: 0xf6dd, + 0xf642: 0xf6de, + 0xf644: 0xf6e0, + 0xf645: 0xf6e1, + 0xf646: 0xf6e2, + 0xf647: 0xf6e3, + 0xf648: 0xf6e5, + 0xf649: 0xf6e6, + 0xf64a: 0xf6e7, + 0xf64b: 0xf6e8, + 0xf64c: 0xf6e9, + 0xf64d: 0xf6ea, + 0xf64e: 0xf6eb, + 0xf64f: 0xf6ec, + 0xf650: 0xf6ed, + 0xf651: 0xf6ee, + 0xf652: 0xf6f0, + 0xf653: 0xf6f1, + 0xf654: 0xf6f2, + 0xf655: 0xf6f4, + 0xf656: 0xf6f5, + 0xf657: 0xf6f6, + 0xf658: 0xf6f7, + 0xf659: 0xf6f8, + 0xf65a: 0xf6f9, + 0xf65b: 0xf6fa, + 0xf65c: 0xf6fb, + 0xf65d: 0xf6fc, + 0xf65e: 0xf6fd, + 0xf65f: 0xf6fe, + 0xf660: 0xf6ff, + 0xf661: 0xf700, + 0xf662: 0xf701, + 0xf663: 0xf702, + 0xf664: 0xf703, + 0xf665: 0xf704, + 0xf666: 0xf705, + 0xf667: 0xf706, + 0xf668: 0xf707, + 0xf669: 0xf708, + 0xf66a: 0xf709, + 0xf66b: 0xf70a, + 0xf66c: 0xf70b, + 0xf66d: 0xf70c, + 0xf66e: 0xf70d, + 0xf66f: 0xf70e, + 0xf670: 0xf70f, + 0xf671: 0xf710, + 0xf672: 0xf711, + 0xf673: 0xf712, + 0xf674: 0xf713, + 0xf675: 0xf714, + 0xf676: 0xf715, + 0xf677: 0xf716, + 0xf678: 0xf717, + 0xf679: 0xf718, + 0xf67a: 0xf719, + 0xf67b: 0xf71a, + 0xf67c: 0xf71b, + 0xf67d: 0xf71c, + 0xf67e: 0xf71d, + 0xf67f: 0xf71e, + 0xf680: 0xf71f, + 0xf681: 0xf720, + 0xf682: 0xf721, + 0xf683: 0xf722, + 0xf684: 0xf724, + 0xf685: 0xf726, + 0xf686: 0xf727, + 0xf687: 0xf728, + 0xf688: 0xf729, + 0xf689: 0xf72a, + 0xf68a: 0xf72b, + 0xf68b: 0xf72c, + 0xf68c: 0xf72d, + 0xf68d: 0xf72e, + 0xf68e: 0xf730, + 0xf68f: 0xf731, + 0xf690: 0xf732, + 0xf691: 0xf733, + 0xf692: 0xf734, + 0xf693: 0xf735, + 0xf694: 0xf736, + 0xf695: 0xf737, + 0xf696: 0xf738, + 0xf697: 0xf739, + 0xf698: 0xf73a, + 0xf699: 0xf73b, + 0xf69a: 0xf73c, + 0xf69b: 0xf73d, + 0xf69c: 0xf73e, + 0xf69d: 0xf73f, + 0xf69e: 0xf740, + 0xf69f: 0xf741, + 0xf6a0: 0xf742, + 0xf6a1: 0xf743, + 0xf6a2: 0xf744, + 0xf6a3: 0xf745, + 0xf6a4: 0xf746, + 0xf6a5: 0xf747, + 0xf6a6: 0xf748, + 0xf6a7: 0xf749, + 0xf6a8: 0xf74a, + 0xf6a9: 0xf74b, + 0xf6aa: 0xf74c, + 0xf6ab: 0xf74e, + 0xf6ac: 0xf74f, + 0xf6ad: 0xf750, + 0xf6ae: 0xf751, + 0xf6af: 0xf752, + 0xf6b0: 0xf753, + 0xf6b1: 0xf754, + 0xf6b2: 0xf755, + 0xf6b3: 0xf756, + 0xf6b4: 0xf757, + 0xf6b5: 0xf758, + 0xf6b6: 0xf759, + 0xf6b7: 0xf75a, + 0xf6b8: 0xf75b, + 0xf6b9: 0xf75c, + 0xf6ba: 0xf75d, + 0xf6bb: 0xf75e, + 0xf6bc: 0xf75f, + 0xf6bd: 0xf760, + 0xf6be: 0xf761, + 0xf6bf: 0xf763, + 0xf6c0: 0xf764, + 0xf6c1: 0xf765, + 0xf6c2: 0xf766, + 0xf6c3: 0xf767, + 0xf6c4: 0xf768, + 0xf6c5: 0xf769, +}; diff --git a/lib/data/single_currency_flow.dart b/lib/data/single_currency_flow.dart index d7094f35..c9fe3045 100644 --- a/lib/data/single_currency_flow.dart +++ b/lib/data/single_currency_flow.dart @@ -57,10 +57,16 @@ class SingleCurrencyFlow { _expenseCount++; if (money.currency == this.currency) { _expenseSum += amount; - } else if (rates != null) { - _expenseSum += money.convert(this.currency, rates).amount; } else { - _hasMissingData = true; + // Converting can throw when a rate is missing; per this class's + // contract that is "missing data", never a hard failure that would + // abort the whole aggregation for one unconvertible transaction. + final double? converted = _tryConvert(money, rates); + if (converted == null) { + _hasMissingData = true; + } else { + _expenseSum += converted; + } } return; @@ -70,16 +76,29 @@ class SingleCurrencyFlow { _incomeCount++; if (money.currency == this.currency) { _incomeSum += amount; - } else if (rates != null) { - _incomeSum += money.convert(this.currency, rates).amount; } else { - _hasMissingData = true; + final double? converted = _tryConvert(money, rates); + if (converted == null) { + _hasMissingData = true; + } else { + _incomeSum += converted; + } } return; } } + double? _tryConvert(Money money, ExchangeRates? rates) { + if (rates == null) return null; + + try { + return money.convert(currency, rates).amount; + } catch (_) { + return null; + } + } + void addAll(Iterable moneys, ExchangeRates? rates) { for (final Money money in moneys) { add(money, rates); diff --git a/lib/graceful_migrations.dart b/lib/graceful_migrations.dart index d009b4f1..40fb0c28 100644 --- a/lib/graceful_migrations.dart +++ b/lib/graceful_migrations.dart @@ -1,5 +1,9 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/legacy_simple_icons_codepoints.dart"; import "package:flow/data/transaction_filter.dart"; import "package:flow/data/transactions_filter/pending_time_range.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction/extensions/default/geo.dart"; import "package:flow/l10n/flow_localizations.dart"; @@ -11,6 +15,7 @@ import "package:flow/services/user_preferences.dart"; import "package:flow/utils/utils.dart"; import "package:logging/logging.dart"; import "package:shared_preferences/shared_preferences.dart"; +import "package:simple_icons_flow/simple_icons_flow.dart"; final Logger _log = Logger("GracefulMigrations"); @@ -310,3 +315,88 @@ void migrateHomePendingTransactionsRange() async { ); } } + +/// Converts Simple Icons brand icons stored as a code-point [IconFlowIcon] into +/// the slug-based [SimpleIconFlowIcon]. +/// +/// Simple Icons reassigns code points every release, so a stored code point is +/// only meaningful for the version it was saved with. Flow shipped +/// simple_icons 14.6.1; [legacySimpleIconsCodepoints] maps those code points +/// forward to the bundled 16.20.0 build, from which we recover the stable slug. +/// This is the *only* remaining use of that table — once this migration has +/// propagated, the migration and the table can both be deleted. +Future migrateSimpleIconsToSlug() async { + const String migrationUuid = "598a1c1d-1d53-44e0-9035-e005c5420538"; + + try { + final SharedPreferencesWithCache prefs = + await SharedPreferencesWithCache.create( + cacheOptions: SharedPreferencesWithCacheOptions(), + ); + + final ok = prefs.getString("flow.migration.$migrationUuid"); + + if (ok != null) return; + + try { + // 16.20.0 code point -> slug, built once from the bundled font. + final Map codePointToSlug = { + for (final entry in SimpleIcons.values.entries) + entry.value.codePoint: entry.key, + }; + + String? slugForIconCode(String iconCode) { + final FlowIconData? parsed = FlowIconData.tryParse(iconCode); + if (parsed is! IconFlowIcon) return null; + if (parsed.iconData.fontFamily != "SimpleIcons") return null; + + // Stored code points are 14.6.1; map them forward before resolving. + // A value already at 16.20.0 isn't a table key, so it passes through. + final int codePoint = + legacySimpleIconsCodepoints[parsed.iconData.codePoint] ?? + parsed.iconData.codePoint; + return codePointToSlug[codePoint]; + } + + final List changedAccounts = []; + for (final Account account in ObjectBox().box().getAll()) { + final String? slug = slugForIconCode(account.iconCode); + if (slug == null) continue; + account.iconCode = SimpleIconFlowIcon(slug).toString(); + changedAccounts.add(account); + } + + final List changedCategories = []; + for (final Category category in ObjectBox().box().getAll()) { + final String? slug = slugForIconCode(category.iconCode); + if (slug == null) continue; + category.iconCode = SimpleIconFlowIcon(slug).toString(); + changedCategories.add(category); + } + + if (changedAccounts.isNotEmpty) { + await ObjectBox().box().putManyAsync(changedAccounts); + } + if (changedCategories.isNotEmpty) { + await ObjectBox().box().putManyAsync(changedCategories); + } + + await prefs.setString("flow.migration.$migrationUuid", "ok"); + _log.info( + "Migrated ${changedAccounts.length} account(s) and " + "${changedCategories.length} category(ies) to slug-based brand icons " + "for migration $migrationUuid", + ); + } catch (e) { + _log.warning( + "Failed to migrate Simple Icons to slugs for migration $migrationUuid", + e, + ); + } + } catch (e) { + _log.warning( + "Failed to read migration status for migration $migrationUuid", + e, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index d9c108d0..7a93f184 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -229,6 +229,7 @@ class FlowState extends State { migrateThemePrefsToDb(); migratePrivacyPreferencesToUserPreferences(); migrateHomePendingTransactionsRange(); + unawaited(migrateSimpleIconsToSlug()); // Geo migration queries `extraTag: "hasExtension:..."`, which is only // populated by the extra-key indexing migration. Chain them so geo diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 9abe421f..cb7a9101 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -251,7 +251,16 @@ extension MainActions on ObjectBox { final K? associatedData = associateBy?.call(transaction); flow[key] ??= MultiCurrencyFlow(associatedData: associatedData); - flow[key]!.add(transaction.money); + + // A single transaction with an invalid/unsupported currency code makes + // [Transaction.money] (and [MultiCurrencyFlow.add]) throw. Skip just that + // transaction instead of aborting the entire aggregation, which would + // otherwise leave every consumer of this grouping with empty results. + try { + flow[key]!.add(transaction.money); + } catch (_) { + continue; + } } return flow; @@ -1134,8 +1143,10 @@ extension AccountActions on Account { TransitiveLocalPreferences.categoryFrecencyType(resolvedType), // Transfers don't carry a category in normal flows, so this branch // is a safety fallback — bucket it with expenses. - TransactionType.transfer => TransitiveLocalPreferences - .categoryFrecencyType(TransactionType.expense), + TransactionType.transfer => + TransitiveLocalPreferences.categoryFrecencyType( + TransactionType.expense, + ), }; unawaited( TransitiveLocalPreferences() diff --git a/lib/routes.dart b/lib/routes.dart index 65188ccf..e2cf802b 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -8,12 +8,6 @@ import "package:flow/routes/categories_page.dart"; import "package:flow/routes/category/category_edit_page.dart"; import "package:flow/routes/category_page.dart"; import "package:flow/routes/community/contributors_page.dart"; -import "package:flow/routes/debug/analytics/debug_cash_flow_page.dart"; -import "package:flow/routes/debug/analytics/debug_net_worth_page.dart"; -import "package:flow/routes/debug/analytics/debug_recurring_page.dart"; -import "package:flow/routes/debug/analytics/debug_spending_calendar_page.dart"; -import "package:flow/routes/debug/analytics/debug_spending_map_page.dart"; -import "package:flow/routes/debug/analytics/debug_wrapped_page.dart"; import "package:flow/routes/debug/debug_icloud_page.dart"; import "package:flow/routes/debug/debug_log_page.dart"; import "package:flow/routes/debug/debug_logs_page.dart"; @@ -55,7 +49,13 @@ 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/cash_flow_page.dart"; +import "package:flow/routes/stats/net_worth_page.dart"; +import "package:flow/routes/stats/recurring_page.dart"; +import "package:flow/routes/stats/spending_calendar_page.dart"; +import "package:flow/routes/stats/spending_map_page.dart"; import "package:flow/routes/stats/stats_by_group_page.dart"; +import "package:flow/routes/stats/wrapped_page.dart"; import "package:flow/routes/support_page.dart"; import "package:flow/routes/transaction_batch_import_page.dart"; import "package:flow/routes/transaction_page.dart"; @@ -496,28 +496,28 @@ final GoRouter router = GoRouter( builder: (context, state) => DebugThemePage(), ), GoRoute( - path: "/_debug/analytics/net-worth", - builder: (context, state) => const DebugNetWorthPage(), + path: "/stats/net-worth", + builder: (context, state) => const NetWorthPage(), ), GoRoute( - path: "/_debug/analytics/wrapped", - builder: (context, state) => const DebugWrappedPage(), + path: "/stats/wrapped", + builder: (context, state) => const WrappedPage(), ), GoRoute( - path: "/_debug/analytics/recurring", - builder: (context, state) => const DebugRecurringPage(), + path: "/stats/recurring", + builder: (context, state) => const RecurringPage(), ), GoRoute( - path: "/_debug/analytics/calendar", - builder: (context, state) => const DebugSpendingCalendarPage(), + path: "/stats/calendar", + builder: (context, state) => const SpendingCalendarPage(), ), GoRoute( - path: "/_debug/analytics/cash-flow", - builder: (context, state) => const DebugCashFlowPage(), + path: "/stats/cash-flow", + builder: (context, state) => const CashFlowPage(), ), GoRoute( - path: "/_debug/analytics/map", - builder: (context, state) => const DebugSpendingMapPage(), + path: "/stats/map", + builder: (context, state) => const SpendingMapPage(), ), GoRoute( path: "/_debug/scheduledNotifications", diff --git a/lib/routes/debug/analytics/debug_cash_flow_page.dart b/lib/routes/debug/analytics/debug_cash_flow_page.dart deleted file mode 100644 index 1b6325c5..00000000 --- a/lib/routes/debug/analytics/debug_cash_flow_page.dart +++ /dev/null @@ -1,357 +0,0 @@ -import "package:flow/data/exchange_rates.dart"; -import "package:flow/data/money.dart"; -import "package:flow/objectbox.dart"; -import "package:flow/objectbox/actions.dart"; -import "package:flow/services/exchange_rates.dart"; -import "package:flow/services/user_preferences.dart"; -import "package:flow/theme/primary_colors.dart"; -import "package:flow/theme/theme.dart"; -import "package:flow/widgets/debug/analytics/sankey_diagram.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:flutter/material.dart"; -import "package:moment_dart/moment_dart.dart"; - -/// [dev] Cash-flow Sankey. -/// -/// Income categories flow through a single total hub into spending categories -/// (plus a balancing "Saved" / "From reserves" node) for the current month. -/// Built from category-flow aggregation (`flowByCategories`) over existing -/// data. -class DebugCashFlowPage extends StatefulWidget { - const DebugCashFlowPage({super.key}); - - @override - State createState() => _DebugCashFlowPageState(); -} - -class _DebugCashFlowPageState extends State { - static const int _maxIncomeNodes = 4; - static const int _maxExpenseNodes = 6; - - bool busy = false; - bool missingRates = false; - - late String primaryCurrency; - ExchangeRates? rates; - - List sources = []; - List targets = []; - double totalIncome = 0.0; - double totalExpense = 0.0; - - @override - void initState() { - super.initState(); - - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - fetch(); - } - - @override - Widget build(BuildContext context) { - final String month = DateTime.now().toMoment().format("MMMM"); - final bool hasData = sources.isNotEmpty && targets.isNotEmpty; - final double net = totalIncome - totalExpense; - - return Scaffold( - appBar: AppBar( - title: Text("Cash flow · $month (dev)"), - elevation: 0.0, - scrolledUnderElevation: 1.0, - centerTitle: false, - shadowColor: context.colorScheme.onSurface.withAlpha(0x40), - backgroundColor: context.colorScheme.surface, - surfaceTintColor: kTransparent, - ), - body: SafeArea( - child: busy && sources.isEmpty - ? const Spinner.center() - : SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16.0), - _Summary( - income: Money(totalIncome, primaryCurrency), - expense: Money(totalExpense, primaryCurrency), - net: Money(net, primaryCurrency), - ), - const SizedBox(height: 16.0), - if (hasData) ...[ - Frame( - child: SankeyDiagram( - sources: sources, - targets: targets, - ), - ), - const SizedBox(height: 24.0), - const ListHeader("Income"), - const SizedBox(height: 8.0), - _Legend(data: sources, currency: primaryCurrency), - const SizedBox(height: 16.0), - const ListHeader("Spending"), - const SizedBox(height: 8.0), - _Legend(data: targets, currency: primaryCurrency), - ] else - const Frame( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 48.0), - child: Center( - child: Text("No cash flow this month."), - ), - ), - ), - if (missingRates) ...[ - const SizedBox(height: 12.0), - Frame( - child: Text( - "Some non-primary currency amounts were skipped " - "(missing exchange rates).", - style: context.textTheme.bodySmall?.copyWith( - color: context.flowColors.expense, - ), - ), - ), - ], - const SizedBox(height: 96.0), - ], - ), - ), - ), - ); - } - - Future fetch() async { - if (!mounted) return; - setState(() { - busy = true; - }); - - bool missing = false; - - try { - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - // Resolve theme colors before the await so we never read [context] - // across an async gap. - final Color otherColor = context.colorScheme.onSurface.withAlpha(0x66); - final Color incomeColor = context.flowColors.income; - final Color expenseColor = context.flowColors.expense; - - final analytics = await ObjectBox().flowByCategories( - range: TimeRange.thisMonth(), - ); - - final List incomeNodes = []; - final List expenseNodes = []; - double income = 0.0; - double expense = 0.0; - int colorIndex = 0; - - for (final entry in analytics.flow.entries) { - final flow = entry.value; - final single = flow.merge(primaryCurrency, rates); - missing = missing || single.hasMissingData; - - final String name = flow.associatedData?.name ?? "Uncategorized"; - final Color color = - flow.associatedData?.colorScheme?.primary ?? - accentColors[colorIndex++ % accentColors.length]; - - final double incomeAmount = single.totalIncome.amount; - final double expenseAmount = single.totalExpense.amount.abs(); - - if (incomeAmount > 0) { - incomeNodes.add( - SankeyDatum(label: name, value: incomeAmount, color: color), - ); - income += incomeAmount; - } - if (expenseAmount > 0) { - expenseNodes.add( - SankeyDatum(label: name, value: expenseAmount, color: color), - ); - expense += expenseAmount; - } - } - - final List nextSources = _bucket( - incomeNodes, - _maxIncomeNodes, - otherColor, - ); - final List nextTargets = _bucket( - expenseNodes, - _maxExpenseNodes, - otherColor, - ); - - // Balance the two sides so the hub is fully covered: surplus becomes a - // "Saved" target, a deficit becomes a "From reserves" source. - final double net = income - expense; - final double threshold = (income > expense ? income : expense) * 0.001; - if (net > threshold) { - nextTargets.add( - SankeyDatum(label: "Saved", value: net, color: incomeColor), - ); - } else if (net < -threshold) { - nextSources.add( - SankeyDatum(label: "From reserves", value: -net, color: expenseColor), - ); - } - - sources = nextSources; - targets = nextTargets; - totalIncome = income; - totalExpense = expense; - missingRates = missing; - } finally { - busy = false; - if (mounted) setState(() {}); - } - } - - /// Keeps the top [max] nodes by value and rolls the rest into "Other". - List _bucket( - List nodes, - int max, - Color otherColor, - ) { - final List sorted = [...nodes] - ..sort((a, b) => b.value.compareTo(a.value)); - - if (sorted.length <= max) return sorted; - - final List top = sorted.take(max - 1).toList(); - final double otherSum = sorted - .skip(max - 1) - .fold(0.0, (sum, node) => sum + node.value); - - return [ - ...top, - SankeyDatum(label: "Other", value: otherSum, color: otherColor), - ]; - } -} - -class _Summary extends StatelessWidget { - final Money income; - final Money expense; - final Money net; - - const _Summary({ - required this.income, - required this.expense, - required this.net, - }); - - @override - Widget build(BuildContext context) { - final bool saved = net.amount >= 0; - - return Frame( - child: Wrap( - spacing: 20.0, - runSpacing: 8.0, - children: [ - _SummaryItem( - label: "In", - money: income, - color: context.flowColors.income, - ), - _SummaryItem( - label: "Out", - money: expense, - color: context.flowColors.expense, - ), - _SummaryItem( - label: saved ? "Saved" : "Overspent", - money: saved ? net : -net, - color: saved - ? context.flowColors.income - : context.flowColors.expense, - ), - ], - ), - ); - } -} - -class _SummaryItem extends StatelessWidget { - final String label; - final Money money; - final Color color; - - const _SummaryItem({ - required this.label, - required this.money, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(label, style: context.textTheme.labelMedium?.semi(context)), - MoneyText( - money, - style: context.textTheme.titleMedium?.copyWith(color: color), - ), - ], - ); - } -} - -class _Legend extends StatelessWidget { - final List data; - final String currency; - - const _Legend({required this.data, required this.currency}); - - @override - Widget build(BuildContext context) { - return Frame( - child: Column( - children: data.map((datum) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - children: [ - Container( - width: 12.0, - height: 12.0, - decoration: BoxDecoration( - color: datum.color, - borderRadius: const BorderRadius.all(Radius.circular(3.0)), - ), - ), - const SizedBox(width: 10.0), - Expanded( - child: Text( - datum.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium, - ), - ), - const SizedBox(width: 8.0), - MoneyText( - Money(datum.value, currency), - style: context.textTheme.bodyMedium?.semi(context), - ), - ], - ), - ); - }).toList(), - ), - ); - } -} diff --git a/lib/routes/debug/analytics/debug_net_worth_page.dart b/lib/routes/debug/analytics/debug_net_worth_page.dart deleted file mode 100644 index e3f3b152..00000000 --- a/lib/routes/debug/analytics/debug_net_worth_page.dart +++ /dev/null @@ -1,552 +0,0 @@ -import "dart:math" as math; - -import "package:fl_chart/fl_chart.dart"; -import "package:flow/data/exchange_rates.dart"; -import "package:flow/data/flow_icon.dart"; -import "package:flow/data/money.dart"; -import "package:flow/entity/account.dart"; -import "package:flow/objectbox.dart"; -import "package:flow/objectbox/actions.dart"; -import "package:flow/services/exchange_rates.dart"; -import "package:flow/services/user_preferences.dart"; -import "package:flow/theme/theme.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:flutter/material.dart"; -import "package:material_symbols_icons_flow/symbols.dart"; -import "package:moment_dart/moment_dart.dart"; - -/// [dev] Net worth over time. -/// -/// Samples [Account.balanceAt] at the end of each month for the selected -/// window, converting non-primary currency balances into the primary -/// currency. Below the trend, balances are grouped into net-worth buckets -/// (cash / savings / investments / debt). -/// -/// Buildable entirely from existing data: account types + [Account.balanceAt]. -class DebugNetWorthPage extends StatefulWidget { - const DebugNetWorthPage({super.key}); - - @override - State createState() => _DebugNetWorthPageState(); -} - -enum _Period { - m3("3M", 3), - m6("6M", 6), - y1("1Y", 12), - all("All", null); - - final String label; - - /// Number of months to look back, or `null` for "all". - final int? months; - - const _Period(this.label, this.months); -} - -enum _NetWorthBucket { - cash("Cash", Symbols.account_balance_wallet_rounded), - savings("Savings", Symbols.savings_rounded), - investments("Investments", Symbols.trending_up_rounded), - debt("Debt", Symbols.credit_card_rounded), - other("Other", Symbols.account_balance_rounded); - - final String label; - final IconData icon; - - const _NetWorthBucket(this.label, this.icon); - - static _NetWorthBucket of(AccountType type) => switch (type) { - AccountType.debit => _NetWorthBucket.cash, - AccountType.savings => _NetWorthBucket.savings, - AccountType.asset => _NetWorthBucket.investments, - AccountType.creditLine || AccountType.loan => _NetWorthBucket.debt, - AccountType.other => _NetWorthBucket.other, - }; -} - -class _NetWorthSample { - final DateTime anchor; - final double amount; - - const _NetWorthSample(this.anchor, this.amount); -} - -class _DebugNetWorthPageState extends State { - _Period period = _Period.y1; - - bool busy = false; - - /// Whether any non-primary currency balance couldn't be converted. - bool missingRates = false; - - List accounts = []; - List<_NetWorthSample> samples = []; - Map<_NetWorthBucket, double> breakdown = {}; - - late String primaryCurrency; - ExchangeRates? rates; - - @override - void initState() { - super.initState(); - - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - fetch(); - } - - @override - Widget build(BuildContext context) { - final bool hasData = samples.length >= 2; - - final Money current = Money( - samples.isEmpty ? 0.0 : samples.last.amount, - primaryCurrency, - ); - final Money first = Money( - samples.isEmpty ? 0.0 : samples.first.amount, - primaryCurrency, - ); - final Money delta = current - first; - - return Scaffold( - appBar: AppBar( - title: const Text("Net worth (dev)"), - elevation: 0.0, - scrolledUnderElevation: 1.0, - centerTitle: false, - shadowColor: context.colorScheme.onSurface.withAlpha(0x40), - backgroundColor: context.colorScheme.surface, - surfaceTintColor: kTransparent, - ), - body: SafeArea( - child: busy && samples.isEmpty - ? const Spinner.center() - : SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16.0), - Frame( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Net worth", - style: context.textTheme.titleSmall?.semi(context), - ), - const SizedBox(height: 2.0), - MoneyText( - current, - style: context.textTheme.displaySmall, - autoSize: true, - tapToToggleAbbreviation: true, - ), - const SizedBox(height: 4.0), - _DeltaLabel(delta: delta, windowLabel: period.label), - ], - ), - ), - const SizedBox(height: 16.0), - Frame( - child: Wrap( - spacing: 8.0, - children: _Period.values - .map( - (p) => FilterChip( - label: Text(p.label), - selected: p == period, - onSelected: busy ? null : (_) => _setPeriod(p), - ), - ) - .toList(), - ), - ), - const SizedBox(height: 16.0), - if (hasData) - Frame( - child: SizedBox( - height: 220.0, - child: _NetWorthChart( - samples: samples, - primaryCurrency: primaryCurrency, - ), - ), - ) - else - const Frame( - child: SizedBox( - height: 120.0, - child: Center( - child: Text("Not enough history to draw a trend."), - ), - ), - ), - if (missingRates) ...[ - const SizedBox(height: 8.0), - Frame( - child: Text( - "Some non-primary currency balances were skipped " - "(missing exchange rates).", - style: context.textTheme.bodySmall?.copyWith( - color: context.flowColors.expense, - ), - ), - ), - ], - const SizedBox(height: 32.0), - const ListHeader("Composition"), - const SizedBox(height: 8.0), - ..._buildBreakdownRows(context), - const SizedBox(height: 96.0), - ], - ), - ), - ), - ); - } - - List _buildBreakdownRows(BuildContext context) { - if (breakdown.isEmpty) { - return [const Frame(child: Text("No accounts to summarize."))]; - } - - // Stable, meaningful order rather than insertion order. - final List<_NetWorthBucket> order = _NetWorthBucket.values - .where(breakdown.containsKey) - .toList(); - - return order.map((bucket) { - final double amount = breakdown[bucket] ?? 0.0; - final bool isDebt = bucket == _NetWorthBucket.debt; - final Color color = isDebt - ? context.flowColors.expense - : context.colorScheme.primary; - - return ListTile( - leading: FlowIcon( - FlowIconData.icon(bucket.icon), - plated: true, - color: color, - ), - title: Text(bucket.label), - trailing: MoneyText( - Money(amount, primaryCurrency), - style: context.textTheme.titleSmall?.copyWith( - color: isDebt ? context.flowColors.expense : null, - ), - ), - ); - }).toList(); - } - - void _setPeriod(_Period value) { - if (value == period) return; - period = value; - fetch(); - } - - Future fetch() async { - if (!mounted) return; - setState(() { - busy = true; - }); - - try { - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - accounts = ObjectBox() - .getAccounts(false) - .where((account) => account.excludeFromTotalBalance != true) - .toList(); - - final int months = period.months ?? _allMonths(accounts); - final List anchors = _monthAnchors(months); - - bool missing = false; - - final List<_NetWorthSample> nextSamples = anchors.map((anchor) { - double total = 0.0; - for (final Account account in accounts) { - final ({double value, bool missing}) result = _convertedBalance( - account.balanceAt(anchor), - ); - total += result.value; - missing = missing || result.missing; - } - return _NetWorthSample(anchor, total); - }).toList(); - - final Map<_NetWorthBucket, double> nextBreakdown = {}; - for (final Account account in accounts) { - final ({double value, bool missing}) result = _convertedBalance( - account.balance, - ); - missing = missing || result.missing; - - final _NetWorthBucket bucket = _NetWorthBucket.of(account.accountType); - nextBreakdown[bucket] = (nextBreakdown[bucket] ?? 0.0) + result.value; - } - - samples = nextSamples; - breakdown = nextBreakdown; - missingRates = missing; - } finally { - busy = false; - if (mounted) setState(() {}); - } - } - - /// Converts [money] into the primary currency, flagging when conversion is - /// impossible (missing rates) so the UI can warn instead of lying. - ({double value, bool missing}) _convertedBalance(Money money) { - if (money.currency == primaryCurrency) { - return (value: money.amount, missing: false); - } - - final ExchangeRates? rates = this.rates; - if (rates == null) { - return (value: 0.0, missing: true); - } - - try { - return ( - value: money.convert(primaryCurrency, rates).amount, - missing: false, - ); - } catch (_) { - return (value: 0.0, missing: true); - } - } - - /// End-of-month anchors for the trailing [months] months, with the most - /// recent point anchored to "now" so the latest figure is live. - List _monthAnchors(int months) { - final DateTime now = DateTime.now(); - final List anchors = []; - - for (int i = months - 1; i >= 0; i--) { - if (i == 0) { - anchors.add(now); - } else { - // Day 0 of (month - i + 1) == last day of (month - i). - anchors.add(DateTime(now.year, now.month - i + 1, 0, 23, 59, 59)); - } - } - - return anchors; - } - - int _allMonths(List accounts) { - if (accounts.isEmpty) return 12; - - final DateTime earliest = accounts - .map((a) => a.createdDate) - .reduce((a, b) => a.isBefore(b) ? a : b); - final DateTime now = DateTime.now(); - final int months = - (now.year - earliest.year) * 12 + (now.month - earliest.month) + 1; - - return months.clamp(3, 60); - } -} - -class _DeltaLabel extends StatelessWidget { - final Money delta; - final String windowLabel; - - const _DeltaLabel({required this.delta, required this.windowLabel}); - - @override - Widget build(BuildContext context) { - final bool up = delta.amount >= 0; - final Color color = up - ? context.flowColors.income - : context.flowColors.expense; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - up ? Symbols.trending_up_rounded : Symbols.trending_down_rounded, - color: color, - size: 18.0, - ), - const SizedBox(width: 4.0), - MoneyText( - delta, - displayAbsoluteAmount: true, - style: context.textTheme.bodyMedium?.copyWith(color: color), - ), - const SizedBox(width: 6.0), - Text( - "in $windowLabel", - style: context.textTheme.bodyMedium?.semi(context), - ), - ], - ); - } -} - -class _NetWorthChart extends StatelessWidget { - final List<_NetWorthSample> samples; - final String primaryCurrency; - - const _NetWorthChart({required this.samples, required this.primaryCurrency}); - - @override - Widget build(BuildContext context) { - final Color line = context.colorScheme.primary; - - final double maxY = samples - .map((s) => s.amount) - .reduce((a, b) => a > b ? a : b); - final double minY = samples - .map((s) => s.amount) - .reduce((a, b) => a < b ? a : b); - - // Frame the chart to the actual data range (plus a little padding) so a - // net worth that stays positive still shows its variation instead of - // being flattened against a 0 baseline. The 0 line is drawn separately - // only when the range actually crosses zero. - final double span = (maxY - minY).abs(); - final double pad = span == 0 ? maxY.abs() * 0.1 + 1 : span * 0.12; - final double resolvedMinY = minY - pad; - final double resolvedMaxY = maxY + pad; - - return LineChart( - LineChartData( - minX: 0.0, - maxX: (samples.length - 1).toDouble(), - minY: resolvedMinY, - maxY: resolvedMaxY, - lineTouchData: LineTouchData( - touchTooltipData: LineTouchTooltipData( - fitInsideHorizontally: true, - fitInsideVertically: true, - getTooltipColor: (_) => context.colorScheme.onPrimary, - getTooltipItems: (touchedSpots) { - return touchedSpots.map((spot) { - final _NetWorthSample sample = samples[spot.x.toInt()]; - return LineTooltipItem( - "${sample.anchor.toMoment().format("MMM yyyy")}\n" - "${Money(sample.amount, primaryCurrency).formattedCompact}", - TextStyle( - color: line, - fontWeight: FontWeight.bold, - fontSize: 13.0, - ), - ); - }).toList(); - }, - ), - ), - titlesData: FlTitlesData( - bottomTitles: AxisTitles(sideTitles: _bottomTitles()), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 48.0, - getTitlesWidget: (value, meta) { - if (value != meta.min && value != meta.max) { - return const SizedBox.shrink(); - } - return MoneyText( - Money(value, primaryCurrency), - initiallyAbbreviated: true, - autoSize: true, - style: context.textTheme.labelSmall, - ); - }, - ), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - ), - gridData: FlGridData(show: true, drawVerticalLine: false), - extraLinesData: ExtraLinesData( - horizontalLines: [ - if (resolvedMinY < 0) - HorizontalLine( - y: 0.0, - color: context.colorScheme.onSurface.withAlpha(0x30), - strokeWidth: 1.0, - ), - ], - ), - 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, - ), - ), - ), - lineBarsData: [ - LineChartBarData( - barWidth: 2.5, - color: line, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - spots: samples - .asMap() - .entries - .map((e) => FlSpot(e.key.toDouble(), e.value.amount)) - .toList(), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [line.withAlpha(0x40), line.withAlpha(0x00)], - ), - ), - ), - ], - ), - ); - } - - SideTitles _bottomTitles() { - // Aim for ~4 labels regardless of window length. - final int step = math.max(1, (samples.length / 4).floor()); - - return SideTitles( - showTitles: true, - interval: 1.0, - reservedSize: 28.0, - getTitlesWidget: (value, meta) { - final int index = value.round(); - if (index < 0 || index >= samples.length) { - return const SizedBox.shrink(); - } - if (index % step != 0 && index != samples.length - 1) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(top: 6.0), - child: Text( - samples[index].anchor.toMoment().format("MMM"), - style: const TextStyle(fontSize: 11.0), - ), - ); - }, - ); - } -} diff --git a/lib/routes/debug/analytics/debug_recurring_page.dart b/lib/routes/debug/analytics/debug_recurring_page.dart deleted file mode 100644 index 7e631b58..00000000 --- a/lib/routes/debug/analytics/debug_recurring_page.dart +++ /dev/null @@ -1,395 +0,0 @@ -import "package:flow/data/exchange_rates.dart"; -import "package:flow/data/flow_icon.dart"; -import "package:flow/data/money.dart"; -import "package:flow/entity/account.dart"; -import "package:flow/entity/category.dart"; -import "package:flow/entity/recurring_transaction.dart"; -import "package:flow/entity/transaction.dart"; -import "package:flow/services/accounts.dart"; -import "package:flow/services/categories.dart"; -import "package:flow/services/exchange_rates.dart"; -import "package:flow/services/recurring_transactions.dart"; -import "package:flow/services/user_preferences.dart"; -import "package:flow/theme/theme.dart"; -import "package:flow/widgets/general/flow_icon.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:flutter/material.dart"; -import "package:material_symbols_icons_flow/symbols.dart"; -import "package:moment_dart/moment_dart.dart"; - -/// [dev] Subscriptions & recurring radar. -/// -/// Projects every active [RecurringTransaction] forward over the next 30 days -/// using its [Recurrence] rules, then lists the upcoming charges and sums the -/// committed outflow. Built entirely from data Flow already stores. -class DebugRecurringPage extends StatefulWidget { - const DebugRecurringPage({super.key}); - - @override - State createState() => _DebugRecurringPageState(); -} - -/// One projected occurrence of a recurring transaction within the window. -class _Upcoming { - final DateTime date; - final Transaction template; - - /// The template's amount, pre-validated so the tile never touches the - /// throwing [Transaction.money] getter. - final Money money; - final Category? category; - final Account? account; - - const _Upcoming({ - required this.date, - required this.template, - required this.money, - this.category, - this.account, - }); -} - -class _DebugRecurringPageState extends State { - static const int _windowDays = 30; - static const int _maxRows = 60; - - bool busy = false; - bool missingRates = false; - - late String primaryCurrency; - ExchangeRates? rates; - - List<_Upcoming> upcoming = []; - int activeCount = 0; - double outflow = 0.0; - - @override - void initState() { - super.initState(); - - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - fetch(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Recurring (dev)"), - elevation: 0.0, - scrolledUnderElevation: 1.0, - centerTitle: false, - shadowColor: context.colorScheme.onSurface.withAlpha(0x40), - backgroundColor: context.colorScheme.surface, - surfaceTintColor: kTransparent, - ), - body: SafeArea( - child: busy && upcoming.isEmpty - ? const Spinner.center() - : SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16.0), - _Header( - outflow: Money(outflow, primaryCurrency), - activeCount: activeCount, - windowDays: _windowDays, - ), - const SizedBox(height: 16.0), - if (activeCount == 0) - const Frame( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 48.0), - child: Center( - child: Text("No recurring transactions set up."), - ), - ), - ) - else if (upcoming.isEmpty) - const Frame( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 48.0), - child: Center( - child: Text("Nothing due in the next 30 days."), - ), - ), - ) - else - ..._buildRows(context), - if (missingRates) ...[ - const SizedBox(height: 8.0), - Frame( - child: Text( - "Some non-primary currency amounts were skipped " - "(missing exchange rates).", - style: context.textTheme.bodySmall?.copyWith( - color: context.flowColors.expense, - ), - ), - ), - ], - const SizedBox(height: 96.0), - ], - ), - ), - ), - ); - } - - List _buildRows(BuildContext context) { - final List rows = upcoming - .take(_maxRows) - .map( - (item) => _UpcomingTile(item: item, primaryCurrency: primaryCurrency), - ) - .toList(); - - if (upcoming.length > _maxRows) { - rows.add( - Frame( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - child: Text( - "+ ${upcoming.length - _maxRows} more not shown", - style: context.textTheme.bodySmall?.semi(context), - ), - ), - ), - ); - } - - return rows; - } - - Future fetch() async { - if (!mounted) return; - setState(() { - busy = true; - }); - - bool missing = false; - - try { - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - final DateTime now = DateTime.now(); - final TimeRange window = CustomTimeRange( - now, - now.add(const Duration(days: _windowDays)), - ); - - final query = RecurringTransactionsService().activeRecurringsQb().build(); - final List recurrings = query.find(); - query.close(); - - final List<_Upcoming> result = []; - double totalOutflow = 0.0; - int active = 0; - - for (final RecurringTransaction recurring in recurrings) { - final Transaction? template = _decodeTemplate(recurring); - if (template == null) continue; - - // Money(...) throws on an unknown currency code; skip the recurring - // rather than letting one stale template break the whole page. - final Money? money = _templateMoney(template); - if (money == null) continue; - - active++; - - final Category? category = template.categoryUuid == null - ? null - : CategoriesService().findOneSync(template.categoryUuid); - final Account? account = template.accountUuid == null - ? null - : AccountsService().findOneSync(template.accountUuid); - - final List occurrences = recurring.recurrence.occurrences( - subrange: window, - ); - - for (final DateTime date in occurrences) { - result.add( - _Upcoming( - date: date, - template: template, - money: money, - category: category, - account: account, - ), - ); - - if (template.type == TransactionType.expense) { - final double? converted = _convert(money, primaryCurrency); - if (converted == null) { - missing = true; - } else { - totalOutflow += converted.abs(); - } - } - } - } - - result.sort((a, b) => a.date.compareTo(b.date)); - - upcoming = result; - activeCount = active; - outflow = totalOutflow; - missingRates = missing; - } finally { - busy = false; - if (mounted) setState(() {}); - } - } - - Transaction? _decodeTemplate(RecurringTransaction recurring) { - try { - return recurring.template; - } catch (_) { - // A malformed template shouldn't take down the whole list. - return null; - } - } - - Money? _templateMoney(Transaction template) { - try { - return template.money; - } catch (_) { - return null; - } - } - - double? _convert(Money money, String currency) { - if (money.currency == currency) return money.amount; - - final ExchangeRates? rates = this.rates; - if (rates == null) return null; - - try { - return money.convert(currency, rates).amount; - } catch (_) { - return null; - } - } -} - -class _Header extends StatelessWidget { - final Money outflow; - final int activeCount; - final int windowDays; - - const _Header({ - required this.outflow, - required this.activeCount, - required this.windowDays, - }); - - @override - Widget build(BuildContext context) { - return Frame( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Committed outflow", - style: context.textTheme.titleSmall?.semi(context), - ), - const SizedBox(height: 2.0), - MoneyText( - outflow, - style: context.textTheme.displaySmall, - autoSize: true, - tapToToggleAbbreviation: true, - ), - const SizedBox(height: 4.0), - Text( - "$activeCount recurring · next $windowDays days", - style: context.textTheme.bodyMedium?.semi(context), - ), - ], - ), - ); - } -} - -class _UpcomingTile extends StatelessWidget { - final _Upcoming item; - final String primaryCurrency; - - const _UpcomingTile({required this.item, required this.primaryCurrency}); - - @override - Widget build(BuildContext context) { - final Transaction template = item.template; - final Color typeColor = template.type.color(context); - - final FlowIconData iconData = - item.category?.icon ?? - item.account?.icon ?? - FlowIconData.icon(Symbols.autorenew_rounded); - - final String title = - template.title ?? item.account?.name ?? "Recurring transaction"; - final String subtitle = - "${item.date.toMoment().fromNow()} · " - "${item.category?.name ?? item.account?.name ?? "Recurring"}"; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), - child: Row( - children: [ - SizedBox( - width: 40.0, - child: Column( - children: [ - Text( - item.date.toMoment().format("D"), - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - item.date.toMoment().format("MMM").toUpperCase(), - style: context.textTheme.labelSmall?.semi(context), - ), - ], - ), - ), - const SizedBox(width: 12.0), - FlowIcon(iconData, plated: true, color: typeColor), - const SizedBox(width: 12.0), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyLarge, - ), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodySmall?.semi(context), - ), - ], - ), - ), - const SizedBox(width: 8.0), - MoneyText( - item.money, - style: context.textTheme.titleSmall?.copyWith(color: typeColor), - ), - ], - ), - ); - } -} diff --git a/lib/routes/debug/analytics/debug_wrapped_page.dart b/lib/routes/debug/analytics/debug_wrapped_page.dart deleted file mode 100644 index 84a6dde0..00000000 --- a/lib/routes/debug/analytics/debug_wrapped_page.dart +++ /dev/null @@ -1,636 +0,0 @@ -import "package:flow/data/exchange_rates.dart"; -import "package:flow/data/flow_analytics.dart"; -import "package:flow/data/money.dart"; -import "package:flow/entity/budget.dart"; -import "package:flow/entity/category.dart"; -import "package:flow/entity/transaction.dart"; -import "package:flow/objectbox.dart"; -import "package:flow/objectbox/actions.dart"; -import "package:flow/objectbox/objectbox.g.dart"; -import "package:flow/reports/trends_report.dart"; -import "package:flow/services/exchange_rates.dart"; -import "package:flow/services/user_preferences.dart"; -import "package:flow/theme/theme.dart"; -import "package:flow/widgets/debug/analytics/bullet_chart.dart"; -import "package:flow/widgets/debug/analytics/insight_card.dart"; -import "package:flow/widgets/debug/analytics/weekday_bars.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:flutter/material.dart"; -import "package:material_symbols_icons_flow/symbols.dart"; -import "package:moment_dart/moment_dart.dart"; - -/// [dev] Monthly "wrapped" — narrative insight cards instead of raw charts. -/// -/// Activates two dormant pieces of Flow: [TrendsReport] (median spend, top -/// titles) and the unused [Budget] entity (rendered as a bullet chart). The -/// remaining cards are period-over-period category comparison and a -/// locally-computed weekday breakdown. -class DebugWrappedPage extends StatefulWidget { - const DebugWrappedPage({super.key}); - - @override - State createState() => _DebugWrappedPageState(); -} - -class _BudgetProgress { - final Budget budget; - final double actual; - - const _BudgetProgress(this.budget, this.actual); -} - -class _DebugWrappedPageState extends State { - bool busy = false; - bool missingRates = false; - - late String primaryCurrency; - ExchangeRates? rates; - - List thisMonthTransactions = []; - TrendsReport? trends; - - Category? topCategory; - double topCategoryCurrent = 0.0; - double topCategoryAverage = 0.0; - List topCategoryHistory = []; - - /// Weekday (1 = Mon .. 7 = Sun) -> summed expense, computed locally. - /// - /// [TrendsReport.expenseByWeekday] is declared but never populated, so its - /// `topSpendingWeekday` always returns null; we compute weekday spend here. - Map weekdayExpense = {}; - Transaction? biggestExpense; - double biggestExpenseConverted = 0.0; - - List<_BudgetProgress> budgets = []; - - @override - void initState() { - super.initState(); - - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - fetch(); - } - - @override - Widget build(BuildContext context) { - final String month = DateTime.now().toMoment().format("MMMM"); - - return Scaffold( - appBar: AppBar( - title: Text("$month, wrapped (dev)"), - elevation: 0.0, - scrolledUnderElevation: 1.0, - centerTitle: false, - shadowColor: context.colorScheme.onSurface.withAlpha(0x40), - backgroundColor: context.colorScheme.surface, - surfaceTintColor: kTransparent, - ), - body: SafeArea( - child: busy && trends == null - ? const Spinner.center() - : SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8.0), - _DevToolbar( - canCreate: topCategory != null, - onCreate: _createSampleBudget, - onClear: _clearDevBudgets, - ), - // Budgets stand on their own range, so they show even when - // the current month has no transactions yet. - ..._buildBudgetCards(context), - if (thisMonthTransactions.isEmpty) - const Frame( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 48.0), - child: Center( - child: Text("No transactions yet this month."), - ), - ), - ) - else - ..._buildInsightCards(context), - if (missingRates) ...[ - const SizedBox(height: 8.0), - Frame( - child: Text( - "Some non-primary currency amounts were skipped " - "(missing exchange rates).", - style: context.textTheme.bodySmall?.copyWith( - color: context.flowColors.expense, - ), - ), - ), - ], - const SizedBox(height: 96.0), - ], - ), - ), - ), - ); - } - - List _buildInsightCards(BuildContext context) { - return [ - if (topCategory != null || topCategoryCurrent > 0) - _buildCategoryTrendCard(context), - if (trends?.sortedTitlesByFrequency.isNotEmpty == true) - _buildTopMerchantCard(context), - if (weekdayExpense.isNotEmpty) _buildWeekdayCard(context), - _buildSpendShapeCard(context), - ]; - } - - List _buildBudgetCards(BuildContext context) { - if (budgets.isEmpty) { - return [ - InsightCard( - icon: Symbols.savings_rounded, - label: "Budget", - title: const Text("No budgets yet"), - subtitle: - "The Budget entity is modeled but unused. Create a sample " - "budget above to see budget-vs-actual.", - ), - ]; - } - - return budgets.map((progress) { - final Budget budget = progress.budget; - final Money actual = Money(progress.actual, budget.currency); - final Money limit = Money(budget.amount, budget.currency); - final bool over = progress.actual > budget.amount; - final double remaining = budget.amount - progress.actual; - - return InsightCard( - icon: Symbols.savings_rounded, - label: "Budget", - accent: over ? context.flowColors.expense : context.flowColors.income, - title: Row( - children: [ - Expanded(child: Text(budget.name)), - MoneyText(actual, style: context.textTheme.titleSmall), - Text( - " / ", - style: context.textTheme.titleSmall?.copyWith( - color: context.colorScheme.onSecondary.withAlpha(0x80), - ), - ), - MoneyText( - limit, - style: context.textTheme.titleSmall?.copyWith( - color: context.colorScheme.onSecondary.withAlpha(0x80), - ), - ), - ], - ), - subtitle: over - ? "Over by ${Money(-remaining, budget.currency).formatted}" - : "${Money(remaining, budget.currency).formatted} left", - child: BulletChart(value: progress.actual, target: budget.amount), - ); - }).toList(); - } - - Widget _buildCategoryTrendCard(BuildContext context) { - final double avg = topCategoryAverage; - final double current = topCategoryCurrent; - final bool up = current >= avg; - final double deltaPct = avg <= 0 ? 0.0 : ((current - avg) / avg) * 100.0; - final Color accent = up - ? context.flowColors.expense - : context.flowColors.income; - - final String name = topCategory?.name ?? "Uncategorized"; - final String direction = up ? "up" : "down"; - - return InsightCard( - icon: Symbols.lunch_dining_rounded, - label: "Category", - accent: accent, - title: Text.rich( - TextSpan( - children: [ - TextSpan(text: "$name is $direction "), - TextSpan( - text: "${deltaPct.abs().toStringAsFixed(0)}%", - style: TextStyle(color: accent, fontWeight: FontWeight.bold), - ), - const TextSpan(text: " vs your 3-month average."), - ], - ), - ), - subtitle: - "${Money(current, primaryCurrency).formatted} this month vs " - "${Money(avg, primaryCurrency).formatted} typical", - child: _MiniBars(values: topCategoryHistory, highlightColor: accent), - ); - } - - Widget _buildTopMerchantCard(BuildContext context) { - final MapEntry top = trends!.sortedTitlesByFrequency.first; - - return InsightCard( - icon: Symbols.storefront_rounded, - label: "Frequent", - title: Text.rich( - TextSpan( - children: [ - const TextSpan(text: "Your most frequent entry: "), - TextSpan( - text: top.key, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - ), - subtitle: "Logged ${top.value} times this month", - ); - } - - Widget _buildWeekdayCard(BuildContext context) { - final int topWeekday = weekdayExpense.entries - .reduce((a, b) => a.value >= b.value ? a : b) - .key; - final String weekdayName = _weekdayName(topWeekday); - - return InsightCard( - icon: Symbols.calendar_month_rounded, - label: "Rhythm", - title: Text.rich( - TextSpan( - children: [ - const TextSpan(text: "You spend most on "), - TextSpan( - text: weekdayName, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const TextSpan(text: "."), - ], - ), - ), - child: WeekdayBars( - byWeekday: weekdayExpense, - topWeekday: topWeekday, - accent: context.colorScheme.primary, - ), - ); - } - - Widget _buildSpendShapeCard(BuildContext context) { - final Money median = - trends?.medianExpensePerTransaction ?? Money(0.0, primaryCurrency); - - final String biggestLine = biggestExpense == null - ? "No expenses recorded." - : "Biggest: ${biggestExpense!.title ?? "Untitled"} · " - "${Money(biggestExpenseConverted, primaryCurrency).formatted} · " - "${biggestExpense!.transactionDate.toMoment().format("MMM D")}"; - - return InsightCard( - icon: Symbols.bar_chart_rounded, - label: "Shape", - title: Text.rich( - TextSpan( - children: [ - const TextSpan(text: "Your median purchase is "), - TextSpan( - text: median.formatted, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const TextSpan(text: "."), - ], - ), - ), - subtitle: biggestLine, - ); - } - - Future fetch() async { - if (!mounted) return; - setState(() { - busy = true; - }); - - bool missing = false; - - try { - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - final List months = _recentMonths(4); - - final FlowAnalytics current = await ObjectBox() - .flowByCategories(range: months.first); - final List> previous = []; - for (final TimeRange range in months.skip(1)) { - previous.add(await ObjectBox().flowByCategories(range: range)); - } - - thisMonthTransactions = await ObjectBox().transcationsByRange( - months.first, - includeTransfers: false, - ); - - trends = TrendsReport( - rates: rates, - primaryCurrency: primaryCurrency, - transactions: thisMonthTransactions, - ); - - missing = missing || _computeTopCategory(current, previous); - missing = missing || _computeWeekdayAndBiggest(); - missing = missing || await _computeBudgets(); - - missingRates = missing; - } finally { - busy = false; - if (mounted) setState(() {}); - } - } - - /// Picks the biggest expense category this month and its 3-month average. - /// - /// Returns whether any currency conversion was skipped. - bool _computeTopCategory( - FlowAnalytics current, - List> previous, - ) { - String? topUuid; - double topExpense = 0.0; - Category? category; - - for (final MapEntry entry in current.flow.entries) { - final double expense = _categoryExpense(current, entry.key); - if (expense > topExpense) { - topExpense = expense; - topUuid = entry.key; - category = current.flow[entry.key]?.associatedData; - } - } - - topCategory = category; - topCategoryCurrent = topExpense; - - // Merging swallows unconvertible foreign currency (sets hasMissingData - // rather than throwing), so check every month's flows to surface the - // "amounts were skipped" banner when the numbers are under-counted. - final bool missing = [current, ...previous].any( - (analytics) => analytics.flow.values.any( - (flow) => flow.merge(primaryCurrency, rates).hasMissingData, - ), - ); - - if (topUuid == null) { - topCategoryAverage = 0.0; - topCategoryHistory = []; - return missing; - } - - final List history = previous.reversed - .map((flow) => _categoryExpense(flow, topUuid!)) - .toList(); - history.add(topExpense); - - topCategoryHistory = history; - topCategoryAverage = previous.isEmpty - ? 0.0 - : previous - .map((flow) => _categoryExpense(flow, topUuid!)) - .fold(0.0, (a, b) => a + b) / - previous.length; - - return missing; - } - - double _categoryExpense(FlowAnalytics analytics, String uuid) { - final flow = analytics.flow[uuid]; - if (flow == null) return 0.0; - return flow.merge(primaryCurrency, rates).totalExpense.amount.abs(); - } - - bool _computeWeekdayAndBiggest() { - bool missing = false; - final Map byWeekday = {}; - - Transaction? biggest; - double biggestAmount = 0.0; - - for (final Transaction transaction in thisMonthTransactions) { - if (transaction.type != TransactionType.expense) continue; - - final double? converted = _convert(transaction.money, primaryCurrency); - if (converted == null) { - missing = true; - continue; - } - - final double magnitude = converted.abs(); - final int weekday = transaction.transactionDate.weekday; - byWeekday[weekday] = (byWeekday[weekday] ?? 0.0) + magnitude; - - if (magnitude > biggestAmount) { - biggestAmount = magnitude; - biggest = transaction; - } - } - - weekdayExpense = byWeekday; - biggestExpense = biggest; - biggestExpenseConverted = biggestAmount; - - return missing; - } - - Future _computeBudgets() async { - bool missing = false; - final List all = ObjectBox().box().getAll(); - final List<_BudgetProgress> result = []; - - for (final Budget budget in all) { - final List transactions = await ObjectBox() - .transcationsByRange(budget.timeRange, includeTransfers: false); - final Set categoryUuids = - budget.categoriesUuids?.toSet() ?? {}; - - double actual = 0.0; - for (final Transaction transaction in transactions) { - if (transaction.type != TransactionType.expense) continue; - // A budget with no categories tracks nothing rather than silently - // summing every expense in the range. - if (categoryUuids.isEmpty || - !categoryUuids.contains(transaction.categoryUuid)) { - continue; - } - - final double? converted = _convert(transaction.money, budget.currency); - if (converted == null) { - missing = true; - continue; - } - actual += converted.abs(); - } - - result.add(_BudgetProgress(budget, actual)); - } - - budgets = result; - return missing; - } - - double? _convert(Money money, String currency) { - if (money.currency == currency) return money.amount; - - final ExchangeRates? rates = this.rates; - if (rates == null) return null; - - try { - return money.convert(currency, rates).amount; - } catch (_) { - return null; - } - } - - List _recentMonths(int count) { - final List months = [TimeRange.thisMonth()]; - for (int i = 1; i < count; i++) { - final TimeRange previous = months.last; - months.add(previous is PageableRange ? previous.last : previous); - } - return months; - } - - void _createSampleBudget() { - final Category? category = topCategory; - if (category == null) return; - - final double base = topCategoryCurrent <= 0 ? 100000.0 : topCategoryCurrent; - final String name = "[dev] ${category.name} budget"; - - final Box box = ObjectBox().box(); - - // Avoid the unique-name constraint on repeated taps. - final List existing = box - .getAll() - .where((budget) => budget.name == name) - .toList(); - for (final Budget budget in existing) { - box.remove(budget.id); - } - - final Budget budget = Budget( - name: name, - amount: (base * 1.2).roundToDouble(), - currency: primaryCurrency, - range: TimeRange.thisMonth().toString(), - )..setCategories([category]); - - box.put(budget); - - fetch(); - } - - void _clearDevBudgets() { - final Box box = ObjectBox().box(); - final List devBudgets = box - .getAll() - .where((budget) => budget.name.startsWith("[dev]")) - .toList(); - for (final Budget budget in devBudgets) { - box.remove(budget.id); - } - - fetch(); - } - - String _weekdayName(int weekday) { - // 1 == Monday .. 7 == Sunday (DateTime.weekday). - return DateTime(2024, 1, weekday).toMoment().format("dddd"); - } -} - -class _DevToolbar extends StatelessWidget { - final bool canCreate; - final VoidCallback onCreate; - final VoidCallback onClear; - - const _DevToolbar({ - required this.canCreate, - required this.onCreate, - required this.onClear, - }); - - @override - Widget build(BuildContext context) { - return Frame( - child: Wrap( - spacing: 8.0, - children: [ - TextButton.icon( - onPressed: canCreate ? onCreate : null, - icon: const Icon(Symbols.add_rounded), - label: const Text("Create sample budget"), - ), - TextButton.icon( - onPressed: onClear, - icon: const Icon(Symbols.delete_rounded), - label: const Text("Clear [dev] budgets"), - ), - ], - ), - ); - } -} - -class _MiniBars extends StatelessWidget { - final List values; - final Color highlightColor; - - const _MiniBars({required this.values, required this.highlightColor}); - - @override - Widget build(BuildContext context) { - if (values.isEmpty) return const SizedBox.shrink(); - - final double max = values.reduce((a, b) => a > b ? a : b); - final Color base = context.colorScheme.onSurface.withAlpha(0x33); - - return SizedBox( - height: 44.0, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: values.asMap().entries.map((entry) { - final bool isLast = entry.key == values.length - 1; - final double factor = max <= 0 ? 0.0 : entry.value / max; - - return Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Align( - alignment: Alignment.bottomCenter, - child: FractionallySizedBox( - heightFactor: factor.clamp(0.05, 1.0), - child: Container( - decoration: BoxDecoration( - color: isLast ? highlightColor : base, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(4.0), - ), - ), - ), - ), - ), - ), - ); - }).toList(), - ), - ); - } -} diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index 9c4260a5..abea07fa 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -126,36 +126,36 @@ class _ProfileTabState extends State { onTap: () => context.push("/preferences"), ), const SizedBox(height: 32.0), - const ListHeader("Analytics lab"), + ListHeader("tabs.stats.insights".t(context)), ListTile( - title: const Text("Net worth over time"), + title: Text("tabs.profile.analytics.netWorth".t(context)), leading: const Icon(Symbols.trending_up_rounded), - onTap: () => context.push("/_debug/analytics/net-worth"), + onTap: () => context.push("/stats/net-worth"), ), ListTile( - title: const Text("Monthly wrapped"), + title: Text("tabs.profile.analytics.wrapped".t(context)), leading: const Icon(Symbols.bar_chart_rounded), - onTap: () => context.push("/_debug/analytics/wrapped"), + onTap: () => context.push("/stats/wrapped"), ), ListTile( - title: const Text("Subscriptions & recurring"), + title: Text("tabs.profile.analytics.recurring".t(context)), leading: const Icon(Symbols.autorenew_rounded), - onTap: () => context.push("/_debug/analytics/recurring"), + onTap: () => context.push("/stats/recurring"), ), ListTile( - title: const Text("Spending calendar"), + title: Text("tabs.profile.analytics.calendar".t(context)), leading: const Icon(Symbols.calendar_month_rounded), - onTap: () => context.push("/_debug/analytics/calendar"), + onTap: () => context.push("/stats/calendar"), ), ListTile( - title: const Text("Cash flow (Sankey)"), + title: Text("tabs.profile.analytics.cashFlow".t(context)), leading: const Icon(Symbols.alt_route_rounded), - onTap: () => context.push("/_debug/analytics/cash-flow"), + onTap: () => context.push("/stats/cash-flow"), ), ListTile( - title: const Text("Spending map"), + title: Text("tabs.profile.analytics.map".t(context)), leading: const Icon(Symbols.map_rounded), - onTap: () => context.push("/_debug/analytics/map"), + onTap: () => context.push("/stats/map"), ), if (flowDebugMode) ...[ const SizedBox(height: 32.0), diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index f31eb8f0..08084753 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,4 +1,5 @@ import "package:auto_size_text/auto_size_text.dart"; +import "package:flow/constants.dart"; import "package:flow/data/exchange_rates.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/l10n/extensions.dart"; @@ -19,6 +20,7 @@ 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/bento/analytics_bento.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"; @@ -76,7 +78,7 @@ class _StatsTabState extends State Widget build(BuildContext context) { super.build(context); - if (busy && intervalFlowReport == null) { + if (!flowDebugMode && busy && intervalFlowReport == null) { return Spinner.center(); } @@ -102,12 +104,26 @@ class _StatsTabState extends State ), ), ), - if (showMissingExchangeRatesWarning) ...[ + if (!flowDebugMode && showMissingExchangeRatesWarning) ...[ RatesMissingErrorBox(), const SizedBox(height: 12.0), ], Expanded( - child: hasData + child: flowDebugMode + ? SingleChildScrollView( + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + AnalyticsBento(range: range), + const SizedBox(height: 96.0), + ], + ), + ), + ) + : hasData ? SingleChildScrollView( child: SafeArea( top: false, @@ -319,6 +335,10 @@ class _StatsTabState extends State } Future fetch() async { + // In debug the Stats tab renders the bento dashboard, whose tiles each + // fetch their own data; the interval reports below are unused there. + if (flowDebugMode) return; + setState(() { busy = true; }); diff --git a/lib/routes/stats/cash_flow_page.dart b/lib/routes/stats/cash_flow_page.dart new file mode 100644 index 00000000..27c62655 --- /dev/null +++ b/lib/routes/stats/cash_flow_page.dart @@ -0,0 +1,259 @@ +import "package:flow/data/money.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/theme/primary_colors.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/analytics/sankey_diagram.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/list_header.dart"; +import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/stats/cash_flow/cash_flow_legend.dart"; +import "package:flow/widgets/stats/cash_flow/cash_flow_summary.dart"; +import "package:flow/widgets/stats/missing_rates_notice.dart"; +import "package:flow/widgets/stats/stats_app_bar.dart"; +import "package:flow/widgets/stats/stats_empty_state.dart"; +import "package:flow/widgets/time_range_selector.dart"; +import "package:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Cash-flow Sankey. +/// +/// Income categories flow through a single total hub into spending categories +/// (plus a balancing "Saved" / "From reserves" node) for the current month. +class CashFlowPage extends StatefulWidget { + const CashFlowPage({super.key}); + + @override + State createState() => _CashFlowPageState(); +} + +class _CashFlowPageState extends State + with PrimaryCurrencyDependentState { + static const int _maxIncomeNodes = 4; + static const int _maxExpenseNodes = 6; + + TimeRange range = TimeRange.thisMonth(); + + bool busy = false; + bool missingRates = false; + bool failed = false; + + List sources = []; + List targets = []; + double totalIncome = 0.0; + double totalExpense = 0.0; + + @override + Widget build(BuildContext context) { + final String label = range.format(useRelative: false); + final bool hasData = sources.isNotEmpty && targets.isNotEmpty; + final double net = totalIncome - totalExpense; + + return Scaffold( + appBar: StatsAppBar( + title: "${"tabs.stats.analytics.cashFlow".t(context)} · $label", + ), + body: SafeArea( + child: busy && sources.isEmpty + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + Frame( + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, + ), + ), + const SizedBox(height: 16.0), + CashFlowSummary( + income: Money(totalIncome, primaryCurrency), + expense: Money(totalExpense, primaryCurrency), + net: Money(net, primaryCurrency), + ), + const SizedBox(height: 16.0), + if (failed) + StatsEmptyState( + message: "tabs.stats.analytics.cashFlow.loadFailed".t( + context, + ), + ) + else if (hasData) ...[ + Frame( + child: SankeyDiagram(sources: sources, targets: targets), + ), + const SizedBox(height: 24.0), + ListHeader("tabs.stats.analytics.income".t(context)), + const SizedBox(height: 8.0), + CashFlowLegend(data: sources, currency: primaryCurrency), + const SizedBox(height: 16.0), + ListHeader("tabs.stats.analytics.spending".t(context)), + const SizedBox(height: 8.0), + CashFlowLegend(data: targets, currency: primaryCurrency), + ] else + StatsEmptyState( + message: "tabs.stats.analytics.cashFlow.empty".t( + context, + ), + ), + if (missingRates) ...[ + const SizedBox(height: 12.0), + MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesAmounts".t( + context, + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + void _updateRange(TimeRange value) { + if (value == range) return; + range = value; + fetch(); + } + + @override + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + bool missing = false; + bool error = false; + + try { + final analytics = await ObjectBox().flowByCategories(range: range); + + // The initial fetch runs from initState (via the mixin), where reading + // inherited widgets like Theme isn't allowed yet — so resolve theme + // colors only after the await, once the element is mounted. + if (!mounted) return; + final Color otherColor = context.colorScheme.onSurface.withAlpha(0x66); + final Color incomeColor = context.flowColors.income; + final Color expenseColor = context.flowColors.expense; + + final List incomeNodes = []; + final List expenseNodes = []; + double income = 0.0; + double expense = 0.0; + int colorIndex = 0; + + for (final entry in analytics.flow.entries) { + final flow = entry.value; + final single = flow.merge(primaryCurrency, rates); + missing = missing || single.hasMissingData; + + final String name = + flow.associatedData?.name ?? + "tabs.stats.analytics.uncategorized".tr(); + final Color color = + flow.associatedData?.colorScheme?.primary ?? + accentColors[colorIndex++ % accentColors.length]; + + final double incomeAmount = single.totalIncome.amount; + final double expenseAmount = single.totalExpense.amount.abs(); + + if (incomeAmount > 0) { + incomeNodes.add( + SankeyDatum(label: name, value: incomeAmount, color: color), + ); + income += incomeAmount; + } + if (expenseAmount > 0) { + expenseNodes.add( + SankeyDatum(label: name, value: expenseAmount, color: color), + ); + expense += expenseAmount; + } + } + + final List nextSources = _bucket( + incomeNodes, + _maxIncomeNodes, + otherColor, + ); + final List nextTargets = _bucket( + expenseNodes, + _maxExpenseNodes, + otherColor, + ); + + // Balance the two sides so the hub is fully covered: surplus becomes a + // "Saved" target, a deficit becomes a "From reserves" source. + final double net = income - expense; + final double threshold = (income > expense ? income : expense) * 0.001; + if (net > threshold) { + nextTargets.add( + SankeyDatum( + label: "tabs.stats.analytics.saved".tr(), + value: net, + color: incomeColor, + ), + ); + } else if (net < -threshold) { + nextSources.add( + SankeyDatum( + label: "tabs.stats.analytics.cashFlow.fromReserves".tr(), + value: -net, + color: expenseColor, + ), + ); + } + + sources = nextSources; + targets = nextTargets; + totalIncome = income; + totalExpense = expense; + missingRates = missing; + } catch (_) { + // Aggregation should be resilient to bad data now, but never leave the + // page silently showing zeros if something unexpected throws. + error = true; + sources = []; + targets = []; + totalIncome = 0.0; + totalExpense = 0.0; + } finally { + busy = false; + failed = error; + if (mounted) setState(() {}); + } + } + + /// Keeps the top [max] nodes by value and rolls the rest into "Other". + List _bucket( + List nodes, + int max, + Color otherColor, + ) { + final List sorted = [...nodes] + ..sort((a, b) => b.value.compareTo(a.value)); + + if (sorted.length <= max) return sorted; + + final List top = sorted.take(max - 1).toList(); + final double otherSum = sorted + .skip(max - 1) + .fold(0.0, (sum, node) => sum + node.value); + + return [ + ...top, + SankeyDatum( + label: "tabs.stats.analytics.other".tr(), + value: otherSum, + color: otherColor, + ), + ]; + } +} diff --git a/lib/routes/stats/net_worth_page.dart b/lib/routes/stats/net_worth_page.dart new file mode 100644 index 00000000..c63aafd2 --- /dev/null +++ b/lib/routes/stats/net_worth_page.dart @@ -0,0 +1,278 @@ +import "package:flow/data/money.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/reports/report.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.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/stats/missing_rates_notice.dart"; +import "package:flow/widgets/stats/money_delta_label.dart"; +import "package:flow/widgets/stats/net_worth/account_balance_share.dart"; +import "package:flow/widgets/stats/net_worth/account_share_tile.dart"; +import "package:flow/widgets/stats/net_worth/net_worth_chart.dart"; +import "package:flow/widgets/stats/net_worth/net_worth_sample.dart"; +import "package:flow/widgets/stats/stats_app_bar.dart"; +import "package:flow/widgets/time_range_selector.dart"; +import "package:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Net worth over time. +/// +/// Samples [Account.balanceAt] at regular intervals across the selected +/// [TimeRange], converting non-primary currency balances into the primary +/// currency. The sampling interval (day / week / month / year) follows the +/// range via [RangeData.getOptimalUnit], mirroring how [IntervalFlowReport] +/// walks a range, so axis labels and tooltips stay distinct and meaningful. +/// +/// Below the trend, each account's current balance is shown as a share of net +/// worth so the composition is informative no matter how many account types a +/// user has. +class NetWorthPage extends StatefulWidget { + const NetWorthPage({super.key}); + + @override + State createState() => _NetWorthPageState(); +} + +class _NetWorthPageState extends State + with PrimaryCurrencyDependentState { + TimeRange range = TimeRange.thisYear(); + + bool busy = false; + + /// Whether any non-primary currency balance couldn't be converted. + bool missingRates = false; + + List accounts = []; + List samples = []; + List shares = []; + + /// Unit the samples are spaced by, used to format axis labels and tooltips. + DurationUnit sampleUnit = DurationUnit.month; + + @override + Widget build(BuildContext context) { + final bool hasData = samples.length >= 2; + + final Money current = Money( + samples.isEmpty ? 0.0 : samples.last.amount, + primaryCurrency, + ); + final Money first = Money( + samples.isEmpty ? 0.0 : samples.first.amount, + primaryCurrency, + ); + final Money delta = current - first; + + return Scaffold( + appBar: StatsAppBar(title: "tabs.stats.analytics.netWorth".t(context)), + body: SafeArea( + child: busy && samples.isEmpty + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + Frame( + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + "tabs.stats.analytics.netWorth".t(context), + style: context.textTheme.titleSmall?.semi(context), + ), + const SizedBox(height: 2.0), + MoneyText( + current, + style: context.textTheme.displaySmall, + autoSize: true, + tapToToggleAbbreviation: true, + ), + const SizedBox(height: 4.0), + MoneyDeltaLabel( + delta: delta, + suffixLabel: "tabs.stats.analytics.inRange".t( + context, + range.format(useRelative: false), + ), + ), + ], + ), + ), + const SizedBox(height: 16.0), + Frame( + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, + ), + ), + const SizedBox(height: 16.0), + if (hasData) + Frame( + child: SizedBox( + height: 220.0, + child: NetWorthChart( + samples: samples, + unit: sampleUnit, + primaryCurrency: primaryCurrency, + ), + ), + ) + else + Frame( + child: SizedBox( + height: 120.0, + child: Center( + child: Text( + "tabs.stats.analytics.netWorth.notEnoughHistory".t( + context, + ), + ), + ), + ), + ), + if (missingRates) ...[ + const SizedBox(height: 8.0), + MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesBalances".t( + context, + ), + ), + ], + const SizedBox(height: 32.0), + ListHeader("tabs.stats.analytics.netWorth.byAccount".t(context)), + const SizedBox(height: 8.0), + ..._buildShareRows(context), + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + List _buildShareRows(BuildContext context) { + if (shares.isEmpty) { + return [ + Frame( + child: Text("tabs.stats.analytics.netWorth.noAccounts".t(context)), + ), + ]; + } + + // Share is relative to the gross size of holdings (sum of absolute + // balances) so debt and assets each get a sensible bar instead of one + // overflowing the other. + final double gross = shares.fold( + 0.0, + (sum, share) => sum + share.amount.abs(), + ); + + return shares + .map( + (share) => AccountShareTile( + share: share, + gross: gross, + primaryCurrency: primaryCurrency, + ), + ) + .toList(); + } + + void _updateRange(TimeRange value) { + if (value == range) return; + range = value; + fetch(); + } + + @override + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + try { + accounts = ObjectBox() + .getAccounts(false) + .where((account) => account.excludeFromTotalBalance != true) + .toList(); + + final DurationUnit unit = RangeData.getOptimalUnit(range); + final List anchors = _anchors(range, unit); + + bool missing = false; + + final List nextSamples = anchors.map((anchor) { + double total = 0.0; + for (final Account account in accounts) { + final double? value = account + .balanceAt(anchor) + .tryConvertAmount(primaryCurrency, rates); + total += value ?? 0.0; + missing = missing || value == null; + } + return NetWorthSample(anchor, total); + }).toList(); + + final List nextShares = []; + for (final Account account in accounts) { + final double? value = account.balance.tryConvertAmount( + primaryCurrency, + rates, + ); + missing = missing || value == null; + + if ((value ?? 0.0) == 0.0) continue; + nextShares.add(AccountBalanceShare(account, value!)); + } + nextShares.sort((a, b) => b.amount.abs().compareTo(a.amount.abs())); + + sampleUnit = unit; + samples = nextSamples; + shares = nextShares; + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + /// Even anchors stepping through [range] by [unit], capped at a readable + /// count, with the final anchor pinned to "now" so the latest figure is + /// live (when the range includes the present). + List _anchors(TimeRange range, DurationUnit unit) { + final DateTime now = DateTime.now(); + final DateTime start = range.from; + final DateTime end = range.to.isAfter(now) ? now : range.to; + + if (!end.isAfter(start)) { + return [end]; + } + + final int stepMicros = unit.microseconds; + final int spanMicros = end.difference(start).inMicroseconds; + + // Keep at most ~24 points; never fewer than one step. + final int rawCount = (spanMicros / stepMicros).floor(); + final int count = rawCount.clamp(1, 24); + final double strideMicros = spanMicros / count; + + final List anchors = []; + for (int i = 0; i < count; i++) { + anchors.add( + start.add(Duration(microseconds: (strideMicros * i).round())), + ); + } + anchors.add(end); + + return anchors; + } +} diff --git a/lib/routes/stats/recurring_page.dart b/lib/routes/stats/recurring_page.dart new file mode 100644 index 00000000..07334e02 --- /dev/null +++ b/lib/routes/stats/recurring_page.dart @@ -0,0 +1,260 @@ +import "package:flow/data/money.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/services/categories.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/stats/missing_rates_notice.dart"; +import "package:flow/widgets/stats/recurring/recurring_summary_header.dart"; +import "package:flow/widgets/stats/stats_app_bar.dart"; +import "package:flow/widgets/stats/stats_empty_state.dart"; +import "package:flow/widgets/time_range_selector.dart"; +import "package:flow/widgets/transaction_list_tile.dart"; +import "package:flow/widgets/transactions_date_header.dart"; +import "package:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Subscriptions & recurring radar. +/// +/// Projects every active [RecurringTransaction] forward across the selected +/// [range] using its [Recurrence] rules, then lists the occurrences — incomes +/// and expenses alike — through the universal transaction list, and sums the +/// expected inflow/outflow. +class RecurringPage extends StatefulWidget { + const RecurringPage({super.key}); + + @override + State createState() => _RecurringPageState(); +} + +class _RecurringPageState extends State + with PrimaryCurrencyDependentState { + /// Caps how many rows are drawn; occurrences are sorted by date, so the + /// soonest survive. Totals are computed over every occurrence regardless. + static const int _maxRows = 60; + + TimeRange range = TimeRange.thisMonth(); + + bool busy = false; + bool missingRates = false; + + /// All projected occurrences in [range], sorted by date ascending. + List occurrences = []; + int activeCount = 0; + double totalIncome = 0.0; + double totalExpense = 0.0; + + @override + Widget build(BuildContext context) { + final List displayed = occurrences.take(_maxRows).toList(); + final int hidden = occurrences.length - displayed.length; + final Map> grouped = displayed.groupByDate(); + + return Scaffold( + appBar: StatsAppBar(title: "tabs.stats.analytics.recurring".t(context)), + body: SafeArea( + child: busy && occurrences.isEmpty + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + RecurringSummaryHeader( + income: Money(totalIncome, primaryCurrency), + expense: Money(totalExpense, primaryCurrency), + count: occurrences.length, + ), + const SizedBox(height: 16.0), + Frame( + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, + ), + ), + const SizedBox(height: 16.0), + if (activeCount == 0) + StatsEmptyState( + message: "tabs.stats.analytics.recurring.none".t( + context, + ), + ) + else if (occurrences.isEmpty) + StatsEmptyState( + message: + "tabs.stats.analytics.recurring.nothingUpcoming".t( + context, + ), + ) + else + ..._buildGroups(context, grouped), + if (hidden > 0) ...[ + const SizedBox(height: 8.0), + Frame( + child: Text( + "tabs.stats.analytics.recurring.moreNotShown".t( + context, + {"count": hidden}, + ), + style: context.textTheme.bodySmall?.semi(context), + ), + ), + ], + if (missingRates) ...[ + const SizedBox(height: 8.0), + MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesAmounts".t( + context, + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + List _buildGroups( + BuildContext context, + Map> grouped, + ) { + final List rows = []; + + for (final MapEntry> entry + in grouped.entries) { + rows.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + child: TransactionListDateHeader( + transactions: entry.value, + range: entry.key, + ), + ), + ); + + for (final Transaction transaction in entry.value) { + // Projections aren't real rows: render them read-only so the universal + // tile's tap/swipe affordances don't act on a non-existent entity. + rows.add( + AbsorbPointer( + child: TransactionListTile( + key: ValueKey( + "${transaction.uuid}-" + "${transaction.transactionDate.microsecondsSinceEpoch}", + ), + transaction: transaction, + recoverFromTrashFn: null, + moveToTrashFn: null, + combineTransfers: false, + ), + ), + ); + } + } + + return rows; + } + + void _updateRange(TimeRange value) { + if (value == range) return; + range = value; + fetch(); + } + + @override + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + bool missing = false; + + try { + final query = RecurringTransactionsService().activeRecurringsQb().build(); + final List recurrings = query.find(); + query.close(); + + final List result = []; + double income = 0.0; + double expense = 0.0; + int active = 0; + + for (final RecurringTransaction recurring in recurrings) { + // Validate the template once; a stale currency code throws on `money`. + if (_decodeTemplate(recurring) == null) continue; + + active++; + + final String? categoryUuid = recurring.template.categoryUuid; + final Category? category = categoryUuid == null + ? null + : CategoriesService().findOneSync(categoryUuid); + + final List dates = recurring.recurrence.occurrences( + subrange: range, + ); + + for (final DateTime date in dates) { + // `template` decodes a fresh instance each access, so each occurrence + // gets its own object to carry a distinct date. + final Transaction occurrence = recurring.template + ..transactionDate = date + ..isPending = true + ..setCategory(category); + + result.add(occurrence); + + final double? converted = occurrence.money.tryConvertAmount( + primaryCurrency, + rates, + ); + if (converted == null) { + missing = true; + continue; + } + + switch (occurrence.type) { + case TransactionType.income: + income += converted.abs(); + case TransactionType.expense: + expense += converted.abs(); + case TransactionType.transfer: + break; + } + } + } + + result.sort((a, b) => a.transactionDate.compareTo(b.transactionDate)); + + occurrences = result; + activeCount = active; + totalIncome = income; + totalExpense = expense; + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + Transaction? _decodeTemplate(RecurringTransaction recurring) { + try { + final Transaction template = recurring.template; + // A malformed template or unknown currency shouldn't take down the page. + template.money; + return template; + } catch (_) { + return null; + } + } +} diff --git a/lib/routes/debug/analytics/debug_spending_calendar_page.dart b/lib/routes/stats/spending_calendar_page.dart similarity index 55% rename from lib/routes/debug/analytics/debug_spending_calendar_page.dart rename to lib/routes/stats/spending_calendar_page.dart index b032fd48..98eafcf0 100644 --- a/lib/routes/debug/analytics/debug_spending_calendar_page.dart +++ b/lib/routes/stats/spending_calendar_page.dart @@ -1,98 +1,76 @@ -import "package:flow/data/exchange_rates.dart"; import "package:flow/data/money.dart"; import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/actions.dart"; -import "package:flow/services/exchange_rates.dart"; -import "package:flow/services/user_preferences.dart"; import "package:flow/theme/theme.dart"; -import "package:flow/widgets/debug/analytics/insight_card.dart"; -import "package:flow/widgets/debug/analytics/spending_heatmap.dart"; -import "package:flow/widgets/debug/analytics/weekday_bars.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/analytics/insight_card.dart"; +import "package:flow/widgets/analytics/spending_heatmap.dart"; +import "package:flow/widgets/analytics/weekday_bars.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/stats/emphasized_text.dart"; +import "package:flow/widgets/stats/missing_rates_notice.dart"; +import "package:flow/widgets/stats/stats_app_bar.dart"; +import "package:flow/widgets/stats/stats_empty_state.dart"; +import "package:flow/widgets/time_range_selector.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; -/// [dev] Spending calendar — a heatmap of daily spend intensity. +/// Spending calendar — a heatmap of daily spend intensity. /// /// Bins expenses by [Transaction.transactionDate] and renders a GitHub-style /// grid, plus a weekday breakdown. The weekday rhythm is computed here because /// `TrendsReport.expenseByWeekday` is never populated upstream. -class DebugSpendingCalendarPage extends StatefulWidget { - const DebugSpendingCalendarPage({super.key}); +class SpendingCalendarPage extends StatefulWidget { + const SpendingCalendarPage({super.key}); @override - State createState() => - _DebugSpendingCalendarPageState(); + State createState() => _SpendingCalendarPageState(); } -enum _Period { - m3("3M", 13), - m6("6M", 26), - y1("1Y", 53); - - final String label; - final int weeks; - - const _Period(this.label, this.weeks); -} - -class _DebugSpendingCalendarPageState extends State { - _Period period = _Period.m6; +class _SpendingCalendarPageState extends State + with PrimaryCurrencyDependentState { + TimeRange range = TimeRange.thisYear(); bool busy = false; bool missingRates = false; - late String primaryCurrency; - ExchangeRates? rates; - Map dailyExpense = {}; Map weekdayExpense = {}; double total = 0.0; DateTime from = DateTime.now(); DateTime to = DateTime.now(); - @override - void initState() { - super.initState(); - - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - fetch(); - } - @override Widget build(BuildContext context) { final bool hasData = dailyExpense.isNotEmpty; return Scaffold( - appBar: AppBar( - title: const Text("Spending calendar (dev)"), - elevation: 0.0, - scrolledUnderElevation: 1.0, - centerTitle: false, - shadowColor: context.colorScheme.onSurface.withAlpha(0x40), - backgroundColor: context.colorScheme.surface, - surfaceTintColor: kTransparent, + appBar: StatsAppBar( + title: "tabs.stats.analytics.spendingCalendar".t(context), ), body: SafeArea( child: busy && dailyExpense.isEmpty ? const Spinner.center() : SingleChildScrollView( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ const SizedBox(height: 16.0), Frame( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( - "Spent in ${period.label}", + "tabs.stats.analytics.calendar.spentIn".t( + context, + range.format(useRelative: false), + ), style: context.textTheme.titleSmall?.semi(context), ), const SizedBox(height: 2.0), @@ -107,17 +85,9 @@ class _DebugSpendingCalendarPageState extends State { ), const SizedBox(height: 16.0), Frame( - child: Wrap( - spacing: 8.0, - children: _Period.values - .map( - (p) => FilterChip( - label: Text(p.label), - selected: p == period, - onSelected: busy ? null : (_) => _setPeriod(p), - ), - ) - .toList(), + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, ), ), const SizedBox(height: 16.0), @@ -131,12 +101,9 @@ class _DebugSpendingCalendarPageState extends State { ), ) else - const Frame( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 48.0), - child: Center( - child: Text("No spending in this window."), - ), + StatsEmptyState( + message: "tabs.stats.analytics.noSpendingWindow".t( + context, ), ), if (weekdayExpense.isNotEmpty) ...[ @@ -145,13 +112,9 @@ class _DebugSpendingCalendarPageState extends State { ], if (missingRates) ...[ const SizedBox(height: 8.0), - Frame( - child: Text( - "Some non-primary currency amounts were skipped " - "(missing exchange rates).", - style: context.textTheme.bodySmall?.copyWith( - color: context.flowColors.expense, - ), + MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesAmounts".t( + context, ), ), ], @@ -170,18 +133,10 @@ class _DebugSpendingCalendarPageState extends State { return InsightCard( icon: Symbols.calendar_month_rounded, - label: "Rhythm", - title: Text.rich( - TextSpan( - children: [ - const TextSpan(text: "Your priciest day is "), - TextSpan( - text: _weekdayName(topWeekday), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const TextSpan(text: "."), - ], - ), + label: "tabs.stats.analytics.rhythm".t(context), + title: EmphasizedText( + template: "tabs.stats.analytics.calendar.priciestDay".t(context), + value: _weekdayName(topWeekday), ), child: WeekdayBars( byWeekday: weekdayExpense, @@ -191,12 +146,13 @@ class _DebugSpendingCalendarPageState extends State { ); } - void _setPeriod(_Period value) { - if (value == period) return; - period = value; + void _updateRange(TimeRange value) { + if (value == range) return; + range = value; fetch(); } + @override Future fetch() async { if (!mounted) return; setState(() { @@ -206,18 +162,14 @@ class _DebugSpendingCalendarPageState extends State { bool missing = false; try { - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - + // Heatmap cells past today render empty, so cap the grid at "now" when + // the range runs into the future (e.g. the remainder of this year). final DateTime now = DateTime.now(); - to = now; - from = now.subtract(Duration(days: period.weeks * 7)); + from = range.from; + to = range.to.isAfter(now) ? now : range.to; final List transactions = await ObjectBox() - .transcationsByRange( - CustomTimeRange(from, to), - includeTransfers: false, - ); + .transcationsByRange(range, includeTransfers: false); final Map daily = {}; final Map weekday = {}; @@ -226,7 +178,10 @@ class _DebugSpendingCalendarPageState extends State { for (final Transaction transaction in transactions) { if (transaction.type != TransactionType.expense) continue; - final double? converted = _convert(transaction.money, primaryCurrency); + final double? converted = transaction.money.tryConvertAmount( + primaryCurrency, + rates, + ); if (converted == null) { missing = true; continue; @@ -255,19 +210,6 @@ class _DebugSpendingCalendarPageState extends State { } } - double? _convert(Money money, String currency) { - if (money.currency == currency) return money.amount; - - final ExchangeRates? rates = this.rates; - if (rates == null) return null; - - try { - return money.convert(currency, rates).amount; - } catch (_) { - return null; - } - } - String _weekdayName(int weekday) { // 1 == Monday .. 7 == Sunday (DateTime.weekday). return DateTime(2024, 1, weekday).toMoment().format("dddd"); diff --git a/lib/routes/debug/analytics/debug_spending_map_page.dart b/lib/routes/stats/spending_map_page.dart similarity index 51% rename from lib/routes/debug/analytics/debug_spending_map_page.dart rename to lib/routes/stats/spending_map_page.dart index 554a8608..87ef2c24 100644 --- a/lib/routes/debug/analytics/debug_spending_map_page.dart +++ b/lib/routes/stats/spending_map_page.dart @@ -1,56 +1,41 @@ -import "package:flow/data/exchange_rates.dart"; import "package:flow/data/money.dart"; import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/actions.dart"; -import "package:flow/services/exchange_rates.dart"; -import "package:flow/services/user_preferences.dart"; import "package:flow/theme/theme.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; import "package:flow/utils/utils.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/stats/missing_rates_notice.dart"; +import "package:flow/widgets/stats/stats_app_bar.dart"; +import "package:flow/widgets/stats/stats_empty_state.dart"; +import "package:flow/widgets/time_range_selector.dart"; import "package:flutter/material.dart"; import "package:flutter_map/flutter_map.dart"; import "package:latlong2/latlong.dart"; import "package:moment_dart/moment_dart.dart"; -/// [dev] Spending map. +/// Spending map. /// /// Clusters geo-bearing expenses (from `Transaction.location` / the geo -/// extension) into ~100 m places, sizes a marker by total spend, and ranks -/// the places. Reads location data already stored on-device. -class DebugSpendingMapPage extends StatefulWidget { - const DebugSpendingMapPage({super.key}); +/// extension) into ~100 m places and sizes a marker by total spend. Places +/// aren't named or ranked: the only label available is the transaction title, +/// which rarely describes the place, so the heatmap stands on its own. +class SpendingMapPage extends StatefulWidget { + const SpendingMapPage({super.key}); @override - State createState() => _DebugSpendingMapPageState(); -} - -enum _Period { - m1("1M", 30), - m3("3M", 90), - y1("1Y", 365); - - final String label; - final int days; - - const _Period(this.label, this.days); + State createState() => _SpendingMapPageState(); } class _Place { final LatLng center; final double total; - final int count; - final String name; - const _Place({ - required this.center, - required this.total, - required this.count, - required this.name, - }); + const _Place({required this.center, required this.total}); } class _PlaceAccumulator { @@ -58,91 +43,54 @@ class _PlaceAccumulator { double sumLng = 0.0; double total = 0.0; int count = 0; - final Map titleFrequency = {}; - void add(LatLng point, double amount, String? title) { + void add(LatLng point, double amount) { sumLat += point.latitude; sumLng += point.longitude; total += amount; count++; - - final String? key = title?.trim(); - if (key != null && key.isNotEmpty) { - titleFrequency[key] = (titleFrequency[key] ?? 0) + 1; - } } - String get topTitle { - if (titleFrequency.isEmpty) return "Pinned location"; - return titleFrequency.entries - .reduce((a, b) => a.value >= b.value ? a : b) - .key; - } - - _Place toPlace() => _Place( - center: LatLng(sumLat / count, sumLng / count), - total: total, - count: count, - name: topTitle, - ); + _Place toPlace() => + _Place(center: LatLng(sumLat / count, sumLng / count), total: total); } -class _DebugSpendingMapPageState extends State { +class _SpendingMapPageState extends State + with PrimaryCurrencyDependentState { /// Caps how many place markers are drawn so a dense window stays smooth; /// places are sorted by spend, so the most significant ones win. static const int _maxMarkers = 150; - _Period period = _Period.m3; + TimeRange range = TimeRange.thisYear(); bool busy = false; bool missingRates = false; - late String primaryCurrency; - ExchangeRates? rates; - List<_Place> places = []; double mappedTotal = 0.0; int locatedCount = 0; int totalExpenseCount = 0; - @override - void initState() { - super.initState(); - - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - fetch(); - } - @override Widget build(BuildContext context) { final bool hasData = places.isNotEmpty; return Scaffold( - appBar: AppBar( - title: const Text("Spending map (dev)"), - elevation: 0.0, - scrolledUnderElevation: 1.0, - centerTitle: false, - shadowColor: context.colorScheme.onSurface.withAlpha(0x40), - backgroundColor: context.colorScheme.surface, - surfaceTintColor: kTransparent, - ), + appBar: StatsAppBar(title: "tabs.stats.analytics.spendingMap".t(context)), body: SafeArea( child: busy && places.isEmpty ? const Spinner.center() : SingleChildScrollView( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ const SizedBox(height: 16.0), Frame( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( - "Mapped spend", + "tabs.stats.analytics.map.mappedSpend".t(context), style: context.textTheme.titleSmall?.semi(context), ), const SizedBox(height: 2.0), @@ -154,8 +102,10 @@ class _DebugSpendingMapPageState extends State { ), const SizedBox(height: 4.0), Text( - "$locatedCount of $totalExpenseCount expenses have " - "a location", + "tabs.stats.analytics.map.locatedCount".t(context, { + "located": locatedCount, + "total": totalExpenseCount, + }), style: context.textTheme.bodyMedium?.semi(context), ), ], @@ -163,44 +113,23 @@ class _DebugSpendingMapPageState extends State { ), const SizedBox(height: 16.0), Frame( - child: Wrap( - spacing: 8.0, - children: _Period.values - .map( - (p) => FilterChip( - label: Text(p.label), - selected: p == period, - onSelected: busy ? null : (_) => _setPeriod(p), - ), - ) - .toList(), + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, ), ), const SizedBox(height: 16.0), - if (hasData) ...[ - Frame(child: _buildMap(context)), - const SizedBox(height: 24.0), - const ListHeader("Top places"), - const SizedBox(height: 8.0), - ..._buildPlaceRows(context), - ] else - const Frame( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 48.0), - child: Center( - child: Text("No located spending in this window."), - ), - ), + if (hasData) + Frame(child: _buildMap(context)) + else + StatsEmptyState( + message: "tabs.stats.analytics.map.empty".t(context), ), if (missingRates) ...[ const SizedBox(height: 8.0), - Frame( - child: Text( - "Some non-primary currency amounts were skipped " - "(missing exchange rates).", - style: context.textTheme.bodySmall?.copyWith( - color: context.flowColors.expense, - ), + MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesAmounts".t( + context, ), ), ], @@ -218,7 +147,7 @@ class _DebugSpendingMapPageState extends State { final Color marker = context.colorScheme.primary; return ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(16.0)), + borderRadius: .all(Radius.circular(16.0)), child: SizedBox( height: 320.0, child: FlutterMap( @@ -239,9 +168,10 @@ class _DebugSpendingMapPageState extends State { ? 0.0 : place.total / maxTotal; final double size = 16.0 + 34.0 * factor; - final String label = - "${place.name}: " - "${Money(place.total, primaryCurrency).formatted}"; + final String label = Money( + place.total, + primaryCurrency, + ).formatted; return Marker( point: place.center, @@ -276,57 +206,13 @@ class _DebugSpendingMapPageState extends State { ); } - List _buildPlaceRows(BuildContext context) { - return places.take(12).toList().asMap().entries.map((entry) { - final int rank = entry.key + 1; - final _Place place = entry.value; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), - child: Row( - children: [ - SizedBox( - width: 24.0, - child: Text( - "$rank", - style: context.textTheme.titleSmall?.semi(context), - ), - ), - const SizedBox(width: 8.0), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - place.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyLarge, - ), - Text( - "${place.count} ${place.count == 1 ? "visit" : "visits"}", - style: context.textTheme.bodySmall?.semi(context), - ), - ], - ), - ), - const SizedBox(width: 8.0), - MoneyText( - Money(place.total, primaryCurrency), - style: context.textTheme.titleSmall, - ), - ], - ), - ); - }).toList(); - } - - void _setPeriod(_Period value) { - if (value == period) return; - period = value; + void _updateRange(TimeRange value) { + if (value == range) return; + range = value; fetch(); } + @override Future fetch() async { if (!mounted) return; setState(() { @@ -336,17 +222,8 @@ class _DebugSpendingMapPageState extends State { bool missing = false; try { - primaryCurrency = UserPreferencesService().primaryCurrency; - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - - final DateTime now = DateTime.now(); - final TimeRange window = CustomTimeRange( - now.subtract(Duration(days: period.days)), - now, - ); - final List transactions = await ObjectBox() - .transcationsByRange(window, includeTransfers: false); + .transcationsByRange(range, includeTransfers: false); final Map clusters = {}; double mapped = 0.0; @@ -360,7 +237,10 @@ class _DebugSpendingMapPageState extends State { final LatLng? point = _latLngOf(transaction); if (point == null) continue; - final double? converted = _convert(transaction.money, primaryCurrency); + final double? converted = transaction.money.tryConvertAmount( + primaryCurrency, + rates, + ); if (converted == null) { missing = true; continue; @@ -374,11 +254,7 @@ class _DebugSpendingMapPageState extends State { final String key = "${(point.latitude * 1000).round()}:" "${(point.longitude * 1000).round()}"; - (clusters[key] ??= _PlaceAccumulator()).add( - point, - magnitude, - transaction.title, - ); + (clusters[key] ??= _PlaceAccumulator()).add(point, magnitude); } final List<_Place> result = @@ -411,17 +287,4 @@ class _DebugSpendingMapPageState extends State { return null; } - - double? _convert(Money money, String currency) { - if (money.currency == currency) return money.amount; - - final ExchangeRates? rates = this.rates; - if (rates == null) return null; - - try { - return money.convert(currency, rates).amount; - } catch (_) { - return null; - } - } } diff --git a/lib/routes/stats/wrapped_page.dart b/lib/routes/stats/wrapped_page.dart new file mode 100644 index 00000000..10c602ba --- /dev/null +++ b/lib/routes/stats/wrapped_page.dart @@ -0,0 +1,355 @@ +import "package:flow/data/flow_analytics.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/reports/trends_report.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/analytics/insight_card.dart"; +import "package:flow/widgets/analytics/weekday_bars.dart"; +import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/stats/emphasized_text.dart"; +import "package:flow/widgets/stats/missing_rates_notice.dart"; +import "package:flow/widgets/stats/stats_app_bar.dart"; +import "package:flow/widgets/stats/stats_empty_state.dart"; +import "package:flow/widgets/stats/wrapped/mini_bars.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Monthly "wrapped" — narrative insight cards instead of raw charts. +/// +/// Combines [TrendsReport] (median spend, top titles) with a +/// period-over-period category comparison and a locally-computed weekday +/// breakdown. +class WrappedPage extends StatefulWidget { + const WrappedPage({super.key}); + + @override + State createState() => _WrappedPageState(); +} + +class _WrappedPageState extends State + with PrimaryCurrencyDependentState { + bool busy = false; + bool missingRates = false; + + List thisMonthTransactions = []; + TrendsReport? trends; + + Category? topCategory; + double topCategoryCurrent = 0.0; + double topCategoryAverage = 0.0; + List topCategoryHistory = []; + + /// Weekday (1 = Mon .. 7 = Sun) -> summed expense, computed locally. + /// + /// [TrendsReport.expenseByWeekday] is declared but never populated, so its + /// `topSpendingWeekday` always returns null; we compute weekday spend here. + Map weekdayExpense = {}; + Transaction? biggestExpense; + double biggestExpenseConverted = 0.0; + + @override + Widget build(BuildContext context) { + final String month = DateTime.now().toMoment().format("MMMM"); + + return Scaffold( + appBar: StatsAppBar( + title: "tabs.stats.analytics.wrapped.title".t(context, month), + ), + body: SafeArea( + child: busy && trends == null + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 8.0), + if (thisMonthTransactions.isEmpty) + StatsEmptyState( + message: "tabs.stats.analytics.wrapped.noTransactions".t( + context, + ), + ) + else + ..._buildInsightCards(context), + if (missingRates) ...[ + const SizedBox(height: 8.0), + MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesAmounts".t( + context, + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + List _buildInsightCards(BuildContext context) { + return [ + if (topCategory != null || topCategoryCurrent > 0) + _buildCategoryTrendCard(context), + if (trends?.sortedTitlesByFrequency.isNotEmpty == true) + _buildTopMerchantCard(context), + if (weekdayExpense.isNotEmpty) _buildWeekdayCard(context), + _buildSpendShapeCard(context), + ]; + } + + Widget _buildCategoryTrendCard(BuildContext context) { + final double avg = topCategoryAverage; + final double current = topCategoryCurrent; + final bool up = current >= avg; + final double deltaPct = avg <= 0 ? 0.0 : ((current - avg) / avg) * 100.0; + final Color accent = up + ? context.flowColors.expense + : context.flowColors.income; + + final String name = + topCategory?.name ?? "tabs.stats.analytics.uncategorized".t(context); + final String direction = up + ? "tabs.stats.analytics.up".t(context) + : "tabs.stats.analytics.down".t(context); + // The {value} token is left for [EmphasizedText]; the Map fill only + // replaces the named tokens it's given. + final String template = "tabs.stats.analytics.wrapped.categoryTrend".t( + context, + {"name": name, "direction": direction}, + ); + + return InsightCard( + icon: Symbols.lunch_dining_rounded, + label: "tabs.stats.analytics.wrapped.label.category".t(context), + accent: accent, + title: EmphasizedText( + template: template, + value: "${deltaPct.abs().toStringAsFixed(0)}%", + valueStyle: TextStyle(color: accent, fontWeight: FontWeight.bold), + ), + subtitle: "tabs.stats.analytics.wrapped.categorySubtitle".t(context, { + "current": Money(current, primaryCurrency).formatted, + "typical": Money(avg, primaryCurrency).formatted, + }), + child: MiniBars(values: topCategoryHistory, highlightColor: accent), + ); + } + + Widget _buildTopMerchantCard(BuildContext context) { + final MapEntry top = trends!.sortedTitlesByFrequency.first; + + return InsightCard( + icon: Symbols.storefront_rounded, + label: "tabs.stats.analytics.wrapped.label.frequent".t(context), + title: EmphasizedText( + template: "tabs.stats.analytics.wrapped.frequentEntry".t(context), + value: top.key, + ), + subtitle: "tabs.stats.analytics.wrapped.loggedTimes".t(context, { + "count": top.value, + }), + ); + } + + Widget _buildWeekdayCard(BuildContext context) { + final int topWeekday = weekdayExpense.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + + return InsightCard( + icon: Symbols.calendar_month_rounded, + label: "tabs.stats.analytics.rhythm".t(context), + title: EmphasizedText( + template: "tabs.stats.analytics.wrapped.spendMostOn".t(context), + value: _weekdayName(topWeekday), + ), + child: WeekdayBars( + byWeekday: weekdayExpense, + topWeekday: topWeekday, + accent: context.colorScheme.primary, + ), + ); + } + + Widget _buildSpendShapeCard(BuildContext context) { + final Money median = + trends?.medianExpensePerTransaction ?? Money(0.0, primaryCurrency); + + final String biggestLine = biggestExpense == null + ? "tabs.stats.analytics.wrapped.noExpenses".t(context) + : "tabs.stats.analytics.wrapped.biggest".t(context, { + "title": + biggestExpense!.title ?? "tabs.stats.analytics.untitled".t(context), + "amount": Money(biggestExpenseConverted, primaryCurrency).formatted, + "date": biggestExpense!.transactionDate.toMoment().format("MMM D"), + }); + + return InsightCard( + icon: Symbols.bar_chart_rounded, + label: "tabs.stats.analytics.wrapped.label.shape".t(context), + title: EmphasizedText( + template: "tabs.stats.analytics.wrapped.medianPurchase".t(context), + value: median.formatted, + ), + subtitle: biggestLine, + ); + } + + @override + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + bool missing = false; + + try { + final List months = _recentMonths(4); + + final FlowAnalytics current = await ObjectBox() + .flowByCategories(range: months.first); + final List> previous = []; + for (final TimeRange range in months.skip(1)) { + previous.add(await ObjectBox().flowByCategories(range: range)); + } + + thisMonthTransactions = await ObjectBox().transcationsByRange( + months.first, + includeTransfers: false, + ); + + trends = TrendsReport( + rates: rates, + primaryCurrency: primaryCurrency, + transactions: thisMonthTransactions, + ); + + missing = missing || _computeTopCategory(current, previous); + missing = missing || _computeWeekdayAndBiggest(); + + missingRates = missing; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + /// Picks the biggest expense category this month and its 3-month average. + /// + /// Returns whether any currency conversion was skipped. + bool _computeTopCategory( + FlowAnalytics current, + List> previous, + ) { + String? topUuid; + double topExpense = 0.0; + Category? category; + + for (final MapEntry entry in current.flow.entries) { + final double expense = _categoryExpense(current, entry.key); + if (expense > topExpense) { + topExpense = expense; + topUuid = entry.key; + category = current.flow[entry.key]?.associatedData; + } + } + + topCategory = category; + topCategoryCurrent = topExpense; + + // Merging swallows unconvertible foreign currency (sets hasMissingData + // rather than throwing), so check every month's flows to surface the + // "amounts were skipped" banner when the numbers are under-counted. + final bool missing = [current, ...previous].any( + (analytics) => analytics.flow.values.any( + (flow) => flow.merge(primaryCurrency, rates).hasMissingData, + ), + ); + + if (topUuid == null) { + topCategoryAverage = 0.0; + topCategoryHistory = []; + return missing; + } + + final List history = previous.reversed + .map((flow) => _categoryExpense(flow, topUuid!)) + .toList(); + history.add(topExpense); + + topCategoryHistory = history; + topCategoryAverage = previous.isEmpty + ? 0.0 + : previous + .map((flow) => _categoryExpense(flow, topUuid!)) + .fold(0.0, (a, b) => a + b) / + previous.length; + + return missing; + } + + double _categoryExpense(FlowAnalytics analytics, String uuid) { + final flow = analytics.flow[uuid]; + if (flow == null) return 0.0; + return flow.merge(primaryCurrency, rates).totalExpense.amount.abs(); + } + + bool _computeWeekdayAndBiggest() { + bool missing = false; + final Map byWeekday = {}; + + Transaction? biggest; + double biggestAmount = 0.0; + + for (final Transaction transaction in thisMonthTransactions) { + if (transaction.type != TransactionType.expense) continue; + + final double? converted = transaction.money.tryConvertAmount( + primaryCurrency, + rates, + ); + if (converted == null) { + missing = true; + continue; + } + + final double magnitude = converted.abs(); + final int weekday = transaction.transactionDate.weekday; + byWeekday[weekday] = (byWeekday[weekday] ?? 0.0) + magnitude; + + if (magnitude > biggestAmount) { + biggestAmount = magnitude; + biggest = transaction; + } + } + + weekdayExpense = byWeekday; + biggestExpense = biggest; + biggestExpenseConverted = biggestAmount; + + return missing; + } + + List _recentMonths(int count) { + final List months = [TimeRange.thisMonth()]; + for (int i = 1; i < count; i++) { + final TimeRange previous = months.last; + months.add(previous is PageableRange ? previous.last : previous); + } + return months; + } + + String _weekdayName(int weekday) { + // 1 == Monday .. 7 == Sunday (DateTime.weekday). + return DateTime(2024, 1, weekday).toMoment().format("dddd"); + } +} diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 66fafd4d..a285c923 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -1,6 +1,7 @@ export "extensions/custom_popups.dart"; export "extensions/go_router.dart"; export "extensions/iterables.dart"; +export "extensions/money.dart"; export "extensions/num.dart"; export "extensions/string.dart"; export "extensions/toast.dart"; diff --git a/lib/utils/extensions/money.dart b/lib/utils/extensions/money.dart new file mode 100644 index 00000000..53951604 --- /dev/null +++ b/lib/utils/extensions/money.dart @@ -0,0 +1,22 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/money.dart"; + +extension MoneyConversion on Money { + /// Converts this amount into [targetCurrency], returning `null` when the + /// conversion isn't possible — no [rates], or an unsupported currency code — + /// instead of throwing. + /// + /// Same-currency amounts are returned as-is. Analytics call sites treat a + /// `null` result as "missing data" so a single unconvertible transaction + /// never aborts a whole aggregation. + double? tryConvertAmount(String targetCurrency, ExchangeRates? rates) { + if (currency == targetCurrency) return amount; + if (rates == null) return null; + + try { + return convert(targetCurrency, rates).amount; + } catch (_) { + return null; + } + } +} diff --git a/lib/utils/primary_currency_dependent_state.dart b/lib/utils/primary_currency_dependent_state.dart new file mode 100644 index 00000000..f8bb7ff0 --- /dev/null +++ b/lib/utils/primary_currency_dependent_state.dart @@ -0,0 +1,58 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flutter/widgets.dart"; + +/// Wires a [State] to the primary currency and its exchange rates. +/// +/// Exchange rates and the primary currency can settle a frame or two after a +/// screen opens — and can change while it's open. Mixing this in keeps +/// [primaryCurrency] and [rates] fresh and re-runs [fetch] whenever either +/// changes, so the first paint is never stale and later edits are reflected. +/// +/// Implementers provide [fetch]; the mixin owns the listener lifecycle and the +/// two fields. [fetch] is called once after the initial values are resolved and +/// again on every dependency change. +mixin PrimaryCurrencyDependentState on State { + late String primaryCurrency; + ExchangeRates? rates; + + /// Reloads this screen's data. Called on init and on every primary-currency + /// or exchange-rate change. + Future fetch(); + + @override + void initState() { + super.initState(); + + _refreshPrimaryCurrencyDependencies(); + fetch(); + + ExchangeRatesService().exchangeRatesCache.addListener( + _onDependenciesChanged, + ); + UserPreferencesService().valueNotifier.addListener(_onDependenciesChanged); + } + + @override + void dispose() { + ExchangeRatesService().exchangeRatesCache.removeListener( + _onDependenciesChanged, + ); + UserPreferencesService().valueNotifier.removeListener( + _onDependenciesChanged, + ); + super.dispose(); + } + + void _refreshPrimaryCurrencyDependencies() { + primaryCurrency = UserPreferencesService().primaryCurrency; + rates = ExchangeRatesService().getPrimaryCurrencyRates(); + } + + void _onDependenciesChanged() { + if (!mounted) return; + _refreshPrimaryCurrencyDependencies(); + fetch(); + } +} diff --git a/lib/widgets/debug/analytics/bullet_chart.dart b/lib/widgets/analytics/bullet_chart.dart similarity index 100% rename from lib/widgets/debug/analytics/bullet_chart.dart rename to lib/widgets/analytics/bullet_chart.dart diff --git a/lib/widgets/debug/analytics/insight_card.dart b/lib/widgets/analytics/insight_card.dart similarity index 100% rename from lib/widgets/debug/analytics/insight_card.dart rename to lib/widgets/analytics/insight_card.dart diff --git a/lib/widgets/debug/analytics/sankey_diagram.dart b/lib/widgets/analytics/sankey_diagram.dart similarity index 100% rename from lib/widgets/debug/analytics/sankey_diagram.dart rename to lib/widgets/analytics/sankey_diagram.dart diff --git a/lib/widgets/debug/analytics/spending_heatmap.dart b/lib/widgets/analytics/spending_heatmap.dart similarity index 100% rename from lib/widgets/debug/analytics/spending_heatmap.dart rename to lib/widgets/analytics/spending_heatmap.dart diff --git a/lib/widgets/debug/analytics/weekday_bars.dart b/lib/widgets/analytics/weekday_bars.dart similarity index 100% rename from lib/widgets/debug/analytics/weekday_bars.dart rename to lib/widgets/analytics/weekday_bars.dart diff --git a/lib/widgets/general/flow_icon.dart b/lib/widgets/general/flow_icon.dart index 254e1b58..14f22d44 100644 --- a/lib/widgets/general/flow_icon.dart +++ b/lib/widgets/general/flow_icon.dart @@ -87,6 +87,14 @@ class FlowIcon extends StatelessWidget { color: color, fill: fill, ), + SimpleIconFlowIcon simpleIcon => Icon( + // Falls back to a neutral glyph if the brand was removed/renamed + // upstream and the slug no longer resolves. + simpleIcon.iconData ?? Symbols.help_rounded, + size: size, + color: color, + fill: fill, + ), ImageFlowIcon image => ClipRRect( borderRadius: borderRadius.subtract(.circular(platePadding.top)), child: Image.file( diff --git a/lib/widgets/home/stats/bento/analytics_bento.dart b/lib/widgets/home/stats/bento/analytics_bento.dart new file mode 100644 index 00000000..7728e9d7 --- /dev/null +++ b/lib/widgets/home/stats/bento/analytics_bento.dart @@ -0,0 +1,70 @@ +import "package:flow/l10n/extensions.dart"; +import "package:flow/widgets/general/list_header.dart"; +import "package:flow/widgets/home/stats/bento/calendar_tile.dart"; +import "package:flow/widgets/home/stats/bento/cash_flow_tile.dart"; +import "package:flow/widgets/home/stats/bento/map_tile.dart"; +import "package:flow/widgets/home/stats/bento/net_worth_tile.dart"; +import "package:flow/widgets/home/stats/bento/recurring_tile.dart"; +import "package:flow/widgets/home/stats/bento/top_categories_tile.dart"; +import "package:flow/widgets/home/stats/bento/wrapped_tile.dart"; +import "package:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// The Stats bento dashboard, split into two sections. +/// +/// The top section is **range-bound**: [CashFlowTile] and [TopCategoriesTile] +/// follow the [range] picked by the Stats tab's time-range selector, so they +/// belong directly beneath it. +/// +/// Below an "Insights" header sits the **timeless** section: net worth, +/// wrapped, the spending calendar, recurring, and the spending map each show +/// their own natural window and ignore the selected month. Grouping them apart +/// keeps the range selector from implying control it doesn't have. The same +/// pages are also reachable from Profile → Insights. +class AnalyticsBento extends StatelessWidget { + final TimeRange range; + + const AnalyticsBento({super.key, required this.range}); + + @override + Widget build(BuildContext context) { + // Tiles have fixed heights, so cap text scaling to keep dense previews + // from overflowing under large accessibility font settings. + return MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.3, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Range-bound — these respond to the selected time range. + CashFlowTile(range: range), + const SizedBox(height: 12.0), + TopCategoriesTile(range: range), + const SizedBox(height: 24.0), + // Timeless — each shows its own natural window, independent of the + // selected range. + ListHeader( + "tabs.stats.insights".t(context), + padding: EdgeInsets.zero, + ), + const SizedBox(height: 12.0), + const WrappedTile(), + const SizedBox(height: 12.0), + const NetWorthTile(), + const SizedBox(height: 12.0), + const Row( + spacing: 12.0, + children: [ + Expanded(child: CalendarTile()), + Expanded(child: RecurringTile()), + ], + ), + const SizedBox(height: 12.0), + const MapTile(), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/home/stats/bento/bento_tile.dart b/lib/widgets/home/stats/bento/bento_tile.dart new file mode 100644 index 00000000..8e680442 --- /dev/null +++ b/lib/widgets/home/stats/bento/bento_tile.dart @@ -0,0 +1,99 @@ +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +/// A single tile in the Stats bento dashboard. +/// +/// A tappable [Surface] with an optional header (icon + uppercase label and a +/// trailing chevron) and a [child] body. Tiles size to a fixed [height] so the +/// bento grid stays predictable across content and locales; previews are +/// expected to fit, not scroll. +class BentoTile extends StatelessWidget { + /// Short header text, e.g. "Net worth". Rendered uppercase next to [icon]. + final String label; + + final IconData icon; + + /// Tints the icon. Defaults to the theme primary color. + final Color? accent; + + /// Fixed tile height. Paired tiles in a row should share the same value. + final double height; + + /// Whether the body is still loading; shows a centered spinner instead. + final bool busy; + + /// Navigation target. When non-null a chevron is shown and the tile ripples. + final VoidCallback? onTap; + + final Widget child; + + const BentoTile({ + super.key, + required this.label, + required this.icon, + required this.height, + required this.child, + this.accent, + this.busy = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final Color resolvedAccent = accent ?? context.colorScheme.primary; + + return Surface( + builder: (context) => InkWell( + borderRadius: .all(Radius.circular(16.0)), + onTap: onTap, + child: SizedBox( + height: height, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + Icon(icon, color: resolvedAccent, size: 18.0), + const SizedBox(width: 8.0), + Expanded( + child: Text( + label.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.flowColors.semi, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ), + if (onTap != null) + Icon( + Symbols.chevron_right_rounded, + size: 18.0, + color: context.flowColors.semi, + ), + ], + ), + const SizedBox(height: 10.0), + Expanded( + child: busy + ? const Spinner.center() + : Align( + alignment: AlignmentDirectional.topStart, + child: child, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/home/stats/bento/calendar_heatmap.dart b/lib/widgets/home/stats/bento/calendar_heatmap.dart new file mode 100644 index 00000000..4d38859e --- /dev/null +++ b/lib/widgets/home/stats/bento/calendar_heatmap.dart @@ -0,0 +1,75 @@ +import "package:flutter/material.dart"; + +/// A compact GitHub-style heatmap of daily spend for the bento calendar tile. +/// +/// Renders [weeks] columns of 7 day-cells starting at [gridStart]; each cell's +/// opacity scales with that day's spend relative to [maxDaily]. Future days +/// are left transparent. +class CalendarHeatmap extends StatelessWidget { + final DateTime gridStart; + final int weeks; + final Map dailyExpense; + final double maxDaily; + final Color filled; + final Color empty; + + const CalendarHeatmap({ + super.key, + required this.gridStart, + required this.weeks, + required this.dailyExpense, + required this.maxDaily, + required this.filled, + required this.empty, + }); + + @override + Widget build(BuildContext context) { + final DateTime now = DateTime.now(); + const double cell = 11.0; + const double gap = 2.5; + + return Row( + mainAxisSize: .min, + children: List.generate(weeks, (week) { + return Padding( + padding: const EdgeInsets.only(right: gap), + child: Column( + mainAxisSize: .min, + children: List.generate(7, (weekday) { + final DateTime day = gridStart.add( + Duration(days: week * 7 + weekday), + ); + final bool future = day.isAfter(now); + final double amount = dailyExpense[day] ?? 0.0; + final double factor = maxDaily <= 0 + ? 0.0 + : (amount / maxDaily).clamp(0.0, 1.0); + + final Color color = future + ? Colors.transparent + : amount <= 0 + ? empty + : Color.alphaBlend( + filled.withAlpha((factor * 0xff).round()), + empty, + ); + + return Padding( + padding: const EdgeInsets.only(bottom: gap), + child: Container( + width: cell, + height: cell, + decoration: BoxDecoration( + color: color, + borderRadius: .all(Radius.circular(2.5)), + ), + ), + ); + }), + ), + ); + }), + ); + } +} diff --git a/lib/widgets/home/stats/bento/calendar_tile.dart b/lib/widgets/home/stats/bento/calendar_tile.dart new file mode 100644 index 00000000..726f1655 --- /dev/null +++ b/lib/widgets/home/stats/bento/calendar_tile.dart @@ -0,0 +1,119 @@ +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/home/stats/bento/bento_tile.dart"; +import "package:flow/widgets/home/stats/bento/calendar_heatmap.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Bento preview of the spending calendar: a compact GitHub-style heatmap of +/// daily expense over the trailing [_weeks] weeks. Range-independent. +class CalendarTile extends StatefulWidget { + const CalendarTile({super.key}); + + @override + State createState() => _CalendarTileState(); +} + +class _CalendarTileState extends State + with PrimaryCurrencyDependentState { + static const int _weeks = 13; + + bool busy = true; + bool loaded = false; + + /// Day (midnight) -> total expense in the primary currency. + Map dailyExpense = {}; + double maxDaily = 0.0; + + /// Monday at the start of the grid window — resolved lazily so it's ready + /// before the mixin runs the first [fetch]. + late final DateTime gridStart = _resolveGridStart(); + + @override + Widget build(BuildContext context) { + return BentoTile( + label: "tabs.stats.analytics.calendar".t(context), + icon: Symbols.calendar_month_rounded, + height: 158.0, + busy: busy && !loaded, + onTap: () => context.push("/stats/calendar"), + child: dailyExpense.isEmpty + ? Text( + "tabs.stats.analytics.noSpendingWindow".t(context), + style: context.textTheme.bodySmall?.semi(context), + ) + : Align( + alignment: AlignmentDirectional.centerStart, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: CalendarHeatmap( + gridStart: gridStart, + weeks: _weeks, + dailyExpense: dailyExpense, + maxDaily: maxDaily, + filled: context.colorScheme.primary, + empty: context.colorScheme.onSurface.withAlpha(0x14), + ), + ), + ), + ); + } + + @override + Future fetch() async { + try { + final DateTime now = DateTime.now(); + final List transactions = await ObjectBox() + .transcationsByRange( + CustomTimeRange(gridStart, now), + includeTransfers: false, + ); + + final Map daily = {}; + + for (final Transaction transaction in transactions) { + if (transaction.type != TransactionType.expense) continue; + + final double? converted = transaction.money.tryConvertAmount( + primaryCurrency, + rates, + ); + if (converted == null) continue; + + final DateTime day = DateTime( + transaction.transactionDate.year, + transaction.transactionDate.month, + transaction.transactionDate.day, + ); + daily[day] = (daily[day] ?? 0.0) + converted.abs(); + } + + dailyExpense = daily; + maxDaily = daily.values.isEmpty + ? 0.0 + : daily.values.reduce((a, b) => a > b ? a : b); + loaded = true; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + /// Monday at the start of the grid window, [_weeks] weeks back. + DateTime _resolveGridStart() { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime thisMonday = today.subtract( + Duration(days: today.weekday - 1), + ); + return thisMonday.subtract(Duration(days: (_weeks - 1) * 7)); + } +} diff --git a/lib/widgets/home/stats/bento/cash_flow_tile.dart b/lib/widgets/home/stats/bento/cash_flow_tile.dart new file mode 100644 index 00000000..e826948d --- /dev/null +++ b/lib/widgets/home/stats/bento/cash_flow_tile.dart @@ -0,0 +1,156 @@ +import "package:flow/data/money.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/home/stats/bento/bento_tile.dart"; +import "package:flow/widgets/stats/cash_flow/cash_flow_figure.dart"; +import "package:flow/widgets/stats/cash_flow/cash_flow_flow_bar.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Bento preview of cash flow for the selected range: a hero net "Saved" or +/// "Overspent" figure, a single stacked in/out flow bar, and the labeled +/// income and expense totals anchored to each side of the bar. +class CashFlowTile extends StatefulWidget { + final TimeRange range; + + const CashFlowTile({super.key, required this.range}); + + @override + State createState() => _CashFlowTileState(); +} + +class _CashFlowTileState extends State + with PrimaryCurrencyDependentState { + bool busy = true; + bool loaded = false; + + double income = 0.0; + double expense = 0.0; + + @override + void didUpdateWidget(CashFlowTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.range != oldWidget.range) fetch(); + } + + @override + Widget build(BuildContext context) { + final double net = income - expense; + final bool saved = net >= 0; + final bool empty = income <= 0 && expense <= 0; + + final Color netColor = saved + ? context.flowColors.income + : context.flowColors.expense; + + return BentoTile( + label: "tabs.stats.analytics.cashFlow".t(context), + icon: Symbols.swap_vert_rounded, + height: 160.0, + busy: busy && !loaded, + onTap: () => context.push("/stats/cash-flow"), + child: empty + ? Text( + "tabs.stats.analytics.cashFlow.noMovement".t(context), + style: context.textTheme.bodySmall?.semi(context), + ) + : Column( + crossAxisAlignment: .start, + children: [ + Row( + crossAxisAlignment: .center, + children: [ + Icon( + saved + ? Symbols.savings_rounded + : Symbols.trending_down_rounded, + color: netColor, + size: 18.0, + ), + const SizedBox(width: 6.0), + Text( + (saved + ? "tabs.stats.analytics.saved" + : "tabs.stats.analytics.overspent") + .t(context), + style: context.textTheme.labelMedium?.semi(context), + ), + const SizedBox(width: 8.0), + Expanded( + child: MoneyText( + Money(saved ? net : -net, primaryCurrency), + style: context.textTheme.titleLarge?.copyWith( + color: netColor, + fontWeight: FontWeight.w700, + ), + autoSize: true, + initiallyAbbreviated: true, + textAlign: TextAlign.end, + ), + ), + ], + ), + const SizedBox(height: 12.0), + CashFlowFlowBar(income: income, expense: expense), + const SizedBox(height: 8.0), + Row( + children: [ + CashFlowFigure( + label: "tabs.stats.analytics.in".t(context), + money: Money(income, primaryCurrency), + color: context.flowColors.income, + ), + const Spacer(), + CashFlowFigure( + label: "tabs.stats.analytics.out".t(context), + money: Money(expense, primaryCurrency), + color: context.flowColors.expense, + alignEnd: true, + ), + ], + ), + ], + ), + ); + } + + @override + Future fetch() async { + try { + final List transactions = await ObjectBox() + .transcationsByRange(widget.range, includeTransfers: false); + + double nextIncome = 0.0; + double nextExpense = 0.0; + + for (final Transaction transaction in transactions) { + final double? converted = transaction.money.tryConvertAmount( + primaryCurrency, + rates, + ); + if (converted == null) continue; + + if (transaction.type == TransactionType.income) { + nextIncome += converted.abs(); + } else if (transaction.type == TransactionType.expense) { + nextExpense += converted.abs(); + } + } + + income = nextIncome; + expense = nextExpense; + loaded = true; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } +} diff --git a/lib/widgets/home/stats/bento/category_slice_row.dart b/lib/widgets/home/stats/bento/category_slice_row.dart new file mode 100644 index 00000000..e6d9d3c0 --- /dev/null +++ b/lib/widgets/home/stats/bento/category_slice_row.dart @@ -0,0 +1,65 @@ +import "package:flow/data/money.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flutter/material.dart"; + +/// One category's spend in the top-categories tile: a name, amount, and a bar +/// sized by the category's share of the largest spender ([maxAmount]). +class CategorySliceRow extends StatelessWidget { + final String name; + final double amount; + final Color color; + final double maxAmount; + final String currency; + + const CategorySliceRow({ + super.key, + required this.name, + required this.amount, + required this.color, + required this.maxAmount, + required this.currency, + }); + + @override + Widget build(BuildContext context) { + final double factor = maxAmount <= 0 + ? 0.0 + : (amount / maxAmount).clamp(0.0, 1.0); + + return Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Row( + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall, + ), + ), + const SizedBox(width: 6.0), + MoneyText( + Money(amount, currency), + style: context.textTheme.labelSmall?.semi(context), + initiallyAbbreviated: true, + ), + ], + ), + const SizedBox(height: 3.0), + ClipRRect( + borderRadius: .all(Radius.circular(3.0)), + child: LinearProgressIndicator( + value: factor, + minHeight: 5.0, + backgroundColor: context.colorScheme.onSurface.withAlpha(0x1a), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/home/stats/bento/map_tile.dart b/lib/widgets/home/stats/bento/map_tile.dart new file mode 100644 index 00000000..05418b09 --- /dev/null +++ b/lib/widgets/home/stats/bento/map_tile.dart @@ -0,0 +1,126 @@ +import "package:flow/data/money.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/home/stats/bento/bento_tile.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:latlong2/latlong.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Bento preview of located spending over the trailing [_days] days: how much +/// spend carries a location. Range-independent. +class MapTile extends StatefulWidget { + const MapTile({super.key}); + + @override + State createState() => _MapTileState(); +} + +class _MapTileState extends State + with PrimaryCurrencyDependentState { + static const int _days = 90; + + bool busy = true; + bool loaded = false; + + double mappedTotal = 0.0; + int locatedCount = 0; + + @override + Widget build(BuildContext context) { + final bool hasData = locatedCount > 0; + + return BentoTile( + label: "tabs.stats.analytics.spendingMap".t(context), + icon: Symbols.map_rounded, + height: 158.0, + busy: busy && !loaded, + onTap: () => context.push("/stats/map"), + child: hasData + ? Column( + crossAxisAlignment: .start, + mainAxisAlignment: .center, + children: [ + Text( + "tabs.stats.analytics.map.mappedShort".t(context, { + "days": _days, + }), + style: context.textTheme.bodySmall?.semi(context), + ), + const SizedBox(height: 4.0), + MoneyText( + Money(mappedTotal, primaryCurrency), + style: context.textTheme.headlineSmall, + autoSize: true, + initiallyAbbreviated: true, + ), + ], + ) + : Text( + "tabs.stats.analytics.map.noneYet".t(context), + style: context.textTheme.bodySmall?.semi(context), + ), + ); + } + + @override + Future fetch() async { + try { + final DateTime now = DateTime.now(); + final List transactions = await ObjectBox() + .transcationsByRange( + CustomTimeRange(now.subtract(const Duration(days: _days)), now), + includeTransfers: false, + ); + + double mapped = 0.0; + int located = 0; + + for (final Transaction transaction in transactions) { + if (transaction.type != TransactionType.expense) continue; + + final LatLng? point = _latLngOf(transaction); + if (point == null) continue; + + final double? converted = transaction.money.tryConvertAmount( + primaryCurrency, + rates, + ); + if (converted == null) continue; + + located++; + mapped += converted.abs(); + } + + mappedTotal = mapped; + locatedCount = located; + loaded = true; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + LatLng? _latLngOf(Transaction transaction) { + final List? location = transaction.location; + if (location != null && location.length == 2) { + final double lat = location[0]; + final double lng = location[1]; + if (lat.isFinite && lng.isFinite) return LatLng(lat, lng); + } + + final LatLng? geo = transaction.extensions.geo?.toLatLngPosition(); + if (geo != null && geo.latitude.isFinite && geo.longitude.isFinite) { + return geo; + } + + return null; + } +} diff --git a/lib/widgets/home/stats/bento/net_worth_sparkline.dart b/lib/widgets/home/stats/bento/net_worth_sparkline.dart new file mode 100644 index 00000000..d61693dd --- /dev/null +++ b/lib/widgets/home/stats/bento/net_worth_sparkline.dart @@ -0,0 +1,56 @@ +import "package:fl_chart/fl_chart.dart"; +import "package:flutter/material.dart"; + +/// A minimal, axis-less net worth trend line for the bento net worth tile. +class NetWorthSparkline extends StatelessWidget { + final List samples; + final Color color; + + const NetWorthSparkline({ + super.key, + required this.samples, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final double maxY = samples.reduce((a, b) => a > b ? a : b); + final double minY = samples.reduce((a, b) => a < b ? a : b); + final double span = (maxY - minY).abs(); + final double pad = span == 0 ? (maxY.abs() * 0.1 + 1.0) : span * 0.15; + + return LineChart( + LineChartData( + minX: 0.0, + maxX: (samples.length - 1).toDouble(), + minY: minY - pad, + maxY: maxY + pad, + lineTouchData: const LineTouchData(enabled: false), + titlesData: const FlTitlesData(show: false), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + barWidth: 2.5, + color: color, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + spots: samples + .asMap() + .entries + .map((e) => FlSpot(e.key.toDouble(), e.value)) + .toList(), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [color.withAlpha(0x40), color.withAlpha(0x00)], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/home/stats/bento/net_worth_tile.dart b/lib/widgets/home/stats/bento/net_worth_tile.dart new file mode 100644 index 00000000..8bf53d26 --- /dev/null +++ b/lib/widgets/home/stats/bento/net_worth_tile.dart @@ -0,0 +1,124 @@ +import "package:flow/data/money.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/home/stats/bento/bento_tile.dart"; +import "package:flow/widgets/home/stats/bento/net_worth_sparkline.dart"; +import "package:flow/widgets/stats/money_delta_label.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +/// Bento preview of net worth: the current total, its change over the sampled +/// window, and a minimal sparkline. Range-independent — it always trails the +/// last [_months] months regardless of the Stats range selector. +class NetWorthTile extends StatefulWidget { + const NetWorthTile({super.key}); + + @override + State createState() => _NetWorthTileState(); +} + +class _NetWorthTileState extends State + with PrimaryCurrencyDependentState { + static const int _months = 6; + + bool busy = true; + bool loaded = false; + + List samples = []; + + @override + Widget build(BuildContext context) { + final bool hasTrend = samples.length >= 2; + final double currentAmount = samples.isEmpty ? 0.0 : samples.last; + final double firstAmount = samples.isEmpty ? 0.0 : samples.first; + + return BentoTile( + label: "tabs.stats.analytics.netWorth".t(context), + icon: Symbols.trending_up_rounded, + height: 188.0, + busy: busy && !loaded, + onTap: () => context.push("/stats/net-worth"), + child: Column( + crossAxisAlignment: .start, + children: [ + MoneyText( + Money(currentAmount, primaryCurrency), + style: context.textTheme.headlineMedium, + autoSize: true, + initiallyAbbreviated: true, + ), + const SizedBox(height: 4.0), + MoneyDeltaLabel( + delta: Money(currentAmount - firstAmount, primaryCurrency), + suffixLabel: "tabs.stats.analytics.inRange".t(context, "${_months}M"), + iconSize: 16.0, + initiallyAbbreviated: true, + suffixStyle: context.textTheme.bodySmall?.semi(context), + ), + const SizedBox(height: 12.0), + Expanded( + child: hasTrend + ? NetWorthSparkline( + samples: samples, + color: context.colorScheme.primary, + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + @override + Future fetch() async { + try { + final List accounts = ObjectBox() + .getAccounts(false) + .where((account) => account.excludeFromTotalBalance != true) + .toList(); + + final List anchors = _monthAnchors(_months); + + samples = anchors.map((anchor) { + double total = 0.0; + for (final Account account in accounts) { + total += + account.balanceAt(anchor).tryConvertAmount( + primaryCurrency, + rates, + ) ?? + 0.0; + } + return total; + }).toList(); + loaded = true; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + /// End-of-month anchors for the trailing [months] months, with the most + /// recent point anchored to "now" so the latest figure is live. + List _monthAnchors(int months) { + final DateTime now = DateTime.now(); + final List anchors = []; + + for (int i = months - 1; i >= 0; i--) { + if (i == 0) { + anchors.add(now); + } else { + anchors.add(DateTime(now.year, now.month - i + 1, 0, 23, 59, 59)); + } + } + + return anchors; + } +} diff --git a/lib/widgets/home/stats/bento/recurring_tile.dart b/lib/widgets/home/stats/bento/recurring_tile.dart new file mode 100644 index 00000000..c9872fcc --- /dev/null +++ b/lib/widgets/home/stats/bento/recurring_tile.dart @@ -0,0 +1,138 @@ +import "package:flow/data/money.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/home/stats/bento/bento_tile.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Bento preview of committed recurring outflow over the next [_windowDays] +/// days. Range-independent — recurring charges are always forward-looking. +class RecurringTile extends StatefulWidget { + const RecurringTile({super.key}); + + @override + State createState() => _RecurringTileState(); +} + +class _RecurringTileState extends State + with PrimaryCurrencyDependentState { + static const int _windowDays = 30; + + bool busy = true; + bool loaded = false; + + double outflow = 0.0; + int upcoming = 0; + + @override + Widget build(BuildContext context) { + return BentoTile( + label: "tabs.stats.analytics.recurring".t(context), + icon: Symbols.autorenew_rounded, + height: 158.0, + busy: busy && !loaded, + onTap: () => context.push("/stats/recurring"), + child: Column( + crossAxisAlignment: .start, + mainAxisAlignment: .center, + children: [ + Text( + "tabs.stats.analytics.recurring.committedShort".t(context, { + "days": _windowDays, + }), + style: context.textTheme.bodySmall?.semi(context), + ), + const SizedBox(height: 4.0), + MoneyText( + Money(outflow, primaryCurrency), + style: context.textTheme.headlineSmall, + autoSize: true, + initiallyAbbreviated: true, + ), + const SizedBox(height: 8.0), + Text( + upcoming == 0 + ? "tabs.stats.analytics.recurring.nothingUpcoming".t(context) + : "tabs.stats.analytics.recurring.upcomingCharges".t( + context, + upcoming, + ), + style: context.textTheme.bodySmall?.semi(context), + ), + ], + ), + ); + } + + @override + Future fetch() async { + try { + final DateTime now = DateTime.now(); + final TimeRange window = CustomTimeRange( + now, + now.add(const Duration(days: _windowDays)), + ); + + final query = RecurringTransactionsService().activeRecurringsQb().build(); + final List recurrings = query.find(); + query.close(); + + double totalOutflow = 0.0; + int count = 0; + + for (final RecurringTransaction recurring in recurrings) { + final Transaction? template = _decodeTemplate(recurring); + if (template == null) continue; + + final Money? money = _templateMoney(template); + if (money == null) continue; + + final List occurrences = recurring.recurrence.occurrences( + subrange: window, + ); + count += occurrences.length; + + if (template.type == TransactionType.expense) { + final double? converted = money.tryConvertAmount( + primaryCurrency, + rates, + ); + if (converted != null) { + totalOutflow += converted.abs() * occurrences.length; + } + } + } + + outflow = totalOutflow; + upcoming = count; + loaded = true; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } + + Transaction? _decodeTemplate(RecurringTransaction recurring) { + try { + return recurring.template; + } catch (_) { + return null; + } + } + + Money? _templateMoney(Transaction template) { + try { + return template.money; + } catch (_) { + return null; + } + } +} diff --git a/lib/widgets/home/stats/bento/top_categories_tile.dart b/lib/widgets/home/stats/bento/top_categories_tile.dart new file mode 100644 index 00000000..a37446a7 --- /dev/null +++ b/lib/widgets/home/stats/bento/top_categories_tile.dart @@ -0,0 +1,119 @@ +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/theme/primary_colors.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/home/stats/bento/bento_tile.dart"; +import "package:flow/widgets/home/stats/bento/category_slice_row.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// One category's spend share for the bento preview. +class _Slice { + final String name; + final double amount; + final Color color; + + const _Slice({required this.name, required this.amount, required this.color}); +} + +/// Bento preview of the top spending categories for the selected range. +class TopCategoriesTile extends StatefulWidget { + final TimeRange range; + + const TopCategoriesTile({super.key, required this.range}); + + @override + State createState() => _TopCategoriesTileState(); +} + +class _TopCategoriesTileState extends State + with PrimaryCurrencyDependentState { + static const int _maxRows = 3; + + bool busy = true; + bool loaded = false; + + List<_Slice> slices = []; + double maxAmount = 0.0; + + @override + void didUpdateWidget(TopCategoriesTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.range != oldWidget.range) fetch(); + } + + @override + Widget build(BuildContext context) { + return BentoTile( + label: "tabs.stats.analytics.topCategories".t(context), + icon: Symbols.donut_small_rounded, + height: 158.0, + busy: busy && !loaded, + onTap: () => context.push( + "/stats/category?range=${Uri.encodeQueryComponent(widget.range.encodeShort())}", + ), + child: slices.isEmpty + ? Text( + "tabs.stats.analytics.noSpendingRange".t(context), + style: context.textTheme.bodySmall?.semi(context), + ) + : Column( + crossAxisAlignment: .start, + mainAxisAlignment: .spaceEvenly, + children: slices + .map( + (slice) => CategorySliceRow( + name: slice.name, + amount: slice.amount, + color: slice.color, + maxAmount: maxAmount, + currency: primaryCurrency, + ), + ) + .toList(), + ), + ); + } + + @override + Future fetch() async { + try { + final analytics = await ObjectBox().flowByCategories(range: widget.range); + + final List<_Slice> next = []; + int colorIndex = 0; + + for (final entry in analytics.flow.entries) { + final flow = entry.value; + final double expense = flow + .merge(primaryCurrency, rates) + .totalExpense + .amount + .abs(); + if (expense <= 0) continue; + + final String name = + flow.associatedData?.name ?? + "tabs.stats.analytics.uncategorized".tr(); + final Color color = + flow.associatedData?.colorScheme?.primary ?? + accentColors[colorIndex++ % accentColors.length]; + + next.add(_Slice(name: name, amount: expense, color: color)); + } + + next.sort((a, b) => b.amount.compareTo(a.amount)); + + slices = next.take(_maxRows).toList(); + maxAmount = slices.isEmpty ? 0.0 : slices.first.amount; + loaded = true; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } +} diff --git a/lib/widgets/home/stats/bento/wrapped_tile.dart b/lib/widgets/home/stats/bento/wrapped_tile.dart new file mode 100644 index 00000000..2e0e7159 --- /dev/null +++ b/lib/widgets/home/stats/bento/wrapped_tile.dart @@ -0,0 +1,130 @@ +import "package:flow/data/money.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/utils/primary_currency_dependent_state.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_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// A slim accent banner inviting the user into their monthly "wrapped" recap. +/// +/// Deliberately styled apart from the data tiles — wrapped is a seasonal +/// moment, not a daily chart. Carries a one-line teaser built from this +/// month's transactions. +class WrappedTile extends StatefulWidget { + const WrappedTile({super.key}); + + @override + State createState() => _WrappedTileState(); +} + +class _WrappedTileState extends State + with PrimaryCurrencyDependentState { + int entryCount = 0; + double biggestExpense = 0.0; + + @override + Widget build(BuildContext context) { + final String month = DateTime.now().toMoment().format("MMMM"); + final Color accent = context.colorScheme.primary; + + final String teaser = entryCount == 0 + ? "tabs.stats.analytics.wrapped.tileTeaserEmpty".t(context) + : (entryCount == 1 + ? "tabs.stats.analytics.wrapped.tileTeaser.one" + : "tabs.stats.analytics.wrapped.tileTeaser") + .t(context, { + "count": entryCount, + "amount": Money( + biggestExpense, + primaryCurrency, + ).formattedCompact, + }); + + return Surface( + builder: (context) => InkWell( + borderRadius: .all(Radius.circular(16.0)), + onTap: () => context.push("/stats/wrapped"), + child: Container( + decoration: BoxDecoration( + borderRadius: .all(Radius.circular(16.0)), + gradient: LinearGradient( + begin: AlignmentDirectional.centerStart, + end: AlignmentDirectional.centerEnd, + colors: [accent.withAlpha(0x2e), accent.withAlpha(0x08)], + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 14.0), + child: Row( + children: [ + Icon(Symbols.auto_awesome_rounded, color: accent, size: 22.0), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + "tabs.stats.analytics.wrapped.tileTitle".t(context, month), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + teaser, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.semi(context), + ), + ], + ), + ), + const SizedBox(width: 8.0), + Icon( + Symbols.chevron_right_rounded, + color: context.flowColors.semi, + size: 20.0, + ), + ], + ), + ), + ), + ); + } + + @override + Future fetch() async { + try { + final List transactions = await ObjectBox() + .transcationsByRange(TimeRange.thisMonth(), includeTransfers: false); + + int count = 0; + double biggest = 0.0; + + for (final Transaction transaction in transactions) { + count++; + if (transaction.type != TransactionType.expense) continue; + + final double? converted = transaction.money.tryConvertAmount( + primaryCurrency, + rates, + ); + if (converted == null) continue; + + final double magnitude = converted.abs(); + if (magnitude > biggest) biggest = magnitude; + } + + entryCount = count; + biggestExpense = biggest; + } finally { + if (mounted) setState(() {}); + } + } +} diff --git a/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart b/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart index cb27ab6d..3a7cf18f 100644 --- a/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart +++ b/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart @@ -7,7 +7,7 @@ import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:material_symbols_icons_flow/symbols.dart"; -/// Pops with [IconFlowIcon] or [null] +/// Pops with [IconFlowIcon], [SimpleIconFlowIcon], or [null] class SelectIconFlowIconSheet extends StatefulWidget { final FlowIconData? initialValue; @@ -24,15 +24,17 @@ class _SelectIconFlowIconSheetState extends State String _query = ""; - IconFlowIcon? value; + FlowIconData? value; @override void initState() { super.initState(); - value = widget.initialValue is IconFlowIcon - ? widget.initialValue as IconFlowIcon - : null; + value = switch (widget.initialValue) { + IconFlowIcon icon => icon, + SimpleIconFlowIcon icon => icon, + _ => null, + }; _controller = TabController(length: 2, vsync: this); } @@ -45,7 +47,9 @@ class _SelectIconFlowIconSheetState extends State @override Widget build(BuildContext context) { - final List simpleIconsResult = querySimpleIcons(_query); + final List> simpleIconsResult = querySimpleIcons( + _query, + ); final List materialSymbolsResult = queryMaterialSymbols(_query); return ModalSheet.scrollable( @@ -89,8 +93,8 @@ class _SelectIconFlowIconSheetState extends State children: [ GridView.builder( itemBuilder: (context, index) => IconButton( - onPressed: () => updateIcon(simpleIconsResult[index]), - icon: Icon(simpleIconsResult[index]), + onPressed: () => updateSimpleIcon(simpleIconsResult[index].key), + icon: Icon(simpleIconsResult[index].value), iconSize: 48.0, ), itemCount: simpleIconsResult.length, @@ -126,4 +130,9 @@ class _SelectIconFlowIconSheetState extends State value = IconFlowIcon(iconData); setState(() {}); } + + void updateSimpleIcon(String slug) { + value = SimpleIconFlowIcon(slug); + setState(() {}); + } } diff --git a/lib/widgets/stats/cash_flow/cash_flow_figure.dart b/lib/widgets/stats/cash_flow/cash_flow_figure.dart new file mode 100644 index 00000000..f1311731 --- /dev/null +++ b/lib/widgets/stats/cash_flow/cash_flow_figure.dart @@ -0,0 +1,69 @@ +import "package:flow/data/money.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flutter/material.dart"; + +/// A colored dot, label, and amount for one side of the cash-flow tile. When +/// [alignEnd] is set the order mirrors so the dot hugs the trailing edge. +class CashFlowFigure extends StatelessWidget { + final String label; + final Money money; + final Color color; + final bool alignEnd; + + const CashFlowFigure({ + super.key, + required this.label, + required this.money, + required this.color, + this.alignEnd = false, + }); + + @override + Widget build(BuildContext context) { + final Widget dot = _Dot(color: color); + final Widget labelText = Text( + label, + style: context.textTheme.labelSmall?.semi(context), + ); + final Widget value = MoneyText( + money, + style: context.textTheme.titleSmall?.copyWith(color: color), + initiallyAbbreviated: true, + ); + + return Row( + mainAxisSize: .min, + children: alignEnd + ? [ + value, + const SizedBox(width: 8.0), + labelText, + const SizedBox(width: 5.0), + dot, + ] + : [ + dot, + const SizedBox(width: 5.0), + labelText, + const SizedBox(width: 8.0), + value, + ], + ); + } +} + +class _Dot extends StatelessWidget { + final Color color; + + const _Dot({required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: 8.0, + height: 8.0, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} diff --git a/lib/widgets/stats/cash_flow/cash_flow_flow_bar.dart b/lib/widgets/stats/cash_flow/cash_flow_flow_bar.dart new file mode 100644 index 00000000..d790aa1f --- /dev/null +++ b/lib/widgets/stats/cash_flow/cash_flow_flow_bar.dart @@ -0,0 +1,42 @@ +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +/// Single stacked bar whose income and expense segments are sized by their +/// share of the period's total movement. Degrades gracefully when either side +/// is zero, never dividing by zero. +class CashFlowFlowBar extends StatelessWidget { + final double income; + final double expense; + + const CashFlowFlowBar({super.key, required this.income, required this.expense}); + + @override + Widget build(BuildContext context) { + final double total = income + expense; + final int incomeFlex = total <= 0 ? 1 : (income / total * 1000).round(); + final int expenseFlex = total <= 0 ? 1 : (expense / total * 1000).round(); + + final Color incomeColor = context.flowColors.income; + final Color expenseColor = context.flowColors.expense; + + return ClipRRect( + borderRadius: .all(Radius.circular(6.0)), + child: SizedBox( + height: 12.0, + child: Row( + children: [ + Expanded( + flex: incomeFlex == 0 ? 1 : incomeFlex, + child: ColoredBox(color: incomeColor), + ), + const SizedBox(width: 3.0), + Expanded( + flex: expenseFlex == 0 ? 1 : expenseFlex, + child: ColoredBox(color: expenseColor), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/stats/cash_flow/cash_flow_legend.dart b/lib/widgets/stats/cash_flow/cash_flow_legend.dart new file mode 100644 index 00000000..c22d96a2 --- /dev/null +++ b/lib/widgets/stats/cash_flow/cash_flow_legend.dart @@ -0,0 +1,53 @@ +import "package:flow/data/money.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/analytics/sankey_diagram.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flutter/material.dart"; + +/// A color-swatch + label + amount legend for one side of the cash-flow Sankey. +class CashFlowLegend extends StatelessWidget { + final List data; + final String currency; + + const CashFlowLegend({super.key, required this.data, required this.currency}); + + @override + Widget build(BuildContext context) { + return Frame( + child: Column( + children: data.map((datum) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + Container( + width: 12.0, + height: 12.0, + decoration: BoxDecoration( + color: datum.color, + borderRadius: .all(Radius.circular(3.0)), + ), + ), + const SizedBox(width: 10.0), + Expanded( + child: Text( + datum.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 8.0), + MoneyText( + Money(datum.value, currency), + style: context.textTheme.bodyMedium?.semi(context), + ), + ], + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/widgets/stats/cash_flow/cash_flow_summary.dart b/lib/widgets/stats/cash_flow/cash_flow_summary.dart new file mode 100644 index 00000000..ac5d3677 --- /dev/null +++ b/lib/widgets/stats/cash_flow/cash_flow_summary.dart @@ -0,0 +1,117 @@ +import "package:flow/data/money.dart"; +import "package:flow/l10n/extensions.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/surface.dart"; +import "package:flow/widgets/stats/cash_flow/cash_flow_figure.dart"; +import "package:flow/widgets/stats/cash_flow/cash_flow_flow_bar.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +/// Cash-flow hero summary: the net result (saved or overspent) headlined above +/// an in-vs-out proportion bar and the two side figures. +/// +/// Mirrors the bento [CashFlowTile] preview so the full page and its dashboard +/// tile read as the same thing, just at different sizes. +class CashFlowSummary extends StatelessWidget { + final Money income; + final Money expense; + final Money net; + + const CashFlowSummary({ + super.key, + required this.income, + required this.expense, + required this.net, + }); + + @override + Widget build(BuildContext context) { + final bool saved = net.amount >= 0; + final Color netColor = saved + ? context.flowColors.income + : context.flowColors.expense; + + return Frame( + child: Surface( + builder: (context) => Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10.0), + decoration: BoxDecoration( + color: netColor.withAlpha(0x24), + shape: BoxShape.circle, + ), + child: Icon( + saved + ? Symbols.savings_rounded + : Symbols.trending_down_rounded, + color: netColor, + size: 22.0, + ), + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + (saved + ? "tabs.stats.analytics.saved" + : "tabs.stats.analytics.overspent") + .t(context), + style: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSecondary.withAlpha( + 0x99, + ), + ), + ), + const SizedBox(height: 2.0), + MoneyText( + saved ? net : -net, + autoSize: true, + maxLines: 1, + tapToToggleAbbreviation: true, + style: context.textTheme.displaySmall?.copyWith( + color: netColor, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20.0), + CashFlowFlowBar(income: income.amount, expense: expense.amount), + const SizedBox(height: 12.0), + Row( + children: [ + CashFlowFigure( + label: "tabs.stats.analytics.in".t(context), + money: income, + color: context.flowColors.income, + ), + const Spacer(), + CashFlowFigure( + label: "tabs.stats.analytics.out".t(context), + money: expense, + color: context.flowColors.expense, + alignEnd: true, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/stats/emphasized_text.dart b/lib/widgets/stats/emphasized_text.dart new file mode 100644 index 00000000..e635bd4c --- /dev/null +++ b/lib/widgets/stats/emphasized_text.dart @@ -0,0 +1,46 @@ +import "package:flutter/material.dart"; + +/// Renders a localized [template] containing a single `{value}` token, with the +/// substituted [value] styled via [valueStyle] (bold by default) and the rest +/// of the sentence in the ambient text style. +/// +/// Lets narrative analytics copy stay translatable — the whole sentence lives +/// in one l10n key — while still emphasizing the dynamic part. +class EmphasizedText extends StatelessWidget { + final String template; + final String value; + final TextStyle? valueStyle; + + const EmphasizedText({ + super.key, + required this.template, + required this.value, + this.valueStyle, + }); + + @override + Widget build(BuildContext context) { + const String token = "{value}"; + final int index = template.indexOf(token); + + if (index < 0) { + return Text(template.replaceAll(token, value)); + } + + final String before = template.substring(0, index); + final String after = template.substring(index + token.length); + + return Text.rich( + TextSpan( + children: [ + if (before.isNotEmpty) TextSpan(text: before), + TextSpan( + text: value, + style: valueStyle ?? const TextStyle(fontWeight: FontWeight.bold), + ), + if (after.isNotEmpty) TextSpan(text: after), + ], + ), + ); + } +} diff --git a/lib/widgets/stats/missing_rates_notice.dart b/lib/widgets/stats/missing_rates_notice.dart new file mode 100644 index 00000000..fc834e52 --- /dev/null +++ b/lib/widgets/stats/missing_rates_notice.dart @@ -0,0 +1,23 @@ +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flutter/material.dart"; + +/// Inline warning shown under analytics content when some amounts or balances +/// couldn't be converted to the primary currency (missing exchange rates). +class MissingRatesNotice extends StatelessWidget { + final String message; + + const MissingRatesNotice({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + return Frame( + child: Text( + message, + style: context.textTheme.bodySmall?.copyWith( + color: context.flowColors.expense, + ), + ), + ); + } +} diff --git a/lib/widgets/stats/money_delta_label.dart b/lib/widgets/stats/money_delta_label.dart new file mode 100644 index 00000000..678f340d --- /dev/null +++ b/lib/widgets/stats/money_delta_label.dart @@ -0,0 +1,62 @@ +import "package:flow/data/money.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +/// A trending up/down arrow, the absolute [delta] amount, and a trailing +/// [suffixLabel] (e.g. "in this year"), colored by the sign of the delta. +/// +/// Shared by the net worth page header and the net worth bento tile so both +/// render the change indicator identically. +class MoneyDeltaLabel extends StatelessWidget { + final Money delta; + final String suffixLabel; + final double iconSize; + final bool initiallyAbbreviated; + final TextStyle? suffixStyle; + + const MoneyDeltaLabel({ + super.key, + required this.delta, + required this.suffixLabel, + this.iconSize = 18.0, + this.initiallyAbbreviated = false, + this.suffixStyle, + }); + + @override + Widget build(BuildContext context) { + final bool up = delta.amount >= 0; + final Color color = up + ? context.flowColors.income + : context.flowColors.expense; + + return Row( + mainAxisSize: .min, + children: [ + Icon( + up ? Symbols.trending_up_rounded : Symbols.trending_down_rounded, + color: color, + size: iconSize, + ), + const SizedBox(width: 4.0), + MoneyText( + delta, + displayAbsoluteAmount: true, + initiallyAbbreviated: initiallyAbbreviated, + style: context.textTheme.bodyMedium?.copyWith(color: color), + ), + const SizedBox(width: 6.0), + Flexible( + child: Text( + suffixLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: suffixStyle ?? context.textTheme.bodyMedium?.semi(context), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/stats/net_worth/account_balance_share.dart b/lib/widgets/stats/net_worth/account_balance_share.dart new file mode 100644 index 00000000..d61c0231 --- /dev/null +++ b/lib/widgets/stats/net_worth/account_balance_share.dart @@ -0,0 +1,9 @@ +import "package:flow/entity/account.dart"; + +/// One account's current balance, as a slice of total net worth. +class AccountBalanceShare { + final Account account; + final double amount; + + const AccountBalanceShare(this.account, this.amount); +} diff --git a/lib/widgets/stats/net_worth/account_share_tile.dart b/lib/widgets/stats/net_worth/account_share_tile.dart new file mode 100644 index 00000000..dfa7921e --- /dev/null +++ b/lib/widgets/stats/net_worth/account_share_tile.dart @@ -0,0 +1,86 @@ +import "package:flow/data/money.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/flow_icon.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/stats/net_worth/account_balance_share.dart"; +import "package:flutter/material.dart"; + +/// One account row in the net worth "By account" breakdown: an icon, the +/// account name and balance, and a bar sized by the account's share of gross +/// holdings ([gross]). Debts are tinted with the expense color. +class AccountShareTile extends StatelessWidget { + final AccountBalanceShare share; + final double gross; + final String primaryCurrency; + + const AccountShareTile({ + super.key, + required this.share, + required this.gross, + required this.primaryCurrency, + }); + + @override + Widget build(BuildContext context) { + final bool negative = share.amount < 0; + final Color color = negative + ? context.flowColors.expense + : context.flowColors.income; + final double fraction = gross == 0 ? 0.0 : share.amount.abs() / gross; + + return Frame( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + children: [ + FlowIcon( + share.account.icon, + plated: true, + colorScheme: share.account.colorScheme, + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + Expanded( + child: Text( + share.account.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall, + ), + ), + const SizedBox(width: 8.0), + MoneyText( + Money(share.amount, primaryCurrency), + style: context.textTheme.titleSmall?.copyWith( + color: negative ? color : null, + ), + ), + ], + ), + const SizedBox(height: 6.0), + ClipRRect( + borderRadius: .all(Radius.circular(4.0)), + child: LinearProgressIndicator( + value: fraction, + minHeight: 6.0, + backgroundColor: context.colorScheme.onSurface.withAlpha( + 0x14, + ), + color: color, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/stats/net_worth/net_worth_chart.dart b/lib/widgets/stats/net_worth/net_worth_chart.dart new file mode 100644 index 00000000..d0de9568 --- /dev/null +++ b/lib/widgets/stats/net_worth/net_worth_chart.dart @@ -0,0 +1,200 @@ +import "dart:math" as math; + +import "package:fl_chart/fl_chart.dart"; +import "package:flow/data/money.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/stats/net_worth/net_worth_sample.dart"; +import "package:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// The net worth trend line for a sequence of [NetWorthSample]s spaced by +/// [unit]. Frames itself to the data range so variation is visible, and only +/// draws a zero baseline when the range actually crosses zero. +class NetWorthChart extends StatelessWidget { + final List samples; + final DurationUnit unit; + final String primaryCurrency; + + const NetWorthChart({ + super.key, + required this.samples, + required this.unit, + required this.primaryCurrency, + }); + + /// Bottom-axis label format for the sampling [unit]. Kept short so adjacent + /// labels never collapse to the same string within the window. + String get _axisFormat => switch (unit) { + DurationUnit.microsecond || + DurationUnit.millisecond || + DurationUnit.second || + DurationUnit.minute || + DurationUnit.hour => "HH:mm", + DurationUnit.day || DurationUnit.week => "MMM D", + DurationUnit.month => "MMM", + DurationUnit.year => "YYYY", + }; + + /// Tooltip format for the sampling [unit]. Always disambiguates the year so + /// the tooltip never reads as a bare repeated "2026". + String get _tooltipFormat => switch (unit) { + DurationUnit.microsecond || + DurationUnit.millisecond || + DurationUnit.second || + DurationUnit.minute || + DurationUnit.hour => "MMM D, HH:mm", + DurationUnit.day || DurationUnit.week => "MMM D, YYYY", + DurationUnit.month => "MMM YYYY", + DurationUnit.year => "YYYY", + }; + + @override + Widget build(BuildContext context) { + final Color line = context.colorScheme.primary; + + final double maxY = samples + .map((s) => s.amount) + .reduce((a, b) => a > b ? a : b); + final double minY = samples + .map((s) => s.amount) + .reduce((a, b) => a < b ? a : b); + + // Frame the chart to the actual data range (plus a little padding) so a + // net worth that stays positive still shows its variation instead of + // being flattened against a 0 baseline. The 0 line is drawn separately + // only when the range actually crosses zero. + final double span = (maxY - minY).abs(); + final double pad = span == 0 ? maxY.abs() * 0.1 + 1 : span * 0.12; + final double resolvedMinY = minY - pad; + final double resolvedMaxY = maxY + pad; + + return LineChart( + LineChartData( + minX: 0.0, + maxX: (samples.length - 1).toDouble(), + minY: resolvedMinY, + maxY: resolvedMaxY, + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => context.colorScheme.onPrimary, + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + final NetWorthSample sample = samples[spot.x.toInt()]; + return LineTooltipItem( + "${sample.anchor.toMoment().format(_tooltipFormat)}\n" + "${Money(sample.amount, primaryCurrency).formattedCompact}", + TextStyle( + color: line, + fontWeight: FontWeight.bold, + fontSize: 13.0, + ), + ); + }).toList(); + }, + ), + ), + titlesData: FlTitlesData( + bottomTitles: AxisTitles(sideTitles: _bottomTitles()), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 48.0, + getTitlesWidget: (value, meta) { + if (value != meta.min && value != meta.max) { + return const SizedBox.shrink(); + } + return MoneyText( + Money(value, primaryCurrency), + initiallyAbbreviated: true, + autoSize: true, + style: context.textTheme.labelSmall, + ); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData(show: true, drawVerticalLine: false), + extraLinesData: ExtraLinesData( + horizontalLines: [ + if (resolvedMinY < 0) + HorizontalLine( + y: 0.0, + color: context.colorScheme.onSurface.withAlpha(0x30), + strokeWidth: 1.0, + ), + ], + ), + 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, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + barWidth: 2.5, + color: line, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + spots: samples + .asMap() + .entries + .map((e) => FlSpot(e.key.toDouble(), e.value.amount)) + .toList(), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [line.withAlpha(0x40), line.withAlpha(0x00)], + ), + ), + ), + ], + ), + ); + } + + SideTitles _bottomTitles() { + // Aim for ~4 labels regardless of window length. + final int step = math.max(1, (samples.length / 4).floor()); + + return SideTitles( + showTitles: true, + interval: 1.0, + reservedSize: 28.0, + getTitlesWidget: (value, meta) { + final int index = value.round(); + if (index < 0 || index >= samples.length) { + return const SizedBox.shrink(); + } + if (index % step != 0 && index != samples.length - 1) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Text( + samples[index].anchor.toMoment().format(_axisFormat), + style: const TextStyle(fontSize: 11.0), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/stats/net_worth/net_worth_sample.dart b/lib/widgets/stats/net_worth/net_worth_sample.dart new file mode 100644 index 00000000..b86e1fb1 --- /dev/null +++ b/lib/widgets/stats/net_worth/net_worth_sample.dart @@ -0,0 +1,7 @@ +/// A single net-worth datapoint: the total at a point in time. +class NetWorthSample { + final DateTime anchor; + final double amount; + + const NetWorthSample(this.anchor, this.amount); +} diff --git a/lib/widgets/stats/recurring/recurring_summary_header.dart b/lib/widgets/stats/recurring/recurring_summary_header.dart new file mode 100644 index 00000000..c4734214 --- /dev/null +++ b/lib/widgets/stats/recurring/recurring_summary_header.dart @@ -0,0 +1,65 @@ +import "package:auto_size_text/auto_size_text.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/flow_card.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flutter/material.dart"; + +/// Hero header for the recurring page: the expected recurring [income] and +/// [expense] over the selected range, shown with the universal income/expense +/// [FlowCard]s, plus how many charges are projected. +class RecurringSummaryHeader extends StatelessWidget { + final Money income; + final Money expense; + final int count; + + const RecurringSummaryHeader({ + super.key, + required this.income, + required this.expense, + required this.count, + }); + + @override + Widget build(BuildContext context) { + final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); + + return Frame( + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + Expanded( + child: FlowCard( + flow: income, + type: TransactionType.income, + autoSizeGroup: autoSizeGroup, + ), + ), + const SizedBox(width: 12.0), + Expanded( + child: FlowCard( + flow: expense, + type: TransactionType.expense, + autoSizeGroup: autoSizeGroup, + ), + ), + ], + ), + if (count > 0) ...[ + const SizedBox(height: 8.0), + Text( + "tabs.stats.analytics.recurring.upcomingCharges".t(context, { + "count": count, + }), + style: context.textTheme.bodyMedium?.semi(context), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/stats/stats_app_bar.dart b/lib/widgets/stats/stats_app_bar.dart new file mode 100644 index 00000000..9e64c020 --- /dev/null +++ b/lib/widgets/stats/stats_app_bar.dart @@ -0,0 +1,29 @@ +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +/// The shared app bar for full-screen analytics pages. +/// +/// A flat surface bar that only lifts a hairline shadow once content scrolls +/// under it. Extracted so the six stats pages share one definition instead of +/// each repeating the same elevation/tint configuration. +class StatsAppBar extends StatelessWidget implements PreferredSizeWidget { + final String title; + + const StatsAppBar({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return AppBar( + title: Text(title), + elevation: 0.0, + scrolledUnderElevation: 1.0, + centerTitle: false, + shadowColor: context.colorScheme.onSurface.withAlpha(0x40), + backgroundColor: context.colorScheme.surface, + surfaceTintColor: kTransparent, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/lib/widgets/stats/stats_empty_state.dart b/lib/widgets/stats/stats_empty_state.dart new file mode 100644 index 00000000..ef5d78cc --- /dev/null +++ b/lib/widgets/stats/stats_empty_state.dart @@ -0,0 +1,25 @@ +import "package:flow/widgets/general/frame.dart"; +import "package:flutter/material.dart"; + +/// Centered placeholder shown inside a [Frame] when an analytics section has no +/// data to display. +class StatsEmptyState extends StatelessWidget { + final String message; + final double verticalPadding; + + const StatsEmptyState({ + super.key, + required this.message, + this.verticalPadding = 48.0, + }); + + @override + Widget build(BuildContext context) { + return Frame( + child: Padding( + padding: EdgeInsets.symmetric(vertical: verticalPadding), + child: Center(child: Text(message)), + ), + ); + } +} diff --git a/lib/widgets/stats/wrapped/mini_bars.dart b/lib/widgets/stats/wrapped/mini_bars.dart new file mode 100644 index 00000000..bf1c4dd2 --- /dev/null +++ b/lib/widgets/stats/wrapped/mini_bars.dart @@ -0,0 +1,50 @@ +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +/// A compact bar chart of [values] with the final (most recent) bar drawn in +/// [highlightColor] and the rest muted. Used inside wrapped insight cards. +class MiniBars extends StatelessWidget { + final List values; + final Color highlightColor; + + const MiniBars({super.key, required this.values, required this.highlightColor}); + + @override + Widget build(BuildContext context) { + if (values.isEmpty) return const SizedBox.shrink(); + + final double max = values.reduce((a, b) => a > b ? a : b); + final Color base = context.colorScheme.onSurface.withAlpha(0x33); + + return SizedBox( + height: 44.0, + child: Row( + crossAxisAlignment: .end, + children: values.asMap().entries.map((entry) { + final bool isLast = entry.key == values.length - 1; + final double factor = max <= 0 ? 0.0 : entry.value / max; + + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + heightFactor: factor.clamp(0.05, 1.0), + child: Container( + decoration: BoxDecoration( + color: isLast ? highlightColor : base, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4.0), + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 515e8dc2..8da11147 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.23.0+345" +version: "0.23.0+346" environment: sdk: ">=3.10.0 <4.0.0" diff --git a/test/l10n/json_integrity_test.dart b/test/l10n/json_integrity_test.dart index 764b219e..a4e99245 100644 --- a/test/l10n/json_integrity_test.dart +++ b/test/l10n/json_integrity_test.dart @@ -12,11 +12,7 @@ List getKeys(File file) { const _pluralSuffixes = [".zero", ".one", ".two", ".few", ".many", ".other"]; -bool _isPluralVariant(String key) { - return _pluralSuffixes.any((suffix) => key.endsWith(suffix)); -} - -String _baseKey(String key) { +String _strippedBaseKey(String key) { for (final suffix in _pluralSuffixes) { if (key.endsWith(suffix)) { return key.substring(0, key.length - suffix.length); @@ -25,6 +21,18 @@ String _baseKey(String key) { return key; } +/// A key is only a plural variant when it ends in a plural suffix *and* the +/// stripped base key actually exists in the base (en) file. This avoids +/// misclassifying real, standalone keys that merely happen to end in a plural +/// suffix (e.g. `tabs.profile.other`, `tabs.stats.analytics.other`) as plural +/// variants. +bool _isPluralVariant(String key, Set baseFileKeys) { + if (!_pluralSuffixes.any((suffix) => key.endsWith(suffix))) return false; + return baseFileKeys.contains(_strippedBaseKey(key)); +} + +String _baseKey(String key) => _strippedBaseKey(key); + void main() { final Directory directory = Directory("assets/l10n"); @@ -39,6 +47,7 @@ void main() { }); final List keys = getKeys(baseFile); + final Set baseFileKeys = keys.toSet(); test("No duplicate keys in base file", () { final Set uniqueKeys = keys.toSet(); @@ -46,7 +55,7 @@ void main() { }); final List baseKeys = - keys.where((k) => !_isPluralVariant(k)).toList(); + keys.where((k) => !_isPluralVariant(k, baseFileKeys)).toList(); final Set baseKeySet = baseKeys.toSet(); for (final entry in directory.listSync()) { @@ -59,7 +68,7 @@ void main() { test("File $name has all base keys in same order", () { final languageKeys = getKeys(entry); final languageBaseKeys = - languageKeys.where((k) => !_isPluralVariant(k)).toList(); + languageKeys.where((k) => !_isPluralVariant(k, baseFileKeys)).toList(); expect(languageBaseKeys.length, baseKeys.length, reason: "Key count mismatch"); @@ -73,7 +82,7 @@ void main() { for (int i = 0; i < languageKeys.length; i++) { final key = languageKeys[i]; - if (!_isPluralVariant(key)) continue; + if (!_isPluralVariant(key, baseFileKeys)) continue; final base = _baseKey(key); expect(baseKeySet.contains(base), true, From 297d30c86bb59c41c4310f3d7cabc7f7a7727dc3 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 7 Jun 2026 15:24:47 +0800 Subject: [PATCH 4/6] Update pubspec.lock --- pubspec.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index df6e5507..02003d7f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1170,10 +1170,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1895,26 +1895,26 @@ packages: dependency: "direct dev" description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.17" timezone: dependency: "direct main" description: @@ -2031,10 +2031,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.2.0" vm_service: dependency: transitive description: From 5a4ff0280c66b94f84e6bce9642c2adc535a6628 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 8 Jun 2026 17:02:44 +0800 Subject: [PATCH 5/6] analytics overhaul test 3 --- assets/l10n/ar.json | 10 + assets/l10n/be_BY.json | 10 + assets/l10n/cs_CZ.json | 10 + assets/l10n/de_DE.json | 10 + assets/l10n/en.json | 10 + assets/l10n/es_ES.json | 10 + assets/l10n/fa_IR.json | 10 + assets/l10n/fr_FR.json | 10 + assets/l10n/it_IT.json | 10 + assets/l10n/mn_MN.json | 10 + assets/l10n/pl_PL.json | 10 + assets/l10n/ru_RU.json | 10 + assets/l10n/tr_TR.json | 10 + assets/l10n/uk_UA.json | 10 + assets/l10n/zh_TW.json | 10 + lib/prefs/local_preferences.dart | 12 + lib/routes.dart | 5 + lib/routes/home/profile_tab.dart | 52 +-- lib/routes/home/stats_tab.dart | 399 +----------------- lib/routes/stats/cash_flow_page.dart | 107 ++++- lib/routes/stats/insights_page.dart | 60 +++ lib/routes/stats/net_worth_page.dart | 9 +- lib/routes/stats/recurring_page.dart | 121 ++++-- lib/routes/stats/spending_map_page.dart | 14 +- lib/routes/stats/wrapped_page.dart | 126 ++++-- lib/theme/color_themes/flow/flow_lights.dart | 8 +- lib/theme/color_themes/monochrome.dart | 6 +- lib/theme/helpers.dart | 11 + lib/widgets/analytics/bullet_chart.dart | 33 +- lib/widgets/analytics/insight_card.dart | 14 +- lib/widgets/analytics/spending_heatmap.dart | 19 +- lib/widgets/analytics/weekday_bars.dart | 2 +- .../home/stats/bento/analytics_bento.dart | 15 +- .../home/stats/bento/net_worth_tile.dart | 12 +- lib/widgets/home/stats/bento/pace_tile.dart | 151 +++++++ .../home/stats/bento/recurring_tile.dart | 19 +- .../home/stats/bento/top_categories_tile.dart | 8 +- .../stats/cash_flow/cash_flow_figure.dart | 21 +- .../stats/cash_flow/cash_flow_summary.dart | 55 ++- lib/widgets/transaction_list_tile.dart | 38 ++ pubspec.lock | 24 +- pubspec.yaml | 2 +- 42 files changed, 904 insertions(+), 589 deletions(-) create mode 100644 lib/routes/stats/insights_page.dart create mode 100644 lib/widgets/home/stats/bento/pace_tile.dart diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index ef70cbe3..be09e1ac 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -658,6 +658,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "تعذر تحميل التدفق النقدي.", "tabs.stats.analytics.cashFlow.noMovement": "لم يتحرك أي نقود في هذه الفترة.", "tabs.stats.analytics.down": "انخفاض", + "tabs.stats.analytics.heatmap.less": "أقل", + "tabs.stats.analytics.heatmap.more": "أكثر", "tabs.stats.analytics.in": "وارد", "tabs.stats.analytics.inRange": "في {}", "tabs.stats.analytics.income": "الدخل", @@ -681,6 +683,10 @@ "tabs.stats.analytics.other": "أخرى", "tabs.stats.analytics.out": "صادر", "tabs.stats.analytics.overspent": "تجاوز الإنفاق", + "tabs.stats.analytics.pace": "الوتيرة", + "tabs.stats.analytics.pace.perDay": "متوسط / يوم", + "tabs.stats.analytics.pace.projected": "متوقع", + "tabs.stats.analytics.pace.totalSpent": "إجمالي المصروفات", "tabs.stats.analytics.recurring": "دورية", "tabs.stats.analytics.recurring.activeSummary": "{count} متكررة · خلال {days} يومًا قادمًا", "tabs.stats.analytics.recurring.committedOutflow": "التدفقات الخارجة الملتزمة", @@ -688,8 +694,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "معاملة دورية", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} أخرى غير معروضة", "tabs.stats.analytics.recurring.none": "لم يتم إعداد معاملات دورية.", + "tabs.stats.analytics.recurring.notLoggedYet": "لم يتم تسجيل هذا بعد — إنه توقع مستقبلي.", "tabs.stats.analytics.recurring.nothingDue": "لا شيء مستحق في الأيام الـ {days} القادمة.", "tabs.stats.analytics.recurring.nothingUpcoming": "لا شيء قادم", + "tabs.stats.analytics.recurring.projectedTitle": "الإجماليات المتوقعة", + "tabs.stats.analytics.recurring.projectionsNote": "تم التقدير بناءً على معاملاتك المتكررة. اضغط على معاملة مسجلة لفتح إدخالها.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} مدفوعات قادمة", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} مدفوعات قادمة", "tabs.stats.analytics.rhythm": "الإيقاع", @@ -701,6 +710,7 @@ "tabs.stats.analytics.uncategorized": "غير مصنّف", "tabs.stats.analytics.untitled": "بلا عنوان", "tabs.stats.analytics.up": "ارتفاع", + "tabs.stats.analytics.wrapped": "ملخص", "tabs.stats.analytics.wrapped.biggest": "الأكبر: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} هذا الشهر مقابل {typical} المعتاد", "tabs.stats.analytics.wrapped.categoryTrend": "{name} هو {direction} بمقدار {value} مقارنة بمتوسط الثلاثة أشهر.", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index a13d7806..f1c227b2 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -656,6 +656,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Не ўдалося загрузіць паток грашовых сродкаў.", "tabs.stats.analytics.cashFlow.noMovement": "За гэты перыяд руху сродкаў не было.", "tabs.stats.analytics.down": "зніжэнне", + "tabs.stats.analytics.heatmap.less": "Менш", + "tabs.stats.analytics.heatmap.more": "Больш", "tabs.stats.analytics.in": "Прыход", "tabs.stats.analytics.inRange": "у {}", "tabs.stats.analytics.income": "Даход", @@ -679,6 +681,10 @@ "tabs.stats.analytics.other": "Іншае", "tabs.stats.analytics.out": "Расход", "tabs.stats.analytics.overspent": "Ператрачана", + "tabs.stats.analytics.pace": "Тэмп", + "tabs.stats.analytics.pace.perDay": "У сярэднім за дзень", + "tabs.stats.analytics.pace.projected": "Прагназуецца", + "tabs.stats.analytics.pace.totalSpent": "Усяго выдаткавана", "tabs.stats.analytics.recurring": "Паўторныя", "tabs.stats.analytics.recurring.activeSummary": "{count} паўторных · на наступныя {days} дзён", "tabs.stats.analytics.recurring.committedOutflow": "Фіксаваныя выдаткі", @@ -686,8 +692,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Паўторная транзакцыя", "tabs.stats.analytics.recurring.moreNotShown": "+ яшчэ {count} не паказана", "tabs.stats.analytics.recurring.none": "Не наладжана ніводнай паўторнай транзакцыі.", + "tabs.stats.analytics.recurring.notLoggedYet": "Гэта яшчэ не зарэгістравана — гэта папярэдні прагноз.", "tabs.stats.analytics.recurring.nothingDue": "У бліжэйшыя {days} дзён нічога не патрабуецца.", "tabs.stats.analytics.recurring.nothingUpcoming": "Нічога не запланавана", + "tabs.stats.analytics.recurring.projectedTitle": "Прагназаваныя сумы", + "tabs.stats.analytics.recurring.projectionsNote": "Прагнозы заснаваныя на вашых рэгулярных транзакцыях. Націсніце на зарэгістраваную транзакцыю, каб адкрыць яе запіс.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} бліжэйшых плацяжоў", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} бліжэйшы плацёж", "tabs.stats.analytics.rhythm": "Рытм", @@ -699,6 +708,7 @@ "tabs.stats.analytics.uncategorized": "Некатэгарызавана", "tabs.stats.analytics.untitled": "Без назвы", "tabs.stats.analytics.up": "рост", + "tabs.stats.analytics.wrapped": "Падсумаванне", "tabs.stats.analytics.wrapped.biggest": "Найбуйнейшая: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} у гэтым месяцы супраць звычайных {typical}", "tabs.stats.analytics.wrapped.categoryTrend": "{name} {direction} на {value} у параўнанні з вашым 3-месячным сярэднім.", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 1abbcd6b..9cbfb159 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -655,6 +655,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Nepodařilo se načíst peněžní tok.", "tabs.stats.analytics.cashFlow.noMovement": "V tomto období se žádné peníze nepohnuly.", "tabs.stats.analytics.down": "dolů", + "tabs.stats.analytics.heatmap.less": "Méně", + "tabs.stats.analytics.heatmap.more": "Více", "tabs.stats.analytics.in": "Příjmy", "tabs.stats.analytics.inRange": "v {}", "tabs.stats.analytics.income": "Příjmy", @@ -678,6 +680,10 @@ "tabs.stats.analytics.other": "Jiné", "tabs.stats.analytics.out": "Výdaje", "tabs.stats.analytics.overspent": "Překročeno", + "tabs.stats.analytics.pace": "Tempo", + "tabs.stats.analytics.pace.perDay": "Průměr / den", + "tabs.stats.analytics.pace.projected": "Odhad", + "tabs.stats.analytics.pace.totalSpent": "Celkem utraceno", "tabs.stats.analytics.recurring": "Opakující se", "tabs.stats.analytics.recurring.activeSummary": "{count} opakujících se · příštích {days} dnů", "tabs.stats.analytics.recurring.committedOutflow": "Závazné výdaje", @@ -685,8 +691,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Opakující se transakce", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} dalších není zobrazeno", "tabs.stats.analytics.recurring.none": "Nebyly nastaveny žádné opakující se transakce.", + "tabs.stats.analytics.recurring.notLoggedYet": "Tento záznam ještě nebyl zaznamenán — jedná se o nadcházející odhad.", "tabs.stats.analytics.recurring.nothingDue": "V příštích {days} dnech není nic splatné.", "tabs.stats.analytics.recurring.nothingUpcoming": "Nic v blízké době", + "tabs.stats.analytics.recurring.projectedTitle": "Odhad celkem", + "tabs.stats.analytics.recurring.projectionsNote": "Odhad vychází z vašich opakujících se transakcí. Klepněte na zaznamenanou položku pro otevření jejího záznamu.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} nadcházejících plateb", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} nadcházející platba", "tabs.stats.analytics.rhythm": "Rytmus", @@ -698,6 +707,7 @@ "tabs.stats.analytics.uncategorized": "Nezařazeno", "tabs.stats.analytics.untitled": "Bez názvu", "tabs.stats.analytics.up": "nahoru", + "tabs.stats.analytics.wrapped": "Shrnutí", "tabs.stats.analytics.wrapped.biggest": "Největší: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} tento měsíc vs obvyklé {typical}", "tabs.stats.analytics.wrapped.categoryTrend": "{name} je {direction} o {value} oproti vašemu průměru za 3 měsíce.", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 3570a4a0..16948fd3 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Cashflow konnte nicht geladen werden.", "tabs.stats.analytics.cashFlow.noMovement": "In diesem Zeitraum wurden keine Geldbewegungen registriert.", "tabs.stats.analytics.down": "gesunken", + "tabs.stats.analytics.heatmap.less": "Weniger", + "tabs.stats.analytics.heatmap.more": "Mehr", "tabs.stats.analytics.in": "Eingang", "tabs.stats.analytics.inRange": "in {}", "tabs.stats.analytics.income": "Einnahmen", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "Sonstige", "tabs.stats.analytics.out": "Ausgang", "tabs.stats.analytics.overspent": "Überzogen", + "tabs.stats.analytics.pace": "Tempo", + "tabs.stats.analytics.pace.perDay": "Durchschnitt pro Tag", + "tabs.stats.analytics.pace.projected": "Prognose", + "tabs.stats.analytics.pace.totalSpent": "Insgesamt ausgegeben", "tabs.stats.analytics.recurring": "Wiederkehrend", "tabs.stats.analytics.recurring.activeSummary": "{count} wiederkehrend · nächste {days} Tage", "tabs.stats.analytics.recurring.committedOutflow": "Verpflichtete Ausgaben", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Wiederkehrende Transaktion", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} weitere nicht angezeigt", "tabs.stats.analytics.recurring.none": "Keine wiederkehrenden Transaktionen eingerichtet.", + "tabs.stats.analytics.recurring.notLoggedYet": "Dieser Eintrag wurde noch nicht erfasst — es handelt sich um eine bevorstehende Prognose.", "tabs.stats.analytics.recurring.nothingDue": "In den nächsten {days} Tagen nichts fällig.", "tabs.stats.analytics.recurring.nothingUpcoming": "Nichts anstehend", + "tabs.stats.analytics.recurring.projectedTitle": "Prognostizierte Gesamtbeträge", + "tabs.stats.analytics.recurring.projectionsNote": "Prognosen basieren auf deinen wiederkehrenden Transaktionen. Tippe auf einen erfassten Eintrag, um ihn zu öffnen.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} anstehende Belastungen", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} anstehende Belastung", "tabs.stats.analytics.rhythm": "Rhythmus", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "Nicht kategorisiert", "tabs.stats.analytics.untitled": "Unbenannt", "tabs.stats.analytics.up": "gestiegen", + "tabs.stats.analytics.wrapped": "Rückblick", "tabs.stats.analytics.wrapped.biggest": "Größter: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} in diesem Monat vs {typical} üblich", "tabs.stats.analytics.wrapped.categoryTrend": "{name} ist {direction} {value} im Vergleich zu Ihrem 3‑Monats‑Durchschnitt.", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index c2ed95f0..b6dddb2e 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Couldn't load cash flow.", "tabs.stats.analytics.cashFlow.noMovement": "No money moved in this range.", "tabs.stats.analytics.down": "down", + "tabs.stats.analytics.heatmap.less": "Less", + "tabs.stats.analytics.heatmap.more": "More", "tabs.stats.analytics.in": "In", "tabs.stats.analytics.inRange": "in {}", "tabs.stats.analytics.income": "Income", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "Other", "tabs.stats.analytics.out": "Out", "tabs.stats.analytics.overspent": "Overspent", + "tabs.stats.analytics.pace": "Pace", + "tabs.stats.analytics.pace.perDay": "Avg / day", + "tabs.stats.analytics.pace.projected": "Projected", + "tabs.stats.analytics.pace.totalSpent": "Total spent", "tabs.stats.analytics.recurring": "Recurring", "tabs.stats.analytics.recurring.activeSummary": "{count} recurring · next {days} days", "tabs.stats.analytics.recurring.committedOutflow": "Committed outflow", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Recurring transaction", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} more not shown", "tabs.stats.analytics.recurring.none": "No recurring transactions set up.", + "tabs.stats.analytics.recurring.notLoggedYet": "This one hasn't been logged yet — it's an upcoming projection.", "tabs.stats.analytics.recurring.nothingDue": "Nothing due in the next {days} days.", "tabs.stats.analytics.recurring.nothingUpcoming": "Nothing upcoming", + "tabs.stats.analytics.recurring.projectedTitle": "Projected totals", + "tabs.stats.analytics.recurring.projectionsNote": "Projected from your recurring transactions. Tap a logged one to open its entry.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} upcoming charges", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} upcoming charge", "tabs.stats.analytics.rhythm": "Rhythm", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "Uncategorized", "tabs.stats.analytics.untitled": "Untitled", "tabs.stats.analytics.up": "up", + "tabs.stats.analytics.wrapped": "Wrapped", "tabs.stats.analytics.wrapped.biggest": "Biggest: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} this month vs {typical} typical", "tabs.stats.analytics.wrapped.categoryTrend": "{name} is {direction} {value} vs your 3-month average.", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 15fd2920..30c5b27a 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "No se pudo cargar el flujo de caja.", "tabs.stats.analytics.cashFlow.noMovement": "No hubo movimientos de dinero en este intervalo.", "tabs.stats.analytics.down": "a la baja", + "tabs.stats.analytics.heatmap.less": "Menos", + "tabs.stats.analytics.heatmap.more": "Más", "tabs.stats.analytics.in": "Entradas", "tabs.stats.analytics.inRange": "en {}", "tabs.stats.analytics.income": "Ingresos", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "Otros", "tabs.stats.analytics.out": "Salidas", "tabs.stats.analytics.overspent": "Gasto excesivo", + "tabs.stats.analytics.pace": "Ritmo", + "tabs.stats.analytics.pace.perDay": "Promedio / día", + "tabs.stats.analytics.pace.projected": "Proyectado", + "tabs.stats.analytics.pace.totalSpent": "Total gastado", "tabs.stats.analytics.recurring": "Recurrentes", "tabs.stats.analytics.recurring.activeSummary": "{count} recurrentes · próximos {days} días", "tabs.stats.analytics.recurring.committedOutflow": "Salida comprometida", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Transacción recurrente", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} más no mostrados", "tabs.stats.analytics.recurring.none": "No se han configurado transacciones recurrentes.", + "tabs.stats.analytics.recurring.notLoggedYet": "Este aún no se ha registrado — es una proyección futura.", "tabs.stats.analytics.recurring.nothingDue": "No hay pagos pendientes en los próximos {days} días.", "tabs.stats.analytics.recurring.nothingUpcoming": "Nada programado", + "tabs.stats.analytics.recurring.projectedTitle": "Totales proyectados", + "tabs.stats.analytics.recurring.projectionsNote": "Proyectado a partir de tus transacciones recurrentes. Pulsa una registrada para abrir su entrada.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} cobros próximos", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} cobro próximo", "tabs.stats.analytics.rhythm": "Ritmo", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "Sin categorizar", "tabs.stats.analytics.untitled": "Sin título", "tabs.stats.analytics.up": "al alza", + "tabs.stats.analytics.wrapped": "Resumen", "tabs.stats.analytics.wrapped.biggest": "Mayor: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} este mes frente a {typical} habitual", "tabs.stats.analytics.wrapped.categoryTrend": "{name} está {direction} {value} respecto a tu media de 3 meses.", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 5dd9d6d0..b96d0369 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "بارگذاری جریان نقدی ممکن نشد.", "tabs.stats.analytics.cashFlow.noMovement": "در این بازه هیچ پولی جابه‌جا نشده است.", "tabs.stats.analytics.down": "کاهش", + "tabs.stats.analytics.heatmap.less": "کمتر", + "tabs.stats.analytics.heatmap.more": "بیشتر", "tabs.stats.analytics.in": "ورودی", "tabs.stats.analytics.inRange": "در {}", "tabs.stats.analytics.income": "درآمد", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "سایر", "tabs.stats.analytics.out": "خروجی", "tabs.stats.analytics.overspent": "بیش‌خرجی", + "tabs.stats.analytics.pace": "سرعت", + "tabs.stats.analytics.pace.perDay": "میانگین در روز", + "tabs.stats.analytics.pace.projected": "برآورد", + "tabs.stats.analytics.pace.totalSpent": "مجموع هزینه‌ها", "tabs.stats.analytics.recurring": "تراکنش‌های دوره‌ای", "tabs.stats.analytics.recurring.activeSummary": "{count} مورد دوره‌ای · {days} روز آینده", "tabs.stats.analytics.recurring.committedOutflow": "پرداخت‌های متعهد شده", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "تراکنش دوره‌ای", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} مورد دیگر نمایش داده نشده", "tabs.stats.analytics.recurring.none": "هیچ تراکنش دوره‌ای تنظیم نشده است.", + "tabs.stats.analytics.recurring.notLoggedYet": "این مورد هنوز ثبت نشده — این یک برآورد آینده است.", "tabs.stats.analytics.recurring.nothingDue": "هیچ موردی در {days} روز آینده موعد ندارد.", "tabs.stats.analytics.recurring.nothingUpcoming": "هیچ موردی در پیش رو نیست", + "tabs.stats.analytics.recurring.projectedTitle": "مجموع‌های پیش‌بینی‌شده", + "tabs.stats.analytics.recurring.projectionsNote": "این برآوردها از تراکنش‌های دوره‌ای شما به‌دست آمده‌اند. برای باز کردن، روی یکی از موارد ثبت‌شده ضربه بزنید.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} هزینهٔ پیش رو", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} هزینهٔ پیش رو", "tabs.stats.analytics.rhythm": "ریتم", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "دسته‌بندی نشده", "tabs.stats.analytics.untitled": "بدون عنوان", "tabs.stats.analytics.up": "افزایش", + "tabs.stats.analytics.wrapped": "خلاصه", "tabs.stats.analytics.wrapped.biggest": "بزرگ‌ترین: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} این ماه در مقایسه با معمولِ {typical}", "tabs.stats.analytics.wrapped.categoryTrend": "{name} نسبت به میانگین سه‌ماههٔ شما {direction} {value} است.", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 34aa6aa3..130fe32a 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Impossible de charger le flux de trésorerie.", "tabs.stats.analytics.cashFlow.noMovement": "Aucun mouvement d'argent dans cette période.", "tabs.stats.analytics.down": "en baisse", + "tabs.stats.analytics.heatmap.less": "Moins", + "tabs.stats.analytics.heatmap.more": "Plus", "tabs.stats.analytics.in": "Entrées", "tabs.stats.analytics.inRange": "dans {}", "tabs.stats.analytics.income": "Revenus", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "Autre", "tabs.stats.analytics.out": "Sorties", "tabs.stats.analytics.overspent": "Dépassement", + "tabs.stats.analytics.pace": "Rythme", + "tabs.stats.analytics.pace.perDay": "Moy. / jour", + "tabs.stats.analytics.pace.projected": "Projeté", + "tabs.stats.analytics.pace.totalSpent": "Total dépensé", "tabs.stats.analytics.recurring": "Récurrent", "tabs.stats.analytics.recurring.activeSummary": "{count} récurrences · dans les {days} prochains jours", "tabs.stats.analytics.recurring.committedOutflow": "Sortie engagée", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Transaction récurrente", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} de plus non affichés", "tabs.stats.analytics.recurring.none": "Aucune transaction récurrente configurée.", + "tabs.stats.analytics.recurring.notLoggedYet": "Cette transaction n'a pas encore été enregistrée — c'est une projection à venir.", "tabs.stats.analytics.recurring.nothingDue": "Aucun paiement dû dans les {days} prochains jours.", "tabs.stats.analytics.recurring.nothingUpcoming": "Rien à venir", + "tabs.stats.analytics.recurring.projectedTitle": "Totaux projetés", + "tabs.stats.analytics.recurring.projectionsNote": "Projettés à partir de vos transactions récurrentes. Touchez une transaction enregistrée pour ouvrir sa fiche.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} paiements à venir", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} paiement à venir", "tabs.stats.analytics.rhythm": "Rythme", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "Non catégorisé", "tabs.stats.analytics.untitled": "Sans titre", "tabs.stats.analytics.up": "en hausse", + "tabs.stats.analytics.wrapped": "Récapitulatif", "tabs.stats.analytics.wrapped.biggest": "Plus gros : {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} ce mois-ci vs {typical} habituellement", "tabs.stats.analytics.wrapped.categoryTrend": "{name} est {direction} de {value} par rapport à votre moyenne sur 3 mois.", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index eb604650..fe4ad100 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Impossibile caricare il flusso di cassa.", "tabs.stats.analytics.cashFlow.noMovement": "Nessun movimento di denaro in questo intervallo.", "tabs.stats.analytics.down": "in calo", + "tabs.stats.analytics.heatmap.less": "Meno", + "tabs.stats.analytics.heatmap.more": "Più", "tabs.stats.analytics.in": "Entrata", "tabs.stats.analytics.inRange": "in {}", "tabs.stats.analytics.income": "Entrate", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "Altro", "tabs.stats.analytics.out": "Uscita", "tabs.stats.analytics.overspent": "Sforato", + "tabs.stats.analytics.pace": "Andamento", + "tabs.stats.analytics.pace.perDay": "Media / giorno", + "tabs.stats.analytics.pace.projected": "Previsto", + "tabs.stats.analytics.pace.totalSpent": "Totale speso", "tabs.stats.analytics.recurring": "Ricorrenti", "tabs.stats.analytics.recurring.activeSummary": "{count} ricorrenti · prossimi {days} giorni", "tabs.stats.analytics.recurring.committedOutflow": "Uscite impegnate", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Transazione ricorrente", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} in più non mostrati", "tabs.stats.analytics.recurring.none": "Nessuna transazione ricorrente impostata.", + "tabs.stats.analytics.recurring.notLoggedYet": "Questa voce non è ancora stata registrata — è una proiezione futura.", "tabs.stats.analytics.recurring.nothingDue": "Niente in scadenza nei prossimi {days} giorni.", "tabs.stats.analytics.recurring.nothingUpcoming": "Niente in arrivo", + "tabs.stats.analytics.recurring.projectedTitle": "Totali previsti", + "tabs.stats.analytics.recurring.projectionsNote": "Previsioni basate sulle tue transazioni ricorrenti. Tocca una voce registrata per aprirla.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} addebiti in arrivo", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} addebito in arrivo", "tabs.stats.analytics.rhythm": "Ritmo", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "Non categorizzato", "tabs.stats.analytics.untitled": "Senza titolo", "tabs.stats.analytics.up": "in aumento", + "tabs.stats.analytics.wrapped": "Riepilogo", "tabs.stats.analytics.wrapped.biggest": "Il più grande: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} questo mese vs {typical} tipico", "tabs.stats.analytics.wrapped.categoryTrend": "{name} è {direction} di {value} rispetto alla tua media su 3 mesi.", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index ad5533f7..9516060c 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Мөнгөний урсгалыг ачаалж чадсангүй.", "tabs.stats.analytics.cashFlow.noMovement": "Энэ хугацаанд мөнгө шилжээгүй.", "tabs.stats.analytics.down": "буурсан", + "tabs.stats.analytics.heatmap.less": "Бага", + "tabs.stats.analytics.heatmap.more": "Их", "tabs.stats.analytics.in": "Ирсэн", "tabs.stats.analytics.inRange": "{} дотор", "tabs.stats.analytics.income": "Орлого", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "Бусад", "tabs.stats.analytics.out": "Гарсан", "tabs.stats.analytics.overspent": "Хэт зарцуулсан", + "tabs.stats.analytics.pace": "Хурд", + "tabs.stats.analytics.pace.perDay": "Өдөрт дундаж", + "tabs.stats.analytics.pace.projected": "Таамагласан", + "tabs.stats.analytics.pace.totalSpent": "Нийт зарцуулалт", "tabs.stats.analytics.recurring": "Давтагдах", "tabs.stats.analytics.recurring.activeSummary": "{count} давтагдах · дараах {days} хоногт", "tabs.stats.analytics.recurring.committedOutflow": "Баталгаажсан зарлага", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Давтагдах гүйлгээ", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} бусад нь үзүүлээгүй", "tabs.stats.analytics.recurring.none": "Давтагдах гүйлгээ тохируулгаагүй.", + "tabs.stats.analytics.recurring.notLoggedYet": "Энэхүү гүйлгээ одоогоор бүртгэгдээгүй — ирээдүйн таамаглал байна.", "tabs.stats.analytics.recurring.nothingDue": "Дараах {days} хоногт төлөх зүйл байхгүй.", "tabs.stats.analytics.recurring.nothingUpcoming": "Ойрын төлөвлөгдсөн зүйл алга.", + "tabs.stats.analytics.recurring.projectedTitle": "Таамагласан нийт", + "tabs.stats.analytics.recurring.projectionsNote": "Давтагддаг гүйлгээнээс таамагласан. Бүртгэгдсэн гүйлгээ дээр товшоод тухайн бичлэгийг нээнэ үү.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} ойрын төлбөр", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} ойрын төлбөр", "tabs.stats.analytics.rhythm": "Хэмнэл", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "Ангилгагүй", "tabs.stats.analytics.untitled": "Гарчиггүй", "tabs.stats.analytics.up": "өссөн", + "tabs.stats.analytics.wrapped": "Тойм", "tabs.stats.analytics.wrapped.biggest": "Хамгийн их: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} энэ сар vs ердийн {typical}", "tabs.stats.analytics.wrapped.categoryTrend": "{name} нь таны 3 сарын дундажтай харьцуулахад {direction} {value}.", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index 2817100d..b180c8e0 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -656,6 +656,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Nie udało się załadować przepływu pieniężnego.", "tabs.stats.analytics.cashFlow.noMovement": "W tym okresie nie było ruchu środków.", "tabs.stats.analytics.down": "niżej", + "tabs.stats.analytics.heatmap.less": "Mniej", + "tabs.stats.analytics.heatmap.more": "Więcej", "tabs.stats.analytics.in": "Wpływy", "tabs.stats.analytics.inRange": "w {}", "tabs.stats.analytics.income": "Przychody", @@ -679,6 +681,10 @@ "tabs.stats.analytics.other": "Inne", "tabs.stats.analytics.out": "Wypływy", "tabs.stats.analytics.overspent": "Przekroczono budżet", + "tabs.stats.analytics.pace": "Tempo", + "tabs.stats.analytics.pace.perDay": "Śr./dzień", + "tabs.stats.analytics.pace.projected": "Prognozowane", + "tabs.stats.analytics.pace.totalSpent": "Wydano łącznie", "tabs.stats.analytics.recurring": "Cykliczne", "tabs.stats.analytics.recurring.activeSummary": "{count} cyklicznych · następne {days} dni", "tabs.stats.analytics.recurring.committedOutflow": "Zobowiązane wydatki", @@ -686,8 +692,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Transakcja cykliczna", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} więcej nie pokazano", "tabs.stats.analytics.recurring.none": "Brak ustawionych transakcji cyklicznych.", + "tabs.stats.analytics.recurring.notLoggedYet": "To jeszcze nie zostało zarejestrowane — to nadchodząca prognoza.", "tabs.stats.analytics.recurring.nothingDue": "W ciągu następnych {days} dni nic nie jest należne.", "tabs.stats.analytics.recurring.nothingUpcoming": "Brak nadchodzących", + "tabs.stats.analytics.recurring.projectedTitle": "Prognozowane sumy", + "tabs.stats.analytics.recurring.projectionsNote": "Prognozy oparte na twoich transakcjach cyklicznych. Stuknij w zalogowaną, aby otworzyć jej wpis.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} nadchodzących obciążeń", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} nadchodzące obciążenie", "tabs.stats.analytics.rhythm": "Rytm", @@ -699,6 +708,7 @@ "tabs.stats.analytics.uncategorized": "Bez kategorii", "tabs.stats.analytics.untitled": "Bez tytułu", "tabs.stats.analytics.up": "wyżej", + "tabs.stats.analytics.wrapped": "Podsumowanie", "tabs.stats.analytics.wrapped.biggest": "Największe: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} w tym miesiącu vs {typical} typowo", "tabs.stats.analytics.wrapped.categoryTrend": "{name} jest {direction} o {value} w porównaniu z 3-miesięczną średnią.", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index f997fe96..925131f6 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -656,6 +656,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Не удалось загрузить денежный поток.", "tabs.stats.analytics.cashFlow.noMovement": "Денежные операции отсутствуют в этом диапазоне.", "tabs.stats.analytics.down": "вниз", + "tabs.stats.analytics.heatmap.less": "Меньше", + "tabs.stats.analytics.heatmap.more": "Больше", "tabs.stats.analytics.in": "Входящие", "tabs.stats.analytics.inRange": "в {}", "tabs.stats.analytics.income": "Доход", @@ -679,6 +681,10 @@ "tabs.stats.analytics.other": "Другое", "tabs.stats.analytics.out": "Исходящие", "tabs.stats.analytics.overspent": "Перерасход", + "tabs.stats.analytics.pace": "Темп", + "tabs.stats.analytics.pace.perDay": "В среднем в день", + "tabs.stats.analytics.pace.projected": "Прогноз", + "tabs.stats.analytics.pace.totalSpent": "Всего потрачено", "tabs.stats.analytics.recurring": "Повторяющиеся", "tabs.stats.analytics.recurring.activeSummary": "{count} повторяющихся · ближайшие {days} дней", "tabs.stats.analytics.recurring.committedOutflow": "Обязательные выплаты", @@ -686,8 +692,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Повторяющаяся транзакция", "tabs.stats.analytics.recurring.moreNotShown": "+ ещё {count} не показано", "tabs.stats.analytics.recurring.none": "Нет настроенных повторяющихся транзакций.", + "tabs.stats.analytics.recurring.notLoggedYet": "Ещё не зафиксировано — это предстоящий прогноз.", "tabs.stats.analytics.recurring.nothingDue": "Ничего не запланировано в ближайшие {days} дней.", "tabs.stats.analytics.recurring.nothingUpcoming": "Ничего не запланировано", + "tabs.stats.analytics.recurring.projectedTitle": "Прогнозируемые итоги", + "tabs.stats.analytics.recurring.projectionsNote": "Прогноз на основе ваших повторяющихся транзакций. Нажмите на любую зафиксированную транзакцию, чтобы открыть её запись.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} предстоящих списаний", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} предстоящее списание", "tabs.stats.analytics.rhythm": "Ритм", @@ -699,6 +708,7 @@ "tabs.stats.analytics.uncategorized": "Без категории", "tabs.stats.analytics.untitled": "Без названия", "tabs.stats.analytics.up": "вверх", + "tabs.stats.analytics.wrapped": "Итоги", "tabs.stats.analytics.wrapped.biggest": "Крупнейшая: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} в этом месяце против типичных {typical}", "tabs.stats.analytics.wrapped.categoryTrend": "{name} {direction} на {value} по сравнению с вашим 3-месячным средним.", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index 94de6430..c4bcdb45 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Nakit akışı yüklenemedi.", "tabs.stats.analytics.cashFlow.noMovement": "Bu aralıkta para hareketi yok.", "tabs.stats.analytics.down": "azaldı", + "tabs.stats.analytics.heatmap.less": "Az", + "tabs.stats.analytics.heatmap.more": "Çok", "tabs.stats.analytics.in": "Giren", "tabs.stats.analytics.inRange": "{} içinde", "tabs.stats.analytics.income": "Gelir", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "Diğer", "tabs.stats.analytics.out": "Çıkan", "tabs.stats.analytics.overspent": "Aşırı harcandı", + "tabs.stats.analytics.pace": "Hız", + "tabs.stats.analytics.pace.perDay": "Günlük ort.", + "tabs.stats.analytics.pace.projected": "Tahmini", + "tabs.stats.analytics.pace.totalSpent": "Toplam harcama", "tabs.stats.analytics.recurring": "Yinelenen", "tabs.stats.analytics.recurring.activeSummary": "{count} yinelenen · önümüzdeki {days} gün", "tabs.stats.analytics.recurring.committedOutflow": "Taahhüt edilmiş çıkış", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Yinelenen işlem", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} daha gösterilmiyor", "tabs.stats.analytics.recurring.none": "Hiç yinelenen işlem ayarlanmadı.", + "tabs.stats.analytics.recurring.notLoggedYet": "Bu henüz kaydedilmedi — yaklaşan bir tahmindir.", "tabs.stats.analytics.recurring.nothingDue": "Önümüzdeki {days} gün içinde ödenecek bir şey yok.", "tabs.stats.analytics.recurring.nothingUpcoming": "Yaklaşan yok", + "tabs.stats.analytics.recurring.projectedTitle": "Tahmini toplamlar", + "tabs.stats.analytics.recurring.projectionsNote": "Tekrarlanan işlemlerinizden tahmin edildi. Detayını açmak için kaydedilmiş bir kayda dokunun.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} yaklaşan işlem", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} yaklaşan işlem", "tabs.stats.analytics.rhythm": "Ritim", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "Kategorize edilmemiş", "tabs.stats.analytics.untitled": "Adsız", "tabs.stats.analytics.up": "arttı", + "tabs.stats.analytics.wrapped": "Özet", "tabs.stats.analytics.wrapped.biggest": "En büyük: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} bu ay · tipik {typical}", "tabs.stats.analytics.wrapped.categoryTrend": "{name}, 3 aylık ortalamanıza göre {direction} {value}.", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 8a013fce..616e61ba 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -656,6 +656,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "Не вдалося завантажити грошовий потік.", "tabs.stats.analytics.cashFlow.noMovement": "У цьому проміжку не було руху коштів.", "tabs.stats.analytics.down": "вниз", + "tabs.stats.analytics.heatmap.less": "Менше", + "tabs.stats.analytics.heatmap.more": "Більше", "tabs.stats.analytics.in": "Надходження", "tabs.stats.analytics.inRange": "в {}", "tabs.stats.analytics.income": "Дохід", @@ -679,6 +681,10 @@ "tabs.stats.analytics.other": "Інше", "tabs.stats.analytics.out": "Витрати", "tabs.stats.analytics.overspent": "Перевитрачено", + "tabs.stats.analytics.pace": "Темп", + "tabs.stats.analytics.pace.perDay": "Середньо за день", + "tabs.stats.analytics.pace.projected": "Прогноз", + "tabs.stats.analytics.pace.totalSpent": "Всього витрачено", "tabs.stats.analytics.recurring": "Повторювані", "tabs.stats.analytics.recurring.activeSummary": "{count} повторюваних · наступні {days} дні", "tabs.stats.analytics.recurring.committedOutflow": "Запланований відтік", @@ -686,8 +692,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "Повторювана транзакція", "tabs.stats.analytics.recurring.moreNotShown": "+ ще {count} не показано", "tabs.stats.analytics.recurring.none": "Не налаштовано повторюваних транзакцій.", + "tabs.stats.analytics.recurring.notLoggedYet": "Це ще не зафіксовано — це майбутній прогноз.", "tabs.stats.analytics.recurring.nothingDue": "Нічого не потрібно сплачувати в наступні {days} днів.", "tabs.stats.analytics.recurring.nothingUpcoming": "Нічого не заплановано", + "tabs.stats.analytics.recurring.projectedTitle": "Прогнозні підсумки", + "tabs.stats.analytics.recurring.projectionsNote": "Розраховано на основі ваших повторюваних транзакцій. Торкніться зареєстрованої транзакції, щоб відкрити її запис.", "tabs.stats.analytics.recurring.upcomingCharges": "{count} майбутніх списань", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} майбутнє списання", "tabs.stats.analytics.rhythm": "Ритм", @@ -699,6 +708,7 @@ "tabs.stats.analytics.uncategorized": "Без категорії", "tabs.stats.analytics.untitled": "Без назви", "tabs.stats.analytics.up": "вгору", + "tabs.stats.analytics.wrapped": "Підсумок", "tabs.stats.analytics.wrapped.biggest": "Найбільша: {title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current} цього місяця проти типових {typical}.", "tabs.stats.analytics.wrapped.categoryTrend": "{name} {direction} на {value} порівняно з вашим 3-місячним середнім.", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 4a6d8855..32b6ecf9 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -654,6 +654,8 @@ "tabs.stats.analytics.cashFlow.loadFailed": "無法載入現金流。", "tabs.stats.analytics.cashFlow.noMovement": "此範圍內沒有資金流動。", "tabs.stats.analytics.down": "下降", + "tabs.stats.analytics.heatmap.less": "少", + "tabs.stats.analytics.heatmap.more": "多", "tabs.stats.analytics.in": "流入", "tabs.stats.analytics.inRange": "在 {} 內", "tabs.stats.analytics.income": "收入", @@ -677,6 +679,10 @@ "tabs.stats.analytics.other": "其他", "tabs.stats.analytics.out": "流出", "tabs.stats.analytics.overspent": "超支", + "tabs.stats.analytics.pace": "進度", + "tabs.stats.analytics.pace.perDay": "每日平均", + "tabs.stats.analytics.pace.projected": "預估", + "tabs.stats.analytics.pace.totalSpent": "總支出", "tabs.stats.analytics.recurring": "定期交易", "tabs.stats.analytics.recurring.activeSummary": "{count} 個定期項目 · 未來 {days} 天", "tabs.stats.analytics.recurring.committedOutflow": "已承諾支出", @@ -684,8 +690,11 @@ "tabs.stats.analytics.recurring.defaultTitle": "定期交易", "tabs.stats.analytics.recurring.moreNotShown": "+ {count} 項未顯示", "tabs.stats.analytics.recurring.none": "未設定任何定期交易。", + "tabs.stats.analytics.recurring.notLoggedYet": "此筆尚未被記錄 — 為即將到來的預估。", "tabs.stats.analytics.recurring.nothingDue": "未來 {days} 天內沒有到期項目。", "tabs.stats.analytics.recurring.nothingUpcoming": "近期無項目", + "tabs.stats.analytics.recurring.projectedTitle": "預估總額", + "tabs.stats.analytics.recurring.projectionsNote": "根據您的定期交易進行預估。點選已記錄的項目以開啟其明細。", "tabs.stats.analytics.recurring.upcomingCharges": "{count} 預定扣款", "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} 預定扣款", "tabs.stats.analytics.rhythm": "節奏", @@ -697,6 +706,7 @@ "tabs.stats.analytics.uncategorized": "未分類", "tabs.stats.analytics.untitled": "未命名", "tabs.stats.analytics.up": "上升", + "tabs.stats.analytics.wrapped": "回顧", "tabs.stats.analytics.wrapped.biggest": "最大:{title} · {amount} · {date}", "tabs.stats.analytics.wrapped.categorySubtitle": "{current}(本月) vs 典型值 {typical}", "tabs.stats.analytics.wrapped.categoryTrend": "{name} 相較於你過去 3 個月的平均 {direction} {value}。", diff --git a/lib/prefs/local_preferences.dart b/lib/prefs/local_preferences.dart index 2c9a3d88..f7a25e50 100644 --- a/lib/prefs/local_preferences.dart +++ b/lib/prefs/local_preferences.dart @@ -55,6 +55,12 @@ class LocalPreferences { late final BoolSettingsEntry preferFullAmounts; late final BoolSettingsEntry useCurrencySymbol; + /// Whether the user has opened the Insights index at least once. + /// + /// Drives the one-time "New" badge on the Insights entry in the Profile tab: + /// it flips to true on the first tap, after which the badge never shows again. + late final BoolSettingsEntry openedInsightsIndex; + /// Number of notifications issued by the app /// /// Used to prevent id collisions @@ -141,6 +147,12 @@ class LocalPreferences { initialValue: true, ); + openedInsightsIndex = BoolSettingsEntry( + key: "openedInsightsIndex", + preferences: _prefs, + initialValue: false, + ); + lastRequestedAppStoreReview = DateTimeSettingsEntry( key: "lastRequestedAppStoreReview", preferences: _prefs, diff --git a/lib/routes.dart b/lib/routes.dart index e2cf802b..d35ab201 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -50,6 +50,7 @@ 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/cash_flow_page.dart"; +import "package:flow/routes/stats/insights_page.dart"; import "package:flow/routes/stats/net_worth_page.dart"; import "package:flow/routes/stats/recurring_page.dart"; import "package:flow/routes/stats/spending_calendar_page.dart"; @@ -495,6 +496,10 @@ final GoRouter router = GoRouter( path: "/_debug/theme", builder: (context, state) => DebugThemePage(), ), + GoRoute( + path: "/stats/insights", + builder: (context, state) => const InsightsPage(), + ), GoRoute( path: "/stats/net-worth", builder: (context, state) => const NetWorthPage(), diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index abea07fa..6cceade7 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -3,6 +3,7 @@ import "dart:async"; import "package:flow/constants.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; +import "package:flow/prefs/local_preferences.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/services/notifications.dart"; import "package:flow/services/sync/icloud_syncer.dart"; @@ -41,6 +42,25 @@ class _ProfileTabState extends State { const SizedBox(height: 24.0), const Center(child: ProfileCard()), const SizedBox(height: 24.0), + ListTile( + title: Text("tabs.stats.insights".t(context)), + leading: const Icon(Symbols.insights_rounded), + trailing: LocalPreferences().openedInsightsIndex.get() + ? null + : Badge( + label: Text("general.new".t(context)), + backgroundColor: context.colorScheme.primary, + textColor: context.colorScheme.onPrimary, + ), + onTap: () { + final entry = LocalPreferences().openedInsightsIndex; + if (!entry.get()) { + entry.set(true); + setState(() {}); + } + context.push("/stats/insights"); + }, + ), ListTile( title: Text("accounts".t(context)), leading: const Icon(Symbols.wallet_rounded), @@ -125,38 +145,6 @@ class _ProfileTabState extends State { leading: const Icon(Symbols.settings_rounded), onTap: () => context.push("/preferences"), ), - const SizedBox(height: 32.0), - ListHeader("tabs.stats.insights".t(context)), - ListTile( - title: Text("tabs.profile.analytics.netWorth".t(context)), - leading: const Icon(Symbols.trending_up_rounded), - onTap: () => context.push("/stats/net-worth"), - ), - ListTile( - title: Text("tabs.profile.analytics.wrapped".t(context)), - leading: const Icon(Symbols.bar_chart_rounded), - onTap: () => context.push("/stats/wrapped"), - ), - ListTile( - title: Text("tabs.profile.analytics.recurring".t(context)), - leading: const Icon(Symbols.autorenew_rounded), - onTap: () => context.push("/stats/recurring"), - ), - ListTile( - title: Text("tabs.profile.analytics.calendar".t(context)), - leading: const Icon(Symbols.calendar_month_rounded), - onTap: () => context.push("/stats/calendar"), - ), - ListTile( - title: Text("tabs.profile.analytics.cashFlow".t(context)), - leading: const Icon(Symbols.alt_route_rounded), - onTap: () => context.push("/stats/cash-flow"), - ), - ListTile( - title: Text("tabs.profile.analytics.map".t(context)), - leading: const Icon(Symbols.map_rounded), - onTap: () => context.push("/stats/map"), - ), if (flowDebugMode) ...[ const SizedBox(height: 32.0), const ListHeader("Debug options"), diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 08084753..76ad8923 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,35 +1,7 @@ -import "package:auto_size_text/auto_size_text.dart"; -import "package:flow/constants.dart"; -import "package:flow/data/exchange_rates.dart"; -import "package:flow/entity/transaction.dart"; -import "package:flow/l10n/extensions.dart"; -import "package:flow/objectbox.dart"; -import "package:flow/objectbox/actions.dart"; -import "package:flow/prefs/transitive.dart"; -import "package:flow/reports/interval_flow_report.dart"; -import "package:flow/reports/range_forecast_report.dart"; -import "package:flow/reports/report.dart"; -import "package:flow/reports/trends_report.dart"; -import "package:flow/services/exchange_rates.dart"; -import "package:flow/services/user_preferences.dart"; -import "package:flow/theme/helpers.dart"; -import "package:flow/utils/extensions/interval_report.dart"; -import "package:flow/widgets/general/blur_backgorund.dart"; -import "package:flow/widgets/general/directional_chevron.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/bento/analytics_bento.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/rates_missing_error_box.dart"; -import "package:flow/widgets/reports/interval_flow_report_view.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:moment_dart/moment_dart.dart"; class StatsTab extends StatefulWidget { @@ -43,56 +15,10 @@ class _StatsTabState extends State with AutomaticKeepAliveClientMixin { TimeRange range = TimeRange.thisMonth(); - List transactions = []; - - RangeForecastReport? rangeForecastReport; - IntervalFlowReport? intervalFlowReport; - IntervalFlowReport? previousIntervalFlowReport; - TrendsReport? trendsReport; - - final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); - - bool busy = false; - - ExchangeRates? rates; - - @override - void initState() { - super.initState(); - - fetch(); - - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - ExchangeRatesService().exchangeRatesCache.addListener(_updateRates); - UserPreferencesService().valueNotifier.addListener(_updateRates); - } - - @override - void dispose() { - ExchangeRatesService().exchangeRatesCache.removeListener(_updateRates); - UserPreferencesService().valueNotifier.removeListener(_updateRates); - super.dispose(); - } - @override Widget build(BuildContext context) { super.build(context); - if (!flowDebugMode && busy && intervalFlowReport == null) { - return Spinner.center(); - } - - final bool hasData = - intervalFlowReport != null && intervalFlowReport!.data.isNotEmpty; - - final bool showForecast = - intervalFlowReport?.rangeData.range.contains(DateTime.now()) == true && - rangeForecastReport != null; - - final bool showMissingExchangeRatesWarning = - rates == null && - TransitiveLocalPreferences().usesNonPrimaryCurrency.get(); - return Column( children: [ SafeArea( @@ -104,223 +30,20 @@ class _StatsTabState extends State ), ), ), - if (!flowDebugMode && showMissingExchangeRatesWarning) ...[ - RatesMissingErrorBox(), - const SizedBox(height: 12.0), - ], Expanded( - child: flowDebugMode - ? SingleChildScrollView( - child: SafeArea( - top: false, - child: Column( - crossAxisAlignment: .start, - children: [ - const SizedBox(height: 16.0), - AnalyticsBento(range: range), - const SizedBox(height: 96.0), - ], - ), - ), - ) - : hasData - ? SingleChildScrollView( - child: SafeArea( - top: false, - child: Column( - crossAxisAlignment: .start, - children: [ - BlurBackground( - blur: busy, - child: Frame( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: .start, - children: [ - Text( - showForecast - ? "tabs.stats.intervalReport.forecast".t( - context, - rangeForecastReport - ?.currentRangeData - .range - .format(), - ) - : "tabs.stats.intervalReport.totalExpense" - .t( - context, - intervalFlowReport! - .rangeData - .range - .format(), - ), - style: context.textTheme.titleSmall?.semi( - context, - ), - ), - Row( - children: [ - MoneyText( - showForecast - ? rangeForecastReport! - .forecast - .totalExpense - : intervalFlowReport!.totalExpense, - style: context.textTheme.displaySmall, - autoSize: true, - tapToToggleAbbreviation: true, - ), - const SizedBox(width: 8.0), - Trend.fromMoney( - current: showForecast - ? rangeForecastReport - ?.forecast - .totalExpense - : intervalFlowReport!.totalExpense, - previous: previousIntervalFlowReport - ?.totalExpense, - ), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 16.0), - if (intervalFlowReport != null) - BlurBackground( - blur: busy, - child: IntervalFlowReportView( - report: intervalFlowReport!, - compareWith: previousIntervalFlowReport, - ), - ), - const SizedBox(height: 24.0), - if (intervalFlowReport != null) ...[ - const SizedBox(height: 24.0), - ListHeader(intervalFlowReport!.averageTitle(context)), - const SizedBox(height: 8.0), - BlurBackground( - blur: busy, - child: Frame( - child: Column( - spacing: 16.0, - children: [ - Row( - spacing: 16.0, - children: [ - Expanded( - child: InfoCardWithDelta( - title: - "tabs.stats.intervalReport.averages.expense" - .t(context), - autoSizeGroup: autoSizeGroup, - money: intervalFlowReport! - .averageExpense, - previousMoney: - previousIntervalFlowReport - ?.averageExpense, - invertDelta: true, - ), - ), - Expanded( - child: InfoCardWithDelta( - title: - "tabs.stats.intervalReport.averages.income" - .t(context), - autoSizeGroup: autoSizeGroup, - money: - intervalFlowReport!.averageIncome, - previousMoney: - previousIntervalFlowReport - ?.averageIncome, - ), - ), - ], - ), - - InfoCardWithDelta( - title: - "tabs.stats.intervalReport.averages.flow" - .t(context), - autoSizeGroup: autoSizeGroup, - money: intervalFlowReport!.averageFlow, - previousMoney: - previousIntervalFlowReport?.averageFlow, - ), - ], - ), - ), - ), - ], - // if (trendsReport != null) ...[ - // const SizedBox(height: 24.0), - // ListHeader("tabs.stats.trends".t(context)), - // const SizedBox(height: 8.0), - // BlurBackground( - // blur: busy, - // child: Frame( - // child: Column( - // spacing: 16.0, - // mainAxisSize: MainAxisSize.min, - // children: [ - // Surface( - // builder: (context) { - // return Padding( - // padding: EdgeInsets.all(16.0), - // child: Column( - // mainAxisSize: MainAxisSize.min, - // children: [ - // Text( - // "tabs.stats.trends.topSpendingTitles" - // .t(context), - // ), - // const SizedBox(height: 16.0), - // ...trendsReport! - // .sortedTitlesByFrequency - // .take(3) - // .map( - // (titleFrequency) => Text( - // "${titleFrequency.key} (${titleFrequency.value})", - // ), - // ), - // ], - // ), - // ); - // }, - // ), - // ], - // ), - // ), - // ), - // ], - const SizedBox(height: 24.0), - ListHeader("tabs.stats.categories".t(context)), - const SizedBox(height: 8.0), - Frame(child: MostSpendingCategory(range: range)), - const SizedBox(height: 12.0), - Frame( - child: Align( - alignment: AlignmentDirectional.topEnd, - child: TextButton.icon( - onPressed: () => context.push( - "/stats/category?range=${Uri.encodeQueryComponent(range.encodeShort())}", - ), - label: Text( - "tabs.stats.categories.seeAll".t(context), - ), - icon: const LeChevron(), - iconAlignment: IconAlignment.end, - ), - ), - ), - const SizedBox(height: 24.0), - const SizedBox(height: 96.0), - ], - ), - ), - ) - : SafeArea(child: NoData()), + child: SingleChildScrollView( + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + AnalyticsBento(range: range), + const SizedBox(height: 96.0), + ], + ), + ), + ), ), ], ); @@ -328,107 +51,11 @@ class _StatsTabState extends State void updateRange(TimeRange value) { range = value; - fetch(); if (!mounted) return; setState(() {}); } - Future fetch() async { - // In debug the Stats tab renders the bento dashboard, whose tiles each - // fetch their own data; the interval reports below are unused there. - if (flowDebugMode) return; - - setState(() { - busy = true; - }); - - try { - final String primaryCurrency = UserPreferencesService().primaryCurrency; - - transactions = await ObjectBox().transcationsByRange( - range, - includeTransfers: false, - ); - - final TimeRange? previousRange = range is PageableRange - ? (range as PageableRange).last - : null; - - final List? previousRangeTransactions = previousRange != null - ? await ObjectBox().transcationsByRange( - previousRange, - includeTransfers: false, - ) - : null; - - final RangeData currentRangeData = RangeData( - range: range, - transactions: transactions, - ); - RangeData previousRangeData = previousRange != null - ? RangeData( - range: previousRange, - transactions: previousRangeTransactions ?? [], - ) - : RangeData( - range: CustomTimeRange( - range.from - range.duration, - range.to - range.duration, - ), - transactions: [], - ); - - // report = await FlowStandardReport.generate(range, rates); - - rangeForecastReport = - (previousRange != null && previousRangeData.transactions.isNotEmpty) - ? RangeForecastReport( - rates: rates, - primaryCurrency: primaryCurrency, - previousRangeData: previousRangeData, - currentRangeData: currentRangeData, - ) - : null; - - final Duration interval = RangeData.getOptimalInterval(range); - - intervalFlowReport = IntervalFlowReport( - interval: interval, - rangeData: currentRangeData, - rates: rates, - primaryCurrency: primaryCurrency, - ); - previousIntervalFlowReport = previousRange != null - ? IntervalFlowReport( - interval: interval, - rangeData: previousRangeData, - rates: rates, - primaryCurrency: primaryCurrency, - ) - : null; - trendsReport = TrendsReport( - rates: rates, - primaryCurrency: primaryCurrency, - transactions: transactions, - ); - } finally { - busy = false; - - if (mounted) { - setState(() {}); - } - } - } - - void _updateRates() { - rates = ExchangeRatesService().getPrimaryCurrencyRates(); - fetch(); - if (mounted) { - setState(() {}); - } - } - @override bool get wantKeepAlive => true; } diff --git a/lib/routes/stats/cash_flow_page.dart b/lib/routes/stats/cash_flow_page.dart index 27c62655..3b5f2c53 100644 --- a/lib/routes/stats/cash_flow_page.dart +++ b/lib/routes/stats/cash_flow_page.dart @@ -1,14 +1,16 @@ +import "package:auto_size_text/auto_size_text.dart"; +import "package:flow/data/flow_standard_report.dart"; import "package:flow/data/money.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/actions.dart"; -import "package:flow/theme/primary_colors.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/primary_currency_dependent_state.dart"; import "package:flow/widgets/analytics/sankey_diagram.dart"; import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/home/stats/info_card_with_delta.dart"; import "package:flow/widgets/stats/cash_flow/cash_flow_legend.dart"; import "package:flow/widgets/stats/cash_flow/cash_flow_summary.dart"; import "package:flow/widgets/stats/missing_rates_notice.dart"; @@ -45,16 +47,32 @@ class _CashFlowPageState extends State double totalIncome = 0.0; double totalExpense = 0.0; + /// Drives the forecast headline and the daily-average cards. Fetched + /// alongside the Sankey aggregation but kept independent, so the averages + /// still render if the per-category pass fails. + FlowStandardReport? report; + + final AutoSizeGroup _averagesGroup = AutoSizeGroup(); + @override Widget build(BuildContext context) { - final String label = range.format(useRelative: false); final bool hasData = sources.isNotEmpty && targets.isNotEmpty; final double net = totalIncome - totalExpense; + final FlowStandardReport? stats = report; + + // The forecast only means something when the range still has days left to + // run and there's movement to project; for a closed range the projection + // equals the actual total, so it's folded into the summary only here. + Money? forecast; + if (stats != null && + range.contains(DateTime.now()) && + (stats.incomeSum.amount != 0 || stats.expenseSum.amount != 0)) { + forecast = stats.currentExpenseSumForecast ?? stats.expenseSum; + } + return Scaffold( - appBar: StatsAppBar( - title: "${"tabs.stats.analytics.cashFlow".t(context)} · $label", - ), + appBar: StatsAppBar(title: "tabs.stats.analytics.cashFlow".t(context)), body: SafeArea( child: busy && sources.isEmpty ? const Spinner.center() @@ -74,6 +92,12 @@ class _CashFlowPageState extends State income: Money(totalIncome, primaryCurrency), expense: Money(totalExpense, primaryCurrency), net: Money(net, primaryCurrency), + forecast: forecast, + forecastComparison: stats?.previousExpenseSum, + forecastLabel: "tabs.stats.intervalReport.forecast".t( + context, + range.format(), + ), ), const SizedBox(height: 16.0), if (failed) @@ -84,7 +108,10 @@ class _CashFlowPageState extends State ) else if (hasData) ...[ Frame( - child: SankeyDiagram(sources: sources, targets: targets), + child: SankeyDiagram( + sources: sources, + targets: targets, + ), ), const SizedBox(height: 24.0), ListHeader("tabs.stats.analytics.income".t(context)), @@ -100,6 +127,12 @@ class _CashFlowPageState extends State context, ), ), + if (stats != null && + (stats.incomeSum.amount != 0 || + stats.expenseSum.amount != 0)) ...[ + const SizedBox(height: 24.0), + _buildAverages(context, stats), + ], if (missingRates) ...[ const SizedBox(height: 12.0), MissingRatesNotice( @@ -122,6 +155,57 @@ class _CashFlowPageState extends State fetch(); } + /// Per-day averages for expense, income, and flow, each with a delta against + /// the previous comparable period when one exists. + Widget _buildAverages(BuildContext context, FlowStandardReport stats) { + return Column( + crossAxisAlignment: .start, + children: [ + ListHeader("tabs.stats.intervalReport.averages@day".t(context)), + const SizedBox(height: 8.0), + Frame( + child: Column( + spacing: 16.0, + children: [ + Row( + spacing: 16.0, + children: [ + Expanded( + child: InfoCardWithDelta( + title: "tabs.stats.intervalReport.averages.expense".t( + context, + ), + autoSizeGroup: _averagesGroup, + money: stats.dailyAvgExpenditure, + previousMoney: stats.previousDailyAvgExpenditure, + invertDelta: true, + ), + ), + Expanded( + child: InfoCardWithDelta( + title: "tabs.stats.intervalReport.averages.income".t( + context, + ), + autoSizeGroup: _averagesGroup, + money: stats.dailyAvgIncome, + previousMoney: stats.previousDailyAvgIncome, + ), + ), + ], + ), + InfoCardWithDelta( + title: "tabs.stats.intervalReport.averages.flow".t(context), + autoSizeGroup: _averagesGroup, + money: stats.dailyAvgFlow, + previousMoney: stats.previousDailyAvgFlow, + ), + ], + ), + ), + ], + ); + } + @override Future fetch() async { if (!mounted) return; @@ -129,6 +213,14 @@ class _CashFlowPageState extends State busy = true; }); + // Powers the forecast + averages; isolated from the Sankey aggregation so a + // failure on either side doesn't blank out the other. + try { + report = await FlowStandardReport.generate(range, rates); + } catch (_) { + report = null; + } + bool missing = false; bool error = false; @@ -142,6 +234,7 @@ class _CashFlowPageState extends State final Color otherColor = context.colorScheme.onSurface.withAlpha(0x66); final Color incomeColor = context.flowColors.income; final Color expenseColor = context.flowColors.expense; + final List palette = context.chartAccents; final List incomeNodes = []; final List expenseNodes = []; @@ -159,7 +252,7 @@ class _CashFlowPageState extends State "tabs.stats.analytics.uncategorized".tr(); final Color color = flow.associatedData?.colorScheme?.primary ?? - accentColors[colorIndex++ % accentColors.length]; + palette[colorIndex++ % palette.length]; final double incomeAmount = single.totalIncome.amount; final double expenseAmount = single.totalExpense.amount.abs(); diff --git a/lib/routes/stats/insights_page.dart b/lib/routes/stats/insights_page.dart new file mode 100644 index 00000000..2af6fbb2 --- /dev/null +++ b/lib/routes/stats/insights_page.dart @@ -0,0 +1,60 @@ +import "package:flow/l10n/extensions.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/home/stats/bento/calendar_tile.dart"; +import "package:flow/widgets/home/stats/bento/map_tile.dart"; +import "package:flow/widgets/home/stats/bento/net_worth_tile.dart"; +import "package:flow/widgets/home/stats/bento/recurring_tile.dart"; +import "package:flow/widgets/home/stats/bento/wrapped_tile.dart"; +import "package:flow/widgets/stats/stats_app_bar.dart"; +import "package:flutter/material.dart"; + +/// Index of the analytics ("Insights") pages. +/// +/// Shows the same bento previews as the Stats tab's timeless section behind a +/// single Profile tab entry, so the insights are discoverable from one place +/// without crowding the menu. Only the range-independent tiles live here — the +/// range-bound ones (cash flow, top categories) stay on the Stats tab — so this +/// page needs no time-range selector. Each tile loads its own preview data and +/// pushes the corresponding `/stats/*` detail page on tap. +class InsightsPage extends StatelessWidget { + const InsightsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: StatsAppBar(title: "tabs.stats.insights".t(context)), + // Tiles have fixed heights, so cap text scaling to keep dense previews + // from overflowing under large accessibility font settings. + body: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.3, + child: SingleChildScrollView( + child: SafeArea( + top: false, + child: Frame( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + const WrappedTile(), + const SizedBox(height: 12.0), + const NetWorthTile(), + const SizedBox(height: 12.0), + const Row( + spacing: 12.0, + children: [ + Expanded(child: CalendarTile()), + Expanded(child: RecurringTile()), + ], + ), + const SizedBox(height: 12.0), + const MapTile(), + const SizedBox(height: 24.0), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/routes/stats/net_worth_page.dart b/lib/routes/stats/net_worth_page.dart index c63aafd2..c80722bd 100644 --- a/lib/routes/stats/net_worth_page.dart +++ b/lib/routes/stats/net_worth_page.dart @@ -131,9 +131,8 @@ class _NetWorthPageState extends State height: 120.0, child: Center( child: Text( - "tabs.stats.analytics.netWorth.notEnoughHistory".t( - context, - ), + "tabs.stats.analytics.netWorth.notEnoughHistory" + .t(context), ), ), ), @@ -147,7 +146,9 @@ class _NetWorthPageState extends State ), ], const SizedBox(height: 32.0), - ListHeader("tabs.stats.analytics.netWorth.byAccount".t(context)), + ListHeader( + "tabs.stats.analytics.netWorth.byAccount".t(context), + ), const SizedBox(height: 8.0), ..._buildShareRows(context), const SizedBox(height: 96.0), diff --git a/lib/routes/stats/recurring_page.dart b/lib/routes/stats/recurring_page.dart index 07334e02..81e31431 100644 --- a/lib/routes/stats/recurring_page.dart +++ b/lib/routes/stats/recurring_page.dart @@ -1,4 +1,5 @@ import "package:flow/data/money.dart"; +import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/category.dart"; import "package:flow/entity/recurring_transaction.dart"; import "package:flow/entity/transaction.dart"; @@ -6,10 +7,13 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/services/categories.dart"; import "package:flow/services/recurring_transactions.dart"; +import "package:flow/services/transactions.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions.dart"; +import "package:flow/utils/extensions/recurring_transaction.dart"; import "package:flow/utils/primary_currency_dependent_state.dart"; import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/stats/missing_rates_notice.dart"; import "package:flow/widgets/stats/recurring/recurring_summary_header.dart"; @@ -19,6 +23,7 @@ import "package:flow/widgets/time_range_selector.dart"; import "package:flow/widgets/transaction_list_tile.dart"; import "package:flow/widgets/transactions_date_header.dart"; import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; import "package:moment_dart/moment_dart.dart"; /// Subscriptions & recurring radar. @@ -51,6 +56,11 @@ class _RecurringPageState extends State double totalIncome = 0.0; double totalExpense = 0.0; + /// Maps an already-logged occurrence's list key to its real transaction id. + /// Occurrences absent here are still upcoming previews — badged with an eye + /// and non-openable. + Map _loggedIdByKey = {}; + @override Widget build(BuildContext context) { final List displayed = occurrences.take(_maxRows).toList(); @@ -66,12 +76,6 @@ class _RecurringPageState extends State child: Column( crossAxisAlignment: .start, children: [ - const SizedBox(height: 16.0), - RecurringSummaryHeader( - income: Money(totalIncome, primaryCurrency), - expense: Money(totalExpense, primaryCurrency), - count: occurrences.length, - ), const SizedBox(height: 16.0), Frame( child: TimeRangeSelector( @@ -80,21 +84,35 @@ class _RecurringPageState extends State ), ), const SizedBox(height: 16.0), - if (activeCount == 0) - StatsEmptyState( + ListHeader( + "tabs.stats.analytics.recurring.projectedTitle".t( + context, + ), + ), + const SizedBox(height: 8.0), + RecurringSummaryHeader( + income: Money(totalIncome, primaryCurrency), + expense: Money(totalExpense, primaryCurrency), + count: occurrences.length, + ), + const SizedBox(height: 16.0), + switch ((activeCount, occurrences.isEmpty)) { + (0, _) => StatsEmptyState( message: "tabs.stats.analytics.recurring.none".t( context, ), - ) - else if (occurrences.isEmpty) - StatsEmptyState( + ), + (_, true) => StatsEmptyState( message: "tabs.stats.analytics.recurring.nothingUpcoming".t( context, ), - ) - else - ..._buildGroups(context, grouped), + ), + (_, false) => Column( + crossAxisAlignment: .start, + children: _buildGroups(context, grouped), + ), + }, if (hidden > 0) ...[ const SizedBox(height: 8.0), Frame( @@ -142,19 +160,26 @@ class _RecurringPageState extends State ); for (final Transaction transaction in entry.value) { - // Projections aren't real rows: render them read-only so the universal - // tile's tap/swipe affordances don't act on a non-existent entity. + // Projections aren't real rows. [IgnorePointer] suppresses the tile's + // own tap (which opens a transaction by id a projection lacks) and its + // swipe (which acts on a real entity); the outer [GestureDetector] adds + // back a tap-only affordance that opens the logged entry, or toasts if + // this occurrence is still just a projection. rows.add( - AbsorbPointer( - child: TransactionListTile( - key: ValueKey( - "${transaction.uuid}-" - "${transaction.transactionDate.microsecondsSinceEpoch}", + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openOccurrence(context, transaction), + child: IgnorePointer( + child: TransactionListTile( + key: ValueKey(_occurrenceKey(transaction)), + transaction: transaction, + recoverFromTrashFn: null, + moveToTrashFn: null, + combineTransfers: false, + preview: !_loggedIdByKey.containsKey( + _occurrenceKey(transaction), + ), ), - transaction: transaction, - recoverFromTrashFn: null, - moveToTrashFn: null, - combineTransfers: false, ), ), ); @@ -170,6 +195,39 @@ class _RecurringPageState extends State fetch(); } + /// Stable per-occurrence key (rule template uuid + date), shared by the row's + /// [ValueKey] and the [_loggedIdByKey] lookup. + String _occurrenceKey(Transaction occurrence) => + "${occurrence.uuid}-" + "${occurrence.transactionDate.microsecondsSinceEpoch}"; + + /// Opens the real transaction behind a tapped occurrence when it's already + /// been logged; otherwise explains it's still an upcoming projection. + void _openOccurrence(BuildContext context, Transaction occurrence) { + final int? loggedId = _loggedIdByKey[_occurrenceKey(occurrence)]; + + if (loggedId == null) { + context.showToast( + text: "tabs.stats.analytics.recurring.notLoggedYet".t(context), + type: .info, + ); + return; + } + + context.push("/transaction/$loggedId"); + } + + /// The real transaction a rule logged on [date], if any (matched by day). + Transaction? _matchLogged(List logged, DateTime date) { + for (final Transaction transaction in logged) { + final DateTime d = transaction.transactionDate; + if (d.year == date.year && d.month == date.month && d.day == date.day) { + return transaction; + } + } + return null; + } + @override Future fetch() async { if (!mounted) return; @@ -185,6 +243,7 @@ class _RecurringPageState extends State query.close(); final List result = []; + final Map loggedIds = {}; double income = 0.0; double expense = 0.0; int active = 0; @@ -195,6 +254,12 @@ class _RecurringPageState extends State active++; + // Real transactions this rule has already generated; occurrences that + // match one (by day) are actual entries, not previews. + final List logged = TransactionsService().findManySync( + TransactionFilter(extraTag: recurring.extensionIdentifierTag), + ); + final String? categoryUuid = recurring.template.categoryUuid; final Category? category = categoryUuid == null ? null @@ -214,6 +279,11 @@ class _RecurringPageState extends State result.add(occurrence); + final Transaction? loggedMatch = _matchLogged(logged, date); + if (loggedMatch != null) { + loggedIds[_occurrenceKey(occurrence)] = loggedMatch.id; + } + final double? converted = occurrence.money.tryConvertAmount( primaryCurrency, rates, @@ -237,6 +307,7 @@ class _RecurringPageState extends State result.sort((a, b) => a.transactionDate.compareTo(b.transactionDate)); occurrences = result; + _loggedIdByKey = loggedIds; activeCount = active; totalIncome = income; totalExpense = expense; diff --git a/lib/routes/stats/spending_map_page.dart b/lib/routes/stats/spending_map_page.dart index 87ef2c24..89f7b556 100644 --- a/lib/routes/stats/spending_map_page.dart +++ b/lib/routes/stats/spending_map_page.dart @@ -84,6 +84,13 @@ class _SpendingMapPageState extends State child: Column( crossAxisAlignment: .start, children: [ + const SizedBox(height: 16.0), + Frame( + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, + ), + ), const SizedBox(height: 16.0), Frame( child: Column( @@ -112,13 +119,6 @@ class _SpendingMapPageState extends State ), ), const SizedBox(height: 16.0), - Frame( - child: TimeRangeSelector( - initialValue: range, - onChanged: _updateRange, - ), - ), - const SizedBox(height: 16.0), if (hasData) Frame(child: _buildMap(context)) else diff --git a/lib/routes/stats/wrapped_page.dart b/lib/routes/stats/wrapped_page.dart index 10c602ba..cdfc3edc 100644 --- a/lib/routes/stats/wrapped_page.dart +++ b/lib/routes/stats/wrapped_page.dart @@ -11,12 +11,14 @@ import "package:flow/utils/extensions.dart"; import "package:flow/utils/primary_currency_dependent_state.dart"; import "package:flow/widgets/analytics/insight_card.dart"; import "package:flow/widgets/analytics/weekday_bars.dart"; +import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/stats/emphasized_text.dart"; import "package:flow/widgets/stats/missing_rates_notice.dart"; import "package:flow/widgets/stats/stats_app_bar.dart"; import "package:flow/widgets/stats/stats_empty_state.dart"; import "package:flow/widgets/stats/wrapped/mini_bars.dart"; +import "package:flow/widgets/time_range_selector.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; @@ -38,6 +40,10 @@ class _WrappedPageState extends State bool busy = false; bool missingRates = false; + /// The period being "wrapped". The selector pages this; the insight cards + /// compare it against its trailing periods (see [_recentPeriods]). + TimeRange range = TimeRange.thisMonth(); + List thisMonthTransactions = []; TrendsReport? trends; @@ -56,44 +62,59 @@ class _WrappedPageState extends State @override Widget build(BuildContext context) { - final String month = DateTime.now().toMoment().format("MMMM"); - return Scaffold( - appBar: StatsAppBar( - title: "tabs.stats.analytics.wrapped.title".t(context, month), - ), + appBar: StatsAppBar(title: "tabs.stats.analytics.wrapped".t(context)), body: SafeArea( - child: busy && trends == null - ? const Spinner.center() - : SingleChildScrollView( - child: Column( - crossAxisAlignment: .start, - children: [ - const SizedBox(height: 8.0), - if (thisMonthTransactions.isEmpty) - StatsEmptyState( - message: "tabs.stats.analytics.wrapped.noTransactions".t( - context, - ), - ) - else - ..._buildInsightCards(context), - if (missingRates) ...[ - const SizedBox(height: 8.0), - MissingRatesNotice( - message: "tabs.stats.analytics.missingRatesAmounts".t( - context, - ), - ), - ], - const SizedBox(height: 96.0), - ], - ), + child: Column( + children: [ + Frame.standalone( + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, ), + ), + Expanded( + child: busy && trends == null + ? const Spinner.center() + : SingleChildScrollView( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 8.0), + if (thisMonthTransactions.isEmpty) + StatsEmptyState( + message: + "tabs.stats.analytics.wrapped.noTransactions" + .t(context), + ) + else + ..._buildInsightCards(context), + if (missingRates) ...[ + const SizedBox(height: 8.0), + MissingRatesNotice( + message: + "tabs.stats.analytics.missingRatesAmounts".t( + context, + ), + ), + ], + const SizedBox(height: 96.0), + ], + ), + ), + ), + ], + ), ), ); } + void _updateRange(TimeRange value) { + if (value == range) return; + range = value; + fetch(); + } + List _buildInsightCards(BuildContext context) { return [ if (topCategory != null || topCategoryCurrent > 0) @@ -187,7 +208,8 @@ class _WrappedPageState extends State ? "tabs.stats.analytics.wrapped.noExpenses".t(context) : "tabs.stats.analytics.wrapped.biggest".t(context, { "title": - biggestExpense!.title ?? "tabs.stats.analytics.untitled".t(context), + biggestExpense!.title ?? + "tabs.stats.analytics.untitled".t(context), "amount": Money(biggestExpenseConverted, primaryCurrency).formatted, "date": biggestExpense!.transactionDate.toMoment().format("MMM D"), }); @@ -213,17 +235,17 @@ class _WrappedPageState extends State bool missing = false; try { - final List months = _recentMonths(4); + final List periods = _recentPeriods(range, 4); final FlowAnalytics current = await ObjectBox() - .flowByCategories(range: months.first); + .flowByCategories(range: periods.first); final List> previous = []; - for (final TimeRange range in months.skip(1)) { - previous.add(await ObjectBox().flowByCategories(range: range)); + for (final TimeRange period in periods.skip(1)) { + previous.add(await ObjectBox().flowByCategories(range: period)); } thisMonthTransactions = await ObjectBox().transcationsByRange( - months.first, + periods.first, includeTransfers: false, ); @@ -339,13 +361,33 @@ class _WrappedPageState extends State return missing; } - List _recentMonths(int count) { - final List months = [TimeRange.thisMonth()]; + /// The selected [anchor] plus the [count] - 1 immediately preceding periods, + /// newest first. + /// + /// Pageable ranges (week/month/year) page backwards via [PageableRange.last]. + /// Non-pageable custom ranges fall back to equal-length preceding spans so the + /// period-over-period comparison degrades sensibly instead of repeating the + /// same range. Unbounded ranges (all-time) have no meaningful preceding + /// period — and their span overflows [DateTime] arithmetic — so the list + /// simply stops at the anchor. + List _recentPeriods(TimeRange anchor, int count) { + final List periods = [anchor]; for (int i = 1; i < count; i++) { - final TimeRange previous = months.last; - months.add(previous is PageableRange ? previous.last : previous); + final TimeRange previous = periods.last; + if (previous is PageableRange) { + periods.add(previous.last); + } else { + if (previous.from <= Moment.minValue || + previous.to >= Moment.maxValue) { + break; + } + final Duration span = previous.duration; + periods.add( + CustomTimeRange(previous.from.subtract(span), previous.from), + ); + } } - return months; + return periods; } String _weekdayName(int weekday) { diff --git a/lib/theme/color_themes/flow/flow_lights.dart b/lib/theme/color_themes/flow/flow_lights.dart index 350ca6b7..19aab6fb 100644 --- a/lib/theme/color_themes/flow/flow_lights.dart +++ b/lib/theme/color_themes/flow/flow_lights.dart @@ -16,8 +16,12 @@ final FlowColorScheme _defaultLightBase = FlowColorScheme( secondary: const Color(0xfff5ccff), onSecondary: const Color(0xff33004f), customColors: FlowCustomColors( - income: Color(0xFF32CC70), - expense: Color(0xFFFF4040), + // Deepened from the punchy web green/red so they stay legible on the pale + // pastel `secondary` card (and the near-white surface) across every light + // scheme — the bright originals dropped to ~1.5:1 on the cards. Shared by + // all 16 light schemes via copyWith. See also [monochrome]. + income: Color(0xFF15803D), + expense: Color(0xFFC42525), semi: Color(0xFF6A666D), ), ); // contrast: 5.82456471142964 diff --git a/lib/theme/color_themes/monochrome.dart b/lib/theme/color_themes/monochrome.dart index df1e5cbc..1ff16ffd 100644 --- a/lib/theme/color_themes/monochrome.dart +++ b/lib/theme/color_themes/monochrome.dart @@ -12,8 +12,10 @@ final FlowColorScheme monochrome = FlowColorScheme( secondary: const Color(0xfff1f2f4), onSecondary: const Color(0xff101828), customColors: FlowCustomColors( - income: Color(0xFF32CC70), - expense: Color(0xFFFF4040), + // Match the deepened light-theme pair so income/expense stay legible on the + // light grey `secondary` card and surface. See [flowLights]. + income: Color(0xFF15803D), + expense: Color(0xFFC42525), semi: Color(0xFF6A666D), ), ); diff --git a/lib/theme/helpers.dart b/lib/theme/helpers.dart index c561f2cd..5e66ede5 100644 --- a/lib/theme/helpers.dart +++ b/lib/theme/helpers.dart @@ -1,6 +1,7 @@ import "package:flow/entity/transaction.dart"; import "package:flow/theme/flow_custom_colors.dart"; import "package:flow/theme/pie_theme_extension.dart"; +import "package:flow/theme/primary_colors.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:pie_menu/pie_menu.dart"; @@ -12,6 +13,16 @@ extension ThemeAccessor on BuildContext { Theme.of(this).extension()!; PieTheme get pieTheme => Theme.of(this).extension()!.pieTheme; + + /// Palette for auto-assigning colors to chart series (category slices, Sankey + /// nodes) that lack their own color. The pale [accentColors] read well on + /// dark surfaces but wash out on light ones, so fall back to the saturated, + /// contrast-checked [primaryColors] in light mode. Both lists share a length, + /// so callers can index either with `% length`. + List get chartAccents => + Theme.of(this).brightness == Brightness.dark + ? accentColors + : primaryColors; } extension TextStyleHelper on TextStyle { diff --git a/lib/widgets/analytics/bullet_chart.dart b/lib/widgets/analytics/bullet_chart.dart index ff80ab85..ec632385 100644 --- a/lib/widgets/analytics/bullet_chart.dart +++ b/lib/widgets/analytics/bullet_chart.dart @@ -50,23 +50,32 @@ class BulletChart extends StatelessWidget { child: Stack( children: [ // Full track. - Container( - decoration: BoxDecoration( - color: track, - borderRadius: BorderRadius.all(Radius.circular(height / 2)), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: track, + borderRadius: BorderRadius.all(Radius.circular(height / 2)), + ), ), ), // Qualitative band up to the target. - Container( - width: targetX, - decoration: BoxDecoration( - color: band, - borderRadius: BorderRadius.all(Radius.circular(height / 2)), + Positioned( + left: 0.0, + top: 0.0, + bottom: 0.0, + child: Container( + width: targetX, + decoration: BoxDecoration( + color: band, + borderRadius: BorderRadius.all(Radius.circular(height / 2)), + ), ), ), - // Measure bar. - Padding( - padding: EdgeInsets.symmetric(vertical: height * 0.28), + // Measure bar, inset vertically so the track reads behind it. + Positioned( + left: 0.0, + top: height * 0.28, + bottom: height * 0.28, child: Container( width: valueWidth, decoration: BoxDecoration( diff --git a/lib/widgets/analytics/insight_card.dart b/lib/widgets/analytics/insight_card.dart index d79d76a9..9541c9e7 100644 --- a/lib/widgets/analytics/insight_card.dart +++ b/lib/widgets/analytics/insight_card.dart @@ -46,7 +46,7 @@ class InsightCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, mainAxisSize: MainAxisSize.min, children: [ if (hasHeader) ...[ @@ -57,7 +57,7 @@ class InsightCard extends StatelessWidget { const SizedBox(width: 8.0), ], if (label != null) - _Pill(label: label!, accent: resolvedAccent), + _buildPill(context, label!, resolvedAccent), ], ), const SizedBox(height: 10.0), @@ -82,16 +82,8 @@ class InsightCard extends StatelessWidget { ), ); } -} - -class _Pill extends StatelessWidget { - final String label; - final Color accent; - - const _Pill({required this.label, required this.accent}); - @override - Widget build(BuildContext context) { + Widget _buildPill(BuildContext context, String label, Color accent) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), decoration: BoxDecoration( diff --git a/lib/widgets/analytics/spending_heatmap.dart b/lib/widgets/analytics/spending_heatmap.dart index 0837f8ae..a348ec71 100644 --- a/lib/widgets/analytics/spending_heatmap.dart +++ b/lib/widgets/analytics/spending_heatmap.dart @@ -1,4 +1,5 @@ import "package:flow/data/money.dart"; +import "package:flow/l10n/extensions.dart"; import "package:flow/theme/theme.dart"; import "package:flutter/material.dart"; import "package:moment_dart/moment_dart.dart"; @@ -44,10 +45,10 @@ class SpendingHeatmap extends StatelessWidget { final double columnWidth = cellSize + gap; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ _WeekdayLabels( cellSize: cellSize, @@ -59,7 +60,7 @@ class SpendingHeatmap extends StatelessWidget { scrollDirection: Axis.horizontal, reverse: true, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SizedBox( height: _headerHeight, @@ -276,9 +277,12 @@ class _Legend extends StatelessWidget { @override Widget build(BuildContext context) { return Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: .end, children: [ - Text("Less", style: context.textTheme.labelSmall?.semi(context)), + Text( + "tabs.stats.analytics.heatmap.less".t(context), + style: context.textTheme.labelSmall?.semi(context), + ), const SizedBox(width: 6.0), ...List.generate(5, (level) { return Padding( @@ -294,7 +298,10 @@ class _Legend extends StatelessWidget { ); }), const SizedBox(width: 6.0), - Text("More", style: context.textTheme.labelSmall?.semi(context)), + Text( + "tabs.stats.analytics.heatmap.more".t(context), + style: context.textTheme.labelSmall?.semi(context), + ), ], ); } diff --git a/lib/widgets/analytics/weekday_bars.dart b/lib/widgets/analytics/weekday_bars.dart index 18c14426..67ced6ee 100644 --- a/lib/widgets/analytics/weekday_bars.dart +++ b/lib/widgets/analytics/weekday_bars.dart @@ -29,7 +29,7 @@ class WeekdayBars extends StatelessWidget { return SizedBox( height: 56.0, child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: .end, children: List.generate(7, (index) { final int weekday = index + 1; final double value = byWeekday[weekday] ?? 0.0; diff --git a/lib/widgets/home/stats/bento/analytics_bento.dart b/lib/widgets/home/stats/bento/analytics_bento.dart index 7728e9d7..f131850e 100644 --- a/lib/widgets/home/stats/bento/analytics_bento.dart +++ b/lib/widgets/home/stats/bento/analytics_bento.dart @@ -1,9 +1,11 @@ import "package:flow/l10n/extensions.dart"; +import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/list_header.dart"; import "package:flow/widgets/home/stats/bento/calendar_tile.dart"; import "package:flow/widgets/home/stats/bento/cash_flow_tile.dart"; import "package:flow/widgets/home/stats/bento/map_tile.dart"; import "package:flow/widgets/home/stats/bento/net_worth_tile.dart"; +import "package:flow/widgets/home/stats/bento/pace_tile.dart"; import "package:flow/widgets/home/stats/bento/recurring_tile.dart"; import "package:flow/widgets/home/stats/bento/top_categories_tile.dart"; import "package:flow/widgets/home/stats/bento/wrapped_tile.dart"; @@ -12,9 +14,9 @@ import "package:moment_dart/moment_dart.dart"; /// The Stats bento dashboard, split into two sections. /// -/// The top section is **range-bound**: [CashFlowTile] and [TopCategoriesTile] -/// follow the [range] picked by the Stats tab's time-range selector, so they -/// belong directly beneath it. +/// The top section is **range-bound**: [CashFlowTile], [PaceTile], and +/// [TopCategoriesTile] follow the [range] picked by the Stats tab's time-range +/// selector, so they belong directly beneath it. /// /// Below an "Insights" header sits the **timeless** section: net worth, /// wrapped, the spending calendar, recurring, and the spending map each show @@ -32,14 +34,15 @@ class AnalyticsBento extends StatelessWidget { // from overflowing under large accessibility font settings. return MediaQuery.withClampedTextScaling( maxScaleFactor: 1.3, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Frame( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ // Range-bound — these respond to the selected time range. CashFlowTile(range: range), const SizedBox(height: 12.0), + PaceTile(range: range), + const SizedBox(height: 12.0), TopCategoriesTile(range: range), const SizedBox(height: 24.0), // Timeless — each shows its own natural window, independent of the diff --git a/lib/widgets/home/stats/bento/net_worth_tile.dart b/lib/widgets/home/stats/bento/net_worth_tile.dart index 8bf53d26..987b0836 100644 --- a/lib/widgets/home/stats/bento/net_worth_tile.dart +++ b/lib/widgets/home/stats/bento/net_worth_tile.dart @@ -57,7 +57,10 @@ class _NetWorthTileState extends State const SizedBox(height: 4.0), MoneyDeltaLabel( delta: Money(currentAmount - firstAmount, primaryCurrency), - suffixLabel: "tabs.stats.analytics.inRange".t(context, "${_months}M"), + suffixLabel: "tabs.stats.analytics.inRange".t( + context, + "${_months}M", + ), iconSize: 16.0, initiallyAbbreviated: true, suffixStyle: context.textTheme.bodySmall?.semi(context), @@ -90,10 +93,9 @@ class _NetWorthTileState extends State double total = 0.0; for (final Account account in accounts) { total += - account.balanceAt(anchor).tryConvertAmount( - primaryCurrency, - rates, - ) ?? + account + .balanceAt(anchor) + .tryConvertAmount(primaryCurrency, rates) ?? 0.0; } return total; diff --git a/lib/widgets/home/stats/bento/pace_tile.dart b/lib/widgets/home/stats/bento/pace_tile.dart new file mode 100644 index 00000000..5e212c37 --- /dev/null +++ b/lib/widgets/home/stats/bento/pace_tile.dart @@ -0,0 +1,151 @@ +import "package:flow/data/flow_standard_report.dart"; +import "package:flow/data/money.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/home/stats/bento/bento_tile.dart"; +import "package:flow/widgets/trend.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Bento preview of spending pace for the selected range. +/// +/// When the range includes today it headlines the projected end-of-range +/// expense (extrapolating the average daily spend over the days left); +/// otherwise it shows the range's total spend. Either way it carries a trend +/// against the previous period and the average spent per day. Taps through to +/// the full forecast + averages on the cash-flow page. +class PaceTile extends StatefulWidget { + final TimeRange range; + + const PaceTile({super.key, required this.range}); + + @override + State createState() => _PaceTileState(); +} + +class _PaceTileState extends State + with PrimaryCurrencyDependentState { + bool busy = true; + bool loaded = false; + + FlowStandardReport? report; + + @override + void didUpdateWidget(PaceTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.range != oldWidget.range) fetch(); + } + + @override + Widget build(BuildContext context) { + final FlowStandardReport? report = this.report; + + final bool empty = + report == null || + (report.incomeSum.amount.abs() <= 0 && + report.expenseSum.amount.abs() <= 0); + + return BentoTile( + label: "tabs.stats.analytics.pace".t(context), + icon: Symbols.speed_rounded, + accent: context.flowColors.expense, + height: 160.0, + busy: busy && !loaded, + onTap: () => context.push("/stats/cash-flow"), + child: empty + ? Text( + "tabs.stats.analytics.cashFlow.noMovement".t(context), + style: context.textTheme.bodySmall?.semi(context), + ) + : _buildContent(context, report), + ); + } + + Widget _buildContent(BuildContext context, FlowStandardReport report) { + final bool forecasting = widget.range.contains(DateTime.now()); + + final Money headline = forecasting + ? (report.currentExpenseSumForecast ?? report.expenseSum) + : report.expenseSum; + + return Column( + crossAxisAlignment: .start, + children: [ + Text( + (forecasting + ? "tabs.stats.analytics.pace.projected" + : "tabs.stats.analytics.pace.totalSpent") + .t(context), + style: context.textTheme.labelMedium?.semi(context), + ), + const SizedBox(height: 2.0), + Row( + children: [ + Expanded( + child: MoneyText( + headline, + displayAbsoluteAmount: true, + style: context.textTheme.titleLarge?.copyWith( + color: context.flowColors.expense, + fontWeight: FontWeight.w700, + ), + autoSize: true, + initiallyAbbreviated: true, + ), + ), + if (report.previousExpenseSum != null) ...[ + const SizedBox(width: 8.0), + Trend.fromMoney( + current: headline, + previous: report.previousExpenseSum, + ), + ], + ], + ), + const Spacer(), + Row( + children: [ + Text( + "tabs.stats.analytics.pace.perDay".t(context), + style: context.textTheme.bodySmall?.semi(context), + ), + const Spacer(), + Flexible( + child: MoneyText( + report.dailyAvgExpenditure, + displayAbsoluteAmount: true, + style: context.textTheme.titleSmall?.copyWith( + color: context.flowColors.expense, + fontWeight: FontWeight.w600, + ), + autoSize: true, + initiallyAbbreviated: true, + textAlign: TextAlign.end, + ), + ), + ], + ), + ], + ); + } + + @override + Future fetch() async { + try { + final FlowStandardReport next = await FlowStandardReport.generate( + widget.range, + rates, + ); + if (!mounted) return; + report = next; + loaded = true; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } +} diff --git a/lib/widgets/home/stats/bento/recurring_tile.dart b/lib/widgets/home/stats/bento/recurring_tile.dart index c9872fcc..af3fd4d1 100644 --- a/lib/widgets/home/stats/bento/recurring_tile.dart +++ b/lib/widgets/home/stats/bento/recurring_tile.dart @@ -92,6 +92,11 @@ class _RecurringTileState extends State final Transaction? template = _decodeTemplate(recurring); if (template == null) continue; + // Only expenses are "charges". Counting income/transfer recurrings + // here would inflate the upcoming count while contributing nothing to + // the outflow total, leaving the two figures describing different sets. + if (template.type != TransactionType.expense) continue; + final Money? money = _templateMoney(template); if (money == null) continue; @@ -100,14 +105,12 @@ class _RecurringTileState extends State ); count += occurrences.length; - if (template.type == TransactionType.expense) { - final double? converted = money.tryConvertAmount( - primaryCurrency, - rates, - ); - if (converted != null) { - totalOutflow += converted.abs() * occurrences.length; - } + final double? converted = money.tryConvertAmount( + primaryCurrency, + rates, + ); + if (converted != null) { + totalOutflow += converted.abs() * occurrences.length; } } diff --git a/lib/widgets/home/stats/bento/top_categories_tile.dart b/lib/widgets/home/stats/bento/top_categories_tile.dart index a37446a7..7f97db8c 100644 --- a/lib/widgets/home/stats/bento/top_categories_tile.dart +++ b/lib/widgets/home/stats/bento/top_categories_tile.dart @@ -1,7 +1,6 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/actions.dart"; -import "package:flow/theme/primary_colors.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/primary_currency_dependent_state.dart"; import "package:flow/widgets/home/stats/bento/bento_tile.dart"; @@ -84,6 +83,11 @@ class _TopCategoriesTileState extends State try { final analytics = await ObjectBox().flowByCategories(range: widget.range); + // Resolve the theme palette only after the await — the first fetch runs + // from initState (via the mixin), before inherited widgets are readable. + if (!mounted) return; + final List palette = context.chartAccents; + final List<_Slice> next = []; int colorIndex = 0; @@ -101,7 +105,7 @@ class _TopCategoriesTileState extends State "tabs.stats.analytics.uncategorized".tr(); final Color color = flow.associatedData?.colorScheme?.primary ?? - accentColors[colorIndex++ % accentColors.length]; + palette[colorIndex++ % palette.length]; next.add(_Slice(name: name, amount: expense, color: color)); } diff --git a/lib/widgets/stats/cash_flow/cash_flow_figure.dart b/lib/widgets/stats/cash_flow/cash_flow_figure.dart index f1311731..507a0072 100644 --- a/lib/widgets/stats/cash_flow/cash_flow_figure.dart +++ b/lib/widgets/stats/cash_flow/cash_flow_figure.dart @@ -21,7 +21,11 @@ class CashFlowFigure extends StatelessWidget { @override Widget build(BuildContext context) { - final Widget dot = _Dot(color: color); + final Widget dot = Container( + width: 8.0, + height: 8.0, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); final Widget labelText = Text( label, style: context.textTheme.labelSmall?.semi(context), @@ -52,18 +56,3 @@ class CashFlowFigure extends StatelessWidget { ); } } - -class _Dot extends StatelessWidget { - final Color color; - - const _Dot({required this.color}); - - @override - Widget build(BuildContext context) { - return Container( - width: 8.0, - height: 8.0, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ); - } -} diff --git a/lib/widgets/stats/cash_flow/cash_flow_summary.dart b/lib/widgets/stats/cash_flow/cash_flow_summary.dart index ac5d3677..65bbe87f 100644 --- a/lib/widgets/stats/cash_flow/cash_flow_summary.dart +++ b/lib/widgets/stats/cash_flow/cash_flow_summary.dart @@ -6,6 +6,7 @@ import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/general/surface.dart"; import "package:flow/widgets/stats/cash_flow/cash_flow_figure.dart"; import "package:flow/widgets/stats/cash_flow/cash_flow_flow_bar.dart"; +import "package:flow/widgets/trend.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons_flow/symbols.dart"; @@ -19,11 +20,22 @@ class CashFlowSummary extends StatelessWidget { final Money expense; final Money net; + /// Optional projected end-of-range expense, shown as a footer beneath the + /// in/out figures. When null the footer is omitted. [forecastLabel] is the + /// caption (e.g. "Expense forecast for June") and [forecastComparison] the + /// previous period's expense, used for the trend. + final Money? forecast; + final Money? forecastComparison; + final String? forecastLabel; + const CashFlowSummary({ super.key, required this.income, required this.expense, required this.net, + this.forecast, + this.forecastComparison, + this.forecastLabel, }); @override @@ -38,7 +50,7 @@ class CashFlowSummary extends StatelessWidget { builder: (context) => Padding( padding: const EdgeInsets.all(20.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Row( children: [ @@ -59,7 +71,7 @@ class CashFlowSummary extends StatelessWidget { const SizedBox(width: 12.0), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, mainAxisSize: MainAxisSize.min, children: [ Text( @@ -108,6 +120,45 @@ class CashFlowSummary extends StatelessWidget { ), ], ), + if (forecast case final Money forecast) ...[ + const SizedBox(height: 16.0), + Container( + height: 1.0, + color: context.colorScheme.onSurface.withAlpha(0x1a), + ), + const SizedBox(height: 16.0), + Row( + children: [ + Expanded( + child: Text( + forecastLabel ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSecondary.withAlpha( + 0x99, + ), + ), + ), + ), + const SizedBox(width: 8.0), + MoneyText( + forecast, + displayAbsoluteAmount: true, + initiallyAbbreviated: true, + tapToToggleAbbreviation: true, + style: context.textTheme.titleMedium?.copyWith( + color: context.flowColors.expense, + fontWeight: FontWeight.w700, + ), + ), + if (forecastComparison case final Money previous) ...[ + const SizedBox(width: 8.0), + Trend.fromMoney(current: forecast, previous: previous), + ], + ], + ), + ], ], ), ), diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index 22838452..175a37d5 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -58,6 +58,10 @@ class TransactionListTile extends StatelessWidget { /// the leading icon always toggles regardless of [selectionActive]. final VoidCallback? onSelectionToggle; + /// Renders an eye badge on the leading icon to mark the row as a read-only + /// preview — e.g. projected recurring occurrences that aren't real entries. + final bool preview; + const TransactionListTile({ super.key, required this.transaction, @@ -73,6 +77,7 @@ class TransactionListTile extends StatelessWidget { this.selectionActive = false, this.selected = false, this.onSelectionToggle, + this.preview = false, }); @override @@ -171,6 +176,8 @@ class TransactionListTile extends StatelessWidget { final Widget visualLeading = selected ? FlowIcon(FlowIconData.icon(Symbols.check_rounded), plated: true) + : preview + ? _previewBadged(context, buildLeading(context, effectiveTheme)) : buildLeading(context, effectiveTheme); final Widget leading = onSelectionToggle != null @@ -375,6 +382,37 @@ class TransactionListTile extends StatelessWidget { ); } + /// Overlays a small eye badge on the bottom-right of a leading [icon] to flag + /// the row as a non-editable preview. There's no shared badge component in the + /// app, so this is a deliberate one-off. + Widget _previewBadged(BuildContext context, Widget icon) { + return Stack( + clipBehavior: Clip.none, + children: [ + icon, + Positioned( + right: -1.0, + bottom: -1.0, + child: DecoratedBox( + // A surface-colored ring separates the badge from the icon plate. + decoration: BoxDecoration( + color: context.colorScheme.surface, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Icon( + Symbols.visibility_rounded, + size: 12.0, + color: context.flowColors.semi, + ), + ), + ), + ), + ], + ); + } + String get dateString { final DateTime now = Moment.now().startOfNextMinute(); diff --git a/pubspec.lock b/pubspec.lock index 02003d7f..df6e5507 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1170,10 +1170,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.18.3" mgrs_dart: dependency: transitive description: @@ -1895,26 +1895,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -2031,10 +2031,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8da11147..433c68ff 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.23.0+346" +version: "0.23.0+347" environment: sdk: ">=3.10.0 <4.0.0" From 1e4890168c62eeaf1867cbee8bb339138ebd71b7 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 9 Jun 2026 16:18:44 +0800 Subject: [PATCH 6/6] analytics rc1 --- docs/feasibility-self-hosted-sync.md | 167 ++++++++++++++++++ lib/routes/stats/net_worth_page.dart | 14 +- lib/routes/stats/spending_calendar_page.dart | 14 +- .../home/stats/bento/analytics_bento.dart | 13 +- .../home/stats/bento/cash_flow_tile.dart | 107 ++++++----- lib/widgets/home/stats/bento/pace_tile.dart | 16 +- pubspec.lock | 24 +-- pubspec.yaml | 2 +- 8 files changed, 282 insertions(+), 75 deletions(-) create mode 100644 docs/feasibility-self-hosted-sync.md diff --git a/docs/feasibility-self-hosted-sync.md b/docs/feasibility-self-hosted-sync.md new file mode 100644 index 00000000..36632d54 --- /dev/null +++ b/docs/feasibility-self-hosted-sync.md @@ -0,0 +1,167 @@ +# Feasibility — Self-hosted, encrypted, multi-user sync + +**Status:** Assessment only (no code written) +**Reviewed against:** `analytics-overhaul` branch, app version `0.23.0+347` +**Scope of request:** Self-hosted backend + multi-device sync + conflict handling + end-to-end encryption + multi-user shared accounts with per-account permissions. + +--- + +## 1. Verdict up front + +This is **feasible but it is the single largest feature ever proposed for Flow** — it is closer to "build a second product" than "add a feature." The request bundles five things that are each substantial on their own: + +1. A sync engine (change tracking + merge + conflict resolution) +2. A self-hostable backend server (Flow has **zero** backend code today) +3. End-to-end encryption with multi-device key management +4. A multi-user identity + permissions model +5. The UI for all of the above + +Two of the stated requirements — **true E2E encryption** and **server-enforced per-account permissions** — are in direct technical tension (see §5). They can be reconciled, but only with real cryptographic engineering (envelope encryption + key rotation), not a CRUD backend. + +My honest recommendation (§8): **do not build the full request as one project.** Ship a self-hosted *encrypted backup-sync* first (small, reuses existing code, covers the most common real-world case — your own devices and couples sharing one login), then decide whether true collaborative multi-user sync is worth the ongoing security + ops burden. + +--- + +## 2. What Flow is today (the parts that matter here) + +Grounded in the code, not assumptions: + +| Area | Current state | File | +|---|---|---| +| Database | **ObjectBox** embedded NoSQL, single store, **single-user, fully offline** | `lib/objectbox.dart` | +| Backend | **None.** No auth, no API client, no server, no concept of a "user account" | — (verified: no auth/token/login code in `lib/`) | +| "Sync" today | **Whole-database JSON snapshot** + assets, zipped, uploaded to iCloud | `lib/sync/export/export_v2.dart`, `lib/services/sync/icloud_syncer.dart` | +| Sync abstraction | `Syncer` interface (`put`/`get`/`list`/`delete`/`download`) — **file-level**, only iCloud implements it | `lib/services/sync/syncer.dart` | +| Encryption | **None.** Backups are plaintext JSON in a zip | `lib/sync/export/export_v2.dart` | +| Reactive UI | ObjectBox `query().watch()` streams + singleton services with listeners | `lib/services/transactions.dart:44` | +| Identity | `Profile` and `UserPreferences` are **single global records** (one user assumed) | `lib/entity/profile.dart`, `lib/entity/user_preferences.dart` | + +### The data model is *partly* sync-ready + +Good news first — these help: + +- **Every entity already has a `uuid` (`@Unique`)** — globally unique IDs, not just local autoincrement `id`. Essential for sync, and it's already there. (`Account`, `Category`, `Transaction`, `TransactionTag`, `FileAttachment`, `RecurringTransaction`, `Budget`, `Goal`, `Profile`, `TransactionFilterPreset`, `UserPreferences`.) +- **Relations are denormalized to UUIDs**, not just ObjectBox int links: `Transaction` carries `accountUuid`, `categoryUuid`, `tagsUuids`, `attachmentsUuids` alongside the `ToOne`/`ToMany`. This means records are portable across devices without remapping local IDs — a real head start. (`lib/entity/transaction.dart:147-225`) +- **Transactions already have soft-delete + tombstone fields**: `isDeleted`, `deletedDate`. (`lib/entity/transaction.dart:36-39`) + +### The gaps that block sync + +These are the parts that don't exist yet and have to be built: + +1. **No `updatedDate` / version / revision on any entity.** Only `createdDate` exists. Confirmed across all entities. Without a per-record modification timestamp or logical clock, **there is no basis for conflict resolution** ("which edit wins?"). This is the #1 schema gap. +2. **Tombstones exist only for `Transaction`.** `Account`, `Category`, `TransactionTag`, `Budget`, `Goal`, etc. are **hard-deleted** (`removeAllAsync`, `box.remove`). A hard delete cannot be synced — the other device never learns it happened. Every syncable entity needs a soft-delete path. +3. **No change-log / outbox.** Nothing records "what changed locally since the last sync." Today writes just hit ObjectBox. A sync engine needs an outbox or a query over `updatedDate > lastSyncCursor`. +4. **File attachments are loose binary blobs on disk** (`FileAttachment.filePath` → file under the app data dir). These are *not* in the database and need a **separate content-addressed blob sync channel** (and separate encryption). (`lib/entity/file_attachment.dart`) +5. **Single-user assumptions baked in.** `Profile`, `UserPreferences`, and the unique constraint on `Account.name` all assume one user. Multi-user breaks several of these (e.g. two users both named "Cash"; whose `primaryCurrency`?). + +--- + +## 3. Decomposing the request into workstreams + +| # | Workstream | What it is | Net-new? | +|---|---|---|---| +| A | **Schema + write-path changes** | Add `updatedDate`/clock + tombstones to all entities; add ownership/visibility to `Account`; outbox; ObjectBox migration | Modifies every entity + every write path | +| B | **Client sync engine** | Track local changes, push/pull deltas, merge into ObjectBox, resolve conflicts, sync attachment blobs, retry/offline queue | Net-new | +| C | **Self-hostable backend** | Auth, sync API, per-account ACL, blob store, invite flow, Docker packaging, versioning, migrations | **Net-new from zero** | +| D | **E2E encryption layer** | Per-account keys, device keypairs, envelope encryption, key backup/recovery, rotation-on-revoke | Net-new, security-critical | +| E | **UI** | Server connection/sign-in, account sharing + members + roles, invites, sync status, conflict surfacing, key-recovery flow | Net-new screens | + +--- + +## 4. Complexity ratings (you asked specifically about UI vs integration) + +### Integration / backend complexity: **Very High** + +- **The hard core is the sync engine + E2E, not the UI.** Conflict resolution, referential integrity across devices, blob sync, and key management are the parts that consume the time and are the ones that, if wrong, cause **silent financial-data loss or a privacy breach**. For a finance app, "mostly works" is not acceptable here. +- **There is no backend to extend** — it's greenfield. Auth, API, storage, ops, packaging, and *keeping the server versioned in lockstep with the app* is a permanent maintenance surface. +- ObjectBox is **not** natively a sync database in the open-source build. You either (a) adopt a sync-capable engine alongside/under it, or (b) hand-roll the sync protocol over the existing snapshot model. Either way this is weeks-to-months, not days. + +### UI complexity: **Medium-High** (the *easier* half, and Flow's strength) + +The reactive foundation is a genuine advantage: because the UI already rebuilds off ObjectBox `query().watch()` streams, **a sync engine that writes merged records into the boxes gets live UI updates for free** — no screen rewrites for "data changed remotely." New screens needed: + +- Server connection / sign-in / "connect to your server" +- Account sharing: member list, role picker (read-only / read-edit), pending invites, revoke +- Invite acceptance flow (share code or link) +- Sync status (syncing / offline / conflict / last-synced) +- **Key backup & recovery** (the scary one — lose your key, lose your encrypted data; the UX must make this nearly impossible to get wrong) +- Reconciling single-user UI assumptions (per-account ownership badges, "private" vs "shared" indicators) + +UI is real work but it's bounded, visual, and exactly the kind of thing this codebase does well (1 widget = 1 file, l10n via `.t()`, established theming). + +--- + +## 5. The two tensions to resolve before any code + +### Tension A — E2E encryption vs. server-enforced permissions (technical) + +The request asks for **both**: +- "encrypted in a way that prevents server operators from accessing plaintext" (true E2E), **and** +- per-account permissions enforced server-side (private/shared, read-only/read-edit, invite, revoke). + +These pull in opposite directions. **If the server cannot read the data, it cannot enforce "user B may only see account X"** by inspecting rows — enforcement has to be *cryptographic*: each shared account gets its own symmetric key, and that key is envelope-encrypted to the public key of every authorized member. Sharing = wrap the account key to a new member's key. **Revoking = rotate the account key** and re-wrap to the remaining members (and note: revoke can only stop *future* updates — anything already synced to a removed member's device was decryptable and can't be clawed back). + +This is a solved problem (it's how E2E group messaging works), but it means workstream D is "build a small group-key-management system," not "add a permissions column." This is the part I'd want reviewed by someone with crypto experience, possibly audited. + +A pragmatic middle ground: **encrypted at rest with a server-held-but-per-user key** (server *could* technically read, but the threat model is "your own server / a trusted host") gives you real privacy + clean server-side RBAC at a fraction of the complexity. Whether that satisfies "prevents server operators from accessing plaintext" depends on how literally that requirement is taken — worth confirming with the requester. + +### Tension B — strategy & economics (product) + +Per the current direction (free privacy-first local core; **Eny is the monetization vehicle**, pay-per-use credits; solo dev, donations currently below the Apple developer fee): a **self-hosted** sync server is the textbook feature that quietly sinks solo apps — + +- It generates **support load** (every user's broken Docker deploy, reverse proxy, TLS cert, and "why won't it sync" becomes your problem). +- It is **security-critical and permanently maintained**, versioned in lockstep with the app. +- Self-hosted by definition produces **no recurring revenue**, and the request explicitly rules out paid dependencies — so it can't subsidize its own maintenance. + +This isn't a reason to reject it. It's a reason to (a) scope it down hard, and (b) consider that a **hosted, optional, paid sync** could be the version that's actually sustainable, with self-hosting as a power-user option on the same protocol. + +--- + +## 6. Architectural options + +### Option B (recommended first step) — Self-hosted *encrypted backup-sync* over WebDAV/S3 +Reuse what exists: `SyncModelV2` snapshot + the `Syncer` abstraction. Add (1) client-side encryption of the snapshot before upload, and (2) a `WebDavSyncer implements Syncer` (and/or S3). The requester explicitly named WebDAV as acceptable. + +- **Gets you:** self-hosted ✅, user-owned data ✅, encrypted ✅, no proprietary cloud ✅, multi-device for one user ✅, couples-sharing-one-login ✅ (the most common real case). +- **Does NOT get you:** real-time sync, concurrent multi-user editing, per-account permissions, fine-grained conflict resolution (it's whole-file last-writer-wins). +- **Effort:** Small-to-medium (days-to-weeks). Mostly reuses existing code + the existing `Syncer` seam. **This is the highest value-per-effort path by far.** + +### Option A — Adopt an existing local-first / CRDT sync engine +Rather than hand-roll the protocol, build on an established local-first stack (CRDT or server-authoritative log-based sync). This is the right path **if** true collaborative multi-user sync is the goal. Trade-off: most such engines are SQLite/Postgres-oriented, not ObjectBox-native, so it likely means introducing a second store or migrating the persistence layer — a large architectural change. (ObjectBox also has its own commercial Sync product with a self-hostable server, which is the most DB-native option but is paid and not E2E by default — worth evaluating against the "no paid services" requirement.) + +- **Effort:** High. Multi-month. But far less risky than hand-rolling conflict resolution and crypto from scratch. + +### Option C — Fully custom build (the literal request) +Workstreams A–E, all hand-built: bespoke sync protocol, custom backend, custom E2E group-key system, custom RBAC. + +- **Effort:** Very high. Realistically **6–12+ months of solo work** to something trustworthy with financial data, plus indefinite maintenance. I'd advise against this as a starting point. + +--- + +## 7. Effort summary + +| Path | What you get | Rough effort (solo) | Ongoing burden | +|---|---|---|---| +| **B — encrypted backup-sync** | Self-host + multi-device (single user) + privacy | Days → few weeks | Low | +| **A — local-first engine** | Real multi-user collaborative sync | Multi-month | Medium-High | +| **C — full custom** | Exactly the request, E2E + RBAC | 6–12+ months | High (security + ops) | + +--- + +## 8. Recommendation + +1. **Phase 1 — ship Option B.** Self-hosted encrypted backup-sync via WebDAV, reusing `SyncModelV2` + the `Syncer` seam + client-side encryption. Small lift, no backend to operate, and it satisfies *most* of what real users mean by "sync across my devices / share with my partner." +2. **Decouple the schema prerequisites and do them anyway.** Adding `updatedDate` + tombstones to every entity and a soft-delete path (workstream A) is **valuable regardless** and is the foundation for any future real sync. Do this early; it's not wasted even if Phase 3 never happens. +3. **Phase 2 — measure demand** for true concurrent multi-user collaboration (private + shared accounts, live permissions). If it's genuinely there: +4. **Phase 3 — build collaborative sync on an existing engine (Option A), not from scratch**, and treat E2E + group-key management (workstream D) as a discrete, security-reviewed sub-project. Seriously weigh a **hosted, optional, paid** tier so the feature can fund its own maintenance, with self-hosting as a power-user option on the same protocol. + +### Open questions for the requester (these change the estimate materially) +- Is **literal zero-knowledge** E2E required, or is "encrypted, on *your own* server, host can't casually read it" enough? (This is the single biggest cost driver — see Tension A.) +- Is the real need **multi-device for one person**, or **genuine concurrent multi-user with private accounts**? The first is Phase 1; the second is Phase 3. +- Acceptable to offer an **optional hosted** version (paid) so it's sustainable, with self-hosting as the open option? + +--- + +*Prepared from a read of the current codebase. No application code was modified.* + + diff --git a/lib/routes/stats/net_worth_page.dart b/lib/routes/stats/net_worth_page.dart index c80722bd..fd65e6a6 100644 --- a/lib/routes/stats/net_worth_page.dart +++ b/lib/routes/stats/net_worth_page.dart @@ -79,6 +79,13 @@ class _NetWorthPageState extends State child: Column( crossAxisAlignment: .start, children: [ + const SizedBox(height: 16.0), + Frame( + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, + ), + ), const SizedBox(height: 16.0), Frame( child: Column( @@ -107,13 +114,6 @@ class _NetWorthPageState extends State ), ), const SizedBox(height: 16.0), - Frame( - child: TimeRangeSelector( - initialValue: range, - onChanged: _updateRange, - ), - ), - const SizedBox(height: 16.0), if (hasData) Frame( child: SizedBox( diff --git a/lib/routes/stats/spending_calendar_page.dart b/lib/routes/stats/spending_calendar_page.dart index 98eafcf0..82a128cc 100644 --- a/lib/routes/stats/spending_calendar_page.dart +++ b/lib/routes/stats/spending_calendar_page.dart @@ -61,6 +61,13 @@ class _SpendingCalendarPageState extends State child: Column( crossAxisAlignment: .start, children: [ + const SizedBox(height: 16.0), + Frame( + child: TimeRangeSelector( + initialValue: range, + onChanged: _updateRange, + ), + ), const SizedBox(height: 16.0), Frame( child: Column( @@ -84,13 +91,6 @@ class _SpendingCalendarPageState extends State ), ), const SizedBox(height: 16.0), - Frame( - child: TimeRangeSelector( - initialValue: range, - onChanged: _updateRange, - ), - ), - const SizedBox(height: 16.0), if (hasData) Frame( child: SpendingHeatmap( diff --git a/lib/widgets/home/stats/bento/analytics_bento.dart b/lib/widgets/home/stats/bento/analytics_bento.dart index f131850e..fea6dbc2 100644 --- a/lib/widgets/home/stats/bento/analytics_bento.dart +++ b/lib/widgets/home/stats/bento/analytics_bento.dart @@ -38,10 +38,15 @@ class AnalyticsBento extends StatelessWidget { child: Column( crossAxisAlignment: .start, children: [ - // Range-bound — these respond to the selected time range. - CashFlowTile(range: range), - const SizedBox(height: 12.0), - PaceTile(range: range), + // Range-bound — these respond to the selected time range. Cash + // flow and pace share a row to stay compact; each takes half. + Row( + spacing: 12.0, + children: [ + Expanded(child: CashFlowTile(range: range)), + Expanded(child: PaceTile(range: range)), + ], + ), const SizedBox(height: 12.0), TopCategoriesTile(range: range), const SizedBox(height: 24.0), diff --git a/lib/widgets/home/stats/bento/cash_flow_tile.dart b/lib/widgets/home/stats/bento/cash_flow_tile.dart index e826948d..fc7b16de 100644 --- a/lib/widgets/home/stats/bento/cash_flow_tile.dart +++ b/lib/widgets/home/stats/bento/cash_flow_tile.dart @@ -65,55 +65,82 @@ class _CashFlowTileState extends State : Column( crossAxisAlignment: .start, children: [ - Row( - crossAxisAlignment: .center, - children: [ - Icon( - saved - ? Symbols.savings_rounded - : Symbols.trending_down_rounded, - color: netColor, - size: 18.0, - ), - const SizedBox(width: 6.0), - Text( - (saved - ? "tabs.stats.analytics.saved" - : "tabs.stats.analytics.overspent") - .t(context), - style: context.textTheme.labelMedium?.semi(context), - ), - const SizedBox(width: 8.0), - Expanded( - child: MoneyText( - Money(saved ? net : -net, primaryCurrency), - style: context.textTheme.titleLarge?.copyWith( - color: netColor, - fontWeight: FontWeight.w700, + LayoutBuilder( + builder: (context, constraints) => Row( + crossAxisAlignment: .center, + children: [ + Icon( + saved + ? Symbols.savings_rounded + : Symbols.trending_down_rounded, + color: netColor, + size: 18.0, + ), + const SizedBox(width: 6.0), + // Cap the label so a long localized "Overspent" + // ellipsizes instead of overflowing the narrow tile; the + // net figure keeps the rest and stays pinned right. + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * 0.55, + ), + child: Text( + (saved + ? "tabs.stats.analytics.saved" + : "tabs.stats.analytics.overspent") + .t(context), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.semi(context), ), - autoSize: true, - initiallyAbbreviated: true, - textAlign: TextAlign.end, ), - ), - ], + const SizedBox(width: 8.0), + Expanded( + child: MoneyText( + Money(saved ? net : -net, primaryCurrency), + style: context.textTheme.titleLarge?.copyWith( + color: netColor, + fontWeight: FontWeight.w700, + ), + autoSize: true, + initiallyAbbreviated: true, + textAlign: TextAlign.end, + ), + ), + ], + ), ), const SizedBox(height: 12.0), CashFlowFlowBar(income: income, expense: expense), const SizedBox(height: 8.0), + // Each figure claims half the row and scales down rather than + // overflowing when the tile is narrow (e.g. paired with Pace). Row( children: [ - CashFlowFigure( - label: "tabs.stats.analytics.in".t(context), - money: Money(income, primaryCurrency), - color: context.flowColors.income, + Expanded( + child: FittedBox( + fit: .scaleDown, + alignment: AlignmentDirectional.centerStart, + child: CashFlowFigure( + label: "tabs.stats.analytics.in".t(context), + money: Money(income, primaryCurrency), + color: context.flowColors.income, + ), + ), ), - const Spacer(), - CashFlowFigure( - label: "tabs.stats.analytics.out".t(context), - money: Money(expense, primaryCurrency), - color: context.flowColors.expense, - alignEnd: true, + const SizedBox(width: 8.0), + Expanded( + child: FittedBox( + fit: .scaleDown, + alignment: AlignmentDirectional.centerEnd, + child: CashFlowFigure( + label: "tabs.stats.analytics.out".t(context), + money: Money(expense, primaryCurrency), + color: context.flowColors.expense, + alignEnd: true, + ), + ), ), ], ), diff --git a/lib/widgets/home/stats/bento/pace_tile.dart b/lib/widgets/home/stats/bento/pace_tile.dart index 5e212c37..5fe82838 100644 --- a/lib/widgets/home/stats/bento/pace_tile.dart +++ b/lib/widgets/home/stats/bento/pace_tile.dart @@ -80,6 +80,8 @@ class _PaceTileState extends State ? "tabs.stats.analytics.pace.projected" : "tabs.stats.analytics.pace.totalSpent") .t(context), + maxLines: 2, + overflow: TextOverflow.ellipsis, style: context.textTheme.labelMedium?.semi(context), ), const SizedBox(height: 2.0), @@ -108,12 +110,18 @@ class _PaceTileState extends State ), const Spacer(), Row( + mainAxisAlignment: .spaceBetween, children: [ - Text( - "tabs.stats.analytics.pace.perDay".t(context), - style: context.textTheme.bodySmall?.semi(context), + Flexible( + child: Text( + "tabs.stats.analytics.pace.perDay".t(context), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.semi(context), + ), ), - const Spacer(), + const SizedBox(width: 8.0), Flexible( child: MoneyText( report.dailyAvgExpenditure, diff --git a/pubspec.lock b/pubspec.lock index df6e5507..02003d7f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1170,10 +1170,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1895,26 +1895,26 @@ packages: dependency: "direct dev" description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.17" timezone: dependency: "direct main" description: @@ -2031,10 +2031,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 433c68ff..3f095309 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.23.0+347" +version: "0.23.0+348" environment: sdk: ">=3.10.0 <4.0.0"