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/assets/l10n/ar.json b/assets/l10n/ar.json
index e9ada935..be09e1ac 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,92 @@
"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.heatmap.less": "أقل",
+ "tabs.stats.analytics.heatmap.more": "أكثر",
+ "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.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": "التدفقات الخارجة الملتزمة",
+ "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.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": "الإيقاع",
+ "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": "ملخص",
+ "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..f1c227b2 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,92 @@
"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.heatmap.less": "Менш",
+ "tabs.stats.analytics.heatmap.more": "Больш",
+ "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.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": "Фіксаваныя выдаткі",
+ "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.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": "Рытм",
+ "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": "Падсумаванне",
+ "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..9cbfb159 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,92 @@
"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.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",
+ "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.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",
+ "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.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",
+ "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": "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.",
+ "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..16948fd3 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,92 @@
"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.heatmap.less": "Weniger",
+ "tabs.stats.analytics.heatmap.more": "Mehr",
+ "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.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",
+ "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.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",
+ "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": "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.",
+ "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..b6dddb2e 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,92 @@
"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.heatmap.less": "Less",
+ "tabs.stats.analytics.heatmap.more": "More",
+ "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.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",
+ "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.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",
+ "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": "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.",
+ "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..30c5b27a 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,92 @@
"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.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",
+ "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.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",
+ "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.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",
+ "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": "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.",
+ "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..b96d0369 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,92 @@
"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.heatmap.less": "کمتر",
+ "tabs.stats.analytics.heatmap.more": "بیشتر",
+ "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.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": "پرداختهای متعهد شده",
+ "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.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": "ریتم",
+ "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": "خلاصه",
+ "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..130fe32a 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,92 @@
"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.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",
+ "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.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",
+ "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.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",
+ "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": "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.",
+ "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..fe4ad100 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,92 @@
"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.heatmap.less": "Meno",
+ "tabs.stats.analytics.heatmap.more": "Più",
+ "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.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",
+ "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.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",
+ "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": "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.",
+ "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..9516060c 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,92 @@
"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.heatmap.less": "Бага",
+ "tabs.stats.analytics.heatmap.more": "Их",
+ "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.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": "Баталгаажсан зарлага",
+ "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.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": "Хэмнэл",
+ "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": "Тойм",
+ "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..b180c8e0 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,92 @@
"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.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",
+ "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.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",
+ "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.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",
+ "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": "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ą.",
+ "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..925131f6 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,92 @@
"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.heatmap.less": "Меньше",
+ "tabs.stats.analytics.heatmap.more": "Больше",
+ "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.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": "Обязательные выплаты",
+ "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.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": "Ритм",
+ "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": "Итоги",
+ "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..c4bcdb45 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,92 @@
"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.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",
+ "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.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ış",
+ "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.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",
+ "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": "Ö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}.",
+ "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..616e61ba 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,92 @@
"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.heatmap.less": "Менше",
+ "tabs.stats.analytics.heatmap.more": "Більше",
+ "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.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": "Запланований відтік",
+ "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.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": "Ритм",
+ "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": "Підсумок",
+ "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..32b6ecf9 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,92 @@
"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.heatmap.less": "少",
+ "tabs.stats.analytics.heatmap.more": "多",
+ "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.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": "已承諾支出",
+ "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.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": "節奏",
+ "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": "回顧",
+ "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/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/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/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/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..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)}";
@@ -84,9 +100,12 @@ 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,
- fontPackage: fontPackage,
+ // ignore: non_const_argument_for_const_parameter
+ fontPackage: _fontPackageMigration[fontPackage] ?? fontPackage,
),
);
}
@@ -100,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 9ce5ceb0..1c200f4e 100644
--- a/lib/data/icons.dart
+++ b/lib/data/icons.dart
@@ -1,13 +1,15 @@
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) {
+/// 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,11 +17,17 @@ 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) =>
- 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 +80,6 @@ const List fSimpleIcons = [
SimpleIcons.googleadmob,
SimpleIcons.googleadsense,
SimpleIcons.googleads,
- SimpleIcons.amazonpay,
SimpleIcons.samsungpay,
];
const List fMaterialSymbols = [
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/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/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/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/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 e860b7be..7a93f184 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;
@@ -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.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/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/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 18d95a55..d35ab201 100644
--- a/lib/routes.dart
+++ b/lib/routes.dart
@@ -49,7 +49,14 @@ 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/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";
+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";
@@ -489,6 +496,34 @@ 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(),
+ ),
+ GoRoute(
+ path: "/stats/wrapped",
+ builder: (context, state) => const WrappedPage(),
+ ),
+ GoRoute(
+ path: "/stats/recurring",
+ builder: (context, state) => const RecurringPage(),
+ ),
+ GoRoute(
+ path: "/stats/calendar",
+ builder: (context, state) => const SpendingCalendarPage(),
+ ),
+ GoRoute(
+ path: "/stats/cash-flow",
+ builder: (context, state) => const CashFlowPage(),
+ ),
+ GoRoute(
+ path: "/stats/map",
+ builder: (context, state) => const SpendingMapPage(),
+ ),
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/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..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";
@@ -15,10 +16,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});
@@ -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),
diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart
index f31eb8f0..76ad8923 100644
--- a/lib/routes/home/stats_tab.dart
+++ b/lib/routes/home/stats_tab.dart
@@ -1,33 +1,7 @@
-import "package:auto_size_text/auto_size_text.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/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/home/stats/bento/analytics_bento.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 {
@@ -41,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 (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(
@@ -102,209 +30,20 @@ class _StatsTabState extends State
),
),
),
- if (showMissingExchangeRatesWarning) ...[
- RatesMissingErrorBox(),
- const SizedBox(height: 12.0),
- ],
Expanded(
- child: 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),
+ ],
+ ),
+ ),
+ ),
),
],
);
@@ -312,103 +51,11 @@ class _StatsTabState extends State
void updateRange(TimeRange value) {
range = value;
- fetch();
if (!mounted) return;
setState(() {});
}
- Future fetch() async {
- 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/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/cash_flow_page.dart b/lib/routes/stats/cash_flow_page.dart
new file mode 100644
index 00000000..3b5f2c53
--- /dev/null
+++ b/lib/routes/stats/cash_flow_page.dart
@@ -0,0 +1,352 @@
+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/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";
+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;
+
+ /// 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 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)),
+ 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),
+ forecast: forecast,
+ forecastComparison: stats?.previousExpenseSum,
+ forecastLabel: "tabs.stats.intervalReport.forecast".t(
+ context,
+ range.format(),
+ ),
+ ),
+ 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 (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(
+ message: "tabs.stats.analytics.missingRatesAmounts".t(
+ context,
+ ),
+ ),
+ ],
+ const SizedBox(height: 96.0),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ void _updateRange(TimeRange value) {
+ if (value == range) return;
+ range = value;
+ 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;
+ setState(() {
+ 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;
+
+ 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 palette = context.chartAccents;
+
+ 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 ??
+ palette[colorIndex++ % palette.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/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
new file mode 100644
index 00000000..fd65e6a6
--- /dev/null
+++ b/lib/routes/stats/net_worth_page.dart
@@ -0,0 +1,279 @@
+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: TimeRangeSelector(
+ initialValue: range,
+ onChanged: _updateRange,
+ ),
+ ),
+ 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),
+ 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..81e31431
--- /dev/null
+++ b/lib/routes/stats/recurring_page.dart
@@ -0,0 +1,331 @@
+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";
+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";
+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:go_router/go_router.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;
+
+ /// 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();
+ 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),
+ Frame(
+ child: TimeRangeSelector(
+ initialValue: range,
+ onChanged: _updateRange,
+ ),
+ ),
+ const SizedBox(height: 16.0),
+ 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,
+ ),
+ ),
+ (_, true) => StatsEmptyState(
+ message:
+ "tabs.stats.analytics.recurring.nothingUpcoming".t(
+ context,
+ ),
+ ),
+ (_, false) => Column(
+ crossAxisAlignment: .start,
+ children: _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. [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(
+ 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),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+ }
+
+ return rows;
+ }
+
+ void _updateRange(TimeRange value) {
+ if (value == range) return;
+ range = value;
+ 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;
+ setState(() {
+ busy = true;
+ });
+
+ bool missing = false;
+
+ try {
+ final query = RecurringTransactionsService().activeRecurringsQb().build();
+ final List recurrings = query.find();
+ query.close();
+
+ final List result = [];
+ final Map loggedIds = {};
+ 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++;
+
+ // 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
+ : 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 Transaction? loggedMatch = _matchLogged(logged, date);
+ if (loggedMatch != null) {
+ loggedIds[_occurrenceKey(occurrence)] = loggedMatch.id;
+ }
+
+ 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;
+ _loggedIdByKey = loggedIds;
+ 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/stats/spending_calendar_page.dart b/lib/routes/stats/spending_calendar_page.dart
new file mode 100644
index 00000000..82a128cc
--- /dev/null
+++ b/lib/routes/stats/spending_calendar_page.dart
@@ -0,0 +1,217 @@
+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/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";
+
+/// 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 SpendingCalendarPage extends StatefulWidget {
+ const SpendingCalendarPage({super.key});
+
+ @override
+ State createState() => _SpendingCalendarPageState();
+}
+
+class _SpendingCalendarPageState extends State
+ with PrimaryCurrencyDependentState {
+ TimeRange range = TimeRange.thisYear();
+
+ bool busy = false;
+ bool missingRates = false;
+
+ Map dailyExpense = {};
+ Map weekdayExpense = {};
+ double total = 0.0;
+ DateTime from = DateTime.now();
+ DateTime to = DateTime.now();
+
+ @override
+ Widget build(BuildContext context) {
+ final bool hasData = dailyExpense.isNotEmpty;
+
+ return Scaffold(
+ appBar: StatsAppBar(
+ title: "tabs.stats.analytics.spendingCalendar".t(context),
+ ),
+ body: SafeArea(
+ child: busy && dailyExpense.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),
+ Frame(
+ child: Column(
+ crossAxisAlignment: .start,
+ children: [
+ Text(
+ "tabs.stats.analytics.calendar.spentIn".t(
+ context,
+ range.format(useRelative: false),
+ ),
+ 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),
+ if (hasData)
+ Frame(
+ child: SpendingHeatmap(
+ dailyExpense: dailyExpense,
+ from: from,
+ to: to,
+ currency: primaryCurrency,
+ ),
+ )
+ else
+ StatsEmptyState(
+ message: "tabs.stats.analytics.noSpendingWindow".t(
+ context,
+ ),
+ ),
+ if (weekdayExpense.isNotEmpty) ...[
+ const SizedBox(height: 16.0),
+ _buildWeekdayInsight(context),
+ ],
+ if (missingRates) ...[
+ const SizedBox(height: 8.0),
+ MissingRatesNotice(
+ message: "tabs.stats.analytics.missingRatesAmounts".t(
+ context,
+ ),
+ ),
+ ],
+ 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: "tabs.stats.analytics.rhythm".t(context),
+ title: EmphasizedText(
+ template: "tabs.stats.analytics.calendar.priciestDay".t(context),
+ value: _weekdayName(topWeekday),
+ ),
+ child: WeekdayBars(
+ byWeekday: weekdayExpense,
+ topWeekday: topWeekday,
+ accent: context.colorScheme.primary,
+ ),
+ );
+ }
+
+ 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 {
+ // 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();
+ from = range.from;
+ to = range.to.isAfter(now) ? now : range.to;
+
+ final List transactions = await ObjectBox()
+ .transcationsByRange(range, 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 = transaction.money.tryConvertAmount(
+ primaryCurrency,
+ rates,
+ );
+ 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(() {});
+ }
+ }
+
+ String _weekdayName(int weekday) {
+ // 1 == Monday .. 7 == Sunday (DateTime.weekday).
+ return DateTime(2024, 1, weekday).toMoment().format("dddd");
+ }
+}
diff --git a/lib/routes/stats/spending_map_page.dart b/lib/routes/stats/spending_map_page.dart
new file mode 100644
index 00000000..89f7b556
--- /dev/null
+++ b/lib/routes/stats/spending_map_page.dart
@@ -0,0 +1,290 @@
+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/primary_currency_dependent_state.dart";
+import "package:flow/utils/utils.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/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";
+
+/// Spending map.
+///
+/// Clusters geo-bearing expenses (from `Transaction.location` / the geo
+/// 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() => _SpendingMapPageState();
+}
+
+class _Place {
+ final LatLng center;
+ final double total;
+
+ const _Place({required this.center, required this.total});
+}
+
+class _PlaceAccumulator {
+ double sumLat = 0.0;
+ double sumLng = 0.0;
+ double total = 0.0;
+ int count = 0;
+
+ void add(LatLng point, double amount) {
+ sumLat += point.latitude;
+ sumLng += point.longitude;
+ total += amount;
+ count++;
+ }
+
+ _Place toPlace() =>
+ _Place(center: LatLng(sumLat / count, sumLng / count), total: total);
+}
+
+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;
+
+ TimeRange range = TimeRange.thisYear();
+
+ bool busy = false;
+ bool missingRates = false;
+
+ List<_Place> places = [];
+ double mappedTotal = 0.0;
+ int locatedCount = 0;
+ int totalExpenseCount = 0;
+
+ @override
+ Widget build(BuildContext context) {
+ final bool hasData = places.isNotEmpty;
+
+ return Scaffold(
+ appBar: StatsAppBar(title: "tabs.stats.analytics.spendingMap".t(context)),
+ body: SafeArea(
+ child: busy && places.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),
+ Frame(
+ child: Column(
+ crossAxisAlignment: .start,
+ children: [
+ Text(
+ "tabs.stats.analytics.map.mappedSpend".t(context),
+ 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(
+ "tabs.stats.analytics.map.locatedCount".t(context, {
+ "located": locatedCount,
+ "total": totalExpenseCount,
+ }),
+ style: context.textTheme.bodyMedium?.semi(context),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16.0),
+ if (hasData)
+ Frame(child: _buildMap(context))
+ else
+ StatsEmptyState(
+ message: "tabs.stats.analytics.map.empty".t(context),
+ ),
+ if (missingRates) ...[
+ const SizedBox(height: 8.0),
+ MissingRatesNotice(
+ message: "tabs.stats.analytics.missingRatesAmounts".t(
+ context,
+ ),
+ ),
+ ],
+ 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: .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 = 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),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ 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 List transactions = await ObjectBox()
+ .transcationsByRange(range, 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 = transaction.money.tryConvertAmount(
+ primaryCurrency,
+ rates,
+ );
+ 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);
+ }
+
+ 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;
+ }
+}
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/stats/wrapped_page.dart b/lib/routes/stats/wrapped_page.dart
new file mode 100644
index 00000000..cdfc3edc
--- /dev/null
+++ b/lib/routes/stats/wrapped_page.dart
@@ -0,0 +1,397 @@
+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/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";
+
+/// 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;
+
+ /// 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;
+
+ 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) {
+ return Scaffold(
+ appBar: StatsAppBar(title: "tabs.stats.analytics.wrapped".t(context)),
+ body: SafeArea(
+ 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)
+ _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 periods = _recentPeriods(range, 4);
+
+ final FlowAnalytics current = await ObjectBox()
+ .flowByCategories(range: periods.first);
+ final List> previous = [];
+ for (final TimeRange period in periods.skip(1)) {
+ previous.add(await ObjectBox().flowByCategories(range: period));
+ }
+
+ thisMonthTransactions = await ObjectBox().transcationsByRange(
+ periods.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;
+ }
+
+ /// 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 = 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 periods;
+ }
+
+ String _weekdayName(int weekday) {
+ // 1 == Monday .. 7 == Sunday (DateTime.weekday).
+ return DateTime(2024, 1, weekday).toMoment().format("dddd");
+ }
+}
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..19aab6fb 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",
@@ -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/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/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 c94c2275..5e66ede5 100644
--- a/lib/theme/helpers.dart
+++ b/lib/theme/helpers.dart
@@ -1,8 +1,9 @@
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/symbols.dart";
+import "package:material_symbols_icons_flow/symbols.dart";
import "package:pie_menu/pie_menu.dart";
extension ThemeAccessor on BuildContext {
@@ -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/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/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/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/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/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/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/analytics/bullet_chart.dart b/lib/widgets/analytics/bullet_chart.dart
new file mode 100644
index 00000000..ec632385
--- /dev/null
+++ b/lib/widgets/analytics/bullet_chart.dart
@@ -0,0 +1,106 @@
+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.
+ Positioned.fill(
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: track,
+ borderRadius: BorderRadius.all(Radius.circular(height / 2)),
+ ),
+ ),
+ ),
+ // Qualitative band up to the target.
+ 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, 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(
+ 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/analytics/insight_card.dart b/lib/widgets/analytics/insight_card.dart
new file mode 100644
index 00000000..9541c9e7
--- /dev/null
+++ b/lib/widgets/analytics/insight_card.dart
@@ -0,0 +1,103 @@
+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: .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)
+ _buildPill(context, label!, 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!],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildPill(BuildContext context, String label, Color accent) {
+ 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/analytics/sankey_diagram.dart b/lib/widgets/analytics/sankey_diagram.dart
new file mode 100644
index 00000000..3a1954e0
--- /dev/null
+++ b/lib/widgets/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/analytics/spending_heatmap.dart b/lib/widgets/analytics/spending_heatmap.dart
new file mode 100644
index 00000000..a348ec71
--- /dev/null
+++ b/lib/widgets/analytics/spending_heatmap.dart
@@ -0,0 +1,308 @@
+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";
+
+/// 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: .start,
+ children: [
+ Row(
+ crossAxisAlignment: .start,
+ children: [
+ _WeekdayLabels(
+ cellSize: cellSize,
+ gap: gap,
+ topOffset: _headerHeight + _headerGap,
+ ),
+ Expanded(
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ reverse: true,
+ child: Column(
+ 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: .end,
+ children: [
+ 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(
+ 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(
+ "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
new file mode 100644
index 00000000..67ced6ee
--- /dev/null
+++ b/lib/widgets/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: .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/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/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..14f22d44 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 {
@@ -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/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/bento/analytics_bento.dart b/lib/widgets/home/stats/bento/analytics_bento.dart
new file mode 100644
index 00000000..fea6dbc2
--- /dev/null
+++ b/lib/widgets/home/stats/bento/analytics_bento.dart
@@ -0,0 +1,78 @@
+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";
+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], [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
+/// 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: Frame(
+ child: Column(
+ crossAxisAlignment: .start,
+ children: [
+ // 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),
+ // 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..fc7b16de
--- /dev/null
+++ b/lib/widgets/home/stats/bento/cash_flow_tile.dart
@@ -0,0 +1,183 @@
+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: [
+ 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),
+ ),
+ ),
+ 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: [
+ 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 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,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ @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..987b0836
--- /dev/null
+++ b/lib/widgets/home/stats/bento/net_worth_tile.dart
@@ -0,0 +1,126 @@
+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/pace_tile.dart b/lib/widgets/home/stats/bento/pace_tile.dart
new file mode 100644
index 00000000..5fe82838
--- /dev/null
+++ b/lib/widgets/home/stats/bento/pace_tile.dart
@@ -0,0 +1,159 @@
+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),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ 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(
+ mainAxisAlignment: .spaceBetween,
+ children: [
+ Flexible(
+ child: Text(
+ "tabs.stats.analytics.pace.perDay".t(context),
+ maxLines: 1,
+ softWrap: false,
+ overflow: TextOverflow.ellipsis,
+ style: context.textTheme.bodySmall?.semi(context),
+ ),
+ ),
+ const SizedBox(width: 8.0),
+ 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
new file mode 100644
index 00000000..af3fd4d1
--- /dev/null
+++ b/lib/widgets/home/stats/bento/recurring_tile.dart
@@ -0,0 +1,141 @@
+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;
+
+ // 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;
+
+ final List occurrences = recurring.recurrence.occurrences(
+ subrange: window,
+ );
+ count += occurrences.length;
+
+ 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..7f97db8c
--- /dev/null
+++ b/lib/widgets/home/stats/bento/top_categories_tile.dart
@@ -0,0 +1,123 @@
+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/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);
+
+ // 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;
+
+ 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 ??
+ palette[colorIndex++ % palette.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/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..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
@@ -5,9 +5,9 @@ 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]
+/// 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/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/stats/cash_flow/cash_flow_figure.dart b/lib/widgets/stats/cash_flow/cash_flow_figure.dart
new file mode 100644
index 00000000..507a0072
--- /dev/null
+++ b/lib/widgets/stats/cash_flow/cash_flow_figure.dart
@@ -0,0 +1,58 @@
+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 = 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),
+ );
+ 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,
+ ],
+ );
+ }
+}
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..65bbe87f
--- /dev/null
+++ b/lib/widgets/stats/cash_flow/cash_flow_summary.dart
@@ -0,0 +1,168 @@
+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:flow/widgets/trend.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;
+
+ /// 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
+ 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: .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: .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,
+ ),
+ ],
+ ),
+ 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/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/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..175a37d5 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 {
@@ -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/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..02003d7f 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:
@@ -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"
@@ -1218,10 +1202,10 @@ packages:
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"
+ sha256: "93c3cd05b69b1a5435d62bff2efc0cec19896a9a431a4a98263c8d870e5c52f0"
url: "https://pub.dev"
source: hosted
- version: "5.3.2"
- native_toolchain_c:
- dependency: transitive
- description:
- name: native_toolchain_c
- sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
- 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: "16.20.0"
+ simple_sparse_list:
+ dependency: transitive
+ description:
+ name: simple_sparse_list
+ sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
url: "https://pub.dev"
source: hosted
- version: "14.6.1"
+ 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:
@@ -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:
@@ -2047,10 +2039,10 @@ packages:
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..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.22.0+344"
+version: "0.23.0+348"
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/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,
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)