diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db23abd..daafa434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,62 @@ # Changelog +## 0.25.0 + +### Features + +* Transactions in a foreign currency now show an approximate amount in your + primary currency (e.g., "≈ R$25"). Can be turned off in Money formatting, + closes [#760](https://github.com/flow-mn/flow/issues/760) +* Added date format preference, and an option to show exact dates in list + headers, [#761](https://github.com/flow-mn/flow/issues/761) +* Categories/accounts stats now open as a ranked list with share bars, + [#761](https://github.com/flow-mn/flow/issues/761) +* Added "Worth knowing" to the stats tab: on-device observations like spending + spikes, unusual categories, and charges that look recurring, closes + [#761](https://github.com/flow-mn/flow/issues/761) +* Added an option to hide zero decimals (e.g., "$3" instead of "$3.00") in + Money formatting +* Recurring transactions can now have a varying amount (e.g., utility bills). + They're added as pending with an estimate, and ask for the actual amount + when you confirm. "Worth knowing" suggests these too + +### Changes + +* Reorganized preferences +* Pie chart now uses category colors, shows the total, and opens the category + when you tap a selected slice again +* Updated icons: Material Symbols 4.2960.0 and Simple Icons (brand icons) + 16.23.0 +* iOS 15 or later is now required + +### Fixes + +* Fixed exchange rates being fetched for the wrong currency at startup, and + not refreshing after changing the primary currency +* Fixed "Mask numbers at startup" not working +* Fixed recurring transactions not being set up when saved with the default + recurrence +* Fixed recurring transactions repeating on the wrong day after changing the + start date +* Fixed recurring start date not going earlier than the transaction date. It + now moves the transaction date with it +* Fixed transfers losing their location and attachments after editing +* Fixed brand icons in tags and old backups showing the wrong brand + +## 0.24.1 + +### Fixes + +* Fixed new transaction button not working when accessibility services (e.g. + TalkBack) are enabled +* Fixed new transaction menu closing on small finger movements +* Fixed Traditional Chinese (Taiwan) translations, [#758](https://github.com/flow-mn/flow/pull/758) by [@olivertzeng](https://github.com/olivertzeng) + ## 0.24.0 ### Features -* **Budgets.** Set a spending limit for a period, over all spending or only - chosen categories. Budgets live under Profile → Budgets, with an overview in - the Stats tab. -* Each budget gets its own page: progress for the current period, how it's - pacing, its recent periods, and every transaction counting towards it. -* Budgets overview surfaces which budgets are over or nearing their limit, and - what to do about it. -* Planned and pending transactions count towards a budget — money you've - scheduled is money the period is committed to. Progress bars draw that part - in a lighter shade, so you can still see what has actually cleared. -* Home screen widgets for budgets on iOS and Android, including a variant that - shows progress without revealing any amounts. Tapping one opens the budget - it's showing. -* Budgets are included in backups, and restore from any v2 backup. -* An in-app alert when a budget goes over, or gets close. +* Added budgets ### Changes diff --git a/analysis_options.yaml b/analysis_options.yaml index 978698e8..ec57be45 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -12,3 +12,8 @@ analyzer: - "build/**" - "**/*.g.dart" - "**/*.freezed.dart" + - android/** + - ios/** + - windows/** + - macos/** + - linux/** diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e0d3245b..6badd884 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -47,25 +47,22 @@ "budget.categories.description": "تُحتسب في هذه الميزانية نفقات الفئات المحددة فقط. لا تحدد أي فئة لاحتساب جميع النفقات.", "budget.delete": "حذف الميزانية", "budget.delete.description": "لن تتأثر المعاملات. هذا الإجراء لا رجعة فيه!", - "budget.insight.nearingLimit": "{name} تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", - "budget.insight.onTrack": "{name} على المسار الصحيح — {amount} متبقي.", - "budget.status.onTrack": "ضمن الحد", - "budget.status.nearing": "قارب الحد", - "budget.status.over": "تجاوز الحد", - "budget.detail.history": "الفترات الأخيرة", - "budget.detail.transactions": "المعاملات", - "budget.detail.unavailable": "أرقام الميزانية غير متوفرة حاليًا.", "budget.detail.daysLeft": "{} أيام متبقية", "budget.detail.daysLeft.one": "{} يوم متبقٍ", + "budget.detail.history": "الفترات الأخيرة", "budget.detail.periodEnded": "انتهت الفترة", - "budget.insight.over.short": "أنت متجاوز بمقدار {amount}. قلل الإنفاق أو ارفع الحد.", + "budget.detail.transactions": "المعاملات", + "budget.detail.unavailable": "أرقام الميزانية غير متوفرة حاليًا.", + "budget.insight.nearingLimit": "{name} تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", "budget.insight.nearingLimit.short": "تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", - "budget.insight.overpacing.short": "بهذه الوتيرة، ستتجاوز الحد بمقدار {amount}.", + "budget.insight.onTrack": "{name} على المسار الصحيح — {amount} متبقي.", "budget.insight.onTrack.short": "على المسار الصحيح — {amount} متبقي.", - "budget.insight.underspending.short": "لديك متسع — {amount} غير مُنفق.", "budget.insight.over": "أنت متجاوز بمقدار {amount} في {name}. قلل الإنفاق أو ارفع الحد.", + "budget.insight.over.short": "أنت متجاوز بمقدار {amount}. قلل الإنفاق أو ارفع الحد.", "budget.insight.overpacing": "بهذه الوتيرة، سيتجاوز {name} الحد بمقدار {amount}.", + "budget.insight.overpacing.short": "بهذه الوتيرة، ستتجاوز الحد بمقدار {amount}.", "budget.insight.underspending": "{name} لديه متسع — {amount} غير مُنفق.", + "budget.insight.underspending.short": "لديك متسع — {amount} غير مُنفق.", "budget.name": "اسم الميزانية", "budget.overview.allHealthy": "كل شيء على المسار الصحيح. عمل رائع.", "budget.overview.budgetsTracked": "{count} ميزانيات متبعة", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "تجديد تلقائي", "budget.renewAutomatically.description": "عند انتهاء الفترة، تنتقل الميزانية إلى الفترة الجديدة. على سبيل المثال، تصبح ميزانية يوليو ميزانية أغسطس.", "budget.scope": "النطاق", + "budget.status.nearing": "قارب الحد", + "budget.status.onTrack": "ضمن الحد", + "budget.status.over": "تجاوز الحد", "budgets": "الميزانيات", "budgets.new": "ميزانية جديدة", "categories": "الفئات", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "لا تنسَ إضافة معاملاتك اليوم!", "preferences": "التفضيلات", "preferences.appearance": "المظهر", - "preferences.changeVisuals": "تغيير", + "preferences.changeVisuals": "مؤشرات الاتجاه", "preferences.changeVisuals.arrow": "السهم", "preferences.changeVisuals.clickToChange": "انقر على السهم أو اللون للتغيير", "preferences.changeVisuals.color": "اللون", "preferences.changeVisuals.expenseIncrease": "زيادة النفقات", "preferences.changeVisuals.incomeIncrease": "زيادة الدخل", + "preferences.data": "البيانات", "preferences.dateFormat": "تنسيق التاريخ", + "preferences.dateFormat.absoluteDateHeaders": "عرض التواريخ الدقيقة في عناوين القائمة", + "preferences.dateFormat.system": "افتراضي اللغة", "preferences.feedback": "مشاكل وملاحظات", "preferences.feedback.debugLogs": "عرض سجلات التصحيح", - "preferences.hapticFeedback": "ردود فعل الأزرار", + "preferences.general": "عام", "preferences.hapticFeedback.description": "صوت/اهتزاز عند الضغط", - "preferences.integrations": "التكاملات", "preferences.language": "اللغة", "preferences.language.choose": "اختر لغة", "preferences.moneyFormatting": "تنسيق الأموال", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "لا تقصر الأرقام إذا أمكن", "preferences.moneyFormatting.setICUPattern": "اختر تنسيقًا مخصصًا", "preferences.moneyFormatting.setICUPattern.default": "افتراضي", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "إظهار المبلغ التقريبي", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "عرض المبالغ الأجنبية بعملتك الرئيسية أيضاً", + "preferences.moneyFormatting.hideZeroDecimals": "إخفاء الكسور الصفرية", + "preferences.moneyFormatting.hideZeroDecimals.description": "عرض \"3\" بدلاً من \"3.00\"", "preferences.moneyFormatting.useCurrencySymbol": "استخدام رمز العملة", "preferences.moneyFormatting.useCurrencySymbol.description": "مثلاً، \"$5\" بدلاً من \"5USD\"", "preferences.numpad": "لوحة الأرقام", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "كلاسيكي", "preferences.numpad.layout.modern": "حديث", "preferences.primaryCurrency": "العملة الأساسية", - "preferences.privacy": "الخصوصية", "preferences.privacy.appLock": "قفل التطبيق", "preferences.privacy.appLock.description#Android": "يتطلب التحقق البيومتري لفتح التطبيق", "preferences.privacy.appLock.description#iOS": "يتطلب Face ID أو Touch ID لفتح التطبيق", "preferences.privacy.appLock.lockAfterClosing": "قفل بعد الإغلاق", "preferences.privacy.maskAtShake": "إخفاء الأرقام (*) عند هزّ الجهاز", "preferences.privacy.maskAtStartup": "إخفاء الأرقام (*) عند بدء التشغيل", + "preferences.privacyAndSecurity": "الخصوصية والأمان", "preferences.reminders": "التذكير", "preferences.reminders.remindDaily": "تذكير يومي", "preferences.reminders.remindDaily.description": "تذكير بتتبع المصروفات يوميًا", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "قد يكون فوضويًا مع الإيصالات الطويلة", "preferences.scan.markPendingThreshold": "وضع المعاملات كمعلقة", "preferences.scan.markPendingThreshold.description": "إذا تم إيقاف هذا الخيار، ستظل المعاملات التي يكون تاريخها المُحلل أقدم من 6 ساعات معلمة على أنها معلقة", - "preferences.sync": "المزامنة", + "preferences.sync": "المزامنة والنسخ الاحتياطي", "preferences.sync.autoBackup": "النسخ الاحتياطي التلقائي", "preferences.sync.autoBackup.disabled": "تعطيل", "preferences.sync.autoBackup.interval": "فاصل النسخ الاحتياطي", @@ -792,6 +798,22 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "اطلع على مراجعة شهرك", "tabs.stats.analytics.wrapped.tileTitle": "ملخص {month} الخاص بك", "tabs.stats.analytics.wrapped.title": "{month}، ملخص", + "tabs.stats.byGroup.accountCount": "{count} حساب", + "tabs.stats.byGroup.accountCount.few": "{count} حسابات", + "tabs.stats.byGroup.accountCount.many": "{count} حسابًا", + "tabs.stats.byGroup.accountCount.one": "{count} حساب", + "tabs.stats.byGroup.accountCount.two": "{count} حسابان", + "tabs.stats.byGroup.accountCount.zero": "{count} حسابات", + "tabs.stats.byGroup.categoryCount": "{count} فئة", + "tabs.stats.byGroup.categoryCount.few": "{count} فئات", + "tabs.stats.byGroup.categoryCount.many": "{count} فئة", + "tabs.stats.byGroup.categoryCount.one": "{count} فئة", + "tabs.stats.byGroup.categoryCount.two": "{count} فئتان", + "tabs.stats.byGroup.categoryCount.zero": "{count} فئات", + "tabs.stats.byGroup.totalEarned": "إجمالي الدخل", + "tabs.stats.byGroup.totalSpent": "إجمالي الإنفاق", + "tabs.stats.byGroup.view.chart": "مخطط", + "tabs.stats.byGroup.view.list": "قائمة", "tabs.stats.categories": "الفئات", "tabs.stats.categories.seeAll": "عرض جميع الفئات", "tabs.stats.categories.top": "أعلى المصروفات", @@ -816,6 +838,77 @@ "tabs.stats.trends.median.description": "الوسيط لمبلغ المصروفات في النطاق الزمني المحدد", "tabs.stats.trends.topSpendingTitles": "المعاملات المتكررة", "tabs.stats.trends.topSpendingTitles.description": "أكثر عناوين المعاملات استخدامًا", + "tabs.stats.worthKnowing": "يستحق المعرفة", + "tabs.stats.worthKnowing.biggestEntries": "أكبر القيود", + "tabs.stats.worthKnowing.categoryDrop": "{category}: انخفاض بنسبة {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: انخفاض بنسبة {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} المعتاد.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: كان {value} المعتاد.", + "tabs.stats.worthKnowing.charges": "الدفعات", + "tabs.stats.worthKnowing.chartBefore": "سابقًا {amount}", + "tabs.stats.worthKnowing.chartCharges": "كل دفعة", + "tabs.stats.worthKnowing.chartMonthly": "الإنفاق كل شهر", + "tabs.stats.worthKnowing.chartThroughDay": "الإنفاق حتى اليوم {dayNumber} من كل شهر", + "tabs.stats.worthKnowing.chartUsual": "المعتاد {amount}", + "tabs.stats.worthKnowing.entries": "{count} قيود", + "tabs.stats.worthKnowing.entries.many": "{count} قيدًا", + "tabs.stats.worthKnowing.entries.one": "قيد واحد", + "tabs.stats.worthKnowing.entries.other": "{count} قيد", + "tabs.stats.worthKnowing.entries.two": "قيدان", + "tabs.stats.worthKnowing.hidden": "تم الإخفاء. يمكنك إعادة تفعيله من «يستحق المعرفة».", + "tabs.stats.worthKnowing.hide.categoryDrop": "عدم إظهار انخفاضات الفئات", + "tabs.stats.worthKnowing.hide.categorySpike": "عدم إظهار ارتفاعات الفئات", + "tabs.stats.worthKnowing.hide.monthPace": "عدم إظهار مقارنات الشهر", + "tabs.stats.worthKnowing.hide.newCategory": "عدم إظهار الفئات الجديدة", + "tabs.stats.worthKnowing.hide.priceChange": "عدم إظهار تغيّرات الأسعار", + "tabs.stats.worthKnowing.hide.recurringCharge": "عدم إظهار اقتراحات الاشتراكات", + "tabs.stats.worthKnowing.kinds": "الإظهار في «يستحق المعرفة»", + "tabs.stats.worthKnowing.lessThanUsual": "أقل من المعتاد بـ {amount}", + "tabs.stats.worthKnowing.monthAbove": "{month}: أعلى من المعتاد بنسبة {value} حتى اليوم {dayNumber}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: أعلى من المعتاد بنسبة {value}.", + "tabs.stats.worthKnowing.monthBelow": "{month}: أقل من المعتاد بنسبة {value} حتى اليوم {dayNumber}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: أقل من المعتاد بنسبة {value}.", + "tabs.stats.worthKnowing.moreThanUsual": "أكثر من المعتاد بـ {amount}", + "tabs.stats.worthKnowing.mostlyCategory": "غالبًا {category}، {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "غالبًا {title} ({amount}). بدونه، ضمن المعتاد.", + "tabs.stats.worthKnowing.mostlyOver": "غالبًا {title} ({amount}). بدونه، لا يزال أعلى بنسبة {percent}.", + "tabs.stats.worthKnowing.mostlyUnder": "غالبًا {title} ({amount}). بدونه، أقل بنسبة {percent}.", + "tabs.stats.worthKnowing.newCategory": "{value}: أول إنفاق منذ {months} أشهر.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: لا شيء غير معتاد حتى الآن.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: لا شيء غير معتاد.", + "tabs.stats.worthKnowing.priceChange": "{title}: من {previous} إلى {value}.", + "tabs.stats.worthKnowing.priceDown": "أقل بـ {amount} سنويًا", + "tabs.stats.worthKnowing.priceUp": "أكثر بـ {amount} سنويًا", + "tabs.stats.worthKnowing.recurringMonthly": "{value} يبدو دفعة شهرية.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} حوالي اليوم {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} يبدو دفعة أسبوعية.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} كل {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} يبدو دفعة سنوية.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} حوالي {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "يظهر عندما تنخفض فئة تستخدمها معظم الأشهر إلى أقل من {percent} من وسيطها.", + "tabs.stats.worthKnowing.rule.categorySpike": "يظهر عندما تبلغ فئة {ratio} وسيطها في الأشهر {months} السابقة، مع {count} قيود أو أكثر.", + "tabs.stats.worthKnowing.rule.footer": "التحويلات والقيود المعلّقة غير محسوبة. تم الحساب على هذا الجهاز.", + "tabs.stats.worthKnowing.rule.monthPace": "الإنفاق حتى {date} مقارنةً بوسيط الأشهر {months} السابقة حتى اليوم نفسه.", + "tabs.stats.worthKnowing.rule.monthPacePast": "إنفاق {month} مقارنةً بوسيط الأشهر {months} السابقة.", + "tabs.stats.worthKnowing.rule.newCategory": "يظهر عندما يكون لفئة إنفاق لأول مرة منذ {months} أشهر.", + "tabs.stats.worthKnowing.rule.priceChange": "يظهر عندما تتغير دفعة متكررة بنسبة {percent} أو أكثر.", + "tabs.stats.worthKnowing.rule.recurringCharge": "دفعات بالعنوان نفسه تتكرر بانتظام. الفواتير المتغيرة، مثل الكهرباء والماء، تحتاج إلى {count} دفعات على الأقل.", + "tabs.stats.worthKnowing.seeTransactions": "عرض هذه المعاملات", + "tabs.stats.worthKnowing.times": "{count} مرات حتى الآن", + "tabs.stats.worthKnowing.times.many": "{count} مرة حتى الآن", + "tabs.stats.worthKnowing.times.one": "مرة واحدة حتى الآن", + "tabs.stats.worthKnowing.times.other": "{count} مرة حتى الآن", + "tabs.stats.worthKnowing.times.two": "مرتان حتى الآن", + "tabs.stats.worthKnowing.track": "تتبّع كمعاملة متكررة", + "tabs.stats.worthKnowing.type.categoryDrop": "انخفاضات الفئات", + "tabs.stats.worthKnowing.type.categorySpike": "ارتفاعات الفئات", + "tabs.stats.worthKnowing.type.monthPace": "مقارنات الشهر", + "tabs.stats.worthKnowing.type.newCategory": "الفئات الجديدة", + "tabs.stats.worthKnowing.type.priceChange": "تغيّرات الأسعار", + "tabs.stats.worthKnowing.type.recurringCharge": "اقتراحات الاشتراكات", + "tabs.stats.worthKnowing.versusTypical": "{current} مقابل {usual} المعتاد", + "tabs.stats.worthKnowing.whatMovedIt": "ما الذي غيّره", + "tabs.stats.worthKnowing.why": "لماذا ترى هذا", "transaction": "معاملة", "transaction.actions": "الإجراءات", "transaction.attachments": "المرفقات", @@ -879,6 +972,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "سيؤدي هذا الإجراء إلى حذف جميع المعاملات المرتبطة، وإيقاف إنشاء معاملات جديدة. استعادة المعاملات من سلة المهملات لن تؤدي إلى إنشاء معاملات جديدة.", "transaction.recurring.edit": "تعديل المعاملة المتكررة", "transaction.recurring.setup": "إعداد المعاملة المتكررة", + "transaction.recurring.variableAmount": "المبلغ متغير", + "transaction.recurring.variableAmount.description": "يطلب المبلغ في كل مرة", "transaction.tags": "الوسوم", "transaction.tags.add": "إضافة وسوم", "transaction.tags.contact.name": "اسم جهة الاتصال", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index a4856514..dcd7d71e 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -47,25 +47,22 @@ "budget.categories.description": "У гэты бюджэт уваходзяць толькі выдаткі выбраных катэгорый. Не выбірайце нічога, каб улічваць усе выдаткі.", "budget.delete": "Выдаліць бюджэт", "budget.delete.description": "Транзакцыі не будуць закрануты. Гэта дзеянне незваротнае!", - "budget.insight.nearingLimit": "{name} выкарыстана на {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", - "budget.insight.onTrack": "{name} у парадку — засталося {amount}.", - "budget.status.onTrack": "У парадку", - "budget.status.nearing": "Блізка да ліміту", - "budget.status.over": "Ліміт перавышаны", - "budget.detail.history": "Апошнія перыяды", - "budget.detail.transactions": "Транзакцыі", - "budget.detail.unavailable": "Даныя бюджэту зараз недаступныя.", "budget.detail.daysLeft": "Засталося {} дзён", "budget.detail.daysLeft.one": "Застаўся {} дзень", + "budget.detail.history": "Апошнія перыяды", "budget.detail.periodEnded": "Перыяд скончыўся", - "budget.insight.over.short": "Вы перавысілі ліміт на {amount}. Зменшыце выдаткі або павялічце ліміт.", + "budget.detail.transactions": "Транзакцыі", + "budget.detail.unavailable": "Даныя бюджэту зараз недаступныя.", + "budget.insight.nearingLimit": "{name} выкарыстана на {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", "budget.insight.nearingLimit.short": "Выкарыстана {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", - "budget.insight.overpacing.short": "Пры такім тэмпе вы перавысіце ліміт на {amount}.", + "budget.insight.onTrack": "{name} у парадку — засталося {amount}.", "budget.insight.onTrack.short": "У парадку — засталося {amount}.", - "budget.insight.underspending.short": "Ёсць запас — не выдаткавана {amount}.", "budget.insight.over": "Вы перавысілі ліміт па {name} на {amount}. Зменшыце выдаткі або павялічце ліміт.", + "budget.insight.over.short": "Вы перавысілі ліміт на {amount}. Зменшыце выдаткі або павялічце ліміт.", "budget.insight.overpacing": "Пры такім тэмпе {name} перавысіць ліміт на {amount}.", + "budget.insight.overpacing.short": "Пры такім тэмпе вы перавысіце ліміт на {amount}.", "budget.insight.underspending": "{name} мае запас — не выдаткавана {amount}.", + "budget.insight.underspending.short": "Ёсць запас — не выдаткавана {amount}.", "budget.name": "Назва бюджэту", "budget.overview.allHealthy": "Усё ідзе па плане. Выдатная праца.", "budget.overview.budgetsTracked": "{count} бюджэтаў адсочваецца", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Аднаўляць аўтаматычна", "budget.renewAutomatically.description": "Калі перыяд заканчваецца, бюджэт пераходзіць на новы перыяд. Напрыклад, бюджэт ліпеня становіцца бюджэтам жніўня.", "budget.scope": "Ахоп", + "budget.status.nearing": "Блізка да ліміту", + "budget.status.onTrack": "У парадку", + "budget.status.over": "Ліміт перавышаны", "budgets": "Бюджэты", "budgets.new": "Новы бюджэт", "categories": "Катэгорыі", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Не забудзьцеся дадаць свае транзакцыі сёння!", "preferences": "Налады", "preferences.appearance": "Вонкавы выгляд", - "preferences.changeVisuals": "Змяніць", + "preferences.changeVisuals": "Індыкатары тэндэнцыі", "preferences.changeVisuals.arrow": "Стрэлка", "preferences.changeVisuals.clickToChange": "Націсніце на стрэлку/колер, каб змяніць", "preferences.changeVisuals.color": "Колер", "preferences.changeVisuals.expenseIncrease": "Рост выбаткаў", "preferences.changeVisuals.incomeIncrease": "Рост даходаў", + "preferences.data": "Даныя", "preferences.dateFormat": "Фармат даты", + "preferences.dateFormat.absoluteDateHeaders": "Паказваць дакладныя даты ў загалоўках", + "preferences.dateFormat.system": "Паводле мовы", "preferences.feedback": "Праблемы і зваротная сувязь", "preferences.feedback.debugLogs": "Прагляд журналаў адладкі", - "preferences.hapticFeedback": "Водгук кнопак", + "preferences.general": "Агульныя", "preferences.hapticFeedback.description": "Гукавы/тактыльны водгук пры націсканні", - "preferences.integrations": "Інтэграцыі", "preferences.language": "Мова", "preferences.language.choose": "Выберыце мову", "preferences.moneyFormatting": "Фарматаванне грошай", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Не скарачаць лічбы, калі гэта магчыма", "preferences.moneyFormatting.setICUPattern": "Выбраць карыстальніцкі фармат", "preferences.moneyFormatting.setICUPattern.default": "Па змаўчанні", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Паказваць прыблізную суму", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Таксама паказваць замежныя сумы ў асноўнай валюце", + "preferences.moneyFormatting.hideZeroDecimals": "Хаваць нулявыя дробы", + "preferences.moneyFormatting.hideZeroDecimals.description": "Паказваць «3» замест «3,00»", "preferences.moneyFormatting.useCurrencySymbol": "Выкарыстоўваць сімвал валюты", "preferences.moneyFormatting.useCurrencySymbol.description": "напр., \"$5\" замест \"5USD\"", "preferences.numpad": "Лічбавая панэль", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Класічная", "preferences.numpad.layout.modern": "Сучасная", "preferences.primaryCurrency": "Асноўная валюта", - "preferences.privacy": "Канфідэнцыяльнасць", "preferences.privacy.appLock": "Блакіроўка праграмы", "preferences.privacy.appLock.description#Android": "Патрабаваць біяметрычную аўтэнтыфікацыю для адкрыцця праграмы", "preferences.privacy.appLock.description#iOS": "Патрабаваць Face ID або Touch ID для адкрыцця праграмы", "preferences.privacy.appLock.lockAfterClosing": "Блакіраваць пасля закрыцця", "preferences.privacy.maskAtShake": "Хаваць лічбы (*) пры ўстрэсванні прылады", "preferences.privacy.maskAtStartup": "Хаваць лічбы (*) пры запуску", + "preferences.privacyAndSecurity": "Канфідэнцыяльнасць і бяспека", "preferences.reminders": "Напамінкі", "preferences.reminders.remindDaily": "Нагадваць штодзень", "preferences.reminders.remindDaily.description": "Нагадваць аб штодзённым адсочванні выдаткаў", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Можа быць хаатычна для доўгіх чэкаў", "preferences.scan.markPendingThreshold": "Адзначаць транзакцыі як чакаючыя", "preferences.scan.markPendingThreshold.description": "Калі выключана, транзакцыі з апрацаванай датай, старэйшай за 6 гадзін, усё роўна будуць пазначацца як чакаючыя", - "preferences.sync": "Сінхранізацыя", + "preferences.sync": "Сінхранізацыя і рэзервовае капіраванне", "preferences.sync.autoBackup": "Аўта-рэзерваванне", "preferences.sync.autoBackup.disabled": "Адключыць", "preferences.sync.autoBackup.interval": "Інтэрвал рэзервавання", @@ -790,6 +796,18 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Паглядзіце агляд вашага месяца", "tabs.stats.analytics.wrapped.tileTitle": "Ваша рэзюмэ за {month}", "tabs.stats.analytics.wrapped.title": "{month}: падсумаванне", + "tabs.stats.byGroup.accountCount": "{count} рахункаў", + "tabs.stats.byGroup.accountCount.few": "{count} рахункі", + "tabs.stats.byGroup.accountCount.many": "{count} рахункаў", + "tabs.stats.byGroup.accountCount.one": "{count} рахунак", + "tabs.stats.byGroup.categoryCount": "{count} катэгорый", + "tabs.stats.byGroup.categoryCount.few": "{count} катэгорыі", + "tabs.stats.byGroup.categoryCount.many": "{count} катэгорый", + "tabs.stats.byGroup.categoryCount.one": "{count} катэгорыя", + "tabs.stats.byGroup.totalEarned": "Усяго атрымана", + "tabs.stats.byGroup.totalSpent": "Усяго выдаткавана", + "tabs.stats.byGroup.view.chart": "Дыяграма", + "tabs.stats.byGroup.view.list": "Спіс", "tabs.stats.categories": "Катэгорыі", "tabs.stats.categories.seeAll": "Глядзець усе катэгорыі", "tabs.stats.categories.top": "Галоўныя выдаткі", @@ -814,6 +832,73 @@ "tabs.stats.trends.median.description": "Медыяна сумы выдаткаў у выбраным дыяпазоне часу", "tabs.stats.trends.topSpendingTitles": "Частыя транзакцыі", "tabs.stats.trends.topSpendingTitles.description": "Назвы транзакцый, што сустракаюцца часцей за ўсё", + "tabs.stats.worthKnowing": "Варта ведаць", + "tabs.stats.worthKnowing.biggestEntries": "Найбуйнейшыя запісы", + "tabs.stats.worthKnowing.categoryDrop": "{category}: менш на {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: было менш на {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} ад звычайнага.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: было {value} ад звычайнага.", + "tabs.stats.worthKnowing.charges": "Спісанні", + "tabs.stats.worthKnowing.chartBefore": "раней {amount}", + "tabs.stats.worthKnowing.chartCharges": "Кожнае спісанне", + "tabs.stats.worthKnowing.chartMonthly": "Выдаткі па месяцах", + "tabs.stats.worthKnowing.chartThroughDay": "Выдаткі да {dayNumber}-га чысла кожнага месяца", + "tabs.stats.worthKnowing.chartUsual": "звычайна {amount}", + "tabs.stats.worthKnowing.entries": "{count} запісаў", + "tabs.stats.worthKnowing.entries.few": "{count} запісы", + "tabs.stats.worthKnowing.entries.one": "{count} запіс", + "tabs.stats.worthKnowing.hidden": "Схавана. Можна вярнуць у картцы «Варта ведаць».", + "tabs.stats.worthKnowing.hide.categoryDrop": "Не паказваць зніжэнні па катэгорыях", + "tabs.stats.worthKnowing.hide.categorySpike": "Не паказваць усплёскі па катэгорыях", + "tabs.stats.worthKnowing.hide.monthPace": "Не паказваць параўнанні месяца", + "tabs.stats.worthKnowing.hide.newCategory": "Не паказваць новыя катэгорыі", + "tabs.stats.worthKnowing.hide.priceChange": "Не паказваць змены цэн", + "tabs.stats.worthKnowing.hide.recurringCharge": "Не паказваць падказкі пра падпіскі", + "tabs.stats.worthKnowing.kinds": "Паказваць у «Варта ведаць»", + "tabs.stats.worthKnowing.lessThanUsual": "на {amount} менш, чым звычайна", + "tabs.stats.worthKnowing.monthAbove": "{month}: на {value} вышэй за звычайнае да {dayNumber}-га чысла.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: на {value} вышэй за звычайнае.", + "tabs.stats.worthKnowing.monthBelow": "{month}: на {value} ніжэй за звычайнае да {dayNumber}-га чысла.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: на {value} ніжэй за звычайнае.", + "tabs.stats.worthKnowing.moreThanUsual": "на {amount} больш, чым звычайна", + "tabs.stats.worthKnowing.mostlyCategory": "Пераважна {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Пераважна {title} ({amount}). Без гэтага — як звычайна.", + "tabs.stats.worthKnowing.mostlyOver": "Пераважна {title} ({amount}). Без гэтага ўсё роўна на {percent} вышэй.", + "tabs.stats.worthKnowing.mostlyUnder": "Пераважна {title} ({amount}). Без гэтага на {percent} ніжэй.", + "tabs.stats.worthKnowing.newCategory": "{value}: першыя выдаткі за {months} мес.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: пакуль нічога незвычайнага.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: нічога незвычайнага.", + "tabs.stats.worthKnowing.priceChange": "{title}: было {previous}, стала {value}.", + "tabs.stats.worthKnowing.priceDown": "на {amount} менш за год", + "tabs.stats.worthKnowing.priceUp": "на {amount} больш за год", + "tabs.stats.worthKnowing.recurringMonthly": "{value} падобна на штомесячны плацёж.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} каля {dayNumber}-га чысла · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} падобна на штотыднёвы плацёж.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} падобна на штогадовы плацёж.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} каля {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "З'яўляецца, калі катэгорыя, якой вы карыстаецеся амаль штомесяц, падае ніжэй за {percent} ад сваёй медыяны.", + "tabs.stats.worthKnowing.rule.categorySpike": "З'яўляецца, калі катэгорыя дасягае {ratio} ад сваёй медыяны за {months} папярэдніх мес. і мае не менш за {count} запісы.", + "tabs.stats.worthKnowing.rule.footer": "Пераводы і чаканыя запісы не ўлічваюцца. Падлічана на гэтай прыладзе.", + "tabs.stats.worthKnowing.rule.monthPace": "Выдаткі да {date} у параўнанні з медыянай {months} папярэдніх мес. на той жа дзень.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Выдаткі за перыяд «{month}» у параўнанні з медыянай {months} папярэдніх мес.", + "tabs.stats.worthKnowing.rule.newCategory": "З'яўляецца, калі ў катэгорыі ўпершыню за {months} мес. ёсць выдаткі.", + "tabs.stats.worthKnowing.rule.priceChange": "З'яўляецца, калі рэгулярны плацёж мяняецца на {percent} ці больш.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Плацяжы з аднолькавай назвай, якія рэгулярна паўтараюцца. Для зменлівых рахункаў, як камунальныя, патрэбна не менш за {count} плацяжы.", + "tabs.stats.worthKnowing.seeTransactions": "Паказаць гэтыя транзакцыі", + "tabs.stats.worthKnowing.times": "ужо {count} разоў", + "tabs.stats.worthKnowing.times.few": "ужо {count} разы", + "tabs.stats.worthKnowing.times.one": "ужо {count} раз", + "tabs.stats.worthKnowing.track": "Зрабіць перыядычнай", + "tabs.stats.worthKnowing.type.categoryDrop": "Зніжэнні па катэгорыях", + "tabs.stats.worthKnowing.type.categorySpike": "Усплёскі па катэгорыях", + "tabs.stats.worthKnowing.type.monthPace": "Параўнанні месяца", + "tabs.stats.worthKnowing.type.newCategory": "Новыя катэгорыі", + "tabs.stats.worthKnowing.type.priceChange": "Змены цэн", + "tabs.stats.worthKnowing.type.recurringCharge": "Падказкі пра падпіскі", + "tabs.stats.worthKnowing.versusTypical": "{current} супраць звычайных {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Што паўплывала", + "tabs.stats.worthKnowing.why": "Чаму вы гэта бачыце", "transaction": "Транзакцыя", "transaction.actions": "Дзеянні", "transaction.attachments": "Прымацаваныя файлы", @@ -900,6 +985,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Гэтае дзеянне выдаліць усе звязаныя транзакцыі і спыніць стварэнне новых. Аднаўленне транзакцый з кошыка не прымусіць іх ствараць новыя транзакцыі.", "transaction.recurring.edit": "Рэдагаваць перыядычную транзакцыю", "transaction.recurring.setup": "Наладзіць перыядычную транзакцыю", + "transaction.recurring.variableAmount": "Сума змяняецца", + "transaction.recurring.variableAmount.description": "Пытаецца суму кожны раз", "transaction.tags": "Тэгі", "transaction.tags.add": "Дадаць тэгі", "transaction.tags.contact.name": "Імя кантакту", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index bc0073a9..cffd980d 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -47,25 +47,22 @@ "budget.categories.description": "Do tohoto rozpočtu se počítají pouze výdaje ve vybraných kategoriích. Nevyberete-li žádnou, počítají se všechny výdaje.", "budget.delete": "Smazat rozpočet", "budget.delete.description": "Transakce nebudou ovlivněny. Tato akce je nevratná!", - "budget.insight.nearingLimit": "{name}: {percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", - "budget.insight.onTrack": "{name} je na správné cestě — zbývá {amount}.", - "budget.status.onTrack": "V pořádku", - "budget.status.nearing": "Blízko limitu", - "budget.status.over": "Nad limitem", - "budget.detail.history": "Poslední období", - "budget.detail.transactions": "Transakce", - "budget.detail.unavailable": "Údaje rozpočtu nejsou momentálně k dispozici.", "budget.detail.daysLeft": "Zbývá {} dní", "budget.detail.daysLeft.one": "Zbývá {} den", + "budget.detail.history": "Poslední období", "budget.detail.periodEnded": "Období skončilo", - "budget.insight.over.short": "Překročili jste rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", + "budget.detail.transactions": "Transakce", + "budget.detail.unavailable": "Údaje rozpočtu nejsou momentálně k dispozici.", + "budget.insight.nearingLimit": "{name}: {percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", "budget.insight.nearingLimit.short": "{percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", - "budget.insight.overpacing.short": "Při tomto tempu překročíte rozpočet o {amount}.", + "budget.insight.onTrack": "{name} je na správné cestě — zbývá {amount}.", "budget.insight.onTrack.short": "Na správné cestě — zbývá {amount}.", - "budget.insight.underspending.short": "Máte rezervu — {amount} nevyužito.", "budget.insight.over": "U {name} jste překročili rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", + "budget.insight.over.short": "Překročili jste rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", "budget.insight.overpacing": "Při tomto tempu {name} překročí rozpočet o {amount}.", + "budget.insight.overpacing.short": "Při tomto tempu překročíte rozpočet o {amount}.", "budget.insight.underspending": "{name} má rezervu — {amount} nevyužito.", + "budget.insight.underspending.short": "Máte rezervu — {amount} nevyužito.", "budget.name": "Název rozpočtu", "budget.overview.allHealthy": "Vše jde podle plánu. Dobrá práce.", "budget.overview.budgetsTracked": "{count} sledovaných rozpočtů", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Obnovovat automaticky", "budget.renewAutomatically.description": "Po skončení období rozpočet přejde do nového období. Například červencový rozpočet se stane srpnovým.", "budget.scope": "Rozsah", + "budget.status.nearing": "Blízko limitu", + "budget.status.onTrack": "V pořádku", + "budget.status.over": "Nad limitem", "budgets": "Rozpočty", "budgets.new": "Nový rozpočet", "categories": "Kategorie", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Nezapomeňte dnes přidat své transakce!", "preferences": "Předvolby", "preferences.appearance": "Vzhled", - "preferences.changeVisuals": "Změnit vzhled", + "preferences.changeVisuals": "Indikátory trendu", "preferences.changeVisuals.arrow": "Šipka", "preferences.changeVisuals.clickToChange": "Klikněte na šipku nebo barvu pro změnu", "preferences.changeVisuals.color": "Barva", "preferences.changeVisuals.expenseIncrease": "Nárůst výdajů", "preferences.changeVisuals.incomeIncrease": "Nárůst příjmů", + "preferences.data": "Data", "preferences.dateFormat": "Formát data", + "preferences.dateFormat.absoluteDateHeaders": "Zobrazit přesná data v záhlavích", + "preferences.dateFormat.system": "Podle jazyka", "preferences.feedback": "Problémy a zpětná vazba", "preferences.feedback.debugLogs": "Zobrazit protokoly ladění", - "preferences.hapticFeedback": "Haptická odezva", + "preferences.general": "Obecné", "preferences.hapticFeedback.description": "Vibrace/zvuk při interakci s uživatelským rozhraním.", - "preferences.integrations": "Integrace", "preferences.language": "Jazyk", "preferences.language.choose": "Vyberte jazyk", "preferences.moneyFormatting": "Formátování měny", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Nezkracovat velká čísla (např. 1 000 000 místo 1M).", "preferences.moneyFormatting.setICUPattern": "Nastavit vlastní formát", "preferences.moneyFormatting.setICUPattern.default": "Výchozí", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Zobrazit přibližnou částku", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Zobrazovat cizí částky i v primární měně", + "preferences.moneyFormatting.hideZeroDecimals": "Skrýt nulová desetinná místa", + "preferences.moneyFormatting.hideZeroDecimals.description": "Zobrazit „3“ místo „3,00“", "preferences.moneyFormatting.useCurrencySymbol": "Používat symbol měny", "preferences.moneyFormatting.useCurrencySymbol.description": "např. \"5 $\" místo \"5USD\"", "preferences.numpad": "Číselná klávesnice", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Klasické", "preferences.numpad.layout.modern": "Moderní", "preferences.primaryCurrency": "Primární měna", - "preferences.privacy": "Soukromí", "preferences.privacy.appLock": "Zámek aplikace", "preferences.privacy.appLock.description#Android": "Vyžadovat biometrické ověření pro otevření aplikace.", "preferences.privacy.appLock.description#iOS": "Vyžadovat Face ID nebo Touch ID pro otevření aplikace.", "preferences.privacy.appLock.lockAfterClosing": "Zamknout po zavření", "preferences.privacy.maskAtShake": "Při zatřesení zařízením maskovat čísla (*)", "preferences.privacy.maskAtStartup": "Maskovat částky (*) při spuštění", + "preferences.privacyAndSecurity": "Soukromí a zabezpečení", "preferences.reminders": "Připomínky", "preferences.reminders.remindDaily": "Připomínat denně", "preferences.reminders.remindDaily.description": "Dostávat denní připomínky k zaznamenání výdajů.", @@ -789,6 +795,16 @@ "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.byGroup.accountCount": "{count} účtů", + "tabs.stats.byGroup.accountCount.few": "{count} účty", + "tabs.stats.byGroup.accountCount.one": "{count} účet", + "tabs.stats.byGroup.categoryCount": "{count} kategorií", + "tabs.stats.byGroup.categoryCount.few": "{count} kategorie", + "tabs.stats.byGroup.categoryCount.one": "{count} kategorie", + "tabs.stats.byGroup.totalEarned": "Celkem přijato", + "tabs.stats.byGroup.totalSpent": "Celkem utraceno", + "tabs.stats.byGroup.view.chart": "Graf", + "tabs.stats.byGroup.view.list": "Seznam", "tabs.stats.categories": "Kategorie", "tabs.stats.categories.seeAll": "Zobrazit všechny kategorie", "tabs.stats.categories.top": "Největší výdaje", @@ -813,6 +829,73 @@ "tabs.stats.trends.median.description": "Střední hodnota výdajů ve vybraném časovém období.", "tabs.stats.trends.topSpendingTitles": "Nejčastější transakce", "tabs.stats.trends.topSpendingTitles.description": "Nejčastěji používané názvy transakcí.", + "tabs.stats.worthKnowing": "Dobré vědět", + "tabs.stats.worthKnowing.biggestEntries": "Největší záznamy", + "tabs.stats.worthKnowing.categoryDrop": "{category}: pokles o {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: pokles o {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} obvyklé částky.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: {value} obvyklé částky.", + "tabs.stats.worthKnowing.charges": "Platby", + "tabs.stats.worthKnowing.chartBefore": "dříve {amount}", + "tabs.stats.worthKnowing.chartCharges": "Každá platba", + "tabs.stats.worthKnowing.chartMonthly": "Výdaje v každém měsíci", + "tabs.stats.worthKnowing.chartThroughDay": "Výdaje do {dayNumber}. dne každého měsíce", + "tabs.stats.worthKnowing.chartUsual": "obvykle {amount}", + "tabs.stats.worthKnowing.entries": "{count} záznamů", + "tabs.stats.worthKnowing.entries.few": "{count} záznamy", + "tabs.stats.worthKnowing.entries.one": "{count} záznam", + "tabs.stats.worthKnowing.hidden": "Skryto. Znovu zapnete v kartě Dobré vědět.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Nezobrazovat poklesy kategorií", + "tabs.stats.worthKnowing.hide.categorySpike": "Nezobrazovat výkyvy kategorií", + "tabs.stats.worthKnowing.hide.monthPace": "Nezobrazovat srovnání měsíce", + "tabs.stats.worthKnowing.hide.newCategory": "Nezobrazovat nové kategorie", + "tabs.stats.worthKnowing.hide.priceChange": "Nezobrazovat změny cen", + "tabs.stats.worthKnowing.hide.recurringCharge": "Nezobrazovat návrhy předplatného", + "tabs.stats.worthKnowing.kinds": "Zobrazovat v Dobré vědět", + "tabs.stats.worthKnowing.lessThanUsual": "o {amount} méně než obvykle", + "tabs.stats.worthKnowing.monthAbove": "{month}: o {value} nad obvyklou úrovní k {dayNumber}. dni.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: o {value} nad obvyklou úrovní.", + "tabs.stats.worthKnowing.monthBelow": "{month}: o {value} pod obvyklou úrovní k {dayNumber}. dni.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: o {value} pod obvyklou úrovní.", + "tabs.stats.worthKnowing.moreThanUsual": "o {amount} více než obvykle", + "tabs.stats.worthKnowing.mostlyCategory": "Hlavně {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Hlavně {title} ({amount}). Bez toho zhruba obvykle.", + "tabs.stats.worthKnowing.mostlyOver": "Hlavně {title} ({amount}). Bez toho stále o {percent} výš.", + "tabs.stats.worthKnowing.mostlyUnder": "Hlavně {title} ({amount}). Bez toho o {percent} níž.", + "tabs.stats.worthKnowing.newCategory": "{value}: první výdaje za {months} měsíců.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: zatím nic neobvyklého.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: nic neobvyklého.", + "tabs.stats.worthKnowing.priceChange": "{title}: dříve {previous}, nyní {value}.", + "tabs.stats.worthKnowing.priceDown": "o {amount} méně ročně", + "tabs.stats.worthKnowing.priceUp": "o {amount} více ročně", + "tabs.stats.worthKnowing.recurringMonthly": "{value} vypadá jako měsíční platba.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} kolem {dayNumber}. dne · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} vypadá jako týdenní platba.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} vypadá jako roční platba.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} kolem {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Zobrazí se, když kategorie používaná téměř každý měsíc klesne pod {percent} svého mediánu.", + "tabs.stats.worthKnowing.rule.categorySpike": "Zobrazí se, když kategorie dosáhne {ratio} svého mediánu za {months} předchozích měsíců a má alespoň {count} záznamy.", + "tabs.stats.worthKnowing.rule.footer": "Převody a čekající záznamy se nezapočítávají. Spočítáno na tomto zařízení.", + "tabs.stats.worthKnowing.rule.monthPace": "Výdaje do {date} ve srovnání s mediánem {months} předchozích měsíců ke stejnému dni.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Výdaje za {month} ve srovnání s mediánem {months} předchozích měsíců.", + "tabs.stats.worthKnowing.rule.newCategory": "Zobrazí se, když má kategorie výdaje poprvé za {months} měsíců.", + "tabs.stats.worthKnowing.rule.priceChange": "Zobrazí se, když se pravidelná platba změní o {percent} nebo více.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Platby se stejným názvem, které se pravidelně opakují. Proměnlivé účty, jako energie, potřebují alespoň {count} platby.", + "tabs.stats.worthKnowing.seeTransactions": "Zobrazit tyto transakce", + "tabs.stats.worthKnowing.times": "zatím {count}krát", + "tabs.stats.worthKnowing.times.few": "zatím {count}krát", + "tabs.stats.worthKnowing.times.one": "zatím {count}krát", + "tabs.stats.worthKnowing.track": "Sledovat jako opakující se", + "tabs.stats.worthKnowing.type.categoryDrop": "Poklesy kategorií", + "tabs.stats.worthKnowing.type.categorySpike": "Výkyvy kategorií", + "tabs.stats.worthKnowing.type.monthPace": "Srovnání měsíce", + "tabs.stats.worthKnowing.type.newCategory": "Nové kategorie", + "tabs.stats.worthKnowing.type.priceChange": "Změny cen", + "tabs.stats.worthKnowing.type.recurringCharge": "Návrhy předplatného", + "tabs.stats.worthKnowing.versusTypical": "{current} vs obvyklé {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Co to ovlivnilo", + "tabs.stats.worthKnowing.why": "Proč to vidíte", "transaction": "Transakce", "transaction.actions": "Akce", "transaction.attachments": "Přílohy", @@ -890,6 +973,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Tato akce odstraní všechny související transakce a zastaví vytváření nových. Obnovení transakcí z koše nezpůsobí opětovné vytváření nových transakcí.", "transaction.recurring.edit": "Upravit opakující se transakci", "transaction.recurring.setup": "Nastavit opakující se transakci", + "transaction.recurring.variableAmount": "Částka se mění", + "transaction.recurring.variableAmount.description": "Pokaždé se zeptá na částku", "transaction.tags": "Štítky", "transaction.tags.add": "Přidat štítky", "transaction.tags.contact.name": "Jméno kontaktu", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 79168597..3f7a7c77 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -47,25 +47,22 @@ "budget.categories.description": "Nur Ausgaben in den ausgewählten Kategorien zählen zu diesem Budget. Ohne Auswahl zählen alle Ausgaben.", "budget.delete": "Budget löschen", "budget.delete.description": "Buchungen sind davon nicht betroffen. Diese Aktion ist unwiderruflich!", - "budget.insight.nearingLimit": "{name} ist zu {percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", - "budget.insight.onTrack": "{name} ist auf Kurs — {amount} übrig.", - "budget.status.onTrack": "Auf Kurs", - "budget.status.nearing": "Nahe am Limit", - "budget.status.over": "Über dem Limit", - "budget.detail.history": "Letzte Zeiträume", - "budget.detail.transactions": "Buchungen", - "budget.detail.unavailable": "Budgetzahlen sind derzeit nicht verfügbar.", "budget.detail.daysLeft": "Noch {} Tage", "budget.detail.daysLeft.one": "Noch {} Tag", + "budget.detail.history": "Letzte Zeiträume", "budget.detail.periodEnded": "Zeitraum beendet", - "budget.insight.over.short": "Sie liegen {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", + "budget.detail.transactions": "Buchungen", + "budget.detail.unavailable": "Budgetzahlen sind derzeit nicht verfügbar.", + "budget.insight.nearingLimit": "{name} ist zu {percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", "budget.insight.nearingLimit.short": "{percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", - "budget.insight.overpacing.short": "Bei diesem Tempo überschreiten Sie das Budget um {amount}.", + "budget.insight.onTrack": "{name} ist auf Kurs — {amount} übrig.", "budget.insight.onTrack.short": "Auf Kurs — {amount} übrig.", - "budget.insight.underspending.short": "Noch viel Spielraum — {amount} nicht ausgegeben.", "budget.insight.over": "Sie liegen bei {name} um {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", + "budget.insight.over.short": "Sie liegen {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", "budget.insight.overpacing": "Bei diesem Tempo wird {name} das Budget um {amount} überschreiten.", + "budget.insight.overpacing.short": "Bei diesem Tempo überschreiten Sie das Budget um {amount}.", "budget.insight.underspending": "{name} hat noch viel Spielraum — {amount} nicht ausgegeben.", + "budget.insight.underspending.short": "Noch viel Spielraum — {amount} nicht ausgegeben.", "budget.name": "Budgetname", "budget.overview.allHealthy": "Alles läuft nach Plan. Gut gemacht.", "budget.overview.budgetsTracked": "{count} Budgets verfolgt", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Automatisch erneuern", "budget.renewAutomatically.description": "Wenn der Zeitraum endet, geht das Budget in den neuen Zeitraum über. Zum Beispiel wird aus einem Juli-Budget ein August-Budget.", "budget.scope": "Umfang", + "budget.status.nearing": "Nahe am Limit", + "budget.status.onTrack": "Auf Kurs", + "budget.status.over": "Über dem Limit", "budgets": "Budgets", "budgets.new": "Neues Budget", "categories": "Kategorien", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Vergiss nicht, heute deine Buchungen hinzuzufügen!", "preferences": "Einstellungen", "preferences.appearance": "Aussehen", - "preferences.changeVisuals": "Ändern", + "preferences.changeVisuals": "Trendanzeigen", "preferences.changeVisuals.arrow": "Pfeil", "preferences.changeVisuals.clickToChange": "Klicken Sie auf den Pfeil oder die Farbe, um die Darstellung zu ändern.", "preferences.changeVisuals.color": "Farbe", "preferences.changeVisuals.expenseIncrease": "Ausgabenanstieg", "preferences.changeVisuals.incomeIncrease": "Einnahmenanstieg", + "preferences.data": "Daten", "preferences.dateFormat": "Datumsformat", + "preferences.dateFormat.absoluteDateHeaders": "Genaue Daten in Listenüberschriften anzeigen", + "preferences.dateFormat.system": "Sprachstandard", "preferences.feedback": "Probleme und Feedback", "preferences.feedback.debugLogs": "Debug-Protokolle anzeigen", - "preferences.hapticFeedback": "Feedback beim Tippen", + "preferences.general": "Allgemein", "preferences.hapticFeedback.description": "Ton/Vibrations-Feedback beim Klicken", - "preferences.integrations": "Integrationen", "preferences.language": "Sprache", "preferences.language.choose": "Sprache auswählen", "preferences.moneyFormatting": "Geldbeträge formatieren", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Zahlen nicht abkürzen, wenn es möglich ist.", "preferences.moneyFormatting.setICUPattern": "Eigenes Format auswählen", "preferences.moneyFormatting.setICUPattern.default": "Standard", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Ungefähren Betrag anzeigen", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Fremdwährungsbeträge auch in der Hauptwährung anzeigen", + "preferences.moneyFormatting.hideZeroDecimals": "Nachkommastellen bei ganzen Beträgen ausblenden", + "preferences.moneyFormatting.hideZeroDecimals.description": "z.B. „3“ statt „3,00“", "preferences.moneyFormatting.useCurrencySymbol": "Währungssymbol verwenden", "preferences.moneyFormatting.useCurrencySymbol.description": "z.B. „5€“ statt „5 EUR“", "preferences.numpad": "Ziffernblock", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Klassisch", "preferences.numpad.layout.modern": "Modern", "preferences.primaryCurrency": "Hauptwährung", - "preferences.privacy": "Datenschutz", "preferences.privacy.appLock": "App sperren", "preferences.privacy.appLock.description#Android": "Die App benötigt Fingerabdruck oder Gesichtserkennung zum Öffnen.", "preferences.privacy.appLock.description#iOS": "Die App benötigt nach Face ID oder Touch ID zum Öffnen.", "preferences.privacy.appLock.lockAfterClosing": "Nach dem Schließen sperren", "preferences.privacy.maskAtShake": "Zahlen beim Schütteln des Geräts mit (*) maskieren", "preferences.privacy.maskAtStartup": "Zahlen (*) beim Start ausblenden", + "preferences.privacyAndSecurity": "Datenschutz & Sicherheit", "preferences.reminders": "Erinnerung", "preferences.reminders.remindDaily": "Täglich erinnern", "preferences.reminders.remindDaily.description": "Täglich daran erinnern, Ausgaben zu erfassen.", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Bei langen Belegen möglicherweise unübersichtlich", "preferences.scan.markPendingThreshold": "Transaktionen als ausstehend markieren", "preferences.scan.markPendingThreshold.description": "Wenn deaktiviert, werden Transaktionen, deren ermitteltes Datum älter als 6 Stunden ist, weiterhin als ausstehend markiert", - "preferences.sync": "Synchronisierung", + "preferences.sync": "Synchronisierung & Backup", "preferences.sync.autoBackup": "Automatische Sicherung", "preferences.sync.autoBackup.disabled": "Deaktivieren", "preferences.sync.autoBackup.interval": "Intervall für Sicherung", @@ -788,6 +794,14 @@ "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.byGroup.accountCount": "{count} Konten", + "tabs.stats.byGroup.accountCount.one": "{count} Konto", + "tabs.stats.byGroup.categoryCount": "{count} Kategorien", + "tabs.stats.byGroup.categoryCount.one": "{count} Kategorie", + "tabs.stats.byGroup.totalEarned": "Gesamt eingenommen", + "tabs.stats.byGroup.totalSpent": "Gesamt ausgegeben", + "tabs.stats.byGroup.view.chart": "Diagramm", + "tabs.stats.byGroup.view.list": "Liste", "tabs.stats.categories": "Kategorien", "tabs.stats.categories.seeAll": "Alle Kategorien anzeigen", "tabs.stats.categories.top": "Top-Ausgaben", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "Median des Ausgabenbetrags im ausgewählten Zeitraum", "tabs.stats.trends.topSpendingTitles": "Häufige Buchungen", "tabs.stats.trends.topSpendingTitles.description": "Am häufigsten verwendete Buchungstitel", + "tabs.stats.worthKnowing": "Gut zu wissen", + "tabs.stats.worthKnowing.biggestEntries": "Größte Buchungen", + "tabs.stats.worthKnowing.categoryDrop": "{category} ist um {value} gesunken.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} lag {value} niedriger.", + "tabs.stats.worthKnowing.categorySpike": "{category} liegt beim {value} des Üblichen.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} lag beim {value} des Üblichen.", + "tabs.stats.worthKnowing.charges": "Abbuchungen", + "tabs.stats.worthKnowing.chartBefore": "vorher {amount}", + "tabs.stats.worthKnowing.chartCharges": "Jede Abbuchung", + "tabs.stats.worthKnowing.chartMonthly": "Ausgaben pro Monat", + "tabs.stats.worthKnowing.chartThroughDay": "Ausgaben bis zum {day} jedes Monats", + "tabs.stats.worthKnowing.chartUsual": "üblich {amount}", + "tabs.stats.worthKnowing.entries": "{count} Buchungen", + "tabs.stats.worthKnowing.entries.one": "{count} Buchung", + "tabs.stats.worthKnowing.hidden": "Ausgeblendet. Unter „Gut zu wissen“ wieder einschalten.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Kategorie-Rückgänge ausblenden", + "tabs.stats.worthKnowing.hide.categorySpike": "Kategorie-Spitzen ausblenden", + "tabs.stats.worthKnowing.hide.monthPace": "Monatsvergleiche ausblenden", + "tabs.stats.worthKnowing.hide.newCategory": "Neue Kategorien ausblenden", + "tabs.stats.worthKnowing.hide.priceChange": "Preisänderungen ausblenden", + "tabs.stats.worthKnowing.hide.recurringCharge": "Abo-Vorschläge ausblenden", + "tabs.stats.worthKnowing.kinds": "In „Gut zu wissen“ zeigen", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} weniger als üblich", + "tabs.stats.worthKnowing.monthAbove": "{month} liegt bis zum {day} {value} über dem Üblichen.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} lag {value} über dem Üblichen.", + "tabs.stats.worthKnowing.monthBelow": "{month} liegt bis zum {day} {value} unter dem Üblichen.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} lag {value} unter dem Üblichen.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} mehr als üblich", + "tabs.stats.worthKnowing.mostlyCategory": "Vor allem {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Vor allem {title} ({amount}). Ohne das etwa wie üblich.", + "tabs.stats.worthKnowing.mostlyOver": "Vor allem {title} ({amount}). Ohne das immer noch {percent} darüber.", + "tabs.stats.worthKnowing.mostlyUnder": "Vor allem {title} ({amount}). Ohne das {percent} darunter.", + "tabs.stats.worthKnowing.newCategory": "Erste Ausgaben für {value} seit {months} Monaten.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: bisher nichts Ungewöhnliches.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: nichts Ungewöhnliches.", + "tabs.stats.worthKnowing.priceChange": "{title} kostet jetzt {value} statt {previous}.", + "tabs.stats.worthKnowing.priceDown": "{amount} weniger pro Jahr", + "tabs.stats.worthKnowing.priceUp": "{amount} mehr pro Jahr", + "tabs.stats.worthKnowing.recurringMonthly": "{value} sieht nach einer monatlichen Zahlung aus.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} um den {day} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} sieht nach einer wöchentlichen Zahlung aus.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} jeden {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} sieht nach einer jährlichen Zahlung aus.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} um den {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Erscheint, wenn eine fast jeden Monat genutzte Kategorie unter {percent} ihres Medians fällt.", + "tabs.stats.worthKnowing.rule.categorySpike": "Erscheint, wenn eine Kategorie das {ratio} ihres Medians der letzten {months} Monate erreicht, bei mindestens {count} Buchungen.", + "tabs.stats.worthKnowing.rule.footer": "Überweisungen und ausstehende Buchungen zählen nicht mit. Auf diesem Gerät berechnet.", + "tabs.stats.worthKnowing.rule.monthPace": "Ausgaben bis {date}, verglichen mit dem Median der letzten {months} Monate bis zum selben Tag.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Ausgaben im {month}, verglichen mit dem Median der letzten {months} Monate.", + "tabs.stats.worthKnowing.rule.newCategory": "Erscheint, wenn eine Kategorie zum ersten Mal seit {months} Monaten Ausgaben hat.", + "tabs.stats.worthKnowing.rule.priceChange": "Erscheint, wenn sich eine wiederkehrende Zahlung um {percent} oder mehr ändert.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Zahlungen mit gleichem Titel, die regelmäßig wiederkehren. Schwankende Rechnungen wie Nebenkosten brauchen mindestens {count} Zahlungen.", + "tabs.stats.worthKnowing.seeTransactions": "Diese Buchungen ansehen", + "tabs.stats.worthKnowing.times": "bisher {count}-mal", + "tabs.stats.worthKnowing.times.one": "bisher {count}-mal", + "tabs.stats.worthKnowing.track": "Als wiederkehrend erfassen", + "tabs.stats.worthKnowing.type.categoryDrop": "Kategorie-Rückgänge", + "tabs.stats.worthKnowing.type.categorySpike": "Kategorie-Spitzen", + "tabs.stats.worthKnowing.type.monthPace": "Monatsvergleiche", + "tabs.stats.worthKnowing.type.newCategory": "Neue Kategorien", + "tabs.stats.worthKnowing.type.priceChange": "Preisänderungen", + "tabs.stats.worthKnowing.type.recurringCharge": "Abo-Vorschläge", + "tabs.stats.worthKnowing.versusTypical": "{current} statt üblich {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Was es bewegt hat", + "tabs.stats.worthKnowing.why": "Warum Sie das sehen", "transaction": "Buchung", "transaction.actions": "Aktionen", "transaction.attachments": "Dateianhänge", @@ -880,6 +959,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Damit löschst du alle dazugehörigen Buchungen. Es werden auch keine neuen Buchungen mehr erstellt. Wenn du die Buchungen aus dem Papierkorb wiederherstellst, werden trotzdem keine neuen Buchungen erstellt.", "transaction.recurring.edit": "Wiederkehrende Buchung bearbeiten", "transaction.recurring.setup": "Wiederkehrende Buchung einrichten", + "transaction.recurring.variableAmount": "Betrag variiert", + "transaction.recurring.variableAmount.description": "Fragt jedes Mal nach dem Betrag", "transaction.tags": "Tags", "transaction.tags.add": "Tags hinzufügen", "transaction.tags.contact.name": "Kontaktname", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 34e7da2d..971896a3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -47,25 +47,22 @@ "budget.categories.description": "Only spending in the selected categories counts towards this budget. Select none to count all spending.", "budget.delete": "Delete budget", "budget.delete.description": "Transactions won't be affected. This action is irreversible!", - "budget.insight.nearingLimit": "{name} is {percent}% spent with {days}d to go. Ease up to stay under.", - "budget.insight.onTrack": "{name} is on track — {amount} left.", - "budget.status.onTrack": "On track", - "budget.status.nearing": "Nearing limit", - "budget.status.over": "Over budget", - "budget.detail.history": "Recent periods", - "budget.detail.transactions": "Transactions", - "budget.detail.unavailable": "Budget numbers aren't available right now.", "budget.detail.daysLeft": "{} days left", "budget.detail.daysLeft.one": "{} day left", + "budget.detail.history": "Recent periods", "budget.detail.periodEnded": "Period ended", - "budget.insight.over.short": "You're {amount} over. Trim spending or raise the limit.", + "budget.detail.transactions": "Transactions", + "budget.detail.unavailable": "Budget numbers aren't available right now.", + "budget.insight.nearingLimit": "{name} is {percent}% spent with {days}d to go. Ease up to stay under.", "budget.insight.nearingLimit.short": "{percent}% spent with {days}d to go. Ease up to stay under.", - "budget.insight.overpacing.short": "At this pace, you'll overshoot by {amount}.", + "budget.insight.onTrack": "{name} is on track — {amount} left.", "budget.insight.onTrack.short": "On track — {amount} left.", - "budget.insight.underspending.short": "Plenty of room — {amount} unspent.", "budget.insight.over": "You're {amount} over on {name}. Trim spending or raise the limit.", + "budget.insight.over.short": "You're {amount} over. Trim spending or raise the limit.", "budget.insight.overpacing": "At this pace, {name} will overshoot by {amount}.", + "budget.insight.overpacing.short": "At this pace, you'll overshoot by {amount}.", "budget.insight.underspending": "{name} has plenty of room — {amount} unspent.", + "budget.insight.underspending.short": "Plenty of room — {amount} unspent.", "budget.name": "Budget name", "budget.overview.allHealthy": "Everything's on track. Nice work.", "budget.overview.budgetsTracked": "{count} budgets tracked", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renew automatically", "budget.renewAutomatically.description": "When the period ends, the budget rolls over to the new period. For example, a July budget becomes an August budget.", "budget.scope": "Scope", + "budget.status.nearing": "Nearing limit", + "budget.status.onTrack": "On track", + "budget.status.over": "Over budget", "budgets": "Budgets", "budgets.new": "New budget", "categories": "Categories", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Don't forget to add your transactions today!", "preferences": "Preferences", "preferences.appearance": "Appearance", - "preferences.changeVisuals": "Change", + "preferences.changeVisuals": "Trend indicators", "preferences.changeVisuals.arrow": "Arrow", "preferences.changeVisuals.clickToChange": "Click on the arrow/color to change", "preferences.changeVisuals.color": "Color", "preferences.changeVisuals.expenseIncrease": "Expense growth", "preferences.changeVisuals.incomeIncrease": "Income growth", + "preferences.data": "Data", "preferences.dateFormat": "Date format", + "preferences.dateFormat.absoluteDateHeaders": "Show exact dates in list headers", + "preferences.dateFormat.system": "Language default", "preferences.feedback": "Problems and feedback", "preferences.feedback.debugLogs": "View debug logs", - "preferences.hapticFeedback": "Button feedback", + "preferences.general": "General", "preferences.hapticFeedback.description": "Sound/haptic feedback upon click", - "preferences.integrations": "Integrations", "preferences.language": "Language", "preferences.language.choose": "Select a language", "preferences.moneyFormatting": "Money formatting", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Don't abbreviate numbers when possible", "preferences.moneyFormatting.setICUPattern": "Select a custom format", "preferences.moneyFormatting.setICUPattern.default": "Default", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Show approximate amount", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Also show foreign amounts in your primary currency", + "preferences.moneyFormatting.hideZeroDecimals": "Hide zero decimals", + "preferences.moneyFormatting.hideZeroDecimals.description": "e.g., \"3\" instead of \"3.00\"", "preferences.moneyFormatting.useCurrencySymbol": "Use currency symbol", "preferences.moneyFormatting.useCurrencySymbol.description": "e.g., \"$5\" instead of \"5USD\"", "preferences.numpad": "Numpad", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Classic", "preferences.numpad.layout.modern": "Modern", "preferences.primaryCurrency": "Primary currency", - "preferences.privacy": "Privacy", "preferences.privacy.appLock": "Lock app", "preferences.privacy.appLock.description#Android": "Require biometric authentication to open the app", "preferences.privacy.appLock.description#iOS": "Require Face ID or Touch ID to open the app", "preferences.privacy.appLock.lockAfterClosing": "Lock after closing", "preferences.privacy.maskAtShake": "Mask numbers (*) when shaking the device", "preferences.privacy.maskAtStartup": "Mask numbers (*) at startup", + "preferences.privacyAndSecurity": "Privacy & security", "preferences.reminders": "Reminder", "preferences.reminders.remindDaily": "Remind daily", "preferences.reminders.remindDaily.description": "Remind to track expenses daily", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Maybe chaotic for long receipts", "preferences.scan.markPendingThreshold": "Mark transactions as pending", "preferences.scan.markPendingThreshold.description": "If off, transactions with parsed date older than 6 hours will still be marked as pending", - "preferences.sync": "Sync", + "preferences.sync": "Sync & backup", "preferences.sync.autoBackup": "Auto-backup", "preferences.sync.autoBackup.disabled": "Disable", "preferences.sync.autoBackup.interval": "Backup interval", @@ -788,6 +794,14 @@ "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.byGroup.accountCount": "{count} accounts", + "tabs.stats.byGroup.accountCount.one": "{count} account", + "tabs.stats.byGroup.categoryCount": "{count} categories", + "tabs.stats.byGroup.categoryCount.one": "{count} category", + "tabs.stats.byGroup.totalEarned": "Total earned", + "tabs.stats.byGroup.totalSpent": "Total spent", + "tabs.stats.byGroup.view.chart": "Chart", + "tabs.stats.byGroup.view.list": "List", "tabs.stats.categories": "Categories", "tabs.stats.categories.seeAll": "See all categories", "tabs.stats.categories.top": "Top spending", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "Median of the expense amount in the selected time range", "tabs.stats.trends.topSpendingTitles": "Frequent transactions", "tabs.stats.trends.topSpendingTitles.description": "Most frequently used transaction titles", + "tabs.stats.worthKnowing": "Worth knowing", + "tabs.stats.worthKnowing.biggestEntries": "Biggest entries", + "tabs.stats.worthKnowing.categoryDrop": "{category} is down {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} was down {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category} is {value} your usual.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} was {value} your usual.", + "tabs.stats.worthKnowing.charges": "Charges", + "tabs.stats.worthKnowing.chartBefore": "before {amount}", + "tabs.stats.worthKnowing.chartCharges": "Each charge", + "tabs.stats.worthKnowing.chartMonthly": "Spent each month", + "tabs.stats.worthKnowing.chartThroughDay": "Spent by the {day} of each month", + "tabs.stats.worthKnowing.chartUsual": "usual {amount}", + "tabs.stats.worthKnowing.entries": "{count} entries", + "tabs.stats.worthKnowing.entries.one": "{count} entry", + "tabs.stats.worthKnowing.hidden": "Hidden. Turn it back on from Worth knowing.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Don't show category drops", + "tabs.stats.worthKnowing.hide.categorySpike": "Don't show category spikes", + "tabs.stats.worthKnowing.hide.monthPace": "Don't show month comparisons", + "tabs.stats.worthKnowing.hide.newCategory": "Don't show new categories", + "tabs.stats.worthKnowing.hide.priceChange": "Don't show price changes", + "tabs.stats.worthKnowing.hide.recurringCharge": "Don't show subscription suggestions", + "tabs.stats.worthKnowing.kinds": "Show in Worth knowing", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} less than usual", + "tabs.stats.worthKnowing.monthAbove": "{month} is {value} above your usual by the {day}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} was {value} above your usual.", + "tabs.stats.worthKnowing.monthBelow": "{month} is {value} below your usual by the {day}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} was {value} below your usual.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} more than usual", + "tabs.stats.worthKnowing.mostlyCategory": "Mostly {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Mostly {title} ({amount}). Without it, about usual.", + "tabs.stats.worthKnowing.mostlyOver": "Mostly {title} ({amount}). Without it, still {percent} over.", + "tabs.stats.worthKnowing.mostlyUnder": "Mostly {title} ({amount}). Without it, {percent} under.", + "tabs.stats.worthKnowing.newCategory": "First {value} spending in {months} months.", + "tabs.stats.worthKnowing.nothingUnusual": "Nothing unusual in {month} so far.", + "tabs.stats.worthKnowing.nothingUnusualPast": "Nothing unusual in {month}.", + "tabs.stats.worthKnowing.priceChange": "{title} went from {previous} to {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} less a year", + "tabs.stats.worthKnowing.priceUp": "{amount} more a year", + "tabs.stats.worthKnowing.recurringMonthly": "{value} looks like a monthly charge.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} around the {day} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} looks like a weekly charge.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} every {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} looks like a yearly charge.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} around {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Shown when a category you use most months falls under {percent} of its median.", + "tabs.stats.worthKnowing.rule.categorySpike": "Shown when a category reaches {ratio} its median of the previous {months} months, with {count} or more entries.", + "tabs.stats.worthKnowing.rule.footer": "Transfers and pending entries are left out. Worked out on this device.", + "tabs.stats.worthKnowing.rule.monthPace": "Spending through {date}, compared with the median of the previous {months} months through the same day.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Spending in {month}, compared with the median of the previous {months} months.", + "tabs.stats.worthKnowing.rule.newCategory": "Shown when a category has spending for the first time in {months} months.", + "tabs.stats.worthKnowing.rule.priceChange": "Shown when a repeating charge changes by {percent} or more.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Charges with the same title, repeating on a regular schedule. Bills that vary, like utilities, need at least {count} charges.", + "tabs.stats.worthKnowing.seeTransactions": "See these transactions", + "tabs.stats.worthKnowing.times": "{count} times so far", + "tabs.stats.worthKnowing.times.one": "{count} time so far", + "tabs.stats.worthKnowing.track": "Track as recurring", + "tabs.stats.worthKnowing.type.categoryDrop": "Category drops", + "tabs.stats.worthKnowing.type.categorySpike": "Category spikes", + "tabs.stats.worthKnowing.type.monthPace": "Month comparisons", + "tabs.stats.worthKnowing.type.newCategory": "New categories", + "tabs.stats.worthKnowing.type.priceChange": "Price changes", + "tabs.stats.worthKnowing.type.recurringCharge": "Subscription suggestions", + "tabs.stats.worthKnowing.versusTypical": "{current} vs {usual} typical", + "tabs.stats.worthKnowing.whatMovedIt": "What moved it", + "tabs.stats.worthKnowing.why": "Why you're seeing this", "transaction": "Transaction", "transaction.actions": "Actions", "transaction.attachments": "File attachments", @@ -875,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "This action will delete all related transactions, and stop new transactions from being created. Recovering the transactions from trash bin wouldn't cause it to create new transactions.", "transaction.recurring.edit": "Edit recurring transaction", "transaction.recurring.setup": "Setup recurring transaction", + "transaction.recurring.variableAmount": "Amount varies", + "transaction.recurring.variableAmount.description": "Asks for the amount each time", "transaction.tags": "Tags", "transaction.tags.add": "Add tags", "transaction.tags.contact.name": "Contact name", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index f87e7652..5bbb77e3 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -47,25 +47,22 @@ "budget.categories.description": "Solo los gastos de las categorías seleccionadas cuentan para este presupuesto. No selecciones ninguna para contar todos los gastos.", "budget.delete": "Eliminar presupuesto", "budget.delete.description": "Las transacciones no se verán afectadas. ¡Esta acción es irreversible!", - "budget.insight.nearingLimit": "{name} está al {percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", - "budget.insight.onTrack": "{name} va según lo previsto — quedan {amount}.", - "budget.status.onTrack": "En orden", - "budget.status.nearing": "Cerca del límite", - "budget.status.over": "Límite superado", - "budget.detail.history": "Períodos recientes", - "budget.detail.transactions": "Transacciones", - "budget.detail.unavailable": "Los datos del presupuesto no están disponibles ahora mismo.", "budget.detail.daysLeft": "Quedan {} días", "budget.detail.daysLeft.one": "Queda {} día", + "budget.detail.history": "Períodos recientes", "budget.detail.periodEnded": "Período finalizado", - "budget.insight.over.short": "Te has excedido {amount}. Reduce el gasto o aumenta el límite.", + "budget.detail.transactions": "Transacciones", + "budget.detail.unavailable": "Los datos del presupuesto no están disponibles ahora mismo.", + "budget.insight.nearingLimit": "{name} está al {percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", "budget.insight.nearingLimit.short": "{percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", - "budget.insight.overpacing.short": "A este ritmo, te excederás en {amount}.", + "budget.insight.onTrack": "{name} va según lo previsto — quedan {amount}.", "budget.insight.onTrack.short": "Según lo previsto — quedan {amount}.", - "budget.insight.underspending.short": "Tienes margen — {amount} sin gastar.", "budget.insight.over": "Has excedido {amount} en {name}. Reduce el gasto o aumenta el límite.", + "budget.insight.over.short": "Te has excedido {amount}. Reduce el gasto o aumenta el límite.", "budget.insight.overpacing": "A este ritmo, {name} se excederá en {amount}.", + "budget.insight.overpacing.short": "A este ritmo, te excederás en {amount}.", "budget.insight.underspending": "{name} tiene margen — {amount} sin gastar.", + "budget.insight.underspending.short": "Tienes margen — {amount} sin gastar.", "budget.name": "Nombre del presupuesto", "budget.overview.allHealthy": "Todo va según lo previsto. Buen trabajo.", "budget.overview.budgetsTracked": "{count} presupuestos registrados", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renovar automáticamente", "budget.renewAutomatically.description": "Cuando termina el período, el presupuesto pasa al nuevo período. Por ejemplo, un presupuesto de julio se convierte en uno de agosto.", "budget.scope": "Alcance", + "budget.status.nearing": "Cerca del límite", + "budget.status.onTrack": "En orden", + "budget.status.over": "Límite superado", "budgets": "Presupuestos", "budgets.new": "Nuevo presupuesto", "categories": "Categorías", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "¡No olvides añadir tus transacciones de hoy!", "preferences": "Preferencias", "preferences.appearance": "Apariencia", - "preferences.changeVisuals": "Cambiar", + "preferences.changeVisuals": "Indicadores de tendencia", "preferences.changeVisuals.arrow": "Flecha", "preferences.changeVisuals.clickToChange": "Haz clic en la flecha o en el color para cambiarlo", "preferences.changeVisuals.color": "Color", "preferences.changeVisuals.expenseIncrease": "Crecimiento de los gastos", "preferences.changeVisuals.incomeIncrease": "Crecimiento de los ingresos", + "preferences.data": "Datos", "preferences.dateFormat": "Formato de fecha", + "preferences.dateFormat.absoluteDateHeaders": "Mostrar fechas exactas en los encabezados", + "preferences.dateFormat.system": "Según el idioma", "preferences.feedback": "Problemas y comentarios", "preferences.feedback.debugLogs": "Ver registros de depuración", - "preferences.hapticFeedback": "Respuesta táctil de botones", + "preferences.general": "General", "preferences.hapticFeedback.description": "Respuesta sonora/táctil al hacer clic", - "preferences.integrations": "Integraciones", "preferences.language": "Idioma", "preferences.language.choose": "Seleccionar un idioma", "preferences.moneyFormatting": "Formato de moneda", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "No abreviar números cuando sea posible", "preferences.moneyFormatting.setICUPattern": "Seleccionar un formato personalizado", "preferences.moneyFormatting.setICUPattern.default": "Predeterminado", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Mostrar importe aproximado", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Mostrar también importes extranjeros en tu moneda principal", + "preferences.moneyFormatting.hideZeroDecimals": "Ocultar decimales en cero", + "preferences.moneyFormatting.hideZeroDecimals.description": "Mostrar \"3\" en lugar de \"3,00\"", "preferences.moneyFormatting.useCurrencySymbol": "Usar símbolo de moneda", "preferences.moneyFormatting.useCurrencySymbol.description": "ej., \"5$\" en lugar de \"5USD\"", "preferences.numpad": "Teclado numérico", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Clásico", "preferences.numpad.layout.modern": "Moderno", "preferences.primaryCurrency": "Moneda principal", - "preferences.privacy": "Privacidad", "preferences.privacy.appLock": "Bloquear aplicación", "preferences.privacy.appLock.description#Android": "Requerir autenticación biométrica para abrir la aplicación", "preferences.privacy.appLock.description#iOS": "Requerir Face ID o Touch ID para abrir la aplicación", "preferences.privacy.appLock.lockAfterClosing": "Bloquear tras cerrar", "preferences.privacy.maskAtShake": "Enmascarar números (*) al agitar el dispositivo", "preferences.privacy.maskAtStartup": "Ocultar números (*) al inicio", + "preferences.privacyAndSecurity": "Privacidad y seguridad", "preferences.reminders": "Recordatorio", "preferences.reminders.remindDaily": "Recordar diariamente", "preferences.reminders.remindDaily.description": "Recordar registrar gastos diariamente", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Puede resultar caótico con recibos largos", "preferences.scan.markPendingThreshold": "Marcar transacciones como pendientes", "preferences.scan.markPendingThreshold.description": "Si está desactivado, las transacciones cuya fecha analizada tenga más de 6 horas seguirán marcándose como pendientes.", - "preferences.sync": "Sincronización", + "preferences.sync": "Sincronización y copia de seguridad", "preferences.sync.autoBackup": "Copia de seguridad automática", "preferences.sync.autoBackup.disabled": "Desactivar", "preferences.sync.autoBackup.interval": "Intervalo de copia de seguridad", @@ -788,6 +794,14 @@ "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.byGroup.accountCount": "{count} cuentas", + "tabs.stats.byGroup.accountCount.one": "{count} cuenta", + "tabs.stats.byGroup.categoryCount": "{count} categorías", + "tabs.stats.byGroup.categoryCount.one": "{count} categoría", + "tabs.stats.byGroup.totalEarned": "Total ingresado", + "tabs.stats.byGroup.totalSpent": "Total gastado", + "tabs.stats.byGroup.view.chart": "Gráfico", + "tabs.stats.byGroup.view.list": "Lista", "tabs.stats.categories": "Categorías", "tabs.stats.categories.seeAll": "Ver todas las categorías", "tabs.stats.categories.top": "Gastos principales", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "Mediana del monto de gasto en el rango de tiempo seleccionado", "tabs.stats.trends.topSpendingTitles": "Transacciones frecuentes", "tabs.stats.trends.topSpendingTitles.description": "Títulos de transacciones más utilizados frecuentemente", + "tabs.stats.worthKnowing": "Conviene saber", + "tabs.stats.worthKnowing.biggestEntries": "Mayores movimientos", + "tabs.stats.worthKnowing.categoryDrop": "{category} ha bajado un {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} bajó un {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category} está en {value} lo habitual.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} estuvo en {value} lo habitual.", + "tabs.stats.worthKnowing.charges": "Cargos", + "tabs.stats.worthKnowing.chartBefore": "antes {amount}", + "tabs.stats.worthKnowing.chartCharges": "Cada cargo", + "tabs.stats.worthKnowing.chartMonthly": "Gasto de cada mes", + "tabs.stats.worthKnowing.chartThroughDay": "Gasto hasta el día {dayNumber} de cada mes", + "tabs.stats.worthKnowing.chartUsual": "habitual {amount}", + "tabs.stats.worthKnowing.entries": "{count} movimientos", + "tabs.stats.worthKnowing.entries.one": "{count} movimiento", + "tabs.stats.worthKnowing.hidden": "Oculto. Puedes reactivarlo desde Conviene saber.", + "tabs.stats.worthKnowing.hide.categoryDrop": "No mostrar bajadas de categoría", + "tabs.stats.worthKnowing.hide.categorySpike": "No mostrar picos de categoría", + "tabs.stats.worthKnowing.hide.monthPace": "No mostrar comparaciones del mes", + "tabs.stats.worthKnowing.hide.newCategory": "No mostrar categorías nuevas", + "tabs.stats.worthKnowing.hide.priceChange": "No mostrar cambios de precio", + "tabs.stats.worthKnowing.hide.recurringCharge": "No mostrar sugerencias de suscripción", + "tabs.stats.worthKnowing.kinds": "Mostrar en Conviene saber", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} menos de lo habitual", + "tabs.stats.worthKnowing.monthAbove": "{month} va un {value} por encima de lo habitual al día {dayNumber}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} estuvo un {value} por encima de lo habitual.", + "tabs.stats.worthKnowing.monthBelow": "{month} va un {value} por debajo de lo habitual al día {dayNumber}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} estuvo un {value} por debajo de lo habitual.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} más de lo habitual", + "tabs.stats.worthKnowing.mostlyCategory": "Sobre todo {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Sobre todo {title} ({amount}). Sin eso, lo habitual.", + "tabs.stats.worthKnowing.mostlyOver": "Sobre todo {title} ({amount}). Sin eso, aún un {percent} por encima.", + "tabs.stats.worthKnowing.mostlyUnder": "Sobre todo {title} ({amount}). Sin eso, un {percent} por debajo.", + "tabs.stats.worthKnowing.newCategory": "Primer gasto en {value} en {months} meses.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: nada fuera de lo normal por ahora.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: nada fuera de lo normal.", + "tabs.stats.worthKnowing.priceChange": "{title} pasó de {previous} a {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} menos al año", + "tabs.stats.worthKnowing.priceUp": "{amount} más al año", + "tabs.stats.worthKnowing.recurringMonthly": "{value} parece un cargo mensual.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} hacia el día {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} parece un cargo semanal.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} cada {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} parece un cargo anual.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} hacia el {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Aparece cuando una categoría que usas casi todos los meses baja del {percent} de su mediana.", + "tabs.stats.worthKnowing.rule.categorySpike": "Aparece cuando una categoría llega a {ratio} su mediana de los {months} meses anteriores, con {count} o más movimientos.", + "tabs.stats.worthKnowing.rule.footer": "No se cuentan transferencias ni movimientos pendientes. Calculado en este dispositivo.", + "tabs.stats.worthKnowing.rule.monthPace": "Gasto hasta el {date}, comparado con la mediana de los {months} meses anteriores hasta el mismo día.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Gasto de {month}, comparado con la mediana de los {months} meses anteriores.", + "tabs.stats.worthKnowing.rule.newCategory": "Aparece cuando una categoría tiene gastos por primera vez en {months} meses.", + "tabs.stats.worthKnowing.rule.priceChange": "Aparece cuando un cargo periódico cambia un {percent} o más.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Cargos con el mismo título que se repiten con regularidad. Las facturas variables, como los suministros, necesitan al menos {count} cargos.", + "tabs.stats.worthKnowing.seeTransactions": "Ver estas transacciones", + "tabs.stats.worthKnowing.times": "{count} veces hasta ahora", + "tabs.stats.worthKnowing.times.one": "{count} vez hasta ahora", + "tabs.stats.worthKnowing.track": "Registrar como recurrente", + "tabs.stats.worthKnowing.type.categoryDrop": "Bajadas de categoría", + "tabs.stats.worthKnowing.type.categorySpike": "Picos de categoría", + "tabs.stats.worthKnowing.type.monthPace": "Comparaciones del mes", + "tabs.stats.worthKnowing.type.newCategory": "Categorías nuevas", + "tabs.stats.worthKnowing.type.priceChange": "Cambios de precio", + "tabs.stats.worthKnowing.type.recurringCharge": "Sugerencias de suscripción", + "tabs.stats.worthKnowing.versusTypical": "{current} frente a {usual} habitual", + "tabs.stats.worthKnowing.whatMovedIt": "Qué lo movió", + "tabs.stats.worthKnowing.why": "Por qué ves esto", "transaction": "Transacción", "transaction.actions": "Acciones", "transaction.attachments": "Archivos adjuntos", @@ -881,6 +960,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Esta acción eliminará todas las transacciones relacionadas e impedirá que se creen nuevas. Recuperar las transacciones de la papelera no hará que se creen nuevas transacciones.", "transaction.recurring.edit": "Editar transacción recurrente", "transaction.recurring.setup": "Configurar transacción recurrente", + "transaction.recurring.variableAmount": "El importe varía", + "transaction.recurring.variableAmount.description": "Pide el importe cada vez", "transaction.tags": "Etiquetas", "transaction.tags.add": "Añadir etiquetas", "transaction.tags.contact.name": "Nombre del contacto", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index bd9ef44c..6c67a6ee 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -47,25 +47,22 @@ "budget.categories.description": "فقط هزینه‌های دسته‌بندی‌های انتخاب‌شده در این بودجه حساب می‌شوند. برای احتساب همه هزینه‌ها هیچ‌کدام را انتخاب نکنید.", "budget.delete": "حذف بودجه", "budget.delete.description": "تراکنش‌ها تغییری نمی‌کنند. این عمل غیرقابل بازگشت است!", - "budget.insight.nearingLimit": "{name}، {percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", - "budget.insight.onTrack": "{name} در مسیر درست است — {amount} باقی‌مانده.", - "budget.status.onTrack": "در مسیر درست", - "budget.status.nearing": "نزدیک به حد", - "budget.status.over": "بیش از بودجه", - "budget.detail.history": "دوره‌های اخیر", - "budget.detail.transactions": "تراکنش‌ها", - "budget.detail.unavailable": "اعداد بودجه در حال حاضر در دسترس نیستند.", "budget.detail.daysLeft": "{} روز مانده", "budget.detail.daysLeft.one": "{} روز مانده", + "budget.detail.history": "دوره‌های اخیر", "budget.detail.periodEnded": "دوره به پایان رسید", - "budget.insight.over.short": "شما {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", + "budget.detail.transactions": "تراکنش‌ها", + "budget.detail.unavailable": "اعداد بودجه در حال حاضر در دسترس نیستند.", + "budget.insight.nearingLimit": "{name}، {percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", "budget.insight.nearingLimit.short": "{percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", - "budget.insight.overpacing.short": "با این روند، تا {amount} بیش از بودجه خواهید رفت.", + "budget.insight.onTrack": "{name} در مسیر درست است — {amount} باقی‌مانده.", "budget.insight.onTrack.short": "در مسیر درست — {amount} باقی‌مانده.", - "budget.insight.underspending.short": "فضای زیادی دارید — {amount} خرج‌نشده.", "budget.insight.over": "شما در {name} {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", + "budget.insight.over.short": "شما {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", "budget.insight.overpacing": "با این روند، {name} تا {amount} بیش از بودجه خواهد شد.", + "budget.insight.overpacing.short": "با این روند، تا {amount} بیش از بودجه خواهید رفت.", "budget.insight.underspending": "{name} فضای زیادی دارد — {amount} خرج‌نشده.", + "budget.insight.underspending.short": "فضای زیادی دارید — {amount} خرج‌نشده.", "budget.name": "نام بودجه", "budget.overview.allHealthy": "همه چیز طبق برنامه است. آفرین.", "budget.overview.budgetsTracked": "{count} بودجه پیگیری شدند", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "تمدید خودکار", "budget.renewAutomatically.description": "با پایان دوره، بودجه به دوره جدید منتقل می‌شود. برای مثال، بودجه ژوئیه به بودجه اوت تبدیل می‌شود.", "budget.scope": "محدوده", + "budget.status.nearing": "نزدیک به حد", + "budget.status.onTrack": "در مسیر درست", + "budget.status.over": "بیش از بودجه", "budgets": "بودجه‌ها", "budgets.new": "بودجه جدید", "categories": "دسته‌بندی‌ها", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "فراموش نکن امروز تراکنش‌هایت را اضافه کنی!", "preferences": "تنظیمات", "preferences.appearance": "ظاهر", - "preferences.changeVisuals": "تغییر", + "preferences.changeVisuals": "نشانگرهای روند", "preferences.changeVisuals.arrow": "فلش", "preferences.changeVisuals.clickToChange": "برای تغییر، روی فلش/رنگ کلیک کنید", "preferences.changeVisuals.color": "رنگ", "preferences.changeVisuals.expenseIncrease": "افزایش هزینه", "preferences.changeVisuals.incomeIncrease": "افزایش درآمد", + "preferences.data": "داده‌ها", "preferences.dateFormat": "فرمت تاریخ", + "preferences.dateFormat.absoluteDateHeaders": "نمایش تاریخ دقیق در سرتیترها", + "preferences.dateFormat.system": "پیش‌فرض زبان", "preferences.feedback": "مشکلات و بازخورد", "preferences.feedback.debugLogs": "مشاهده لاگ‌های دیباگ", - "preferences.hapticFeedback": "بازخورد دکمه", + "preferences.general": "عمومی", "preferences.hapticFeedback.description": "بازخورد صوتی/هپتیک هنگام کلیک", - "preferences.integrations": "یکپارچه‌سازی‌ها", "preferences.language": "زبان", "preferences.language.choose": "یک زبان انتخاب کنید", "preferences.moneyFormatting": "فرمت نمایش پول", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "تا جای ممکن اعداد را خلاصه نکن", "preferences.moneyFormatting.setICUPattern": "انتخاب فرمت سفارشی", "preferences.moneyFormatting.setICUPattern.default": "پیش‌فرض", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "نمایش مبلغ تقریبی", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "مبالغ ارزی را به ارز اصلی هم نشان بده", + "preferences.moneyFormatting.hideZeroDecimals": "پنهان کردن اعشار صفر", + "preferences.moneyFormatting.hideZeroDecimals.description": "نمایش «3» به جای «3.00»", "preferences.moneyFormatting.useCurrencySymbol": "استفاده از نماد ارز", "preferences.moneyFormatting.useCurrencySymbol.description": "مثلاً «$5» به‌جای «5USD»", "preferences.numpad": "صفحه‌کلید عددی", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "کلاسیک", "preferences.numpad.layout.modern": "مدرن", "preferences.primaryCurrency": "ارز اصلی", - "preferences.privacy": "حریم خصوصی", "preferences.privacy.appLock": "قفل برنامه", "preferences.privacy.appLock.description#Android": "برای باز کردن برنامه، احراز هویت بیومتریک لازم باشد", "preferences.privacy.appLock.description#iOS": "برای باز کردن برنامه، Face ID یا Touch ID لازم باشد", "preferences.privacy.appLock.lockAfterClosing": "قفل پس از بستن", "preferences.privacy.maskAtShake": "مخفی‌سازی اعداد (*) با تکان دادن دستگاه", "preferences.privacy.maskAtStartup": "مخفی‌سازی اعداد (*) هنگام شروع", + "preferences.privacyAndSecurity": "حریم خصوصی و امنیت", "preferences.reminders": "یادآوری", "preferences.reminders.remindDaily": "یادآوری روزانه", "preferences.reminders.remindDaily.description": "روزانه یادآوری کند هزینه‌ها را ثبت کنید", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "برای رسیدهای طولانی ممکن است شلوغ شود", "preferences.scan.markPendingThreshold": "علامت‌گذاری تراکنش‌ها به‌صورت در انتظار", "preferences.scan.markPendingThreshold.description": "اگر خاموش باشد، تراکنش‌هایی که تاریخِ استخراج‌شده‌شان بیش از ۶ ساعت قبل باشد هم در حالت در انتظار می‌مانند", - "preferences.sync": "همگام‌سازی", + "preferences.sync": "همگام‌سازی و پشتیبان‌گیری", "preferences.sync.autoBackup": "پشتیبان‌گیری خودکار", "preferences.sync.autoBackup.disabled": "غیرفعال", "preferences.sync.autoBackup.interval": "فاصله پشتیبان‌گیری", @@ -788,6 +794,14 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "مرور ماه خود را ببینید", "tabs.stats.analytics.wrapped.tileTitle": "خلاصهٔ {month} شما", "tabs.stats.analytics.wrapped.title": "{month}، خلاصه", + "tabs.stats.byGroup.accountCount": "{count} حساب", + "tabs.stats.byGroup.accountCount.one": "{count} حساب", + "tabs.stats.byGroup.categoryCount": "{count} دسته‌بندی", + "tabs.stats.byGroup.categoryCount.one": "{count} دسته‌بندی", + "tabs.stats.byGroup.totalEarned": "کل درآمد", + "tabs.stats.byGroup.totalSpent": "کل هزینه", + "tabs.stats.byGroup.view.chart": "نمودار", + "tabs.stats.byGroup.view.list": "فهرست", "tabs.stats.categories": "دسته‌بندی‌ها", "tabs.stats.categories.seeAll": "مشاهده همه دسته‌بندی‌ها", "tabs.stats.categories.top": "بیشترین هزینه", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "میانه مبلغ هزینه در بازه زمانی انتخاب‌شده", "tabs.stats.trends.topSpendingTitles": "تراکنش‌های پرتکرار", "tabs.stats.trends.topSpendingTitles.description": "عنوان‌های پرتکرارِ تراکنش‌ها", + "tabs.stats.worthKnowing": "خوب است بدانید", + "tabs.stats.worthKnowing.biggestEntries": "بزرگ‌ترین تراکنش‌ها", + "tabs.stats.worthKnowing.categoryDrop": "{category}: {value} کاهش.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: {value} کاهش داشت.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} حالت معمول.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: {value} حالت معمول بود.", + "tabs.stats.worthKnowing.charges": "پرداخت‌ها", + "tabs.stats.worthKnowing.chartBefore": "قبلاً {amount}", + "tabs.stats.worthKnowing.chartCharges": "هر پرداخت", + "tabs.stats.worthKnowing.chartMonthly": "هزینهٔ هر ماه", + "tabs.stats.worthKnowing.chartThroughDay": "هزینه تا روز {dayNumber} هر ماه", + "tabs.stats.worthKnowing.chartUsual": "معمول {amount}", + "tabs.stats.worthKnowing.entries": "{count} تراکنش", + "tabs.stats.worthKnowing.entries.one": "{count} تراکنش", + "tabs.stats.worthKnowing.hidden": "پنهان شد. از «خوب است بدانید» دوباره روشنش کنید.", + "tabs.stats.worthKnowing.hide.categoryDrop": "کاهش دسته‌ها را نشان نده", + "tabs.stats.worthKnowing.hide.categorySpike": "افزایش ناگهانی دسته‌ها را نشان نده", + "tabs.stats.worthKnowing.hide.monthPace": "مقایسه‌های ماه را نشان نده", + "tabs.stats.worthKnowing.hide.newCategory": "دسته‌های جدید را نشان نده", + "tabs.stats.worthKnowing.hide.priceChange": "تغییر قیمت‌ها را نشان نده", + "tabs.stats.worthKnowing.hide.recurringCharge": "پیشنهاد اشتراک‌ها را نشان نده", + "tabs.stats.worthKnowing.kinds": "نمایش در «خوب است بدانید»", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} کمتر از معمول", + "tabs.stats.worthKnowing.monthAbove": "{month}: تا روز {dayNumber}، {value} بالاتر از معمول.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: {value} بالاتر از معمول بود.", + "tabs.stats.worthKnowing.monthBelow": "{month}: تا روز {dayNumber}، {value} پایین‌تر از معمول.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: {value} پایین‌تر از معمول بود.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} بیشتر از معمول", + "tabs.stats.worthKnowing.mostlyCategory": "بیشتر {category}، {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "بیشتر {title} ({amount}). بدون آن، در حد معمول.", + "tabs.stats.worthKnowing.mostlyOver": "بیشتر {title} ({amount}). بدون آن هم {percent} بالاتر.", + "tabs.stats.worthKnowing.mostlyUnder": "بیشتر {title} ({amount}). بدون آن، {percent} پایین‌تر.", + "tabs.stats.worthKnowing.newCategory": "{value}: اولین هزینه در {months} ماه اخیر.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: تا اینجا چیز غیرعادی‌ای نیست.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: چیز غیرعادی‌ای نبود.", + "tabs.stats.worthKnowing.priceChange": "{title}: از {previous} به {value}.", + "tabs.stats.worthKnowing.priceDown": "سالانه {amount} کمتر", + "tabs.stats.worthKnowing.priceUp": "سالانه {amount} بیشتر", + "tabs.stats.worthKnowing.recurringMonthly": "{value} شبیه یک پرداخت ماهانه است.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} حدود روز {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} شبیه یک پرداخت هفتگی است.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} هر {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} شبیه یک پرداخت سالانه است.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} حدود {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "وقتی دسته‌ای که تقریباً هر ماه استفاده می‌کنید به زیر {percent} میانه‌اش برسد نمایش داده می‌شود.", + "tabs.stats.worthKnowing.rule.categorySpike": "وقتی یک دسته به {ratio} میانهٔ {months} ماه قبل برسد و دست‌کم {count} تراکنش داشته باشد نمایش داده می‌شود.", + "tabs.stats.worthKnowing.rule.footer": "انتقال‌ها و تراکنش‌های در انتظار حساب نمی‌شوند. روی همین دستگاه محاسبه شد.", + "tabs.stats.worthKnowing.rule.monthPace": "هزینه تا {date}، در مقایسه با میانهٔ {months} ماه قبل تا همان روز.", + "tabs.stats.worthKnowing.rule.monthPacePast": "هزینهٔ {month}، در مقایسه با میانهٔ {months} ماه قبل.", + "tabs.stats.worthKnowing.rule.newCategory": "وقتی دسته‌ای برای اولین بار در {months} ماه هزینه داشته باشد نمایش داده می‌شود.", + "tabs.stats.worthKnowing.rule.priceChange": "وقتی یک پرداخت تکراری {percent} یا بیشتر تغییر کند نمایش داده می‌شود.", + "tabs.stats.worthKnowing.rule.recurringCharge": "پرداخت‌هایی با عنوان یکسان که مرتب تکرار می‌شوند. قبض‌های متغیر، مثل آب و برق، دست‌کم {count} پرداخت لازم دارند.", + "tabs.stats.worthKnowing.seeTransactions": "دیدن این تراکنش‌ها", + "tabs.stats.worthKnowing.times": "تا حالا {count} بار", + "tabs.stats.worthKnowing.times.one": "تا حالا {count} بار", + "tabs.stats.worthKnowing.track": "ثبت به‌عنوان تکرارشونده", + "tabs.stats.worthKnowing.type.categoryDrop": "کاهش دسته‌ها", + "tabs.stats.worthKnowing.type.categorySpike": "افزایش ناگهانی دسته‌ها", + "tabs.stats.worthKnowing.type.monthPace": "مقایسه‌های ماه", + "tabs.stats.worthKnowing.type.newCategory": "دسته‌های جدید", + "tabs.stats.worthKnowing.type.priceChange": "تغییر قیمت‌ها", + "tabs.stats.worthKnowing.type.recurringCharge": "پیشنهاد اشتراک‌ها", + "tabs.stats.worthKnowing.versusTypical": "{current} در برابر معمولِ {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "چه چیزی آن را تغییر داد", + "tabs.stats.worthKnowing.why": "چرا این را می‌بینید", "transaction": "تراکنش", "transaction.actions": "اقدامات", "transaction.attachments": "پیوست‌های فایل", @@ -875,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "این کار همه تراکنش‌های مرتبط را حذف می‌کند و ساخت تراکنش‌های جدید را متوقف می‌کند. بازیابی تراکنش‌ها از سطل زباله باعث ساخت دوباره تراکنش‌های جدید نمی‌شود.", "transaction.recurring.edit": "ویرایش تراکنش تکرارشونده", "transaction.recurring.setup": "تنظیم تراکنش تکرارشونده", + "transaction.recurring.variableAmount": "مبلغ متغیر است", + "transaction.recurring.variableAmount.description": "هر بار مبلغ را می‌پرسد", "transaction.tags": "برچسب‌ها", "transaction.tags.add": "افزودن برچسب‌ها", "transaction.tags.contact.name": "نام مخاطب", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index f4d91732..bef1f673 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -47,25 +47,22 @@ "budget.categories.description": "Seules les dépenses des catégories sélectionnées comptent dans ce budget. N'en sélectionnez aucune pour compter toutes les dépenses.", "budget.delete": "Supprimer le budget", "budget.delete.description": "Les transactions ne seront pas affectées. Cette action est irréversible !", - "budget.insight.nearingLimit": "{name} est à {percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", - "budget.insight.onTrack": "{name} est dans les clous — {amount} restants.", - "budget.status.onTrack": "Dans les clous", - "budget.status.nearing": "Limite proche", - "budget.status.over": "Budget dépassé", - "budget.detail.history": "Périodes récentes", - "budget.detail.transactions": "Transactions", - "budget.detail.unavailable": "Les chiffres du budget ne sont pas disponibles pour le moment.", "budget.detail.daysLeft": "{} jours restants", "budget.detail.daysLeft.one": "{} jour restant", + "budget.detail.history": "Périodes récentes", "budget.detail.periodEnded": "Période terminée", - "budget.insight.over.short": "Vous êtes en dépassement de {amount}. Réduisez les dépenses ou augmentez la limite.", + "budget.detail.transactions": "Transactions", + "budget.detail.unavailable": "Les chiffres du budget ne sont pas disponibles pour le moment.", + "budget.insight.nearingLimit": "{name} est à {percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", "budget.insight.nearingLimit.short": "{percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", - "budget.insight.overpacing.short": "À ce rythme, vous dépasserez la limite de {amount}.", + "budget.insight.onTrack": "{name} est dans les clous — {amount} restants.", "budget.insight.onTrack.short": "Dans les clous — {amount} restants.", - "budget.insight.underspending.short": "Il reste de la marge — {amount} non dépensé.", "budget.insight.over": "Vous êtes en dépassement de {amount} pour {name}. Réduisez les dépenses ou augmentez la limite.", + "budget.insight.over.short": "Vous êtes en dépassement de {amount}. Réduisez les dépenses ou augmentez la limite.", "budget.insight.overpacing": "À ce rythme, {name} dépassera la limite de {amount}.", + "budget.insight.overpacing.short": "À ce rythme, vous dépasserez la limite de {amount}.", "budget.insight.underspending": "{name} a de la marge — {amount} non dépensé.", + "budget.insight.underspending.short": "Il reste de la marge — {amount} non dépensé.", "budget.name": "Nom du budget", "budget.overview.allHealthy": "Tout est sur la bonne voie. Bon travail.", "budget.overview.budgetsTracked": "{count} budgets suivis", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renouveler automatiquement", "budget.renewAutomatically.description": "À la fin de la période, le budget passe à la période suivante. Par exemple, un budget de juillet devient un budget d'août.", "budget.scope": "Périmètre", + "budget.status.nearing": "Limite proche", + "budget.status.onTrack": "Dans les clous", + "budget.status.over": "Budget dépassé", "budgets": "Budgets", "budgets.new": "Nouveau budget", "categories": "Catégories", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "N'oubliez pas d'ajouter vos transactions aujourd'hui !", "preferences": "Préférences", "preferences.appearance": "Apparence", - "preferences.changeVisuals": "Modifier", + "preferences.changeVisuals": "Indicateurs de tendance", "preferences.changeVisuals.arrow": "Flèche", "preferences.changeVisuals.clickToChange": "Cliquez sur la flèche/la couleur pour modifier", "preferences.changeVisuals.color": "Couleur", "preferences.changeVisuals.expenseIncrease": "Augmentation des dépenses", "preferences.changeVisuals.incomeIncrease": "Augmentation des revenus", + "preferences.data": "Données", "preferences.dateFormat": "Format de date", + "preferences.dateFormat.absoluteDateHeaders": "Afficher les dates exactes dans les en-têtes", + "preferences.dateFormat.system": "Selon la langue", "preferences.feedback": "Problèmes et retours", "preferences.feedback.debugLogs": "Voir les journaux de débogage", - "preferences.hapticFeedback": "Retour tactile", + "preferences.general": "Général", "preferences.hapticFeedback.description": "Retour sonore/tactile lors du clic", - "preferences.integrations": "Intégrations", "preferences.language": "Langue", "preferences.language.choose": "Sélectionner une langue", "preferences.moneyFormatting": "Formatage de l'argent", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Ne pas abréger les nombres lorsque c'est possible", "preferences.moneyFormatting.setICUPattern": "Sélectionner un format personnalisé", "preferences.moneyFormatting.setICUPattern.default": "Par défaut", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Afficher le montant approximatif", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Afficher aussi les montants étrangers dans votre devise principale", + "preferences.moneyFormatting.hideZeroDecimals": "Masquer les décimales nulles", + "preferences.moneyFormatting.hideZeroDecimals.description": "Afficher « 3 » au lieu de « 3,00 »", "preferences.moneyFormatting.useCurrencySymbol": "Utiliser le symbole de la devise", "preferences.moneyFormatting.useCurrencySymbol.description": "par exemple, \"5 €\" au lieu de \"5 EUR\"", "preferences.numpad": "Clavier numérique", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Classique", "preferences.numpad.layout.modern": "Moderne", "preferences.primaryCurrency": "Devise principale", - "preferences.privacy": "Confidentialité", "preferences.privacy.appLock": "Verrouiller l'application", "preferences.privacy.appLock.description#Android": "Exiger l'authentification biométrique pour ouvrir l'application", "preferences.privacy.appLock.description#iOS": "Exiger Face ID ou Touch ID pour ouvrir l'application", "preferences.privacy.appLock.lockAfterClosing": "Verrouiller après la fermeture", "preferences.privacy.maskAtShake": "Masquer les numéros (*) en secouant l'appareil", "preferences.privacy.maskAtStartup": "Masquer les nombres (*) au démarrage", + "preferences.privacyAndSecurity": "Confidentialité et sécurité", "preferences.reminders": "Rappel quotidien", "preferences.reminders.remindDaily": "Rappel quotidien", "preferences.reminders.remindDaily.description": "Rappeler de suivre les dépenses quotidiennement", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Peut devenir chaotique pour les tickets de caisse longs", "preferences.scan.markPendingThreshold": "Marquer les transactions comme en attente", "preferences.scan.markPendingThreshold.description": "Si désactivée, les transactions dont la date analysée est antérieure à 6 heures resteront marquées comme « en attente ».", - "preferences.sync": "Synchronisation", + "preferences.sync": "Synchronisation et sauvegarde", "preferences.sync.autoBackup": "Sauvegarde automatique", "preferences.sync.autoBackup.disabled": "Désactiver", "preferences.sync.autoBackup.interval": "Intervalle de sauvegarde", @@ -788,6 +794,14 @@ "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.byGroup.accountCount": "{count} comptes", + "tabs.stats.byGroup.accountCount.one": "{count} compte", + "tabs.stats.byGroup.categoryCount": "{count} catégories", + "tabs.stats.byGroup.categoryCount.one": "{count} catégorie", + "tabs.stats.byGroup.totalEarned": "Total gagné", + "tabs.stats.byGroup.totalSpent": "Total dépensé", + "tabs.stats.byGroup.view.chart": "Graphique", + "tabs.stats.byGroup.view.list": "Liste", "tabs.stats.categories": "Catégories", "tabs.stats.categories.seeAll": "Voir toutes les catégories", "tabs.stats.categories.top": "Dépenses principales", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "Médiane du montant des dépenses dans la période sélectionnée", "tabs.stats.trends.topSpendingTitles": "Transactions fréquentes", "tabs.stats.trends.topSpendingTitles.description": "Titres de transaction les plus fréquemment utilisés", + "tabs.stats.worthKnowing": "Bon à savoir", + "tabs.stats.worthKnowing.biggestEntries": "Plus grosses dépenses", + "tabs.stats.worthKnowing.categoryDrop": "{category} est en baisse de {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} était en baisse de {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category} est à {value} l'habitude.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} était à {value} l'habitude.", + "tabs.stats.worthKnowing.charges": "Prélèvements", + "tabs.stats.worthKnowing.chartBefore": "avant {amount}", + "tabs.stats.worthKnowing.chartCharges": "Chaque prélèvement", + "tabs.stats.worthKnowing.chartMonthly": "Dépenses de chaque mois", + "tabs.stats.worthKnowing.chartThroughDay": "Dépenses jusqu'au {dayNumber} de chaque mois", + "tabs.stats.worthKnowing.chartUsual": "habituel {amount}", + "tabs.stats.worthKnowing.entries": "{count} opérations", + "tabs.stats.worthKnowing.entries.one": "{count} opération", + "tabs.stats.worthKnowing.hidden": "Masqué. Réactivez-le depuis Bon à savoir.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Masquer les baisses de catégorie", + "tabs.stats.worthKnowing.hide.categorySpike": "Masquer les pics de catégorie", + "tabs.stats.worthKnowing.hide.monthPace": "Masquer les comparaisons du mois", + "tabs.stats.worthKnowing.hide.newCategory": "Masquer les nouvelles catégories", + "tabs.stats.worthKnowing.hide.priceChange": "Masquer les changements de prix", + "tabs.stats.worthKnowing.hide.recurringCharge": "Masquer les suggestions d'abonnement", + "tabs.stats.worthKnowing.kinds": "Afficher dans Bon à savoir", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} de moins que d'habitude", + "tabs.stats.worthKnowing.monthAbove": "{month} : {value} au-dessus de l'habitude au {dayNumber}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} : {value} au-dessus de l'habitude.", + "tabs.stats.worthKnowing.monthBelow": "{month} : {value} en dessous de l'habitude au {dayNumber}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} : {value} en dessous de l'habitude.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} de plus que d'habitude", + "tabs.stats.worthKnowing.mostlyCategory": "Surtout {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Surtout {title} ({amount}). Sans cela, comme d'habitude.", + "tabs.stats.worthKnowing.mostlyOver": "Surtout {title} ({amount}). Sans cela, encore {percent} au-dessus.", + "tabs.stats.worthKnowing.mostlyUnder": "Surtout {title} ({amount}). Sans cela, {percent} en dessous.", + "tabs.stats.worthKnowing.newCategory": "Premières dépenses en {value} depuis {months} mois.", + "tabs.stats.worthKnowing.nothingUnusual": "{month} : rien d'inhabituel pour l'instant.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month} : rien d'inhabituel.", + "tabs.stats.worthKnowing.priceChange": "{title} est passé de {previous} à {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} de moins par an", + "tabs.stats.worthKnowing.priceUp": "{amount} de plus par an", + "tabs.stats.worthKnowing.recurringMonthly": "{value} ressemble à un prélèvement mensuel.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} vers le {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} ressemble à un prélèvement hebdomadaire.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} chaque {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} ressemble à un prélèvement annuel.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} vers le {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Affiché quand une catégorie utilisée presque chaque mois passe sous {percent} de sa médiane.", + "tabs.stats.worthKnowing.rule.categorySpike": "Affiché quand une catégorie atteint {ratio} sa médiane des {months} mois précédents, avec au moins {count} opérations.", + "tabs.stats.worthKnowing.rule.footer": "Les virements et opérations en attente ne sont pas comptés. Calculé sur cet appareil.", + "tabs.stats.worthKnowing.rule.monthPace": "Dépenses jusqu'au {date}, comparées à la médiane des {months} mois précédents au même jour.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Dépenses de {month}, comparées à la médiane des {months} mois précédents.", + "tabs.stats.worthKnowing.rule.newCategory": "Affiché quand une catégorie a des dépenses pour la première fois depuis {months} mois.", + "tabs.stats.worthKnowing.rule.priceChange": "Affiché quand un prélèvement régulier change de {percent} ou plus.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Prélèvements au même titre, qui reviennent régulièrement. Les factures variables, comme l'électricité, demandent au moins {count} prélèvements.", + "tabs.stats.worthKnowing.seeTransactions": "Voir ces transactions", + "tabs.stats.worthKnowing.times": "{count} fois jusqu'ici", + "tabs.stats.worthKnowing.times.one": "{count} fois jusqu'ici", + "tabs.stats.worthKnowing.track": "Suivre comme récurrent", + "tabs.stats.worthKnowing.type.categoryDrop": "Baisses de catégorie", + "tabs.stats.worthKnowing.type.categorySpike": "Pics de catégorie", + "tabs.stats.worthKnowing.type.monthPace": "Comparaisons du mois", + "tabs.stats.worthKnowing.type.newCategory": "Nouvelles catégories", + "tabs.stats.worthKnowing.type.priceChange": "Changements de prix", + "tabs.stats.worthKnowing.type.recurringCharge": "Suggestions d'abonnement", + "tabs.stats.worthKnowing.versusTypical": "{current} contre {usual} habituellement", + "tabs.stats.worthKnowing.whatMovedIt": "Ce qui l'explique", + "tabs.stats.worthKnowing.why": "Pourquoi vous voyez ceci", "transaction": "Transaction", "transaction.actions": "Actions", "transaction.attachments": "Pièces jointes", @@ -881,6 +960,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Cette action supprimera toutes les transactions associées et empêchera la création de nouvelles transactions. La récupération des transactions depuis la corbeille ne provoquera pas la création de nouvelles transactions.", "transaction.recurring.edit": "Modifier la transaction récurrente", "transaction.recurring.setup": "Configurer la transaction récurrente", + "transaction.recurring.variableAmount": "Montant variable", + "transaction.recurring.variableAmount.description": "Demande le montant à chaque fois", "transaction.tags": "Étiquettes", "transaction.tags.add": "Ajouter des étiquettes", "transaction.tags.contact.name": "Nom du contact", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index b2697528..ed830e8c 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -47,25 +47,22 @@ "budget.categories.description": "Solo le spese nelle categorie selezionate contano per questo budget. Non selezionarne nessuna per contare tutte le spese.", "budget.delete": "Elimina budget", "budget.delete.description": "Le transazioni non saranno interessate. Questa azione è irreversibile!", - "budget.insight.nearingLimit": "{name} ha speso il {percent}% e mancano {days} giorni. Rallenta per restare entro il limite.", - "budget.insight.onTrack": "{name} è sulla buona strada — {amount} rimanenti.", - "budget.status.onTrack": "Nei limiti", - "budget.status.nearing": "Vicino al limite", - "budget.status.over": "Oltre il limite", - "budget.detail.history": "Periodi recenti", - "budget.detail.transactions": "Transazioni", - "budget.detail.unavailable": "I dati del budget non sono al momento disponibili.", "budget.detail.daysLeft": "{} giorni rimasti", "budget.detail.daysLeft.one": "{} giorno rimasto", + "budget.detail.history": "Periodi recenti", "budget.detail.periodEnded": "Periodo terminato", - "budget.insight.over.short": "Hai superato di {amount}. Riduci le spese o aumenta il limite.", + "budget.detail.transactions": "Transazioni", + "budget.detail.unavailable": "I dati del budget non sono al momento disponibili.", + "budget.insight.nearingLimit": "{name} ha speso il {percent}% e mancano {days} giorni. Rallenta per restare entro il limite.", "budget.insight.nearingLimit.short": "{percent}% speso e mancano {days} giorni. Rallenta per restare entro il limite.", - "budget.insight.overpacing.short": "A questo ritmo, sforerai di {amount}.", + "budget.insight.onTrack": "{name} è sulla buona strada — {amount} rimanenti.", "budget.insight.onTrack.short": "Sulla buona strada — {amount} rimanenti.", - "budget.insight.underspending.short": "C'è molto margine — {amount} non spesi.", "budget.insight.over": "Hai superato di {amount} il budget per {name}. Riduci le spese o aumenta il limite.", + "budget.insight.over.short": "Hai superato di {amount}. Riduci le spese o aumenta il limite.", "budget.insight.overpacing": "A questo ritmo, {name} sforerà di {amount}.", + "budget.insight.overpacing.short": "A questo ritmo, sforerai di {amount}.", "budget.insight.underspending": "{name} ha molto margine — {amount} non spesi.", + "budget.insight.underspending.short": "C'è molto margine — {amount} non spesi.", "budget.name": "Nome del budget", "budget.overview.allHealthy": "Tutto procede bene. Ottimo lavoro.", "budget.overview.budgetsTracked": "{count} budget monitorati", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Rinnova automaticamente", "budget.renewAutomatically.description": "Al termine del periodo, il budget passa al periodo successivo. Ad esempio, un budget di luglio diventa un budget di agosto.", "budget.scope": "Ambito", + "budget.status.nearing": "Vicino al limite", + "budget.status.onTrack": "Nei limiti", + "budget.status.over": "Oltre il limite", "budgets": "Budget", "budgets.new": "Nuovo budget", "categories": "Categorie", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Non dimenticare di aggiungere le tue transazioni oggi!", "preferences": "Preferenze", "preferences.appearance": "Aspetto", - "preferences.changeVisuals": "Modifica", + "preferences.changeVisuals": "Indicatori di tendenza", "preferences.changeVisuals.arrow": "Freccia", "preferences.changeVisuals.clickToChange": "Fai clic sulla freccia o sul colore per modificare", "preferences.changeVisuals.color": "Colore", "preferences.changeVisuals.expenseIncrease": "Crescita delle spese", "preferences.changeVisuals.incomeIncrease": "Crescita delle entrate", + "preferences.data": "Dati", "preferences.dateFormat": "Formato data", + "preferences.dateFormat.absoluteDateHeaders": "Mostra date esatte nelle intestazioni", + "preferences.dateFormat.system": "Predefinito della lingua", "preferences.feedback": "Problemi e feedback", "preferences.feedback.debugLogs": "Visualizza i log di debug", - "preferences.hapticFeedback": "Feedback dei tasti", + "preferences.general": "Generali", "preferences.hapticFeedback.description": "Feedback sonoro/tattile alla pressione", - "preferences.integrations": "Integrazioni", "preferences.language": "Lingua", "preferences.language.choose": "Selezionare una lingua", "preferences.moneyFormatting": "Formato valuta", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Non abbreviare i numeri quando possibile", "preferences.moneyFormatting.setICUPattern": "Seleziona un formato personalizzato", "preferences.moneyFormatting.setICUPattern.default": "Predefinito", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Mostra importo approssimativo", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Mostra anche gli importi esteri nella valuta principale", + "preferences.moneyFormatting.hideZeroDecimals": "Nascondi decimali a zero", + "preferences.moneyFormatting.hideZeroDecimals.description": "Mostra \"3\" invece di \"3,00\"", "preferences.moneyFormatting.useCurrencySymbol": "Usa simbolo valuta", "preferences.moneyFormatting.useCurrencySymbol.description": "Ad esempio, \"€5\" invece di \"5 EUR\"", "preferences.numpad": "Tastierino numerico", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Classico", "preferences.numpad.layout.modern": "Moderno", "preferences.primaryCurrency": "Valuta principale", - "preferences.privacy": "Privacy", "preferences.privacy.appLock": "Blocca app", "preferences.privacy.appLock.description#Android": "Richiedi autenticazione biometrica per aprire l'app", "preferences.privacy.appLock.description#iOS": "Richiedi Face ID o Touch ID per aprire l'app", "preferences.privacy.appLock.lockAfterClosing": "Blocca dopo la chiusura", "preferences.privacy.maskAtShake": "Maschera i numeri (*) quando si scuote il dispositivo", "preferences.privacy.maskAtStartup": "Nascondi i numeri (*) all'avvio", + "preferences.privacyAndSecurity": "Privacy e sicurezza", "preferences.reminders": "Promemoria giornaliero", "preferences.reminders.remindDaily": "Ricorda quotidianamente", "preferences.reminders.remindDaily.description": "Ricorda di tenere traccia delle spese quotidianamente", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Potrebbe risultare caotico per scontrini lunghi", "preferences.scan.markPendingThreshold": "Contrassegna le transazioni come in sospeso", "preferences.scan.markPendingThreshold.description": "Se disattivata, le transazioni la cui data interpretata ha più di 6 ore verranno comunque contrassegnate come in sospeso", - "preferences.sync": "Sincronizzazione", + "preferences.sync": "Sincronizzazione e backup", "preferences.sync.autoBackup": "Backup automatico", "preferences.sync.autoBackup.disabled": "Disabilita", "preferences.sync.autoBackup.interval": "Intervallo di backup", @@ -788,6 +794,14 @@ "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.byGroup.accountCount": "{count} conti", + "tabs.stats.byGroup.accountCount.one": "{count} conto", + "tabs.stats.byGroup.categoryCount": "{count} categorie", + "tabs.stats.byGroup.categoryCount.one": "{count} categoria", + "tabs.stats.byGroup.totalEarned": "Totale entrate", + "tabs.stats.byGroup.totalSpent": "Totale spese", + "tabs.stats.byGroup.view.chart": "Grafico", + "tabs.stats.byGroup.view.list": "Elenco", "tabs.stats.categories": "Categorie", "tabs.stats.categories.seeAll": "Vedi tutte le categorie", "tabs.stats.categories.top": "Spese principali", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "Mediana dell'importo della spesa nell'intervallo di tempo selezionato", "tabs.stats.trends.topSpendingTitles": "Transazioni frequenti", "tabs.stats.trends.topSpendingTitles.description": "Titoli di transazione più frequentemente utilizzati", + "tabs.stats.worthKnowing": "Da sapere", + "tabs.stats.worthKnowing.biggestEntries": "Movimenti più grandi", + "tabs.stats.worthKnowing.categoryDrop": "{category} è calata del {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} era calata del {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category} è a {value} il solito.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} era a {value} il solito.", + "tabs.stats.worthKnowing.charges": "Addebiti", + "tabs.stats.worthKnowing.chartBefore": "prima {amount}", + "tabs.stats.worthKnowing.chartCharges": "Ogni addebito", + "tabs.stats.worthKnowing.chartMonthly": "Spesa di ogni mese", + "tabs.stats.worthKnowing.chartThroughDay": "Spesa fino al {dayNumber} di ogni mese", + "tabs.stats.worthKnowing.chartUsual": "solito {amount}", + "tabs.stats.worthKnowing.entries": "{count} movimenti", + "tabs.stats.worthKnowing.entries.one": "{count} movimento", + "tabs.stats.worthKnowing.hidden": "Nascosto. Puoi riattivarlo da Da sapere.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Non mostrare cali di categoria", + "tabs.stats.worthKnowing.hide.categorySpike": "Non mostrare picchi di categoria", + "tabs.stats.worthKnowing.hide.monthPace": "Non mostrare confronti del mese", + "tabs.stats.worthKnowing.hide.newCategory": "Non mostrare nuove categorie", + "tabs.stats.worthKnowing.hide.priceChange": "Non mostrare variazioni di prezzo", + "tabs.stats.worthKnowing.hide.recurringCharge": "Non mostrare suggerimenti di abbonamento", + "tabs.stats.worthKnowing.kinds": "Mostra in Da sapere", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} meno del solito", + "tabs.stats.worthKnowing.monthAbove": "{month} è {value} sopra il solito al giorno {dayNumber}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} è stato {value} sopra il solito.", + "tabs.stats.worthKnowing.monthBelow": "{month} è {value} sotto il solito al giorno {dayNumber}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} è stato {value} sotto il solito.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} più del solito", + "tabs.stats.worthKnowing.mostlyCategory": "Soprattutto {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Soprattutto {title} ({amount}). Senza, come al solito.", + "tabs.stats.worthKnowing.mostlyOver": "Soprattutto {title} ({amount}). Senza, ancora {percent} sopra.", + "tabs.stats.worthKnowing.mostlyUnder": "Soprattutto {title} ({amount}). Senza, {percent} sotto.", + "tabs.stats.worthKnowing.newCategory": "Prima spesa in {value} da {months} mesi.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: niente di insolito finora.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: niente di insolito.", + "tabs.stats.worthKnowing.priceChange": "{title} è passato da {previous} a {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} in meno all'anno", + "tabs.stats.worthKnowing.priceUp": "{amount} in più all'anno", + "tabs.stats.worthKnowing.recurringMonthly": "{value} sembra un addebito mensile.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} verso il {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} sembra un addebito settimanale.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} ogni {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} sembra un addebito annuale.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} verso il {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Appare quando una categoria usata quasi ogni mese scende sotto il {percent} della sua mediana.", + "tabs.stats.worthKnowing.rule.categorySpike": "Appare quando una categoria arriva a {ratio} la sua mediana dei {months} mesi precedenti, con almeno {count} movimenti.", + "tabs.stats.worthKnowing.rule.footer": "Trasferimenti e movimenti in sospeso sono esclusi. Calcolato su questo dispositivo.", + "tabs.stats.worthKnowing.rule.monthPace": "Spesa fino al {date}, confrontata con la mediana dei {months} mesi precedenti allo stesso giorno.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Spesa di {month}, confrontata con la mediana dei {months} mesi precedenti.", + "tabs.stats.worthKnowing.rule.newCategory": "Appare quando una categoria ha spese per la prima volta da {months} mesi.", + "tabs.stats.worthKnowing.rule.priceChange": "Appare quando un addebito ricorrente cambia del {percent} o più.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Addebiti con lo stesso titolo, che si ripetono regolarmente. Le bollette variabili, come le utenze, richiedono almeno {count} addebiti.", + "tabs.stats.worthKnowing.seeTransactions": "Vedi queste transazioni", + "tabs.stats.worthKnowing.times": "{count} volte finora", + "tabs.stats.worthKnowing.times.one": "{count} volta finora", + "tabs.stats.worthKnowing.track": "Segui come ricorrente", + "tabs.stats.worthKnowing.type.categoryDrop": "Cali di categoria", + "tabs.stats.worthKnowing.type.categorySpike": "Picchi di categoria", + "tabs.stats.worthKnowing.type.monthPace": "Confronti del mese", + "tabs.stats.worthKnowing.type.newCategory": "Nuove categorie", + "tabs.stats.worthKnowing.type.priceChange": "Variazioni di prezzo", + "tabs.stats.worthKnowing.type.recurringCharge": "Suggerimenti di abbonamento", + "tabs.stats.worthKnowing.versusTypical": "{current} contro {usual} di solito", + "tabs.stats.worthKnowing.whatMovedIt": "Cosa l'ha mosso", + "tabs.stats.worthKnowing.why": "Perché lo vedi", "transaction": "Transazione", "transaction.actions": "Azioni", "transaction.attachments": "File allegati", @@ -881,6 +960,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Questa azione eliminerà tutte le transazioni correlate e impedirà la creazione di nuove transazioni. Il recupero delle transazioni dal cestino non causerà la creazione di nuove transazioni.", "transaction.recurring.edit": "Modifica transazione ricorrente", "transaction.recurring.setup": "Configurare la transazione ricorrente", + "transaction.recurring.variableAmount": "Importo variabile", + "transaction.recurring.variableAmount.description": "Chiede l'importo ogni volta", "transaction.tags": "Tag", "transaction.tags.add": "Aggiungi tag", "transaction.tags.contact.name": "Nome del contatto", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 5137a456..9677deeb 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -47,25 +47,22 @@ "budget.categories.description": "Зөвхөн сонгосон ангиллын зарлага энэ төсөвт тооцогдоно. Юу ч сонгоогүй бол бүх зарлага тооцогдоно.", "budget.delete": "Төсвийг устгах", "budget.delete.description": "Гүйлгээнүүдэд нөлөөлөхгүй. Энэ үйлдлийг буцаах боломжгүй!", - "budget.insight.nearingLimit": "{name} нь {percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", - "budget.insight.onTrack": "{name} хэвийн байна — {amount} үлдсэн.", - "budget.status.onTrack": "Хэвийн", - "budget.status.nearing": "Хязгаарт дөхсөн", - "budget.status.over": "Хэтэрсэн", - "budget.detail.history": "Сүүлийн хугацаанууд", - "budget.detail.transactions": "Гүйлгээнүүд", - "budget.detail.unavailable": "Төсвийн тоо одоогоор гарахгүй байна.", "budget.detail.daysLeft": "{} хоног үлдлээ", "budget.detail.daysLeft.one": "{} хоног үлдлээ", + "budget.detail.history": "Сүүлийн хугацаанууд", "budget.detail.periodEnded": "Хугацаа дууссан", - "budget.insight.over.short": "Та төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", + "budget.detail.transactions": "Гүйлгээнүүд", + "budget.detail.unavailable": "Төсвийн тоо одоогоор гарахгүй байна.", + "budget.insight.nearingLimit": "{name} нь {percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", "budget.insight.nearingLimit.short": "{percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", - "budget.insight.overpacing.short": "Энэ янзаараа төсөв {amount}-р хэтрэх болно.", + "budget.insight.onTrack": "{name} хэвийн байна — {amount} үлдсэн.", "budget.insight.onTrack.short": "Хэвийн байна — {amount} үлдсэн.", - "budget.insight.underspending.short": "Төсөвт {amount} зарцуулагдаагүй үлджээ.", "budget.insight.over": "Та {name} төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", + "budget.insight.over.short": "Та төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", "budget.insight.overpacing": "Энэ янзаараа {name} төсөв {amount}-р хэтрэх болно.", + "budget.insight.overpacing.short": "Энэ янзаараа төсөв {amount}-р хэтрэх болно.", "budget.insight.underspending": "{name} төсөвт {amount} зарцуулагдаагүй үлджээ.", + "budget.insight.underspending.short": "Төсөвт {amount} зарцуулагдаагүй үлджээ.", "budget.name": "Төсвийн нэр", "budget.overview.allHealthy": "Бүх зүйл төсвийг дагуу. Янзтай!", "budget.overview.budgetsTracked": "{count} төсөв хянагдаж байна", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Автоматаар шинэчлэх", "budget.renewAutomatically.description": "Хугацаа дуусахад төсөв дараагийн хугацаа руу шилжинэ. Жишээ нь, 7-р сарын төсөв 8-р сарын төсөв болно.", "budget.scope": "Хүрээ", + "budget.status.nearing": "Хязгаарт дөхсөн", + "budget.status.onTrack": "Хэвийн", + "budget.status.over": "Хэтэрсэн", "budgets": "Төсвүүд", "budgets.new": "Шинэ төсөв", "categories": "Ангиллууд", @@ -363,12 +363,14 @@ "preferences.changeVisuals.color": "Өнгө", "preferences.changeVisuals.expenseIncrease": "Зарлага өсөх", "preferences.changeVisuals.incomeIncrease": "Орлого өсөх", + "preferences.data": "Өгөгдөл", "preferences.dateFormat": "Огнооны хэв", + "preferences.dateFormat.absoluteDateHeaders": "Гарчигт яг огноог харуулах", + "preferences.dateFormat.system": "Хэлний үндсэн", "preferences.feedback": "Асуудал ", "preferences.feedback.debugLogs": "Дибаг лог харах", - "preferences.hapticFeedback": "Товчны хариу", + "preferences.general": "Ерөнхий", "preferences.hapticFeedback.description": "Дарах үед дуу/чичиргээ тоглуулна", - "preferences.integrations": "Холболтууд", "preferences.language": "Хэл", "preferences.language.choose": "Хэл сонгох", "preferences.moneyFormatting": "Мөнгөний хэлбэр", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Тоог хураахгүй бүтнээр нь харуулах", "preferences.moneyFormatting.setICUPattern": "Өөр хэлбэр сонгох", "preferences.moneyFormatting.setICUPattern.default": "Өгөгдмөл", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Ойролцоо дүнг харуулах", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Гадаад валютын дүнг үндсэн валютаар давхар харуулах", + "preferences.moneyFormatting.hideZeroDecimals": "Бүхэл дүнгийн бутархайг нуух", + "preferences.moneyFormatting.hideZeroDecimals.description": "Жишээлбэл, \"3.00\" гэхийн оронд \"3\"", "preferences.moneyFormatting.useCurrencySymbol": "Тэмдэгт ашиглах", "preferences.moneyFormatting.useCurrencySymbol.description": "Жишээлбэл, \"MNT 3,200\" гэхийн оронд \"3,200₮\"", "preferences.numpad": "Тоон товчлуур", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Хуучны", "preferences.numpad.layout.modern": "Орчин үеийн", "preferences.primaryCurrency": "Үндсэн валют", - "preferences.privacy": "Нууцлал", "preferences.privacy.appLock": "Апп түгжих", "preferences.privacy.appLock.description#Android": "Аппыг нээхийн тулд биометрик түгжээ ашиглаж өөрийгөө баталгаажуулах", "preferences.privacy.appLock.description#iOS": "Аппыг нээхийн тулд Face ID эсвэл Touch ID шаардах", "preferences.privacy.appLock.lockAfterClosing": "Хаах үед түгжих", "preferences.privacy.maskAtShake": "Төхөөрөмж сэгсрэх үед дүнг нууцлах (*)", "preferences.privacy.maskAtStartup": "Апп нээгдэхэд дүнг нууцлах (*)", + "preferences.privacyAndSecurity": "Нууцлал ба аюулгүй байдал", "preferences.reminders": "Сануулга", "preferences.reminders.remindDaily": "Өдөр бүр сануулах", "preferences.reminders.remindDaily.description": "Гүйлгээгээ бүртгэхийг сануулах", @@ -688,7 +694,7 @@ "tabs.profile.analytics.cashFlow": "Мөнгөний урсгал (Sankey)", "tabs.profile.analytics.map": "Зарцуулалтын газрын зураг", "tabs.profile.analytics.netWorth": "Цаг хугацааны явцад нийт хөрөнгө", - "tabs.profile.analytics.recurring": "Бүртгэл ба давтагдах", + "tabs.profile.analytics.recurring": "Давтагдах төлбөрүүд", "tabs.profile.analytics.wrapped": "Сарын тойм", "tabs.profile.backup": "Нөөцлөх", "tabs.profile.community": "Холбоо", @@ -788,6 +794,14 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Сарынхаа тоймыг үзэх", "tabs.stats.analytics.wrapped.tileTitle": "Таны {month}-ын тойм", "tabs.stats.analytics.wrapped.title": "{month}-ын тойм", + "tabs.stats.byGroup.accountCount": "{count} данс", + "tabs.stats.byGroup.accountCount.one": "{count} данс", + "tabs.stats.byGroup.categoryCount": "{count} ангилал", + "tabs.stats.byGroup.categoryCount.one": "{count} ангилал", + "tabs.stats.byGroup.totalEarned": "Нийт орлого", + "tabs.stats.byGroup.totalSpent": "Нийт зарлага", + "tabs.stats.byGroup.view.chart": "График", + "tabs.stats.byGroup.view.list": "Жагсаалт", "tabs.stats.categories": "Ангилал", "tabs.stats.categories.seeAll": "Бүх ангиллыг харах", "tabs.stats.categories.top": "Хамгийн зарлагатай", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "Сонгосон хугацаан дахь зарлагын гүйлгээнүүдийг голч", "tabs.stats.trends.topSpendingTitles": "Түгээмэл зарлагууд", "tabs.stats.trends.topSpendingTitles.description": "Хамгийн түгээмэл хийгддэг зарлагууд", + "tabs.stats.worthKnowing": "Мэдэхэд илүүдэхгүй", + "tabs.stats.worthKnowing.biggestEntries": "Хамгийн том гүйлгээнүүд", + "tabs.stats.worthKnowing.categoryDrop": "{category}: ердийнхөөс {value} бага.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: ердийнхөөс {value} бага байсан.", + "tabs.stats.worthKnowing.categorySpike": "{category}: ердийнхөөс {value} их.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: ердийнхөөс {value} их байсан.", + "tabs.stats.worthKnowing.charges": "Төлбөрүүд", + "tabs.stats.worthKnowing.chartBefore": "өмнө нь {amount}", + "tabs.stats.worthKnowing.chartCharges": "Төлбөр бүр", + "tabs.stats.worthKnowing.chartMonthly": "Сар бүрийн зарлага", + "tabs.stats.worthKnowing.chartThroughDay": "Сар бүрийн {day} өдөр хүртэлх зарлага", + "tabs.stats.worthKnowing.chartUsual": "ердийн {amount}", + "tabs.stats.worthKnowing.entries": "{count} гүйлгээ", + "tabs.stats.worthKnowing.entries.one": "{count} гүйлгээ", + "tabs.stats.worthKnowing.hidden": "Нуулаа. «Мэдэхэд илүүдэхгүй» картаас дахин асааж болно.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Ангиллын бууралтыг харуулахгүй", + "tabs.stats.worthKnowing.hide.categorySpike": "Ангиллын өсөлтийг харуулахгүй", + "tabs.stats.worthKnowing.hide.monthPace": "Сарын харьцуулалтыг харуулахгүй", + "tabs.stats.worthKnowing.hide.newCategory": "Шинэ ангиллыг харуулахгүй", + "tabs.stats.worthKnowing.hide.priceChange": "Үнийн өөрчлөлтийг харуулахгүй", + "tabs.stats.worthKnowing.hide.recurringCharge": "Захиалгын саналыг харуулахгүй", + "tabs.stats.worthKnowing.kinds": "«Мэдэхэд илүүдэхгүй»-д харуулах", + "tabs.stats.worthKnowing.lessThanUsual": "ердийнхөөс {amount} бага", + "tabs.stats.worthKnowing.monthAbove": "{month}: {day} өдрийн байдлаар ердийнхөөс {value} өндөр.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: ердийнхөөс {value} өндөр байсан.", + "tabs.stats.worthKnowing.monthBelow": "{month}: {day} өдрийн байдлаар ердийнхөөс {value} бага.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: ердийнхөөс {value} бага байсан.", + "tabs.stats.worthKnowing.moreThanUsual": "ердийнхөөс {amount} их", + "tabs.stats.worthKnowing.mostlyCategory": "Голчлон {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Голчлон {title} ({amount}). Үүнгүйгээр ердийн хэмжээнд.", + "tabs.stats.worthKnowing.mostlyOver": "Голчлон {title} ({amount}). Үүнгүйгээр ч {percent} өндөр.", + "tabs.stats.worthKnowing.mostlyUnder": "Голчлон {title} ({amount}). Үүнгүйгээр {percent} бага.", + "tabs.stats.worthKnowing.newCategory": "{value}: {months} сард анх удаа зарлага гарлаа.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: одоогоор онцгой зүйлгүй.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: онцгой зүйлгүй.", + "tabs.stats.worthKnowing.priceChange": "{title}: {previous} байсан бол одоо {value}.", + "tabs.stats.worthKnowing.priceDown": "жилд {amount} бага", + "tabs.stats.worthKnowing.priceUp": "жилд {amount} их", + "tabs.stats.worthKnowing.recurringMonthly": "{value} сар бүрийн төлбөр бололтой.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount}, {day} өдрийн орчим · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} долоо хоног бүрийн төлбөр бололтой.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} бүр · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} жил бүрийн төлбөр бололтой.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount}, {date} орчим · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Бараг сар бүр хэрэглэдэг ангиллын зарлага ердийн (медиан) дүнгийн {percent}-иас доош буувал харагдана.", + "tabs.stats.worthKnowing.rule.categorySpike": "Ангиллын зарлага өмнөх {months} сарын ердийн (медиан) дүнгийн {ratio} хүрч, дор хаяж {count} гүйлгээтэй бол харагдана.", + "tabs.stats.worthKnowing.rule.footer": "Шилжүүлэг болон хүлээгдэж буй гүйлгээг тооцохгүй. Энэ төхөөрөмж дээр тооцоолов.", + "tabs.stats.worthKnowing.rule.monthPace": "{date} хүртэлх зарлагыг өмнөх {months} сарын мөн өдөр хүртэлх ердийн (медиан) дүнтэй харьцуулав.", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month}: зарлагыг өмнөх {months} сарын ердийн (медиан) дүнтэй харьцуулав.", + "tabs.stats.worthKnowing.rule.newCategory": "Ангилалд {months} сард анх удаа зарлага гарвал харагдана.", + "tabs.stats.worthKnowing.rule.priceChange": "Давтагддаг төлбөр {percent} буюу түүнээс их өөрчлөгдвөл харагдана.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Ижил нэртэй, тогтмол давтагддаг төлбөрүүд. Цахилгаан гэх мэт дүн нь өөрчлөгддөг төлбөрт дор хаяж {count} гүйлгээ шаардлагатай.", + "tabs.stats.worthKnowing.seeTransactions": "Эдгээр гүйлгээг харах", + "tabs.stats.worthKnowing.times": "одоогоор {count} удаа", + "tabs.stats.worthKnowing.times.one": "одоогоор {count} удаа", + "tabs.stats.worthKnowing.track": "Давтагдах гүйлгээ болгох", + "tabs.stats.worthKnowing.type.categoryDrop": "Ангиллын бууралт", + "tabs.stats.worthKnowing.type.categorySpike": "Ангиллын өсөлт", + "tabs.stats.worthKnowing.type.monthPace": "Сарын харьцуулалт", + "tabs.stats.worthKnowing.type.newCategory": "Шинэ ангилал", + "tabs.stats.worthKnowing.type.priceChange": "Үнийн өөрчлөлт", + "tabs.stats.worthKnowing.type.recurringCharge": "Захиалгын санал", + "tabs.stats.worthKnowing.versusTypical": "{current} (ердийн {usual})", + "tabs.stats.worthKnowing.whatMovedIt": "Юу нөлөөлсөн бэ", + "tabs.stats.worthKnowing.why": "Яагаад үүнийг харж байна вэ", "transaction": "Гүйлгээ", "transaction.actions": "Үйлдлүүд", "transaction.attachments": "Файл хасвралтууд", @@ -875,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Энэ үйлдэл нь холбоотой бүх гүйлгээг устгах бөгөөд, цаашид шинэ гүйлгээ үүсгэхгүй. Хогийн савнаас устгсан гүйлгээнүүдийг сэргээх боломжтой боловч шинэ гүйлгээг дахиж үүсэхгүй.", "transaction.recurring.edit": "Давтамжит гүйлгээг засварлах", "transaction.recurring.setup": "Давтамжит гүйлгээ тохируулах", + "transaction.recurring.variableAmount": "Дүн өөрчлөгддөг", + "transaction.recurring.variableAmount.description": "Дүнг тухай бүрт асууна", "transaction.tags": "Шошго", "transaction.tags.add": "Шошго нэмэх", "transaction.tags.contact.name": "Хүний нэр", @@ -946,4 +1027,4 @@ "transactions.query.noResult": "Тохирох гүйлгээнүүд олдсонгүй", "transactions.query.noResult.description": "Шүүлтүүрээ өөрчлөөд дахин оролдоно уу", "visitGitHubRepo": "GitHub repo-р зочлох" -} +} \ No newline at end of file diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index e2f5aba4..81b9f6f2 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -47,25 +47,22 @@ "budget.categories.description": "Do tego budżetu liczą się tylko wydatki z wybranych kategorii. Nie wybieraj żadnej, aby liczyć wszystkie wydatki.", "budget.delete": "Usuń budżet", "budget.delete.description": "Transakcje nie zostaną naruszone. Ta akcja jest nieodwracalna!", - "budget.insight.nearingLimit": "{name} jest wykorzystany w {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", - "budget.insight.onTrack": "{name} jest na dobrej drodze — pozostało {amount}.", - "budget.status.onTrack": "W normie", - "budget.status.nearing": "Blisko limitu", - "budget.status.over": "Limit przekroczony", - "budget.detail.history": "Ostatnie okresy", - "budget.detail.transactions": "Transakcje", - "budget.detail.unavailable": "Dane budżetu są chwilowo niedostępne.", "budget.detail.daysLeft": "Pozostało {} dni", "budget.detail.daysLeft.one": "Pozostał {} dzień", + "budget.detail.history": "Ostatnie okresy", "budget.detail.periodEnded": "Okres zakończony", - "budget.insight.over.short": "Masz przekroczenie o {amount}. Ogranicz wydatki lub zwiększ limit.", + "budget.detail.transactions": "Transakcje", + "budget.detail.unavailable": "Dane budżetu są chwilowo niedostępne.", + "budget.insight.nearingLimit": "{name} jest wykorzystany w {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", "budget.insight.nearingLimit.short": "Wykorzystano {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", - "budget.insight.overpacing.short": "W tym tempie przekroczysz limit o {amount}.", + "budget.insight.onTrack": "{name} jest na dobrej drodze — pozostało {amount}.", "budget.insight.onTrack.short": "Na dobrej drodze — pozostało {amount}.", - "budget.insight.underspending.short": "Jest dużo zapasu — pozostało {amount}.", "budget.insight.over": "Masz przekroczenie o {amount} w {name}. Ogranicz wydatki lub zwiększ limit.", + "budget.insight.over.short": "Masz przekroczenie o {amount}. Ogranicz wydatki lub zwiększ limit.", "budget.insight.overpacing": "W tym tempie {name} przekroczy limit o {amount}.", + "budget.insight.overpacing.short": "W tym tempie przekroczysz limit o {amount}.", "budget.insight.underspending": "{name} ma dużo zapasu — pozostało {amount}.", + "budget.insight.underspending.short": "Jest dużo zapasu — pozostało {amount}.", "budget.name": "Nazwa budżetu", "budget.overview.allHealthy": "Wszystko idzie zgodnie z planem. Świetna robota.", "budget.overview.budgetsTracked": "{count} śledzonych budżetów", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Odnawiaj automatycznie", "budget.renewAutomatically.description": "Gdy okres się kończy, budżet przechodzi na nowy okres. Na przykład budżet lipcowy staje się budżetem sierpniowym.", "budget.scope": "Zakres", + "budget.status.nearing": "Blisko limitu", + "budget.status.onTrack": "W normie", + "budget.status.over": "Limit przekroczony", "budgets": "Budżety", "budgets.new": "Nowy budżet", "categories": "Kategorie", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Nie zapomnij dzisiaj dodać transakcji!", "preferences": "Preferencje", "preferences.appearance": "Wygląd", - "preferences.changeVisuals": "Zmień", + "preferences.changeVisuals": "Wskaźniki trendu", "preferences.changeVisuals.arrow": "Strzałka", "preferences.changeVisuals.clickToChange": "Kliknij strzałkę/kolor, aby zmienić", "preferences.changeVisuals.color": "Kolor", "preferences.changeVisuals.expenseIncrease": "Wzrost wydatków", "preferences.changeVisuals.incomeIncrease": "Wzrost przychodów", + "preferences.data": "Dane", "preferences.dateFormat": "Format daty", + "preferences.dateFormat.absoluteDateHeaders": "Pokazuj dokładne daty w nagłówkach", + "preferences.dateFormat.system": "Zgodnie z językiem", "preferences.feedback": "Problemy i opinie", "preferences.feedback.debugLogs": "Zobacz logi debugowania", - "preferences.hapticFeedback": "Wibracje przycisków", + "preferences.general": "Ogólne", "preferences.hapticFeedback.description": "Dźwięk/wibracja przy kliknięciu", - "preferences.integrations": "Integracje", "preferences.language": "Język", "preferences.language.choose": "Wybierz język", "preferences.moneyFormatting": "Formatowanie waluty", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Nie skracaj liczb, jeśli to możliwe", "preferences.moneyFormatting.setICUPattern": "Wybierz niestandardowy format", "preferences.moneyFormatting.setICUPattern.default": "Domyślny", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Pokaż przybliżoną kwotę", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Pokazuj też kwoty obce w walucie głównej", + "preferences.moneyFormatting.hideZeroDecimals": "Ukryj zerowe miejsca dziesiętne", + "preferences.moneyFormatting.hideZeroDecimals.description": "Pokazuj „3” zamiast „3,00”", "preferences.moneyFormatting.useCurrencySymbol": "Użyj symbolu waluty", "preferences.moneyFormatting.useCurrencySymbol.description": "np. \"$5\" zamiast \"5USD\"", "preferences.numpad": "Klawiatura numeryczna", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Klasyczny", "preferences.numpad.layout.modern": "Nowoczesny", "preferences.primaryCurrency": "Waluta główna", - "preferences.privacy": "Prywatność", "preferences.privacy.appLock": "Zablokuj aplikację", "preferences.privacy.appLock.description#Android": "Wymagaj uwierzytelniania biometrycznego, aby otworzyć aplikację", "preferences.privacy.appLock.description#iOS": "Wymagaj Face ID lub Touch ID, aby otworzyć aplikację", "preferences.privacy.appLock.lockAfterClosing": "Zablokuj po zamknięciu", "preferences.privacy.maskAtShake": "Ukryj kwoty (*) po potrząśnięciu", "preferences.privacy.maskAtStartup": "Ukryj kwoty (*) przy uruchomieniu", + "preferences.privacyAndSecurity": "Prywatność i bezpieczeństwo", "preferences.reminders": "Przypomnienia", "preferences.reminders.remindDaily": "Codzienne przypomnienie", "preferences.reminders.remindDaily.description": "Przypominaj o codziennym zapisywaniu wydatków", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Może być uciążliwe w przypadku długich paragonów", "preferences.scan.markPendingThreshold": "Oznacz jako oczekujące", "preferences.scan.markPendingThreshold.description": "Jeśli wyłączone, transakcje z datą starszą niż 6 godzin nadal będą oznaczane jako oczekujące", - "preferences.sync": "Synchronizacja", + "preferences.sync": "Synchronizacja i kopia zapasowa", "preferences.sync.autoBackup": "Auto kopia zapasowa", "preferences.sync.autoBackup.disabled": "Wyłączone", "preferences.sync.autoBackup.interval": "Interwał kopii zapasowej", @@ -790,6 +796,18 @@ "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.byGroup.accountCount": "{count} kont", + "tabs.stats.byGroup.accountCount.few": "{count} konta", + "tabs.stats.byGroup.accountCount.many": "{count} kont", + "tabs.stats.byGroup.accountCount.one": "{count} konto", + "tabs.stats.byGroup.categoryCount": "{count} kategorii", + "tabs.stats.byGroup.categoryCount.few": "{count} kategorie", + "tabs.stats.byGroup.categoryCount.many": "{count} kategorii", + "tabs.stats.byGroup.categoryCount.one": "{count} kategoria", + "tabs.stats.byGroup.totalEarned": "Łącznie zarobione", + "tabs.stats.byGroup.totalSpent": "Łącznie wydane", + "tabs.stats.byGroup.view.chart": "Wykres", + "tabs.stats.byGroup.view.list": "Lista", "tabs.stats.categories": "Kategorie", "tabs.stats.categories.seeAll": "Pokaż wszystkie kategorie", "tabs.stats.categories.top": "Największe wydatki", @@ -814,6 +832,73 @@ "tabs.stats.trends.median.description": "Mediana kwoty wydatku w wybranym okresie czasu", "tabs.stats.trends.topSpendingTitles": "Częste transakcje", "tabs.stats.trends.topSpendingTitles.description": "Najczęściej powtarzające się tytuły transakcji", + "tabs.stats.worthKnowing": "Warto wiedzieć", + "tabs.stats.worthKnowing.biggestEntries": "Największe wpisy", + "tabs.stats.worthKnowing.categoryDrop": "{category}: spadek o {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: spadek o {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} zwykłej kwoty.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: {value} zwykłej kwoty.", + "tabs.stats.worthKnowing.charges": "Obciążenia", + "tabs.stats.worthKnowing.chartBefore": "wcześniej {amount}", + "tabs.stats.worthKnowing.chartCharges": "Każde obciążenie", + "tabs.stats.worthKnowing.chartMonthly": "Wydatki w każdym miesiącu", + "tabs.stats.worthKnowing.chartThroughDay": "Wydatki do {dayNumber}. dnia każdego miesiąca", + "tabs.stats.worthKnowing.chartUsual": "zwykle {amount}", + "tabs.stats.worthKnowing.entries": "{count} wpisów", + "tabs.stats.worthKnowing.entries.few": "{count} wpisy", + "tabs.stats.worthKnowing.entries.one": "{count} wpis", + "tabs.stats.worthKnowing.hidden": "Ukryto. Możesz to włączyć w karcie Warto wiedzieć.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Nie pokazuj spadków kategorii", + "tabs.stats.worthKnowing.hide.categorySpike": "Nie pokazuj skoków kategorii", + "tabs.stats.worthKnowing.hide.monthPace": "Nie pokazuj porównań miesiąca", + "tabs.stats.worthKnowing.hide.newCategory": "Nie pokazuj nowych kategorii", + "tabs.stats.worthKnowing.hide.priceChange": "Nie pokazuj zmian cen", + "tabs.stats.worthKnowing.hide.recurringCharge": "Nie pokazuj sugestii subskrypcji", + "tabs.stats.worthKnowing.kinds": "Pokazuj w Warto wiedzieć", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} mniej niż zwykle", + "tabs.stats.worthKnowing.monthAbove": "{month}: {value} powyżej normy do {dayNumber}. dnia.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: {value} powyżej normy.", + "tabs.stats.worthKnowing.monthBelow": "{month}: {value} poniżej normy do {dayNumber}. dnia.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: {value} poniżej normy.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} więcej niż zwykle", + "tabs.stats.worthKnowing.mostlyCategory": "Głównie {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Głównie {title} ({amount}). Bez tego w normie.", + "tabs.stats.worthKnowing.mostlyOver": "Głównie {title} ({amount}). Bez tego nadal {percent} powyżej.", + "tabs.stats.worthKnowing.mostlyUnder": "Głównie {title} ({amount}). Bez tego {percent} poniżej.", + "tabs.stats.worthKnowing.newCategory": "Pierwsze wydatki na {value} od {months} miesięcy.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: na razie nic nietypowego.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: nic nietypowego.", + "tabs.stats.worthKnowing.priceChange": "{title}: zmiana z {previous} na {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} mniej rocznie", + "tabs.stats.worthKnowing.priceUp": "{amount} więcej rocznie", + "tabs.stats.worthKnowing.recurringMonthly": "{value} wygląda na opłatę miesięczną.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} około {dayNumber}. dnia · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} wygląda na opłatę tygodniową.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} wygląda na opłatę roczną.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} około {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Pojawia się, gdy kategoria używana niemal co miesiąc spada poniżej {percent} swojej mediany.", + "tabs.stats.worthKnowing.rule.categorySpike": "Pojawia się, gdy kategoria osiąga {ratio} swojej mediany z {months} poprzednich miesięcy przy co najmniej {count} wpisach.", + "tabs.stats.worthKnowing.rule.footer": "Przelewy i oczekujące wpisy są pomijane. Obliczone na tym urządzeniu.", + "tabs.stats.worthKnowing.rule.monthPace": "Wydatki do {date} w porównaniu z medianą {months} poprzednich miesięcy do tego samego dnia.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Wydatki w miesiącu {month} w porównaniu z medianą {months} poprzednich miesięcy.", + "tabs.stats.worthKnowing.rule.newCategory": "Pojawia się, gdy kategoria ma wydatki pierwszy raz od {months} miesięcy.", + "tabs.stats.worthKnowing.rule.priceChange": "Pojawia się, gdy cykliczna opłata zmienia się o {percent} lub więcej.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Opłaty o tym samym tytule, powtarzające się regularnie. Zmienne rachunki, jak media, wymagają co najmniej {count} opłat.", + "tabs.stats.worthKnowing.seeTransactions": "Zobacz te transakcje", + "tabs.stats.worthKnowing.times": "{count} razy dotąd", + "tabs.stats.worthKnowing.times.few": "{count} razy dotąd", + "tabs.stats.worthKnowing.times.one": "{count} raz dotąd", + "tabs.stats.worthKnowing.track": "Śledź jako cykliczną", + "tabs.stats.worthKnowing.type.categoryDrop": "Spadki kategorii", + "tabs.stats.worthKnowing.type.categorySpike": "Skoki kategorii", + "tabs.stats.worthKnowing.type.monthPace": "Porównania miesiąca", + "tabs.stats.worthKnowing.type.newCategory": "Nowe kategorie", + "tabs.stats.worthKnowing.type.priceChange": "Zmiany cen", + "tabs.stats.worthKnowing.type.recurringCharge": "Sugestie subskrypcji", + "tabs.stats.worthKnowing.versusTypical": "{current} vs zwykle {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Co na to wpłynęło", + "tabs.stats.worthKnowing.why": "Dlaczego to widzisz", "transaction": "Transakcja", "transaction.actions": "Akcje", "transaction.attachments": "Załączniki plików", @@ -894,6 +979,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Ta akcja usunie wszystkie powiązane transakcje i wstrzyma tworzenie nowych. Przywrócenie ich z kosza nie przywróci ich cykliczności.", "transaction.recurring.edit": "Edytuj transakcję cykliczną", "transaction.recurring.setup": "Ustaw transakcję cykliczną", + "transaction.recurring.variableAmount": "Kwota się zmienia", + "transaction.recurring.variableAmount.description": "Pyta o kwotę za każdym razem", "transaction.tags": "Tagi", "transaction.tags.add": "Dodaj tagi", "transaction.tags.contact.name": "Nazwa kontaktu", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index d7bd6847..d4032232 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -47,25 +47,22 @@ "budget.categories.description": "В этот бюджет входят только расходы выбранных категорий. Не выбирайте ничего, чтобы учитывать все расходы.", "budget.delete": "Удалить бюджет", "budget.delete.description": "Транзакции не будут затронуты. Это действие необратимо!", - "budget.insight.nearingLimit": "{name}: потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", - "budget.insight.onTrack": "{name} в норме — осталось {amount}.", - "budget.status.onTrack": "В норме", - "budget.status.nearing": "Близко к лимиту", - "budget.status.over": "Лимит превышен", - "budget.detail.history": "Последние периоды", - "budget.detail.transactions": "Транзакции", - "budget.detail.unavailable": "Данные бюджета сейчас недоступны.", "budget.detail.daysLeft": "Осталось {} дн.", "budget.detail.daysLeft.one": "Остался {} день", + "budget.detail.history": "Последние периоды", "budget.detail.periodEnded": "Период завершён", - "budget.insight.over.short": "Вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", + "budget.detail.transactions": "Транзакции", + "budget.detail.unavailable": "Данные бюджета сейчас недоступны.", + "budget.insight.nearingLimit": "{name}: потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", "budget.insight.nearingLimit.short": "Потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", - "budget.insight.overpacing.short": "С таким темпом вы превысите лимит на {amount}.", + "budget.insight.onTrack": "{name} в норме — осталось {amount}.", "budget.insight.onTrack.short": "В норме — осталось {amount}.", - "budget.insight.underspending.short": "Есть запас — не потрачено {amount}.", "budget.insight.over": "По {name} вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", + "budget.insight.over.short": "Вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", "budget.insight.overpacing": "С таким темпом {name} превысит лимит на {amount}.", + "budget.insight.overpacing.short": "С таким темпом вы превысите лимит на {amount}.", "budget.insight.underspending": "{name} имеет запас — не потрачено {amount}.", + "budget.insight.underspending.short": "Есть запас — не потрачено {amount}.", "budget.name": "Название бюджета", "budget.overview.allHealthy": "Все в порядке. Отличная работа.", "budget.overview.budgetsTracked": "{count} отслеживаемых бюджетов", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Продлевать автоматически", "budget.renewAutomatically.description": "Когда период заканчивается, бюджет переходит на новый период. Например, бюджет июля становится бюджетом августа.", "budget.scope": "Область", + "budget.status.nearing": "Близко к лимиту", + "budget.status.onTrack": "В норме", + "budget.status.over": "Лимит превышен", "budgets": "Бюджеты", "budgets.new": "Новый бюджет", "categories": "Категории", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Не забудьте добавить свои транзакции сегодня!", "preferences": "Настройки", "preferences.appearance": "Внешний вид", - "preferences.changeVisuals": "Изменить", + "preferences.changeVisuals": "Индикаторы тренда", "preferences.changeVisuals.arrow": "Стрелка", "preferences.changeVisuals.clickToChange": "Нажмите на стрелку/цвет, чтобы изменить", "preferences.changeVisuals.color": "Цвет", "preferences.changeVisuals.expenseIncrease": "Рост расходов", "preferences.changeVisuals.incomeIncrease": "Рост дохода", + "preferences.data": "Данные", "preferences.dateFormat": "Формат даты", + "preferences.dateFormat.absoluteDateHeaders": "Показывать точные даты в заголовках", + "preferences.dateFormat.system": "Как в языке", "preferences.feedback": "Проблемы и отзывы", "preferences.feedback.debugLogs": "Просмотреть отладочные логи", - "preferences.hapticFeedback": "Отклик кнопок", + "preferences.general": "Общие", "preferences.hapticFeedback.description": "Звуковой/тактильный отклик при нажатии", - "preferences.integrations": "Интеграции", "preferences.language": "Язык", "preferences.language.choose": "Выберите язык", "preferences.moneyFormatting": "Форматирование денег", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Не сокращать числа, когда это возможно", "preferences.moneyFormatting.setICUPattern": "Выберите пользовательский формат", "preferences.moneyFormatting.setICUPattern.default": "По умолчанию", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Показывать примерную сумму", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Также показывать иностранные суммы в основной валюте", + "preferences.moneyFormatting.hideZeroDecimals": "Скрывать нулевые дроби", + "preferences.moneyFormatting.hideZeroDecimals.description": "Показывать «3» вместо «3,00»", "preferences.moneyFormatting.useCurrencySymbol": "Использовать символ валюты", "preferences.moneyFormatting.useCurrencySymbol.description": "например, \"$5\" вместо \"5USD\"", "preferences.numpad": "Цифровая клавиатура", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Классическая", "preferences.numpad.layout.modern": "Современная", "preferences.primaryCurrency": "Основная валюта", - "preferences.privacy": "Конфиденциальность", "preferences.privacy.appLock": "Заблокировать приложение", "preferences.privacy.appLock.description#Android": "Требовать биометрическую аутентификацию для открытия приложения", "preferences.privacy.appLock.description#iOS": "Требовать Face ID или Touch ID для открытия приложения", "preferences.privacy.appLock.lockAfterClosing": "Блокировать после закрытия", "preferences.privacy.maskAtShake": "Маскировать цифры (*) при встряхивании устройства", "preferences.privacy.maskAtStartup": "Маскировать числа (*) при запуске", + "preferences.privacyAndSecurity": "Конфиденциальность и безопасность", "preferences.reminders": "Напоминание", "preferences.reminders.remindDaily": "Напоминать ежедневно", "preferences.reminders.remindDaily.description": "Напоминать ежедневно отслеживать расходы", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Может быть хаотично при длинных чеках", "preferences.scan.markPendingThreshold": "Помечать транзакции как ожидающие", "preferences.scan.markPendingThreshold.description": "Если опция отключена, транзакции с распознанной датой старше 6 часов всё равно будут помечены как ожидающие", - "preferences.sync": "Синхронизация", + "preferences.sync": "Синхронизация и резервное копирование", "preferences.sync.autoBackup": "Авто-резервное копирование", "preferences.sync.autoBackup.disabled": "Отключить", "preferences.sync.autoBackup.interval": "Интервал резервного копирования", @@ -790,6 +796,18 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Посмотреть обзор месяца", "tabs.stats.analytics.wrapped.tileTitle": "Ваш {month}: итоги", "tabs.stats.analytics.wrapped.title": "{month}: итоги", + "tabs.stats.byGroup.accountCount": "{count} счетов", + "tabs.stats.byGroup.accountCount.few": "{count} счёта", + "tabs.stats.byGroup.accountCount.many": "{count} счетов", + "tabs.stats.byGroup.accountCount.one": "{count} счёт", + "tabs.stats.byGroup.categoryCount": "{count} категорий", + "tabs.stats.byGroup.categoryCount.few": "{count} категории", + "tabs.stats.byGroup.categoryCount.many": "{count} категорий", + "tabs.stats.byGroup.categoryCount.one": "{count} категория", + "tabs.stats.byGroup.totalEarned": "Всего получено", + "tabs.stats.byGroup.totalSpent": "Всего потрачено", + "tabs.stats.byGroup.view.chart": "Диаграмма", + "tabs.stats.byGroup.view.list": "Список", "tabs.stats.categories": "Категории", "tabs.stats.categories.seeAll": "Посмотреть все категории", "tabs.stats.categories.top": "Наибольшие расходы", @@ -814,6 +832,73 @@ "tabs.stats.trends.median.description": "Медиана суммы расходов за выбранный временной диапазон", "tabs.stats.trends.topSpendingTitles": "Частые транзакции", "tabs.stats.trends.topSpendingTitles.description": "Наиболее часто используемые названия транзакций", + "tabs.stats.worthKnowing": "Стоит знать", + "tabs.stats.worthKnowing.biggestEntries": "Крупнейшие записи", + "tabs.stats.worthKnowing.categoryDrop": "{category}: меньше на {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: было меньше на {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} от обычного.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: было {value} от обычного.", + "tabs.stats.worthKnowing.charges": "Списания", + "tabs.stats.worthKnowing.chartBefore": "раньше {amount}", + "tabs.stats.worthKnowing.chartCharges": "Каждое списание", + "tabs.stats.worthKnowing.chartMonthly": "Расходы по месяцам", + "tabs.stats.worthKnowing.chartThroughDay": "Расходы к {dayNumber}-му числу каждого месяца", + "tabs.stats.worthKnowing.chartUsual": "обычно {amount}", + "tabs.stats.worthKnowing.entries": "{count} записей", + "tabs.stats.worthKnowing.entries.few": "{count} записи", + "tabs.stats.worthKnowing.entries.one": "{count} запись", + "tabs.stats.worthKnowing.hidden": "Скрыто. Можно вернуть в карточке «Стоит знать».", + "tabs.stats.worthKnowing.hide.categoryDrop": "Не показывать снижения по категориям", + "tabs.stats.worthKnowing.hide.categorySpike": "Не показывать всплески по категориям", + "tabs.stats.worthKnowing.hide.monthPace": "Не показывать сравнения месяца", + "tabs.stats.worthKnowing.hide.newCategory": "Не показывать новые категории", + "tabs.stats.worthKnowing.hide.priceChange": "Не показывать изменения цен", + "tabs.stats.worthKnowing.hide.recurringCharge": "Не показывать подсказки о подписках", + "tabs.stats.worthKnowing.kinds": "Показывать в «Стоит знать»", + "tabs.stats.worthKnowing.lessThanUsual": "на {amount} меньше обычного", + "tabs.stats.worthKnowing.monthAbove": "{month}: на {value} выше обычного к {dayNumber}-му числу.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: на {value} выше обычного.", + "tabs.stats.worthKnowing.monthBelow": "{month}: на {value} ниже обычного к {dayNumber}-му числу.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: на {value} ниже обычного.", + "tabs.stats.worthKnowing.moreThanUsual": "на {amount} больше обычного", + "tabs.stats.worthKnowing.mostlyCategory": "В основном {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "В основном {title} ({amount}). Без этого — как обычно.", + "tabs.stats.worthKnowing.mostlyOver": "В основном {title} ({amount}). Без этого всё равно на {percent} выше.", + "tabs.stats.worthKnowing.mostlyUnder": "В основном {title} ({amount}). Без этого на {percent} ниже.", + "tabs.stats.worthKnowing.newCategory": "{value}: первые расходы за {months} мес.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: пока ничего необычного.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: ничего необычного.", + "tabs.stats.worthKnowing.priceChange": "{title}: было {previous}, стало {value}.", + "tabs.stats.worthKnowing.priceDown": "на {amount} меньше в год", + "tabs.stats.worthKnowing.priceUp": "на {amount} больше в год", + "tabs.stats.worthKnowing.recurringMonthly": "{value} похоже на ежемесячный платёж.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} около {dayNumber}-го числа · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} похоже на еженедельный платёж.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} похоже на ежегодный платёж.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} около {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Показывается, когда категория, которую вы используете почти каждый месяц, опускается ниже {percent} от своей медианы.", + "tabs.stats.worthKnowing.rule.categorySpike": "Показывается, когда категория достигает {ratio} от своей медианы за {months} прошлых мес. и в ней не меньше {count} записей.", + "tabs.stats.worthKnowing.rule.footer": "Переводы и ожидающие записи не учитываются. Посчитано на этом устройстве.", + "tabs.stats.worthKnowing.rule.monthPace": "Расходы по {date} в сравнении с медианой {months} прошлых мес. на тот же день.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Расходы за период «{month}» в сравнении с медианой {months} прошлых мес.", + "tabs.stats.worthKnowing.rule.newCategory": "Показывается, когда в категории впервые за {months} мес. появляются расходы.", + "tabs.stats.worthKnowing.rule.priceChange": "Показывается, когда регулярный платёж меняется на {percent} или больше.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Платежи с одинаковым названием, которые повторяются регулярно. Для меняющихся счетов, как коммунальные, нужно не меньше {count} платежей.", + "tabs.stats.worthKnowing.seeTransactions": "Показать эти транзакции", + "tabs.stats.worthKnowing.times": "{count} раз уже", + "tabs.stats.worthKnowing.times.few": "{count} раза уже", + "tabs.stats.worthKnowing.times.one": "{count} раз уже", + "tabs.stats.worthKnowing.track": "Сделать повторяющейся", + "tabs.stats.worthKnowing.type.categoryDrop": "Снижения по категориям", + "tabs.stats.worthKnowing.type.categorySpike": "Всплески по категориям", + "tabs.stats.worthKnowing.type.monthPace": "Сравнения месяца", + "tabs.stats.worthKnowing.type.newCategory": "Новые категории", + "tabs.stats.worthKnowing.type.priceChange": "Изменения цен", + "tabs.stats.worthKnowing.type.recurringCharge": "Подсказки о подписках", + "tabs.stats.worthKnowing.versusTypical": "{current} против обычных {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Что повлияло", + "tabs.stats.worthKnowing.why": "Почему вы это видите", "transaction": "Транзакция", "transaction.actions": "Действия", "transaction.attachments": "Файловые вложения", @@ -900,6 +985,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Это действие удалит все связанные транзакции и прекратит создание новых транзакций. Восстановление транзакций из корзины не приведет к созданию новых транзакций.", "transaction.recurring.edit": "Редактировать повторяющуюся транзакцию", "transaction.recurring.setup": "Настроить повторяющуюся транзакцию", + "transaction.recurring.variableAmount": "Сумма меняется", + "transaction.recurring.variableAmount.description": "Спрашивает сумму каждый раз", "transaction.tags": "Теги", "transaction.tags.add": "Добавить теги", "transaction.tags.contact.name": "Имя контакта", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index acff5519..b3d52679 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -47,25 +47,22 @@ "budget.categories.description": "Bu bütçeye yalnızca seçilen kategorilerdeki harcamalar dahil edilir. Tüm harcamaları saymak için hiçbirini seçmeyin.", "budget.delete": "Bütçeyi sil", "budget.delete.description": "İşlemler etkilenmeyecek. Bu işlem geri alınamaz!", - "budget.insight.nearingLimit": "{name} için {percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", - "budget.insight.onTrack": "{name} iyi gidiyor — {amount} kaldı.", - "budget.status.onTrack": "İyi gidiyor", - "budget.status.nearing": "Limite yakın", - "budget.status.over": "Limit aşıldı", - "budget.detail.history": "Son dönemler", - "budget.detail.transactions": "İşlemler", - "budget.detail.unavailable": "Bütçe rakamları şu anda kullanılamıyor.", "budget.detail.daysLeft": "{} gün kaldı", "budget.detail.daysLeft.one": "{} gün kaldı", + "budget.detail.history": "Son dönemler", "budget.detail.periodEnded": "Dönem sona erdi", - "budget.insight.over.short": "Bütçeyi {amount} aştınız. Harcamaları kısın veya limiti artırın.", + "budget.detail.transactions": "İşlemler", + "budget.detail.unavailable": "Bütçe rakamları şu anda kullanılamıyor.", + "budget.insight.nearingLimit": "{name} için {percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", "budget.insight.nearingLimit.short": "{percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", - "budget.insight.overpacing.short": "Bu hızla bütçeyi {amount} kadar aşacaksınız.", + "budget.insight.onTrack": "{name} iyi gidiyor — {amount} kaldı.", "budget.insight.onTrack.short": "İyi gidiyor — {amount} kaldı.", - "budget.insight.underspending.short": "Fazla bütçe kaldı — {amount} harcanmadı.", "budget.insight.over": "{name} bütçesini {amount} aştınız. Harcamaları kısın veya limiti artırın.", + "budget.insight.over.short": "Bütçeyi {amount} aştınız. Harcamaları kısın veya limiti artırın.", "budget.insight.overpacing": "Bu hızla {name} bütçesi {amount} kadar aşılacak.", + "budget.insight.overpacing.short": "Bu hızla bütçeyi {amount} kadar aşacaksınız.", "budget.insight.underspending": "{name} için fazla bütçe kaldı — {amount} harcanmadı.", + "budget.insight.underspending.short": "Fazla bütçe kaldı — {amount} harcanmadı.", "budget.name": "Bütçe adı", "budget.overview.allHealthy": "Her şey yolunda. İyi iş.", "budget.overview.budgetsTracked": "{count} bütçe takip ediliyor", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Otomatik yenile", "budget.renewAutomatically.description": "Dönem sona erdiğinde bütçe yeni döneme geçer. Örneğin, temmuz bütçesi ağustos bütçesine dönüşür.", "budget.scope": "Kapsam", + "budget.status.nearing": "Limite yakın", + "budget.status.onTrack": "İyi gidiyor", + "budget.status.over": "Limit aşıldı", "budgets": "Bütçeler", "budgets.new": "Yeni bütçe", "categories": "Kategori", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Bugün işlemlerinizi eklemeyi unutmayın!", "preferences": "Tercihler", "preferences.appearance": "Görünüm", - "preferences.changeVisuals": "Değiştir", + "preferences.changeVisuals": "Trend göstergeleri", "preferences.changeVisuals.arrow": "Ok", "preferences.changeVisuals.clickToChange": "Değiştirmek için oka/renge tıklayın", "preferences.changeVisuals.color": "Renk", "preferences.changeVisuals.expenseIncrease": "Gider artışı", "preferences.changeVisuals.incomeIncrease": "Gelir artışı", + "preferences.data": "Veriler", "preferences.dateFormat": "Tarih formatı", + "preferences.dateFormat.absoluteDateHeaders": "Başlıklarda tam tarihleri göster", + "preferences.dateFormat.system": "Dil varsayılanı", "preferences.feedback": "Sorunlar ve geri bildirim", "preferences.feedback.debugLogs": "Hata ayıklama günlüklerini görüntüle", - "preferences.hapticFeedback": "Düğme geri bildirimi", + "preferences.general": "Genel", "preferences.hapticFeedback.description": "Tıklama üzerine sesli/dokunsal geri bildirim", - "preferences.integrations": "Entegrasyonlar", "preferences.language": "Dil", "preferences.language.choose": "Bir dil seçin", "preferences.moneyFormatting": "Para biçimlendirme", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Mümkün olduğunca sayıları kısaltmayın", "preferences.moneyFormatting.setICUPattern": "Özel bir biçim seçin", "preferences.moneyFormatting.setICUPattern.default": "Varsayılan", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Yaklaşık tutarı göster", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Yabancı tutarları ana para biriminde de göster", + "preferences.moneyFormatting.hideZeroDecimals": "Sıfır ondalıkları gizle", + "preferences.moneyFormatting.hideZeroDecimals.description": "\"3,00\" yerine \"3\" göster", "preferences.moneyFormatting.useCurrencySymbol": "Para birimi simgesini kullan", "preferences.moneyFormatting.useCurrencySymbol.description": "örneğin, \"5$\" yerine \"5 ABD Doları\"", "preferences.numpad": "Sayısal tuş takımı", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Klasik", "preferences.numpad.layout.modern": "Modern", "preferences.primaryCurrency": "Birincil para birimi", - "preferences.privacy": "Gizlilik", "preferences.privacy.appLock": "Uygulamayı kilitle", "preferences.privacy.appLock.description#Android": "Uygulamayı açmak için biyometrik kimlik doğrulama gerektirir", "preferences.privacy.appLock.description#iOS": "Uygulamayı açmak için Face ID veya Touch ID gerektirir", "preferences.privacy.appLock.lockAfterClosing": "Kapattıktan sonra kilitle", "preferences.privacy.maskAtShake": "Cihaz sallanınca numaraları (*) ile gizle", "preferences.privacy.maskAtStartup": "Başlangıçta sayıları maskele (*)", + "preferences.privacyAndSecurity": "Gizlilik ve güvenlik", "preferences.reminders": "Hatırlatıcı", "preferences.reminders.remindDaily": "Remind daily", "preferences.reminders.remindDaily.description": "Remind to track expenses daily", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Uzun fişlerde karışıklık yaratabilir", "preferences.scan.markPendingThreshold": "İşlemleri beklemede olarak işaretle", "preferences.scan.markPendingThreshold.description": "Kapalıysa, tarih bilgisi 6 saatten eski olan işlemler yine de beklemede olarak işaretlenir.", - "preferences.sync": "Senkranizasyon", + "preferences.sync": "Senkronizasyon ve yedekleme", "preferences.sync.autoBackup": "Otomatik yedekleme", "preferences.sync.autoBackup.disabled": "Devre dışı bırak", "preferences.sync.autoBackup.interval": "Yedekleme aralığı", @@ -788,6 +794,14 @@ "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.byGroup.accountCount": "{count} hesap", + "tabs.stats.byGroup.accountCount.one": "{count} hesap", + "tabs.stats.byGroup.categoryCount": "{count} kategori", + "tabs.stats.byGroup.categoryCount.one": "{count} kategori", + "tabs.stats.byGroup.totalEarned": "Toplam gelir", + "tabs.stats.byGroup.totalSpent": "Toplam harcama", + "tabs.stats.byGroup.view.chart": "Grafik", + "tabs.stats.byGroup.view.list": "Liste", "tabs.stats.categories": "Kategoriler", "tabs.stats.categories.seeAll": "Tüm kategorileri gör", "tabs.stats.categories.top": "En fazla harcama yapılan", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "Seçilen zaman aralığındaki gider miktarının medyanı", "tabs.stats.trends.topSpendingTitles": "Sık yapılan işlemler", "tabs.stats.trends.topSpendingTitles.description": "En sık kullanılan işlem başlıkları", + "tabs.stats.worthKnowing": "Bilmekte fayda var", + "tabs.stats.worthKnowing.biggestEntries": "En büyük kayıtlar", + "tabs.stats.worthKnowing.categoryDrop": "{category} {value} düştü.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} {value} düşüktü.", + "tabs.stats.worthKnowing.categorySpike": "{category} olağanın {value} katında.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} olağanın {value} katındaydı.", + "tabs.stats.worthKnowing.charges": "Ödemeler", + "tabs.stats.worthKnowing.chartBefore": "önce {amount}", + "tabs.stats.worthKnowing.chartCharges": "Her ödeme", + "tabs.stats.worthKnowing.chartMonthly": "Aylık harcama", + "tabs.stats.worthKnowing.chartThroughDay": "Her ayın {dayNumber}. gününe kadar harcama", + "tabs.stats.worthKnowing.chartUsual": "olağan {amount}", + "tabs.stats.worthKnowing.entries": "{count} kayıt", + "tabs.stats.worthKnowing.entries.one": "{count} kayıt", + "tabs.stats.worthKnowing.hidden": "Gizlendi. Bilmekte fayda var kartından yeniden açabilirsiniz.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Kategori düşüşlerini gösterme", + "tabs.stats.worthKnowing.hide.categorySpike": "Kategori artışlarını gösterme", + "tabs.stats.worthKnowing.hide.monthPace": "Ay karşılaştırmalarını gösterme", + "tabs.stats.worthKnowing.hide.newCategory": "Yeni kategorileri gösterme", + "tabs.stats.worthKnowing.hide.priceChange": "Fiyat değişikliklerini gösterme", + "tabs.stats.worthKnowing.hide.recurringCharge": "Abonelik önerilerini gösterme", + "tabs.stats.worthKnowing.kinds": "Bilmekte fayda var'da göster", + "tabs.stats.worthKnowing.lessThanUsual": "olağandan {amount} az", + "tabs.stats.worthKnowing.monthAbove": "{month}, ayın {dayNumber}. günü itibarıyla olağanın {value} üzerinde.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} olağanın {value} üzerindeydi.", + "tabs.stats.worthKnowing.monthBelow": "{month}, ayın {dayNumber}. günü itibarıyla olağanın {value} altında.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} olağanın {value} altındaydı.", + "tabs.stats.worthKnowing.moreThanUsual": "olağandan {amount} fazla", + "tabs.stats.worthKnowing.mostlyCategory": "Çoğunlukla {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Çoğunlukla {title} ({amount}). O olmadan olağan seviyede.", + "tabs.stats.worthKnowing.mostlyOver": "Çoğunlukla {title} ({amount}). O olmadan yine {percent} üzerinde.", + "tabs.stats.worthKnowing.mostlyUnder": "Çoğunlukla {title} ({amount}). O olmadan {percent} altında.", + "tabs.stats.worthKnowing.newCategory": "{months} aydır ilk kez {value} harcaması.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: şimdilik olağan dışı bir şey yok.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: olağan dışı bir şey yok.", + "tabs.stats.worthKnowing.priceChange": "{title} {previous} tutarından {value} tutarına çıktı.", + "tabs.stats.worthKnowing.priceDown": "yılda {amount} daha az", + "tabs.stats.worthKnowing.priceUp": "yılda {amount} daha fazla", + "tabs.stats.worthKnowing.recurringMonthly": "{value} aylık bir ödeme gibi görünüyor.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount}, ayın {dayNumber}. günü civarı · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} haftalık bir ödeme gibi görünüyor.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, her {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} yıllık bir ödeme gibi görünüyor.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount}, {date} civarı · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Neredeyse her ay kullandığınız bir kategori medyanının {percent} altına düştüğünde gösterilir.", + "tabs.stats.worthKnowing.rule.categorySpike": "Bir kategori önceki {months} ayın medyanının {ratio} katına ulaştığında ve en az {count} kayıt olduğunda gösterilir.", + "tabs.stats.worthKnowing.rule.footer": "Transferler ve bekleyen kayıtlar hariçtir. Bu cihazda hesaplandı.", + "tabs.stats.worthKnowing.rule.monthPace": "{date} tarihine kadarki harcama, önceki {months} ayın aynı güne kadarki medyanıyla karşılaştırıldı.", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month} harcaması, önceki {months} ayın medyanıyla karşılaştırıldı.", + "tabs.stats.worthKnowing.rule.newCategory": "Bir kategoride {months} aydır ilk kez harcama olduğunda gösterilir.", + "tabs.stats.worthKnowing.rule.priceChange": "Düzenli bir ödeme {percent} veya daha fazla değiştiğinde gösterilir.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Aynı başlıklı ve düzenli tekrarlanan ödemeler. Faturalar gibi değişken tutarlı ödemeler için en az {count} ödeme gerekir.", + "tabs.stats.worthKnowing.seeTransactions": "Bu işlemleri gör", + "tabs.stats.worthKnowing.times": "şimdiye kadar {count} kez", + "tabs.stats.worthKnowing.times.one": "şimdiye kadar {count} kez", + "tabs.stats.worthKnowing.track": "Yinelenen olarak takip et", + "tabs.stats.worthKnowing.type.categoryDrop": "Kategori düşüşleri", + "tabs.stats.worthKnowing.type.categorySpike": "Kategori artışları", + "tabs.stats.worthKnowing.type.monthPace": "Ay karşılaştırmaları", + "tabs.stats.worthKnowing.type.newCategory": "Yeni kategoriler", + "tabs.stats.worthKnowing.type.priceChange": "Fiyat değişiklikleri", + "tabs.stats.worthKnowing.type.recurringCharge": "Abonelik önerileri", + "tabs.stats.worthKnowing.versusTypical": "{current} · tipik {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Etkileyenler", + "tabs.stats.worthKnowing.why": "Bunu neden görüyorsunuz", "transaction": "İşlem", "transaction.actions": "Eylemler", "transaction.attachments": "Dosya ekleri", @@ -875,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Bu işlem, ilgili tüm işlemleri silecek ve yeni işlemlerin oluşturulmasını durduracaktır. İşlemleri çöp kutusundan kurtarmak yeni işlemlerin oluşturulmasına neden olmayacaktır.", "transaction.recurring.edit": "Yinelenen işlemi düzenle", "transaction.recurring.setup": "Yinelenen işlemi ayarlayın", + "transaction.recurring.variableAmount": "Tutar değişken", + "transaction.recurring.variableAmount.description": "Her seferinde tutarı sorar", "transaction.tags": "Etiketler", "transaction.tags.add": "Etiket ekle", "transaction.tags.contact.name": "Kişi adı", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 0d897f37..bc8f298a 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -47,25 +47,22 @@ "budget.categories.description": "До цього бюджету входять лише витрати вибраних категорій. Не вибирайте нічого, щоб враховувати всі витрати.", "budget.delete": "Видалити бюджет", "budget.delete.description": "Транзакції не будуть змінені. Ця дія незворотна!", - "budget.insight.nearingLimit": "{name} використано на {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", - "budget.insight.onTrack": "{name} в рамках бюджету — залишилося {amount}.", - "budget.status.onTrack": "У межах ліміту", - "budget.status.nearing": "Близько до ліміту", - "budget.status.over": "Ліміт перевищено", - "budget.detail.history": "Останні періоди", - "budget.detail.transactions": "Транзакції", - "budget.detail.unavailable": "Дані бюджету зараз недоступні.", "budget.detail.daysLeft": "Залишилося {} дн.", "budget.detail.daysLeft.one": "Залишився {} день", + "budget.detail.history": "Останні періоди", "budget.detail.periodEnded": "Період завершено", - "budget.insight.over.short": "Ви перевищили бюджет на {amount}. Зменшіть витрати або підвищіть ліміт.", + "budget.detail.transactions": "Транзакції", + "budget.detail.unavailable": "Дані бюджету зараз недоступні.", + "budget.insight.nearingLimit": "{name} використано на {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", "budget.insight.nearingLimit.short": "Використано {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", - "budget.insight.overpacing.short": "При такому темпі ви перевищите ліміт на {amount}.", + "budget.insight.onTrack": "{name} в рамках бюджету — залишилося {amount}.", "budget.insight.onTrack.short": "У рамках бюджету — залишилося {amount}.", - "budget.insight.underspending.short": "Є запас — не витрачено {amount}.", "budget.insight.over": "Ви перевищили бюджет {name} на {amount}. Зменшіть витрати або підвищіть ліміт.", + "budget.insight.over.short": "Ви перевищили бюджет на {amount}. Зменшіть витрати або підвищіть ліміт.", "budget.insight.overpacing": "При такому темпі {name} перевищить ліміт на {amount}.", + "budget.insight.overpacing.short": "При такому темпі ви перевищите ліміт на {amount}.", "budget.insight.underspending": "{name} має запас — не витрачено {amount}.", + "budget.insight.underspending.short": "Є запас — не витрачено {amount}.", "budget.name": "Назва бюджету", "budget.overview.allHealthy": "Усе йде за планом. Чудова робота.", "budget.overview.budgetsTracked": "Відстежується {count} бюджетів", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Поновлювати автоматично", "budget.renewAutomatically.description": "Коли період закінчується, бюджет переходить на новий період. Наприклад, бюджет липня стає бюджетом серпня.", "budget.scope": "Охоплення", + "budget.status.nearing": "Близько до ліміту", + "budget.status.onTrack": "У межах ліміту", + "budget.status.over": "Ліміт перевищено", "budgets": "Бюджети", "budgets.new": "Новий бюджет", "categories": "Категорії", @@ -357,18 +357,20 @@ "notifications.reminderText#7": "Не забудьте додати свої транзакції сьогодні!", "preferences": "Налаштування", "preferences.appearance": "Зовнішній вигляд", - "preferences.changeVisuals": "Змінити", + "preferences.changeVisuals": "Індикатори тренду", "preferences.changeVisuals.arrow": "Стрілка", "preferences.changeVisuals.clickToChange": "Натисніть на стрілку/колір, щоб змінити", "preferences.changeVisuals.color": "Колір", "preferences.changeVisuals.expenseIncrease": "Зростання витрат", "preferences.changeVisuals.incomeIncrease": "Зростання доходу", + "preferences.data": "Дані", "preferences.dateFormat": "Формат дати", + "preferences.dateFormat.absoluteDateHeaders": "Показувати точні дати в заголовках", + "preferences.dateFormat.system": "Як у мові", "preferences.feedback": "Проблеми та відгуки", "preferences.feedback.debugLogs": "Переглянути налагоджувальні журнали", - "preferences.hapticFeedback": "Тактильний відгук", + "preferences.general": "Загальні", "preferences.hapticFeedback.description": "Звуковий/тактильний відгук при натисканні", - "preferences.integrations": "Інтеграції", "preferences.language": "Мова", "preferences.language.choose": "Виберіть мову", "preferences.moneyFormatting": "Форматування грошей", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "Не скорочувати числа, коли це можливо", "preferences.moneyFormatting.setICUPattern": "Виберіть користувацький формат", "preferences.moneyFormatting.setICUPattern.default": "За замовчуванням", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Показувати приблизну суму", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Також показувати іноземні суми в основній валюті", + "preferences.moneyFormatting.hideZeroDecimals": "Приховувати нульові дроби", + "preferences.moneyFormatting.hideZeroDecimals.description": "Показувати «3» замість «3,00»", "preferences.moneyFormatting.useCurrencySymbol": "Використовувати символ валюти", "preferences.moneyFormatting.useCurrencySymbol.description": "наприклад, \"$5\" замість \"5USD\"", "preferences.numpad": "Цифрова клавіатура", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "Класична", "preferences.numpad.layout.modern": "Сучасна", "preferences.primaryCurrency": "Основна валюта", - "preferences.privacy": "Конфіденційність", "preferences.privacy.appLock": "Заблокувати програму", "preferences.privacy.appLock.description#Android": "Вимагати біометричну автентифікацію для відкриття програми", "preferences.privacy.appLock.description#iOS": "Вимагати Face ID або Touch ID для відкриття програми", "preferences.privacy.appLock.lockAfterClosing": "Блокувати після закриття", "preferences.privacy.maskAtShake": "Маскувати числа (*) при струшуванні пристрою", "preferences.privacy.maskAtStartup": "Маскувати числа (*) при запуску", + "preferences.privacyAndSecurity": "Конфіденційність і безпека", "preferences.reminders": "Нагадування", "preferences.reminders.remindDaily": "Нагадувати щодня", "preferences.reminders.remindDaily.description": "Нагадувати щодня відстежувати витрати", @@ -401,7 +407,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Може бути хаотично для довгих чеків", "preferences.scan.markPendingThreshold": "Позначати транзакції як очікувані", "preferences.scan.markPendingThreshold.description": "Якщо вимкнено, транзакції з датою, розпізнаною більше ніж 6 годин тому, все одно будуть позначені як очікувані", - "preferences.sync": "Синхронізація", + "preferences.sync": "Синхронізація та резервне копіювання", "preferences.sync.autoBackup": "Автоматичне резервне копіювання", "preferences.sync.autoBackup.disabled": "Вимкнути", "preferences.sync.autoBackup.interval": "Інтервал резервного копіювання", @@ -790,6 +796,18 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Перегляньте підсумки місяця", "tabs.stats.analytics.wrapped.tileTitle": "Ваш підсумок за {month}", "tabs.stats.analytics.wrapped.title": "{month}: підсумок", + "tabs.stats.byGroup.accountCount": "{count} рахунків", + "tabs.stats.byGroup.accountCount.few": "{count} рахунки", + "tabs.stats.byGroup.accountCount.many": "{count} рахунків", + "tabs.stats.byGroup.accountCount.one": "{count} рахунок", + "tabs.stats.byGroup.categoryCount": "{count} категорій", + "tabs.stats.byGroup.categoryCount.few": "{count} категорії", + "tabs.stats.byGroup.categoryCount.many": "{count} категорій", + "tabs.stats.byGroup.categoryCount.one": "{count} категорія", + "tabs.stats.byGroup.totalEarned": "Усього отримано", + "tabs.stats.byGroup.totalSpent": "Усього витрачено", + "tabs.stats.byGroup.view.chart": "Діаграма", + "tabs.stats.byGroup.view.list": "Список", "tabs.stats.categories": "Категорії", "tabs.stats.categories.seeAll": "Переглянути всі категорії", "tabs.stats.categories.top": "Найбільші витрати", @@ -814,6 +832,73 @@ "tabs.stats.trends.median.description": "Медіана суми витрат за вибраний часовий діапазон", "tabs.stats.trends.topSpendingTitles": "Часті транзакції", "tabs.stats.trends.topSpendingTitles.description": "Найбільш часто використовувані назви транзакцій", + "tabs.stats.worthKnowing": "Варто знати", + "tabs.stats.worthKnowing.biggestEntries": "Найбільші записи", + "tabs.stats.worthKnowing.categoryDrop": "{category}: менше на {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: було менше на {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} від звичайного.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: було {value} від звичайного.", + "tabs.stats.worthKnowing.charges": "Списання", + "tabs.stats.worthKnowing.chartBefore": "раніше {amount}", + "tabs.stats.worthKnowing.chartCharges": "Кожне списання", + "tabs.stats.worthKnowing.chartMonthly": "Витрати за місяцями", + "tabs.stats.worthKnowing.chartThroughDay": "Витрати до {dayNumber}-го числа кожного місяця", + "tabs.stats.worthKnowing.chartUsual": "зазвичай {amount}", + "tabs.stats.worthKnowing.entries": "{count} записів", + "tabs.stats.worthKnowing.entries.few": "{count} записи", + "tabs.stats.worthKnowing.entries.one": "{count} запис", + "tabs.stats.worthKnowing.hidden": "Приховано. Можна повернути в картці «Варто знати».", + "tabs.stats.worthKnowing.hide.categoryDrop": "Не показувати зниження за категоріями", + "tabs.stats.worthKnowing.hide.categorySpike": "Не показувати сплески за категоріями", + "tabs.stats.worthKnowing.hide.monthPace": "Не показувати порівняння місяця", + "tabs.stats.worthKnowing.hide.newCategory": "Не показувати нові категорії", + "tabs.stats.worthKnowing.hide.priceChange": "Не показувати зміни цін", + "tabs.stats.worthKnowing.hide.recurringCharge": "Не показувати підказки про підписки", + "tabs.stats.worthKnowing.kinds": "Показувати у «Варто знати»", + "tabs.stats.worthKnowing.lessThanUsual": "на {amount} менше, ніж зазвичай", + "tabs.stats.worthKnowing.monthAbove": "{month}: на {value} вище звичайного до {dayNumber}-го числа.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: на {value} вище звичайного.", + "tabs.stats.worthKnowing.monthBelow": "{month}: на {value} нижче звичайного до {dayNumber}-го числа.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: на {value} нижче звичайного.", + "tabs.stats.worthKnowing.moreThanUsual": "на {amount} більше, ніж зазвичай", + "tabs.stats.worthKnowing.mostlyCategory": "Здебільшого {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Здебільшого {title} ({amount}). Без цього — як зазвичай.", + "tabs.stats.worthKnowing.mostlyOver": "Здебільшого {title} ({amount}). Без цього все одно на {percent} вище.", + "tabs.stats.worthKnowing.mostlyUnder": "Здебільшого {title} ({amount}). Без цього на {percent} нижче.", + "tabs.stats.worthKnowing.newCategory": "{value}: перші витрати за {months} міс.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: поки нічого незвичного.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: нічого незвичного.", + "tabs.stats.worthKnowing.priceChange": "{title}: було {previous}, стало {value}.", + "tabs.stats.worthKnowing.priceDown": "на {amount} менше на рік", + "tabs.stats.worthKnowing.priceUp": "на {amount} більше на рік", + "tabs.stats.worthKnowing.recurringMonthly": "{value} схоже на щомісячний платіж.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} близько {dayNumber}-го числа · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} схоже на щотижневий платіж.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} схоже на щорічний платіж.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} близько {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "З'являється, коли категорія, якою ви користуєтеся майже щомісяця, падає нижче {percent} від своєї медіани.", + "tabs.stats.worthKnowing.rule.categorySpike": "З'являється, коли категорія досягає {ratio} від своєї медіани за {months} попередніх міс. і має щонайменше {count} записи.", + "tabs.stats.worthKnowing.rule.footer": "Перекази та очікувані записи не враховуються. Пораховано на цьому пристрої.", + "tabs.stats.worthKnowing.rule.monthPace": "Витрати до {date} порівняно з медіаною {months} попередніх міс. на той самий день.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Витрати за період «{month}» порівняно з медіаною {months} попередніх міс.", + "tabs.stats.worthKnowing.rule.newCategory": "З'являється, коли в категорії вперше за {months} міс. є витрати.", + "tabs.stats.worthKnowing.rule.priceChange": "З'являється, коли регулярний платіж змінюється на {percent} або більше.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Платежі з однаковою назвою, що регулярно повторюються. Для змінних рахунків, як комунальні, потрібно щонайменше {count} платежі.", + "tabs.stats.worthKnowing.seeTransactions": "Показати ці транзакції", + "tabs.stats.worthKnowing.times": "уже {count} разів", + "tabs.stats.worthKnowing.times.few": "уже {count} рази", + "tabs.stats.worthKnowing.times.one": "уже {count} раз", + "tabs.stats.worthKnowing.track": "Зробити повторюваною", + "tabs.stats.worthKnowing.type.categoryDrop": "Зниження за категоріями", + "tabs.stats.worthKnowing.type.categorySpike": "Сплески за категоріями", + "tabs.stats.worthKnowing.type.monthPace": "Порівняння місяця", + "tabs.stats.worthKnowing.type.newCategory": "Нові категорії", + "tabs.stats.worthKnowing.type.priceChange": "Зміни цін", + "tabs.stats.worthKnowing.type.recurringCharge": "Підказки про підписки", + "tabs.stats.worthKnowing.versusTypical": "{current} проти звичних {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Що вплинуло", + "tabs.stats.worthKnowing.why": "Чому ви це бачите", "transaction": "Транзакція", "transaction.actions": "Дії", "transaction.attachments": "Файлові вкладення", @@ -900,6 +985,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Ця дія видалить усі пов'язані транзакції та припинить створення нових транзакцій. Відновлення транзакцій з кошика не призведе до створення нових транзакцій.", "transaction.recurring.edit": "Редагувати повторювану транзакцію", "transaction.recurring.setup": "Налаштувати повторювану транзакцію", + "transaction.recurring.variableAmount": "Сума змінюється", + "transaction.recurring.variableAmount.description": "Запитує суму щоразу", "transaction.tags": "Теги", "transaction.tags.add": "Додати теги", "transaction.tags.contact.name": "Ім’я контакту", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index 5d8006ec..092d10a1 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -47,25 +47,22 @@ "budget.categories.description": "只有所选分类的支出会计入此预算。若不选择任何分类,则计入所有支出。", "budget.delete": "删除预算", "budget.delete.description": "交易不会受到影响。此操作无法撤销!", - "budget.insight.nearingLimit": "{name} 已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", - "budget.insight.onTrack": "{name} 进度正常 — 剩余 {amount}。", - "budget.status.onTrack": "进度正常", - "budget.status.nearing": "接近上限", - "budget.status.over": "超出预算", - "budget.detail.history": "最近的周期", - "budget.detail.transactions": "交易", - "budget.detail.unavailable": "当前无法获取预算数据。", "budget.detail.daysLeft": "剩余 {} 天", "budget.detail.daysLeft.one": "剩余 {} 天", + "budget.detail.history": "最近的周期", "budget.detail.periodEnded": "周期已结束", - "budget.insight.over.short": "已超出 {amount}。请减少支出或提高上限。", + "budget.detail.transactions": "交易", + "budget.detail.unavailable": "当前无法获取预算数据。", + "budget.insight.nearingLimit": "{name} 已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", "budget.insight.nearingLimit.short": "已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", - "budget.insight.overpacing.short": "照此速度,将超出 {amount}。", + "budget.insight.onTrack": "{name} 进度正常 — 剩余 {amount}。", "budget.insight.onTrack.short": "进度正常 — 剩余 {amount}。", - "budget.insight.underspending.short": "余量充足 — 尚有 {amount} 未使用。", "budget.insight.over": "你在 {name} 已超出 {amount}。请减少支出或提高上限。", + "budget.insight.over.short": "已超出 {amount}。请减少支出或提高上限。", "budget.insight.overpacing": "照此速度,{name} 将超出 {amount}。", + "budget.insight.overpacing.short": "照此速度,将超出 {amount}。", "budget.insight.underspending": "{name} 余量充足 — 尚有 {amount} 未使用。", + "budget.insight.underspending.short": "余量充足 — 尚有 {amount} 未使用。", "budget.name": "预算名称", "budget.overview.allHealthy": "一切正常,做得好。", "budget.overview.budgetsTracked": "已跟踪 {count} 个预算", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "自动续期", "budget.renewAutomatically.description": "周期结束后,预算会进入新的周期。例如,七月的预算会变成八月的预算。", "budget.scope": "范围", + "budget.status.nearing": "接近上限", + "budget.status.onTrack": "进度正常", + "budget.status.over": "超出预算", "budgets": "预算", "budgets.new": "新建预算", "categories": "分类", @@ -363,12 +363,14 @@ "preferences.changeVisuals.color": "颜色", "preferences.changeVisuals.expenseIncrease": "支出增加", "preferences.changeVisuals.incomeIncrease": "收入增加", + "preferences.data": "数据", "preferences.dateFormat": "日期格式", + "preferences.dateFormat.absoluteDateHeaders": "在列表标题中显示具体日期", + "preferences.dateFormat.system": "跟随语言", "preferences.feedback": "问题与反馈", "preferences.feedback.debugLogs": "查看调试日志", - "preferences.hapticFeedback": "按钮反馈", + "preferences.general": "常规", "preferences.hapticFeedback.description": "点击时的声音/触觉震动反馈", - "preferences.integrations": "集成服务", "preferences.language": "语言", "preferences.language.choose": "选择语言", "preferences.moneyFormatting": "金额格式", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "尽可能不缩写数字", "preferences.moneyFormatting.setICUPattern": "选择自定义格式", "preferences.moneyFormatting.setICUPattern.default": "默认", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "显示近似金额", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "同时以主要货币显示外币金额", + "preferences.moneyFormatting.hideZeroDecimals": "整数金额不显示小数", + "preferences.moneyFormatting.hideZeroDecimals.description": "显示“3”而不是“3.00”", "preferences.moneyFormatting.useCurrencySymbol": "使用货币符号", "preferences.moneyFormatting.useCurrencySymbol.description": "例如:使用“$5”取代“5USD”", "preferences.numpad": "数字键盘", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "经典", "preferences.numpad.layout.modern": "现代", "preferences.primaryCurrency": "主要币种", - "preferences.privacy": "隐私", "preferences.privacy.appLock": "锁定应用程序", "preferences.privacy.appLock.description#Android": "开启应用程序需要生物识别验证", "preferences.privacy.appLock.description#iOS": "开启应用程序需要 Face ID 或 Touch ID", "preferences.privacy.appLock.lockAfterClosing": "关闭后锁定", "preferences.privacy.maskAtShake": "摇晃设备时遮蔽数字 (*)", "preferences.privacy.maskAtStartup": "启动时遮蔽数字 (*)", + "preferences.privacyAndSecurity": "隐私与安全", "preferences.reminders": "提醒事项", "preferences.reminders.remindDaily": "每日提醒", "preferences.reminders.remindDaily.description": "提醒每天记录支出", @@ -788,6 +794,14 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "查看你的月度回顾", "tabs.stats.analytics.wrapped.tileTitle": "你的 {month} 回顾", "tabs.stats.analytics.wrapped.title": "{month} 回顾", + "tabs.stats.byGroup.accountCount": "{count} 个账户", + "tabs.stats.byGroup.accountCount.one": "{count} 个账户", + "tabs.stats.byGroup.categoryCount": "{count} 个分类", + "tabs.stats.byGroup.categoryCount.one": "{count} 个分类", + "tabs.stats.byGroup.totalEarned": "总收入", + "tabs.stats.byGroup.totalSpent": "总支出", + "tabs.stats.byGroup.view.chart": "图表", + "tabs.stats.byGroup.view.list": "列表", "tabs.stats.categories": "分类", "tabs.stats.categories.seeAll": "查看所有分类", "tabs.stats.categories.top": "最高支出", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "所选时间范围内的支出金额中位数", "tabs.stats.trends.topSpendingTitles": "频繁交易", "tabs.stats.trends.topSpendingTitles.description": "最常使用的交易标题", + "tabs.stats.worthKnowing": "值得了解", + "tabs.stats.worthKnowing.biggestEntries": "最大的几笔", + "tabs.stats.worthKnowing.categoryDrop": "{category} 下降了 {value}。", + "tabs.stats.worthKnowing.categoryDropPast": "{category} 下降了 {value}。", + "tabs.stats.worthKnowing.categorySpike": "{category} 是平时的 {value}。", + "tabs.stats.worthKnowing.categorySpikePast": "{category} 曾是平时的 {value}。", + "tabs.stats.worthKnowing.charges": "扣款", + "tabs.stats.worthKnowing.chartBefore": "之前 {amount}", + "tabs.stats.worthKnowing.chartCharges": "每笔扣款", + "tabs.stats.worthKnowing.chartMonthly": "每月支出", + "tabs.stats.worthKnowing.chartThroughDay": "每月截至 {dayNumber} 日的支出", + "tabs.stats.worthKnowing.chartUsual": "平时 {amount}", + "tabs.stats.worthKnowing.entries": "{count} 笔", + "tabs.stats.worthKnowing.entries.one": "{count} 笔", + "tabs.stats.worthKnowing.hidden": "已隐藏。可在「值得了解」中重新开启。", + "tabs.stats.worthKnowing.hide.categoryDrop": "不显示分类下降", + "tabs.stats.worthKnowing.hide.categorySpike": "不显示分类激增", + "tabs.stats.worthKnowing.hide.monthPace": "不显示月度对比", + "tabs.stats.worthKnowing.hide.newCategory": "不显示新分类", + "tabs.stats.worthKnowing.hide.priceChange": "不显示价格变动", + "tabs.stats.worthKnowing.hide.recurringCharge": "不显示订阅建议", + "tabs.stats.worthKnowing.kinds": "在「值得了解」中显示", + "tabs.stats.worthKnowing.lessThanUsual": "比平时少 {amount}", + "tabs.stats.worthKnowing.monthAbove": "{month}截至 {dayNumber} 日比平时高 {value}。", + "tabs.stats.worthKnowing.monthAbovePast": "{month}比平时高 {value}。", + "tabs.stats.worthKnowing.monthBelow": "{month}截至 {dayNumber} 日比平时低 {value}。", + "tabs.stats.worthKnowing.monthBelowPast": "{month}比平时低 {value}。", + "tabs.stats.worthKnowing.moreThanUsual": "比平时多 {amount}", + "tabs.stats.worthKnowing.mostlyCategory": "主要是 {category},{difference}。", + "tabs.stats.worthKnowing.mostlyEven": "主要是 {title}({amount})。除去它,与平时相当。", + "tabs.stats.worthKnowing.mostlyOver": "主要是 {title}({amount})。除去它,仍高出 {percent}。", + "tabs.stats.worthKnowing.mostlyUnder": "主要是 {title}({amount})。除去它,低 {percent}。", + "tabs.stats.worthKnowing.newCategory": "{months} 个月来首次在 {value} 上支出。", + "tabs.stats.worthKnowing.nothingUnusual": "{month}目前没有异常。", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}没有异常。", + "tabs.stats.worthKnowing.priceChange": "{title} 从 {previous} 变为 {value}。", + "tabs.stats.worthKnowing.priceDown": "每年少 {amount}", + "tabs.stats.worthKnowing.priceUp": "每年多 {amount}", + "tabs.stats.worthKnowing.recurringMonthly": "{value} 看起来是每月扣款。", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount},约每月 {dayNumber} 日 · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} 看起来是每周扣款。", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount},每{weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} 看起来是每年扣款。", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount},约 {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "当几乎每月都用的分类低于其中位数的 {percent} 时显示。", + "tabs.stats.worthKnowing.rule.categorySpike": "当某分类达到前 {months} 个月中位数的 {ratio},且至少有 {count} 笔时显示。", + "tabs.stats.worthKnowing.rule.footer": "不含转账和待处理记录。在本设备上计算。", + "tabs.stats.worthKnowing.rule.monthPace": "截至 {date} 的支出,与前 {months} 个月同一天的中位数对比。", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month}的支出,与前 {months} 个月的中位数对比。", + "tabs.stats.worthKnowing.rule.newCategory": "当某分类 {months} 个月来首次有支出时显示。", + "tabs.stats.worthKnowing.rule.priceChange": "当定期扣款变动 {percent} 或以上时显示。", + "tabs.stats.worthKnowing.rule.recurringCharge": "标题相同、按固定周期重复的扣款。金额浮动的账单(如水电费)至少需要 {count} 次扣款。", + "tabs.stats.worthKnowing.seeTransactions": "查看这些交易", + "tabs.stats.worthKnowing.times": "已 {count} 次", + "tabs.stats.worthKnowing.times.one": "已 {count} 次", + "tabs.stats.worthKnowing.track": "设为周期性交易", + "tabs.stats.worthKnowing.type.categoryDrop": "分类下降", + "tabs.stats.worthKnowing.type.categorySpike": "分类激增", + "tabs.stats.worthKnowing.type.monthPace": "月度对比", + "tabs.stats.worthKnowing.type.newCategory": "新分类", + "tabs.stats.worthKnowing.type.priceChange": "价格变动", + "tabs.stats.worthKnowing.type.recurringCharge": "订阅建议", + "tabs.stats.worthKnowing.versusTypical": "{current},平时 {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "主要原因", + "tabs.stats.worthKnowing.why": "为什么会看到这个", "transaction": "交易", "transaction.actions": "动作", "transaction.attachments": "文件附件", @@ -875,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "这将删除所有相关的交易,并停止创建新交易。即使从回收站恢复交易,也不会让它重新开始创建新交易。", "transaction.recurring.edit": "编辑周期性交易", "transaction.recurring.setup": "设置周期性交易", + "transaction.recurring.variableAmount": "金额不固定", + "transaction.recurring.variableAmount.description": "每次询问金额", "transaction.tags": "标签", "transaction.tags.add": "添加标签", "transaction.tags.contact.name": "联系人名称", @@ -946,4 +1027,4 @@ "transactions.query.noResult": "没有可显示的交易", "transactions.query.noResult.description": "尝试更新您的过滤条件", "visitGitHubRepo": "前往 GitHub 查看" -} +} \ No newline at end of file diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index c7fa9dfa..20fb496b 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -47,25 +47,22 @@ "budget.categories.description": "只有所選分類的支出會計入此預算。若未選擇任何分類,則計入所有支出。", "budget.delete": "刪除預算", "budget.delete.description": "交易不會受到影響。此操作無法復原!", - "budget.insight.nearingLimit": "{name} 已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", - "budget.insight.onTrack": "{name} 進度正常 — {amount} 剩餘。", - "budget.status.onTrack": "進度正常", - "budget.status.nearing": "接近上限", - "budget.status.over": "超出預算", - "budget.detail.history": "最近的期間", - "budget.detail.transactions": "交易", - "budget.detail.unavailable": "目前無法取得預算數字。", "budget.detail.daysLeft": "剩餘 {} 天", "budget.detail.daysLeft.one": "剩餘 {} 天", + "budget.detail.history": "最近的期間", "budget.detail.periodEnded": "期間已結束", - "budget.insight.over.short": "已超出 {amount}。請減少支出或提高上限。", + "budget.detail.transactions": "交易", + "budget.detail.unavailable": "目前無法取得預算數字。", + "budget.insight.nearingLimit": "{name} 已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", "budget.insight.nearingLimit.short": "已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", - "budget.insight.overpacing.short": "照此速度,將超出 {amount}。", + "budget.insight.onTrack": "{name} 進度正常 — {amount} 剩餘。", "budget.insight.onTrack.short": "進度正常 — {amount} 剩餘。", - "budget.insight.underspending.short": "空間充足 — 尚有 {amount} 未使用。", "budget.insight.over": "你在 {name} 超出 {amount}。請減少支出或提高上限。", + "budget.insight.over.short": "已超出 {amount}。請減少支出或提高上限。", "budget.insight.overpacing": "照此速度,{name} 將超出 {amount}。", + "budget.insight.overpacing.short": "照此速度,將超出 {amount}。", "budget.insight.underspending": "{name} 空間充足 — 尚有 {amount} 未使用。", + "budget.insight.underspending.short": "空間充足 — 尚有 {amount} 未使用。", "budget.name": "預算名稱", "budget.overview.allHealthy": "一切正常,做得好。", "budget.overview.budgetsTracked": "追蹤 {count} 個預算", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "自動續期", "budget.renewAutomatically.description": "期間結束後,預算會進入新的期間。例如,七月的預算會變成八月的預算。", "budget.scope": "範圍", + "budget.status.nearing": "接近上限", + "budget.status.onTrack": "進度正常", + "budget.status.over": "超出預算", "budgets": "預算", "budgets.new": "新增預算", "categories": "分類", @@ -240,8 +240,8 @@ "enum.TransactionType@expense": "支出", "enum.TransactionType@income": "收入", "enum.TransactionType@transfer": "轉帳", - "error.exchangeRates.cannotFetch": "獲取失敗,請檢查您的網路連線。", - "error.exchangeRates.inaccurateDataDueToMissingRates": "獲取匯率失敗,交易資料可能無法完全準確。", + "error.exchangeRates.cannotFetch": "取得失敗,請檢查您的網路連線。", + "error.exchangeRates.inaccurateDataDueToMissingRates": "取得匯率失敗,交易資料可能無法完全準確。", "error.failedLocalAuth": "身分驗證失敗,請再試一次。", "error.input.cropFailed": "裁切圖片時發生錯誤", "error.input.duplicate.accountName": "名稱「{}」已被使用。請嘗試其他名稱。", @@ -277,7 +277,7 @@ "fileAttachment.pick": "選擇檔案", "fileAttachment.share": "分享檔案", "fileAttachment.takePhoto": "拍照", - "flowIcon.change": "更改圖示", + "flowIcon.change": "變更圖示", "flowIcon.type.character": "表情符號/字母", "flowIcon.type.character.description": "輸入表情符號或字母以作為圖示", "flowIcon.type.icon": "圖示", @@ -363,12 +363,14 @@ "preferences.changeVisuals.color": "顏色", "preferences.changeVisuals.expenseIncrease": "支出增加", "preferences.changeVisuals.incomeIncrease": "收入增加", + "preferences.data": "資料", "preferences.dateFormat": "日期格式", + "preferences.dateFormat.absoluteDateHeaders": "在列表標題中顯示具體日期", + "preferences.dateFormat.system": "跟隨語言", "preferences.feedback": "問題與回饋", "preferences.feedback.debugLogs": "檢視偵錯日誌", - "preferences.hapticFeedback": "按鈕回饋", + "preferences.general": "一般", "preferences.hapticFeedback.description": "點擊時的聲音/觸覺震動回饋", - "preferences.integrations": "整合服務", "preferences.language": "語言", "preferences.language.choose": "選擇語言", "preferences.moneyFormatting": "金額格式", @@ -376,6 +378,10 @@ "preferences.moneyFormatting.preferFull.description": "盡可能不縮寫數字", "preferences.moneyFormatting.setICUPattern": "選擇自訂格式", "preferences.moneyFormatting.setICUPattern.default": "預設", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "顯示約略金額", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "同時以主要貨幣顯示外幣金額", + "preferences.moneyFormatting.hideZeroDecimals": "整數金額不顯示小數", + "preferences.moneyFormatting.hideZeroDecimals.description": "顯示「3」而非「3.00」", "preferences.moneyFormatting.useCurrencySymbol": "使用貨幣符號", "preferences.moneyFormatting.useCurrencySymbol.description": "例如:使用「$5」取代「5USD」", "preferences.numpad": "數字鍵盤", @@ -383,13 +389,13 @@ "preferences.numpad.layout.classic": "經典", "preferences.numpad.layout.modern": "現代", "preferences.primaryCurrency": "主要幣別", - "preferences.privacy": "隱私", "preferences.privacy.appLock": "鎖定應用程式", "preferences.privacy.appLock.description#Android": "開啟應用程式需要生物辨識驗證", "preferences.privacy.appLock.description#iOS": "開啟應用程式需要 Face ID 或 Touch ID", "preferences.privacy.appLock.lockAfterClosing": "關閉後鎖定", "preferences.privacy.maskAtShake": "搖晃裝置時遮蔽數字 (*)", "preferences.privacy.maskAtStartup": "啟動時遮蔽數字 (*)", + "preferences.privacyAndSecurity": "隱私與安全", "preferences.reminders": "提醒事項", "preferences.reminders.remindDaily": "每日提醒", "preferences.reminders.remindDaily.description": "提醒每天記錄支出", @@ -417,7 +423,7 @@ "preferences.sync.iCloud.noOfBackupsToKeep.description": "在 iCloud 中保留備份檔案的最大數量。這將佔用您的 iCloud 空間,因此您可能希望將此數字設低一些。在每次啟動時會根據此設定刪除舊備份。", "preferences.sync.iCloud.noOfBackupsToKeep.infiniteBackups": "無限數量", "preferences.sync.iCloud.noOfBackupsToKeep.nBackups": "{n} 個備份", - "preferences.sync.iCloud.privacyNotice": "您的資料存儲在您的 iCloud 內一個僅供 Flow 存取的私人空間。其他人無法存取您的資料,Flow 亦無法存取您 iCloud 中的其他資料。", + "preferences.sync.iCloud.privacyNotice": "您的資料儲存在您的 iCloud 內一個僅供 Flow 存取的私人空間。其他人無法存取您的資料,Flow 亦無法存取您 iCloud 中的其他資料。", "preferences.sync.iCloud.singleDeviceSupportDisclaimer": "此功能不支援在多個裝置上同步。使用多個裝置可能導致資料遺失。", "preferences.theme": "主題", "preferences.theme.choose": "選擇主題", @@ -425,7 +431,7 @@ "preferences.theme.enableOledTheme": "使用 OLED 深色主題", "preferences.theme.other": "其他主題", "preferences.theme.themeChangesAppIcon": "應用程式圖示跟隨主題變更", - "preferences.transactionButtonOrder": "按鈕配置", + "preferences.transactionButtonOrder": "按鈕順序", "preferences.transactionButtonOrder.description": "變更首頁新增交易按鈕的順序", "preferences.transactionButtonOrder.guide": "拖曳按鈕以重新排序", "preferences.transactionButtonOrder.widgetDescription": "此順序將會反映在主畫面的交易按鈕小工具上", @@ -568,24 +574,24 @@ "setup.onboarding.recoverICloudBackup.description.loading": "從 iCloud 復原 (正在載入資料...)", "setup.onboarding.recoverICloudBackup.description.none": "從 iCloud 復原 (找不到備份)", "setup.primaryCurrency.choose": "選擇幣別", - "setup.primaryCurrency.description": "這將成為您的主要幣別。您稍後可以在「偏好設定」中更改。", + "setup.primaryCurrency.description": "這將成為您的主要幣別。您稍後可以在「偏好設定」中變更。", "setup.primaryCurrency.setup": "選擇主要幣別", "setup.profile.addPhoto": "新增相片", "setup.profile.addPhoto.skip": "略過", "setup.profile.setup": "您叫什麼名字?", - "setup.slides.foss.description": "完全免費且開源代碼公開。", + "setup.slides.foss.description": "完全免費且原始碼公開。", "setup.slides.foss.seeRepo": "在 GitHub 上查看專案", "setup.slides.foss.title": "免費 & 開源", "setup.slides.privacy": "完全掌控您的資料", "setup.slides.privacy.description": "您的所有資料僅儲存在本機(或您的私人雲端空間),並提供完整的資料匯出選項。", "setup.transactionTags": "選擇標籤", - "support": "支援", + "support": "支持", "support.contribute": "貢獻程式碼", "support.contribute.description": "如果您是一位開發者,您可以參與 Flow 的開發。貢獻者名單等待您的加入。", "support.description": "Flow 是一款出於熱忱、免費且為所有人開源的軟體。如果您認為 Flow 很有幫助,考慮協助專案成長!您可以透過以下方式支持我們:", - "support.donateDeveloper": "打賞開發者", + "support.donateDeveloper": "贊助開發者", "support.donateDeveloper.action": "請創作者喝杯咖啡", - "support.donateDeveloper.description": "Flow 的所有功能均免費提供,打賞開發者不會解鎖任何額外功能", + "support.donateDeveloper.description": "Flow 的所有功能均免費提供,贊助開發者不會解鎖任何額外功能", "support.leaveAReview": "留下評論", "support.leaveAReview.action": "為 Flow 評分", "support.leaveAReview.description": "您可以在 {appStore} 為 Flow 評分並留下您的評論", @@ -667,7 +673,7 @@ "tabs.home.last7days": "過去 7 天", "tabs.home.noTransactions": "沒有符合條件的交易", "tabs.home.noTransactions.addSome": "點擊下方的 (+) 按鈕新增一筆交易", - "tabs.home.noTransactions.tryChangingFilters": "嘗試更改過濾條件", + "tabs.home.noTransactions.tryChangingFilters": "嘗試變更過濾條件", "tabs.home.pendingTransactions": "待處理 ({count})", "tabs.home.pendingTransactions.needAttention": "有 {} 筆交易需要確認", "tabs.home.pendingTransactions.seeAll": "查看全部", @@ -788,6 +794,14 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "查看你的月度回顧", "tabs.stats.analytics.wrapped.tileTitle": "你的 {month} 回顧", "tabs.stats.analytics.wrapped.title": "{month} 回顧", + "tabs.stats.byGroup.accountCount": "{count} 個帳戶", + "tabs.stats.byGroup.accountCount.one": "{count} 個帳戶", + "tabs.stats.byGroup.categoryCount": "{count} 個分類", + "tabs.stats.byGroup.categoryCount.one": "{count} 個分類", + "tabs.stats.byGroup.totalEarned": "總收入", + "tabs.stats.byGroup.totalSpent": "總支出", + "tabs.stats.byGroup.view.chart": "圖表", + "tabs.stats.byGroup.view.list": "列表", "tabs.stats.categories": "分類", "tabs.stats.categories.seeAll": "查看所有分類", "tabs.stats.categories.top": "最高支出", @@ -812,6 +826,71 @@ "tabs.stats.trends.median.description": "所選時間範圍內的支出金額中位數", "tabs.stats.trends.topSpendingTitles": "頻繁交易", "tabs.stats.trends.topSpendingTitles.description": "最常使用的交易標題", + "tabs.stats.worthKnowing": "值得了解", + "tabs.stats.worthKnowing.biggestEntries": "最大的幾筆", + "tabs.stats.worthKnowing.categoryDrop": "{category} 下降了 {value}。", + "tabs.stats.worthKnowing.categoryDropPast": "{category} 下降了 {value}。", + "tabs.stats.worthKnowing.categorySpike": "{category} 是平時的 {value}。", + "tabs.stats.worthKnowing.categorySpikePast": "{category} 曾是平時的 {value}。", + "tabs.stats.worthKnowing.charges": "扣款", + "tabs.stats.worthKnowing.chartBefore": "之前 {amount}", + "tabs.stats.worthKnowing.chartCharges": "每筆扣款", + "tabs.stats.worthKnowing.chartMonthly": "每月支出", + "tabs.stats.worthKnowing.chartThroughDay": "每月截至 {dayNumber} 日的支出", + "tabs.stats.worthKnowing.chartUsual": "平時 {amount}", + "tabs.stats.worthKnowing.entries": "{count} 筆", + "tabs.stats.worthKnowing.entries.one": "{count} 筆", + "tabs.stats.worthKnowing.hidden": "已隱藏。可在「值得了解」中重新開啟。", + "tabs.stats.worthKnowing.hide.categoryDrop": "不顯示分類下降", + "tabs.stats.worthKnowing.hide.categorySpike": "不顯示分類激增", + "tabs.stats.worthKnowing.hide.monthPace": "不顯示月度比較", + "tabs.stats.worthKnowing.hide.newCategory": "不顯示新分類", + "tabs.stats.worthKnowing.hide.priceChange": "不顯示價格變動", + "tabs.stats.worthKnowing.hide.recurringCharge": "不顯示訂閱建議", + "tabs.stats.worthKnowing.kinds": "在「值得了解」中顯示", + "tabs.stats.worthKnowing.lessThanUsual": "比平時少 {amount}", + "tabs.stats.worthKnowing.monthAbove": "{month}截至 {dayNumber} 日比平時高 {value}。", + "tabs.stats.worthKnowing.monthAbovePast": "{month}比平時高 {value}。", + "tabs.stats.worthKnowing.monthBelow": "{month}截至 {dayNumber} 日比平時低 {value}。", + "tabs.stats.worthKnowing.monthBelowPast": "{month}比平時低 {value}。", + "tabs.stats.worthKnowing.moreThanUsual": "比平時多 {amount}", + "tabs.stats.worthKnowing.mostlyCategory": "主要是 {category},{difference}。", + "tabs.stats.worthKnowing.mostlyEven": "主要是 {title}({amount})。除去它,與平時相當。", + "tabs.stats.worthKnowing.mostlyOver": "主要是 {title}({amount})。除去它,仍高出 {percent}。", + "tabs.stats.worthKnowing.mostlyUnder": "主要是 {title}({amount})。除去它,低 {percent}。", + "tabs.stats.worthKnowing.newCategory": "{months} 個月來首次在 {value} 上支出。", + "tabs.stats.worthKnowing.nothingUnusual": "{month}目前沒有異常。", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}沒有異常。", + "tabs.stats.worthKnowing.priceChange": "{title} 從 {previous} 變為 {value}。", + "tabs.stats.worthKnowing.priceDown": "每年少 {amount}", + "tabs.stats.worthKnowing.priceUp": "每年多 {amount}", + "tabs.stats.worthKnowing.recurringMonthly": "{value} 看起來是每月扣款。", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount},約每月 {dayNumber} 日 · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} 看起來是每週扣款。", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount},每{weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} 看起來是每年扣款。", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount},約 {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "當幾乎每月都用的分類低於其中位數的 {percent} 時顯示。", + "tabs.stats.worthKnowing.rule.categorySpike": "當某分類達到前 {months} 個月中位數的 {ratio},且至少有 {count} 筆時顯示。", + "tabs.stats.worthKnowing.rule.footer": "不含轉帳和待處理紀錄。在本裝置上計算。", + "tabs.stats.worthKnowing.rule.monthPace": "截至 {date} 的支出,與前 {months} 個月同一天的中位數比較。", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month}的支出,與前 {months} 個月的中位數比較。", + "tabs.stats.worthKnowing.rule.newCategory": "當某分類 {months} 個月來首次有支出時顯示。", + "tabs.stats.worthKnowing.rule.priceChange": "當定期扣款變動 {percent} 或以上時顯示。", + "tabs.stats.worthKnowing.rule.recurringCharge": "標題相同、按固定週期重複的扣款。金額浮動的帳單(如水電費)至少需要 {count} 次扣款。", + "tabs.stats.worthKnowing.seeTransactions": "查看這些交易", + "tabs.stats.worthKnowing.times": "已 {count} 次", + "tabs.stats.worthKnowing.times.one": "已 {count} 次", + "tabs.stats.worthKnowing.track": "設為週期性交易", + "tabs.stats.worthKnowing.type.categoryDrop": "分類下降", + "tabs.stats.worthKnowing.type.categorySpike": "分類激增", + "tabs.stats.worthKnowing.type.monthPace": "月度比較", + "tabs.stats.worthKnowing.type.newCategory": "新分類", + "tabs.stats.worthKnowing.type.priceChange": "價格變動", + "tabs.stats.worthKnowing.type.recurringCharge": "訂閱建議", + "tabs.stats.worthKnowing.versusTypical": "{current},平時 {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "主要原因", + "tabs.stats.worthKnowing.why": "為什麼會看到這個", "transaction": "交易", "transaction.actions": "動作", "transaction.attachments": "檔案附件", @@ -875,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "這將刪除所有相關的交易,並停止建立新交易。即使從垃圾桶復原交易,也不會讓它重新開始建立新交易。", "transaction.recurring.edit": "編輯週期性交易", "transaction.recurring.setup": "設定週期性交易", + "transaction.recurring.variableAmount": "金額不固定", + "transaction.recurring.variableAmount.description": "每次詢問金額", "transaction.tags": "標籤", "transaction.tags.add": "新增標籤", "transaction.tags.contact.name": "聯絡人名稱", @@ -946,4 +1027,4 @@ "transactions.query.noResult": "沒有可顯示的交易", "transactions.query.noResult.description": "嘗試更新您的過濾條件", "visitGitHubRepo": "前往 GitHub 查看" -} \ No newline at end of file +} diff --git a/docs/feasibility-self-hosted-sync.md b/docs/feasibility-self-hosted-sync.md deleted file mode 100644 index 36632d54..00000000 --- a/docs/feasibility-self-hosted-sync.md +++ /dev/null @@ -1,167 +0,0 @@ -# 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 bd1b76c0..993ece26 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -41,7 +41,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 flutter_dynamic_icon_plus: a7c81df360708aa1c9538f89cd96958fdda608ea icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2 ObjectBox: 2aae8ad350012cb53c6e7848064307cf68617f73 @@ -50,4 +50,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 1ac52b241a59f6195eb0dcd62320a80726757816 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d6d72121..3d615064 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -613,7 +613,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -882,7 +882,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -936,7 +936,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/lib/data/chart_data.dart b/lib/data/chart_data.dart index d77aeb75..e8dc1f47 100644 --- a/lib/data/chart_data.dart +++ b/lib/data/chart_data.dart @@ -6,6 +6,7 @@ class ChartData implements Comparable> { final Money money; final String currency; final T? associatedData; + final int transactionCount; double get displayTotal => money.amount.abs(); @@ -14,6 +15,7 @@ class ChartData implements Comparable> { required this.money, required this.currency, required this.associatedData, + this.transactionCount = 0, }); @override diff --git a/lib/data/flow_icon.dart b/lib/data/flow_icon.dart index 427c47f3..a79bb2c5 100644 --- a/lib/data/flow_icon.dart +++ b/lib/data/flow_icon.dart @@ -3,6 +3,7 @@ import "dart:typed_data"; import "dart:ui" as ui; import "package:cross_file/cross_file.dart"; +import "package:flow/data/legacy_simple_icons_codepoints.dart"; import "package:flow/objectbox.dart"; import "package:flutter/material.dart"; import "package:path/path.dart" as path; @@ -80,12 +81,10 @@ class IconFlowIcon extends FlowIconData { /// 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. + /// Brand icons stored by code point are resolved to [SimpleIconFlowIcon] + /// in [parse] instead. static const Map _fontPackageMigration = { "material_symbols_icons": "material_symbols_icons_flow", - "simple_icons": "simple_icons_flow", }; @override @@ -97,11 +96,21 @@ class IconFlowIcon extends FlowIconData { final payload = serialized.split(":")[1]; final [fontFamily, fontPackage, codePointHex] = payload.split(","); + final int codePoint = int.parse(codePointHex, radix: 16); + + // Brand icons are stored by slug since 0.23, so a code point here is + // always from simple_icons 14.6.1 (e.g., tags, old backups). Brands removed + // upstream get an empty slug, which renders a neutral fallback. + if (fontFamily == "SimpleIcons") { + return FlowIconData.simpleIcon( + legacySimpleIconsCodepointToSlug[codePoint] ?? "", + ); + } return FlowIconData.icon( IconData( // ignore: non_const_argument_for_const_parameter - int.parse(codePointHex, radix: 16), + codePoint, // ignore: non_const_argument_for_const_parameter fontFamily: fontFamily, // ignore: non_const_argument_for_const_parameter diff --git a/lib/data/insights/insight.dart b/lib/data/insights/insight.dart new file mode 100644 index 00000000..c8f5e337 --- /dev/null +++ b/lib/data/insights/insight.dart @@ -0,0 +1,306 @@ +import "package:flow/data/insights/recurring_series.dart"; + +export "package:flow/data/insights/recurring_series.dart"; + +/// One per "don't show this kind" switch. +enum InsightType { + monthPace, + categorySpike, + categoryDrop, + newCategory, + recurringCharge, + priceChange, +} + +enum InsightDirection { above, below } + +/// A month's value in an evidence chart. +class InsightMonthValue { + /// First day of the month. + final DateTime month; + + final double amount; + + /// False for months skipped as logging gaps (too few entries). + final bool counted; + + const InsightMonthValue({ + required this.month, + required this.amount, + required this.counted, + }); +} + +/// The single transaction that explains most of a change. +class InsightAttribution { + final String transactionUuid; + final String? title; + final String? categoryUuid; + final DateTime date; + + /// Absolute amount, in the primary currency. + final double amount; + + const InsightAttribution({ + required this.transactionUuid, + required this.title, + required this.categoryUuid, + required this.date, + required this.amount, + }); +} + +/// How much one category moved a month comparison. +class InsightCategoryDelta { + /// Null for uncategorized. + final String? categoryUuid; + final double current; + final double usual; + + const InsightCategoryDelta({ + required this.categoryUuid, + required this.current, + required this.usual, + }); + + double get delta => current - usual; +} + +/// All amounts are absolute, in the primary currency. +sealed class Insight { + /// Money at stake relative to the usual month, used for ranking. + final double score; + + const Insight({required this.score}); + + InsightType get type; + + /// What this insight is about, within its [type]. + String get subject; + + /// Stable identity for cooldowns and dismissals. + String get key => "${type.name}:$subject"; + + /// Money at stake. Never negative. + double get stake; + + /// The transactions behind the insight, most relevant first. + List get transactionUuids; +} + +/// The month as a whole is running above or below the usual. For an +/// in-progress month, both sides are measured through [throughDay]. +final class MonthPaceInsight extends Insight { + final InsightDirection direction; + final double current; + final double usual; + + /// Null for a complete month. + final int? throughDay; + + /// Baseline months, oldest first, measured the same way as [current]. + final List history; + + /// Only for [InsightDirection.above]. + final InsightAttribution? attribution; + + /// Categories that moved in [direction], biggest first. + final List drivers; + + @override + final List transactionUuids; + + const MonthPaceInsight({ + required super.score, + required this.direction, + required this.current, + required this.usual, + required this.throughDay, + required this.history, + required this.attribution, + required this.drivers, + required this.transactionUuids, + }); + + @override + InsightType get type => .monthPace; + + @override + String get subject => direction.name; + + @override + double get stake => (current - usual).abs(); + + /// e.g. 1.27 for "27% above". + double get ratio => current / usual; + + /// [current] without [attribution], if any. + double? get currentWithoutAttribution => + attribution == null ? null : current - attribution!.amount; + + double? get ratioWithoutAttribution => + attribution == null ? null : currentWithoutAttribution! / usual; + + /// The first driver when it explains at least half of the change. + InsightCategoryDelta? get dominantDriver { + final InsightCategoryDelta? first = drivers.firstOrNull; + if (first == null) return null; + + return first.delta.abs() * 2 >= stake ? first : null; + } +} + +/// A category is well above its usual month. [current] may be a month in +/// progress; [usual] is always a complete month. +final class CategorySpikeInsight extends Insight { + final String categoryUuid; + final double current; + final double usual; + final int entryCount; + + /// Complete baseline months, oldest first. + final List history; + + final InsightAttribution? attribution; + + @override + final List transactionUuids; + + const CategorySpikeInsight({ + required super.score, + required this.categoryUuid, + required this.current, + required this.usual, + required this.entryCount, + required this.history, + required this.attribution, + required this.transactionUuids, + }); + + @override + InsightType get type => .categorySpike; + + @override + String get subject => categoryUuid; + + @override + double get stake => current - usual; + + double get ratio => current / usual; +} + +/// A regular category is well below its usual. For an in-progress month, +/// both sides are measured through [throughDay]. +final class CategoryDropInsight extends Insight { + final String categoryUuid; + final double current; + final double usual; + final int? throughDay; + final List history; + + @override + final List transactionUuids; + + const CategoryDropInsight({ + required super.score, + required this.categoryUuid, + required this.current, + required this.usual, + required this.throughDay, + required this.history, + required this.transactionUuids, + }); + + @override + InsightType get type => .categoryDrop; + + @override + String get subject => categoryUuid; + + @override + double get stake => usual - current; + + double get ratio => current / usual; +} + +/// Spend in a category that had none in the baseline months. +final class NewCategoryInsight extends Insight { + final String categoryUuid; + final double current; + final int entryCount; + + @override + final List transactionUuids; + + const NewCategoryInsight({ + required super.score, + required this.categoryUuid, + required this.current, + required this.entryCount, + required this.transactionUuids, + }); + + @override + InsightType get type => .newCategory; + + @override + String get subject => categoryUuid; + + @override + double get stake => current; +} + +/// A charge that repeats but isn't tracked as recurring in Flow yet. +final class RecurringChargeInsight extends Insight { + final RecurringSeries series; + + const RecurringChargeInsight({required super.score, required this.series}); + + @override + InsightType get type => .recurringCharge; + + @override + String get subject => series.key; + + @override + double get stake => series.annualCost; + + @override + List get transactionUuids => series.occurrences.reversed + .map((occurrence) => occurrence.transactionUuid) + .toList(); +} + +/// The latest charge of a recurring series changed price. +final class PriceChangeInsight extends Insight { + final RecurringSeries series; + final double previousAmount; + final double currentAmount; + + const PriceChangeInsight({ + required super.score, + required this.series, + required this.previousAmount, + required this.currentAmount, + }); + + @override + InsightType get type => .priceChange; + + @override + String get subject => series.key; + + /// Yearly difference. + @override + double get stake => + (currentAmount - previousAmount).abs() * series.cadence.perYear; + + double get ratio => currentAmount / previousAmount; + + DateTime get changedOn => series.occurrences.last.date; + + @override + List get transactionUuids => series.occurrences.reversed + .map((occurrence) => occurrence.transactionUuid) + .toList(); +} diff --git a/lib/data/insights/insight_adapter.dart b/lib/data/insights/insight_adapter.dart new file mode 100644 index 00000000..6dfb765c --- /dev/null +++ b/lib/data/insights/insight_adapter.dart @@ -0,0 +1,111 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/insights/insight_request.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/utils/extensions/money.dart"; + +/// Maps Flow transactions to [InsightTransaction]s in [primaryCurrency]. +/// +/// Deleted, pending and transfer transactions are left out. Ones that can't +/// be converted are skipped and flagged with `hasMissingRates`. +({List transactions, bool hasMissingRates}) +insightTransactionsOf( + Iterable transactions, { + required String primaryCurrency, + required ExchangeRates? rates, +}) { + final List result = []; + bool hasMissingRates = false; + + for (final Transaction transaction in transactions) { + if (transaction.isDeleted == true || + transaction.isPending == true || + transaction.isTransfer) { + continue; + } + + final double? amount = _convert( + transaction.amount, + transaction.currency, + primaryCurrency, + rates, + ); + + if (amount == null) { + hasMissingRates = true; + continue; + } + + result.add( + InsightTransaction( + uuid: transaction.uuid, + date: transaction.transactionDate.toLocal(), + amount: amount, + title: transaction.title, + categoryUuid: transaction.categoryUuid, + accountUuid: transaction.accountUuid, + recurringUuid: transaction.extensions.recurring?.uuid, + ), + ); + } + + return (transactions: result, hasMissingRates: hasMissingRates); +} + +/// Active, non-transfer recurring transactions as templates. Ones that can't +/// be read or converted are skipped. +List insightTemplatesOf( + Iterable recurringTransactions, { + required String primaryCurrency, + required ExchangeRates? rates, +}) { + final List result = []; + + for (final RecurringTransaction recurring in recurringTransactions) { + if (recurring.disabled || recurring.transferToAccountUuid != null) { + continue; + } + + final Transaction template; + + try { + template = recurring.template; + } catch (_) { + continue; + } + + final double? amount = _convert( + template.amount, + template.currency, + primaryCurrency, + rates, + ); + + if (amount == null) continue; + + result.add( + InsightRecurringTemplate( + title: template.title, + amount: amount, + uuid: recurring.uuid, + variableAmount: recurring.variableAmount, + ), + ); + } + + return result; +} + +double? _convert( + double amount, + String currency, + String primaryCurrency, + ExchangeRates? rates, +) { + try { + return Money(amount, currency).tryConvertAmount(primaryCurrency, rates); + } catch (_) { + return null; + } +} diff --git a/lib/data/insights/insight_engine.dart b/lib/data/insights/insight_engine.dart new file mode 100644 index 00000000..0d2a9ac3 --- /dev/null +++ b/lib/data/insights/insight_engine.dart @@ -0,0 +1,725 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight.dart"; +import "package:flow/data/insights/insight_math.dart"; +import "package:flow/data/insights/insight_request.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; + +export "package:flow/data/insights/insight.dart"; +export "package:flow/data/insights/insight_request.dart"; + +/// Entry point for `compute()`. +InsightReport computeInsights(InsightRequest request) => + InsightEngine(request).run(); + +class _Expense { + final InsightTransaction source; + final double amount; + + /// Month index this expense is compared in. Differs from the calendar + /// month only for monthly charges paid a few days early or late. + int bucket; + + /// Set for members of a monthly series. + RecurringSeries? monthlySeries; + + /// A yearly charge that happened before, so not unusual. + bool isYearlyRepeat = false; + + _Expense(this.source) + : amount = source.amount.abs(), + bucket = monthIndexOf(source.date); + + String get uuid => source.uuid; + String? get categoryUuid => source.categoryUuid; + + /// A fixed monthly charge or a yearly one seen before. Usual by + /// definition, so it never explains an unusual month. + bool get isRoutine => + (monthlySeries?.isFixedPrice ?? false) || isYearlyRepeat; +} + +/// Local, deterministic spending insights for one month. +/// +/// Baselines are medians over the last [InsightThresholds.baselineMonths] +/// complete months, so one big purchase doesn't move what counts as usual. A +/// month in progress is compared with the same point of earlier months. +class InsightEngine { + final InsightRequest request; + + InsightEngine(this.request); + + late final int _month = monthIndexOf(request.month); + late final bool _inProgress = _month == monthIndexOf(request.now); + late final int? _throughDay = _inProgress ? request.now.day : null; + + /// Nothing at or after this is considered. + late final DateTime _cutoff = _inProgress + ? DateTime(request.now.year, request.now.month, request.now.day + 1) + : monthStartOf(_month + 1); + + late final List _baseline = [ + for (int i = InsightThresholds.baselineMonths; i >= 1; i--) _month - i, + ]; + + final Map> _buckets = {}; + List _series = const []; + + /// Monthly series already charged for the analyzed month. + final Set _includedSeries = {}; + + /// Monthly series expected later in the analyzed month. + final Set _deferredSeries = {}; + + List _active = const []; + double _usualTotal = 0.0; + double _floor = 0.0; + + InsightReport run() { + if (_month > monthIndexOf(request.now)) { + return _report(.futureMonth, const []); + } + + _prepare(); + + _active = _baseline + .where( + (month) => + _full(month).length >= InsightThresholds.minExpensesPerMonth, + ) + .toList(); + + if (_active.length < InsightThresholds.minActiveMonths) { + return _report(.notEnoughHistory, const []); + } + + _usualTotal = median(_active.map((month) => _sum(_full(month)))); + + if (_usualTotal <= 0.0) { + return _report(.notEnoughHistory, const []); + } + + final double medianExpense = median( + _active.expand(_full).map((expense) => expense.amount), + ); + + _floor = math.max( + _usualTotal * InsightThresholds.materialityShare, + medianExpense * InsightThresholds.materialityMedianMultiple, + ); + + final bool tooEarly = + _inProgress && _throughDay! < InsightThresholds.minDayOfMonth; + + final List candidates = [ + if (!tooEarly) ...[ + ?_monthPace(), + ..._categorySpikes(), + ..._categoryDrops(), + ..._newCategories(), + ], + // Suggesting to track a charge only helps while the month is current. + if (_inProgress) ..._recurringCharges(), + ..._priceChanges(), + ]; + + return _report( + tooEarly ? .tooEarly : .ready, + _rank( + candidates + .where((insight) => !request.hiddenTypes.contains(insight.type)) + .toList(), + ), + ); + } + + InsightReport _report(InsightReportStatus status, List insights) => + InsightReport( + month: monthStartOf(_month), + status: status, + insights: insights, + usualMonthTotal: _usualTotal > 0.0 ? _usualTotal : null, + throughDay: _throughDay, + hasMissingRates: request.hasMissingRates, + ); + + void _prepare() { + final List valid = + request.transactions + .where( + (transaction) => + !transaction.isTransfer && + !transaction.isPending && + transaction.amount.isFinite && + transaction.amount != 0.0 && + transaction.date.isBefore(_cutoff), + ) + .toList() + ..sort((a, b) { + final int byDate = a.date.compareTo(b.date); + return byDate != 0 ? byDate : a.uuid.compareTo(b.uuid); + }); + + final Set refunded = _matchRefunds(valid); + + final List<_Expense> expenses = valid + .where( + (transaction) => + transaction.amount < 0.0 && !refunded.contains(transaction.uuid), + ) + .map(_Expense.new) + .toList(); + + _series = detectRecurringSeries( + expenses.map((expense) => expense.source).toList(), + templates: request.recurringTemplates, + ); + + final Map byUuid = { + for (final _Expense expense in expenses) expense.uuid: expense, + }; + + for (final RecurringSeries series in _series) { + for (int i = 0; i < series.occurrences.length; i++) { + final RecurringOccurrence occurrence = series.occurrences[i]; + final _Expense expense = byUuid[occurrence.transactionUuid]!; + + switch (series.cadence) { + case .monthly: + expense.monthlySeries = series; + expense.bucket = series.periodOf(occurrence); + case .yearly: + expense.isYearlyRepeat = i > 0; + case .weekly: + break; + } + } + } + + for (final _Expense expense in expenses) { + (_buckets[expense.bucket] ??= []).add(expense); + } + + if (_inProgress) _splitSeriesByTiming(); + } + + /// Pairs a positive transaction with an earlier expense of the same amount + /// and title (or category), so a returned purchase isn't counted as spend. + Set _matchRefunds(List valid) { + final Map> expensesByKey = {}; + + for (final InsightTransaction transaction in valid) { + if (transaction.amount >= 0.0) continue; + + final String? key = _refundKey(transaction); + if (key != null) (expensesByKey[key] ??= []).add(transaction); + } + + final Set matched = {}; + + for (final InsightTransaction refund in valid) { + if (refund.amount <= 0.0) continue; + + final String? key = _refundKey(refund); + final List? candidates = expensesByKey[key]; + if (candidates == null) continue; + + for (final InsightTransaction expense in candidates.reversed) { + if (matched.contains(expense.uuid)) continue; + if (expense.date.isAfter(refund.date)) continue; + if (dayDifference(expense.date, refund.date) > + InsightThresholds.refundWindowDays) { + break; + } + + final double difference = (expense.amount.abs() - refund.amount).abs(); + if (difference <= + refund.amount * InsightThresholds.refundAmountTolerance) { + matched + ..add(expense.uuid) + ..add(refund.uuid); + break; + } + } + } + + return matched; + } + + String? _refundKey(InsightTransaction transaction) { + final String? title = normalizeTitle(transaction.title); + if (title != null) return "title:$title"; + + final String? category = transaction.categoryUuid; + return category == null ? null : "category:$category"; + } + + /// For a month in progress, a monthly charge that already happened is + /// compared in full against earlier months, and one still to come is left + /// out of both sides. Rent paid on the 20th instead of the 28th is then + /// not "spending more". + void _splitSeriesByTiming() { + for (final RecurringSeries series in _series) { + if (series.cadence != .monthly) continue; + + final List periods = series.occurrences + .map(series.periodOf) + .toList(); + + if (periods.contains(_month)) { + _includedSeries.add(series.key); + } else if (periods.last >= _month - 2 && + chargeDayOf(_month, series.anchorDay).day > _throughDay!) { + _deferredSeries.add(series.key); + } + } + } + + List<_Expense> _full(int month) => _buckets[month] ?? const []; + + /// [_full], cut to the same day of month as the analyzed month. + Iterable<_Expense> _comparable(int month) => + _full(month).where((expense) => _counts(expense, month)); + + bool _counts(_Expense expense, int month) { + if (!_inProgress || month == _month) return true; + + final RecurringSeries? series = expense.monthlySeries; + + if (series != null) { + if (_includedSeries.contains(series.key)) return true; + if (_deferredSeries.contains(series.key)) return false; + } + + return _dayInBucket(expense) <= math.min(_throughDay!, daysInMonth(month)); + } + + int _dayInBucket(_Expense expense) { + final int calendarMonth = monthIndexOf(expense.source.date); + + if (calendarMonth < expense.bucket) return 1; + if (calendarMonth > expense.bucket) return 31; + return expense.source.date.day; + } + + double _sum(Iterable<_Expense> expenses) => + expenses.fold(0.0, (total, expense) => total + expense.amount); + + Iterable<_Expense> _inCategory(Iterable<_Expense> expenses, String? uuid) => + expenses.where((expense) => expense.categoryUuid == uuid); + + List<_Expense> _biggestFirst(Iterable<_Expense> expenses) => + expenses.toList()..sort((a, b) { + final int byAmount = b.amount.compareTo(a.amount); + return byAmount != 0 ? byAmount : a.uuid.compareTo(b.uuid); + }); + + List _evidence(Iterable<_Expense> expenses) => _biggestFirst(expenses) + .take(InsightThresholds.maxEvidenceTransactions) + .map((expense) => expense.uuid) + .toList(); + + /// The biggest unusual expense, when it covers most of [delta]. + InsightAttribution? _attribute(Iterable<_Expense> expenses, double delta) { + final _Expense? biggest = _biggestFirst( + expenses.where((expense) => !expense.isRoutine), + ).firstOrNull; + + if (biggest == null || + biggest.amount < delta * InsightThresholds.attributionShare) { + return null; + } + + return InsightAttribution( + transactionUuid: biggest.uuid, + title: biggest.source.title, + categoryUuid: biggest.categoryUuid, + date: biggest.source.date, + amount: biggest.amount, + ); + } + + /// A yearly charge that was paid last year too shouldn't make a month + /// look unusual. + bool _survivesYearlyRepeats( + Iterable<_Expense> current, + double usual, + double ratio, + ) { + final double withoutRepeats = _sum( + current.where((expense) => !expense.isYearlyRepeat), + ); + + return withoutRepeats >= usual * ratio && withoutRepeats - usual >= _floor; + } + + List _history(double Function(int month) value) => [ + for (final int month in _baseline) + InsightMonthValue( + month: monthStartOf(month), + amount: value(month), + counted: _active.contains(month), + ), + ]; + + Set _categories(Iterable months, {bool comparable = false}) => { + for (final int month in months) + for (final _Expense expense + in comparable ? _comparable(month) : _full(month)) + expense.categoryUuid, + }; + + double _score(double stake, {double weight = 1.0}) => + stake / _usualTotal * weight; + + MonthPaceInsight? _monthPace() { + final List<_Expense> current = _comparable(_month).toList(); + final double currentTotal = _sum(current); + final double usual = median( + _active.map((month) => _sum(_comparable(month))), + ); + + if (usual <= 0.0) return null; + + final double ratio = currentTotal / usual; + final double delta = currentTotal - usual; + + final InsightDirection? direction = + ratio >= InsightThresholds.monthAboveRatio + ? .above + : ratio <= InsightThresholds.monthBelowRatio + ? .below + : null; + + if (direction == null || delta.abs() < _floor) return null; + + if (direction == .above && + !_survivesYearlyRepeats( + current, + usual, + InsightThresholds.monthAboveRatio, + )) { + return null; + } + + return MonthPaceInsight( + score: _score(delta.abs()), + direction: direction, + current: currentTotal, + usual: usual, + throughDay: _throughDay, + history: _history((month) => _sum(_comparable(month))), + attribution: direction == .above ? _attribute(current, delta) : null, + drivers: _drivers(direction), + transactionUuids: _evidence(current), + ); + } + + List _drivers(InsightDirection direction) { + final List drivers = []; + + for (final String? category in _categories([ + _month, + ..._active, + ], comparable: true)) { + final double current = _sum(_inCategory(_comparable(_month), category)); + final double usual = median( + _active.map((month) => _sum(_inCategory(_comparable(month), category))), + ); + + final InsightCategoryDelta driver = InsightCategoryDelta( + categoryUuid: category, + current: current, + usual: usual, + ); + + if (direction == .above ? driver.delta > 0.0 : driver.delta < 0.0) { + drivers.add(driver); + } + } + + drivers.sort((a, b) { + final int byDelta = b.delta.abs().compareTo(a.delta.abs()); + return byDelta != 0 + ? byDelta + : (a.categoryUuid ?? "").compareTo(b.categoryUuid ?? ""); + }); + + return drivers.take(InsightThresholds.maxDrivers).toList(); + } + + List _categorySpikes() { + final List spikes = []; + + for (final String? category in _categories([_month])) { + if (category == null) continue; + + final List<_Expense> current = _inCategory( + _full(_month), + category, + ).toList(); + + if (current.length < InsightThresholds.categorySpikeMinEntries) continue; + + final List present = _active + .map((month) => _sum(_inCategory(_full(month), category))) + .where((total) => total > 0.0) + .toList(); + + if (present.length < InsightThresholds.categoryMinMonthsPresent) { + continue; + } + + // Months without this category don't drag its usual down. + final double usual = median(present); + final double currentTotal = _sum(current); + final double delta = currentTotal - usual; + + if (currentTotal < usual * InsightThresholds.categorySpikeRatio || + delta < _floor || + !_survivesYearlyRepeats( + current, + usual, + InsightThresholds.categorySpikeRatio, + )) { + continue; + } + + spikes.add( + CategorySpikeInsight( + score: _score(delta), + categoryUuid: category, + current: currentTotal, + usual: usual, + entryCount: current.length, + history: _history( + (month) => _sum(_inCategory(_full(month), category)), + ), + attribution: _attribute(current, delta), + transactionUuids: _evidence(current), + ), + ); + } + + return spikes; + } + + List _categoryDrops() { + if (_inProgress && _throughDay! < InsightThresholds.categoryDropMinDay) { + return const []; + } + + final List drops = []; + + for (final String? category in _categories(_active)) { + if (category == null) continue; + + final List fullTotals = _active + .map((month) => _sum(_inCategory(_full(month), category))) + .toList(); + + final int present = fullTotals.where((total) => total > 0.0).length; + + // Only categories that show up nearly every month can "drop". + if (present < + math.max( + InsightThresholds.categoryMinMonthsPresent, + _active.length - 1, + )) { + continue; + } + + if (median(fullTotals) < + _usualTotal * InsightThresholds.categoryDropMinShare) { + continue; + } + + final List<_Expense> current = _inCategory( + _comparable(_month), + category, + ).toList(); + final double currentTotal = _sum(current); + final double usual = median( + _active.map((month) => _sum(_inCategory(_comparable(month), category))), + ); + + if (usual <= 0.0 || + currentTotal > usual * InsightThresholds.categoryDropRatio || + usual - currentTotal < _floor) { + continue; + } + + drops.add( + CategoryDropInsight( + score: _score(usual - currentTotal), + categoryUuid: category, + current: currentTotal, + usual: usual, + throughDay: _throughDay, + history: _history( + (month) => _sum(_inCategory(_comparable(month), category)), + ), + transactionUuids: _evidence(current), + ), + ); + } + + return drops; + } + + List _newCategories() { + final Set seen = _categories(_baseline); + final List result = []; + + for (final String? category in _categories([_month])) { + if (category == null || seen.contains(category)) continue; + + final List<_Expense> current = _inCategory( + _full(_month), + category, + ).toList(); + final double total = _sum( + current.where((expense) => !expense.isYearlyRepeat), + ); + + if (total < _floor) continue; + + result.add( + NewCategoryInsight( + score: _score(total), + categoryUuid: category, + current: _sum(current), + entryCount: current.length, + transactionUuids: _evidence(current), + ), + ); + } + + return result; + } + + bool _chargedThisMonth(RecurringSeries series) => + series.periodOf(series.occurrences.last) == _month; + + /// Needs two charges at the old price, so a one-off discount isn't a change. + bool _changedPriceThisMonth(RecurringSeries series) => + series.isFixedPrice && + series.priceChangeIndex == series.occurrences.length - 1 && + series.priceChangeIndex! - (series.previousPriceIndex ?? 0) >= 2 && + _chargedThisMonth(series); + + bool _isSuggestableVariable(RecurringSeries series) => + series.cadence == .monthly && + series.occurrences.length >= + InsightThresholds.variableSuggestionMinOccurrences && + coefficientOfVariation( + series.occurrences.map((item) => item.amount).toList(), + ) <= + InsightThresholds.variableSuggestionMaxVariation; + + List _recurringCharges() => [ + for (final RecurringSeries series in _series) + // A price change says more this month; the suggestion can wait. + if ((series.isFixedPrice || _isSuggestableVariable(series)) && + !series.isTracked && + _chargedThisMonth(series) && + !_changedPriceThisMonth(series) && + series.annualCost >= _floor) + RecurringChargeInsight( + score: _score( + series.annualCost, + weight: InsightThresholds.actionWeight, + ), + series: series, + ), + ]; + + List _priceChanges() => [ + for (final RecurringSeries series in _series) + if (_changedPriceThisMonth(series)) + PriceChangeInsight( + score: _score( + (series.typicalAmount - series.previousAmount!).abs() * + series.cadence.perYear, + ), + series: series, + previousAmount: series.previousAmount!, + currentAmount: series.typicalAmount, + ), + ]; + + bool _isCoolingDown(Insight insight) { + if (!_inProgress) return false; + + final DateTime monthStart = monthStartOf(_month); + + // Cooldown only across months; within a month the card stays stable. + return request.sightings.any( + (sighting) => + sighting.key == insight.key && + sighting.shownAt.isBefore(monthStart) && + dayDifference(sighting.shownAt, request.now) < + InsightThresholds.cooldownDays && + insight.stake < sighting.stake * InsightThresholds.cooldownGrowth, + ); + } + + List _rank(List candidates) { + final List sorted = candidates.toList() + ..sort((a, b) { + final int byScore = b.score.compareTo(a.score); + if (byScore != 0) return byScore; + + final int byType = a.type.index.compareTo(b.type.index); + return byType != 0 ? byType : a.key.compareTo(b.key); + }); + + // A cooling-down month insight still covers what it explains. + final MonthPaceInsight? pace = sorted + .whereType() + .firstOrNull; + + final List result = []; + int spikes = 0; + int suggestions = 0; + + for (final Insight insight in sorted) { + if (result.length >= request.limit) break; + if (_isCoolingDown(insight)) continue; + if (pace != null && _explains(pace, insight)) continue; + + switch (insight) { + case CategorySpikeInsight(): + if (++spikes > InsightThresholds.maxCategorySpikes) continue; + case RecurringChargeInsight(): + if (++suggestions > InsightThresholds.maxRecurringSuggestions) { + continue; + } + default: + break; + } + + result.add(insight); + } + + return result; + } + + /// Whether [insight] mostly repeats what [pace] already says. + bool _explains(MonthPaceInsight pace, Insight insight) { + final bool sameDirection = switch (insight) { + CategorySpikeInsight() || + NewCategoryInsight() => pace.direction == .above, + CategoryDropInsight() => pace.direction == .below, + _ => false, + }; + + if (!sameDirection) return false; + + final String? attributed = pace.attribution?.transactionUuid; + + return insight.stake >= pace.stake * InsightThresholds.explainedShare || + (attributed != null && + insight.transactionUuids.firstOrNull == attributed); + } +} diff --git a/lib/data/insights/insight_math.dart b/lib/data/insights/insight_math.dart new file mode 100644 index 00000000..3c1e0470 --- /dev/null +++ b/lib/data/insights/insight_math.dart @@ -0,0 +1,109 @@ +import "dart:math" as math; + +/// Months since year 0, so month arithmetic never touches [Duration]s. +int monthIndexOf(DateTime date) => date.year * 12 + date.month - 1; + +DateTime monthStartOf(int monthIndex) => + DateTime(monthIndex ~/ 12, monthIndex % 12 + 1); + +int daysInMonth(int monthIndex) { + final int year = monthIndex ~/ 12; + + return switch (monthIndex % 12 + 1) { + 2 => year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) ? 29 : 28, + 4 || 6 || 9 || 11 => 30, + _ => 31, + }; +} + +/// Calendar days from [from] to [to], ignoring time of day and DST. +int dayDifference(DateTime from, DateTime to) => DateTime.utc( + to.year, + to.month, + to.day, +).difference(DateTime.utc(from.year, from.month, from.day)).inDays; + +/// The charge day for [anchorDay] in a month, clamped for short months. +DateTime chargeDayOf(int monthIndex, int anchorDay) => DateTime( + monthIndex ~/ 12, + monthIndex % 12 + 1, + math.min(anchorDay, daysInMonth(monthIndex)), +); + +/// The month whose charge on [anchorDay] is nearest to [date], and the signed +/// distance in days (positive when [date] is late). +/// +/// Plain day arithmetic: this runs for every charge and candidate day, and +/// local [DateTime]s are slow on iOS. +({int slot, int offset}) nearestSlot(DateTime date, int anchorDay) { + final int index = monthIndexOf(date); + + int chargeDay(int slot) => math.min(anchorDay, daysInMonth(slot)); + + final List<({int slot, int offset})> candidates = [ + ( + slot: index - 1, + offset: date.day + daysInMonth(index - 1) - chargeDay(index - 1), + ), + (slot: index, offset: date.day - chargeDay(index)), + ( + slot: index + 1, + offset: date.day - daysInMonth(index) - chargeDay(index + 1), + ), + ]; + + return candidates.reduce( + (best, next) => next.offset.abs() < best.offset.abs() ? next : best, + ); +} + +/// Returns 0.0 for an empty list. +double median(Iterable values) { + final List sorted = values.toList()..sort(); + + if (sorted.isEmpty) return 0.0; + + final int middle = sorted.length ~/ 2; + + if (sorted.length.isOdd) return sorted[middle]; + + return (sorted[middle - 1] + sorted[middle]) / 2; +} + +double mean(Iterable values) { + if (values.isEmpty) return 0.0; + + return values.fold(0.0, (a, b) => a + b) / values.length; +} + +/// Standard deviation over mean. 0.0 for fewer than two values or a zero mean. +double coefficientOfVariation(List values) { + if (values.length < 2) return 0.0; + + final double average = mean(values); + if (average == 0.0) return 0.0; + + final double variance = mean( + values.map((value) => (value - average) * (value - average)), + ); + + return math.sqrt(variance) / average.abs(); +} + +final RegExp _separators = RegExp(r"[^\p{L}\p{N}]+", unicode: true); +final RegExp _digit = RegExp(r"\d"); + +/// Lowercased title without punctuation and without tokens that contain +/// digits (reference numbers, dates), so "NETFLIX #1234" and "Netflix" group +/// together. Null when nothing is left. +String? normalizeTitle(String? title) { + if (title == null) return null; + + final String normalized = title + .toLowerCase() + .split(_separators) + .where((token) => token.isNotEmpty && !token.contains(_digit)) + .join(" "); + + return normalized.isEmpty ? null : normalized; +} diff --git a/lib/data/insights/insight_request.dart b/lib/data/insights/insight_request.dart new file mode 100644 index 00000000..5367887a --- /dev/null +++ b/lib/data/insights/insight_request.dart @@ -0,0 +1,148 @@ +import "package:flow/data/insights/insight.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; + +/// A transaction as the insight engine sees it. Plain data, safe to send to +/// another isolate. +class InsightTransaction { + final String uuid; + + /// Local time. + final DateTime date; + + /// In the primary currency. Negative for expenses. + final double amount; + + final String? title; + final String? categoryUuid; + final String? accountUuid; + + final bool isTransfer; + final bool isPending; + + /// Uuid of the Flow recurring transaction that created this, if any. + final String? recurringUuid; + + const InsightTransaction({ + required this.uuid, + required this.date, + required this.amount, + this.title, + this.categoryUuid, + this.accountUuid, + this.isTransfer = false, + this.isPending = false, + this.recurringUuid, + }); +} + +/// A recurring transaction the user already set up in Flow. +class InsightRecurringTemplate { + final String? title; + + /// Absolute amount, in the primary currency. + final double amount; + + final String? uuid; + + /// Amount changes each time, so only the title has to match. + final bool variableAmount; + + const InsightRecurringTemplate({ + required this.title, + required this.amount, + this.uuid, + this.variableAmount = false, + }); +} + +/// An insight the UI has shown before, used for the cooldown. +class InsightSighting { + /// [Insight.key] + final String key; + final DateTime shownAt; + + /// [Insight.stake] at the time it was shown. + final double stake; + + const InsightSighting({ + required this.key, + required this.shownAt, + required this.stake, + }); +} + +class InsightRequest { + /// Everything available up to [now]. Pass at least 7 months; recurring + /// detection benefits from more (yearly charges need two years). + final List transactions; + + /// Any moment within the month to analyze. + final DateTime month; + + final DateTime now; + + final List recurringTemplates; + + /// Types the user chose not to see. + final Set hiddenTypes; + + final List sightings; + + final int limit; + + /// Passed through to [InsightReport.hasMissingRates]. + final bool hasMissingRates; + + const InsightRequest({ + required this.transactions, + required this.month, + required this.now, + this.recurringTemplates = const [], + this.hiddenTypes = const {}, + this.sightings = const [], + this.limit = InsightThresholds.maxInsights, + this.hasMissingRates = false, + }); +} + +enum InsightReportStatus { + ready, + + /// Before [InsightThresholds.minDayOfMonth]; only recurring charge + /// insights can appear. + tooEarly, + + /// Fewer than [InsightThresholds.minActiveMonths] usable baseline months. + notEnoughHistory, + + futureMonth, +} + +class InsightReport { + /// First day of the analyzed month. + final DateTime month; + + final InsightReportStatus status; + + /// Ranked by money at stake, at most [InsightRequest.limit]. + final List insights; + + /// Median spend of a complete baseline month. Null without enough history. + final double? usualMonthTotal; + + /// Day of month the comparisons run through. Null for a complete month. + final int? throughDay; + + final bool hasMissingRates; + + const InsightReport({ + required this.month, + required this.status, + required this.insights, + this.usualMonthTotal, + this.throughDay, + this.hasMissingRates = false, + }); + + bool get isInProgress => throughDay != null; +} diff --git a/lib/data/insights/insight_thresholds.dart b/lib/data/insights/insight_thresholds.dart new file mode 100644 index 00000000..325c1aef --- /dev/null +++ b/lib/data/insights/insight_thresholds.dart @@ -0,0 +1,105 @@ +/// Every tuning knob of the insight engine, in one place. +abstract final class InsightThresholds { + /// Complete months before the analyzed one that make up the baseline. + static const int baselineMonths = 6; + + /// A baseline month with fewer expenses is treated as a logging gap. + static const int minExpensesPerMonth = 10; + + /// Active baseline months required before the engine says anything. + static const int minActiveMonths = 3; + + /// Month comparisons stay quiet before this day of an in-progress month. + static const int minDayOfMonth = 7; + + static const double monthAboveRatio = 1.25; + static const double monthBelowRatio = 0.80; + + /// Materiality floor: max(share × usual month, multiple × median expense). + static const double materialityShare = 0.05; + static const double materialityMedianMultiple = 3.0; + + /// A single transaction covering this share of a change gets named. + static const double attributionShare = 0.5; + + static const double categorySpikeRatio = 1.5; + static const int categorySpikeMinEntries = 3; + static const int maxCategorySpikes = 2; + + /// Months a category must appear in to have a usual amount. + static const int categoryMinMonthsPresent = 3; + + static const double categoryDropRatio = 0.6; + + /// Categories smaller than this share of the usual month can't "drop". + static const double categoryDropMinShare = 0.05; + + /// Early in a month everything looks low. + static const int categoryDropMinDay = 20; + + /// A category change covering this share of the month change is folded + /// into the month insight. + static const double explainedShare = 0.8; + + /// Weight for insights that come with an action. + static const double actionWeight = 1.5; + + static const int maxInsights = 3; + static const int maxRecurringSuggestions = 1; + + static const int cooldownDays = 28; + + /// A cooled-down insight comes back early if it grew by this factor. + static const double cooldownGrowth = 1.5; + + static const int recurringMinOccurrences = 3; + static const int yearlyMinOccurrences = 2; + + /// Allowed distance from the usual charge day (weekends, bank delays). + static const int monthlyDayJitter = 4; + + /// Longer series tolerate the odd charge paid this far off. + static const int monthlyMaxShift = 10; + + /// Longest step between charges, in months (skipped months). + static const int monthlyMaxStep = 3; + + static const int weeklyDayJitter = 1; + static const int yearlyMinGap = 360; + static const int yearlyMaxGap = 370; + + /// Coefficient of variation allowed for a fixed-price charge. + static const double recurringMaxVariation = 0.10; + + /// Looser variation for variable bills (utilities), only used to line up + /// month boundaries, never suggested. + static const double variableChargeMaxVariation = 0.5; + + /// Variable bills need a longer, steadier history to be suggested. + static const int variableSuggestionMinOccurrences = 4; + static const double variableSuggestionMaxVariation = 0.35; + + /// Amounts further apart than this ratio are split into separate series. + static const double amountClusterRatio = 1.25; + + static const double priceChangeMinRatio = 0.05; + static const double priceChangeMinAmount = 1.0; + + /// The step must also beat this many times the charges' own wobble. + static const double priceChangeNoiseMultiple = 3.0; + + /// Charges wobbling more than this are bills without a price to change. + static const double priceChangeMaxNoise = 0.05; + + /// Bigger jumps are more likely a different charge than a price change. + static const double priceChangeMaxRatio = 0.5; + + /// A Flow recurring template matches a series within this amount ratio. + static const double templateAmountTolerance = 0.25; + + static const double refundAmountTolerance = 0.01; + static const int refundWindowDays = 60; + + static const int maxDrivers = 3; + static const int maxEvidenceTransactions = 5; +} diff --git a/lib/data/insights/recurring_series.dart b/lib/data/insights/recurring_series.dart new file mode 100644 index 00000000..23b2b1a3 --- /dev/null +++ b/lib/data/insights/recurring_series.dart @@ -0,0 +1,503 @@ +import "dart:collection"; +import "dart:math" as math; + +import "package:flow/data/insights/insight_math.dart"; +import "package:flow/data/insights/insight_request.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; + +enum RecurringCadence { + weekly(52), + monthly(12), + yearly(1); + + final int perYear; + + const RecurringCadence(this.perYear); +} + +class RecurringOccurrence { + final String transactionUuid; + final DateTime date; + + /// Absolute amount, in the primary currency. + final double amount; + + const RecurringOccurrence({ + required this.transactionUuid, + required this.date, + required this.amount, + }); +} + +/// A charge that repeats on a steady cadence. +class RecurringSeries { + /// `recurring:` for Flow recurring transactions, `title:` + /// otherwise (with a `:<n>` suffix when one title holds several amounts). + final String key; + + /// Title of the latest occurrence. + final String? title; + + final RecurringCadence cadence; + + /// Monthly: day of month (clamped in short months). Weekly: weekday of the + /// latest charge. Yearly: day of month of the latest charge. + final int anchorDay; + + /// Oldest first. + final List<RecurringOccurrence> occurrences; + + /// False for variable bills, like utilities. + final bool isFixedPrice; + + /// Index in [occurrences] where the current price starts, if it changed. + final int? priceChangeIndex; + + /// Index in [occurrences] where the price before [priceChangeIndex] + /// starts, if that changed too. + final int? previousPriceIndex; + + final String? categoryUuid; + final String? accountUuid; + + /// Already set up as recurring in Flow. + final bool isTracked; + + const RecurringSeries({ + required this.key, + required this.title, + required this.cadence, + required this.anchorDay, + required this.occurrences, + required this.isFixedPrice, + required this.priceChangeIndex, + this.previousPriceIndex, + required this.categoryUuid, + required this.accountUuid, + required this.isTracked, + }); + + /// Median amount at the current price. + double get typicalAmount => median( + occurrences.skip(priceChangeIndex ?? 0).map((item) => item.amount), + ); + + /// Median amount before the price change. + double? get previousAmount => priceChangeIndex == null + ? null + : median( + occurrences + .sublist(previousPriceIndex ?? 0, priceChangeIndex) + .map((item) => item.amount), + ); + + double get annualCost => typicalAmount * cadence.perYear; + + /// Month index of the charge [occurrence] belongs to. Monthly charges paid + /// a few days early or late still land in their own month. + int periodOf(RecurringOccurrence occurrence) => switch (cadence) { + .monthly => nearestSlot(occurrence.date, anchorDay).slot, + _ => monthIndexOf(occurrence.date), + }; +} + +/// Finds repeating charges among [expenses] (negative amounts). +/// +/// Charges are grouped by their Flow recurring transaction, or else by +/// [normalizeTitle]. Untitled charges are never grouped. +List<RecurringSeries> detectRecurringSeries( + List<InsightTransaction> expenses, { + List<InsightRecurringTemplate> templates = const [], +}) { + final SplayTreeMap<String, List<InsightTransaction>> groups = SplayTreeMap(); + + for (final InsightTransaction expense in expenses) { + final String? title = normalizeTitle(expense.title); + + final String? key = expense.recurringUuid != null + ? "recurring:${expense.recurringUuid}" + : title != null + ? "title:$title" + : null; + + if (key == null) continue; + + (groups[key] ??= []).add(expense); + } + + final List<RecurringSeries> result = []; + + for (final MapEntry<String, List<InsightTransaction>> group + in groups.entries) { + final List<InsightTransaction> items = group.value + ..sort(_compareTransactions); + + final RecurringSeries? whole = _detect(group.key, items, templates); + if (whole != null) { + result.add(whole); + continue; + } + + final List<List<InsightTransaction>> clusters = _amountClusters(items); + if (clusters.length < 2) continue; + + for (int i = 0; i < clusters.length; i++) { + final RecurringSeries? series = _detect( + "${group.key}:$i", + clusters[i]..sort(_compareTransactions), + templates, + ); + if (series != null) result.add(series); + } + } + + return result; +} + +int _compareTransactions(InsightTransaction a, InsightTransaction b) { + final int byDate = a.date.compareTo(b.date); + return byDate != 0 ? byDate : a.uuid.compareTo(b.uuid); +} + +/// Splits a title that covers several prices, like two "Apple" plans. +List<List<InsightTransaction>> _amountClusters(List<InsightTransaction> items) { + final List<InsightTransaction> byAmount = [...items] + ..sort((a, b) => b.amount.compareTo(a.amount)); + + final List<List<InsightTransaction>> clusters = []; + + for (final InsightTransaction item in byAmount) { + final List<InsightTransaction>? last = clusters.lastOrNull; + + if (last != null && + item.amount.abs() <= + last.last.amount.abs() * InsightThresholds.amountClusterRatio) { + last.add(item); + } else { + clusters.add([item]); + } + } + + return clusters; +} + +RecurringSeries? _detect( + String key, + List<InsightTransaction> items, + List<InsightRecurringTemplate> templates, +) { + final ({RecurringCadence cadence, int anchorDay})? rhythm = + _monthly(items) ?? _weekly(items) ?? _yearly(items); + + if (rhythm == null) return null; + + final List<double> amounts = items.map((item) => item.amount.abs()).toList(); + + ({bool isFixedPrice, int? priceChangeIndex, int? previousPriceIndex})? price = + _price(amounts, rhythm.cadence); + + if (price == null) return null; + + // Tracked as variable, so there's no price to change + if (_isVariableTemplate(key, templates)) { + price = ( + isFixedPrice: false, + priceChangeIndex: null, + previousPriceIndex: null, + ); + } + + final List<RecurringOccurrence> occurrences = items + .map( + (item) => RecurringOccurrence( + transactionUuid: item.uuid, + date: item.date, + amount: item.amount.abs(), + ), + ) + .toList(); + + final double typicalAmount = median( + amounts.skip(price.priceChangeIndex ?? 0), + ); + + return RecurringSeries( + key: key, + title: items.last.title, + cadence: rhythm.cadence, + anchorDay: rhythm.anchorDay, + occurrences: occurrences, + isFixedPrice: price.isFixedPrice, + priceChangeIndex: price.priceChangeIndex, + previousPriceIndex: price.previousPriceIndex, + categoryUuid: _mostCommon(items.map((item) => item.categoryUuid)), + accountUuid: _mostCommon(items.map((item) => item.accountUuid)), + isTracked: + key.startsWith("recurring:") || + _matchesTemplate(items.last.title, [ + typicalAmount, + amounts.last, + ], templates), + ); +} + +/// Same calendar day each month, give or take a few days, with at most a +/// couple of skipped months. Short months clamp the day (31st -> Feb 28th). +({RecurringCadence cadence, int anchorDay})? _monthly( + List<InsightTransaction> items, +) { + if (items.length < InsightThresholds.recurringMinOccurrences) return null; + + ({int anchorDay, int outliers, int total, int worst})? best; + + for (int anchorDay = 1; anchorDay <= 31; anchorDay++) { + int outliers = 0; + int total = 0; + int worst = 0; + + for (final InsightTransaction item in items) { + final int offset = nearestSlot(item.date, anchorDay).offset.abs(); + if (offset > InsightThresholds.monthlyDayJitter) outliers++; + total += offset; + worst = math.max(worst, offset); + } + + if (best == null || + outliers < best.outliers || + (outliers == best.outliers && total < best.total)) { + best = ( + anchorDay: anchorDay, + outliers: outliers, + total: total, + worst: worst, + ); + } + } + + // A long series may have the odd charge paid well early or late. + final int allowedOutliers = math.min( + math.max(1, items.length ~/ 5), + items.length - InsightThresholds.recurringMinOccurrences, + ); + + if (best!.outliers > allowedOutliers || + best.worst > InsightThresholds.monthlyMaxShift) { + return null; + } + + final List<int> slots = items + .map((item) => nearestSlot(item.date, best!.anchorDay).slot) + .toList(); + + int consecutive = 0; + + for (int i = 1; i < slots.length; i++) { + final int step = slots[i] - slots[i - 1]; + + if (step < 1 || step > InsightThresholds.monthlyMaxStep) return null; + if (step == 1) consecutive++; + } + + // Mostly month after month, so every-other-month charges don't qualify. + if (consecutive * 2 < slots.length - 1) return null; + + return (cadence: .monthly, anchorDay: best.anchorDay); +} + +({RecurringCadence cadence, int anchorDay})? _weekly( + List<InsightTransaction> items, +) { + if (items.length < InsightThresholds.recurringMinOccurrences) return null; + + int consecutive = 0; + + for (int i = 1; i < items.length; i++) { + final int gap = dayDifference(items[i - 1].date, items[i].date); + final int weeks = (gap / 7).round(); + + if (weeks < 1 || weeks > 2) return null; + if ((gap - weeks * 7).abs() > InsightThresholds.weeklyDayJitter) { + return null; + } + if (weeks == 1) consecutive++; + } + + if (consecutive * 2 < items.length - 1) return null; + + return (cadence: .weekly, anchorDay: items.last.date.weekday); +} + +({RecurringCadence cadence, int anchorDay})? _yearly( + List<InsightTransaction> items, +) { + if (items.length < InsightThresholds.yearlyMinOccurrences) return null; + + for (int i = 1; i < items.length; i++) { + final int gap = dayDifference(items[i - 1].date, items[i].date); + + if (gap < InsightThresholds.yearlyMinGap || + gap > InsightThresholds.yearlyMaxGap) { + return null; + } + } + + return (cadence: .yearly, anchorDay: items.last.date.day); +} + +/// A fixed price, a fixed price that changed, or (monthly only) a variable +/// bill. Null when amounts are all over the place. +({bool isFixedPrice, int? priceChangeIndex, int? previousPriceIndex})? _price( + List<double> amounts, + RecurringCadence cadence, +) { + // Two yearly charges can't tell a price change from two different things. + if (amounts.length >= InsightThresholds.recurringMinOccurrences) { + final List<int?> changes = _latestPriceChanges(amounts); + final int? change = changes.last; + + if (change != null) { + return ( + isFixedPrice: true, + priceChangeIndex: change, + previousPriceIndex: changes[change], + ); + } + } + + final double variation = coefficientOfVariation(amounts); + + if (variation <= InsightThresholds.recurringMaxVariation) { + return ( + isFixedPrice: true, + priceChangeIndex: null, + previousPriceIndex: null, + ); + } + + if (cadence == .monthly && + variation <= InsightThresholds.variableChargeMaxVariation) { + return ( + isFixedPrice: false, + priceChangeIndex: null, + previousPriceIndex: null, + ); + } + + return null; +} + +/// Where the latest price change starts in the first `n` [amounts], for +/// every `n`. Each change is measured against the price right before it, +/// so a yearly raise still shows after an earlier one. +List<int?> _latestPriceChanges(List<double> amounts) { + final List<int?> changes = List.filled(amounts.length + 1, null); + + for (int end = 3; end <= amounts.length; end++) { + final double wobble = _wobble(amounts.sublist(0, end)); + + for (int split = end - 1; split >= 2; split--) { + final int start = changes[split] ?? 0; + + // A price needs two charges, so a one-off discount isn't one. + if (split - start < 2) continue; + + if (_isPriceChange( + amounts.sublist(start, split), + amounts.sublist(split, end), + wobble, + )) { + changes[end] = split; + break; + } + } + } + + return changes; +} + +/// Typical move from one charge to the next. Unlike the spread, a price +/// change barely moves it. +double _wobble(List<double> amounts) => median([ + for (int i = 1; i < amounts.length; i++) + if (amounts[i - 1] > 0.0) + (amounts[i] - amounts[i - 1]).abs() / amounts[i - 1], +]); + +/// Both sides steady, and the step clearly bigger than their wobble (a +/// charge converted from another currency moves a little every month). +bool _isPriceChange(List<double> before, List<double> after, double wobble) { + final double noise = math.max( + wobble, + math.max(coefficientOfVariation(before), coefficientOfVariation(after)), + ); + + if (noise > InsightThresholds.priceChangeMaxNoise) return false; + + final double previous = median(before); + final double change = (median(after) - previous).abs(); + + if (previous <= 0.0) return false; + + final double ratio = change / previous; + + return change >= InsightThresholds.priceChangeMinAmount && + ratio >= + math.max( + InsightThresholds.priceChangeMinRatio, + noise * InsightThresholds.priceChangeNoiseMultiple, + ) && + ratio <= InsightThresholds.priceChangeMaxRatio; +} + +/// Title matches and either amount is close to the template's. +bool _matchesTemplate( + String? seriesTitle, + List<double> amounts, + List<InsightRecurringTemplate> templates, +) { + final String? title = normalizeTitle(seriesTitle); + if (title == null) return false; + + return templates.any((template) { + if (normalizeTitle(template.title) != title) return false; + if (template.variableAmount) return true; + + final double amount = template.amount.abs(); + final double tolerance = amount * InsightThresholds.templateAmountTolerance; + + return amounts.any((value) => (value - amount).abs() <= tolerance); + }); +} + +bool _isVariableTemplate( + String key, + List<InsightRecurringTemplate> templates, +) => templates.any( + (template) => + template.variableAmount && + template.uuid != null && + (key == "recurring:${template.uuid}" || + key.startsWith("recurring:${template.uuid}:")), +); + +/// Ties go to the most recent value. +String? _mostCommon(Iterable<String?> values) { + final List<String?> list = values.toList(); + final Map<String?, int> counts = {}; + + for (final String? value in list) { + counts[value] = (counts[value] ?? 0) + 1; + } + + String? result; + int bestCount = 0; + + for (final String? value in list.reversed) { + if (counts[value]! > bestCount) { + bestCount = counts[value]!; + result = value; + } + } + + return result; +} diff --git a/lib/data/legacy_simple_icons_codepoints.dart b/lib/data/legacy_simple_icons_codepoints.dart index 569b4255..11496835 100644 --- a/lib/data/legacy_simple_icons_codepoints.dart +++ b/lib/data/legacy_simple_icons_codepoints.dart @@ -1,3155 +1,3181 @@ // 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. +// shift between releases. Flow shipped simple_icons 14.6.1 and stored brand +// icons by code point. This table maps a legacy (14.6.1) code point to its +// slug, which is stable across releases, so the migration to +// [SimpleIconFlowIcon] doesn't depend on the bundled simple_icons_flow +// version. Slugs removed upstream (absent from the bundled build) have no +// entry and are left unchanged. library; -const Map<int, int> 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, +const Map<int, String> legacySimpleIconsCodepointToSlug = { + 0xea01: "dotenv", + 0xea02: "dotnet", + 0xea03: "e", + 0xea04: "n1dot1dot1dot1", + 0xea05: "n1and1", + 0xea06: "n1001tracklists", + 0xea07: "n1panel", + 0xea08: "n1password", + 0xea09: "n2fas", + 0xea0a: "n2k", + 0xea0b: "n30secondsofcode", + 0xea0c: "n365datascience", + 0xea0d: "n3m", + 0xea0e: "n42", + 0xea0f: "n4chan", + 0xea10: "n4d", + 0xea11: "n500px", + 0xea12: "n7zip", + 0xea13: "n99designs", + 0xea14: "n9gag", + 0xea15: "aframe", + 0xea17: "abbott", + 0xea18: "abbvie", + 0xea19: "aboutdotme", + 0xea1a: "abstracticon", + 0xea1b: "abusedotch", + 0xea1c: "academia", + 0xea1d: "accenture", + 0xea1e: "accusoft", + 0xea1f: "accuweather", + 0xea20: "acer", + 0xea21: "acm", + 0xea22: "actigraph", + 0xea23: "activision", + 0xea24: "activitypub", + 0xea25: "actix", + 0xea26: "actualbudget", + 0xea27: "acura", + 0xea28: "adafruit", + 0xea29: "adblock", + 0xea2a: "adblockplus", + 0xea2b: "addydotio", + 0xea2c: "adguard", + 0xea2d: "adidas", + 0xea2e: "adminer", + 0xea2f: "adonisjs", + 0xea30: "adp", + 0xea31: "adroll", + 0xea32: "adventofcode", + 0xea33: "adyen", + 0xea34: "aegisauthenticator", + 0xea36: "aeroflot", + 0xea37: "aeromexico", + 0xea3a: "afdian", + 0xea3b: "affine", + 0xea40: "afterpay", + 0xea41: "aftership", + 0xea42: "agora", + 0xea43: "aidungeon", + 0xea44: "aib", + 0xea45: "aiohttp", + 0xea46: "aiqfome", + 0xea47: "aircanada", + 0xea48: "airchina", + 0xea49: "airfrance", + 0xea4a: "airindia", + 0xea4b: "airserbia", + 0xea4c: "airtransat", + 0xea4d: "airasia", + 0xea4e: "airbnb", + 0xea4f: "airbrake", + 0xea50: "airbus", + 0xea51: "airbyte", + 0xea52: "aircall", + 0xea53: "airplayaudio", + 0xea54: "airplayvideo", + 0xea55: "airtable", + 0xea56: "airtel", + 0xea57: "ajv", + 0xea58: "akamai", + 0xea59: "akasaair", + 0xea5a: "akaunting", + 0xea5b: "akiflow", + 0xea5c: "alacritty", + 0xea5d: "alamy", + 0xea5e: "albertheijn", + 0xea5f: "alby", + 0xea60: "alchemy", + 0xea61: "aldinord", + 0xea62: "aldisud", + 0xea64: "alfred", + 0xea65: "algolia", + 0xea66: "algorand", + 0xea67: "alibabacloud", + 0xea68: "alibabadotcom", + 0xea69: "alienware", + 0xea6a: "aliexpress", + 0xea6b: "alipay", + 0xea6c: "allegro", + 0xea6d: "alliedmodders", + 0xea6f: "alltrails", + 0xea70: "almalinux", + 0xea71: "alpinelinux", + 0xea72: "alpinedotjs", + 0xea73: "alternativeto", + 0xea76: "alwaysdata", + 0xea92: "amd", + 0xea93: "ameba", + 0xea94: "americanairlines", + 0xea95: "americanexpress", + 0xea96: "amg", + 0xea97: "amp", + 0xea98: "amul", + 0xea99: "ana", + 0xea9a: "anaconda", + 0xea9b: "analogue", + 0xea9c: "andela", + 0xea9d: "android", + 0xea9e: "androidauto", + 0xea9f: "androidstudio", + 0xeaa0: "angular", + 0xeaa1: "anilist", + 0xeaa2: "animalplanet", + 0xeaa3: "ankermake", + 0xeaa4: "anki", + 0xeaa5: "ansible", + 0xeaa6: "answer", + 0xeaa7: "ansys", + 0xeaa8: "antdesign", + 0xeaa9: "anta", + 0xeaaa: "antena3", + 0xeaab: "anthropic", + 0xeaac: "antv", + 0xeaad: "anycubic", + 0xeaae: "anydesk", + 0xeaaf: "anytype", + 0xeab1: "apache", + 0xeab2: "apacheairflow", + 0xeab3: "apacheant", + 0xeab4: "apachecassandra", + 0xeab5: "apachecloudstack", + 0xeab6: "apachecordova", + 0xeab7: "apachecouchdb", + 0xeab8: "apachedolphinscheduler", + 0xeab9: "apachedruid", + 0xeaba: "apacheecharts", + 0xeabb: "apacheflink", + 0xeabc: "apachefreemarker", + 0xeabd: "apachegroovy", + 0xeabe: "apacheguacamole", + 0xeabf: "apachehadoop", + 0xeac0: "apachehbase", + 0xeac1: "apachehive", + 0xeac2: "apachejmeter", + 0xeac3: "apachekafka", + 0xeac4: "apachekylin", + 0xeac5: "apachelucene", + 0xeac6: "apachemaven", + 0xeac7: "apachenetbeanside", + 0xeac8: "apachenifi", + 0xeac9: "apacheopenoffice", + 0xeaca: "apacheparquet", + 0xeacb: "apachepulsar", + 0xeacc: "apacherocketmq", + 0xeacd: "apachesolr", + 0xeace: "apachespark", + 0xeacf: "apachestorm", + 0xead0: "apachesuperset", + 0xead1: "apachetomcat", + 0xead2: "aparat", + 0xead3: "apifox", + 0xead4: "apmterminals", + 0xead5: "apollographql", + 0xead6: "apostrophe", + 0xead7: "appstore", + 0xead8: "appgallery", + 0xead9: "appian", + 0xeada: "appium", + 0xeadb: "apple", + 0xeadc: "applearcade", + 0xeadd: "applemusic", + 0xeade: "applenews", + 0xeadf: "applepay", + 0xeae0: "applepodcasts", + 0xeae1: "appletv", + 0xeae2: "appsignal", + 0xeae3: "appsmith", + 0xeae4: "appveyor", + 0xeae5: "appwrite", + 0xeae6: "aqua", + 0xeae7: "aral", + 0xeae8: "arangodb", + 0xeae9: "arc", + 0xeaea: "arcgis", + 0xeaeb: "archlinux", + 0xeaec: "archicad", + 0xeaed: "archiveofourown", + 0xeaee: "ardour", + 0xeaef: "arduino", + 0xeaf0: "argo", + 0xeaf1: "argos", + 0xeaf2: "ariakit", + 0xeaf3: "arkecosystem", + 0xeaf4: "arlo", + 0xeaf5: "arm", + 0xeaf6: "armkeil", + 0xeaf7: "arstechnica", + 0xeaf8: "artifacthub", + 0xeaf9: "artixlinux", + 0xeafa: "artstation", + 0xeafb: "arxiv", + 0xeafc: "asahilinux", + 0xeafd: "asana", + 0xeafe: "asciidoctor", + 0xeaff: "asciinema", + 0xeb00: "asda", + 0xeb01: "aseprite", + 0xeb02: "assemblyscript", + 0xeb03: "asterisk", + 0xeb04: "astonmartin", + 0xeb05: "astra", + 0xeb06: "astral", + 0xeb07: "astro", + 0xeb08: "asus", + 0xeb09: "atandt", + 0xeb0a: "atari", + 0xeb0b: "atlasos", + 0xeb0c: "atlassian", + 0xeb0d: "auchan", + 0xeb0e: "audacity", + 0xeb0f: "audi", + 0xeb10: "audible", + 0xeb11: "audiotechnica", + 0xeb12: "audiobookshelf", + 0xeb13: "audioboom", + 0xeb14: "audiomack", + 0xeb15: "aurelia", + 0xeb16: "auth0", + 0xeb17: "authelia", + 0xeb18: "authentik", + 0xeb1a: "autocad", + 0xeb1b: "autocannon", + 0xeb1c: "autodesk", + 0xeb1d: "autodeskmaya", + 0xeb1e: "autodeskrevit", + 0xeb1f: "autohotkey", + 0xeb20: "autoit", + 0xeb21: "automattic", + 0xeb22: "autoprefixer", + 0xeb23: "autozone", + 0xeb24: "avajs", + 0xeb25: "avast", + 0xeb26: "avianca", + 0xeb27: "avira", + 0xeb28: "awesomelists", + 0xeb29: "awesomewm", + 0xeb30: "awwwards", + 0xeb31: "axios", + 0xeb32: "bandrautomation", + 0xeb33: "babel", + 0xeb34: "babelio", + 0xeb35: "babylondotjs", + 0xeb36: "backblaze", + 0xeb37: "backbone", + 0xeb38: "backbonedotjs", + 0xeb39: "backendless", + 0xeb3a: "backstage", + 0xeb3b: "badoo", + 0xeb3c: "baidu", + 0xeb3d: "bakalari", + 0xeb3e: "bamboo", + 0xeb3f: "bambulab", + 0xeb40: "bandcamp", + 0xeb41: "bandlab", + 0xeb42: "bandsintown", + 0xeb43: "bankofamerica", + 0xeb44: "barclays", + 0xeb45: "baremetrics", + 0xeb46: "barmenia", + 0xeb47: "basecamp", + 0xeb48: "baserow", + 0xeb49: "basicattentiontoken", + 0xeb4a: "bastyon", + 0xeb4b: "bat", + 0xeb4c: "bata", + 0xeb4d: "battledotnet", + 0xeb4e: "bazel", + 0xeb4f: "beatport", + 0xeb50: "beats", + 0xeb51: "beatsbydre", + 0xeb52: "beatstars", + 0xeb53: "beekeeperstudio", + 0xeb54: "behance", + 0xeb55: "beijingsubway", + 0xeb56: "bem", + 0xeb57: "bentley", + 0xeb58: "bento", + 0xeb59: "bentobox", + 0xeb5a: "bentoml", + 0xeb5b: "bereal", + 0xeb5c: "betfair", + 0xeb5d: "betterstack", + 0xeb5e: "betterdiscord", + 0xeb5f: "bevy", + 0xeb60: "bigcartel", + 0xeb61: "bigbasket", + 0xeb62: "bigbluebutton", + 0xeb63: "bigcommerce", + 0xeb64: "bilibili", + 0xeb65: "billboard", + 0xeb66: "bim", + 0xeb67: "binance", + 0xeb68: "biolink", + 0xeb69: "biome", + 0xeb6a: "bisecthosting", + 0xeb6b: "bit", + 0xeb6c: "bitbucket", + 0xeb6d: "bitcoin", + 0xeb6e: "bitcoincash", + 0xeb6f: "bitcoinsv", + 0xeb70: "bitcomet", + 0xeb71: "bitdefender", + 0xeb72: "bitly", + 0xeb73: "bitrise", + 0xeb74: "bittorrent", + 0xeb75: "bitwarden", + 0xeb76: "bitwig", + 0xeb77: "blackberry", + 0xeb78: "blackmagicdesign", + 0xeb79: "blazemeter", + 0xeb7a: "blazor", + 0xeb7b: "blender", + 0xeb7c: "blockbench", + 0xeb7d: "blockchaindotcom", + 0xeb7e: "blogger", + 0xeb7f: "bloglovin", + 0xeb80: "blueprint", + 0xeb81: "bluesky", + 0xeb82: "bluesound", + 0xeb83: "bluetooth", + 0xeb84: "bmcsoftware", + 0xeb85: "bmw", + 0xeb86: "bnbchain", + 0xeb87: "boardgamegeek", + 0xeb88: "boat", + 0xeb89: "boehringeringelheim", + 0xeb8a: "boeing", + 0xeb8b: "bombardier", + 0xeb8c: "bookalope", + 0xeb8d: "bookbub", + 0xeb8e: "bookmeter", + 0xeb8f: "bookmyshow", + 0xeb90: "bookstack", + 0xeb91: "boost", + 0xeb92: "boosty", + 0xeb93: "boots", + 0xeb94: "bootstrap", + 0xeb95: "borgbackup", + 0xeb96: "bosch", + 0xeb97: "bose", + 0xeb98: "botblecms", + 0xeb99: "boulanger", + 0xeb9a: "bower", + 0xeb9b: "box", + 0xeb9c: "boxysvg", + 0xeb9d: "braintree", + 0xeb9e: "brandfolder", + 0xeb9f: "brave", + 0xeba0: "breaker", + 0xeba1: "brenntag", + 0xeba2: "brevo", + 0xeba3: "brex", + 0xeba4: "bricks", + 0xeba5: "britishairways", + 0xeba6: "broadcom", + 0xeba7: "bruno", + 0xeba8: "bsd", + 0xeba9: "bspwm", + 0xebaa: "bt", + 0xebab: "buddy", + 0xebac: "budibase", + 0xebad: "buefy", + 0xebae: "buffer", + 0xebaf: "bugatti", + 0xebb0: "bugcrowd", + 0xebb2: "buhl", + 0xebb3: "buildkite", + 0xebb4: "builtbybit", + 0xebb5: "bukalapak", + 0xebb6: "bulma", + 0xebb7: "bun", + 0xebb8: "bungie", + 0xebb9: "bunq", + 0xebba: "burgerking", + 0xebbb: "burpsuite", + 0xebbc: "burton", + 0xebbd: "buymeacoffee", + 0xebbe: "buysellads", + 0xebbf: "buzzfeed", + 0xebc0: "bvg", + 0xebc1: "byjus", + 0xebc2: "bytedance", + 0xebc3: "c", + 0xebc4: "cplusplus", + 0xebc5: "cplusplusbuilder", + 0xebc6: "cachet", + 0xebc7: "caddy", + 0xebc8: "cadillac", + 0xebc9: "cafepress", + 0xebca: "cairographics", + 0xebcb: "cairometro", + 0xebcc: "caixabank", + 0xebcd: "cakephp", + 0xebce: "caldotcom", + 0xebcf: "calendly", + 0xebd0: "calibreweb", + 0xebd1: "campaignmonitor", + 0xebd2: "camunda", + 0xebd3: "canonical", + 0xebd5: "canvas", + 0xebd6: "capacitor", + 0xebd7: "caprover", + 0xebd8: "carthrottle", + 0xebd9: "cardano", + 0xebda: "carlsberggroup", + 0xebdb: "carrd", + 0xebdc: "carrefour", + 0xebdd: "carto", + 0xebde: "cashapp", + 0xebdf: "castbox", + 0xebe0: "castorama", + 0xebe1: "castro", + 0xebe2: "caterpillar", + 0xebe3: "cbc", + 0xebe4: "cbs", + 0xebe5: "ccc", + 0xebe6: "ccleaner", + 0xebe7: "cdprojekt", + 0xebe8: "ce", + 0xebe9: "celery", + 0xebea: "celestron", + 0xebeb: "centos", + 0xebec: "ceph", + 0xebed: "cesium", + 0xebee: "chai", + 0xebef: "chainguard", + 0xebf0: "chainlink", + 0xebf1: "chakraui", + 0xebf2: "channel4", + 0xebf3: "charles", + 0xebf4: "chartdotjs", + 0xebf5: "chartmogul", + 0xebf6: "chase", + 0xebf7: "chatbot", + 0xebf8: "chatwoot", + 0xebf9: "checkio", + 0xebfa: "checkmarx", + 0xebfb: "checkmk", + 0xebfc: "chedraui", + 0xebfd: "cheerio", + 0xebfe: "chef", + 0xebff: "chemex", + 0xec00: "chessdotcom", + 0xec01: "chevrolet", + 0xec02: "chianetwork", + 0xec03: "chinaeasternairlines", + 0xec04: "chinasouthernairlines", + 0xec05: "chocolatey", + 0xec06: "chromatic", + 0xec07: "chromewebstore", + 0xec09: "chrysler", + 0xec0a: "chupachups", + 0xec0b: "cilium", + 0xec0c: "cinema4d", + 0xec0d: "cinnamon", + 0xec0e: "circle", + 0xec0f: "circleci", + 0xec10: "circuitverse", + 0xec11: "cirrusci", + 0xec12: "cisco", + 0xec13: "citrix", + 0xec14: "citroen", + 0xec15: "civicrm", + 0xec16: "civo", + 0xec17: "clarifai", + 0xec18: "claris", + 0xec19: "clarivate", + 0xec1a: "claude", + 0xec1b: "clerk", + 0xec1c: "clevercloud", + 0xec1d: "clickhouse", + 0xec1e: "clickup", + 0xec1f: "clion", + 0xec20: "clockify", + 0xec21: "clojure", + 0xec22: "cloud66", + 0xec23: "cloudfoundry", + 0xec24: "cloudbees", + 0xec25: "cloudcannon", + 0xec26: "cloudera", + 0xec27: "cloudflare", + 0xec28: "cloudflarepages", + 0xec29: "cloudflareworkers", + 0xec2a: "cloudinary", + 0xec2b: "cloudron", + 0xec2c: "cloudsmith", + 0xec2d: "cloudways", + 0xec2e: "clubforce", + 0xec2f: "clubhouse", + 0xec30: "clyp", + 0xec31: "cmake", + 0xec32: "cncf", + 0xec33: "cnes", + 0xec34: "cnet", + 0xec35: "cnn", + 0xec36: "coop", + 0xec37: "cocacola", + 0xec38: "cockpit", + 0xec39: "cockroachlabs", + 0xec3a: "cocoapods", + 0xec3b: "cocos", + 0xec3c: "coda", + 0xec3d: "codacy", + 0xec3e: "codeclimate", + 0xec3f: "codeblocks", + 0xec40: "codeberg", + 0xec41: "codecademy", + 0xec42: "codeceptjs", + 0xec43: "codechef", + 0xec44: "codecov", + 0xec45: "codecrafters", + 0xec46: "codefactor", + 0xec47: "codeforces", + 0xec48: "codefresh", + 0xec49: "codeigniter", + 0xec4b: "codemagic", + 0xec4c: "codementor", + 0xec4d: "codemirror", + 0xec4e: "codenewbie", + 0xec50: "codeproject", + 0xec51: "coder", + 0xec52: "codersrank", + 0xec53: "coderwall", + 0xec54: "codesandbox", + 0xec55: "codeship", + 0xec56: "codesignal", + 0xec57: "codestream", + 0xec58: "codewars", + 0xec59: "codingninjas", + 0xec5a: "codingame", + 0xec5b: "codio", + 0xec5c: "coffeescript", + 0xec5d: "coggle", + 0xec60: "coinbase", + 0xec61: "coinmarketcap", + 0xec62: "collaboraonline", + 0xec63: "comicfury", + 0xec64: "comma", + 0xec65: "commerzbank", + 0xec66: "commitlint", + 0xec67: "commodore", + 0xec68: "commonlisp", + 0xec69: "commonworkflowlanguage", + 0xec6a: "compilerexplorer", + 0xec6b: "composer", + 0xec6c: "comptia", + 0xec6d: "comsol", + 0xec6e: "conan", + 0xec6f: "concourse", + 0xec70: "condaforge", + 0xec71: "conekta", + 0xec72: "confluence", + 0xec73: "construct3", + 0xec74: "consul", + 0xec75: "contabo", + 0xec76: "contactlesspayment", + 0xec77: "containerd", + 0xec78: "contao", + 0xec79: "contentful", + 0xec7a: "contentstack", + 0xec7b: "continente", + 0xec7c: "contributorcovenant", + 0xec7d: "conventionalcommits", + 0xec7e: "convertio", + 0xec7f: "cookiecutter", + 0xec80: "coolermaster", + 0xec81: "copaairlines", + 0xec82: "coppel", + 0xec83: "cora", + 0xec84: "coreldraw", + 0xec85: "coronaengine", + 0xec86: "coronarenderer", + 0xec87: "corsair", + 0xec88: "couchbase", + 0xec89: "counterstrike", + 0xec8a: "countingworkspro", + 0xec8b: "coursera", + 0xec8c: "coveralls", + 0xec8d: "coze", + 0xec8e: "cpanel", + 0xec8f: "craftcms", + 0xec90: "craftsman", + 0xec91: "cratedb", + 0xec92: "crayon", + 0xec93: "creality", + 0xec94: "createreactapp", + 0xec95: "creativecommons", + 0xec96: "creativetechnology", + 0xec97: "credly", + 0xec98: "crehana", + 0xec99: "crewunited", + 0xec9a: "crewai", + 0xec9b: "criticalrole", + 0xec9c: "crowdin", + 0xec9d: "crowdsource", + 0xec9e: "crunchbase", + 0xec9f: "crunchyroll", + 0xeca0: "cryengine", + 0xeca1: "cryptomator", + 0xeca2: "cryptpad", + 0xeca3: "crystal", + 0xeca4: "csdn", + 0xeca5: "css", + 0xeca6: "cssdesignawards", + 0xeca7: "cssmodules", + 0xeca8: "csswizardry", + 0xecaa: "cts", + 0xecab: "cucumber", + 0xecac: "cultura", + 0xecad: "curl", + 0xecae: "curseforge", + 0xecaf: "customink", + 0xecb0: "cyberdefenders", + 0xecb1: "cycling74", + 0xecb2: "cypress", + 0xecb3: "cytoscapedotjs", + 0xecb4: "d", + 0xecb5: "dedge", + 0xecb6: "dwavesystems", + 0xecb7: "d3", + 0xecb8: "dacia", + 0xecb9: "daf", + 0xecba: "dailydotdev", + 0xecbb: "dailymotion", + 0xecbc: "daisyui", + 0xecbd: "dapr", + 0xecbe: "darkreader", + 0xecbf: "dart", + 0xecc0: "darty", + 0xecc1: "daserste", + 0xecc2: "dash", + 0xecc3: "dashlane", + 0xecc4: "dask", + 0xecc5: "dassaultsystemes", + 0xecc6: "datadotai", + 0xecc7: "databricks", + 0xecc8: "datacamp", + 0xecc9: "datadog", + 0xecca: "datagrip", + 0xeccb: "dataiku", + 0xeccc: "datastax", + 0xeccd: "datefns", + 0xecce: "datev", + 0xeccf: "datocms", + 0xecd0: "datto", + 0xecd1: "davinciresolve", + 0xecd2: "dazhongdianping", + 0xecd3: "dazn", + 0xecd4: "dbeaver", + 0xecd5: "dblp", + 0xecd7: "dcentertainment", + 0xecd8: "delonghi", + 0xecd9: "debian", + 0xecda: "debridlink", + 0xecdb: "decapcms", + 0xecdc: "decentraland", + 0xecdd: "deepcool", + 0xecde: "deepgram", + 0xecdf: "deepin", + 0xece0: "deepl", + 0xece1: "deepnote", + 0xece2: "deliveroo", + 0xece3: "dell", + 0xece4: "delphi", + 0xece5: "delta", + 0xece6: "deluge", + 0xece7: "deno", + 0xece8: "denon", + 0xece9: "dependabot", + 0xecea: "depositphotos", + 0xeceb: "derspiegel", + 0xecec: "deutschebahn", + 0xeced: "deutschebank", + 0xecee: "deutschepost", + 0xecef: "deutschetelekom", + 0xecf0: "deutschewelle", + 0xecf1: "devdotto", + 0xecf2: "devbox", + 0xecf3: "devexpress", + 0xecf4: "deviantart", + 0xecf5: "devpost", + 0xecf6: "devrant", + 0xecf7: "dgraph", + 0xecf8: "dhl", + 0xecf9: "diagramsdotnet", + 0xecfa: "dialogflow", + 0xecfb: "diaspora", + 0xecfc: "dictionarydotcom", + 0xecfd: "digg", + 0xecfe: "digikeyelectronics", + 0xecff: "digitalocean", + 0xed00: "dinersclub", + 0xed01: "dior", + 0xed02: "directus", + 0xed03: "discogs", + 0xed04: "discord", + 0xed05: "discourse", + 0xed06: "discover", + 0xed07: "disqus", + 0xed08: "disroot", + 0xed09: "distrokid", + 0xed0a: "django", + 0xed0b: "dji", + 0xed0c: "dlib", + 0xed0d: "dlna", + 0xed0e: "dm", + 0xed0f: "docker", + 0xed10: "docsdotrs", + 0xed11: "docsify", + 0xed12: "doctrine", + 0xed13: "docusaurus", + 0xed14: "dogecoin", + 0xed15: "doi", + 0xed16: "dolby", + 0xed17: "doordash", + 0xed18: "dota2", + 0xed19: "douban", + 0xed1a: "doubanread", + 0xed1b: "dovecot", + 0xed1c: "dovetail", + 0xed1d: "downdetector", + 0xed1e: "doxygen", + 0xed1f: "dpd", + 0xed20: "dragonframe", + 0xed21: "draugiemdotlv", + 0xed22: "dreamstime", + 0xed23: "dribbble", + 0xed24: "drizzle", + 0xed25: "drone", + 0xed26: "drooble", + 0xed27: "dropbox", + 0xed28: "drupal", + 0xed29: "dsautomobiles", + 0xed2a: "dts", + 0xed2b: "dtube", + 0xed2c: "ducati", + 0xed2d: "duckdb", + 0xed2e: "duckduckgo", + 0xed2f: "dungeonsanddragons", + 0xed30: "dunked", + 0xed31: "dunzo", + 0xed32: "duolingo", + 0xed33: "duplicati", + 0xed34: "dvc", + 0xed35: "dwm", + 0xed36: "dynatrace", + 0xed37: "edotleclerc", + 0xed38: "e3", + 0xed39: "ea", + 0xed3a: "eac", + 0xed3b: "eagle", + 0xed3c: "easyeda", + 0xed3d: "easyjet", + 0xed3e: "ebay", + 0xed3f: "ebox", + 0xed40: "eclipseadoptium", + 0xed41: "eclipseche", + 0xed42: "eclipseide", + 0xed43: "eclipsejetty", + 0xed44: "eclipsemosquitto", + 0xed45: "eclipsevertdotx", + 0xed46: "ecosia", + 0xed47: "ecovacs", + 0xed48: "edeka", + 0xed49: "edgeimpulse", + 0xed4a: "editorconfig", + 0xed4b: "educative", + 0xed4c: "edx", + 0xed4d: "egghead", + 0xed4e: "egnyte", + 0xed4f: "eight", + 0xed50: "eightsleep", + 0xed51: "ejs", + 0xed52: "elastic", + 0xed53: "elasticcloud", + 0xed54: "elasticstack", + 0xed55: "elasticsearch", + 0xed56: "elavon", + 0xed57: "electron", + 0xed58: "electronfiddle", + 0xed59: "electronbuilder", + 0xed5a: "elegoo", + 0xed5b: "element", + 0xed5c: "elementary", + 0xed5d: "elementor", + 0xed5e: "elevenlabs", + 0xed5f: "eleventy", + 0xed60: "elgato", + 0xed61: "elixir", + 0xed62: "elm", + 0xed63: "elsevier", + 0xed64: "embarcadero", + 0xed65: "embark", + 0xed66: "emberdotjs", + 0xed67: "emby", + 0xed68: "emirates", + 0xed69: "emlakjet", + 0xed6b: "endeavouros", + 0xed6d: "enpass", + 0xed6e: "enterprisedb", + 0xed6f: "envato", + 0xed70: "envoyproxy", + 0xed71: "epel", + 0xed72: "epicgames", + 0xed73: "epson", + 0xed74: "equinixmetal", + 0xed75: "eraser", + 0xed76: "ericsson", + 0xed77: "erlang", + 0xed78: "erpnext", + 0xed79: "esbuild", + 0xed7a: "esea", + 0xed7b: "eslgaming", + 0xed7c: "eslint", + 0xed7d: "esotericsoftware", + 0xed7e: "esphome", + 0xed7f: "espressif", + 0xed80: "esri", + 0xed81: "etcd", + 0xed82: "ethereum", + 0xed83: "ethers", + 0xed84: "ethiopianairlines", + 0xed85: "etihadairways", + 0xed86: "etsy", + 0xed87: "europeanunion", + 0xed88: "eventstore", + 0xed8a: "evernote", + 0xed8b: "excalidraw", + 0xed8c: "exercism", + 0xed8d: "exordo", + 0xed8e: "exoscale", + 0xed8f: "expedia", + 0xed90: "expensify", + 0xed91: "expertsexchange", + 0xed92: "expo", + 0xed93: "express", + 0xed94: "expressdotcom", + 0xed95: "expressvpn", + 0xed96: "eyeem", + 0xed97: "fdroid", + 0xed99: "fsharp", + 0xed9a: "f1", + 0xed9b: "f5", + 0xed9c: "facebook", + 0xed9d: "facebookgaming", + 0xed9e: "facebooklive", + 0xed9f: "faceit", + 0xeda0: "facepunch", + 0xeda1: "fairphone", + 0xeda2: "falco", + 0xeda3: "falcon", + 0xeda4: "fampay", + 0xeda5: "fandango", + 0xeda6: "fandom", + 0xeda7: "fanfou", + 0xeda8: "fantom", + 0xeda9: "farcaster", + 0xedaa: "fareharbor", + 0xedab: "farfetch", + 0xedac: "fastapi", + 0xedad: "fastify", + 0xedae: "fastlane", + 0xedaf: "fastly", + 0xedb0: "fathom", + 0xedb1: "fauna", + 0xedb2: "favro", + 0xedb3: "fcc", + 0xedb4: "fedex", + 0xedb5: "fedora", + 0xedb6: "feedly", + 0xedb7: "ferrari", + 0xedb8: "ferrarinv", + 0xedb9: "ferretdb", + 0xedba: "ffmpeg", + 0xedbb: "fi", + 0xedbc: "fiat", + 0xedbd: "fidoalliance", + 0xedbe: "fifa", + 0xedbf: "fig", + 0xedc0: "figma", + 0xedc1: "figshare", + 0xedc2: "fila", + 0xedc3: "filament", + 0xedc4: "filedotio", + 0xedc5: "filen", + 0xedc6: "files", + 0xedc7: "filezilla", + 0xedc8: "fineco", + 0xedc9: "fing", + 0xedca: "firebase", + 0xedcb: "firefish", + 0xedcc: "fireflyiii", + 0xedcd: "firefox", + 0xedce: "firefoxbrowser", + 0xedcf: "fireship", + 0xedd0: "firewalla", + 0xedd1: "first", + 0xedd2: "fishshell", + 0xedd3: "fitbit", + 0xedd4: "fivem", + 0xedd5: "fiverr", + 0xedd6: "fizz", + 0xedd7: "flashforge", + 0xedd8: "flask", + 0xedd9: "flat", + 0xedda: "flathub", + 0xeddb: "flatpak", + 0xeddc: "flickr", + 0xeddd: "flightaware", + 0xedde: "flipboard", + 0xede0: "floatplane", + 0xede1: "flood", + 0xede2: "fluentbit", + 0xede3: "fluentd", + 0xede4: "fluke", + 0xede5: "flutter", + 0xede6: "flux", + 0xede7: "flydotio", + 0xede8: "flyway", + 0xede9: "fmod", + 0xedea: "fnac", + 0xedeb: "folium", + 0xedec: "fonoma", + 0xeded: "fontawesome", + 0xedee: "fontbase", + 0xedef: "fontforge", + 0xedf0: "foobar2000", + 0xedf1: "foodpanda", + 0xedf2: "ford", + 0xedf3: "forgejo", + 0xedf4: "formik", + 0xedf5: "formspree", + 0xedf6: "formstack", + 0xedf7: "fortinet", + 0xedf8: "fortran", + 0xedf9: "fossa", + 0xedfa: "fossilscm", + 0xedfb: "foundryvirtualtabletop", + 0xedfc: "foursquare", + 0xedfd: "fox", + 0xedfe: "foxtel", + 0xedff: "fozzy", + 0xee00: "framer", + 0xee01: "framework", + 0xee02: "framework7", + 0xee03: "franprix", + 0xee04: "frappe", + 0xee05: "fraunhofergesellschaft", + 0xee06: "freebsd", + 0xee07: "freecad", + 0xee08: "freecodecamp", + 0xee09: "freedesktopdotorg", + 0xee0a: "freelancer", + 0xee0b: "freelancermap", + 0xee0c: "freenas", + 0xee0d: "freenet", + 0xee0e: "freepik", + 0xee0f: "fresh", + 0xee10: "frontendmentor", + 0xee11: "frontify", + 0xee12: "fubo", + 0xee13: "fueler", + 0xee14: "fugacloud", + 0xee15: "fujifilm", + 0xee16: "fujitsu", + 0xee17: "furaffinity", + 0xee18: "furrynetwork", + 0xee19: "fusionauth", + 0xee1a: "futurelearn", + 0xee1b: "fyle", + 0xee1c: "g2", + 0xee1d: "g2a", + 0xee1e: "g2g", + 0xee1f: "galaxus", + 0xee20: "gamedeveloper", + 0xee21: "gamejolt", + 0xee22: "gamescience", + 0xee23: "gamebanana", + 0xee24: "gameloft", + 0xee25: "gamemaker", + 0xee26: "garmin", + 0xee27: "gatling", + 0xee28: "gatsby", + 0xee29: "gcore", + 0xee2a: "gdal", + 0xee2b: "geeksforgeeks", + 0xee2c: "generalelectric", + 0xee2d: "generalmotors", + 0xee2e: "genius", + 0xee2f: "gentoo", + 0xee30: "geocaching", + 0xee31: "geode", + 0xee32: "geopandas", + 0xee33: "gerrit", + 0xee34: "getx", + 0xee35: "ghost", + 0xee36: "ghostery", + 0xee37: "gimp", + 0xee38: "gin", + 0xee39: "giphy", + 0xee3a: "git", + 0xee3b: "gitextensions", + 0xee3c: "gitforwindows", + 0xee3d: "gitlfs", + 0xee3e: "gitbook", + 0xee3f: "gitconnected", + 0xee40: "gitea", + 0xee41: "gitee", + 0xee42: "github", + 0xee43: "githubactions", + 0xee44: "githubcopilot", + 0xee45: "githubpages", + 0xee46: "githubsponsors", + 0xee47: "gitignoredotio", + 0xee48: "gitkraken", + 0xee49: "gitlab", + 0xee4a: "gitpod", + 0xee4b: "gitter", + 0xee4c: "gldotinet", + 0xee4d: "glassdoor", + 0xee4e: "glide", + 0xee4f: "glitch", + 0xee50: "globus", + 0xee51: "glovo", + 0xee52: "gltf", + 0xee53: "gmail", + 0xee54: "gmx", + 0xee55: "gnome", + 0xee56: "gnometerminal", + 0xee57: "gnu", + 0xee58: "gnubash", + 0xee59: "gnuemacs", + 0xee5a: "gnuicecat", + 0xee5b: "gnuprivacyguard", + 0xee5c: "gnusocial", + 0xee5d: "go", + 0xee5e: "gocd", + 0xee5f: "godaddy", + 0xee60: "godotengine", + 0xee61: "gofundme", + 0xee62: "gogdotcom", + 0xee63: "gojek", + 0xee64: "goland", + 0xee66: "goldmansachs", + 0xee67: "goodreads", + 0xee68: "google", + 0xee69: "googleadmob", + 0xee6a: "googleads", + 0xee6b: "googleadsense", + 0xee6c: "googleanalytics", + 0xee6d: "googleappsscript", + 0xee6e: "googleassistant", + 0xee6f: "googleauthenticator", + 0xee70: "googlebigquery", + 0xee71: "googlebigtable", + 0xee72: "googlecalendar", + 0xee73: "googlecampaignmanager360", + 0xee74: "googlecardboard", + 0xee75: "googlechat", + 0xee76: "googlechrome", + 0xee77: "googlechronicle", + 0xee78: "googleclassroom", + 0xee79: "googlecloud", + 0xee7a: "googlecloudcomposer", + 0xee7b: "googlecloudspanner", + 0xee7c: "googlecloudstorage", + 0xee7d: "googlecolab", + 0xee7e: "googlecontaineroptimizedos", + 0xee80: "googledataflow", + 0xee81: "googledataproc", + 0xee82: "googledisplayandvideo360", + 0xee83: "googledocs", + 0xee84: "googledrive", + 0xee85: "googleearth", + 0xee86: "googleearthengine", + 0xee88: "googlefonts", + 0xee89: "googleforms", + 0xee8a: "googlegemini", + 0xee8b: "googlehome", + 0xee8c: "googlekeep", + 0xee8d: "googlelens", + 0xee8e: "googlemaps", + 0xee8f: "googlemarketingplatform", + 0xee90: "googlemeet", + 0xee91: "googlemessages", + 0xee92: "googlenearby", + 0xee93: "googlenews", + 0xee94: "googlepay", + 0xee95: "googlephotos", + 0xee96: "googleplay", + 0xee97: "googlepubsub", + 0xee98: "googlescholar", + 0xee99: "googlesearchconsole", + 0xee9a: "googlesheets", + 0xee9b: "googleslides", + 0xee9c: "googlestreetview", + 0xee9d: "googletagmanager", + 0xee9e: "googletasks", + 0xee9f: "googletranslate", + 0xeea0: "gotomeeting", + 0xeea1: "grab", + 0xeea2: "gradio", + 0xeea3: "gradle", + 0xeea4: "gradleplaypublisher", + 0xeea5: "grafana", + 0xeea6: "grammarly", + 0xeea7: "grandfrais", + 0xeea8: "grapheneos", + 0xeea9: "graphite", + 0xeeaa: "graphql", + 0xeeab: "grav", + 0xeeac: "gravatar", + 0xeead: "graylog", + 0xeeae: "greasyfork", + 0xeeaf: "greatlearning", + 0xeeb0: "greenhouse", + 0xeeb1: "greensock", + 0xeeb2: "griddotai", + 0xeeb3: "gridsome", + 0xeeb4: "grocy", + 0xeeb5: "groupme", + 0xeeb6: "groupon", + 0xeeb8: "grunt", + 0xeeb9: "gsk", + 0xeeba: "gsmarenadotcom", + 0xeebb: "gstreamer", + 0xeebc: "gtk", + 0xeebd: "guangzhoumetro", + 0xeebe: "guilded", + 0xeebf: "guitarpro", + 0xeec0: "gulp", + 0xeec1: "gumroad", + 0xeec2: "gumtree", + 0xeec3: "gunicorn", + 0xeec4: "gurobi", + 0xeec5: "gusto", + 0xeec6: "gutenberg", + 0xeec7: "handm", + 0xeec8: "h3", + 0xeec9: "habr", + 0xeeca: "hackclub", + 0xeecb: "hackthebox", + 0xeecc: "hackaday", + 0xeecd: "hackernoon", + 0xeece: "hackerearth", + 0xeecf: "hackerone", + 0xeed0: "hackerrank", + 0xeed1: "hackster", + 0xeed2: "hal", + 0xeed3: "handlebarsdotjs", + 0xeed4: "handshake", + 0xeed5: "handshakeProtocol", + 0xeed6: "happycow", + 0xeed7: "harbor", + 0xeed8: "harmonyos", + 0xeed9: "hashicorp", + 0xeeda: "hashnode", + 0xeedb: "haskell", + 0xeedc: "hasura", + 0xeedd: "hatenabookmark", + 0xeede: "haveibeenpwned", + 0xeedf: "haxe", + 0xeee0: "hbo", + 0xeee1: "hcl", + 0xeee2: "hdfcbank", + 0xeee3: "headlessui", + 0xeee4: "headphonezone", + 0xeee5: "headspace", + 0xeee6: "hearth", + 0xeee7: "hearthisdotat", + 0xeee8: "hedera", + 0xeee9: "helium", + 0xeeea: "helix", + 0xeeeb: "hellofresh", + 0xeeec: "hellyhansen", + 0xeeed: "helm", + 0xeeee: "helpscout", + 0xeeef: "helpdesk", + 0xeef0: "hepsiemlak", + 0xeef1: "here", + 0xeef2: "hermes", + 0xeef4: "hetzner", + 0xeef5: "hevy", + 0xeef6: "hexlet", + 0xeef7: "hexo", + 0xeef8: "hey", + 0xeef9: "hibob", + 0xeefa: "hibernate", + 0xeefb: "hilton", + 0xeefc: "hiltonhotelsandresorts", + 0xeefd: "hitachi", + 0xeefe: "hive", + 0xeeff: "hiveBlockchain", + 0xef00: "hivemq", + 0xef01: "homarr", + 0xef02: "homeassistant", + 0xef03: "homeassistantcommunitystore", + 0xef04: "homeadvisor", + 0xef05: "homebrew", + 0xef06: "homebridge", + 0xef07: "homepage", + 0xef08: "homify", + 0xef09: "honda", + 0xef0a: "honey", + 0xef0b: "honeybadger", + 0xef0c: "honeygain", + 0xef0d: "hono", + 0xef0e: "honor", + 0xef0f: "hootsuite", + 0xef10: "hoppscotch", + 0xef11: "hostinger", + 0xef12: "hotelsdotcom", + 0xef13: "hotjar", + 0xef14: "hotwire", + 0xef15: "houdini", + 0xef16: "houzz", + 0xef17: "hp", + 0xef18: "hsbc", + 0xef19: "htc", + 0xef1a: "htcvive", + 0xef1b: "htmlacademy", + 0xef1c: "html5", + 0xef1d: "htmx", + 0xef1e: "htop", + 0xef1f: "httpie", + 0xef20: "huawei", + 0xef21: "hubspot", + 0xef22: "huggingface", + 0xef23: "hugo", + 0xef24: "humblebundle", + 0xef25: "humhub", + 0xef26: "hungryjacks", + 0xef27: "husqvarna", + 0xef28: "hyper", + 0xef29: "hyperskill", + 0xef2a: "hyperx", + 0xef2b: "hypothesis", + 0xef2c: "hyprland", + 0xef2d: "hyundai", + 0xef2e: "i18next", + 0xef2f: "i3", + 0xef30: "iata", + 0xef31: "ibeacon", + 0xef32: "iberia", + 0xef33: "iced", + 0xef34: "iceland", + 0xef35: "icicibank", + 0xef36: "icinga", + 0xef37: "icloud", + 0xef38: "icomoon", + 0xef39: "icon", + 0xef3a: "iconfinder", + 0xef3b: "iconify", + 0xef3c: "iconjar", + 0xef3d: "icons8", + 0xef3e: "icq", + 0xef3f: "ieee", + 0xef40: "ifixit", + 0xef41: "ifood", + 0xef42: "ifttt", + 0xef43: "igdb", + 0xef44: "ign", + 0xef45: "iheartradio", + 0xef46: "ikea", + 0xef47: "iledefrancemobilites", + 0xef48: "imagedotsc", + 0xef49: "imagej", + 0xef4a: "imdb", + 0xef4b: "imessage", + 0xef4c: "imgur", + 0xef4d: "immer", + 0xef4e: "immich", + 0xef4f: "imou", + 0xef50: "improvmx", + 0xef51: "indeed", + 0xef52: "indiansuperleague", + 0xef53: "indiehackers", + 0xef54: "indigo", + 0xef55: "inductiveautomation", + 0xef56: "inertia", + 0xef57: "infiniti", + 0xef58: "influxdb", + 0xef59: "infomaniak", + 0xef5a: "infoq", + 0xef5c: "infosys", + 0xef5d: "infracost", + 0xef5e: "ingress", + 0xef5f: "inkdrop", + 0xef60: "inkscape", + 0xef61: "inoreader", + 0xef62: "insomnia", + 0xef63: "inspire", + 0xef64: "insta360", + 0xef65: "instacart", + 0xef66: "instagram", + 0xef67: "instapaper", + 0xef68: "instatus", + 0xef69: "instructables", + 0xef6a: "instructure", + 0xef6b: "intel", + 0xef6c: "intellijidea", + 0xef6d: "interactiondesignfoundation", + 0xef6e: "interactjs", + 0xef6f: "interbase", + 0xef70: "intercom", + 0xef71: "intermarche", + 0xef72: "internetarchive", + 0xef73: "internetcomputer", + 0xef74: "intigriti", + 0xef75: "intuit", + 0xef77: "invoiceninja", + 0xef78: "iobroker", + 0xef79: "ionic", + 0xef7a: "ionos", + 0xef7b: "ios", + 0xef7c: "iota", + 0xef7d: "ipfs", + 0xef7e: "iris", + 0xef7f: "irobot", + 0xef80: "isc2", + 0xef81: "issuu", + 0xef82: "istio", + 0xef83: "itchdotio", + 0xef84: "iterm2", + 0xef85: "itunes", + 0xef86: "itvx", + 0xef87: "iveco", + 0xef88: "jabber", + 0xef89: "jaeger", + 0xef8c: "jameson", + 0xef8d: "jamstack", + 0xef8e: "japanairlines", + 0xef8f: "jasmine", + 0xef90: "javascript", + 0xef91: "jbl", + 0xef92: "jcb", + 0xef93: "jeep", + 0xef94: "jekyll", + 0xef95: "jellyfin", + 0xef96: "jenkins", + 0xef97: "jest", + 0xef98: "jet", + 0xef99: "jetblue", + 0xef9a: "jetbrains", + 0xef9b: "jetpackcompose", + 0xef9c: "jfrog", + 0xef9d: "jfrogpipelines", + 0xef9e: "jhipster", + 0xef9f: "jinja", + 0xefa0: "jio", + 0xefa1: "jira", + 0xefa2: "jirasoftware", + 0xefa3: "jitpack", + 0xefa4: "jitsi", + 0xefa5: "johndeere", + 0xefa6: "joomla", + 0xefa7: "joplin", + 0xefa8: "jordan", + 0xefa9: "jouav", + 0xefaa: "jovian", + 0xefab: "jpeg", + 0xefac: "jquery", + 0xefad: "jrgroup", + 0xefae: "jsdelivr", + 0xefaf: "jsfiddle", + 0xefb0: "json", + 0xefb1: "jsonwebtokens", + 0xefb2: "jsr", + 0xefb3: "jss", + 0xefb4: "juce", + 0xefb5: "juejin", + 0xefb6: "juke", + 0xefb7: "julia", + 0xefb8: "junipernetworks", + 0xefb9: "junit5", + 0xefba: "jupyter", + 0xefbb: "justeat", + 0xefbc: "justgiving", + 0xefbd: "k3s", + 0xefbe: "k6", + 0xefbf: "kaggle", + 0xefc0: "kagi", + 0xefc1: "kahoot", + 0xefc2: "kaios", + 0xefc3: "kakao", + 0xefc4: "kakaotalk", + 0xefc5: "kalilinux", + 0xefc6: "kamailio", + 0xefc7: "kaniko", + 0xefc8: "karlsruherverkehrsverbund", + 0xefc9: "kasasmart", + 0xefca: "kashflow", + 0xefcb: "kaspersky", + 0xefcc: "katana", + 0xefcd: "kaufland", + 0xefce: "kde", + 0xefcf: "kdeplasma", + 0xefd0: "kdenlive", + 0xefd1: "kedro", + 0xefd2: "keenetic", + 0xefd3: "keepachangelog", + 0xefd4: "keepassxc", + 0xefd5: "keeper", + 0xefd6: "keeweb", + 0xefd7: "kentico", + 0xefd8: "keras", + 0xefd9: "keybase", + 0xefda: "keycdn", + 0xefdb: "keycloak", + 0xefdc: "keystone", + 0xefdd: "kfc", + 0xefde: "khanacademy", + 0xefdf: "khronosgroup", + 0xefe0: "kia", + 0xefe1: "kibana", + 0xefe2: "kicad", + 0xefe3: "kick", + 0xefe4: "kickstarter", + 0xefe5: "kik", + 0xefe6: "kingstontechnology", + 0xefe7: "kinopoisk", + 0xefe8: "kinsta", + 0xefe9: "kirby", + 0xefea: "kit", + 0xefeb: "kitsu", + 0xefec: "klarna", + 0xefed: "kleinanzeigen", + 0xefee: "klm", + 0xefef: "klook", + 0xeff0: "knative", + 0xeff1: "knexdotjs", + 0xeff2: "knime", + 0xeff3: "knip", + 0xeff4: "knowledgebase", + 0xeff5: "known", + 0xeff6: "kofi", + 0xeff7: "koa", + 0xeff8: "koc", + 0xeff9: "kodak", + 0xeffa: "kodi", + 0xeffb: "koenigsegg", + 0xeffc: "kofax", + 0xeffd: "komoot", + 0xeffe: "konami", + 0xefff: "kong", + 0xf000: "kongregate", + 0xf001: "konva", + 0xf002: "kotlin", + 0xf003: "koyeb", + 0xf004: "krita", + 0xf005: "ktm", + 0xf006: "ktor", + 0xf007: "kuaishou", + 0xf008: "kubernetes", + 0xf009: "kubuntu", + 0xf00a: "kucoin", + 0xf00b: "kueski", + 0xf00c: "kuma", + 0xf00d: "kununu", + 0xf00e: "kuula", + 0xf00f: "kx", + 0xf010: "kyocera", + 0xf011: "lequipe", + 0xf012: "labview", + 0xf013: "lada", + 0xf014: "lamborghini", + 0xf016: "langchain", + 0xf017: "langflow", + 0xf018: "langgraph", + 0xf019: "languagetool", + 0xf01a: "lapce", + 0xf01b: "laragon", + 0xf01c: "laravel", + 0xf01d: "laravelhorizon", + 0xf01e: "laravelnova", + 0xf01f: "lastdotfm", + 0xf020: "lastpass", + 0xf021: "latex", + 0xf022: "launchpad", + 0xf023: "lazarus", + 0xf024: "lazyvim", + 0xf025: "lbry", + 0xf026: "leaderprice", + 0xf027: "leaflet", + 0xf028: "leagueoflegends", + 0xf029: "leanpub", + 0xf02a: "leetcode", + 0xf02b: "lefthook", + 0xf02c: "legacygames", + 0xf02d: "leica", + 0xf02e: "lemmy", + 0xf02f: "lemonsqueezy", + 0xf030: "lenovo", + 0xf031: "lens", + 0xf032: "leptos", + 0xf033: "lerna", + 0xf034: "leroymerlin", + 0xf035: "leslibraires", + 0xf036: "less", + 0xf037: "letsencrypt", + 0xf038: "letterboxd", + 0xf039: "levelsdotfyi", + 0xf03a: "lg", + 0xf03b: "lining", + 0xf03c: "liberadotchat", + 0xf03d: "liberapay", + 0xf03e: "librariesdotio", + 0xf03f: "librarything", + 0xf040: "libreoffice", + 0xf041: "libreofficebase", + 0xf042: "libreofficecalc", + 0xf043: "libreofficedraw", + 0xf044: "libreofficeimpress", + 0xf045: "libreofficemath", + 0xf046: "libreofficewriter", + 0xf047: "libretranslate", + 0xf048: "libretube", + 0xf049: "librewolf", + 0xf04a: "libuv", + 0xf04b: "lichess", + 0xf04c: "lidl", + 0xf04d: "lifx", + 0xf04e: "lightburn", + 0xf04f: "lighthouse", + 0xf050: "lightning", + 0xf051: "limesurvey", + 0xf052: "line", + 0xf053: "lineageos", + 0xf054: "linear", + 0xf055: "linkerd", + 0xf056: "linkfire", + 0xf057: "linksys", + 0xf058: "linktree", + 0xf059: "linphone", + 0xf05a: "lintcode", + 0xf05b: "linux", + 0xf05c: "linuxcontainers", + 0xf05d: "linuxfoundation", + 0xf05e: "linuxmint", + 0xf05f: "linuxprofessionalinstitute", + 0xf060: "linuxserver", + 0xf061: "lionair", + 0xf062: "liquibase", + 0xf063: "listmonk", + 0xf064: "lit", + 0xf065: "litecoin", + 0xf066: "literal", + 0xf067: "litiengine", + 0xf068: "livechat", + 0xf069: "livejournal", + 0xf06a: "livewire", + 0xf06b: "llvm", + 0xf06c: "lmms", + 0xf06d: "lobsters", + 0xf06e: "local", + 0xf06f: "lodash", + 0xf072: "logmein", + 0xf073: "logseq", + 0xf074: "logstash", + 0xf075: "looker", + 0xf076: "loom", + 0xf077: "loop", + 0xf078: "loopback", + 0xf079: "lootcrate", + 0xf07a: "lospec", + 0xf07b: "lotpolishairlines", + 0xf07c: "lottiefiles", + 0xf07d: "ltspice", + 0xf07e: "lua", + 0xf07f: "lubuntu", + 0xf080: "lucia", + 0xf081: "lucid", + 0xf082: "lucide", + 0xf083: "ludwig", + 0xf084: "lufthansa", + 0xf085: "lumen", + 0xf086: "lunacy", + 0xf087: "lutris", + 0xf088: "lvgl", + 0xf089: "lydia", + 0xf08a: "lyft", + 0xf08b: "maas", + 0xf08c: "macos", + 0xf08d: "macpaw", + 0xf08e: "macys", + 0xf08f: "magasinsu", + 0xf090: "magic", + 0xf091: "magisk", + 0xf092: "mahindra", + 0xf093: "maildotcom", + 0xf094: "maildotru", + 0xf096: "mailchimp", + 0xf097: "mailgun", + 0xf098: "mailtrap", + 0xf099: "mainwp", + 0xf09a: "majorleaguehacking", + 0xf09b: "make", + 0xf09c: "makerbot", + 0xf09d: "malt", + 0xf09e: "malwarebytes", + 0xf09f: "mambaui", + 0xf0a0: "mamp", + 0xf0a1: "man", + 0xf0a2: "manageiq", + 0xf0a3: "manjaro", + 0xf0a4: "mantine", + 0xf0a5: "mapbox", + 0xf0a6: "mapillary", + 0xf0a7: "maplibre", + 0xf0a8: "maptiler", + 0xf0a9: "mariadb", + 0xf0aa: "mariadbfoundation", + 0xf0ab: "markdown", + 0xf0ac: "marko", + 0xf0ad: "marriott", + 0xf0ae: "marvelapp", + 0xf0af: "maserati", + 0xf0b0: "mastercard", + 0xf0b1: "mastercomfig", + 0xf0b2: "mastodon", + 0xf0b3: "materialdesign", + 0xf0b4: "materialdesignicons", + 0xf0b5: "materialformkdocs", + 0xf0b6: "matillion", + 0xf0b7: "matomo", + 0xf0b8: "matrix", + 0xf0b9: "matterdotjs", + 0xf0ba: "mattermost", + 0xf0bb: "matternet", + 0xf0bc: "mautic", + 0xf0bd: "max", + 0xf0be: "maxplanckgesellschaft", + 0xf0bf: "maytag", + 0xf0c0: "mazda", + 0xf0c1: "maze", + 0xf0c2: "mcafee", + 0xf0c3: "mcdonalds", + 0xf0c4: "mclaren", + 0xf0c5: "mdbook", + 0xf0c6: "mdnwebdocs", + 0xf0c7: "mdx", + 0xf0c8: "mealie", + 0xf0c9: "mediafire", + 0xf0ca: "mediamarkt", + 0xf0cb: "mediapipe", + 0xf0cc: "mediatek", + 0xf0cd: "medibangpaint", + 0xf0ce: "medium", + 0xf0cf: "medusa", + 0xf0d0: "meetup", + 0xf0d1: "mega", + 0xf0d2: "meilisearch", + 0xf0d3: "meituan", + 0xf0d4: "meizu", + 0xf0d5: "mendeley", + 0xf0d6: "mentorcruise", + 0xf0d7: "mercadopago", + 0xf0d9: "merck", + 0xf0da: "mercurial", + 0xf0db: "mermaid", + 0xf0dc: "messenger", + 0xf0dd: "meta", + 0xf0de: "metabase", + 0xf0df: "metacritic", + 0xf0e0: "metafilter", + 0xf0e1: "metasploit", + 0xf0e2: "meteor", + 0xf0e3: "metro", + 0xf0e4: "metrodelaciudaddemexico", + 0xf0e5: "metrodemadrid", + 0xf0e6: "metrodeparis", + 0xf0e7: "mewe", + 0xf0e8: "mg", + 0xf0e9: "microeditor", + 0xf0ea: "microbit", + 0xf0eb: "microdotblog", + 0xf0ec: "micropython", + 0xf0ed: "microstation", + 0xf0ee: "microstrategy", + 0xf0ef: "midi", + 0xf0f0: "migadu", + 0xf0f1: "mihoyo", + 0xf0f2: "mikrotik", + 0xf0f3: "milanote", + 0xf0f4: "milvus", + 0xf0f5: "minds", + 0xf0f7: "mingww64", + 0xf0f8: "mini", + 0xf0f9: "minio", + 0xf0fa: "mintlify", + 0xf0fb: "minutemailer", + 0xf0fc: "miraheze", + 0xf0fd: "miro", + 0xf0fe: "misskey", + 0xf0ff: "mitsubishi", + 0xf100: "mix", + 0xf101: "mixcloud", + 0xf102: "mixpanel", + 0xf103: "mlb", + 0xf104: "mlflow", + 0xf105: "mobx", + 0xf106: "mobxstatetree", + 0xf107: "mocha", + 0xf108: "mockserviceworker", + 0xf109: "modal", + 0xf10a: "modin", + 0xf10b: "modrinth", + 0xf10c: "modx", + 0xf10d: "mojeek", + 0xf10e: "moleculer", + 0xf10f: "momenteo", + 0xf110: "monero", + 0xf111: "moneygram", + 0xf112: "mongodb", + 0xf113: "mongoose", + 0xf114: "mongoosedotws", + 0xf115: "monica", + 0xf116: "monkeytie", + 0xf117: "monkeytype", + 0xf118: "monogame", + 0xf119: "monoprix", + 0xf11a: "monster", + 0xf11b: "monzo", + 0xf11c: "moo", + 0xf11d: "moodle", + 0xf11e: "moonrepo", + 0xf11f: "moq", + 0xf120: "moqups", + 0xf121: "morrisons", + 0xf122: "moscowmetro", + 0xf123: "motorola", + 0xf124: "movistar", + 0xf125: "mozilla", + 0xf126: "mpv", + 0xf127: "mqtt", + 0xf128: "msi", + 0xf129: "msibusiness", + 0xf12a: "mta", + 0xf12b: "mtr", + 0xf12c: "mubi", + 0xf12d: "mui", + 0xf12f: "muller", + 0xf130: "mullvad", + 0xf131: "multisim", + 0xf132: "mumble", + 0xf133: "muo", + 0xf134: "mural", + 0xf136: "musicbrainz", + 0xf137: "mxlinux", + 0xf138: "myanimelist", + 0xf139: "myget", + 0xf13a: "myob", + 0xf13b: "myspace", + 0xf13c: "mysql", + 0xf13d: "n26", + 0xf13e: "n8n", + 0xf13f: "namebase", + 0xf140: "namecheap", + 0xf141: "namemc", + 0xf142: "namesilo", + 0xf143: "namuwiki", + 0xf144: "nano", + 0xf145: "nanostores", + 0xf146: "napster", + 0xf147: "nasa", + 0xf148: "nationalgrid", + 0xf149: "nationalrail", + 0xf14a: "nativescript", + 0xf14b: "natsdotio", + 0xf14c: "naver", + 0xf14d: "nba", + 0xf14e: "nbb", + 0xf14f: "nbc", + 0xf150: "ndr", + 0xf151: "near", + 0xf152: "nebula", + 0xf153: "nec", + 0xf154: "neo4j", + 0xf155: "neovim", + 0xf156: "neptune", + 0xf157: "nestjs", + 0xf158: "netapp", + 0xf159: "netbsd", + 0xf15a: "netcup", + 0xf15b: "netdata", + 0xf15c: "neteasecloudmusic", + 0xf15d: "netflix", + 0xf15e: "netgear", + 0xf15f: "netlify", + 0xf160: "nette", + 0xf161: "netto", + 0xf162: "neutralinojs", + 0xf163: "newbalance", + 0xf164: "newjapanprowrestling", + 0xf165: "newrelic", + 0xf166: "newyorktimes", + 0xf167: "newegg", + 0xf168: "nexon", + 0xf169: "nextdotjs", + 0xf16a: "nextbilliondotai", + 0xf16b: "nextcloud", + 0xf16c: "nextdns", + 0xf16d: "nextdoor", + 0xf16e: "nextflow", + 0xf16f: "nextra", + 0xf172: "nfcore", + 0xf173: "nfc", + 0xf174: "nginx", + 0xf175: "nginxproxymanager", + 0xf176: "ngrok", + 0xf177: "ngrx", + 0xf178: "nhl", + 0xf179: "nicehash", + 0xf17a: "niconico", + 0xf17b: "nike", + 0xf17c: "nikon", + 0xf17d: "nim", + 0xf17e: "nissan", + 0xf17f: "nixos", + 0xf180: "nodered", + 0xf181: "nodedotjs", + 0xf182: "nodemon", + 0xf183: "nokia", + 0xf184: "nomad", + 0xf185: "norco", + 0xf186: "nordicsemiconductor", + 0xf187: "nordvpn", + 0xf188: "normalizedotcss", + 0xf189: "norton", + 0xf18a: "norwegian", + 0xf18b: "notepadplusplus", + 0xf18c: "notion", + 0xf18d: "notist", + 0xf18e: "nounproject", + 0xf18f: "novu", + 0xf190: "now", + 0xf191: "npm", + 0xf192: "nrwl", + 0xf193: "nsis", + 0xf194: "ntfy", + 0xf195: "nubank", + 0xf196: "nucleo", + 0xf197: "nuget", + 0xf198: "nuke", + 0xf199: "numba", + 0xf19a: "numpy", + 0xf19b: "nunjucks", + 0xf19c: "nushell", + 0xf19d: "nutanix", + 0xf19e: "nuxt", + 0xf19f: "nvidia", + 0xf1a0: "nvm", + 0xf1a1: "nx", + 0xf1a2: "nxp", + 0xf1a3: "nzxt", + 0xf1a4: "oreilly", + 0xf1a5: "o2", + 0xf1a6: "obb", + 0xf1a7: "obsstudio", + 0xf1a8: "observable", + 0xf1a9: "obsidian", + 0xf1aa: "obtainium", + 0xf1ab: "ocaml", + 0xf1ac: "oclc", + 0xf1ad: "oclif", + 0xf1ae: "octanerender", + 0xf1af: "octave", + 0xf1b0: "octobercms", + 0xf1b1: "octoprint", + 0xf1b2: "octopusdeploy", + 0xf1b3: "oculus", + 0xf1b4: "odin", + 0xf1b5: "odnoklassniki", + 0xf1b6: "odoo", + 0xf1b7: "odysee", + 0xf1b8: "ohdear", + 0xf1b9: "okcupid", + 0xf1ba: "okta", + 0xf1bb: "okx", + 0xf1bc: "ollama", + 0xf1bd: "omadacloud", + 0xf1be: "oneplus", + 0xf1bf: "onlyfans", + 0xf1c0: "onlyoffice", + 0xf1c1: "onnx", + 0xf1c2: "onstar", + 0xf1c3: "opel", + 0xf1c4: "openaccess", + 0xf1c5: "openbadges", + 0xf1c6: "openbugbounty", + 0xf1c7: "opencollective", + 0xf1c8: "opencontainersinitiative", + 0xf1c9: "opensourcehardware", + 0xf1ca: "opensourceinitiative", + 0xf1cb: "open3d", + 0xf1cd: "openaigym", + 0xf1ce: "openapiinitiative", + 0xf1cf: "openbsd", + 0xf1d0: "opencv", + 0xf1d1: "openfaas", + 0xf1d2: "opengl", + 0xf1d3: "openhab", + 0xf1d4: "openid", + 0xf1d5: "openjdk", + 0xf1d6: "openjsfoundation", + 0xf1d7: "openlayers", + 0xf1d8: "openmediavault", + 0xf1d9: "openmined", + 0xf1da: "opennebula", + 0xf1db: "openproject", + 0xf1dc: "openscad", + 0xf1dd: "opensea", + 0xf1de: "opensearch", + 0xf1df: "openssl", + 0xf1e0: "openstack", + 0xf1e1: "openstreetmap", + 0xf1e2: "opensuse", + 0xf1e3: "opentelemetry", + 0xf1e4: "opentext", + 0xf1e5: "opentofu", + 0xf1e6: "openverse", + 0xf1e7: "openvpn", + 0xf1e8: "openwrt", + 0xf1e9: "openzeppelin", + 0xf1ea: "openzfs", + 0xf1eb: "opera", + 0xf1ec: "operagx", + 0xf1ed: "opnsense", + 0xf1ee: "oppo", + 0xf1ef: "opsgenie", + 0xf1f0: "opslevel", + 0xf1f1: "optimism", + 0xf1f2: "orange", + 0xf1f3: "orcid", + 0xf1f4: "org", + 0xf1f5: "organicmaps", + 0xf1f6: "origin", + 0xf1f7: "osano", + 0xf1f8: "osf", + 0xf1f9: "osgeo", + 0xf1fa: "oshkosh", + 0xf1fb: "osmc", + 0xf1fc: "osu", + 0xf1fd: "otto", + 0xf1fe: "outline", + 0xf1ff: "overcast", + 0xf200: "overleaf", + 0xf201: "ovh", + 0xf202: "owasp", + 0xf203: "dependencycheck", + 0xf204: "owncloud", + 0xf205: "oxygen", + 0xf206: "oyo", + 0xf207: "p5dotjs", + 0xf208: "packagist", + 0xf209: "packer", + 0xf20a: "packt", + 0xf20b: "paddle", + 0xf20c: "paddlepaddle", + 0xf20d: "paddypower", + 0xf20e: "pagekit", + 0xf20f: "pagerduty", + 0xf210: "pagespeedinsights", + 0xf211: "pagseguro", + 0xf212: "palantir", + 0xf213: "paloaltonetworks", + 0xf214: "paloaltosoftware", + 0xf215: "panasonic", + 0xf216: "pandas", + 0xf217: "pandora", + 0xf218: "pantheon", + 0xf219: "paperlessngx", + 0xf21a: "paperswithcode", + 0xf21b: "paperspace", + 0xf21c: "paradoxinteractive", + 0xf21d: "paramountplus", + 0xf21e: "paritysubstrate", + 0xf21f: "parrotsecurity", + 0xf220: "parsedotly", + 0xf221: "passport", + 0xf222: "pastebin", + 0xf223: "patreon", + 0xf224: "paychex", + 0xf225: "payhip", + 0xf226: "payloadcms", + 0xf227: "payoneer", + 0xf228: "paypal", + 0xf229: "paytm", + 0xf22a: "pcgamingwiki", + 0xf22b: "pdm", + 0xf22c: "pdq", + 0xf22d: "peakdesign", + 0xf22e: "pearson", + 0xf22f: "peerlist", + 0xf230: "peertube", + 0xf231: "pegasusairlines", + 0xf232: "pelican", + 0xf233: "peloton", + 0xf234: "penny", + 0xf235: "penpot", + 0xf236: "percy", + 0xf237: "perforce", + 0xf238: "perl", + 0xf239: "perplexity", + 0xf23a: "persistent", + 0xf23b: "personio", + 0xf23c: "petsathome", + 0xf23d: "peugeot", + 0xf23e: "pexels", + 0xf23f: "pfsense", + 0xf240: "phabricator", + 0xf241: "philipshue", + 0xf242: "phoenixframework", + 0xf243: "phonepe", + 0xf244: "phosphoricons", + 0xf245: "photobucket", + 0xf246: "photocrowd", + 0xf247: "photon", + 0xf248: "photopea", + 0xf249: "php", + 0xf24a: "phpbb", + 0xf24b: "phpmyadmin", + 0xf24c: "phpstorm", + 0xf24d: "pinetwork", + 0xf24e: "pihole", + 0xf24f: "piaggiogroup", + 0xf250: "piapro", + 0xf251: "picardsurgeles", + 0xf252: "picartodottv", + 0xf253: "picnic", + 0xf254: "picpay", + 0xf255: "picrew", + 0xf256: "picsart", + 0xf257: "picxy", + 0xf258: "pimcore", + 0xf259: "pinboard", + 0xf25a: "pinescript", + 0xf25b: "pingdom", + 0xf25c: "pino", + 0xf25d: "pinterest", + 0xf25e: "pioneerdj", + 0xf25f: "piped", + 0xf260: "pipx", + 0xf261: "pivotaltracker", + 0xf262: "piwigo", + 0xf263: "pix", + 0xf264: "pixabay", + 0xf265: "pixelfed", + 0xf266: "pixiv", + 0xf267: "pixlr", + 0xf268: "pkgsrc", + 0xf269: "planet", + 0xf26a: "planetscale", + 0xf26b: "plangrid", + 0xf26c: "platformdotsh", + 0xf26d: "platformio", + 0xf26e: "platzi", + 0xf26f: "plausibleanalytics", + 0xf270: "playcanvas", + 0xf271: "playerfm", + 0xf272: "playerdotme", + 0xf273: "playstation", + 0xf274: "playstation2", + 0xf275: "playstation3", + 0xf276: "playstation4", + 0xf277: "playstation5", + 0xf278: "playstationportable", + 0xf279: "playstationvita", + 0xf27a: "pleroma", + 0xf27b: "plesk", + 0xf27c: "plex", + 0xf27d: "plotly", + 0xf27e: "plume", + 0xf27f: "pluralsight", + 0xf280: "plurk", + 0xf282: "pm2", + 0xf283: "pnpm", + 0xf285: "pocketcasts", + 0xf286: "pocketbase", + 0xf287: "podcastaddict", + 0xf288: "podcastindex", + 0xf289: "podman", + 0xf28a: "poe", + 0xf28b: "poetry", + 0xf28d: "polars", + 0xf28e: "polestar", + 0xf28f: "polkadot", + 0xf290: "poly", + 0xf291: "polygon", + 0xf292: "polymerproject", + 0xf293: "polywork", + 0xf294: "pond5", + 0xf295: "popos", + 0xf296: "porkbun", + 0xf297: "porsche", + 0xf298: "portainer", + 0xf299: "portswigger", + 0xf29a: "posit", + 0xf29b: "postcss", + 0xf29c: "postgresql", + 0xf29d: "posthog", + 0xf29e: "postman", + 0xf29f: "postmates", + 0xf2a0: "powers", + 0xf2a1: "prdotco", + 0xf2a2: "precommit", + 0xf2a3: "preact", + 0xf2a4: "prefect", + 0xf2a5: "premierleague", + 0xf2a6: "prepbytes", + 0xf2a7: "prestashop", + 0xf2a8: "presto", + 0xf2a9: "prettier", + 0xf2aa: "pretzel", + 0xf2ab: "prevention", + 0xf2ac: "prezi", + 0xf2af: "primefaces", + 0xf2b0: "primeng", + 0xf2b1: "primereact", + 0xf2b2: "primevue", + 0xf2b3: "printables", + 0xf2b4: "prisma", + 0xf2b5: "prismic", + 0xf2b6: "privatedivision", + 0xf2b7: "privateinternetaccess", + 0xf2b8: "protools", + 0xf2b9: "probot", + 0xf2ba: "processingfoundation", + 0xf2bb: "processwire", + 0xf2bc: "producthunt", + 0xf2bd: "progate", + 0xf2be: "progress", + 0xf2bf: "prometheus", + 0xf2c0: "pronounsdotpage", + 0xf2c1: "prosieben", + 0xf2c2: "proteus", + 0xf2c3: "protodotio", + 0xf2c4: "protocolsdotio", + 0xf2c5: "proton", + 0xf2c6: "protoncalendar", + 0xf2c7: "protondrive", + 0xf2c8: "protonmail", + 0xf2c9: "protonvpn", + 0xf2ca: "protondb", + 0xf2cb: "protractor", + 0xf2cc: "proxmox", + 0xf2cd: "pterodactyl", + 0xf2ce: "pubg", + 0xf2cf: "publons", + 0xf2d0: "pubmed", + 0xf2d1: "pug", + 0xf2d2: "pulumi", + 0xf2d3: "puma", + 0xf2d4: "puppet", + 0xf2d5: "puppeteer", + 0xf2d6: "purescript", + 0xf2d7: "purgecss", + 0xf2d8: "purism", + 0xf2d9: "pushbullet", + 0xf2da: "pusher", + 0xf2db: "pwa", + 0xf2dc: "pycharm", + 0xf2dd: "pycqa", + 0xf2de: "pydantic", + 0xf2df: "pyg", + 0xf2e0: "pypi", + 0xf2e1: "pypy", + 0xf2e2: "pyscaffold", + 0xf2e3: "pysyft", + 0xf2e4: "pytest", + 0xf2e5: "python", + 0xf2e6: "pythonanywhere", + 0xf2e7: "pytorch", + 0xf2e8: "pyup", + 0xf2e9: "qantas", + 0xf2ea: "qase", + 0xf2eb: "qatarairways", + 0xf2ec: "qbittorrent", + 0xf2ed: "qemu", + 0xf2ee: "qgis", + 0xf2ef: "qi", + 0xf2f0: "qiita", + 0xf2f1: "qiskit", + 0xf2f2: "qiwi", + 0xf2f3: "qlik", + 0xf2f4: "qmk", + 0xf2f5: "qnap", + 0xf2f6: "qq", + 0xf2f7: "qt", + 0xf2f8: "qualcomm", + 0xf2f9: "qualtrics", + 0xf2fa: "qualys", + 0xf2fb: "quantcast", + 0xf2fc: "quantconnect", + 0xf2fd: "quarkus", + 0xf2fe: "quarto", + 0xf2ff: "quasar", + 0xf300: "qubesos", + 0xf301: "quest", + 0xf302: "quickbooks", + 0xf303: "quicklook", + 0xf304: "quicktime", + 0xf305: "quicktype", + 0xf307: "quizlet", + 0xf308: "quora", + 0xf309: "qwant", + 0xf30a: "qwik", + 0xf30b: "qwiklabs", + 0xf30c: "qzone", + 0xf30d: "r", + 0xf30e: "r3", + 0xf30f: "rabbitmq", + 0xf310: "racket", + 0xf311: "radstudio", + 0xf312: "radar", + 0xf313: "radarr", + 0xf314: "radixui", + 0xf315: "railway", + 0xf316: "rainmeter", + 0xf317: "rakuten", + 0xf318: "ram", + 0xf319: "rancher", + 0xf31a: "rapid", + 0xf31b: "rarible", + 0xf31c: "rasa", + 0xf31d: "raspberrypi", + 0xf31e: "ravelry", + 0xf31f: "ray", + 0xf320: "raycast", + 0xf321: "raylib", + 0xf322: "razer", + 0xf323: "razorpay", + 0xf324: "rclone", + 0xf325: "react", + 0xf326: "reactbootstrap", + 0xf327: "reacthookform", + 0xf328: "reactquery", + 0xf329: "reactrouter", + 0xf32a: "reacttable", + 0xf32b: "reactiveresume", + 0xf32c: "reactivex", + 0xf32d: "reactos", + 0xf32e: "readthedocs", + 0xf32f: "readdotcv", + 0xf330: "readme", + 0xf331: "reason", + 0xf332: "reasonstudios", + 0xf333: "recoil", + 0xf334: "red", + 0xf335: "redbull", + 0xf336: "redcandlegames", + 0xf337: "redhat", + 0xf338: "redhatopenshift", + 0xf339: "redash", + 0xf33a: "redbubble", + 0xf33b: "reddit", + 0xf33c: "redis", + 0xf33d: "redmine", + 0xf33e: "redox", + 0xf33f: "redragon", + 0xf340: "redsys", + 0xf341: "redux", + 0xf342: "reduxsaga", + 0xf343: "redwoodjs", + 0xf344: "reebok", + 0xf345: "refine", + 0xf346: "refinedgithub", + 0xf347: "relay", + 0xf348: "relianceindustrieslimited", + 0xf349: "remark", + 0xf34a: "remedyentertainment", + 0xf34b: "remix", + 0xf34c: "removedotbg", + 0xf34d: "renpy", + 0xf34e: "renault", + 0xf34f: "render", + 0xf350: "renovate", + 0xf351: "renren", + 0xf352: "replicate", + 0xf353: "replit", + 0xf354: "republicofgamers", + 0xf355: "rescript", + 0xf356: "rescuetime", + 0xf357: "researchgate", + 0xf358: "resend", + 0xf359: "resharper", + 0xf35a: "resurrectionremixos", + 0xf35b: "retool", + 0xf35c: "retroarch", + 0xf35d: "retropie", + 0xf35e: "revanced", + 0xf35f: "revealdotjs", + 0xf360: "reverbnation", + 0xf361: "revoltdotchat", + 0xf362: "revolut", + 0xf363: "rewe", + 0xf364: "rezgo", + 0xf365: "rhinoceros", + 0xf366: "rich", + 0xf367: "rider", + 0xf368: "rimacautomobili", + 0xf369: "rime", + 0xf36a: "ring", + 0xf36b: "riotgames", + 0xf36c: "ripple", + 0xf36d: "riscv", + 0xf36e: "riseup", + 0xf36f: "ritzcarlton", + 0xf370: "rive", + 0xf371: "roadmapdotsh", + 0xf372: "roamresearch", + 0xf373: "robinhood", + 0xf374: "roblox", + 0xf375: "robloxstudio", + 0xf376: "roboflow", + 0xf377: "robotframework", + 0xf378: "rocket", + 0xf379: "rocketdotchat", + 0xf37a: "rocksdb", + 0xf37b: "rockstargames", + 0xf37c: "rockwellautomation", + 0xf37d: "rockylinux", + 0xf37e: "roku", + 0xf37f: "roll20", + 0xf380: "rollsroyce", + 0xf381: "rollupdotjs", + 0xf382: "rook", + 0xf383: "roon", + 0xf384: "rootme", + 0xf385: "roots", + 0xf386: "rootsbedrock", + 0xf387: "rootssage", + 0xf388: "ros", + 0xf389: "rossmann", + 0xf38a: "rotaryinternational", + 0xf38b: "rottentomatoes", + 0xf38c: "roundcube", + 0xf38d: "rsocket", + 0xf38e: "rss", + 0xf38f: "rstudioide", + 0xf390: "rte", + 0xf391: "rtl", + 0xf392: "rtlzwei", + 0xf393: "rtm", + 0xf394: "rubocop", + 0xf395: "ruby", + 0xf396: "rubyonrails", + 0xf397: "rubysinatra", + 0xf398: "rubygems", + 0xf399: "rubymine", + 0xf39a: "ruff", + 0xf39b: "rumahweb", + 0xf39c: "rumble", + 0xf39d: "rundeck", + 0xf39e: "runkeeper", + 0xf39f: "runkit", + 0xf3a0: "runrundotit", + 0xf3a1: "rust", + 0xf3a2: "rustdesk", + 0xf3a3: "rxdb", + 0xf3a4: "ryanair", + 0xf3a5: "rye", + 0xf3a6: "s7airlines", + 0xf3a7: "sabanci", + 0xf3a8: "safari", + 0xf3a9: "sage", + 0xf3aa: "sahibinden", + 0xf3ab: "sailfishos", + 0xf3ac: "sailsdotjs", + 0xf3ae: "salla", + 0xf3af: "saltproject", + 0xf3b0: "samsclub", + 0xf3b1: "samsung", + 0xf3b2: "samsungpay", + 0xf3b3: "sanfranciscomunicipalrailway", + 0xf3b5: "sanic", + 0xf3b6: "sanity", + 0xf3b7: "saopaulometro", + 0xf3b8: "sap", + 0xf3b9: "sartorius", + 0xf3ba: "sass", + 0xf3bb: "sat1", + 0xf3bc: "satellite", + 0xf3bd: "saturn", + 0xf3be: "saucelabs", + 0xf3bf: "saudia", + 0xf3c0: "scala", + 0xf3c1: "scalar", + 0xf3c2: "scaleway", + 0xf3c3: "scania", + 0xf3c4: "schneiderelectric", + 0xf3c5: "scikitlearn", + 0xf3c6: "scilab", + 0xf3c7: "scipy", + 0xf3c8: "scopus", + 0xf3c9: "scpfoundation", + 0xf3ca: "scrapbox", + 0xf3cb: "scrapy", + 0xf3cc: "scratch", + 0xf3cd: "screencastify", + 0xf3cf: "scrimba", + 0xf3d0: "scrollreveal", + 0xf3d1: "scrumalliance", + 0xf3d2: "scrutinizerci", + 0xf3d3: "scylladb", + 0xf3d4: "seagate", + 0xf3d5: "searxng", + 0xf3d6: "seat", + 0xf3d7: "seatgeek", + 0xf3d8: "securityscorecard", + 0xf3d9: "sefaria", + 0xf3da: "sega", + 0xf3db: "selenium", + 0xf3dc: "sellfy", + 0xf3dd: "semanticscholar", + 0xf3de: "semanticui", + 0xf3df: "semanticuireact", + 0xf3e0: "semanticweb", + 0xf3e1: "semanticrelease", + 0xf3e2: "semaphoreci", + 0xf3e3: "semrush", + 0xf3e4: "semver", + 0xf3e5: "sencha", + 0xf3e7: "sennheiser", + 0xf3e8: "sensu", + 0xf3e9: "sentry", + 0xf3ea: "sepa", + 0xf3eb: "sequelize", + 0xf3ec: "serverfault", + 0xf3ed: "serverless", + 0xf3ee: "session", + 0xf3ef: "sessionize", + 0xf3f0: "setapp", + 0xf3f1: "sfml", + 0xf3f2: "shadcnui", + 0xf3f3: "shadow", + 0xf3f4: "shanghaimetro", + 0xf3f5: "sharex", + 0xf3f6: "sharp", + 0xf3f7: "shazam", + 0xf3f8: "shell", + 0xf3f9: "shelly", + 0xf3fa: "shenzhenmetro", + 0xf3fb: "shieldsdotio", + 0xf3fc: "shikimori", + 0xf3fd: "shopee", + 0xf3fe: "shopify", + 0xf3ff: "shopware", + 0xf400: "shortcut", + 0xf401: "showpad", + 0xf402: "showtime", + 0xf403: "showwcase", + 0xf405: "sidekiq", + 0xf406: "sidequest", + 0xf407: "siemens", + 0xf408: "sifive", + 0xf409: "signal", + 0xf40a: "silverairways", + 0xf40b: "similarweb", + 0xf40c: "simkl", + 0xf40d: "simpleanalytics", + 0xf40e: "simpleicons", + 0xf40f: "simplelogin", + 0xf410: "simplenote", + 0xf411: "simplex", + 0xf412: "sinaweibo", + 0xf413: "singaporeairlines", + 0xf414: "singlestore", + 0xf415: "sitecore", + 0xf416: "sitepoint", + 0xf417: "siyuan", + 0xf418: "skaffold", + 0xf419: "sketch", + 0xf41a: "sketchfab", + 0xf41b: "sketchup", + 0xf41c: "skillshare", + 0xf41d: "skoda", + 0xf41e: "sky", + 0xf41f: "skypack", + 0xf421: "slackware", + 0xf422: "slashdot", + 0xf423: "slickpic", + 0xf424: "slides", + 0xf425: "slideshare", + 0xf426: "slint", + 0xf427: "smart", + 0xf428: "smartthings", + 0xf429: "smashingmagazine", + 0xf42a: "smrt", + 0xf42b: "smugmug", + 0xf42c: "snapchat", + 0xf42d: "snapcraft", + 0xf42e: "snapdragon", + 0xf42f: "sncf", + 0xf430: "snort", + 0xf431: "snowflake", + 0xf432: "snowpack", + 0xf433: "snyk", + 0xf434: "socialblade", + 0xf435: "society6", + 0xf436: "socket", + 0xf437: "socketdotio", + 0xf438: "softcatala", + 0xf439: "softpedia", + 0xf43a: "sogou", + 0xf43b: "solana", + 0xf43c: "solid", + 0xf43d: "solidity", + 0xf43e: "sololearn", + 0xf43f: "solus", + 0xf440: "sonar", + 0xf444: "sonarr", + 0xf445: "sonatype", + 0xf446: "songkick", + 0xf447: "songoda", + 0xf448: "sonicwall", + 0xf449: "sonos", + 0xf44a: "sony", + 0xf44b: "soriana", + 0xf44c: "soundcharts", + 0xf44d: "soundcloud", + 0xf44e: "sourceengine", + 0xf44f: "sourceforge", + 0xf450: "sourcehut", + 0xf451: "sourcetree", + 0xf452: "southwestairlines", + 0xf453: "spacemacs", + 0xf454: "spaceship", + 0xf455: "spacex", + 0xf456: "spacy", + 0xf457: "sparkar", + 0xf458: "sparkasse", + 0xf459: "sparkfun", + 0xf45a: "sparkpost", + 0xf45b: "spdx", + 0xf45c: "speakerdeck", + 0xf45d: "spectrum", + 0xf45e: "speedtest", + 0xf45f: "speedypage", + 0xf460: "spoj", + 0xf461: "sphinx", + 0xf462: "spigotmc", + 0xf463: "spine", + 0xf464: "spinnaker", + 0xf465: "splunk", + 0xf466: "spond", + 0xf467: "spotify", + 0xf468: "spotlight", + 0xf469: "spreadshirt", + 0xf46a: "spreaker", + 0xf46b: "spring", + 0xf46c: "springCreators", + 0xf46d: "springboot", + 0xf46e: "springsecurity", + 0xf46f: "spyderide", + 0xf470: "sqlalchemy", + 0xf471: "sqlite", + 0xf472: "square", + 0xf473: "squareenix", + 0xf474: "squarespace", + 0xf475: "srgssr", + 0xf476: "ssrn", + 0xf477: "sst", + 0xf478: "stackexchange", + 0xf479: "stackoverflow", + 0xf47a: "stackbit", + 0xf47b: "stackblitz", + 0xf47c: "stackedit", + 0xf47d: "stackhawk", + 0xf47e: "stackshare", + 0xf47f: "stadia", + 0xf480: "staffbase", + 0xf481: "stagetimer", + 0xf482: "standardresume", + 0xf483: "standardjs", + 0xf484: "startrek", + 0xf485: "starbucks", + 0xf486: "stardock", + 0xf487: "starlingbank", + 0xf488: "starship", + 0xf489: "startdotgg", + 0xf48a: "startpage", + 0xf48b: "starz", + 0xf48c: "statamic", + 0xf48d: "statista", + 0xf48e: "statuspage", + 0xf48f: "statuspal", + 0xf490: "steam", + 0xf491: "steamdeck", + 0xf492: "steamdb", + 0xf493: "steamworks", + 0xf494: "steelseries", + 0xf495: "steem", + 0xf496: "steemit", + 0xf497: "steinberg", + 0xf498: "stellar", + 0xf499: "stencil", + 0xf49a: "stencyl", + 0xf49b: "stimulus", + 0xf49c: "stmicroelectronics", + 0xf49d: "stockx", + 0xf49e: "stopstalk", + 0xf49f: "storyblok", + 0xf4a0: "storybook", + 0xf4a1: "strapi", + 0xf4a2: "strava", + 0xf4a3: "streamlabs", + 0xf4a4: "streamlit", + 0xf4a5: "streamrunners", + 0xf4a6: "stremio", + 0xf4a7: "stripe", + 0xf4a8: "strongswan", + 0xf4a9: "stryker", + 0xf4aa: "stubhub", + 0xf4ab: "studio3t", + 0xf4ac: "styledcomponents", + 0xf4ad: "stylelint", + 0xf4ae: "styleshare", + 0xf4af: "stylus", + 0xf4b0: "subaru", + 0xf4b1: "sublimetext", + 0xf4b2: "substack", + 0xf4b3: "subtitleedit", + 0xf4b4: "subversion", + 0xf4b5: "suckless", + 0xf4b6: "sui", + 0xf4b7: "sumologic", + 0xf4b8: "suno", + 0xf4b9: "sunrise", + 0xf4ba: "supabase", + 0xf4bb: "superuser", + 0xf4bc: "supercrease", + 0xf4bd: "supermicro", + 0xf4be: "surfshark", + 0xf4bf: "surrealdb", + 0xf4c0: "surveymonkey", + 0xf4c1: "suse", + 0xf4c2: "suzuki", + 0xf4c3: "svelte", + 0xf4c4: "svg", + 0xf4c5: "svgdotjs", + 0xf4c6: "svgo", + 0xf4c7: "svgtrace", + 0xf4c8: "swagger", + 0xf4c9: "swarm", + 0xf4ca: "sway", + 0xf4cb: "swc", + 0xf4cc: "swift", + 0xf4cd: "swiggy", + 0xf4ce: "swiper", + 0xf4cf: "swr", + 0xf4d0: "symantec", + 0xf4d1: "symbolab", + 0xf4d2: "symfony", + 0xf4d3: "symphony", + 0xf4d4: "sympy", + 0xf4d5: "syncthing", + 0xf4d6: "synology", + 0xf4d7: "system76", + 0xf4d8: "tabelog", + 0xf4d9: "tablecheck", + 0xf4da: "tacobell", + 0xf4db: "tado", + 0xf4dc: "taichigraphics", + 0xf4dd: "taichilang", + 0xf4de: "tails", + 0xf4df: "tailscale", + 0xf4e0: "tailwindcss", + 0xf4e1: "taipy", + 0xf4e2: "taketwointeractivesoftware", + 0xf4e3: "talend", + 0xf4e4: "talenthouse", + 0xf4e5: "talos", + 0xf4e6: "tamiya", + 0xf4e7: "tampermonkey", + 0xf4e8: "taobao", + 0xf4e9: "tapas", + 0xf4ea: "target", + 0xf4eb: "tarom", + 0xf4ec: "task", + 0xf4ed: "tasmota", + 0xf4ee: "tata", + 0xf4ef: "tcs", + 0xf4f0: "tauri", + 0xf4f1: "taxbuzz", + 0xf4f2: "teal", + 0xf4f3: "teamcity", + 0xf4f4: "teamspeak", + 0xf4f5: "teamviewer", + 0xf4f6: "techcrunch", + 0xf4f7: "ted", + 0xf4f8: "teepublic", + 0xf4f9: "teespring", + 0xf4fa: "tekton", + 0xf4fb: "tele5", + 0xf4fc: "telequebec", + 0xf4fd: "telefonica", + 0xf4fe: "telegram", + 0xf4ff: "telegraph", + 0xf500: "temporal", + 0xf501: "tensorflow", + 0xf502: "teradata", + 0xf503: "teratail", + 0xf504: "termius", + 0xf505: "terraform", + 0xf506: "tesco", + 0xf507: "tesla", + 0xf508: "testcafe", + 0xf509: "testin", + 0xf50a: "testinglibrary", + 0xf50b: "testrail", + 0xf50c: "tether", + 0xf50d: "textpattern", + 0xf50e: "tga", + 0xf50f: "thangs", + 0xf510: "thanos", + 0xf511: "thealgorithms", + 0xf512: "theboringcompany", + 0xf513: "theconversation", + 0xf514: "thefinals", + 0xf515: "theguardian", + 0xf516: "theirishtimes", + 0xf517: "themighty", + 0xf518: "themodelsresource", + 0xf519: "themoviedatabase", + 0xf51a: "thenorthface", + 0xf51b: "theodinproject", + 0xf51c: "theregister", + 0xf51d: "thesoundsresource", + 0xf51e: "thespritersresource", + 0xf51f: "thewashingtonpost", + 0xf520: "theweatherchannel", + 0xf521: "thingiverse", + 0xf522: "thinkpad", + 0xf523: "thirdweb", + 0xf524: "threadless", + 0xf525: "threads", + 0xf526: "threedotjs", + 0xf527: "threema", + 0xf528: "thumbtack", + 0xf529: "thunderbird", + 0xf52a: "thunderstore", + 0xf52b: "thurgauerkantonalbank", + 0xf52c: "thymeleaf", + 0xf52d: "ticketmaster", + 0xf52e: "ticktick", + 0xf52f: "tidal", + 0xf530: "tiddlywiki", + 0xf531: "tide", + 0xf532: "tidyverse", + 0xf533: "tietoevry", + 0xf534: "tiktok", + 0xf535: "tildapublishing", + 0xf536: "tile", + 0xf537: "timescale", + 0xf538: "tina", + 0xf539: "tinder", + 0xf53a: "tindie", + 0xf53b: "tinkercad", + 0xf53c: "tinygrad", + 0xf53d: "tinyletter", + 0xf53e: "tistory", + 0xf53f: "tldraw", + 0xf540: "tmux", + 0xf541: "todoist", + 0xf542: "toggl", + 0xf543: "toggltrack", + 0xf544: "tokyometro", + 0xf545: "toll", + 0xf546: "toml", + 0xf547: "tomorrowland", + 0xf548: "tomtom", + 0xf549: "ton", + 0xf54a: "topdotgg", + 0xf54b: "topcoder", + 0xf54c: "toptal", + 0xf54d: "torbrowser", + 0xf54e: "torproject", + 0xf54f: "torizon", + 0xf550: "toshiba", + 0xf551: "totvs", + 0xf552: "tourbox", + 0xf553: "tower", + 0xf554: "toyota", + 0xf555: "tplink", + 0xf556: "tqdm", + 0xf557: "traccar", + 0xf558: "tradingview", + 0xf559: "traefikmesh", + 0xf55a: "traefikproxy", + 0xf55b: "trailforks", + 0xf55c: "trainerroad", + 0xf55d: "trakt", + 0xf55e: "transifex", + 0xf55f: "transmission", + 0xf560: "transportforireland", + 0xf561: "transportforlondon", + 0xf562: "travisci", + 0xf563: "treehouse", + 0xf564: "trello", + 0xf565: "trendmicro", + 0xf566: "treyarch", + 0xf567: "tricentis", + 0xf568: "trilium", + 0xf569: "triller", + 0xf56a: "trillertv", + 0xf56b: "trimble", + 0xf56c: "trino", + 0xf56d: "tripdotcom", + 0xf56e: "tripadvisor", + 0xf56f: "trivago", + 0xf570: "trivy", + 0xf571: "trove", + 0xf572: "trpc", + 0xf573: "truenas", + 0xf574: "trueup", + 0xf575: "trulia", + 0xf576: "trustedshops", + 0xf577: "trustpilot", + 0xf578: "tryitonline", + 0xf579: "tryhackme", + 0xf57a: "tsnode", + 0xf57b: "tubi", + 0xf57c: "tui", + 0xf57d: "tumblr", + 0xf57f: "turbo", + 0xf580: "turborepo", + 0xf581: "turbosquid", + 0xf582: "turkishairlines", + 0xf583: "turso", + 0xf584: "tuta", + 0xf585: "tvtime", + 0xf586: "tv4play", + 0xf588: "twinkly", + 0xf589: "twinmotion", + 0xf58a: "twitch", + 0xf58b: "typeform", + 0xf58c: "typeorm", + 0xf58d: "typer", + 0xf58e: "typescript", + 0xf58f: "typo3", + 0xf590: "typst", + 0xf591: "udotsdotnews", + 0xf592: "uber", + 0xf593: "ubereats", + 0xf594: "ubiquiti", + 0xf595: "ubisoft", + 0xf596: "ublockorigin", + 0xf597: "ubuntu", + 0xf598: "ubuntumate", + 0xf599: "udacity", + 0xf59a: "udemy", + 0xf59b: "ufc", + 0xf59c: "uikit", + 0xf59d: "uipath", + 0xf59e: "ukca", + 0xf59f: "ultralytics", + 0xf5a0: "ulule", + 0xf5a1: "umami", + 0xf5a2: "umbraco", + 0xf5a3: "uml", + 0xf5a4: "unacademy", + 0xf5a5: "underarmour", + 0xf5a6: "underscoredotjs", + 0xf5a7: "undertale", + 0xf5a8: "unicode", + 0xf5a9: "unilever", + 0xf5aa: "uniqlo", + 0xf5ab: "uniqloJa", + 0xf5ac: "unitedairlines", + 0xf5ad: "unitednations", + 0xf5ae: "unity", + 0xf5af: "unjs", + 0xf5b0: "unlicense", + 0xf5b1: "unocss", + 0xf5b2: "unpkg", + 0xf5b3: "unraid", + 0xf5b4: "unrealengine", + 0xf5b5: "unsplash", + 0xf5b6: "untappd", + 0xf5b7: "upcloud", + 0xf5b8: "uphold", + 0xf5b9: "uplabs", + 0xf5ba: "upptime", + 0xf5bb: "ups", + 0xf5bc: "upstash", + 0xf5bd: "uptimekuma", + 0xf5be: "upwork", + 0xf5bf: "usps", + 0xf5c0: "utorrent", + 0xf5c1: "uv", + 0xf5c2: "v", + 0xf5c3: "v0", + 0xf5c4: "v2ex", + 0xf5c5: "v8", + 0xf5c6: "vaadin", + 0xf5c7: "vagrant", + 0xf5c8: "vala", + 0xf5c9: "valorant", + 0xf5ca: "valve", + 0xf5cb: "vapor", + 0xf5cc: "vault", + 0xf5cd: "vaultwarden", + 0xf5ce: "vauxhall", + 0xf5cf: "vbulletin", + 0xf5d0: "vectary", + 0xf5d1: "vectorlogozone", + 0xf5d2: "vectorworks", + 0xf5d3: "veeam", + 0xf5d4: "veed", + 0xf5d5: "veepee", + 0xf5d6: "vega", + 0xf5d7: "vegas", + 0xf5d8: "velocity", + 0xf5d9: "velog", + 0xf5da: "vencord", + 0xf5db: "venmo", + 0xf5dc: "vercel", + 0xf5dd: "verdaccio", + 0xf5de: "veritas", + 0xf5df: "verizon", + 0xf5e0: "vespa", + 0xf5e1: "vestel", + 0xf5e2: "vexxhost", + 0xf5e3: "vfairs", + 0xf5e4: "viadeo", + 0xf5e5: "viaplay", + 0xf5e6: "viber", + 0xf5e7: "viblo", + 0xf5e8: "victoriametrics", + 0xf5e9: "victronenergy", + 0xf5ea: "vim", + 0xf5eb: "vimeo", + 0xf5ec: "vimeolivestream", + 0xf5ed: "virgin", + 0xf5ee: "virginatlantic", + 0xf5ef: "virginmedia", + 0xf5f0: "virtualbox", + 0xf5f1: "virustotal", + 0xf5f2: "visa", + 0xf5f3: "visx", + 0xf5f4: "vite", + 0xf5f5: "vitepress", + 0xf5f6: "vitess", + 0xf5f7: "vitest", + 0xf5f8: "vivawallet", + 0xf5f9: "vivaldi", + 0xf5fa: "vivino", + 0xf5fb: "vivint", + 0xf5fc: "vivo", + 0xf5fd: "vk", + 0xf5fe: "vlcmediaplayer", + 0xf5ff: "vmware", + 0xf600: "vodafone", + 0xf601: "voidlinux", + 0xf602: "voipdotms", + 0xf603: "volkswagen", + 0xf604: "volvo", + 0xf605: "vonage", + 0xf606: "vorondesign", + 0xf607: "vowpalwabbit", + 0xf608: "vox", + 0xf609: "vrchat", + 0xf60a: "vsco", + 0xf60b: "vscodium", + 0xf60c: "vtex", + 0xf60d: "vuedotjs", + 0xf60e: "vuetify", + 0xf60f: "vulkan", + 0xf610: "vultr", + 0xf611: "vyond", + 0xf612: "w3schools", + 0xf613: "wacom", + 0xf614: "wagmi", + 0xf615: "wagtail", + 0xf616: "wails", + 0xf617: "wakatime", + 0xf618: "walkman", + 0xf619: "wallabag", + 0xf61a: "walletconnect", + 0xf61c: "wantedly", + 0xf61d: "wappalyzer", + 0xf61f: "warp", + 0xf620: "wasabi", + 0xf621: "wasmcloud", + 0xf622: "wasmer", + 0xf623: "watchtower", + 0xf624: "wattpad", + 0xf625: "wayland", + 0xf626: "waze", + 0xf627: "wazirx", + 0xf628: "wearos", + 0xf629: "weasyl", + 0xf62a: "webdotde", + 0xf62b: "web3dotjs", + 0xf62c: "webassembly", + 0xf62d: "webauthn", + 0xf62e: "webcomponentsdotorg", + 0xf62f: "webdriverio", + 0xf630: "webex", + 0xf631: "webflow", + 0xf632: "webgl", + 0xf633: "webgpu", + 0xf634: "weblate", + 0xf635: "webmin", + 0xf636: "webmoney", + 0xf637: "webpack", + 0xf638: "webrtc", + 0xf639: "webstorm", + 0xf63a: "webtoon", + 0xf63b: "webtrees", + 0xf63c: "wechat", + 0xf63d: "wegame", + 0xf63e: "weightsandbiases", + 0xf63f: "welcometothejungle", + 0xf640: "wellfound", + 0xf641: "wellsfargo", + 0xf642: "wemo", + 0xf644: "westernunion", + 0xf645: "wetransfer", + 0xf646: "wezterm", + 0xf647: "wgpu", + 0xf648: "whatsapp", + 0xf649: "wheniwork", + 0xf64a: "wikidotgg", + 0xf64b: "wikidotjs", + 0xf64c: "wikibooks", + 0xf64d: "wikidata", + 0xf64e: "wikimediacommons", + 0xf64f: "wikimediafoundation", + 0xf650: "wikipedia", + 0xf651: "wikiquote", + 0xf652: "wikiversity", + 0xf653: "wikivoyage", + 0xf654: "winamp", + 0xf655: "wine", + 0xf656: "wipro", + 0xf657: "wire", + 0xf658: "wireguard", + 0xf659: "wireshark", + 0xf65a: "wise", + 0xf65b: "wish", + 0xf65c: "wistia", + 0xf65d: "wix", + 0xf65e: "wizzair", + 0xf65f: "wolfram", + 0xf660: "wolframlanguage", + 0xf661: "wolframmathematica", + 0xf662: "wondershare", + 0xf663: "wondersharefilmora", + 0xf664: "woo", + 0xf665: "woocommerce", + 0xf666: "wordpress", + 0xf667: "workplace", + 0xf668: "worldhealthorganization", + 0xf669: "wpengine", + 0xf66a: "wprocket", + 0xf66b: "wpexplorer", + 0xf66c: "writedotas", + 0xf66d: "wwe", + 0xf66e: "wwise", + 0xf66f: "wxt", + 0xf670: "wykop", + 0xf671: "wyze", + 0xf672: "x", + 0xf673: "xdotorg", + 0xf674: "xampp", + 0xf675: "xcode", + 0xf676: "xdadevelopers", + 0xf677: "xendit", + 0xf678: "xero", + 0xf679: "xfce", + 0xf67a: "xiaohongshu", + 0xf67b: "xiaomi", + 0xf67c: "xing", + 0xf67d: "xml", + 0xf67e: "xmpp", + 0xf67f: "xo", + 0xf680: "xrp", + 0xf681: "xsplit", + 0xf682: "xstate", + 0xf683: "xubuntu", + 0xf684: "ycombinator", + 0xf685: "yabai", + 0xf686: "yale", + 0xf687: "yamahacorporation", + 0xf688: "yamahamotorcorporation", + 0xf689: "yaml", + 0xf68a: "yandexcloud", + 0xf68b: "yarn", + 0xf68c: "yelp", + 0xf68d: "yeti", + 0xf68e: "yii", + 0xf68f: "yoast", + 0xf690: "yolo", + 0xf691: "youhodler", + 0xf692: "youtube", + 0xf693: "youtubegaming", + 0xf694: "youtubekids", + 0xf695: "youtubemusic", + 0xf696: "youtubeshorts", + 0xf697: "youtubestudio", + 0xf698: "youtubetv", + 0xf699: "yr", + 0xf69a: "yubico", + 0xf69b: "yunohost", + 0xf69c: "zabka", + 0xf69d: "zaim", + 0xf69e: "zalando", + 0xf69f: "zalo", + 0xf6a0: "zap", + 0xf6a1: "zapier", + 0xf6a2: "zara", + 0xf6a3: "zazzle", + 0xf6a4: "zcash", + 0xf6a5: "zcool", + 0xf6a6: "zdf", + 0xf6a7: "zebpay", + 0xf6a8: "zebratechnologies", + 0xf6a9: "zedindustries", + 0xf6aa: "zelle", + 0xf6ab: "zend", + 0xf6ac: "zendesk", + 0xf6ad: "zenn", + 0xf6ae: "zenodo", + 0xf6af: "zensar", + 0xf6b0: "zerodha", + 0xf6b1: "zerotier", + 0xf6b2: "zettlr", + 0xf6b3: "zhihu", + 0xf6b4: "zig", + 0xf6b5: "zigbee", + 0xf6b6: "zigbee2mqtt", + 0xf6b7: "ziggo", + 0xf6b8: "zilch", + 0xf6b9: "zillow", + 0xf6ba: "zincsearch", + 0xf6bb: "zingat", + 0xf6bc: "zod", + 0xf6bd: "zoho", + 0xf6be: "zoiper", + 0xf6bf: "zomato", + 0xf6c0: "zoom", + 0xf6c1: "zorin", + 0xf6c2: "zotero", + 0xf6c3: "zsh", + 0xf6c4: "zulip", + 0xf6c5: "zyte", }; diff --git a/lib/data/money.dart b/lib/data/money.dart index c9430865..cbed6e1c 100644 --- a/lib/data/money.dart +++ b/lib/data/money.dart @@ -184,7 +184,10 @@ class Money { final int totalDecimalPlaces = CurrencyRegistryService() .detectDecimalPrecision(amountToFormat, currency); - if (preferredDecimalPlaces != null) { + if (totalDecimalPlaces == 0 && + UserPreferencesService().hideZeroDecimals) { + decimalDigits = 0; + } else if (preferredDecimalPlaces != null) { decimalDigits = math.max( 2, math.min(totalDecimalPlaces, preferredDecimalPlaces), diff --git a/lib/data/prefs/date_format_preset.dart b/lib/data/prefs/date_format_preset.dart new file mode 100644 index 00000000..8eb9e9eb --- /dev/null +++ b/lib/data/prefs/date_format_preset.dart @@ -0,0 +1,44 @@ +import "package:flow/l10n/date_format_moment_localization.dart"; +import "package:moment_dart/moment_dart.dart"; + +enum DateFormatPreset { + system("system", null, null, null), + iso("iso", "YYYY-MM-DD", "YYYY-MM-DD", "MM-DD"), + dayMonthYearSlash("dmySlash", "DD/MM/YYYY", "DD/MM/YYYY", "D/M"), + monthDayYearSlash("mdySlash", "MM/DD/YYYY", "MM/DD/YYYY", "M/D"), + dayMonthYearDot("dmyDot", "DD.MM.YYYY", "DD.MM.YYYY", "D.M"), + dayMonthNameYear("dMonY", "D MMM YYYY", "D MMMM YYYY", "D MMM"); + + final String value; + + /// moment_dart patterns, null means follow the locale + final String? pattern; + final String? longPattern; + final String? dayMonthPattern; + + const DateFormatPreset( + this.value, + this.pattern, + this.longPattern, + this.dayMonthPattern, + ); + + static DateFormatPreset? tryParse(String? value) { + for (final DateFormatPreset preset in values) { + if (preset.value == value) return preset; + } + + return null; + } + + /// Wraps [localization] so it formats dates with this preset + MomentLocalization apply(MomentLocalization localization) { + final MomentLocalization base = localization is DateFormatMomentLocalization + ? localization.inner + : localization; + + if (this == .system) return base; + + return DateFormatMomentLocalization(base, this); + } +} diff --git a/lib/data/setup/demo_data.dart b/lib/data/setup/demo_data.dart index 0e0d6cc1..4039b0af 100644 --- a/lib/data/setup/demo_data.dart +++ b/lib/data/setup/demo_data.dart @@ -99,7 +99,7 @@ class DemoDataGenerator { DateTime cursor = _start; while (cursor.isBefore(end)) { _runDay(cursor); - cursor = cursor.add(const Duration(days: 1)); + cursor = DateTime(cursor.year, cursor.month, cursor.day + 1); } return _txns; @@ -683,7 +683,7 @@ class DemoDataGenerator { final int nights = 2 + rng.nextInt(4); for (int i = 0; i < nights; i++) { - final DateTime night = day.add(Duration(days: i)); + final DateTime night = DateTime(day.year, day.month, day.day + i); if (!_within(night)) break; _spend( @@ -708,7 +708,7 @@ class DemoDataGenerator { } // Return flight - final DateTime back = day.add(Duration(days: nights)); + final DateTime back = DateTime(day.year, day.month, day.day + nights); if (_within(back)) { _spend( amount: _money(160, 520), diff --git a/lib/entity/recurring_transaction.dart b/lib/entity/recurring_transaction.dart index 800c3700..379b6baa 100644 --- a/lib/entity/recurring_transaction.dart +++ b/lib/entity/recurring_transaction.dart @@ -82,9 +82,14 @@ class RecurringTransaction extends EntityBase { bool disabled; + /// Amount changes each time, e.g., utility bills. Generated transactions + /// are pending with an estimate, and ask for the amount upon confirmation. + bool variableAmount; + RecurringTransaction({ this.id = 0, this.disabled = false, + this.variableAmount = false, required this.rules, required this.jsonTransactionTemplate, required this.range, diff --git a/lib/entity/recurring_transaction.g.dart b/lib/entity/recurring_transaction.g.dart index dc6d722c..03be8714 100644 --- a/lib/entity/recurring_transaction.g.dart +++ b/lib/entity/recurring_transaction.g.dart @@ -10,6 +10,7 @@ RecurringTransaction _$RecurringTransactionFromJson( Map<String, dynamic> json, ) => RecurringTransaction( disabled: json['disabled'] as bool? ?? false, + variableAmount: json['variableAmount'] as bool? ?? false, rules: (json['rules'] as List<dynamic>).map((e) => e as String).toList(), jsonTransactionTemplate: json['jsonTransactionTemplate'] as String, range: json['range'] as String, @@ -39,6 +40,7 @@ Map<String, dynamic> _$RecurringTransactionToJson( const UTCDateTimeConverter().toJson, ), 'disabled': instance.disabled, + 'variableAmount': instance.variableAmount, }; Value? _$JsonConverterFromJson<Json, Value>( diff --git a/lib/entity/transaction/extensions/default/recurring.dart b/lib/entity/transaction/extensions/default/recurring.dart index e47ea41d..48c75ab6 100644 --- a/lib/entity/transaction/extensions/default/recurring.dart +++ b/lib/entity/transaction/extensions/default/recurring.dart @@ -23,11 +23,15 @@ class Recurring extends TransactionExtension implements Jasonable { /// transaction is updated. final bool locked; + /// Amount is an estimate until confirmed. + final bool variableAmount; + Recurring({ required super.uuid, required this.initialTransactionDate, this.relatedTransactionUuid, this.locked = false, + this.variableAmount = false, }) : super(); @override @@ -44,6 +48,7 @@ class Recurring extends TransactionExtension implements Jasonable { String? relatedTransactionUuid, String? uuid, bool? locked, + bool? variableAmount, }) { return Recurring( initialTransactionDate: @@ -52,6 +57,7 @@ class Recurring extends TransactionExtension implements Jasonable { relatedTransactionUuid ?? this.relatedTransactionUuid, uuid: uuid ?? this.uuid, locked: locked ?? this.locked, + variableAmount: variableAmount ?? this.variableAmount, ); } } diff --git a/lib/entity/transaction/extensions/default/recurring.g.dart b/lib/entity/transaction/extensions/default/recurring.g.dart index 16374446..c9d794eb 100644 --- a/lib/entity/transaction/extensions/default/recurring.g.dart +++ b/lib/entity/transaction/extensions/default/recurring.g.dart @@ -13,6 +13,7 @@ Recurring _$RecurringFromJson(Map<String, dynamic> json) => Recurring( ), relatedTransactionUuid: json['relatedTransactionUuid'] as String?, locked: json['locked'] as bool? ?? false, + variableAmount: json['variableAmount'] as bool? ?? false, ); Map<String, dynamic> _$RecurringToJson(Recurring instance) => <String, dynamic>{ @@ -20,5 +21,6 @@ Map<String, dynamic> _$RecurringToJson(Recurring instance) => <String, dynamic>{ 'relatedTransactionUuid': instance.relatedTransactionUuid, 'initialTransactionDate': instance.initialTransactionDate.toIso8601String(), 'locked': instance.locked, + 'variableAmount': instance.variableAmount, 'key': instance.key, }; diff --git a/lib/entity/user_preferences.dart b/lib/entity/user_preferences.dart index 5cc60a08..712a804b 100644 --- a/lib/entity/user_preferences.dart +++ b/lib/entity/user_preferences.dart @@ -1,5 +1,6 @@ import "package:flow/data/flow_button_type.dart"; import "package:flow/data/prefs/change_visuals.dart"; +import "package:flow/data/prefs/date_format_preset.dart"; import "package:flow/data/transactions_filter/pending_time_range.dart"; import "package:flow/entity/_base.dart"; import "package:flow/entity/user_preferences/transaction_entry_flow.dart"; @@ -76,6 +77,8 @@ class UserPreferences implements EntityBase { bool transactionListTileShowExternalSource; bool transactionListTileRelaxedDensity; + bool transactionListAbsoluteDateHeaders; + bool createTransactionsPerItemInScans; int? scansPendingThresholdInHours; @@ -84,6 +87,12 @@ class UserPreferences implements EntityBase { String? icuCurrencyFormattingPattern; + /// Omits decimals for whole amounts, e.g., $3 instead of $3.00 + bool hideZeroDecimals; + + /// Serialized version of [DateFormatPreset] + String? dateFormatPreset; + String? primaryCurrency; String? primaryAccountUuid; @@ -174,6 +183,7 @@ class UserPreferences implements EntityBase { this.transactionListTileShowAccountForLeading = false, this.transactionListTileShowExternalSource = true, this.transactionListTileRelaxedDensity = false, + this.transactionListAbsoluteDateHeaders = false, this.createTransactionsPerItemInScans = true, this.scansPendingThresholdInHours = 6, this.privacyModeUponLaunch = false, @@ -185,6 +195,8 @@ class UserPreferences implements EntityBase { this.iCloudBackupsToKeep = 10, this.autoBackupIntervalInHours = 72, this.icuCurrencyFormattingPattern, + this.hideZeroDecimals = false, + this.dateFormatPreset, this.primaryCurrency, this.primaryAccountUuid, this.transactionButtonOrderJoined, diff --git a/lib/entity/user_preferences.g.dart b/lib/entity/user_preferences.g.dart index 1d5675d5..97624f5d 100644 --- a/lib/entity/user_preferences.g.dart +++ b/lib/entity/user_preferences.g.dart @@ -21,6 +21,8 @@ UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) => json['transactionListTileShowExternalSource'] as bool? ?? true, transactionListTileRelaxedDensity: json['transactionListTileRelaxedDensity'] as bool? ?? false, + transactionListAbsoluteDateHeaders: + json['transactionListAbsoluteDateHeaders'] as bool? ?? false, createTransactionsPerItemInScans: json['createTransactionsPerItemInScans'] as bool? ?? true, scansPendingThresholdInHours: @@ -40,6 +42,8 @@ UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) => (json['autoBackupIntervalInHours'] as num?)?.toInt() ?? 72, icuCurrencyFormattingPattern: json['icuCurrencyFormattingPattern'] as String?, + hideZeroDecimals: json['hideZeroDecimals'] as bool? ?? false, + dateFormatPreset: json['dateFormatPreset'] as String?, primaryCurrency: json['primaryCurrency'] as String?, primaryAccountUuid: json['primaryAccountUuid'] as String?, transactionButtonOrderJoined: @@ -74,11 +78,15 @@ Map<String, dynamic> _$UserPreferencesToJson( instance.transactionListTileShowExternalSource, 'transactionListTileRelaxedDensity': instance.transactionListTileRelaxedDensity, + 'transactionListAbsoluteDateHeaders': + instance.transactionListAbsoluteDateHeaders, 'createTransactionsPerItemInScans': instance.createTransactionsPerItemInScans, 'scansPendingThresholdInHours': instance.scansPendingThresholdInHours, 'privacyModeUponLaunch': instance.privacyModeUponLaunch, 'privacyModeUponShaking': instance.privacyModeUponShaking, 'icuCurrencyFormattingPattern': instance.icuCurrencyFormattingPattern, + 'hideZeroDecimals': instance.hideZeroDecimals, + 'dateFormatPreset': instance.dateFormatPreset, 'primaryCurrency': instance.primaryCurrency, 'primaryAccountUuid': instance.primaryAccountUuid, 'autoBackupIntervalInHours': instance.autoBackupIntervalInHours, diff --git a/lib/graceful_migrations.dart b/lib/graceful_migrations.dart index 40fb0c28..a9797930 100644 --- a/lib/graceful_migrations.dart +++ b/lib/graceful_migrations.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:flow/data/flow_icon.dart"; import "package:flow/data/legacy_simple_icons_codepoints.dart"; import "package:flow/data/transaction_filter.dart"; @@ -15,7 +17,6 @@ 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"); @@ -71,6 +72,9 @@ void migratePrivacyPreferencesToUserPreferences() async { final bool privacyMode = LocalPreferences().privacyMode.get(); UserPreferencesService().privacyModeUponLaunch = privacyMode; + if (privacyMode) { + unawaited(TransitiveLocalPreferences().sessionPrivacyMode.set(true)); + } await prefs.setString("flow.migration.$migrationUuid", "ok"); } catch (e) { @@ -321,10 +325,8 @@ void migrateHomePendingTransactionsRange() async { /// /// 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. +/// simple_icons 14.6.1; [legacySimpleIconsCodepointToSlug] maps those code +/// points straight to their stable slug, independent of the bundled version. Future<void> migrateSimpleIconsToSlug() async { const String migrationUuid = "598a1c1d-1d53-44e0-9035-e005c5420538"; @@ -339,23 +341,14 @@ Future<void> migrateSimpleIconsToSlug() async { if (ok != null) return; try { - // 16.20.0 code point -> slug, built once from the bundled font. - final Map<int, String> codePointToSlug = { - for (final entry in SimpleIcons.values.entries) - entry.value.codePoint: entry.key, - }; - + // [IconFlowIcon.parse] resolves legacy brand code points to slugs. + // Slugs missing from the bundled build (removed upstream) are left as + // they are. String? slugForIconCode(String iconCode) { + if (!iconCode.startsWith("IconFlowIcon:")) return null; 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]; + if (parsed is! SimpleIconFlowIcon || parsed.slug.isEmpty) return null; + return parsed.slug; } final List<Account> changedAccounts = []; diff --git a/lib/l10n/date_format_moment_localization.dart b/lib/l10n/date_format_moment_localization.dart new file mode 100644 index 00000000..a70a6efc --- /dev/null +++ b/lib/l10n/date_format_moment_localization.dart @@ -0,0 +1,126 @@ +import "package:flow/data/prefs/date_format_preset.dart"; +import "package:moment_dart/moment_dart.dart"; +// ignore: implementation_imports +import "package:moment_dart/src/localizations/mixins/simple_range.dart"; + +/// Delegates to [inner], except for localized date formats (l, LL, lll...) +class DateFormatMomentLocalization extends MomentLocalization with SimpleRange { + final MomentLocalization inner; + final DateFormatPreset preset; + + DateFormatMomentLocalization(this.inner, this.preset) + : assert(preset.pattern != null && preset.longPattern != null); + + late final Map<FormatterToken, FormatterTokenFn?> _innerFormatters = + inner.formatters; + + @override + late final Map<FormatterToken, FormatterTokenFn?> formatters = { + ..._innerFormatters, + FormatterToken.l: _short, + FormatterToken.L: _short, + FormatterToken.ll: _short, + FormatterToken.LL: _long, + FormatterToken.lll: (dateTime) => "${_short(dateTime)} ${_time(dateTime)}", + FormatterToken.LLL: (dateTime) => "${_long(dateTime)} ${_time(dateTime)}", + FormatterToken.llll: (dateTime) => + "${reformat(dateTime, "ddd")}, ${_short(dateTime)} ${_time(dateTime)}", + FormatterToken.LLLL: (dateTime) => + "${reformat(dateTime, "dddd")}, ${_long(dateTime)} ${_time(dateTime)}", + }; + + String _short(DateTime dateTime) => reformat(dateTime, preset.pattern!); + String _long(DateTime dateTime) => reformat(dateTime, preset.longPattern!); + String _time(DateTime dateTime) => + _innerFormatters[FormatterToken.LT]?.call(dateTime) ?? + reformat(dateTime, "HH:mm"); + + @override + Map<FormatterToken, FormatterTokenFn?> overrideFormatters() => formatters; + + @override + SimpleRangeData get simpleRangeData => (inner as SimpleRange).simpleRangeData; + + @override + String range(TimeRange range, {DateTime? anchor, bool useRelative = true}) { + if (inner is! SimpleRange) { + return inner.range(range, anchor: anchor, useRelative: useRelative); + } + + return super.range(range, anchor: anchor, useRelative: useRelative); + } + + @override + String relative( + Duration duration, { + bool dropPrefixOrSuffix = false, + Abbreviation form = Abbreviation.none, + }) => inner.relative( + duration, + dropPrefixOrSuffix: dropPrefixOrSuffix, + form: form, + ); + + @override + String duration( + Duration duration, { + bool round = true, + bool omitZeros = true, + bool includeWeeks = false, + Abbreviation form = Abbreviation.none, + String? delimiter, + DurationFormat format = DurationFormat.auto, + bool dropPrefixOrSuffix = false, + }) => inner.duration( + duration, + round: round, + omitZeros: omitZeros, + includeWeeks: includeWeeks, + form: form, + delimiter: delimiter, + format: format, + dropPrefixOrSuffix: dropPrefixOrSuffix, + ); + + @override + String calendarTime(Moment moment) => inner.calendarTime(moment); + + @override + String calendar( + Moment moment, { + DateTime? reference, + String? customFormat, + bool omitHours = false, + bool omitHoursIfDistant = true, + }) => inner.calendar( + moment, + reference: reference, + customFormat: customFormat, + omitHours: omitHours, + omitHoursIfDistant: omitHoursIfDistant, + ); + + @override + CalendarLocalizationData? get calendarData => inner.calendarData; + + @override + Map<int, String> get weekdayName => inner.weekdayName; + + @override + String get languageCode => inner.languageCode; + + @override + String? get countryCode => inner.countryCode; + + @override + String get locale => inner.locale; + + @override + String get endonym => inner.endonym; + + @override + String get languageNameInEnglish => inner.languageNameInEnglish; + + @override + int get weekStart => inner.weekStart; +} diff --git a/lib/main.dart b/lib/main.dart index 927d3d0b..2b4374c9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import "dart:ui"; import "package:flow/constants.dart"; import "package:flow/data/flow_icon.dart"; +import "package:flow/data/prefs/date_format_preset.dart"; import "package:flow/entity/profile.dart"; import "package:flow/graceful_migrations.dart"; import "package:flow/l10n/flow_localizations.dart"; @@ -124,9 +125,6 @@ void main() async { }), ); - startupLog.fine("Initializing exchange rates service"); - ExchangeRatesService().init(); - CurrencyRegistryService(); if (Platform.isIOS) { @@ -145,6 +143,20 @@ void main() async { startupLog.severe("Failed to initialize UserPreferencesService", e); } + // Must run after [UserPreferencesService] has loaded from ObjectBox + unawaited( + TransitiveLocalPreferences().sessionPrivacyMode + .set(UserPreferencesService().privacyModeUponLaunch) + .catchError((error) { + startupLog.warning("Failed to seed session privacy mode", error); + return false; + }), + ); + + // Depends on [UserPreferencesService] for the primary currency + startupLog.fine("Initializing exchange rates service"); + ExchangeRatesService().init(); + try { startupLog.fine("Initializing SyncService"); SyncService(); @@ -187,6 +199,8 @@ class FlowState extends State<Flow> { late final AppLifecycleListener _appLifeCycleListener; Locale _locale = FlowLocalizations.supportedLocales.first; + DateFormatPreset _dateFormatPreset = .system; + String? _primaryCurrency; ThemeMode _themeMode = ThemeMode.system; ThemeFactory _themeFactory = ThemeFactory.fromThemeName(null); @@ -220,11 +234,12 @@ class FlowState extends State<Flow> { UserPreferencesService().valueNotifier.addListener(_reloadTheme); UserPreferencesService().valueNotifier.addListener(_listenToShakes); UserPreferencesService().valueNotifier.addListener(_syncWidgets); + UserPreferencesService().valueNotifier.addListener(_reloadDateFormat); ExchangeRatesService().exchangeRatesCache.addListener(_syncWidgets); LocalPreferences().localeOverride.addListener(_reloadLocale); - LocalPreferences().primaryCurrency.addListener(_refreshExchangeRates); + UserPreferencesService().valueNotifier.addListener(_refreshExchangeRates); _tempLock = LocalPreferences().requireLocalAuth.get(); @@ -287,10 +302,13 @@ class FlowState extends State<Flow> { @override void dispose() { LocalPreferences().localeOverride.removeListener(_reloadLocale); - LocalPreferences().primaryCurrency.removeListener(_refreshExchangeRates); + UserPreferencesService().valueNotifier.removeListener( + _refreshExchangeRates, + ); UserPreferencesService().valueNotifier.removeListener(_reloadTheme); UserPreferencesService().valueNotifier.removeListener(_listenToShakes); UserPreferencesService().valueNotifier.removeListener(_syncWidgets); + UserPreferencesService().valueNotifier.removeListener(_reloadDateFormat); ExchangeRatesService().exchangeRatesCache.removeListener(_syncWidgets); @@ -456,17 +474,42 @@ class FlowState extends State<Flow> { "Setting moment_dart localization to ${newMomentLocalization.locale}", ); - Moment.setGlobalLocalization(newMomentLocalization); + _dateFormatPreset = UserPreferencesService().dateFormatPreset; + + Moment.setGlobalLocalization( + _dateFormatPreset.apply(newMomentLocalization), + ); Intl.defaultLocale = overriddenLocale.code; setState(() {}); } - void _refreshExchangeRates() { - ExchangeRatesService().tryFetchRates( - UserPreferencesService().primaryCurrency, + void _reloadDateFormat() { + final DateFormatPreset dateFormatPreset = + UserPreferencesService().dateFormatPreset; + + if (_dateFormatPreset == dateFormatPreset) return; + + _dateFormatPreset = dateFormatPreset; + + mainLogger.fine("Setting date format preset to ${dateFormatPreset.value}"); + + Moment.setGlobalLocalization( + dateFormatPreset.apply(Moment.defaultLocalization), ); + + setState(() {}); + } + + void _refreshExchangeRates() { + final String primaryCurrency = UserPreferencesService().primaryCurrency; + + if (_primaryCurrency == primaryCurrency) return; + + _primaryCurrency = primaryCurrency; + + ExchangeRatesService().tryFetchRates(primaryCurrency); } void _syncWidgets() { diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 3953de0d..2bd11839 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -18,6 +18,7 @@ import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction/extensions/base.dart"; import "package:flow/entity/transaction/extensions/default/geo.dart"; import "package:flow/entity/transaction/extensions/default/recurring.dart"; +import "package:flow/entity/transaction/wrapper.dart"; import "package:flow/entity/transaction/extensions/default/transfer.dart"; import "package:flow/entity/transaction_tag.dart"; import "package:flow/l10n/extensions.dart"; @@ -633,6 +634,30 @@ extension TransactionActions on Transaction { } } + /// Confirms with the actual [amount], keeping the sign. The other side of + /// a transfer follows through the conversion rate. + bool confirmWithAmount(double amount, [bool updateTransactionDate = true]) { + final bool outgoing = this.amount.isNegative; + + this.amount = outgoing ? -amount.abs() : amount.abs(); + TransactionsService().updateOneSync(this); + + final Transaction? related = TransactionsService() + .findTransferRelatedTransactionSync(this); + + if (related != null) { + final double rate = switch (extensions.transfer?.conversionRate) { + double value when value != 0 => value, + _ => 1.0, + }; + + related.amount = outgoing ? amount.abs() * rate : -amount.abs() / rate; + TransactionsService().updateOneSync(related); + } + + return confirm(true, updateTransactionDate); + } + /// Returns the ObjectBox ID for the newly created transaction int duplicate() { if (extensions.transfer case Transfer transferDetails) { @@ -901,6 +926,7 @@ extension AccountActions on Account { bool? isPending, double? conversionRate = 1.0, Recurrence? recurrence, + bool variableRecurringAmount = false, List<String>? extraTags, }) { if (conversionRate == 0) { @@ -921,6 +947,7 @@ extension AccountActions on Account { isPending: isPending, conversionRate: 1.0 / (conversionRate ?? 1.0), recurrence: recurrence, + variableRecurringAmount: variableRecurringAmount, extraTags: extraTags, tags: tags, attachments: attachments, @@ -945,8 +972,21 @@ extension AccountActions on Account { "to": targetAccount.name, }); - final List<TransactionExtension> filteredExtensions = - extensions?.where((ext) => ext is! Transfer).toList() ?? []; + // Copy per side, a shared instance only binds to `from`. Stale + // [Recurring] (e.g., from an edited transfer) is dropped. + List<TransactionExtension> extensionsFor(String transactionUuid) => + ExtensionsWrapper.parse( + ExtensionsWrapper( + extensions?.where((ext) => ext is! Transfer).toList() ?? [], + ).serialize(), + ).data + .where( + (ext) => + ext is! Recurring || + (recurrence == null && ext.relatedTransactionUuid == null), + ) + .map((ext) => ext..setRelatedTransactionUuid(transactionUuid)) + .toList(); transactionDate ??= recurrence?.range.from ?? DateTime.now(); @@ -968,7 +1008,11 @@ extension AccountActions on Account { amount: -amount, title: resolvedTitle, description: description, - extensions: [...filteredExtensions, transferData, ?recurringExtension], + extensions: [ + ...extensionsFor(fromTransactionUuid), + transferData, + ?recurringExtension, + ], uuidOverride: fromTransactionUuid, createdDate: createdDate, transactionDate: transactionDate, @@ -982,7 +1026,7 @@ extension AccountActions on Account { title: resolvedTitle, description: description, extensions: [ - ...filteredExtensions, + ...extensionsFor(toTransactionUuid), transferData.copyWith(relatedTransactionUuid: fromTransactionUuid), if (recurringExtension != null) recurringExtension.copyWith( @@ -1004,6 +1048,7 @@ extension AccountActions on Account { recurrence: recurrence!, uuidOverride: recurringTransactionUuid, transferToAccountUuid: targetAccount.uuid, + variableAmount: variableRecurringAmount, ); } @@ -1025,6 +1070,7 @@ extension AccountActions on Account { bool? isPending, TransactionSubtype? subtype, Recurrence? recurrence, + bool variableRecurringAmount = false, List<String>? extraTags, double? latitude, double? longitude, @@ -1122,6 +1168,7 @@ extension AccountActions on Account { identifier: uuid, recurrence: recurrence!, uuidOverride: recurringTransactionUuid, + variableAmount: variableRecurringAmount, ); } diff --git a/lib/objectbox/objectbox-model.json b/lib/objectbox/objectbox-model.json index 7a03b93a..e86f65eb 100644 --- a/lib/objectbox/objectbox-model.json +++ b/lib/objectbox/objectbox-model.json @@ -362,7 +362,7 @@ }, { "id": "10:7829328581176695647", - "lastPropertyId": "30:5353888497210708730", + "lastPropertyId": "33:7003500790338467583", "name": "UserPreferences", "properties": [ { @@ -507,6 +507,21 @@ "id": "30:5353888497210708730", "name": "homePendingTransactionsTimeRangeSerialized", "type": 9 + }, + { + "id": "31:8739212611241568931", + "name": "transactionListAbsoluteDateHeaders", + "type": 1 + }, + { + "id": "32:5426961238010158288", + "name": "dateFormatPreset", + "type": 9 + }, + { + "id": "33:7003500790338467583", + "name": "hideZeroDecimals", + "type": 1 } ], "relations": [] @@ -577,7 +592,7 @@ }, { "id": "12:800756592587838565", - "lastPropertyId": "11:420551111786793892", + "lastPropertyId": "12:7730179234101270201", "name": "RecurringTransaction", "properties": [ { @@ -625,6 +640,11 @@ "id": "10:651096412135846367", "name": "lastGeneratedTransactionDate", "type": 10 + }, + { + "id": "12:7730179234101270201", + "name": "variableAmount", + "type": 1 } ], "relations": [] diff --git a/lib/objectbox/objectbox.g.dart b/lib/objectbox/objectbox.g.dart index af65cad6..308e151e 100644 --- a/lib/objectbox/objectbox.g.dart +++ b/lib/objectbox/objectbox.g.dart @@ -455,7 +455,7 @@ final _entities = <obx_int.ModelEntity>[ obx_int.ModelEntity( id: const obx_int.IdUid(10, 7829328581176695647), name: 'UserPreferences', - lastPropertyId: const obx_int.IdUid(30, 5353888497210708730), + lastPropertyId: const obx_int.IdUid(33, 7003500790338467583), flags: 0, properties: <obx_int.ModelProperty>[ obx_int.ModelProperty( @@ -627,6 +627,24 @@ final _entities = <obx_int.ModelEntity>[ type: 9, flags: 0, ), + obx_int.ModelProperty( + id: const obx_int.IdUid(31, 8739212611241568931), + name: 'transactionListAbsoluteDateHeaders', + type: 1, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(32, 5426961238010158288), + name: 'dateFormatPreset', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(33, 7003500790338467583), + name: 'hideZeroDecimals', + type: 1, + flags: 0, + ), ], relations: <obx_int.ModelRelation>[], backlinks: <obx_int.ModelBacklink>[], @@ -706,7 +724,7 @@ final _entities = <obx_int.ModelEntity>[ obx_int.ModelEntity( id: const obx_int.IdUid(12, 800756592587838565), name: 'RecurringTransaction', - lastPropertyId: const obx_int.IdUid(11, 420551111786793892), + lastPropertyId: const obx_int.IdUid(12, 7730179234101270201), flags: 0, properties: <obx_int.ModelProperty>[ obx_int.ModelProperty( @@ -763,6 +781,12 @@ final _entities = <obx_int.ModelEntity>[ type: 10, flags: 0, ), + obx_int.ModelProperty( + id: const obx_int.IdUid(12, 7730179234101270201), + name: 'variableAmount', + type: 1, + flags: 0, + ), ], relations: <obx_int.ModelRelation>[], backlinks: <obx_int.ModelBacklink>[], @@ -1657,7 +1681,10 @@ obx_int.ModelDefinition getObjectBoxModel() { : fbb.writeString( object.homePendingTransactionsTimeRangeSerialized!, ); - fbb.startTable(31); + final dateFormatPresetOffset = object.dateFormatPreset == null + ? null + : fbb.writeString(object.dateFormatPreset!); + fbb.startTable(34); fbb.addInt64(0, object.id); fbb.addOffset(1, uuidOffset); fbb.addBool(2, object.combineTransfers); @@ -1686,6 +1713,9 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addBool(27, object.privacyModeUponShaking); fbb.addBool(28, object.transactionListTileShowExternalSource); fbb.addOffset(29, homePendingTransactionsTimeRangeSerializedOffset); + fbb.addBool(30, object.transactionListAbsoluteDateHeaders); + fbb.addOffset(31, dateFormatPresetOffset); + fbb.addBool(32, object.hideZeroDecimals); fbb.finish(fbb.endTable()); return object.id; }, @@ -1720,6 +1750,8 @@ obx_int.ModelDefinition getObjectBoxModel() { .vTableGet(buffer, rootOffset, 60, false); final transactionListTileRelaxedDensityParam = const fb.BoolReader() .vTableGet(buffer, rootOffset, 46, false); + final transactionListAbsoluteDateHeadersParam = const fb.BoolReader() + .vTableGet(buffer, rootOffset, 64, false); final createTransactionsPerItemInScansParam = const fb.BoolReader() .vTableGet(buffer, rootOffset, 52, false); final scansPendingThresholdInHoursParam = const fb.Int64Reader() @@ -1758,6 +1790,15 @@ obx_int.ModelDefinition getObjectBoxModel() { final icuCurrencyFormattingPatternParam = const fb.StringReader( asciiOptimization: true, ).vTableGetNullable(buffer, rootOffset, 30); + final hideZeroDecimalsParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 68, + false, + ); + final dateFormatPresetParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 66); final primaryCurrencyParam = const fb.StringReader( asciiOptimization: true, ).vTableGetNullable(buffer, rootOffset, 34); @@ -1796,6 +1837,8 @@ obx_int.ModelDefinition getObjectBoxModel() { transactionListTileShowExternalSourceParam, transactionListTileRelaxedDensity: transactionListTileRelaxedDensityParam, + transactionListAbsoluteDateHeaders: + transactionListAbsoluteDateHeadersParam, createTransactionsPerItemInScans: createTransactionsPerItemInScansParam, scansPendingThresholdInHours: scansPendingThresholdInHoursParam, @@ -1809,6 +1852,8 @@ obx_int.ModelDefinition getObjectBoxModel() { iCloudBackupsToKeep: iCloudBackupsToKeepParam, autoBackupIntervalInHours: autoBackupIntervalInHoursParam, icuCurrencyFormattingPattern: icuCurrencyFormattingPatternParam, + hideZeroDecimals: hideZeroDecimalsParam, + dateFormatPreset: dateFormatPresetParam, primaryCurrency: primaryCurrencyParam, primaryAccountUuid: primaryAccountUuidParam, transactionButtonOrderJoined: transactionButtonOrderJoinedParam, @@ -1940,7 +1985,7 @@ obx_int.ModelDefinition getObjectBoxModel() { final transferToAccountUuidOffset = object.transferToAccountUuid == null ? null : fbb.writeString(object.transferToAccountUuid!); - fbb.startTable(12); + fbb.startTable(13); fbb.addInt64(0, object.id); fbb.addOffset(1, uuidOffset); fbb.addOffset(2, jsonTransactionTemplateOffset); @@ -1953,6 +1998,7 @@ obx_int.ModelDefinition getObjectBoxModel() { 9, object.lastGeneratedTransactionDate?.millisecondsSinceEpoch, ); + fbb.addBool(11, object.variableAmount); fbb.finish(fbb.endTable()); return object.id; }, @@ -1973,6 +2019,12 @@ obx_int.ModelDefinition getObjectBoxModel() { 16, false, ); + final variableAmountParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 26, + false, + ); final rulesParam = const fb.ListReader<String>( fb.StringReader(asciiOptimization: true), lazy: false, @@ -2001,6 +2053,7 @@ obx_int.ModelDefinition getObjectBoxModel() { final object = RecurringTransaction( id: idParam, disabled: disabledParam, + variableAmount: variableAmountParam, rules: rulesParam, jsonTransactionTemplate: jsonTransactionTemplateParam, range: rangeParam, @@ -2694,6 +2747,20 @@ class UserPreferences_ { /// See [UserPreferences.homePendingTransactionsTimeRangeSerialized]. static final homePendingTransactionsTimeRangeSerialized = obx.QueryStringProperty<UserPreferences>(_entities[6].properties[27]); + + /// See [UserPreferences.transactionListAbsoluteDateHeaders]. + static final transactionListAbsoluteDateHeaders = + obx.QueryBooleanProperty<UserPreferences>(_entities[6].properties[28]); + + /// See [UserPreferences.dateFormatPreset]. + static final dateFormatPreset = obx.QueryStringProperty<UserPreferences>( + _entities[6].properties[29], + ); + + /// See [UserPreferences.hideZeroDecimals]. + static final hideZeroDecimals = obx.QueryBooleanProperty<UserPreferences>( + _entities[6].properties[30], + ); } /// [Budget] entity fields to define ObjectBox queries. @@ -2792,6 +2859,11 @@ class RecurringTransaction_ { /// See [RecurringTransaction.lastGeneratedTransactionDate]. static final lastGeneratedTransactionDate = obx.QueryDateProperty<RecurringTransaction>(_entities[8].properties[8]); + + /// See [RecurringTransaction.variableAmount]. + static final variableAmount = obx.QueryBooleanProperty<RecurringTransaction>( + _entities[8].properties[9], + ); } /// [TransactionTag] entity fields to define ObjectBox queries. diff --git a/lib/prefs/insights_preferences.dart b/lib/prefs/insights_preferences.dart new file mode 100644 index 00000000..7d6aceae --- /dev/null +++ b/lib/prefs/insights_preferences.dart @@ -0,0 +1,99 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; +import "package:local_settings/local_settings.dart"; +import "package:logging/logging.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +final Logger _log = Logger("InsightsLocalPreferences"); + +class InsightsLocalPreferences { + final SharedPreferencesWithCache _prefs; + + static InsightsLocalPreferences? _instance; + + factory InsightsLocalPreferences() { + if (_instance == null) { + throw Exception( + "You must initialize InsightsLocalPreferences by calling initialize().", + ); + } + + return _instance!; + } + + /// [InsightType.name]s the user chose not to see. + late final StringListSettingsEntry hiddenTypes; + + /// Insights shown for the current month, latest per key. Used for the + /// engine's cooldown. + late final JsonListSettingsEntry<InsightSighting> sightings; + + InsightsLocalPreferences._internal(this._prefs) { + SettingsEntry.defaultPrefix = "flow.insights."; + + hiddenTypes = StringListSettingsEntry( + key: "hiddenTypes", + preferences: _prefs, + removeDuplicates: true, + ); + + sightings = JsonListSettingsEntry<InsightSighting>( + key: "sightings", + preferences: _prefs, + fromJson: (json) => InsightSighting( + key: json["key"] as String, + shownAt: DateTime.parse(json["shownAt"] as String), + stake: (json["stake"] as num).toDouble(), + ), + toJson: (sighting) => { + "key": sighting.key, + "shownAt": sighting.shownAt.toIso8601String(), + "stake": sighting.stake, + }, + ); + } + + Set<InsightType> get hiddenTypeSet { + final Map<String, InsightType> byName = InsightType.values.asNameMap(); + + return (hiddenTypes.get() ?? const []) + .map((name) => byName[name]) + .nonNulls + .toSet(); + } + + Future<void> setHidden(InsightType type, bool hidden) => hidden + ? hiddenTypes.addItem(type.name) + : hiddenTypes.removeItem(type.name); + + List<InsightSighting> get sightingList { + try { + return sightings.get() ?? const []; + } catch (e, stackTrace) { + _log.warning("Failed to read insight sightings", e, stackTrace); + return const []; + } + } + + /// Replaces older sightings of the same keys, and forgets the ones too old + /// to matter. + Future<void> recordSightings(List<Insight> insights, DateTime now) async { + final Set<String> keys = insights.map((insight) => insight.key).toSet(); + final DateTime cutoff = now.subtract( + const Duration(days: InsightThresholds.cooldownDays * 2), + ); + + await sightings.set([ + ...sightingList.where( + (sighting) => + !keys.contains(sighting.key) && sighting.shownAt.isAfter(cutoff), + ), + for (final Insight insight in insights) + InsightSighting(key: insight.key, shownAt: now, stake: insight.stake), + ]); + } + + static InsightsLocalPreferences initialize( + SharedPreferencesWithCache instance, + ) => _instance ??= InsightsLocalPreferences._internal(instance); +} diff --git a/lib/prefs/local_preferences.dart b/lib/prefs/local_preferences.dart index f7a25e50..89f5d1cc 100644 --- a/lib/prefs/local_preferences.dart +++ b/lib/prefs/local_preferences.dart @@ -6,6 +6,7 @@ import "package:flow/logging.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/objectbox.g.dart"; import "package:flow/prefs/eny_preferences.dart"; +import "package:flow/prefs/insights_preferences.dart"; import "package:flow/prefs/pending_transactions.dart"; import "package:flow/prefs/transitive.dart"; import "package:intl/intl.dart"; @@ -55,6 +56,10 @@ class LocalPreferences { late final BoolSettingsEntry preferFullAmounts; late final BoolSettingsEntry useCurrencySymbol; + /// Whether to show foreign currency amounts converted to the primary + /// currency, e.g., "≈ R$25" + late final BoolSettingsEntry showApproximatePrimaryAmount; + /// 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: @@ -69,6 +74,7 @@ class LocalPreferences { late final PendingTransactionsLocalPreferences pendingTransactions; late final TransitiveLocalPreferences transitive; late final EnyLocalPreferences eny; + late final InsightsLocalPreferences insights; LocalPreferences._internal(this._prefs) { SettingsEntry.defaultPrefix = "flow."; @@ -146,6 +152,11 @@ class LocalPreferences { preferences: _prefs, initialValue: true, ); + showApproximatePrimaryAmount = BoolSettingsEntry( + key: "showApproximatePrimaryAmount", + preferences: _prefs, + initialValue: true, + ); openedInsightsIndex = BoolSettingsEntry( key: "openedInsightsIndex", @@ -170,6 +181,7 @@ class LocalPreferences { ); transitive = TransitiveLocalPreferences.initialize(_prefs); eny = EnyLocalPreferences.initialize(_prefs); + insights = InsightsLocalPreferences.initialize(_prefs); } @Deprecated("Use UserPreferencesService().primaryCurrency instead") diff --git a/lib/prefs/transitive.dart b/lib/prefs/transitive.dart index 3f8067e8..17b31146 100644 --- a/lib/prefs/transitive.dart +++ b/lib/prefs/transitive.dart @@ -10,7 +10,6 @@ import "package:flow/entity/category.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction_tag.dart"; import "package:flow/objectbox.dart"; -import "package:flow/prefs/local_preferences.dart"; import "package:flow/services/accounts.dart"; import "package:flow/services/transactions.dart"; import "package:flow/services/user_preferences.dart"; @@ -157,12 +156,6 @@ class TransitiveLocalPreferences { _log.warning("Cannot update transitive properties", e, stackTrace); } - try { - unawaited(sessionPrivacyMode.set(LocalPreferences().privacyMode.get())); - } catch (e) { - _log.warning("Failed to seed sessionPrivacyMode from privacyMode", e); - } - try { if (transitiveLastTimeFrecencyUpdated.get() == null || !transitiveLastTimeFrecencyUpdated.get()!.isAtSameDayAs( diff --git a/lib/routes.dart b/lib/routes.dart index 5e90b659..47840eb4 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -33,6 +33,7 @@ import "package:flow/routes/integrate/integrate_eny_page.dart"; import "package:flow/routes/integrations/eny_page.dart"; import "package:flow/routes/preferences/button_order_preferences_page.dart"; import "package:flow/routes/preferences/change_preferences_page.dart"; +import "package:flow/routes/preferences/date_format_preferences_page.dart"; import "package:flow/routes/preferences/integrations/eny_preferences_page.dart"; import "package:flow/routes/preferences/money_formatting_preferences_page.dart"; import "package:flow/routes/preferences/numpad_preferences_page.dart"; @@ -79,6 +80,7 @@ import "package:flow/sync/import/import_v2.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; final GlobalKey<NavigatorState> globalNavigatorKey = GlobalKey<NavigatorState>(); @@ -148,14 +150,23 @@ final GoRouter router = GoRouter( pageBuilder: (context, state) => MaterialPage( child: TransactionPage.edit( transactionId: int.tryParse(state.pathParameters["id"]!) ?? -1, + initialRecurrence: Recurrence.tryParse( + state.uri.queryParameters["recurrence"] ?? "", + ), + initialVariableAmount: + state.uri.queryParameters["variableAmount"] == "true", ), fullscreenDialog: true, ), ), GoRoute( path: "/transactions", - builder: (context, state) => - TransactionsPage.all(title: "transactions.all".t(context)), + builder: (context, state) => TransactionsPage.all( + title: "transactions.all".t(context), + initialRange: TimeRange.tryParse( + state.uri.queryParameters["range"] ?? "", + ), + ), ), GoRoute( path: "/transactions/pending", @@ -329,6 +340,10 @@ final GoRouter router = GoRouter( path: "moneyFormatting", builder: (context, state) => const MoneyFormattingPreferencesPage(), ), + GoRoute( + path: "dateFormat", + builder: (context, state) => const DateFormatPreferencesPage(), + ), GoRoute( path: "sync", builder: (context, state) => const SyncPreferencesPage(), diff --git a/lib/routes/preferences/date_format_preferences_page.dart b/lib/routes/preferences/date_format_preferences_page.dart new file mode 100644 index 00000000..0c4112d7 --- /dev/null +++ b/lib/routes/preferences/date_format_preferences_page.dart @@ -0,0 +1,94 @@ +import "package:flow/data/prefs/date_format_preset.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/helpers.dart"; +import "package:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +class DateFormatPreferencesPage extends StatefulWidget { + const DateFormatPreferencesPage({super.key}); + + @override + State<DateFormatPreferencesPage> createState() => + _DateFormatPreferencesPageState(); +} + +class _DateFormatPreferencesPageState extends State<DateFormatPreferencesPage> { + @override + Widget build(BuildContext context) { + final DateFormatPreset dateFormatPreset = + UserPreferencesService().dateFormatPreset; + final bool transactionListAbsoluteDateHeaders = + UserPreferencesService().transactionListAbsoluteDateHeaders; + + return Scaffold( + appBar: AppBar(title: Text("preferences.dateFormat".t(context))), + body: SingleChildScrollView( + child: SafeArea( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + Center( + child: Text( + _example(dateFormatPreset).lll, + style: context.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16.0), + RadioGroup<DateFormatPreset>( + groupValue: dateFormatPreset, + onChanged: updateDateFormatPreset, + child: Column( + mainAxisSize: MainAxisSize.min, + children: DateFormatPreset.values + .map( + (preset) => RadioListTile<DateFormatPreset>( + title: Text( + preset == .system + ? "preferences.dateFormat.system".t(context) + : _example(preset).format("l"), + ), + subtitle: preset == .system + ? Text(_example(preset).format("l")) + : null, + value: preset, + activeColor: context.colorScheme.primary, + ), + ) + .toList(), + ), + ), + const SizedBox(height: 16.0), + SwitchListTile( + title: Text( + "preferences.dateFormat.absoluteDateHeaders".t(context), + ), + value: transactionListAbsoluteDateHeaders, + onChanged: updateTransactionListAbsoluteDateHeaders, + ), + ], + ), + ), + ), + ); + } + + Moment _example(DateFormatPreset preset) => + Moment.now(localization: preset.apply(Moment.defaultLocalization)); + + void updateDateFormatPreset(DateFormatPreset? newDateFormatPreset) { + if (newDateFormatPreset == null) return; + + UserPreferencesService().dateFormatPreset = newDateFormatPreset; + + setState(() {}); + } + + void updateTransactionListAbsoluteDateHeaders(bool newValue) { + UserPreferencesService().transactionListAbsoluteDateHeaders = newValue; + + setState(() {}); + } +} diff --git a/lib/routes/preferences/money_formatting_preferences_page.dart b/lib/routes/preferences/money_formatting_preferences_page.dart index 8c758ba5..0109f83b 100644 --- a/lib/routes/preferences/money_formatting_preferences_page.dart +++ b/lib/routes/preferences/money_formatting_preferences_page.dart @@ -23,6 +23,10 @@ class _MoneyFormattingPreferencesPageState Widget build(BuildContext context) { final bool preferFullAmounts = LocalPreferences().preferFullAmounts.get(); final bool useCurrencySymbol = LocalPreferences().useCurrencySymbol.get(); + final bool showApproximatePrimaryAmount = LocalPreferences() + .showApproximatePrimaryAmount + .get(); + final bool hideZeroDecimals = UserPreferencesService().hideZeroDecimals; return Scaffold( appBar: AppBar(title: Text("preferences.moneyFormatting".t(context))), @@ -65,6 +69,31 @@ class _MoneyFormattingPreferencesPageState value: useCurrencySymbol, onChanged: updateUseCurrencySymbol, ), + CheckboxListTile( + title: Text( + "preferences.moneyFormatting.showApproximatePrimaryAmount".t( + context, + ), + ), + subtitle: Text( + "preferences.moneyFormatting.showApproximatePrimaryAmount.description" + .t(context), + ), + value: showApproximatePrimaryAmount, + onChanged: updateShowApproximatePrimaryAmount, + ), + CheckboxListTile( + title: Text( + "preferences.moneyFormatting.hideZeroDecimals".t(context), + ), + subtitle: Text( + "preferences.moneyFormatting.hideZeroDecimals.description".t( + context, + ), + ), + value: hideZeroDecimals, + onChanged: updateHideZeroDecimals, + ), ListTile( title: Text( "preferences.moneyFormatting.setICUPattern".t(context), @@ -95,6 +124,26 @@ class _MoneyFormattingPreferencesPageState if (mounted) setState(() {}); } + void updateShowApproximatePrimaryAmount( + bool? newShowApproximatePrimaryAmount, + ) async { + if (newShowApproximatePrimaryAmount == null) return; + + await LocalPreferences().showApproximatePrimaryAmount.set( + newShowApproximatePrimaryAmount, + ); + + if (mounted) setState(() {}); + } + + void updateHideZeroDecimals(bool? newHideZeroDecimals) { + if (newHideZeroDecimals == null) return; + + UserPreferencesService().hideZeroDecimals = newHideZeroDecimals; + + if (mounted) setState(() {}); + } + void updateCustomICUCurrencyFormatter() async { final Optional<String?>? result = await showModalBottomSheet( context: context, diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart index 35436126..e4ec6de8 100644 --- a/lib/routes/preferences_page.dart +++ b/lib/routes/preferences_page.dart @@ -84,19 +84,8 @@ class PreferencesPageState extends State<PreferencesPage> { body: SafeArea( child: ListView( children: [ - ListTile( - title: Text("preferences.sync".t(context)), - leading: const Icon(Symbols.sync_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/sync"), - trailing: const LeChevron(), - ), - if (flowDebugMode || NotificationsService.schedulingSupported) - ListTile( - title: Text("preferences.reminders".t(context)), - leading: const Icon(Symbols.notifications_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/reminders"), - trailing: const LeChevron(), - ), + ListHeader("preferences.general".t(context)), + const SizedBox(height: 8.0), ListTile( title: Text("preferences.language".t(context)), leading: const Icon(Symbols.language_rounded), @@ -113,45 +102,46 @@ class PreferencesPageState extends State<PreferencesPage> { trailing: const LeChevron(), ), ListTile( - title: Text("preferences.transfer".t(context)), - leading: const Icon(Symbols.sync_alt_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/transfer"), - subtitle: Text( - "preferences.transfer.description".t(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + title: Text("preferences.moneyFormatting".t(context)), + leading: const Icon(Symbols.numbers_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/moneyFormatting"), trailing: const LeChevron(), ), ListTile( - title: Text("preferences.trashBin".t(context)), - leading: const Icon(Symbols.delete_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/trashBin"), + title: Text("preferences.dateFormat".t(context)), + leading: const Icon(Symbols.calendar_month_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/dateFormat"), trailing: const LeChevron(), ), + if (flowDebugMode || NotificationsService.schedulingSupported) + ListTile( + title: Text("preferences.reminders".t(context)), + leading: const Icon(Symbols.notifications_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/reminders"), + trailing: const LeChevron(), + ), + const Haptics(), + const SizedBox(height: 24.0), + ListHeader("preferences.transactions".t(context)), + const SizedBox(height: 8.0), ListTile( - title: Text("preferences.moneyFormatting".t(context)), - leading: const Icon(Symbols.numbers_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/moneyFormatting"), + leading: const Icon(Symbols.automation_rounded), + title: Text("preferences.transactionEntryFlow".t(context)), + onTap: () => + _pushAndRefreshAfter("/preferences/transactionEntryFlow"), trailing: const LeChevron(), ), - const SizedBox(height: 24.0), - ListHeader("preferences.integrations".t(context)), - const SizedBox(height: 8.0), ListTile( - title: Text("Eny"), - leading: const SizedBox( - width: 24.0, - height: 24.0, - child: AnimatedEnyLogo(), + title: Text("preferences.transfer".t(context)), + leading: const Icon(Symbols.sync_alt_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/transfer"), + subtitle: Text( + "preferences.transfer.description".t(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - onTap: () => - _pushAndRefreshAfter("/preferences/integrations/eny"), trailing: const LeChevron(), ), - const SizedBox(height: 24.0), - ListHeader("preferences.transactions".t(context)), - const SizedBox(height: 8.0), ListTile( title: Text("preferences.transactions.pending".t(context)), subtitle: Text( @@ -189,13 +179,6 @@ class PreferencesPageState extends State<PreferencesPage> { ), trailing: const LeChevron(), ), - ListTile( - leading: const Icon(Symbols.automation_rounded), - title: Text("preferences.transactionEntryFlow".t(context)), - onTap: () => - _pushAndRefreshAfter("/preferences/transactionEntryFlow"), - trailing: const LeChevron(), - ), const SizedBox(height: 24.0), ListHeader("preferences.appearance".t(context)), const SizedBox(height: 8.0), @@ -240,23 +223,45 @@ class PreferencesPageState extends State<PreferencesPage> { trailing: const LeChevron(), ), const SizedBox(height: 24.0), - ListHeader("preferences.privacy".t(context)), + ListHeader("preferences.privacyAndSecurity".t(context)), const SizedBox(height: 8.0), const Privacy(), if (_showLockApp) ...[const SizedBox(height: 8.0), const LockApp()], const SizedBox(height: 24.0), - ListHeader("preferences.hapticFeedback".t(context)), - const SizedBox(height: 8.0), - const Haptics(), - const SizedBox(height: 24.0), - ListHeader("preferences.feedback".t(context)), + ListHeader("preferences.data".t(context)), const SizedBox(height: 8.0), + ListTile( + title: Text("preferences.sync".t(context)), + leading: const Icon(Symbols.sync_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/sync"), + trailing: const LeChevron(), + ), + ListTile( + title: Text("Eny"), + leading: const SizedBox( + width: 24.0, + height: 24.0, + child: AnimatedEnyLogo(), + ), + onTap: () => + _pushAndRefreshAfter("/preferences/integrations/eny"), + trailing: const LeChevron(), + ), + ListTile( + title: Text("preferences.trashBin".t(context)), + leading: const Icon(Symbols.delete_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/trashBin"), + trailing: const LeChevron(), + ), ListTile( title: Text("fileAttachment.cleanupHangingFiles".t(context)), - leading: const Icon(Symbols.bug_report_rounded), + leading: const Icon(Symbols.folder_delete_rounded), onTap: () => _deleteHangingFiles(), trailing: const LeChevron(), ), + const SizedBox(height: 24.0), + ListHeader("preferences.feedback".t(context)), + const SizedBox(height: 8.0), ListTile( title: Text("preferences.feedback.debugLogs".t(context)), leading: const Icon(Symbols.bug_report_rounded), diff --git a/lib/routes/stats/recurring_page.dart b/lib/routes/stats/recurring_page.dart index 81e31431..20bbb871 100644 --- a/lib/routes/stats/recurring_page.dart +++ b/lib/routes/stats/recurring_page.dart @@ -3,6 +3,8 @@ 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/entity/transaction/extensions/default/recurring.dart"; +import "package:flow/entity/transaction/wrapper.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/services/categories.dart"; @@ -56,6 +58,10 @@ class _RecurringPageState extends State<RecurringPage> double totalIncome = 0.0; double totalExpense = 0.0; + /// Whether the totals include variable amount estimates + bool approximateIncome = false; + bool approximateExpense = false; + /// 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. @@ -94,6 +100,8 @@ class _RecurringPageState extends State<RecurringPage> income: Money(totalIncome, primaryCurrency), expense: Money(totalExpense, primaryCurrency), count: occurrences.length, + approximateIncome: approximateIncome, + approximateExpense: approximateExpense, ), const SizedBox(height: 16.0), switch ((activeCount, occurrences.isEmpty)) { @@ -246,11 +254,14 @@ class _RecurringPageState extends State<RecurringPage> final Map<String, int> loggedIds = {}; double income = 0.0; double expense = 0.0; + bool incomeEstimated = false; + bool expenseEstimated = false; 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; + final Transaction? template = _decodeTemplate(recurring); + if (template == null) continue; active++; @@ -260,6 +271,8 @@ class _RecurringPageState extends State<RecurringPage> TransactionFilter(extraTag: recurring.extensionIdentifierTag), ); + final double estimate = recurring.estimateAmount(template, logged); + final String? categoryUuid = recurring.template.categoryUuid; final Category? category = categoryUuid == null ? null @@ -284,6 +297,10 @@ class _RecurringPageState extends State<RecurringPage> loggedIds[_occurrenceKey(occurrence)] = loggedMatch.id; } + final bool estimated = + recurring.variableAmount && + _estimateOccurrence(occurrence, loggedMatch, estimate); + final double? converted = occurrence.money.tryConvertAmount( primaryCurrency, rates, @@ -296,8 +313,10 @@ class _RecurringPageState extends State<RecurringPage> switch (occurrence.type) { case TransactionType.income: income += converted.abs(); + incomeEstimated |= estimated; case TransactionType.expense: expense += converted.abs(); + expenseEstimated |= estimated; case TransactionType.transfer: break; } @@ -311,6 +330,8 @@ class _RecurringPageState extends State<RecurringPage> activeCount = active; totalIncome = income; totalExpense = expense; + approximateIncome = incomeEstimated; + approximateExpense = expenseEstimated; missingRates = missing; } finally { busy = false; @@ -318,6 +339,32 @@ class _RecurringPageState extends State<RecurringPage> } } + /// Shows the amount [logged] has, [estimate] otherwise. + /// + /// Returns whether [occurrence] ended up with an estimate. + bool _estimateOccurrence( + Transaction occurrence, + Transaction? logged, + double estimate, + ) { + if (logged != null && logged.accountUuid == occurrence.accountUuid) { + occurrence.amount = logged.amount; + if (logged.isPending != true) return false; + } else { + occurrence.amount = estimate; + } + + occurrence.extensions = ExtensionsWrapper([ + ...occurrence.extensions.data.where((ext) => ext is! Recurring), + Recurring( + uuid: occurrence.extensions.recurring?.uuid ?? occurrence.uuid, + initialTransactionDate: occurrence.transactionDate, + variableAmount: true, + ), + ]); + return true; + } + Transaction? _decodeTemplate(RecurringTransaction recurring) { try { final Transaction template = recurring.template; diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart index 821fb142..0a336259 100644 --- a/lib/routes/stats/stats_by_group_page.dart +++ b/lib/routes/stats/stats_by_group_page.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:flow/data/chart_data.dart"; import "package:flow/data/exchange_rates.dart"; import "package:flow/data/flow_analytics.dart"; @@ -10,6 +12,7 @@ import "package:flow/objectbox/actions.dart"; import "package:flow/prefs/local_preferences.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; import "package:flow/utils/time_and_range.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/home/stats/group_list_view.dart"; @@ -42,7 +45,7 @@ class StatsByGroupPageState extends State<StatsByGroupPage> FlowAnalytics? analytics; bool busy = false; - bool useListView = false; + bool useChart = false; @override void initState() { @@ -62,12 +65,31 @@ class StatsByGroupPageState extends State<StatsByGroupPage> widget.byCategory ? "categories".t(context) : "accounts".t(context), ), actions: [ - IconButton( - onPressed: () => setState(() => useListView = !useListView), - icon: Icon( - useListView - ? Symbols.pie_chart_rounded - : Symbols.list_alt_rounded, + Padding( + padding: const EdgeInsetsDirectional.only(end: 8.0), + child: SegmentedButton<bool>( + selected: {useChart}, + onSelectionChanged: (selection) => + updateUseChart(selection.first), + showSelectedIcon: false, + style: SegmentedButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + selectedBackgroundColor: context.colorScheme.primary, + selectedForegroundColor: context.colorScheme.onPrimary, + ), + segments: [ + ButtonSegment<bool>( + value: false, + icon: const Icon(Symbols.view_list_rounded), + tooltip: "tabs.stats.byGroup.view.list".t(context), + ), + ButtonSegment<bool>( + value: true, + icon: const Icon(Symbols.donut_large_rounded), + tooltip: "tabs.stats.byGroup.view.chart".t(context), + ), + ], ), ), ], @@ -128,27 +150,31 @@ class StatsByGroupPageState extends State<StatsByGroupPage> child: TabBarView( controller: _tabController, children: [ - useListView - ? GroupListView( + useChart + ? PieGraphView( data: expenses, changeMode: changeMode, range: range, ) - : PieGraphView( + : GroupListView( data: expenses, changeMode: changeMode, range: range, + type: TransactionType.expense, + byCategory: widget.byCategory, ), - useListView - ? GroupListView( + useChart + ? PieGraphView( data: incomes, changeMode: changeMode, range: range, ) - : PieGraphView( + : GroupListView( data: incomes, changeMode: changeMode, range: range, + type: TransactionType.income, + byCategory: widget.byCategory, ), ], ), @@ -161,6 +187,12 @@ class StatsByGroupPageState extends State<StatsByGroupPage> ); } + void updateUseChart(bool newValue) { + setState(() { + useChart = newValue; + }); + } + void updateRange(TimeRange newRange) { setState(() { range = newRange; @@ -212,6 +244,7 @@ class StatsByGroupPageState extends State<StatsByGroupPage> final String primaryCurrency = UserPreferencesService().primaryCurrency; final Map<String, Money> cache = {}; + final Map<String, int> counts = {}; final List<MapEntry<String, MultiCurrencyFlow<T>>> filtered = raw.entries .where((entry) { @@ -219,9 +252,11 @@ class StatsByGroupPageState extends State<StatsByGroupPage> if (type == TransactionType.expense) { cache[entry.key] = mergedFlow.totalExpense; + counts[entry.key] = entry.value.expenseCount; return mergedFlow.totalExpense.amount < 0.0; } else { cache[entry.key] = mergedFlow.totalIncome; + counts[entry.key] = entry.value.incomeCount; return mergedFlow.totalIncome.amount > 0.0; } }) @@ -241,6 +276,7 @@ class StatsByGroupPageState extends State<StatsByGroupPage> money: cache[entry.key]!, currency: primaryCurrency, associatedData: entry.value.associatedData, + transactionCount: counts[entry.key] ?? 0, ), ), ), diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index 766ac2f8..bc4f5ed3 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -40,7 +40,9 @@ import "package:flow/services/recurring_transactions.dart"; import "package:flow/services/transactions.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/recurrence.dart"; import "package:flow/utils/utils.dart"; +import "package:flow/widgets/general/approximate_money_text.dart"; import "package:flow/widgets/general/button.dart"; import "package:flow/widgets/general/directional_chevron.dart"; import "package:flow/widgets/general/flow_icon.dart"; @@ -77,11 +79,24 @@ class TransactionPage extends StatefulWidget { final TransactionProgrammableObject? params; + /// Suggested recurrence for a transaction that isn't recurring yet. + final Recurrence? initialRecurrence; + + /// Suggested [RecurringTransaction.variableAmount] for [initialRecurrence] + final bool initialVariableAmount; + bool get isNewTransaction => transactionId == 0; - const TransactionPage.create({super.key, this.params}) : transactionId = 0; - const TransactionPage.edit({super.key, required this.transactionId}) - : params = null; + const TransactionPage.create({super.key, this.params}) + : transactionId = 0, + initialRecurrence = null, + initialVariableAmount = false; + const TransactionPage.edit({ + super.key, + required this.transactionId, + this.initialRecurrence, + this.initialVariableAmount = false, + }) : params = null; @override State<TransactionPage> createState() => _TransactionPageState(); @@ -135,6 +150,8 @@ class _TransactionPageState extends State<TransactionPage> { Recurrence? _recurrence; + bool _variableAmount = false; + DateTime? _transactionDate; DateTime get transactionDate => _transactionDate ?? DateTime.now(); @@ -240,6 +257,10 @@ class _TransactionPageState extends State<TransactionPage> { _currentlyEditing.extensions.recurring?.uuid, ); _recurrence = _recurringTransaction?.recurrence; + _variableAmount = _recurringTransaction?.variableAmount ?? false; + } else { + _recurrence = widget.initialRecurrence; + _variableAmount = widget.initialVariableAmount; } } } @@ -338,14 +359,29 @@ class _TransactionPageState extends State<TransactionPage> { Center( child: InkWell( onTap: inputAmount, - child: Center( - child: Text( - Money( - _amount, - _selectedAccount?.currency ?? primaryCurrency, - ).formatMoney(), - style: context.textTheme.displayMedium, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Money( + _amount, + _selectedAccount?.currency ?? primaryCurrency, + ).formatMoney(), + style: context.textTheme.displayMedium, + ), + ApproximateMoneyText( + Money( + _amount, + _selectedAccount?.currency ?? primaryCurrency, + ), + overrideObscure: false, + textAlign: .center, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurface + .withAlpha(0x80), + ), + ), + ], ), ), ), @@ -499,10 +535,28 @@ class _TransactionPageState extends State<TransactionPage> { child: AnimatedSize( duration: const Duration(milliseconds: 300), child: _recurrence != null - ? SelectRecurrence( - initialValue: _recurrence, - onChanged: updateRecurrence, - startBounds: startBounds, + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectRecurrence( + initialValue: _recurrence, + onChanged: updateRecurrence, + startBounds: startBounds, + ), + SwitchListTile( + title: Text( + "transaction.recurring.variableAmount" + .t(context), + ), + subtitle: Text( + "transaction.recurring.variableAmount.description" + .t(context), + ), + secondary: Icon(Symbols.tune_rounded), + value: _variableAmount, + onChanged: updateVariableAmount, + ), + ], ) : ListTile( leading: Icon(Symbols.repeat_rounded), @@ -909,6 +963,7 @@ class _TransactionPageState extends State<TransactionPage> { setState(() { _transactionDate = result ?? _transactionDate; + _moveRecurrenceStart(); }); _postSelectTransactionDate(); @@ -930,6 +985,7 @@ class _TransactionPageState extends State<TransactionPage> { microsecond: 0, millisecond: 0, ); + _moveRecurrenceStart(); }); _postSelectTransactionDate(); @@ -942,8 +998,9 @@ class _TransactionPageState extends State<TransactionPage> { } void updateRecurrence(Recurrence? recurrence) { - if (widget.isNewTransaction) { - _transactionDate = recurrence?.range.from; + // The series starts with this transaction + if (_recurringTransaction == null && recurrence != null) { + _transactionDate = recurrence.range.from; } _recurrence = recurrence; @@ -952,6 +1009,20 @@ class _TransactionPageState extends State<TransactionPage> { setState(() {}); } + /// Keeps a series that isn't saved yet starting at the transaction date + void _moveRecurrenceStart() { + if (_recurringTransaction != null) return; + if (_recurrence?.range.from == transactionDate) return; + + _recurrence = _recurrence?.startingAt(transactionDate); + } + + void updateVariableAmount(bool value) { + setState(() { + _variableAmount = value; + }); + } + void _postSelectTransactionDate() async { final bool pendingTransactionsRequireConfrimation = LocalPreferences() .pendingTransactions @@ -1023,13 +1094,14 @@ class _TransactionPageState extends State<TransactionPage> { context: context, builder: (context) => SelectRecurrenceSheet( initialValue: _recurrence, - startBounds: transactionDate.rangeToMax(), + startBounds: TimeRange.allTime(), + defaultStart: transactionDate, ), ); - _recurrence ??= result; + if (result == null || _recurrence != null) return; - setState(() {}); + updateRecurrence(result); } void onTagsChanged(List<TransactionTag> newTags) { @@ -1093,7 +1165,8 @@ class _TransactionPageState extends State<TransactionPage> { if (originalTransactionWasRecurring) { final List<RecurringUpdateMode> availableModes = [ - if (_recurrence == _recurringTransaction!.recurrence) + if (_recurrence == _recurringTransaction!.recurrence && + _variableAmount == _recurringTransaction!.variableAmount) RecurringUpdateMode.current, RecurringUpdateMode.thisAndFuture, ]; @@ -1134,7 +1207,9 @@ class _TransactionPageState extends State<TransactionPage> { isPending: _isPending, conversionRate: crossCurrencyTransfer ? _conversionRate : null, recurrence: _recurrence, + variableRecurringAmount: _variableAmount, tags: _selectedTags, + attachments: _attachments, ); _currentlyEditing.permanentlyDelete(true); @@ -1189,6 +1264,7 @@ class _TransactionPageState extends State<TransactionPage> { transferToAccountUuid: isTransfer ? _selectedAccountTransferTo?.uuid : null, + variableAmount: _variableAmount, ); } @@ -1244,6 +1320,7 @@ class _TransactionPageState extends State<TransactionPage> { _recurrence?.range ?? recurringTransaction.timeRange; recurringTransaction.recurrenceRules = _recurrence?.rules ?? recurringTransaction.recurrenceRules; + recurringTransaction.variableAmount = _variableAmount; recurringTransaction.transferToAccountUuid = _selectedAccountTransferTo?.uuid ?? recurringTransaction.transferToAccountUuid; @@ -1293,6 +1370,7 @@ class _TransactionPageState extends State<TransactionPage> { isPending: _isPending, conversionRate: crossCurrencyTransfer ? _conversionRate : null, recurrence: _recurrence, + variableRecurringAmount: _variableAmount, tags: _selectedTags, attachments: _attachments, latitude: _geo?.latitude, @@ -1308,6 +1386,7 @@ class _TransactionPageState extends State<TransactionPage> { extensions: extensions, isPending: _isPending, recurrence: _recurrence, + variableRecurringAmount: _variableAmount, tags: _selectedTags, attachments: _attachments, latitude: _geo?.latitude, @@ -1350,7 +1429,10 @@ class _TransactionPageState extends State<TransactionPage> { _attachments?.map((attachment) => attachment.uuid).toSet(), _currentlyEditing.attachments.map((file) => file.uuid).toSet(), ) || - _currentlyEditing.transactionDate != _transactionDate; + _currentlyEditing.transactionDate != _transactionDate || + _variableAmount != + (_recurringTransaction?.variableAmount ?? + widget.initialVariableAmount); } return _amount != 0 || @@ -1447,14 +1529,7 @@ class _TransactionPageState extends State<TransactionPage> { } TimeRange? getStartBounds() { - if (widget.isNewTransaction || _currentlyEditing == null) { - return TimeRange.allTime(); - } - - if (!_currentlyEditing.isRecurring) { - return (_transactionDate ?? _currentlyEditing.transactionDate) - .rangeToMax(); - } + if (_recurringTransaction == null) return TimeRange.allTime(); return null; } diff --git a/lib/routes/transaction_page/select_recurrence.dart b/lib/routes/transaction_page/select_recurrence.dart index 45ffde9f..1d44708c 100644 --- a/lib/routes/transaction_page/select_recurrence.dart +++ b/lib/routes/transaction_page/select_recurrence.dart @@ -5,6 +5,7 @@ import "package:flow/routes/transaction_page/select_recurrence/input_occurrences import "package:flow/routes/transaction_page/select_recurrence/select_until_mode_sheet.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/custom_popups.dart"; +import "package:flow/utils/extensions/recurrence.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; @@ -32,11 +33,15 @@ class SelectRecurrence extends StatefulWidget { final TimeRange? startBounds; + /// Where the default starts when there's no [initialValue] + final DateTime? defaultStart; + const SelectRecurrence({ super.key, required this.onChanged, required this.startBounds, this.initialValue, + this.defaultStart, }); @override @@ -53,6 +58,13 @@ class _SelectRecurrenceState extends State<SelectRecurrence> { void initState() { super.initState(); _setRecurrence(widget.initialValue); + + // Report the default, so saving it untouched still sets up recurrence + if (widget.initialValue == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onChanged(_recurrence); + }); + } } @override @@ -213,20 +225,18 @@ class _SelectRecurrenceState extends State<SelectRecurrence> { ); if (result == null) return; - _recurrence = _recurrence.copyWith( - range: CustomTimeRange(result, _recurrence.range.to), - ); - + _setFrom(result); if (!mounted) return; - setState(() {}); - widget.onChanged(_recurrence); final DateTime? resultWithTime = await context.pickTime(anchor: result); if (resultWithTime == null) return; - _recurrence = _recurrence.copyWith( - range: CustomTimeRange(resultWithTime, _recurrence.range.to), - ); + _setFrom(resultWithTime); + } + + void _setFrom(DateTime from) { + _recurrence = _recurrence.startingAt(from); + if (!mounted) return; setState(() {}); widget.onChanged(_recurrence); @@ -354,7 +364,8 @@ class _SelectRecurrenceState extends State<SelectRecurrence> { rules: [ MonthlyRecurrenceRule(day: recurrence?.range.from.day ?? 1), ], - start: DateTime.now().startOfSecond(), + start: (widget.defaultStart ?? DateTime.now()) + .startOfSecond(), )) .realign(); diff --git a/lib/routes/transaction_page/select_recurrence_sheet.dart b/lib/routes/transaction_page/select_recurrence_sheet.dart index 6babc868..b38c6bfc 100644 --- a/lib/routes/transaction_page/select_recurrence_sheet.dart +++ b/lib/routes/transaction_page/select_recurrence_sheet.dart @@ -14,7 +14,14 @@ class SelectRecurrenceSheet extends StatefulWidget { final TimeRange? startBounds; - const SelectRecurrenceSheet({super.key, this.initialValue, this.startBounds}); + final DateTime? defaultStart; + + const SelectRecurrenceSheet({ + super.key, + this.initialValue, + this.startBounds, + this.defaultStart, + }); @override State<SelectRecurrenceSheet> createState() => _SelectRecurrenceSheetState(); @@ -52,6 +59,7 @@ class _SelectRecurrenceSheetState extends State<SelectRecurrenceSheet> { onChanged: (value) => setState(() => _recurrence = value), startBounds: widget.startBounds, initialValue: widget.initialValue, + defaultStart: widget.defaultStart, ), ); } diff --git a/lib/routes/transactions_page.dart b/lib/routes/transactions_page.dart index 4190f446..62ad5862 100644 --- a/lib/routes/transactions_page.dart +++ b/lib/routes/transactions_page.dart @@ -66,7 +66,12 @@ class TransactionsPage extends StatefulWidget { return TransactionsPage(queryFn: queryBuilder, key: key, title: title); } - factory TransactionsPage.all({Key? key, String? title, Widget? header}) { + factory TransactionsPage.all({ + Key? key, + String? title, + Widget? header, + TimeRange? initialRange, + }) { QueryBuilder<Transaction> queryBuilder(TimeRange range) => TransactionFilter( sortBy: TransactionSortField.transactionDate, @@ -74,7 +79,12 @@ class TransactionsPage extends StatefulWidget { range: TransactionFilterTimeRange.fromTimeRange(range), ).queryBuilder(); - return TransactionsPage(queryFn: queryBuilder, key: key, title: title); + return TransactionsPage( + queryFn: queryBuilder, + key: key, + title: title, + initialRange: initialRange, + ); } factory TransactionsPage.pending({ diff --git a/lib/services/exchange_rates.dart b/lib/services/exchange_rates.dart index e0a8236b..bb6209fb 100644 --- a/lib/services/exchange_rates.dart +++ b/lib/services/exchange_rates.dart @@ -66,6 +66,9 @@ class ExchangeRatesService { "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@$dateParam/v1/currencies/$normalizedCurrency.min.json", ), ); + if (response.statusCode != 200) { + throw Exception("Unexpected status code: ${response.statusCode}"); + } jsonResponse = jsonDecode(response.body); } catch (e, stackTrace) { _log.warning( @@ -75,19 +78,24 @@ class ExchangeRatesService { ); } - try { - final response = await http.get( - Uri.parse( - "https://$dateParam.currency-api.pages.dev/v1/currencies/$normalizedCurrency.min.json", - ), - ); - jsonResponse = jsonDecode(response.body); - } catch (e, stackTrace) { - _log.warning( - "Failed to fetch exchange rates from side source", - e, - stackTrace, - ); + if (jsonResponse == null) { + try { + final response = await http.get( + Uri.parse( + "https://$dateParam.currency-api.pages.dev/v1/currencies/$normalizedCurrency.min.json", + ), + ); + if (response.statusCode != 200) { + throw Exception("Unexpected status code: ${response.statusCode}"); + } + jsonResponse = jsonDecode(response.body); + } catch (e, stackTrace) { + _log.warning( + "Failed to fetch exchange rates from side source", + e, + stackTrace, + ); + } } if (jsonResponse == null) { diff --git a/lib/services/insights.dart b/lib/services/insights.dart new file mode 100644 index 00000000..650ded55 --- /dev/null +++ b/lib/services/insights.dart @@ -0,0 +1,78 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/insights/insight_adapter.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/transaction_filter.dart"; +import "package:flow/data/transactions_filter/time_range.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flow/services/transactions.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flutter/foundation.dart"; +import "package:moment_dart/moment_dart.dart"; + +class InsightsService { + static InsightsService? _instance; + + factory InsightsService() => _instance ??= InsightsService._internal(); + + InsightsService._internal(); + + /// Two years, so yearly charges can be seen repeating. + static const int historyMonths = 26; + + /// Insights for the month containing [month], computed off the main + /// isolate. + Future<InsightReport> computeFor( + DateTime month, { + Set<InsightType> hiddenTypes = const {}, + List<InsightSighting> sightings = const [], + }) async { + final String primaryCurrency = UserPreferencesService().primaryCurrency; + final ExchangeRates? rates = ExchangeRatesService() + .getPrimaryCurrencyRates(); + + final List<Transaction> transactions = await TransactionsService().findMany( + TransactionFilter( + range: TransactionFilterTimeRange.fromTimeRange( + CustomTimeRange( + DateTime(month.year, month.month - historyMonths), + DateTime(month.year, month.month + 1), + ), + ), + ), + ); + + final Query<RecurringTransaction> recurringQuery = + RecurringTransactionsService().activeRecurringsQb().build(); + final List<RecurringTransaction> recurringTransactions = recurringQuery + .find(); + recurringQuery.close(); + + final ({List<InsightTransaction> transactions, bool hasMissingRates}) + converted = insightTransactionsOf( + transactions, + primaryCurrency: primaryCurrency, + rates: rates, + ); + + return compute( + computeInsights, + InsightRequest( + transactions: converted.transactions, + month: month, + now: DateTime.now(), + recurringTemplates: insightTemplatesOf( + recurringTransactions, + primaryCurrency: primaryCurrency, + rates: rates, + ), + hiddenTypes: hiddenTypes, + sightings: sightings, + hasMissingRates: converted.hasMissingRates, + ), + ); + } +} diff --git a/lib/services/recurring_transactions.dart b/lib/services/recurring_transactions.dart index 930e0fc5..1ed9afed 100644 --- a/lib/services/recurring_transactions.dart +++ b/lib/services/recurring_transactions.dart @@ -41,7 +41,10 @@ class RecurringTransactionsService { /// constructor: `FlowState.initState`'s post-frame callback decides when /// to do the first sync so it doesn't race with first-frame rendering or /// other startup work. - Future<void> synchronizeAll() => _synchronizeAll(); + /// + /// [anchor] stands in for now, defaults to [DateTime.now]. + Future<void> synchronizeAll({DateTime? anchor}) => + _synchronizeAll(anchor: anchor); Future<void> _synchronize( RecurringTransaction recurringTransaction, { @@ -188,13 +191,20 @@ class RecurringTransactionsService { ); } - final bool isPending = nextOccurence.isAfter(anchor) - ? PendingTransactionsLocalPreferences().requireConfrimation.get() - : false; + // Variable amounts are estimates, so they wait for the actual amount + final bool isPending = + recurringTransaction.variableAmount || + (nextOccurence.isAfter(anchor) && + PendingTransactionsLocalPreferences().requireConfrimation.get()); + + final double amount = recurringTransaction.estimateAmount( + template, + relatedTransactions, + ); if (to == null) { from.createAndSaveTransaction( - amount: template.amount, + amount: amount, category: category, tags: tags, title: template.title, @@ -207,6 +217,7 @@ class RecurringTransactionsService { initialTransactionDate: nextOccurence, uuid: recurringTransaction.uuid, relatedTransactionUuid: generatedTransactionUuid, + variableAmount: recurringTransaction.variableAmount, ), ], isPending: isPending, @@ -219,7 +230,7 @@ class RecurringTransactionsService { final (int fromObjectId, int toObjectId) = from.transferTo( targetAccount: to, tags: tags, - amount: template.amount.abs(), + amount: amount.abs(), title: template.title, description: template.description, transactionDate: nextOccurence, @@ -227,6 +238,7 @@ class RecurringTransactionsService { Recurring( uuid: recurringTransaction.uuid, initialTransactionDate: nextOccurence, + variableAmount: recurringTransaction.variableAmount, ), ], isPending: isPending, @@ -249,7 +261,7 @@ class RecurringTransactionsService { "$loggingPrefix Next occurrence is before anchor: $anchor, trying to create another one", ); - await _synchronize(recurringTransaction); + await _synchronize(recurringTransaction, anchor: anchor); } _log.fine( @@ -268,7 +280,7 @@ class RecurringTransactionsService { /// be called over and over again. /// /// Current rule is one transaction in the future for the recurrence. - Future<void> _synchronizeAll() async { + Future<void> _synchronizeAll({DateTime? anchor}) async { _log.fine("Synchronizing recurring transactions"); final Query<RecurringTransaction> query = activeRecurringsQb().build(); @@ -277,7 +289,7 @@ class RecurringTransactionsService { try { for (var item in items) { - await _synchronize(item); + await _synchronize(item, anchor: anchor); } } finally { query.close(); @@ -298,6 +310,7 @@ class RecurringTransactionsService { required Recurrence recurrence, String? uuidOverride, String? transferToAccountUuid, + bool variableAmount = false, }) { if (identifier == null) { throw ArgumentError("identifier must be a Transaction or an identifier"); @@ -319,6 +332,7 @@ class RecurringTransactionsService { uuid: uuidOverride ?? const Uuid().v4(), jsonTransactionTemplate: jsonEncode(transaction.toJson()), transferToAccountUuid: transferToAccountUuid, + variableAmount: variableAmount, range: recurrence.range.encodeShort(), rules: recurrence.rules.map((e) => e.serialize()).toList(), lastGeneratedTransactionDate: transaction.transactionDate, diff --git a/lib/services/user_preferences.dart b/lib/services/user_preferences.dart index 529ae03f..a9223dd5 100644 --- a/lib/services/user_preferences.dart +++ b/lib/services/user_preferences.dart @@ -5,6 +5,7 @@ import "package:flow/constants.dart"; import "package:flow/data/flow_button_type.dart"; import "package:flow/data/flow_notification_payload.dart"; import "package:flow/data/prefs/change_visuals.dart"; +import "package:flow/data/prefs/date_format_preset.dart"; import "package:flow/data/transactions_filter/pending_time_range.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/transaction_filter_preset.dart"; @@ -200,6 +201,16 @@ class UserPreferencesService { ObjectBox().box<UserPreferences>().put(value); } + bool get transactionListAbsoluteDateHeaders => + value.transactionListAbsoluteDateHeaders; + set transactionListAbsoluteDateHeaders( + bool newTransactionListAbsoluteDateHeaders, + ) { + value.transactionListAbsoluteDateHeaders = + newTransactionListAbsoluteDateHeaders; + ObjectBox().box<UserPreferences>().put(value); + } + bool get transactionListTileShowAccountForLeading => value.transactionListTileShowAccountForLeading; set transactionListTileShowAccountForLeading( @@ -309,6 +320,19 @@ class UserPreferencesService { ObjectBox().box<UserPreferences>().put(value); } + bool get hideZeroDecimals => value.hideZeroDecimals; + set hideZeroDecimals(bool newHideZeroDecimals) { + value.hideZeroDecimals = newHideZeroDecimals; + ObjectBox().box<UserPreferences>().put(value); + } + + DateFormatPreset get dateFormatPreset => + DateFormatPreset.tryParse(value.dateFormatPreset) ?? .system; + set dateFormatPreset(DateFormatPreset newDateFormatPreset) { + value.dateFormatPreset = newDateFormatPreset.value; + ObjectBox().box<UserPreferences>().put(value); + } + List<FlowButtonType> get transactionButtonOrder => value.transactionButtonOrder; diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 136035c8..47067f13 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -44,7 +44,6 @@ class ThemeFactory { overlayColor: colorScheme.surface.withAlpha(0xe0), pointerColor: kTransparent, angleOffset: 0.0, - pointerSize: 2.0, tooltipTextStyle: flowTextTheme.displaySmall!.copyWith( color: colorScheme.onSurface, ), diff --git a/lib/utils/extensions/chart_data.dart b/lib/utils/extensions/chart_data.dart new file mode 100644 index 00000000..29b42bdf --- /dev/null +++ b/lib/utils/extensions/chart_data.dart @@ -0,0 +1,49 @@ +import "package:flow/data/chart_data.dart"; +import "package:flow/data/flow_icon.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/theme/flow_color_scheme.dart"; +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +extension ChartDataGroupHelpers on ChartData { + String resolveName(BuildContext context) => switch (associatedData) { + Category category => category.name, + Account account => account.name, + _ => "category.none".t(context), + }; + + FlowIconData get icon => switch (associatedData) { + Category category => category.icon, + Account account => account.icon, + _ => FlowIconData.icon(Symbols.category_rounded), + }; + + FlowColorScheme? get colorScheme => switch (associatedData) { + Category category => category.colorScheme, + Account account => account.colorScheme, + _ => null, + }; +} + +extension ChartDataMapHelpers on Map<String, ChartData> { + double get displayTotal => + values.fold(0.0, (total, chartData) => total + chartData.displayTotal); + + /// Uses each group's own color, falling back to [ThemeAccessor.chartAccents] + /// in map order, so the list and the chart agree. + Map<String, Color> resolveColors(BuildContext context) { + final List<Color> palette = context.chartAccents; + int fallbackIndex = 0; + + return map( + (key, chartData) => MapEntry( + key, + chartData.colorScheme?.primary ?? + palette[fallbackIndex++ % palette.length], + ), + ); + } +} diff --git a/lib/utils/extensions/recurrence.dart b/lib/utils/extensions/recurrence.dart new file mode 100644 index 00000000..f1d8c46e --- /dev/null +++ b/lib/utils/extensions/recurrence.dart @@ -0,0 +1,13 @@ +import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; + +extension RecurrenceHelpers on Recurrence { + /// Moves the start to [from]. Rules follow it, e.g., monthly lands on + /// [from]'s day. Runs indefinitely if it'd end before [from]. + Recurrence startingAt(DateTime from) => copyWith( + range: CustomTimeRange( + from, + range.to.isBefore(from) ? Moment.maxValue : range.to, + ), + ).realign(); +} diff --git a/lib/utils/extensions/recurring_transaction.dart b/lib/utils/extensions/recurring_transaction.dart index 1e508880..55e81271 100644 --- a/lib/utils/extensions/recurring_transaction.dart +++ b/lib/utils/extensions/recurring_transaction.dart @@ -1,4 +1,5 @@ import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction/extensions/default/recurring.dart"; extension RecurringTransactionHelpers on RecurringTransaction { @@ -6,4 +7,32 @@ extension RecurringTransactionHelpers on RecurringTransaction { uuid: uuid, initialTransactionDate: DateTime.now(), ).extensionIdentifierTag; + + /// Amount for the next occurrence. With [variableAmount], it's the latest + /// confirmed amount among [logged], falling back to the [template]'s. + double estimateAmount(Transaction template, Iterable<Transaction> logged) { + if (!variableAmount) return template.amount; + + Transaction? latest; + + for (final Transaction transaction in logged) { + if (transaction.isPending == true || transaction.isDeleted == true) { + continue; + } + // Transfers log both sides; the template is the outgoing one + if (transaction.isTransfer && !transaction.amount.isNegative) continue; + + // Confirming may move [Transaction.transactionDate] to now + if (latest == null || + _scheduledDate(transaction).isAfter(_scheduledDate(latest))) { + latest = transaction; + } + } + + return latest?.amount ?? template.amount; + } + + DateTime _scheduledDate(Transaction transaction) => + transaction.extensions.recurring?.initialTransactionDate ?? + transaction.transactionDate; } diff --git a/lib/utils/extensions/transaction.dart b/lib/utils/extensions/transaction.dart index f5ea09bd..12f2fddf 100644 --- a/lib/utils/extensions/transaction.dart +++ b/lib/utils/extensions/transaction.dart @@ -6,7 +6,9 @@ import "package:flow/entity/transaction/extensions/default/recurring.dart"; import "package:flow/entity/transaction/extensions/default/transfer.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/routes/transaction_page/input_amount_sheet.dart"; import "package:flow/routes/transaction_page/select_recurring_update_mode_sheet.dart"; import "package:flow/services/recurring_transactions.dart"; import "package:flow/services/transactions.dart"; @@ -27,6 +29,36 @@ extension TransactionHelpers on Transaction { ); } + /// Pending estimate of a variable amount recurring transaction + bool get isAmountEstimate => + isPending == true && + isDeleted != true && + extensions.recurring?.variableAmount == true; + + /// Confirms, asking for the actual amount first if it's an estimate. + Future<bool> confirmPrompted( + BuildContext context, [ + bool updateTransactionDate = true, + ]) async { + if (!isAmountEstimate) return confirm(true, updateTransactionDate); + + final double? amount = await showModalBottomSheet<double>( + context: context, + builder: (context) => InputAmountSheet( + initialAmount: this.amount.abs(), + currency: currency, + title: title, + lockSign: true, + allowNegative: false, + ), + isScrollControlled: true, + ); + + if (amount == null || amount == 0) return false; + + return confirmWithAmount(amount, updateTransactionDate); + } + bool holdable([DateTime? anchor]) { if (isDeleted == true) return false; if (isPending == true) return false; @@ -281,6 +313,8 @@ class BulkTransactions { } /// Confirms every transaction (and its transfer partner), leaving pending. + /// + /// Skips estimates, they need the actual amount. static int confirm( Iterable<Transaction> transactions, { bool updateTransactionDate = true, @@ -291,8 +325,12 @@ class BulkTransactions { final List<Transaction> toUpdate = []; final Set<String> seen = {}; + int count = 0; + for (final Transaction t in list) { + if (t.isAmountEstimate) continue; if (!seen.add(t.uuid)) continue; + count++; t.isPending = false; if (updateTransactionDate && !t.extraTags.contains(Transaction.importedFromSiriTag)) { @@ -316,7 +354,7 @@ class BulkTransactions { } catch (e, stackTrace) { _log.severe("Bulk confirm failed", e, stackTrace); } - return list.length; + return count; } /// Sets [category] on every non-transfer transaction. diff --git a/lib/widgets/budgets/budget_history_strip.dart b/lib/widgets/budgets/budget_history_strip.dart index 61e4d676..f8706fe1 100644 --- a/lib/widgets/budgets/budget_history_strip.dart +++ b/lib/widgets/budgets/budget_history_strip.dart @@ -1,4 +1,5 @@ import "package:flow/data/budget_progress.dart"; +import "package:flow/services/user_preferences.dart"; import "package:flow/theme/theme.dart"; import "package:flutter/material.dart"; import "package:moment_dart/moment_dart.dart"; @@ -143,6 +144,9 @@ class _Bar extends StatelessWidget { String _label() => switch (progress.range) { MonthTimeRange month => month.from.format(payload: "MMM"), YearTimeRange year => year.year.toString(), - _ => progress.range.from.format(payload: "D/M"), + _ => progress.range.from.format( + payload: + UserPreferencesService().dateFormatPreset.dayMonthPattern ?? "D/M", + ), }; } diff --git a/lib/widgets/flow_card.dart b/lib/widgets/flow_card.dart index fa26c72c..0ff5efcb 100644 --- a/lib/widgets/flow_card.dart +++ b/lib/widgets/flow_card.dart @@ -11,11 +11,15 @@ class FlowCard extends StatelessWidget { final TransactionType type; final Money flow; + /// Prefixes "~" when [flow] includes estimates + final bool approximate; + const FlowCard({ super.key, required this.flow, required this.type, this.autoSizeGroup, + this.approximate = false, }); @override @@ -33,7 +37,7 @@ class FlowCard extends StatelessWidget { alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), child: AutoSizeText( - flow.abs().formatted, + "${approximate ? "~" : ""}${flow.abs().formatted}", style: context.textTheme.displaySmall?.copyWith( color: type.color(context), ), diff --git a/lib/widgets/general/approximate_money_text.dart b/lib/widgets/general/approximate_money_text.dart new file mode 100644 index 00000000..940af439 --- /dev/null +++ b/lib/widgets/general/approximate_money_text.dart @@ -0,0 +1,79 @@ +import "package:flow/data/money.dart"; +import "package:flow/prefs/local_preferences.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/utils/extensions/money.dart"; +import "package:flow/widgets/general/money_text_builder.dart"; +import "package:flow/widgets/general/money_text_raw.dart"; +import "package:flutter/material.dart"; + +/// Shows [money] converted to the primary currency using the latest rates, +/// e.g., "≈ R$25". Renders nothing when conversion isn't possible or needed, +/// or when [LocalPreferences.showApproximatePrimaryAmount] is off. +class ApproximateMoneyText extends StatelessWidget { + final Money money; + + final bool displayAbsoluteAmount; + + /// Set this to [true] to make it always unobscured + /// + /// Set this to [false] to make it always obscured + /// + /// Set this to [null] to use the default behavior + final bool? overrideObscure; + + final TextAlign? textAlign; + final TextStyle? style; + + const ApproximateMoneyText( + this.money, { + super.key, + this.displayAbsoluteAmount = false, + this.overrideObscure, + this.textAlign, + this.style, + }); + + @override + Widget build(BuildContext context) { + final String primaryCurrency = UserPreferencesService().primaryCurrency; + + if (money.currency == primaryCurrency || money.amount == 0) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: + LocalPreferences().showApproximatePrimaryAmount.valueNotifier, + builder: (context, showApproximatePrimaryAmount, child) { + if (showApproximatePrimaryAmount == false) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: ExchangeRatesService().exchangeRatesCache, + builder: (context, exchangeRatesCache, child) { + final double? converted = money.tryConvertAmount( + primaryCurrency, + exchangeRatesCache?.get(primaryCurrency), + ); + + if (converted == null) return const SizedBox.shrink(); + + return MoneyTextBuilder( + money: Money(converted, primaryCurrency), + displayAbsoluteAmount: displayAbsoluteAmount, + overrideObscure: overrideObscure, + builder: (context, text, money) => MoneyTextRaw( + text: "≈ $text", + style: style, + textAlign: textAlign, + maxLines: 1, + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/general/money_text.dart b/lib/widgets/general/money_text.dart index 6463e5a1..a5aa7b29 100644 --- a/lib/widgets/general/money_text.dart +++ b/lib/widgets/general/money_text.dart @@ -21,6 +21,9 @@ class MoneyText extends StatefulWidget { final bool displayAbsoluteAmount; final bool omitCurrency; + /// Prefixes "~" for estimates, e.g., "~$42" + final bool approximate; + /// Uses 3-letter-code instead of the currency symbol. /// /// e.g., '€' instead of 'EUR' @@ -55,6 +58,7 @@ class MoneyText extends StatefulWidget { this.initiallyAbbreviated = false, this.displayAbsoluteAmount = false, this.omitCurrency = false, + this.approximate = false, this.maxLines = 1, this.overrideUseCurrencySymbol, this.overrideObscure, @@ -100,7 +104,7 @@ class _MoneyTextState extends State<MoneyText> { widget.onTap != null || widget.tapToToggleAbbreviation; return MoneyTextRaw( - text: text, + text: widget.approximate ? "~$text" : text, style: widget.style, textAlign: widget.textAlign, maxLines: widget.maxLines, diff --git a/lib/widgets/general/money_text_builder.dart b/lib/widgets/general/money_text_builder.dart index 0b4117c0..8634e407 100644 --- a/lib/widgets/general/money_text_builder.dart +++ b/lib/widgets/general/money_text_builder.dart @@ -1,5 +1,6 @@ import "package:flow/data/money.dart"; import "package:flow/prefs/local_preferences.dart"; +import "package:flow/services/user_preferences.dart"; import "package:flow/utils/utils.dart"; import "package:flutter/material.dart"; @@ -64,6 +65,7 @@ class _MoneyTextBuilderState extends State<MoneyTextBuilder> { _privacyModeUpdate, ); LocalPreferences().useCurrencySymbol.addListener(_useCurrencySymbolUpdate); + UserPreferencesService().valueNotifier.addListener(_userPreferencesUpdate); globalPrivacyMode = TransitiveLocalPreferences().sessionPrivacyMode.get(); globalUseCurrencySymbol = LocalPreferences().useCurrencySymbol.get(); @@ -94,6 +96,9 @@ class _MoneyTextBuilderState extends State<MoneyTextBuilder> { LocalPreferences().useCurrencySymbol.removeListener( _useCurrencySymbolUpdate, ); + UserPreferencesService().valueNotifier.removeListener( + _userPreferencesUpdate, + ); super.dispose(); } @@ -117,6 +122,11 @@ class _MoneyTextBuilderState extends State<MoneyTextBuilder> { setState(() {}); } + void _userPreferencesUpdate() { + if (!mounted) return; + setState(() {}); + } + String getString() { final Money? money = widget.money; diff --git a/lib/widgets/grouped_transactions_list_view.dart b/lib/widgets/grouped_transactions_list_view.dart index 11c46a22..1424363c 100644 --- a/lib/widgets/grouped_transactions_list_view.dart +++ b/lib/widgets/grouped_transactions_list_view.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/objectbox/actions.dart"; @@ -199,7 +201,13 @@ class _GroupedTransactionsListViewState .updateDateUponConfirmation .get(); - transaction.confirm(confirm, updateTransactionDate); + if (confirm) { + unawaited( + transaction.confirmPrompted(context, updateTransactionDate), + ); + } else { + transaction.confirm(false, updateTransactionDate); + } }, duplicateFn: () => transaction.duplicate(), overrideObscure: widget.overrideObscure, diff --git a/lib/widgets/home/navbar/new_transaction_button.dart b/lib/widgets/home/navbar/new_transaction_button.dart index 775aa296..e778ced1 100644 --- a/lib/widgets/home/navbar/new_transaction_button.dart +++ b/lib/widgets/home/navbar/new_transaction_button.dart @@ -2,16 +2,22 @@ import "dart:math"; import "package:flow/data/flow_button_type.dart"; import "package:flow/entity/user_preferences.dart"; +import "package:flow/l10n/extensions.dart"; import "package:flow/l10n/named_enum.dart"; import "package:flow/services/integrations/eny.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/theme/navbar_theme.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/directionality.dart"; +import "package:flow/widgets/sheets/select_flow_button_type_sheet.dart"; +import "package:flutter/gestures.dart"; import "package:flutter/material.dart" hide Flow; +import "package:logging/logging.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:pie_menu/pie_menu.dart"; +final Logger _log = Logger("NewTransactionButton"); + class NewTransactionButton extends StatefulWidget { final Function(FlowButtonType type) onActionTap; @@ -36,9 +42,46 @@ class _NewTransactionButtonState extends State<NewTransactionButton> ), ); + bool _sheetOpen = false; + bool _menuOpen = false; + bool _pointerDownDelivered = false; + bool? _accessibleNavigation; + + @override + void initState() { + super.initState(); + GestureBinding.instance.pointerRouter.addGlobalRoute(_observePointerDown); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final bool accessibleNavigation = MediaQuery.accessibleNavigationOf( + context, + ); + if (_accessibleNavigation == accessibleNavigation) return; + + _accessibleNavigation = accessibleNavigation; + _log.fine("Button ready: accessibleNavigation=$accessibleNavigation"); + } + + @override + void dispose() { + GestureBinding.instance.pointerRouter.removeGlobalRoute( + _observePointerDown, + ); + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final NavbarTheme navbarTheme = Theme.of(context).extension<NavbarTheme>()!; + final double touchSlop = max( + kTouchSlop, + MediaQuery.gestureSettingsOf(context).touchSlop ?? kTouchSlop, + ); return AnimatedBuilder( animation: Listenable.merge([ @@ -67,7 +110,15 @@ class _NewTransactionButtonState extends State<NewTransactionButton> }; return PieMenu( + // [PieMenu] skips the radial menu under accessible navigation, and + // only calls [onPressed]. Without it, the button does nothing. + onPressed: MediaQuery.accessibleNavigationOf(context) + ? () => _showSheet(buttonOrder) + : null, theme: context.pieTheme.copyWith( + // [PieMenu] closes the menu once the pointer moves further than + // `pointerSize / 2`, so keep it at least as big as touch slop. + pointerSize: touchSlop * 2, customAngle: 90.0, customAngleDiff: 48.0, radius: 108.0, @@ -85,7 +136,8 @@ class _NewTransactionButtonState extends State<NewTransactionButton> .map( (transactionType) => PieAction( tooltip: Text(transactionType.localizedNameContext(context)), - onSelect: () => widget.onActionTap(transactionType), + semanticsLabel: transactionType.localizedNameContext(context), + onSelect: () => onSelect(transactionType), child: Icon(transactionType.icon, weight: 800.0), buttonTheme: PieButtonTheme( backgroundColor: transactionType.actionBackgroundColor( @@ -96,24 +148,30 @@ class _NewTransactionButtonState extends State<NewTransactionButton> ), ) .toList(), - child: Material( - color: navbarTheme.transactionButtonBackgroundColor, - shape: RoundedRectangleBorder(borderRadius: .circular(32.0)), - child: Padding( - padding: const EdgeInsets.all(20.0), - child: AnimatedBuilder( - animation: _bounceAnimation, - builder: (context, child) { - return Transform.rotate( - angle: _bounceAnimation.value, - child: child, - ); - }, - child: Icon( - Symbols.add_rounded, - fill: 0.0, - color: navbarTheme.transactionButtonForegroundColor, - weight: 600.0, + child: Listener( + onPointerDown: _logPointerEvent, + onPointerUp: _logPointerEvent, + onPointerCancel: _logPointerEvent, + child: Material( + color: navbarTheme.transactionButtonBackgroundColor, + shape: RoundedRectangleBorder(borderRadius: .circular(32.0)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: AnimatedBuilder( + animation: _bounceAnimation, + builder: (context, child) { + return Transform.rotate( + angle: _bounceAnimation.value, + child: child, + ); + }, + child: Icon( + Symbols.add_rounded, + semanticLabel: "transaction.new".t(context), + fill: 0.0, + color: navbarTheme.transactionButtonForegroundColor, + weight: 600.0, + ), ), ), ), @@ -124,10 +182,83 @@ class _NewTransactionButtonState extends State<NewTransactionButton> } void onToggle(bool toggled) { + _menuOpen = toggled; + _log.fine("Radial menu ${toggled ? "opened" : "closed"}"); + if (toggled) { _animationController.forward(); } else { _animationController.reverse(); } } + + void onSelect(FlowButtonType type) { + _log.fine("Selected transaction action: ${type.name}"); + widget.onActionTap(type); + } + + void _showSheet(List<FlowButtonType> buttonOrder) async { + if (_sheetOpen) return; + + _sheetOpen = true; + _log.fine("Opening accessible transaction picker"); + + try { + final FlowButtonType? type = await showModalBottomSheet<FlowButtonType>( + context: context, + builder: (context) => SelectFlowButtonTypeSheet(types: buttonOrder), + isScrollControlled: true, + ); + + if (!mounted || type == null) return; + + onSelect(type); + } finally { + _sheetOpen = false; + } + } + + void _logPointerEvent(PointerEvent event) { + if (event is PointerDownEvent) { + _pointerDownDelivered = true; + } + + final MediaQueryData media = MediaQuery.of(context); + + _log.fine( + "${event.runtimeType}: pointer=${event.pointer}, " + "kind=${event.kind.name}, buttons=${event.buttons}, " + "local=${event.localPosition}, time=${event.timeStamp.inMicroseconds}, " + "accessibleNavigation=${media.accessibleNavigation}, " + "touchSlop=${media.gestureSettings.touchSlop}, " + "pixelRatio=${media.devicePixelRatio}, " + "padding=${media.viewPadding}, gestures=${media.systemGestureInsets}", + ); + } + + /// Global routes see every pointer down, even the ones an overlay or + /// [IgnorePointer] kept from reaching this button's [Listener]. + void _observePointerDown(PointerEvent event) { + if (event is! PointerDownEvent) return; + + final bool delivered = _pointerDownDelivered; + _pointerDownDelivered = false; + + if (!mounted || delivered || _menuOpen) return; + if (ModalRoute.of(context)?.isCurrent == false) return; + if (!TickerMode.valuesOf(context).enabled) return; + if (event.viewId != View.of(context).viewId) return; + + final RenderObject? box = context.findRenderObject(); + if (box is! RenderBox || !box.attached || !box.hasSize) return; + + final Offset local = box.globalToLocal(event.position); + if (!box.size.contains(local)) return; + + _log.warning( + "Touch reached Flutter inside transaction button bounds but missed " + "the button hit test: pointer=${event.pointer}, local=$local, " + "accessibleNavigation=$_accessibleNavigation", + ); + } } diff --git a/lib/widgets/home/stats/bento/analytics_bento.dart b/lib/widgets/home/stats/bento/analytics_bento.dart index 531c8694..066c7f4e 100644 --- a/lib/widgets/home/stats/bento/analytics_bento.dart +++ b/lib/widgets/home/stats/bento/analytics_bento.dart @@ -9,15 +9,16 @@ 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/worth_knowing_card.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. +/// The top section is **range-bound**: [WorthKnowingCard], [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, budgets, the spending calendar, recurring, and the spending map @@ -39,6 +40,8 @@ class AnalyticsBento extends StatelessWidget { child: Column( crossAxisAlignment: .start, children: [ + // Collapses to nothing outside month ranges, spacing included. + WorthKnowingCard(range: range), // Range-bound — these respond to the selected time range. Cash // flow and pace share a row to stay compact; each takes half. Row( diff --git a/lib/widgets/home/stats/bento/recurring_tile.dart b/lib/widgets/home/stats/bento/recurring_tile.dart index af3fd4d1..3740de8c 100644 --- a/lib/widgets/home/stats/bento/recurring_tile.dart +++ b/lib/widgets/home/stats/bento/recurring_tile.dart @@ -1,10 +1,13 @@ import "package:flow/data/money.dart"; +import "package:flow/data/transaction_filter.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/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/money_text.dart"; import "package:flow/widgets/home/stats/bento/bento_tile.dart"; @@ -32,6 +35,9 @@ class _RecurringTileState extends State<RecurringTile> double outflow = 0.0; int upcoming = 0; + /// Whether [outflow] includes variable amount estimates + bool approximate = false; + @override Widget build(BuildContext context) { return BentoTile( @@ -56,6 +62,7 @@ class _RecurringTileState extends State<RecurringTile> style: context.textTheme.headlineSmall, autoSize: true, initiallyAbbreviated: true, + approximate: approximate, ), const SizedBox(height: 8.0), Text( @@ -87,6 +94,7 @@ class _RecurringTileState extends State<RecurringTile> double totalOutflow = 0.0; int count = 0; + bool estimated = false; for (final RecurringTransaction recurring in recurrings) { final Transaction? template = _decodeTemplate(recurring); @@ -97,13 +105,14 @@ class _RecurringTileState extends State<RecurringTile> // the outflow total, leaving the two figures describing different sets. if (template.type != TransactionType.expense) continue; - final Money? money = _templateMoney(template); + final Money? money = _estimateMoney(recurring, template); if (money == null) continue; final List<DateTime> occurrences = recurring.recurrence.occurrences( subrange: window, ); count += occurrences.length; + estimated |= recurring.variableAmount && occurrences.isNotEmpty; final double? converted = money.tryConvertAmount( primaryCurrency, @@ -116,6 +125,7 @@ class _RecurringTileState extends State<RecurringTile> outflow = totalOutflow; upcoming = count; + approximate = estimated; loaded = true; } finally { busy = false; @@ -131,9 +141,18 @@ class _RecurringTileState extends State<RecurringTile> } } - Money? _templateMoney(Transaction template) { + Money? _estimateMoney(RecurringTransaction recurring, Transaction template) { try { - return template.money; + if (!recurring.variableAmount) return template.money; + + final List<Transaction> logged = TransactionsService().findManySync( + TransactionFilter(extraTag: recurring.extensionIdentifierTag), + ); + + return Money( + recurring.estimateAmount(template, logged), + template.currency, + ); } catch (_) { return null; } diff --git a/lib/widgets/home/stats/bento/worth_knowing_card.dart b/lib/widgets/home/stats/bento/worth_knowing_card.dart new file mode 100644 index 00000000..6fc54cff --- /dev/null +++ b/lib/widgets/home/stats/bento/worth_knowing_card.dart @@ -0,0 +1,177 @@ +import "dart:async"; + +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/prefs/insights_preferences.dart"; +import "package:flow/prefs/local_preferences.dart"; +import "package:flow/services/insights.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/insights/insight_actions.dart"; +import "package:flow/widgets/insights/insight_evidence_sheet.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flow/widgets/insights/insight_kinds_sheet.dart"; +import "package:flow/widgets/insights/worth_knowing_view.dart"; +import "package:flutter/material.dart"; +import "package:logging/logging.dart"; +import "package:moment_dart/moment_dart.dart"; + +final Logger _log = Logger("WorthKnowingCard"); + +/// A few computed observations about the selected month, above the Stats +/// tiles. Hidden for ranges other than a month. +class WorthKnowingCard extends StatefulWidget { + final TimeRange range; + + const WorthKnowingCard({super.key, required this.range}); + + @override + State<WorthKnowingCard> createState() => _WorthKnowingCardState(); +} + +class _WorthKnowingCardState extends State<WorthKnowingCard> + with PrimaryCurrencyDependentState<WorthKnowingCard> { + bool busy = true; + InsightReport? report; + + /// Drops results of fetches that a newer one replaced. + int _generation = 0; + + DateTime? get month => switch (widget.range) { + MonthTimeRange range => range.from, + _ => null, + }; + + InsightFormatter get format => InsightFormatter( + currency: primaryCurrency, + obscure: TransitiveLocalPreferences().sessionPrivacyMode.get(), + useCurrencySymbol: LocalPreferences().useCurrencySymbol.get(), + preferFullAmounts: LocalPreferences().preferFullAmounts.get(), + ); + + late final Listenable _formatting = Listenable.merge([ + TransitiveLocalPreferences().sessionPrivacyMode.valueNotifier, + LocalPreferences().useCurrencySymbol.valueNotifier, + LocalPreferences().preferFullAmounts.valueNotifier, + InsightsLocalPreferences().hiddenTypes.valueNotifier, + ]); + + @override + void initState() { + super.initState(); + InsightsLocalPreferences().hiddenTypes.addListener(_onHiddenTypesChanged); + } + + @override + void dispose() { + InsightsLocalPreferences().hiddenTypes.removeListener( + _onHiddenTypesChanged, + ); + super.dispose(); + } + + @override + void didUpdateWidget(WorthKnowingCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.range != oldWidget.range) fetch(); + } + + @override + Widget build(BuildContext context) { + final DateTime? month = this.month; + + return AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: month == null || (!busy && report == null) + ? const SizedBox(width: double.infinity) + : ListenableBuilder( + listenable: _formatting, + builder: (context, _) => WorthKnowingView( + report: report, + stale: + report != null && + (report!.month.year != month.year || + report!.month.month != month.month), + format: format, + hiddenTypeCount: + InsightsLocalPreferences().hiddenTypeSet.length, + onOpen: _open, + onTrack: (insight) => + InsightActions.trackAsRecurring(context, insight.series), + onManageHidden: _manageHidden, + ), + ), + ); + } + + void _onHiddenTypesChanged() => fetch(); + + void _open(Insight insight) { + final InsightReport? report = this.report; + if (report == null) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => InsightEvidenceSheet( + insight: insight, + report: report, + format: format, + ), + ); + } + + void _manageHidden() => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => const InsightKindsSheet(), + ); + + @override + Future<void> fetch() async { + final int generation = ++_generation; + final DateTime? month = this.month; + + if (month == null) { + report = null; + busy = false; + if (mounted) setState(() {}); + return; + } + + busy = true; + + try { + final InsightReport next = await InsightsService().computeFor( + month, + hiddenTypes: InsightsLocalPreferences().hiddenTypeSet, + sightings: InsightsLocalPreferences().sightingList, + ); + if (generation != _generation) return; + + report = next; + + if (next.isInProgress && next.insights.isNotEmpty) { + unawaited( + InsightsLocalPreferences() + .recordSightings(next.insights, DateTime.now()) + .catchError( + (error, stackTrace) => _log.warning( + "Failed to record insight sightings", + error, + stackTrace, + ), + ), + ); + } + } catch (e, stackTrace) { + _log.warning("Failed to compute insights", e, stackTrace); + if (generation == _generation) report = null; + } finally { + if (generation == _generation) { + busy = false; + if (mounted) setState(() {}); + } + } + } +} diff --git a/lib/widgets/home/stats/group_list_tile.dart b/lib/widgets/home/stats/group_list_tile.dart index b9962498..bfce4c34 100644 --- a/lib/widgets/home/stats/group_list_tile.dart +++ b/lib/widgets/home/stats/group_list_tile.dart @@ -1,96 +1,97 @@ import "package:flow/data/chart_data.dart"; -import "package:flow/data/flow_icon.dart"; -import "package:flow/entity/account.dart"; -import "package:flow/entity/category.dart"; -import "package:flow/l10n/extensions.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/chart_data.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_flow/symbols.dart"; class GroupListTile extends StatelessWidget { final ChartData chartData; final double percent; + + /// Bar length, relative to the largest group (0.0 to 1.0) + final double barFactor; + final Color color; + final VoidCallback? onTap; const GroupListTile({ super.key, required this.chartData, required this.percent, + required this.barFactor, + required this.color, this.onTap, }); @override Widget build(BuildContext context) { - final String name = _resolveName(context, chartData.associatedData); - final FlowIconData iconData = _resolveIcon(chartData.associatedData); - final FlowColorScheme? colorScheme = _resolveColorScheme( - chartData.associatedData, - ); + final FlowColorScheme? colorScheme = chartData.colorScheme; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - child: Row( - spacing: 12.0, - children: [ - FlowIcon( - iconData, - plated: true, - color: colorScheme?.primary, - plateColor: colorScheme?.secondary, - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: .start, - children: [ - Text( - name, - style: context.textTheme.bodyLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text("${percent.toStringAsFixed(1)}%"), - ], + return MergeSemantics( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + spacing: 12.0, + children: [ + FlowIcon( + chartData.icon, + plated: true, + color: colorScheme?.primary, + plateColor: colorScheme?.secondary, ), - ), - MoneyText( - chartData.money, - displayAbsoluteAmount: true, - style: context.textTheme.bodyLarge?.copyWith( - color: chartData.money.isNegative - ? context.flowColors.expense - : context.flowColors.income, - fontWeight: FontWeight.bold, + Expanded( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Row( + spacing: 8.0, + children: [ + Expanded( + child: Text( + chartData.resolveName(context), + style: context.textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + percent < 0.1 + ? "<0.1%" + : "${percent.toStringAsFixed(1)}%", + style: context.textTheme.bodySmall?.semi(context), + ), + MoneyText( + chartData.money, + displayAbsoluteAmount: true, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 6.0), + ClipRRect( + borderRadius: .all(Radius.circular(3.0)), + child: LinearProgressIndicator( + value: barFactor.clamp(0.01, 1.0), + minHeight: 5.0, + backgroundColor: context.colorScheme.onSurface + .withAlpha(0x1a), + valueColor: AlwaysStoppedAnimation<Color>(color), + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), ); } - - static String _resolveName(BuildContext context, Object? entity) => - switch (entity) { - Category category => category.name, - Account account => account.name, - _ => "category.none".t(context), - }; - - static FlowIconData _resolveIcon(Object? entity) => switch (entity) { - Category category => category.icon, - Account account => account.icon, - _ => FlowIconData.icon(Symbols.category_rounded), - }; - - static FlowColorScheme? _resolveColorScheme(Object? entity) => - switch (entity) { - Category category => category.colorScheme, - Account account => account.colorScheme, - _ => null, - }; } diff --git a/lib/widgets/home/stats/group_list_view.dart b/lib/widgets/home/stats/group_list_view.dart index 2664356a..8f0c4c86 100644 --- a/lib/widgets/home/stats/group_list_view.dart +++ b/lib/widgets/home/stats/group_list_view.dart @@ -1,21 +1,28 @@ import "package:flow/data/chart_data.dart"; -import "package:flow/data/money.dart"; import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/utils/extensions/chart_data.dart"; import "package:flow/widgets/home/stats/group_list_tile.dart"; +import "package:flow/widgets/home/stats/group_summary_card.dart"; import "package:flow/widgets/home/stats/no_data.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:moment_dart/moment_dart.dart"; class GroupListView extends StatelessWidget { + /// Expected to be sorted by amount, descending final Map<String, ChartData> data; final TimeRange range; + final TransactionType type; + final bool byCategory; final void Function() changeMode; const GroupListView({ super.key, required this.data, required this.range, + required this.type, + required this.byCategory, required this.changeMode, }); @@ -25,28 +32,42 @@ class GroupListView extends StatelessWidget { return NoData(selectTimeRange: changeMode); } - final Money totalAmount = data.values.fold<Money>( - Money(0, data.values.first.money.currency), - (previousValue, element) => previousValue + element.money, - ); + final Map<String, Color> colors = data.resolveColors(context); + final double total = data.displayTotal; + final double maxAmount = data.values.first.displayTotal; + + final List<ChartData> sorted = data.values.toList(); + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: GroupSummaryCard( + data: data, + colors: colors, + type: type, + byCategory: byCategory, + ), + ), + SliverPadding( + padding: const EdgeInsets.only(bottom: 96.0), + sliver: SliverList.builder( + itemCount: sorted.length, + itemBuilder: (context, index) { + final ChartData chartData = sorted[index]; - return ListView.builder( - padding: const EdgeInsets.only(bottom: 96.0, top: 8.0), - itemCount: data.length, - itemBuilder: (context, index) { - final entry = data.entries.elementAt(index); - final chartData = entry.value; - - final double percent = totalAmount.amount != 0 - ? (chartData.displayTotal / totalAmount.amount.abs()) * 100 - : 0; - - return GroupListTile( - chartData: chartData, - percent: percent, - onTap: () => _onTap(context, chartData.associatedData), - ); - }, + return GroupListTile( + chartData: chartData, + percent: total > 0 ? chartData.displayTotal / total * 100 : 0, + barFactor: maxAmount > 0 + ? chartData.displayTotal / maxAmount + : 0, + color: colors[chartData.key]!, + onTap: () => _onTap(context, chartData.associatedData), + ); + }, + ), + ), + ], ); } diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index 80396c67..3c5e726a 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -8,12 +8,11 @@ import "package:flow/data/money.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/category.dart"; import "package:flow/l10n/extensions.dart"; -import "package:flow/main.dart"; -import "package:flow/theme/primary_colors.dart"; import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/chart_data.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/home/stats/pie_percent_badge.dart"; -import "package:flutter/material.dart" hide Flow; +import "package:flutter/material.dart"; class GroupPieChart<T> extends StatefulWidget { final EdgeInsets chartPadding; @@ -57,6 +56,9 @@ class _GroupPieChartState<T> extends State<GroupPieChart<T>> { String? selectedKey; + /// Selection before the current touch, so only a re-tap opens a slice + String? _keyAtTouchStart; + @override void initState() { super.initState(); @@ -77,17 +79,11 @@ class _GroupPieChartState<T> extends State<GroupPieChart<T>> { ? null : data[selectedKey!]; - final Money? selectedSectionTotal = selectedSection?.money; + final Map<String, Color> colors = data.resolveColors(context); return Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 16.0), - Text( - "tabs.stats.chart.total".t(context), - style: context.textTheme.labelMedium, - ), - MoneyText(totalAmount, style: context.textTheme.headlineMedium), Padding( padding: widget.chartPadding, child: ConstrainedBox( @@ -111,33 +107,7 @@ class _GroupPieChartState<T> extends State<GroupPieChart<T>> { children: [ PieChart( PieChartData( - pieTouchData: PieTouchData( - touchCallback: (event, response) { - if (!event.isInterestedForInteractions || - response == null || - response.touchedSection == null) { - return; - } - - final int index = - response.touchedSection!.touchedSectionIndex; - - if (index > -1) { - final String newSelectedKey = data.entries - .elementAt(index) - .key; - - // if (!usingMouse && - // newSelectedKey == selectedKey) { - // widget.onReselect?.call(newSelectedKey); - // } - - setState(() { - selectedKey = newSelectedKey; - }); - } - }, - ), + pieTouchData: PieTouchData(touchCallback: _onTouch), sectionsSpace: 1.0, centerSpaceRadius: centerHoleDiameter / 2, startDegreeOffset: -90.0, @@ -146,7 +116,7 @@ class _GroupPieChartState<T> extends State<GroupPieChart<T>> { (e) => sectionData( data[e.$2.key]!, selected: e.$2.key == selectedKey, - index: e.$1, + color: colors[e.$2.key]!, radius: radius, ), ) @@ -165,15 +135,17 @@ class _GroupPieChartState<T> extends State<GroupPieChart<T>> { mainAxisSize: MainAxisSize.min, children: [ Text( - resolveName( - selectedSection?.associatedData, - ), + selectedSection == null + ? "tabs.stats.chart.total".t(context) + : resolveName( + selectedSection.associatedData, + ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), MoneyText( - selectedSectionTotal, + selectedSection?.money ?? totalAmount, displayAbsoluteAmount: true, textAlign: TextAlign.center, style: context.textTheme.headlineSmall, @@ -195,20 +167,40 @@ class _GroupPieChartState<T> extends State<GroupPieChart<T>> { ); } + void _onTouch(FlTouchEvent event, PieTouchResponse? response) { + final int index = response?.touchedSection?.touchedSectionIndex ?? -1; + final String? key = index > -1 && index < data.length + ? data.keys.elementAt(index) + : null; + + // One tap fires both pan down and tap down, so check reselect on tap up + if (event is FlPanDownEvent) { + _keyAtTouchStart = selectedKey; + } + + if (event is FlTapUpEvent) { + if (key != null && key == _keyAtTouchStart) { + widget.onReselect?.call(key); + } + return; + } + + if (!event.isInterestedForInteractions || key == null) return; + + setState(() { + selectedKey = key; + }); + } + PieChartSectionData sectionData( ChartData<T> data, { required double radius, + required Color color, bool selected = false, - int index = 0, }) { - final bool usingDarkTheme = Flow.of(context).useDarkTheme; - - final Color color = (usingDarkTheme - ? accentColors - : primaryColors)[index % primaryColors.length]; - final Color backgroundColor = (usingDarkTheme - ? primaryColors - : accentColors)[index % primaryColors.length]; + final Color backgroundColor = + data.colorScheme?.secondary ?? + Color.alphaBlend(color.withAlpha(0x40), context.colorScheme.surface); return PieChartSectionData( color: color, @@ -221,7 +213,7 @@ class _GroupPieChartState<T> extends State<GroupPieChart<T>> { data.associatedData, color: color, backgroundColor: backgroundColor, - percent: data.displayTotal / totalAmount.amount, + percent: data.displayTotal / totalAmount.amount.abs(), ) : null, badgePositionPercentageOffset: 0.8, diff --git a/lib/widgets/home/stats/group_share_bar.dart b/lib/widgets/home/stats/group_share_bar.dart new file mode 100644 index 00000000..b54eb02a --- /dev/null +++ b/lib/widgets/home/stats/group_share_bar.dart @@ -0,0 +1,65 @@ +import "package:flow/data/chart_data.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/chart_data.dart"; +import "package:flutter/material.dart"; + +/// Stacked 100% bar of each group's share of the total. +class GroupShareBar extends StatelessWidget { + /// Shares below this are merged into a single "other" segment + static const double otherThreshold = 0.02; + + final Map<String, ChartData> data; + final Map<String, Color> colors; + + final double height; + + const GroupShareBar({ + super.key, + required this.data, + required this.colors, + this.height = 10.0, + }); + + @override + Widget build(BuildContext context) { + final double total = data.displayTotal; + + if (total <= 0) return SizedBox(height: height); + + final List<(double, Color)> segments = []; + double other = 0.0; + + for (final MapEntry(:key, :value) in data.entries) { + if (value.displayTotal / total < otherThreshold) { + other += value.displayTotal; + } else { + segments.add((value.displayTotal, colors[key]!)); + } + } + + if (other > 0) { + segments.add((other, context.flowColors.semi.withAlpha(0x80))); + } + + return ExcludeSemantics( + child: ClipRRect( + borderRadius: .all(Radius.circular(height / 2)), + child: SizedBox( + height: height, + child: Row( + crossAxisAlignment: .stretch, + spacing: 2.0, + children: segments + .map( + (segment) => Expanded( + flex: (segment.$1 / total * 1000).round().clamp(1, 1000), + child: ColoredBox(color: segment.$2), + ), + ) + .toList(), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/home/stats/group_summary_card.dart b/lib/widgets/home/stats/group_summary_card.dart new file mode 100644 index 00000000..b24d82b0 --- /dev/null +++ b/lib/widgets/home/stats/group_summary_card.dart @@ -0,0 +1,69 @@ +import "package:flow/data/chart_data.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/utils/extensions/chart_data.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/home/stats/group_share_bar.dart"; +import "package:flutter/material.dart"; + +class GroupSummaryCard extends StatelessWidget { + final Map<String, ChartData> data; + final Map<String, Color> colors; + final TransactionType type; + final bool byCategory; + + const GroupSummaryCard({ + super.key, + required this.data, + required this.colors, + required this.type, + required this.byCategory, + }); + + @override + Widget build(BuildContext context) { + final String currency = data.values.first.money.currency; + final int transactionCount = data.values.fold( + 0, + (count, chartData) => count + chartData.transactionCount, + ); + + final String groupCount = + (byCategory + ? "tabs.stats.byGroup.categoryCount" + : "tabs.stats.byGroup.accountCount") + .t(context, data.length); + + return Surface( + margin: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0), + builder: (context) => Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + (type == TransactionType.income + ? "tabs.stats.byGroup.totalEarned" + : "tabs.stats.byGroup.totalSpent") + .t(context), + style: context.textTheme.labelMedium, + ), + MoneyText( + Money(data.displayTotal, currency), + style: context.textTheme.headlineSmall, + ), + Text( + "$groupCount · ${"transactions.count".t(context, transactionCount)}", + style: context.textTheme.bodySmall, + ), + const SizedBox(height: 12.0), + GroupShareBar(data: data, colors: colors), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/insights/insight_actions.dart b/lib/widgets/insights/insight_actions.dart new file mode 100644 index 00000000..e2331965 --- /dev/null +++ b/lib/widgets/insights/insight_actions.dart @@ -0,0 +1,73 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/providers/categories_provider.dart"; +import "package:flow/services/transactions.dart"; +import "package:flutter/widgets.dart"; +import "package:go_router/go_router.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; + +/// Where Worth knowing rows lead to. +abstract final class InsightActions { + /// Opens the latest charge with its recurrence filled in, so saving it + /// starts tracking the series from there. + static Future<void> trackAsRecurring( + BuildContext context, + RecurringSeries series, + ) async { + final Transaction? latest = TransactionsService().findByIdentifierSync( + series.occurrences.last.transactionUuid, + ); + if (latest == null) return; + + final DateTime date = latest.transactionDate; + + final Recurrence recurrence = Recurrence.fromIndefinitely( + rules: [ + switch (series.cadence) { + .weekly => RecurrenceRule.weekly(date.weekday), + .monthly => RecurrenceRule.monthly(date.day), + .yearly => RecurrenceRule.yearly(date.month, date.day), + }, + ], + start: date, + ); + + await context.push( + "/transaction/${latest.id}?recurrence=${Uri.encodeQueryComponent(recurrence.serialize())}&variableAmount=${!series.isFixedPrice}", + ); + } + + /// A list of the transactions behind [insight], or null if there's no + /// better list than the evidence itself. + static String? transactionsPath( + BuildContext context, + Insight insight, + InsightReport report, + ) { + final String range = Uri.encodeQueryComponent( + MonthTimeRange.fromDateTime(report.month).encodeShort(), + ); + + final String? categoryUuid = switch (insight) { + CategorySpikeInsight spike => spike.categoryUuid, + CategoryDropInsight drop => drop.categoryUuid, + NewCategoryInsight newCategory => newCategory.categoryUuid, + _ => null, + }; + + if (categoryUuid != null) { + final Category? category = CategoriesProvider.of( + context, + ).get(categoryUuid); + + return category == null ? null : "/category/${category.id}?range=$range"; + } + + return switch (insight) { + MonthPaceInsight() => "/transactions?range=$range", + _ => null, + }; + } +} diff --git a/lib/widgets/insights/insight_driver_tile.dart b/lib/widgets/insights/insight_driver_tile.dart new file mode 100644 index 00000000..a1cbebf4 --- /dev/null +++ b/lib/widgets/insights/insight_driver_tile.dart @@ -0,0 +1,65 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/providers/categories_provider.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/flow_icon.dart"; +import "package:flow/widgets/insights/insight_formatter.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 category that moved a month comparison, opening that category's month. +class InsightDriverTile extends StatelessWidget { + final InsightCategoryDelta driver; + + /// First day of the analyzed month. + final DateTime month; + + final InsightFormatter format; + + const InsightDriverTile({ + super.key, + required this.driver, + required this.month, + required this.format, + }); + + @override + Widget build(BuildContext context) { + final Category? category = driver.categoryUuid == null + ? null + : CategoriesProvider.of(context).get(driver.categoryUuid); + final String range = Uri.encodeQueryComponent( + MonthTimeRange.fromDateTime(month).encodeShort(), + ); + + return ListTile( + leading: FlowIcon( + category?.icon ?? FlowIconData.icon(Symbols.category_rounded), + plated: true, + colorScheme: category?.colorScheme, + ), + title: Text( + category?.name ?? "tabs.stats.analytics.uncategorized".t(context), + ), + subtitle: Text( + (driver.delta >= 0.0 + ? "tabs.stats.worthKnowing.moreThanUsual" + : "tabs.stats.worthKnowing.lessThanUsual") + .t(context, {"amount": format.money(driver.delta)}), + ), + trailing: Text( + format.money(driver.current), + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + onTap: category == null + ? null + : () => context.push("/category/${category.id}?range=$range"), + ); + } +} diff --git a/lib/widgets/insights/insight_evidence_sheet.dart b/lib/widgets/insights/insight_evidence_sheet.dart new file mode 100644 index 00000000..3ce47fd7 --- /dev/null +++ b/lib/widgets/insights/insight_evidence_sheet.dart @@ -0,0 +1,370 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; +import "package:flow/data/transaction_filter.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/prefs/insights_preferences.dart"; +import "package:flow/services/transactions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/modal_overflow_bar.dart"; +import "package:flow/widgets/general/modal_sheet.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/insights/insight_actions.dart"; +import "package:flow/widgets/insights/insight_driver_tile.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flow/widgets/insights/insight_history_chart.dart"; +import "package:flow/widgets/insights/insight_summary.dart"; +import "package:flow/widgets/stats/emphasized_text.dart"; +import "package:flow/widgets/transaction_list_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"; + +/// The math behind one Worth knowing row: its history against the usual, +/// what moved it, and the rule that fired. +class InsightEvidenceSheet extends StatefulWidget { + final Insight insight; + final InsightReport report; + final InsightFormatter format; + + const InsightEvidenceSheet({ + super.key, + required this.insight, + required this.report, + required this.format, + }); + + @override + State<InsightEvidenceSheet> createState() => _InsightEvidenceSheetState(); +} + +class _InsightEvidenceSheetState extends State<InsightEvidenceSheet> { + static const String _prefix = "tabs.stats.worthKnowing"; + static const int _maxChargeBars = 8; + + late final List<Transaction> transactions; + + Insight get insight => widget.insight; + InsightFormatter get format => widget.format; + + @override + void initState() { + super.initState(); + + final List<String> uuids = insight.transactionUuids + .take(InsightThresholds.maxEvidenceTransactions) + .toList(); + final Map<String, Transaction> found = { + for (final Transaction transaction in TransactionsService().findManySync( + TransactionFilter(uuids: uuids), + )) + transaction.uuid: transaction, + }; + + transactions = uuids.map((uuid) => found[uuid]).nonNulls.toList(); + } + + @override + Widget build(BuildContext context) { + final InsightSummary summary = InsightSummary.of( + context, + insight: insight, + report: widget.report, + format: format, + ); + final String? transactionsPath = InsightActions.transactionsPath( + context, + insight, + widget.report, + ); + final List<InsightChartBar>? bars = _bars(); + final List<InsightCategoryDelta> drivers = switch (insight) { + MonthPaceInsight pace => pace.drivers, + _ => const [], + }; + + return ModalSheet.scrollable( + trailing: ModalOverflowBar( + alignment: .end, + children: [ + TextButton.icon( + onPressed: _hide, + icon: const Icon(Symbols.visibility_off_rounded), + label: Text("$_prefix.hide.${insight.type.name}".t(context)), + ), + if (insight case RecurringChargeInsight recurring) + TextButton.icon( + onPressed: () => _track(recurring.series), + icon: const Icon(Symbols.add_rounded), + label: Text("$_prefix.track".t(context)), + ) + else if (transactionsPath != null) + TextButton.icon( + onPressed: () => context.push(transactionsPath), + icon: const Icon(Symbols.list_rounded), + label: Text("$_prefix.seeTransactions".t(context)), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: .start, + children: [ + Frame( + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + Icon( + Symbols.help_rounded, + color: context.colorScheme.primary, + size: 18.0, + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + "$_prefix.why".t(context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.flowColors.semi, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8.0), + DefaultTextStyle.merge( + style: context.textTheme.titleSmall?.copyWith(height: 1.35), + child: EmphasizedText( + template: summary.template, + value: summary.value, + valueStyle: TextStyle( + color: summary.valueColor(context), + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 4.0), + Text( + summary.detail, + style: context.textTheme.labelMedium?.semi(context), + ), + if (bars != null) ...[ + const SizedBox(height: 16.0), + Surface( + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB( + 14.0, + 14.0, + 14.0, + 10.0, + ), + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + _chartCaption(), + style: context.textTheme.labelMedium?.semi( + context, + ), + ), + const SizedBox(height: 20.0), + InsightHistoryChart( + bars: bars, + reference: _reference(), + referenceLabel: _referenceLabel(), + formatAmount: format.money, + ), + ], + ), + ), + ), + ], + ], + ), + ), + if (drivers.isNotEmpty) ...[ + const SizedBox(height: 16.0), + Frame( + child: Text( + "$_prefix.whatMovedIt".t(context), + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + for (final InsightCategoryDelta driver in drivers) + InsightDriverTile( + driver: driver, + month: widget.report.month, + format: format, + ), + ], + if (transactions.isNotEmpty) ...[ + const SizedBox(height: 16.0), + Frame( + child: Text( + _transactionsTitle(drivers.isNotEmpty), + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + for (final Transaction transaction in transactions) + TransactionListTile( + transaction: transaction, + recoverFromTrashFn: null, + moveToTrashFn: null, + combineTransfers: false, + groupRange: .month, + ), + ], + const SizedBox(height: 16.0), + Frame( + child: Text( + [_rule(), "$_prefix.rule.footer".t(context)].join(" "), + style: context.textTheme.labelMedium?.semi(context), + ), + ), + const SizedBox(height: 16.0), + ], + ), + ), + ); + } + + List<InsightChartBar> _monthBars( + List<InsightMonthValue> history, + double current, + ) => [ + for (final InsightMonthValue value in history) + ( + label: value.month.toMoment().format("MMM"), + amount: value.amount, + muted: !value.counted, + ), + ( + label: widget.report.month.toMoment().format("MMM"), + amount: current, + muted: false, + ), + ]; + + List<InsightChartBar> _chargeBars(RecurringSeries series) => [ + for (final RecurringOccurrence occurrence + in series.occurrences.reversed.take(_maxChargeBars).toList().reversed) + ( + label: occurrence.date.toMoment().format("MMM D"), + amount: occurrence.amount, + muted: false, + ), + ]; + + List<InsightChartBar>? _bars() => switch (insight) { + MonthPaceInsight pace => _monthBars(pace.history, pace.current), + CategorySpikeInsight spike => _monthBars(spike.history, spike.current), + CategoryDropInsight drop => _monthBars(drop.history, drop.current), + NewCategoryInsight() => null, + RecurringChargeInsight recurring => _chargeBars(recurring.series), + PriceChangeInsight priceChange => _chargeBars(priceChange.series), + }; + + double _reference() => switch (insight) { + MonthPaceInsight pace => pace.usual, + CategorySpikeInsight spike => spike.usual, + CategoryDropInsight drop => drop.usual, + NewCategoryInsight() => 0.0, + RecurringChargeInsight recurring => recurring.series.typicalAmount, + PriceChangeInsight priceChange => priceChange.previousAmount, + }; + + String _referenceLabel() => + "$_prefix.${switch (insight) { + PriceChangeInsight() => "chartBefore", + _ => "chartUsual", + }}" + .t(context, {"amount": format.money(_reference())}); + + int? get _throughDay => switch (insight) { + MonthPaceInsight pace => pace.throughDay, + CategoryDropInsight drop => drop.throughDay, + _ => null, + }; + + String _chartCaption() { + if (insight is RecurringChargeInsight || insight is PriceChangeInsight) { + return "$_prefix.chartCharges".t(context); + } + + return switch (_throughDay) { + int day => "$_prefix.chartThroughDay".t( + context, + InsightSummary.dayValues(day), + ), + null => "$_prefix.chartMonthly".t(context), + }; + } + + String _transactionsTitle(bool hasDrivers) => + "$_prefix.${switch (insight) { + RecurringChargeInsight() || PriceChangeInsight() => "charges", + _ when hasDrivers => "biggestEntries", + _ => "whatMovedIt", + }}" + .t(context); + + String _rule() { + final int months = InsightThresholds.baselineMonths; + + return switch (insight) { + MonthPaceInsight(throughDay: int day) => + "$_prefix.rule.monthPace".t(context, { + "date": DateTime( + widget.report.month.year, + widget.report.month.month, + day, + ).toMoment().format("MMM D"), + "months": months, + }), + MonthPaceInsight() => "$_prefix.rule.monthPacePast".t(context, { + "month": widget.report.month.toMoment().format("MMMM"), + "months": months, + }), + CategorySpikeInsight() => "$_prefix.rule.categorySpike".t(context, { + "ratio": format.ratio(InsightThresholds.categorySpikeRatio), + "count": InsightThresholds.categorySpikeMinEntries, + "months": months, + }), + CategoryDropInsight() => "$_prefix.rule.categoryDrop".t(context, { + "percent": format.percent(InsightThresholds.categoryDropRatio), + }), + NewCategoryInsight() => "$_prefix.rule.newCategory".t(context, { + "months": months, + }), + RecurringChargeInsight() => "$_prefix.rule.recurringCharge".t(context, { + "count": InsightThresholds.variableSuggestionMinOccurrences, + }), + PriceChangeInsight() => "$_prefix.rule.priceChange".t(context, { + "percent": format.percent(InsightThresholds.priceChangeMinRatio), + }), + }; + } + + Future<void> _hide() async { + await InsightsLocalPreferences().setHidden(insight.type, true); + if (!mounted) return; + + context.showToast(text: "$_prefix.hidden".t(context)); + context.pop(); + } + + void _track(RecurringSeries series) { + context.pop(); + InsightActions.trackAsRecurring(context, series); + } +} diff --git a/lib/widgets/insights/insight_formatter.dart b/lib/widgets/insights/insight_formatter.dart new file mode 100644 index 00000000..35d6e585 --- /dev/null +++ b/lib/widgets/insights/insight_formatter.dart @@ -0,0 +1,39 @@ +import "package:flow/data/money.dart"; +import "package:flow/utils/extensions.dart"; + +/// Formats insight figures for use inside sentences. Honors privacy mode and +/// the money formatting preferences. +class InsightFormatter { + final String currency; + final bool obscure; + final bool useCurrencySymbol; + final bool preferFullAmounts; + + const InsightFormatter({ + required this.currency, + this.obscure = false, + this.useCurrencySymbol = true, + this.preferFullAmounts = false, + }); + + /// Cents only matter for small amounts, like a subscription. + String money(double amount) { + final double value = amount.abs(); + final Money money = Money(value, currency); + + final String text = !preferFullAmounts && value >= 10000.0 + ? money.formatMoney(compact: true, useCurrencySymbol: useCurrencySymbol) + : money.formatMoney( + useCurrencySymbol: useCurrencySymbol, + decimalDigits: value >= 100.0 ? 0 : null, + ); + + return obscure ? text.digitsObscured : text; + } + + /// e.g. "27%" for 0.27 or -0.27. + String percent(double fraction) => fraction.abs().percentInt; + + /// e.g. "1.9×". + String ratio(double ratio) => "${ratio.toStringAsFixed(1)}×"; +} diff --git a/lib/widgets/insights/insight_history_chart.dart b/lib/widgets/insights/insight_history_chart.dart new file mode 100644 index 00000000..f9f54a89 --- /dev/null +++ b/lib/widgets/insights/insight_history_chart.dart @@ -0,0 +1,150 @@ +import "dart:math" as math; + +import "package:fl_chart/fl_chart.dart"; +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +/// One bar in an [InsightHistoryChart]. +typedef InsightChartBar = ({String label, double amount, bool muted}); + +/// Bars for past values with the latest one highlighted, and a dashed line +/// at the [reference] value (usually the median). +class InsightHistoryChart extends StatelessWidget { + /// Oldest first. The last one is highlighted. + final List<InsightChartBar> bars; + + final double reference; + final String referenceLabel; + + /// Formats amounts for the tooltip. + final String Function(double amount) formatAmount; + + final double height; + + const InsightHistoryChart({ + super.key, + required this.bars, + required this.reference, + required this.referenceLabel, + required this.formatAmount, + this.height = 120.0, + }); + + @override + Widget build(BuildContext context) { + final double maxAmount = bars.fold( + reference, + (max, bar) => math.max(max, bar.amount), + ); + final Color base = context.colorScheme.onSurface.withAlpha(0x33); + final Color muted = context.colorScheme.onSurface.withAlpha(0x14); + + return SizedBox( + height: height, + child: LayoutBuilder( + builder: (context, constraints) { + final double barWidth = + (constraints.maxWidth / math.max(bars.length, 1) * 0.6).clamp( + 4.0, + 28.0, + ); + + return BarChart( + BarChartData( + minY: 0.0, + maxY: maxAmount <= 0.0 ? 1.0 : maxAmount * 1.2, + alignment: .spaceAround, + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => context.colorScheme.onSurface, + getTooltipItem: (group, groupIndex, rod, rodIndex) => + BarTooltipItem( + formatAmount(rod.toY), + TextStyle( + color: context.colorScheme.surface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + titlesData: FlTitlesData( + leftTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + topTitles: const AxisTitles(), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 20.0, + getTitlesWidget: (value, meta) { + final int index = value.toInt(); + if (index < 0 || index >= bars.length) { + return const SizedBox.shrink(); + } + + final bool last = index == bars.length - 1; + + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + bars[index].label, + maxLines: 1, + style: context.textTheme.labelSmall?.copyWith( + color: last ? null : context.flowColors.semi, + fontWeight: last ? FontWeight.w600 : null, + ), + ), + ); + }, + ), + ), + ), + extraLinesData: ExtraLinesData( + horizontalLines: [ + HorizontalLine( + y: reference, + color: context.colorScheme.onSurface.withAlpha(0x8c), + strokeWidth: 1.5, + dashArray: [4, 4], + label: HorizontalLineLabel( + show: true, + alignment: Alignment.topLeft, + padding: const EdgeInsets.only(bottom: 2.0), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + labelResolver: (_) => referenceLabel, + ), + ), + ], + ), + barGroups: [ + for (final (int index, InsightChartBar bar) in bars.indexed) + BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: bar.amount, + width: barWidth, + color: index == bars.length - 1 + ? context.colorScheme.primary + : bar.muted + ? muted + : base, + borderRadius: const .vertical( + top: Radius.circular(5.0), + ), + ), + ], + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/insights/insight_icon_badge.dart b/lib/widgets/insights/insight_icon_badge.dart new file mode 100644 index 00000000..7889091f --- /dev/null +++ b/lib/widgets/insights/insight_icon_badge.dart @@ -0,0 +1,25 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/flow_icon.dart"; +import "package:flutter/material.dart"; + +/// A small tinted square holding an insight's icon. +class InsightIconBadge extends StatelessWidget { + final FlowIconData icon; + + const InsightIconBadge({super.key, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + width: 32.0, + height: 32.0, + alignment: Alignment.center, + decoration: BoxDecoration( + color: context.colorScheme.primary.withAlpha(0x29), + borderRadius: .all(Radius.circular(10.0)), + ), + child: FlowIcon(icon, size: 18.0), + ); + } +} diff --git a/lib/widgets/insights/insight_kinds_sheet.dart b/lib/widgets/insights/insight_kinds_sheet.dart new file mode 100644 index 00000000..479dc7d6 --- /dev/null +++ b/lib/widgets/insights/insight_kinds_sheet.dart @@ -0,0 +1,41 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/prefs/insights_preferences.dart"; +import "package:flow/widgets/general/modal_sheet.dart"; +import "package:flutter/material.dart"; + +/// Turns kinds of insights back on after "Don't show". +class InsightKindsSheet extends StatelessWidget { + const InsightKindsSheet({super.key}); + + @override + Widget build(BuildContext context) { + final InsightsLocalPreferences preferences = InsightsLocalPreferences(); + + return ModalSheet.scrollable( + title: Text("tabs.stats.worthKnowing.kinds".t(context)), + child: SingleChildScrollView( + child: ValueListenableBuilder( + valueListenable: preferences.hiddenTypes.valueNotifier, + builder: (context, _, _) { + final Set<InsightType> hidden = preferences.hiddenTypeSet; + + return Column( + mainAxisSize: .min, + children: [ + for (final InsightType type in InsightType.values) + SwitchListTile( + title: Text( + "tabs.stats.worthKnowing.type.${type.name}".t(context), + ), + value: !hidden.contains(type), + onChanged: (show) => preferences.setHidden(type, !show), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/insights/insight_summary.dart b/lib/widgets/insights/insight_summary.dart new file mode 100644 index 00000000..439173e2 --- /dev/null +++ b/lib/widgets/insights/insight_summary.dart @@ -0,0 +1,272 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/providers/categories_provider.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// What a Worth knowing row says about an [Insight]: one sentence with an +/// emphasized figure, and one line of context. +class InsightSummary { + /// Holds a `{value}` token for [value], see `EmphasizedText`. + final String template; + final String value; + final String detail; + + final FlowIconData icon; + + /// Whether spending went up. Null for insights that aren't a change. + final bool? increase; + + /// Past values, oldest first, ending with the current one. + final List<double>? spark; + + const InsightSummary({ + required this.template, + required this.value, + required this.detail, + required this.icon, + this.increase, + this.spark, + }); + + /// A day of month as `{day}` ("25th") and `{dayNumber}` ("25"), for + /// languages without ordinal formatting. + static Map<String, String> dayValues(int day) => { + "day": DateTime(2000, 1, day).toMoment().format("Do"), + "dayNumber": day.toString(), + }; + + /// The sentence without emphasis. + String get sentence => template.replaceAll("{value}", value); + + /// Follows the user's change colors. + Color valueColor(BuildContext context) { + final bool? increase = this.increase; + if (increase == null) return context.colorScheme.primary; + + final bool redWhenUp = + UserPreferencesService().changeVisuals.expenseIncreaseRed; + + return increase == redWhenUp + ? context.flowColors.expense + : context.flowColors.income; + } + + factory InsightSummary.of( + BuildContext context, { + required Insight insight, + required InsightReport report, + required InsightFormatter format, + }) => _InsightSummaryBuilder( + context, + report: report, + format: format, + ).build(insight); +} + +class _InsightSummaryBuilder { + static const String _prefix = "tabs.stats.worthKnowing"; + static const int _maxSparkCharges = 7; + + final BuildContext context; + final InsightReport report; + final InsightFormatter format; + + const _InsightSummaryBuilder( + this.context, { + required this.report, + required this.format, + }); + + bool get _past => !report.isInProgress; + + String get _month => report.month.toMoment().format("MMMM"); + + String _t(String key, [dynamic replace]) => + "$_prefix.$key".t(context, replace); + + Category? _category(String? uuid) => + uuid == null ? null : CategoriesProvider.of(context).get(uuid); + + String _categoryName(String? uuid) => + _category(uuid)?.name ?? "tabs.stats.analytics.uncategorized".t(context); + + FlowIconData _categoryIcon(String? uuid) => + _category(uuid)?.icon ?? FlowIconData.icon(Symbols.category_rounded); + + String _untitled(String? title) => switch (title) { + String title when title.trim().isNotEmpty => title, + _ => "tabs.stats.analytics.untitled".t(context), + }; + + String _versus(double current, double usual) => _t("versusTypical", { + "current": format.money(current), + "usual": format.money(usual), + }); + + List<double> _spark(List<InsightMonthValue> history, double current) => [ + ...history.map((month) => month.amount), + current, + ]; + + InsightSummary build(Insight insight) => switch (insight) { + MonthPaceInsight pace => _monthPace(pace), + CategorySpikeInsight spike => _categorySpike(spike), + CategoryDropInsight drop => _categoryDrop(drop), + NewCategoryInsight newCategory => _newCategory(newCategory), + RecurringChargeInsight recurring => _recurringCharge(recurring), + PriceChangeInsight priceChange => _priceChange(priceChange), + }; + + InsightSummary _monthPace(MonthPaceInsight pace) { + final bool above = pace.direction == .above; + final bool upArrow = + above == UserPreferencesService().changeVisuals.expenseIncreaseUpArrow; + + final String key = switch ((above, _past)) { + (true, false) => "monthAbove", + (false, false) => "monthBelow", + (true, true) => "monthAbovePast", + (false, true) => "monthBelowPast", + }; + + return InsightSummary( + template: _t(key, { + "month": _month, + if (pace.throughDay case int day) ...InsightSummary.dayValues(day), + }), + value: format.percent(pace.ratio - 1.0), + detail: _monthPaceDetail(pace), + icon: FlowIconData.icon( + upArrow ? Symbols.trending_up_rounded : Symbols.trending_down_rounded, + ), + increase: above, + spark: _spark(pace.history, pace.current), + ); + } + + String _monthPaceDetail(MonthPaceInsight pace) { + final InsightAttribution? attribution = pace.attribution; + + if (attribution != null) { + final double ratio = pace.ratioWithoutAttribution!; + final Map<String, String> values = { + "title": switch (attribution.title) { + String title when title.trim().isNotEmpty => title, + _ => _categoryName(attribution.categoryUuid), + }, + "amount": format.money(attribution.amount), + "percent": format.percent(ratio - 1.0), + }; + + return switch (ratio) { + < 0.995 => _t("mostlyUnder", values), + > 1.005 => _t("mostlyOver", values), + _ => _t("mostlyEven", values), + }; + } + + if (pace.dominantDriver case InsightCategoryDelta driver) { + return _t("mostlyCategory", { + "category": _categoryName(driver.categoryUuid), + "difference": _difference(driver.delta), + }); + } + + return _versus(pace.current, pace.usual); + } + + String _difference(double delta) => _t( + delta >= 0.0 ? "moreThanUsual" : "lessThanUsual", + {"amount": format.money(delta)}, + ); + + InsightSummary _categorySpike(CategorySpikeInsight spike) => InsightSummary( + template: _t(_past ? "categorySpikePast" : "categorySpike", { + "category": _categoryName(spike.categoryUuid), + }), + value: format.ratio(spike.ratio), + detail: [ + _versus(spike.current, spike.usual), + _t("entries", spike.entryCount), + ].join(" · "), + icon: _categoryIcon(spike.categoryUuid), + increase: true, + spark: _spark(spike.history, spike.current), + ); + + InsightSummary _categoryDrop(CategoryDropInsight drop) => InsightSummary( + template: _t(_past ? "categoryDropPast" : "categoryDrop", { + "category": _categoryName(drop.categoryUuid), + }), + value: format.percent(1.0 - drop.ratio), + detail: _versus(drop.current, drop.usual), + icon: _categoryIcon(drop.categoryUuid), + increase: false, + spark: _spark(drop.history, drop.current), + ); + + InsightSummary _newCategory(NewCategoryInsight insight) => InsightSummary( + template: _t("newCategory", {"months": InsightThresholds.baselineMonths}), + value: _categoryName(insight.categoryUuid), + detail: [ + format.money(insight.current), + _t("entries", insight.entryCount), + ].join(" · "), + icon: _categoryIcon(insight.categoryUuid), + ); + + InsightSummary _recurringCharge(RecurringChargeInsight insight) { + final RecurringSeries series = insight.series; + + final Map<String, String> values = { + "amount": + "${series.isFixedPrice ? "" : "~"}" + "${format.money(series.typicalAmount)}", + "times": _t("times", series.occurrences.length), + ...InsightSummary.dayValues(series.anchorDay), + "weekday": DateTime(2024, 1, series.anchorDay).toMoment().format("dddd"), + "date": series.occurrences.last.date.toMoment().format("MMMM Do"), + }; + + final String cadence = switch (series.cadence) { + .weekly => "Weekly", + .monthly => "Monthly", + .yearly => "Yearly", + }; + + return InsightSummary( + template: _t("recurring$cadence"), + value: _untitled(series.title), + detail: _t("recurring${cadence}Detail", values), + icon: FlowIconData.icon(Symbols.autorenew_rounded), + ); + } + + InsightSummary _priceChange(PriceChangeInsight insight) => InsightSummary( + template: _t("priceChange", { + "title": _untitled(insight.series.title), + "previous": format.money(insight.previousAmount), + }), + value: format.money(insight.currentAmount), + detail: _t( + insight.currentAmount >= insight.previousAmount ? "priceUp" : "priceDown", + {"amount": format.money(insight.stake)}, + ), + icon: FlowIconData.icon(Symbols.price_change_rounded), + increase: insight.currentAmount >= insight.previousAmount, + spark: insight.series.occurrences.reversed + .take(_maxSparkCharges) + .map((occurrence) => occurrence.amount) + .toList() + .reversed + .toList(), + ); +} diff --git a/lib/widgets/insights/worth_knowing_row.dart b/lib/widgets/insights/worth_knowing_row.dart new file mode 100644 index 00000000..9a71abf5 --- /dev/null +++ b/lib/widgets/insights/worth_knowing_row.dart @@ -0,0 +1,86 @@ +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/insights/insight_icon_badge.dart"; +import "package:flow/widgets/insights/insight_summary.dart"; +import "package:flow/widgets/stats/emphasized_text.dart"; +import "package:flow/widgets/stats/wrapped/mini_bars.dart"; +import "package:flutter/material.dart"; + +/// One insight in the Worth knowing card: a sentence, a line of context, and +/// a tiny history when there is one. +class WorthKnowingRow extends StatelessWidget { + final InsightSummary summary; + final VoidCallback onTap; + + /// Shown under the text, e.g. "Track as recurring". + final Widget? action; + + const WorthKnowingRow({ + super.key, + required this.summary, + required this.onTap, + this.action, + }); + + @override + Widget build(BuildContext context) { + final List<double>? spark = summary.spark; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + crossAxisAlignment: .start, + children: [ + InsightIconBadge(icon: summary.icon), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + DefaultTextStyle.merge( + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + height: 1.35, + ), + child: EmphasizedText( + template: summary.template, + value: summary.value, + valueStyle: TextStyle( + color: summary.valueColor(context), + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 2.0), + Text( + summary.detail, + style: context.textTheme.labelMedium?.semi(context), + ), + if (action != null) ...[const SizedBox(height: 8.0), action!], + ], + ), + ), + if (spark != null && spark.length > 1) ...[ + const SizedBox(width: 12.0), + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: SizedBox( + width: 44.0, + child: ExcludeSemantics( + child: MiniBars( + values: spark, + highlightColor: context.colorScheme.primary, + height: 28.0, + spacing: 2.0, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/insights/worth_knowing_view.dart b/lib/widgets/insights/worth_knowing_view.dart new file mode 100644 index 00000000..9c084f7b --- /dev/null +++ b/lib/widgets/insights/worth_knowing_view.dart @@ -0,0 +1,192 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flow/widgets/insights/insight_summary.dart"; +import "package:flow/widgets/insights/worth_knowing_row.dart"; +import "package:flow/widgets/stats/missing_rates_notice.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// The Worth knowing card for a computed [report]. Renders nothing when the +/// engine has too little history to say anything. +class WorthKnowingView extends StatelessWidget { + /// Null while the first report is on its way. + final InsightReport? report; + + /// Whether [report] is for another month than the one being loaded. + final bool stale; + + final InsightFormatter format; + + final int hiddenTypeCount; + + final ValueChanged<Insight> onOpen; + final ValueChanged<RecurringChargeInsight> onTrack; + final VoidCallback onManageHidden; + + const WorthKnowingView({ + super.key, + required this.report, + required this.format, + required this.onOpen, + required this.onTrack, + required this.onManageHidden, + this.stale = false, + this.hiddenTypeCount = 0, + }); + + static bool isVisible(InsightReport? report) => switch (report?.status) { + null || InsightReportStatus.ready || InsightReportStatus.tooEarly => true, + _ => false, + }; + + @override + Widget build(BuildContext context) { + final InsightReport? report = this.report; + + if (!isVisible(report)) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Surface( + clipBehavior: .antiAlias, + // Rows of the previous month would open that month's evidence. + builder: (context) => IgnorePointer( + ignoring: stale, + child: AnimatedOpacity( + opacity: stale ? 0.5 : 1.0, + duration: const Duration(milliseconds: 150), + child: Column( + crossAxisAlignment: .start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + 16.0, + hiddenTypeCount > 0 ? 4.0 : 16.0, + hiddenTypeCount > 0 ? 4.0 : 16.0, + hiddenTypeCount > 0 ? 0.0 : 4.0, + ), + child: Row( + children: [ + Icon( + Symbols.lightbulb_rounded, + color: context.colorScheme.primary, + size: 18.0, + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + "tabs.stats.worthKnowing".t(context).toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.flowColors.semi, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ), + if (hiddenTypeCount > 0) + IconButton( + onPressed: onManageHidden, + tooltip: "tabs.stats.worthKnowing.kinds".t(context), + visualDensity: VisualDensity.compact, + iconSize: 18.0, + color: context.flowColors.semi, + icon: const Icon(Symbols.visibility_off_rounded), + ), + ], + ), + ), + if (report == null) + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 20.0), + child: Container( + width: 180.0, + height: 12.0, + decoration: BoxDecoration( + color: context.colorScheme.onSurface.withAlpha(0x14), + borderRadius: .all(Radius.circular(6.0)), + ), + ), + ) + else if (report.insights.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), + child: Text( + (report.isInProgress + ? "tabs.stats.worthKnowing.nothingUnusual" + : "tabs.stats.worthKnowing.nothingUnusualPast") + .t(context, { + "month": report.month.toMoment().format("MMMM"), + }), + style: context.textTheme.bodyMedium?.semi(context), + ), + ) + else + for (final (int index, Insight insight) + in report.insights.indexed) ...[ + if (index > 0) + Divider( + height: 1.0, + thickness: 1.0, + indent: 16.0, + endIndent: 16.0, + color: context.colorScheme.onSurface.withAlpha(0x17), + ), + WorthKnowingRow( + summary: InsightSummary.of( + context, + insight: insight, + report: report, + format: format, + ), + onTap: () => onOpen(insight), + action: insight is RecurringChargeInsight + ? ActionChip( + avatar: Icon( + Symbols.add_rounded, + color: context.colorScheme.primary, + ), + label: Text( + "tabs.stats.worthKnowing.track".t(context), + ), + labelStyle: context.textTheme.labelMedium + ?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + side: BorderSide( + color: context.colorScheme.primary.withAlpha( + 0x80, + ), + ), + shape: const StadiumBorder(), + visualDensity: VisualDensity.compact, + onPressed: () => onTrack(insight), + ) + : null, + ), + ], + if (report?.hasMissingRates == true) + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesAmounts".t( + context, + ), + ), + ), + if (report != null && report.insights.isNotEmpty) + const SizedBox(height: 4.0), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/sheets/select_flow_button_type_sheet.dart b/lib/widgets/sheets/select_flow_button_type_sheet.dart new file mode 100644 index 00000000..79a7b7ae --- /dev/null +++ b/lib/widgets/sheets/select_flow_button_type_sheet.dart @@ -0,0 +1,47 @@ +import "package:flow/data/flow_button_type.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/l10n/named_enum.dart"; +import "package:flow/widgets/general/directional_chevron.dart"; +import "package:flow/widgets/general/modal_overflow_bar.dart"; +import "package:flow/widgets/general/modal_sheet.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +class SelectFlowButtonTypeSheet extends StatelessWidget { + final List<FlowButtonType> types; + + const SelectFlowButtonTypeSheet({super.key, required this.types}); + + @override + Widget build(BuildContext context) { + return ModalSheet.scrollable( + title: Text("transaction.new".t(context)), + trailing: ModalOverflowBar( + alignment: .end, + children: [ + TextButton.icon( + onPressed: () => context.pop(null), + icon: const Icon(Symbols.close_rounded), + label: Text("general.cancel".t(context)), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: types + .map( + (value) => ListTile( + leading: Icon(value.icon), + title: Text(value.localizedNameContext(context)), + trailing: const LeChevron(), + onTap: () => context.pop(value), + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/lib/widgets/stats/net_worth/net_worth_chart.dart b/lib/widgets/stats/net_worth/net_worth_chart.dart index d0de9568..c5462d34 100644 --- a/lib/widgets/stats/net_worth/net_worth_chart.dart +++ b/lib/widgets/stats/net_worth/net_worth_chart.dart @@ -44,7 +44,7 @@ class NetWorthChart extends StatelessWidget { DurationUnit.second || DurationUnit.minute || DurationUnit.hour => "MMM D, HH:mm", - DurationUnit.day || DurationUnit.week => "MMM D, YYYY", + DurationUnit.day || DurationUnit.week => "ll", DurationUnit.month => "MMM YYYY", DurationUnit.year => "YYYY", }; diff --git a/lib/widgets/stats/recurring/recurring_summary_header.dart b/lib/widgets/stats/recurring/recurring_summary_header.dart index c4734214..6232b7f9 100644 --- a/lib/widgets/stats/recurring/recurring_summary_header.dart +++ b/lib/widgets/stats/recurring/recurring_summary_header.dart @@ -15,11 +15,17 @@ class RecurringSummaryHeader extends StatelessWidget { final Money expense; final int count; + /// Whether [income] or [expense] include variable amount estimates + final bool approximateIncome; + final bool approximateExpense; + const RecurringSummaryHeader({ super.key, required this.income, required this.expense, required this.count, + this.approximateIncome = false, + this.approximateExpense = false, }); @override @@ -37,6 +43,7 @@ class RecurringSummaryHeader extends StatelessWidget { flow: income, type: TransactionType.income, autoSizeGroup: autoSizeGroup, + approximate: approximateIncome, ), ), const SizedBox(width: 12.0), @@ -45,6 +52,7 @@ class RecurringSummaryHeader extends StatelessWidget { flow: expense, type: TransactionType.expense, autoSizeGroup: autoSizeGroup, + approximate: approximateExpense, ), ), ], diff --git a/lib/widgets/stats/wrapped/mini_bars.dart b/lib/widgets/stats/wrapped/mini_bars.dart index bf1c4dd2..4e6ef53b 100644 --- a/lib/widgets/stats/wrapped/mini_bars.dart +++ b/lib/widgets/stats/wrapped/mini_bars.dart @@ -7,7 +7,18 @@ class MiniBars extends StatelessWidget { final List<double> values; final Color highlightColor; - const MiniBars({super.key, required this.values, required this.highlightColor}); + final double height; + + /// Gap between two bars. + final double spacing; + + const MiniBars({ + super.key, + required this.values, + required this.highlightColor, + this.height = 44.0, + this.spacing = 8.0, + }); @override Widget build(BuildContext context) { @@ -17,7 +28,7 @@ class MiniBars extends StatelessWidget { final Color base = context.colorScheme.onSurface.withAlpha(0x33); return SizedBox( - height: 44.0, + height: height, child: Row( crossAxisAlignment: .end, children: values.asMap().entries.map((entry) { @@ -26,7 +37,7 @@ class MiniBars extends StatelessWidget { return Expanded( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), + padding: EdgeInsets.symmetric(horizontal: spacing / 2), child: Align( alignment: Alignment.bottomCenter, child: FractionallySizedBox( diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index 175a37d5..4c636b4b 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -9,6 +9,7 @@ import "package:flow/providers/accounts_provider.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/transaction.dart"; +import "package:flow/widgets/general/approximate_money_text.dart"; import "package:flow/widgets/general/directional_slidable.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/money_text.dart"; @@ -239,6 +240,7 @@ class TransactionListTile extends StatelessWidget { transaction.money, displayAbsoluteAmount: transaction.isTransfer && combineTransfers, + approximate: transaction.isAmountEstimate, style: context.textTheme.bodyLarge?.copyWith( color: transaction.type.color(context), fontWeight: FontWeight.bold, @@ -274,6 +276,18 @@ class TransactionListTile extends StatelessWidget { ), ), overrideObscure: overrideObscure, + ) + else + ApproximateMoneyText( + transaction.money, + displayAbsoluteAmount: + transaction.isTransfer && combineTransfers, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurface.withAlpha( + 0x80, + ), + ), + overrideObscure: overrideObscure, ), ], ), @@ -443,4 +457,3 @@ class TransactionListTile extends StatelessWidget { ), ); } - diff --git a/lib/widgets/transactions_date_header.dart b/lib/widgets/transactions_date_header.dart index 2a5dbf55..53527c90 100644 --- a/lib/widgets/transactions_date_header.dart +++ b/lib/widgets/transactions_date_header.dart @@ -61,6 +61,7 @@ class _TransactionListDateHeaderState extends State<TransactionListDateHeader> { TransitiveLocalPreferences().sessionPrivacyMode.addListener( _updatePrivacyMode, ); + UserPreferencesService().valueNotifier.addListener(_rerender); obscure = TransitiveLocalPreferences().sessionPrivacyMode.get(); } @@ -70,6 +71,7 @@ class _TransactionListDateHeaderState extends State<TransactionListDateHeader> { TransitiveLocalPreferences().sessionPrivacyMode.removeListener( _updatePrivacyMode, ); + UserPreferencesService().valueNotifier.removeListener(_rerender); super.dispose(); } @@ -174,6 +176,11 @@ class _TransactionListDateHeaderState extends State<TransactionListDateHeader> { setState(() {}); } + void _rerender() { + if (!mounted) return; + setState(() {}); + } + void _handleRangeTextTap() { rangeTitleAlternative = !rangeTitleAlternative; @@ -195,7 +202,11 @@ class _TransactionListDateHeaderState extends State<TransactionListDateHeader> { } String _getRangeTitle() { - return switch ((widget.range, rangeTitleAlternative)) { + final bool absolute = + UserPreferencesService().transactionListAbsoluteDateHeaders != + rangeTitleAlternative; + + return switch ((widget.range, absolute)) { (DayTimeRange dayTimeRange, false) => dayTimeRange.from.toMoment().calendar(omitHours: true), (DayTimeRange dayTimeRange, true) => dayTimeRange.from.toMoment().format( diff --git a/pubspec.lock b/pubspec.lock index 1e31cd06..6551d4a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1018,10 +1018,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -1202,10 +1202,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1218,18 +1218,18 @@ packages: dependency: "direct main" description: name: material_symbols_icons_flow - sha256: "31f66ea83ac9f2a73ac9b04ae5f75696a71c4bc754d31172082e127fc1a9b8c2" + sha256: "350e7f8c118f3bea6bba996579fb6eceb5dfe2fa96835a251653cf1cd257ac2b" url: "https://pub.dev" source: hosted - version: "4.2928.1" + version: "4.2960.0" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mgrs_dart: dependency: transitive description: @@ -1818,10 +1818,10 @@ packages: dependency: "direct main" description: name: simple_icons_flow - sha256: "929fffba2872fb1687b467f28a91d82ad4117468314f776ac799ca04eac164ea" + sha256: c7ac49006c41788881dab01b24262fdb1c429ea36746496c7a267b85bdb15c7e url: "https://pub.dev" source: hosted - version: "16.20.0" + version: "16.23.0" simple_sparse_list: dependency: transitive description: @@ -1927,26 +1927,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -2063,10 +2063,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b988ed3a..908dd611 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.24.0+357" +version: "0.25.0+363" 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_flow: ^4.2928.1 + material_symbols_icons_flow: ^4.2960.0 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_flow: ^16.20.0 + simple_icons_flow: ^16.23.0 smooth_page_indicator: ^2.0.1 timezone: ^0.11.0 toastification: ^3.0.3 diff --git a/reformat_l10n_files.sh b/reformat_l10n_files.sh index 5027e70c..e1f826a9 100755 --- a/reformat_l10n_files.sh +++ b/reformat_l10n_files.sh @@ -1 +1 @@ -dart ./scripts/reformat_l10n_files.dart \ No newline at end of file +dart ./scripts/reformat_l10n_files.dart diff --git a/test/database_test.dart b/test/database_test.dart index 7409bf29..e2176e9f 100644 --- a/test/database_test.dart +++ b/test/database_test.dart @@ -3,6 +3,8 @@ import "dart:io"; import "package:flow/data/money.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/transaction.dart"; +import "package:flow/entity/transaction/extensions/default/geo.dart"; +import "package:flow/entity/transaction/extensions/default/recurring.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/objectbox/objectbox.g.dart"; @@ -232,6 +234,75 @@ void main() { }, ); + test("Transfer keeps location on both sides, including edits", () async { + final Query<Account> accountQuery = ObjectBox() + .box<Account>() + .query(Account_.currency.equals("MNT")) + .build(); + final List<Account> mntAccounts = await accountQuery.findAsync(); + accountQuery.close(); + + final Geo geo = Geo(uuid: "geo", latitude: 47.9, longitude: 106.9); + + final (fromTxnId, toTxnId) = mntAccounts[0].transferTo( + targetAccount: mntAccounts[1], + amount: 100.0, + extensions: [geo], + ); + + final Transaction fromTxn = (await TransactionsService().getOne( + fromTxnId, + ))!; + final Transaction toTxn = (await TransactionsService().getOne(toTxnId))!; + + expect(fromTxn.extensions.geo?.latitude, equals(47.9)); + expect(toTxn.extensions.geo?.latitude, equals(47.9)); + + // Editing a transfer re-creates it from the old transaction's extensions + final (editedFromId, editedToId) = mntAccounts[0].transferTo( + targetAccount: mntAccounts[1], + amount: 200.0, + extensions: fromTxn.extensions.data, + ); + fromTxn.permanentlyDelete(true); + + final Transaction editedFrom = (await TransactionsService().getOne( + editedFromId, + ))!; + final Transaction editedTo = (await TransactionsService().getOne( + editedToId, + ))!; + + expect(editedFrom.extensions.geo?.longitude, equals(106.9)); + expect(editedTo.extensions.geo?.longitude, equals(106.9)); + expect(editedFrom.extensions.recurring, isNull); + }); + + test("Generated recurring transfer keeps its Recurring link", () async { + final Query<Account> accountQuery = ObjectBox() + .box<Account>() + .query(Account_.currency.equals("MNT")) + .build(); + final List<Account> mntAccounts = await accountQuery.findAsync(); + accountQuery.close(); + + final (fromTxnId, toTxnId) = mntAccounts[0].transferTo( + targetAccount: mntAccounts[1], + amount: 100.0, + extensions: [ + Recurring(uuid: "series", initialTransactionDate: DateTime.now()), + ], + ); + + final Transaction fromTxn = (await TransactionsService().getOne( + fromTxnId, + ))!; + final Transaction toTxn = (await TransactionsService().getOne(toTxnId))!; + + expect(fromTxn.extensions.recurring?.uuid, equals("series")); + expect(toTxn.extensions.recurring?.uuid, equals("series")); + }); + tearDownAll(() async { await testCleanupObject( instance: ObjectBox(), diff --git a/test/insights_demo_data_test.dart b/test/insights_demo_data_test.dart new file mode 100644 index 00000000..bf301ce2 --- /dev/null +++ b/test/insights_demo_data_test.dart @@ -0,0 +1,235 @@ +import "dart:io"; +import "dart:math"; + +import "package:flow/data/insights/insight_adapter.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/setup/default_accounts.dart"; +import "package:flow/data/setup/default_categories.dart"; +import "package:flow/data/setup/demo_data.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/services/insights.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path/path.dart" as path; + +import "objectbox_erase.dart"; + +/// Runs the insight engine over the demo data, month by month, the way the +/// Stats tab would. Worth running under a few `TZ`s. +void main() { + late ObjectBox obx; + late List<Category> categories; + late List<Account> accounts; + + final String directory = path.join( + Directory.current.path, + ".objectbox_test_insights", + ); + + final DateTime now = DateTime(2026, 9, 25, 15, 30); + + const List<String> monthlyBills = [ + "Rent", + "Gym membership", + "iCloud+", + "Netflix", + "Internet", + "Spotify", + "ChatGPT Plus", + ]; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + await FlowLocalizations(const Locale("en")).load(); + + obx = await ObjectBox.initialize( + customDirectory: directory, + subdirectory: "insights", + ); + + categories = await obx.box<Category>().putAndGetManyAsync( + getCategoryPresets().map((category) => category..id = 0).toList(), + ); + accounts = await obx.box<Account>().putAndGetManyAsync([ + ...getAccountPresets("USD").map((account) => account..id = 0), + Account.preset( + name: "Credit Card", + currency: "USD", + iconCode: "credit_card", + uuid: "credit-card", + type: AccountType.creditLineValue, + creditLimit: 5000, + excludeFromTotalBalance: true, + )..id = 0, + ]); + }); + + tearDownAll(() async { + await testCleanupObject( + instance: obx, + directory: path.join(directory, "insights"), + ); + }); + + List<Transaction> generate(int seed) { + final [main, cash, savings, creditCard] = accounts; + + return DemoDataGenerator( + main: main, + cash: cash, + savings: savings, + creditCard: creditCard, + categories: categories, + tags: const {}, + random: Random(seed), + now: now, + ).generate(); + } + + /// The last 12 months, each with the history the Stats tab would load. + Map<DateTime, InsightReport> reports(List<Transaction> transactions) { + final List<InsightTransaction> all = insightTransactionsOf( + transactions, + primaryCurrency: "USD", + rates: null, + ).transactions; + + return { + for (int back = 0; back < 12; back++) + DateTime(now.year, now.month - back): computeInsights( + InsightRequest( + transactions: all.where((transaction) { + final DateTime month = DateTime(now.year, now.month - back); + final DateTime from = DateTime( + month.year, + month.month - InsightsService.historyMonths, + ); + + return !transaction.date.isBefore(from) && + transaction.date.isBefore( + DateTime(month.year, month.month + 1), + ); + }).toList(), + month: DateTime(now.year, now.month - back), + now: now, + limit: 9, + ), + ), + }; + } + + test("every monthly bill is charged once a month", () { + final List<Transaction> transactions = generate(1); + + for (int back = 1; back < 36; back++) { + final DateTime month = DateTime(now.year, now.month - back); + + for (final String title in monthlyBills) { + expect( + transactions.where( + (transaction) => + transaction.title == title && + transaction.transactionDate.year == month.year && + transaction.transactionDate.month == month.month, + ), + hasLength(1), + reason: "$title in $month", + ); + } + } + }); + + for (int seed = 1; seed <= 3; seed++) { + group("seed $seed", () { + late String? rentCategory; + late Map<DateTime, InsightReport> byMonth; + + setUpAll(() { + final List<Transaction> transactions = generate(seed); + + rentCategory = transactions + .firstWhere((transaction) => transaction.title == "Rent") + .categoryUuid; + byMonth = reports(transactions); + }); + + test("tracking is only suggested for this month", () { + for (final MapEntry(key: month, value: report) in byMonth.entries) { + final List<RecurringChargeInsight> suggestions = report.insights + .whereType<RecurringChargeInsight>() + .toList(); + + if (month.month != now.month) { + expect(suggestions, isEmpty, reason: "$month"); + continue; + } + + final RecurringSeries rent = suggestions.single.series; + expect(rent.title, "Rent"); + expect(rent.typicalAmount, 1690.0); + expect(rent.occurrences.last.date.month, now.month); + expect(rent.occurrences.last.date.day, 1); + } + }); + + test("regular bills never explain a month", () { + for (final MapEntry(key: month, value: report) in byMonth.entries) { + for (final Insight insight in report.insights) { + final InsightAttribution? attribution = switch (insight) { + MonthPaceInsight pace => pace.attribution, + CategorySpikeInsight spike => spike.attribution, + _ => null, + }; + + expect( + monthlyBills, + isNot(contains(attribution?.title)), + reason: "$month: ${insight.key}", + ); + + if (insight case MonthPaceInsight pace) { + expect( + pace.dominantDriver?.categoryUuid, + isNot(rentCategory), + reason: "$month", + ); + } + } + } + }); + + test("the rent raise shows in its month only", () { + for (final MapEntry(key: month, value: report) in byMonth.entries) { + final List<PriceChangeInsight> changes = report.insights + .whereType<PriceChangeInsight>() + .toList(); + + if (month != DateTime(2025, 10)) { + expect(changes, isEmpty, reason: "$month"); + continue; + } + + expect(changes.single.series.title, "Rent"); + expect(changes.single.previousAmount, 1590.0); + expect(changes.single.currentAmount, 1690.0); + } + }); + + test("every insight is shown once", () { + for (final MapEntry(key: month, value: report) in byMonth.entries) { + expect(report.status, InsightReportStatus.ready, reason: "$month"); + expect( + report.insights.map((insight) => insight.key).toSet(), + hasLength(report.insights.length), + reason: "$month", + ); + } + }); + }); + } +} diff --git a/test/unit/data/date_format_preset_test.dart b/test/unit/data/date_format_preset_test.dart new file mode 100644 index 00000000..8d435130 --- /dev/null +++ b/test/unit/data/date_format_preset_test.dart @@ -0,0 +1,72 @@ +import "package:flow/data/prefs/date_format_preset.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; + +void main() { + final DateTime date = DateTime(2026, 9, 5, 14, 30); + + String format(DateFormatPreset preset, String payload) => Moment( + date, + localization: preset.apply(MomentLocalizations.enUS()), + ).format(payload); + + test("presets format short dates", () { + expect(format(.system, "l"), "9/5/2026"); + expect(format(.iso, "l"), "2026-09-05"); + expect(format(.dayMonthYearSlash, "l"), "05/09/2026"); + expect(format(.monthDayYearSlash, "L"), "09/05/2026"); + expect(format(.dayMonthYearDot, "ll"), "05.09.2026"); + expect(format(.dayMonthNameYear, "ll"), "5 Sep 2026"); + expect(format(.dayMonthNameYear, "LL"), "5 September 2026"); + }); + + test("presets keep the locale's time format", () { + expect(format(.iso, "lll"), "2026-09-05 2:30 PM"); + expect(format(.dayMonthYearDot, "llll"), "Sat, 05.09.2026 2:30 PM"); + expect(format(.iso, "LT"), "2:30 PM"); + }); + + test("calendar falls back to the preset for distant dates", () { + final Moment moment = Moment( + date, + localization: DateFormatPreset.iso.apply(MomentLocalizations.enUS()), + ); + + expect( + moment.calendar(reference: DateTime(2026, 10, 20), omitHours: true), + "2026-09-05", + ); + expect(moment.calendar(reference: DateTime(2026, 9, 6)), startsWith("Y")); + }); + + test("custom ranges use the preset", () { + final MomentLocalization localization = DateFormatPreset.dayMonthYearSlash + .apply(MomentLocalizations.enUS()); + + expect( + CustomTimeRange( + DateTime(2026, 1, 1), + DateTime(2026, 2, 1), + ).format(localization: localization, anchor: DateTime(2026, 9, 25)), + "01/01/2026 - 01/02/2026", + ); + }); + + test("apply does not stack wrappers", () { + final MomentLocalization wrapped = DateFormatPreset.iso.apply( + MomentLocalizations.enUS(), + ); + + expect(DateFormatPreset.system.apply(wrapped).locale, "en_US"); + expect(DateFormatPreset.system.apply(wrapped), isNot(same(wrapped))); + }); + + test("tryParse maps stored values", () { + expect( + DateFormatPreset.tryParse("dmySlash"), + DateFormatPreset.dayMonthYearSlash, + ); + expect(DateFormatPreset.tryParse("nope"), isNull); + expect(DateFormatPreset.tryParse(null), isNull); + }); +} diff --git a/test/unit/data/flow_icon_test.dart b/test/unit/data/flow_icon_test.dart new file mode 100644 index 00000000..b0e9da37 --- /dev/null +++ b/test/unit/data/flow_icon_test.dart @@ -0,0 +1,33 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + test("Legacy brand code points resolve to slugs", () { + final FlowIconData? paypal = FlowIconData.tryParse( + "IconFlowIcon:SimpleIcons,simple_icons,f228", + ); + + expect(paypal, isA<SimpleIconFlowIcon>()); + expect((paypal as SimpleIconFlowIcon).slug, equals("paypal")); + expect(paypal.iconData, isNotNull); + }); + + test("Brands removed upstream don't resolve to another brand", () { + final FlowIconData? amazon = FlowIconData.tryParse( + "IconFlowIcon:SimpleIcons,simple_icons_flow,ea78", + ); + + expect(amazon, isA<SimpleIconFlowIcon>()); + expect((amazon as SimpleIconFlowIcon).iconData, isNull); + }); + + test("Material Symbols stay code point based", () { + final FlowIconData? icon = FlowIconData.tryParse( + "IconFlowIcon:MaterialSymbolsRounded,material_symbols_icons,e5ca", + ); + + expect(icon, isA<IconFlowIcon>()); + expect((icon as IconFlowIcon).iconData.codePoint, equals(0xe5ca)); + expect(icon.iconData.fontPackage, equals("material_symbols_icons_flow")); + }); +} diff --git a/test/unit/data/money_format_test.dart b/test/unit/data/money_format_test.dart new file mode 100644 index 00000000..e5d55dc9 --- /dev/null +++ b/test/unit/data/money_format_test.dart @@ -0,0 +1,40 @@ +import "package:flow/data/money.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:intl/intl.dart"; + +void main() { + setUp(() { + Intl.defaultLocale = "en_US"; + }); + + tearDown(() { + UserPreferencesService().value.hideZeroDecimals = false; + }); + + test("Shows zero decimals by default", () { + expect(Money(3.0, "USD").formatted, equals(r"$3.00")); + expect(Money(1500.0, "JPY").formatted, equals("¥1,500.00")); + expect(Money(1500.0, "KRW").formatted, equals("₩1,500.00")); + expect(Money(1500.0, "TWD").formatted, equals(r"NT$1,500.00")); + }); + + test("Hides zero decimals per amount when enabled", () { + UserPreferencesService().value.hideZeroDecimals = true; + + expect(Money(3.0, "USD").formatted, equals(r"$3")); + expect(Money(1.23, "USD").formatted, equals(r"$1.23")); + expect(Money(1.5, "USD").formatted, equals(r"$1.50")); + expect(Money(1200000.0, "USD").formattedCompact, equals(r"$1.2M")); + }); + + test("Hides zero decimals for any currency when enabled", () { + UserPreferencesService().value.hideZeroDecimals = true; + + expect(Money(1500.0, "JPY").formatted, equals("¥1,500")); + expect(Money(1500.5, "JPY").formatted, equals("¥1,500.50")); + expect(Money(1500.0, "KRW").formatted, equals("₩1,500")); + expect(Money(1500.0, "TWD").formatted, equals(r"NT$1,500")); + expect(Money(2000.0, "MNT").formatted, equals("₮2,000")); + }); +} diff --git a/test/unit/insights/insight_adapter_test.dart b/test/unit/insights/insight_adapter_test.dart new file mode 100644 index 00000000..7b62673d --- /dev/null +++ b/test/unit/insights/insight_adapter_test.dart @@ -0,0 +1,141 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/insights/insight_adapter.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/entity/transaction/extensions/default/recurring.dart"; +import "package:flow/entity/transaction/extensions/default/transfer.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + final ExchangeRates rates = ExchangeRates( + date: DateTime(2026, 9, 25), + baseCurrency: "USD", + rates: const {"usd": 1, "eur": 0.5, "mnt": 3500}, + ); + + int counter = 0; + + Transaction transaction( + double amount, { + String currency = "USD", + String? title, + bool? isPending, + bool? isDeleted, + }) => Transaction( + amount: amount, + currency: currency, + title: title, + isPending: isPending, + uuid: "tx-${counter++}", + transactionDate: DateTime.utc(2026, 9, 10, 12), + )..isDeleted = isDeleted; + + ({List<InsightTransaction> transactions, bool hasMissingRates}) adapt( + List<Transaction> transactions, { + ExchangeRates? withRates, + }) => insightTransactionsOf( + transactions, + primaryCurrency: "USD", + rates: withRates, + ); + + test("converts other currencies to the primary one", () { + final result = adapt([ + transaction(-10.0, currency: "EUR"), + transaction(-7000.0, currency: "MNT"), + transaction(-3.0), + ], withRates: rates); + + expect(result.hasMissingRates, isFalse); + expect(result.transactions.map((item) => item.amount), [-20.0, -2.0, -3.0]); + }); + + test("skips and flags what can't be converted", () { + final withoutRates = adapt([ + transaction(-10.0, currency: "EUR"), + transaction(-3.0), + ]); + final unknownRate = adapt([ + transaction(-10.0, currency: "JPY"), + ], withRates: rates); + + expect(withoutRates.transactions.single.amount, -3.0); + expect(withoutRates.hasMissingRates, isTrue); + expect(unknownRate.transactions, isEmpty); + expect(unknownRate.hasMissingRates, isTrue); + }); + + test("leaves out deleted, pending and transfers", () { + final Transaction transfer = transaction(-50.0) + ..addExtensions([ + Transfer( + uuid: "transfer", + fromAccountUuid: "a", + toAccountUuid: "b", + relatedTransactionUuid: "other", + ), + ]); + + final result = adapt([ + transaction(-1.0, isDeleted: true), + transaction(-2.0, isPending: true), + transfer, + transaction(-4.0, isDeleted: false, isPending: false), + ], withRates: rates); + + expect(result.transactions.single.amount, -4.0); + expect(result.hasMissingRates, isFalse); + }); + + test("carries title, local date and the recurring link", () { + final Transaction generated = transaction(-11.99, title: "Spotify") + ..addExtensions([ + Recurring( + uuid: "recurring-1", + initialTransactionDate: DateTime(2026, 1, 3), + ), + ]); + + final InsightTransaction item = adapt([generated]).transactions.single; + + expect(item.uuid, generated.uuid); + expect(item.title, "Spotify"); + expect(item.recurringUuid, "recurring-1"); + expect(item.date.isUtc, isFalse); + expect(item.date, DateTime.utc(2026, 9, 10, 12).toLocal()); + }); + + test("recurring templates: active, non-transfer, converted", () { + RecurringTransaction recurring( + Transaction template, { + bool disabled = false, + String? transferTo, + }) => RecurringTransaction( + rules: const [], + range: "", + jsonTransactionTemplate: "", + disabled: disabled, + transferToAccountUuid: transferTo, + )..template = template; + + final List<InsightRecurringTemplate> templates = insightTemplatesOf( + [ + recurring(transaction(-15.0, currency: "EUR", title: "Netflix")), + recurring(transaction(-9.0, title: "Old"), disabled: true), + recurring(transaction(-100.0, title: "Savings"), transferTo: "b"), + recurring(transaction(-5.0, currency: "JPY", title: "Unknown")), + RecurringTransaction( + rules: const [], + range: "", + jsonTransactionTemplate: "not json", + ), + ], + primaryCurrency: "USD", + rates: rates, + ); + + expect(templates.single.title, "Netflix"); + expect(templates.single.amount, -30.0); + }); +} diff --git a/test/unit/insights/insight_engine_test.dart b/test/unit/insights/insight_engine_test.dart new file mode 100644 index 00000000..caf512f0 --- /dev/null +++ b/test/unit/insights/insight_engine_test.dart @@ -0,0 +1,1560 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight_engine.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "insight_test_data.dart"; + +/// Closed-month scenarios analyze August 2026 as seen from late September. +final DateTime august = DateTime(2026, 8); +final DateTime september = DateTime(2026, 9); +final DateTime lateSeptember = DateTime(2026, 9, 25, 18); + +/// For suggestions, which only show while their month is in progress. +final DateTime lateAugust = DateTime(2026, 8, 25, 18); + +/// Feb..Jul 2026, the baseline of August. +const List<int> augustBaseline = [2, 3, 4, 5, 6, 7]; + +/// Mar..Aug 2026, the baseline of September. +const List<int> septemberBaseline = [3, 4, 5, 6, 7, 8]; + +List<T> only<T extends Insight>(InsightReport report) => + report.insights.whereType<T>().toList(); + +void main() { + group("personas", () { + test("a steady spender only hears about an untracked subscription", () { + final math.Random random = math.Random(7); + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 8; month++) { + ledger.steadyMonth(2026, month, random); + } + ledger.steadyMonth(2026, 9, random, throughDay: 25); + + final InsightReport closed = ledger.analyze(august, now: lateSeptember); + expect(closed.status, InsightReportStatus.ready); + expect(closed.insights, isEmpty); + + final InsightReport report = ledger.analyze( + september, + now: lateSeptember, + ); + expect(report.status, InsightReportStatus.ready); + expect(report.insights, hasLength(1)); + + final RecurringChargeInsight insight = + report.insights.single as RecurringChargeInsight; + expect(insight.series.title, "Spotify"); + expect(insight.series.anchorDay, 3); + expect(insight.series.cadence, RecurringCadence.monthly); + }); + + test("a traveler's flight is named, and the month is fine without it", () { + final math.Random random = math.Random(3); + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 8; month++) { + ledger.steadyMonth(2026, month, random); + } + ledger.steadyMonth(2026, 9, random, throughDay: 25); + final InsightTransaction flight = ledger.spend( + DateTime(2026, 9, 12, 8), + 1500.0, + title: "Flight to Tokyo", + category: "travel", + ); + + final InsightReport report = ledger.analyze( + september, + now: lateSeptember, + ); + + final MonthPaceInsight pace = report.insights.first as MonthPaceInsight; + + expect(pace.direction, InsightDirection.above); + expect(pace.throughDay, 25); + expect(pace.ratio, greaterThan(1.7)); + expect(pace.attribution?.transactionUuid, flight.uuid); + expect(pace.attribution?.title, "Flight to Tokyo"); + expect(pace.ratioWithoutAttribution, lessThan(1.1)); + expect(pace.dominantDriver?.categoryUuid, "travel"); + expect(pace.transactionUuids.first, flight.uuid); + expect(pace.history, hasLength(6)); + expect(pace.history.first.month, DateTime(2026, 3)); + + // "First travel spend" would only repeat the same flight. + expect(only<NewCategoryInsight>(report), isEmpty); + }); + + test("a freelancer's irregular income changes nothing", () { + final math.Random random = math.Random(11); + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 9; month++) { + ledger.steadyMonth(2026, month, random, throughDay: 25); + for (int i = 0; i < random.nextInt(4); i++) { + ledger.earn( + DateTime(2026, month, 1 + random.nextInt(25)), + 500.0 + random.nextDouble() * 6000, + title: "Client ${random.nextInt(5)}", + category: "freelance", + ); + } + } + + final InsightReport report = ledger.analyze( + september, + now: lateSeptember, + ); + + expect(report.status, InsightReportStatus.ready); + expect( + report.insights.where((insight) => insight is! RecurringChargeInsight), + isEmpty, + ); + }); + + test("a new user with two months hears nothing", () { + final math.Random random = math.Random(5); + final TestLedger ledger = TestLedger(); + + ledger.steadyMonth(2026, 7, random); + ledger.steadyMonth(2026, 8, random); + ledger.steadyMonth(2026, 9, random, throughDay: 25); + ledger.spend(DateTime(2026, 9, 12), 5000.0, title: "Laptop"); + + final InsightReport report = ledger.analyze( + september, + now: lateSeptember, + ); + + expect(report.status, InsightReportStatus.notEnoughHistory); + expect(report.insights, isEmpty); + expect(report.usualMonthTotal, isNull); + }); + + test("three good months are enough", () { + final TestLedger ledger = TestLedger(); + + for (final int month in [5, 6, 7]) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 20), 2000.0); + + expect( + ledger.analyze(august, now: lateSeptember).status, + InsightReportStatus.ready, + ); + }); + + test("sparse months don't count toward history", () { + final TestLedger ledger = TestLedger(); + + ledger.flatMonth(2026, 5); + ledger.flatMonth(2026, 6); + ledger.flatMonth(2026, 7, count: 9, each: 111.0); + ledger.flatMonth(2026, 8); + + expect( + ledger.analyze(august, now: lateSeptember).status, + InsightReportStatus.notEnoughHistory, + ); + }); + + test("a subscription paid abroad, converted at drifting rates, " + "isn't a price change", () { + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 8; month++) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + final double rate = 1.08 + 0.03 * math.sin(month.toDouble()); + ledger.spend( + DateTime(2026, month, 15), + 12.99 * rate, + title: "Netflix", + category: "subscriptions", + ); + } + + final InsightReport report = ledger.analyze(august, now: lateAugust); + + expect(only<PriceChangeInsight>(report), isEmpty); + expect( + only<RecurringChargeInsight>(report).single.series.isFixedPrice, + isTrue, + ); + }); + }); + + group("month pace thresholds", () { + /// 20 × 50 a month: usual 1000, floor max(50, 150) = 150. + TestLedger smallFloor() { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 20, each: 50.0); + } + return ledger; + } + + /// 10 × 100 a month: usual 1000, floor max(50, 300) = 300. + TestLedger bigFloor() { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + return ledger; + } + + test("just under 1.25× stays quiet", () { + final TestLedger ledger = smallFloor() + ..flatMonth(2026, 8, count: 20, each: 50.0) + ..spend(DateTime(2026, 8, 20), 249.0); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("1.25× fires", () { + final TestLedger ledger = smallFloor() + ..flatMonth(2026, 8, count: 20, each: 50.0) + ..spend(DateTime(2026, 8, 20), 250.0); + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + final MonthPaceInsight pace = only<MonthPaceInsight>(report).single; + + expect(report.usualMonthTotal, 1000.0); + expect(pace.direction, InsightDirection.above); + expect(pace.ratio, 1.25); + expect(pace.stake, 250.0); + expect(pace.score, 0.25); + expect(pace.throughDay, isNull); + }); + + test("0.80× fires as below, 0.801× doesn't", () { + final TestLedger below = smallFloor() + ..flatMonth(2026, 8, count: 16, each: 50.0); + final TestLedger almost = smallFloor() + ..flatMonth(2026, 8, count: 16, each: 50.0) + ..spend(DateTime(2026, 8, 30), 1.0); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + below.analyze(august, now: lateSeptember), + ).single; + + expect(pace.direction, InsightDirection.below); + expect(pace.attribution, isNull); + expect(pace.drivers.single.categoryUuid, "misc"); + expect(pace.drivers.single.delta, -200.0); + expect(almost.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("a ratio over 1.25 still needs the absolute floor", () { + final TestLedger under = bigFloor() + ..flatMonth(2026, 8) + ..spend(DateTime(2026, 8, 20), 299.0); + final TestLedger over = bigFloor() + ..flatMonth(2026, 8) + ..spend(DateTime(2026, 8, 20), 300.0); + + expect(under.analyze(august, now: lateSeptember).insights, isEmpty); + expect( + only<MonthPaceInsight>(over.analyze(august, now: lateSeptember)), + hasLength(1), + ); + }); + + test("nothing fires before the 7th", () { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 9, throughDay: 6); + ledger.spend(DateTime(2026, 9, 2), 2000.0, title: "Sofa"); + + final InsightReport early = ledger.analyze( + september, + now: DateTime(2026, 9, 6, 23, 59), + ); + final InsightReport onTime = ledger.analyze( + september, + now: DateTime(2026, 9, 7, 0, 1), + ); + + expect(early.status, InsightReportStatus.tooEarly); + expect(early.insights, isEmpty); + expect(onTime.status, InsightReportStatus.ready); + expect(only<MonthPaceInsight>(onTime).single.throughDay, 7); + }); + + test("an in-progress month is compared with the same day before", () { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month); + } + // Days 1, 4, 7, 10, 13 so far: exactly the usual pace. + ledger.flatMonth(2026, 9, throughDay: 15); + + final InsightReport report = ledger.analyze( + september, + now: DateTime(2026, 9, 15, 12), + ); + + expect(report.insights, isEmpty); + expect(report.throughDay, 15); + expect(report.isInProgress, isTrue); + }); + + test("a spike explained by one purchase says what it'd be without it", () { + final TestLedger ledger = bigFloor()..flatMonth(2026, 8); + final InsightTransaction tv = ledger.spend( + DateTime(2026, 8, 9), + 900.0, + title: "TV", + ); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(pace.attribution?.transactionUuid, tv.uuid); + expect(pace.attribution?.amount, 900.0); + expect(pace.currentWithoutAttribution, 1000.0); + expect(pace.ratioWithoutAttribution, 1.0); + }); + + test("many small increases aren't pinned on one purchase", () { + final TestLedger ledger = bigFloor() + ..flatMonth(2026, 8, count: 18, each: 100.0); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(pace.attribution, isNull); + expect(pace.ratioWithoutAttribution, isNull); + }); + }); + + group("categories", () { + /// misc 30 × 30 plus dining [diningEach] × [diningCount] each month. + TestLedger withDining({int diningCount = 4, double diningEach = 50.0}) { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + ledger.flatMonth( + 2026, + month, + count: diningCount, + each: diningEach, + category: "dining", + ); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + return ledger; + } + + test("1.5× the usual fires, just under doesn't", () { + final TestLedger spiking = withDining() + ..flatMonth(2026, 8, count: 6, each: 50.0, category: "dining"); + final TestLedger almost = withDining() + ..flatMonth(2026, 8, count: 5, each: 50.0, category: "dining") + ..spend(DateTime(2026, 8, 30), 49.0, category: "dining"); + + final CategorySpikeInsight spike = only<CategorySpikeInsight>( + spiking.analyze(august, now: lateSeptember), + ).single; + + expect(spike.categoryUuid, "dining"); + expect(spike.ratio, 1.5); + expect(spike.usual, 200.0); + expect(spike.entryCount, 6); + expect(spike.history.map((value) => value.amount), everyElement(200.0)); + expect(almost.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("the absolute floor applies to categories too", () { + // Usual month 1000, median expense 30: floor 90. + final TestLedger under = withDining(diningCount: 2) + ..flatMonth(2026, 8, count: 2, each: 60.0, category: "dining") + ..spend(DateTime(2026, 8, 30), 69.0, category: "dining"); + final TestLedger over = withDining(diningCount: 2) + ..flatMonth(2026, 8, count: 2, each: 60.0, category: "dining") + ..spend(DateTime(2026, 8, 30), 70.0, category: "dining"); + + expect( + only<CategorySpikeInsight>(under.analyze(august, now: lateSeptember)), + isEmpty, + ); + expect( + only<CategorySpikeInsight>(over.analyze(august, now: lateSeptember)), + hasLength(1), + ); + }); + + test("fewer than three entries can't spike", () { + final TestLedger ledger = withDining() + ..flatMonth(2026, 8, count: 2, each: 250.0, category: "dining"); + + expect( + only<CategorySpikeInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + + test("a category seen in only two months has no usual", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + } + ledger.flatMonth(2026, 3, count: 3, each: 20.0, category: "gifts"); + ledger.flatMonth(2026, 6, count: 3, each: 20.0, category: "gifts"); + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 4, each: 60.0, category: "gifts"); + + expect( + only<CategorySpikeInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + + test("months without the category don't drag its usual down", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + } + for (final int month in [3, 5, 7]) { + ledger.flatMonth(2026, month, count: 3, each: 60.0, category: "hobby"); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 3, each: 60.0, category: "hobby"); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("uncategorized spend never spikes", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + for (int i = 0; i < 3; i++) { + ledger.spend(DateTime(2026, month, 3 + i), 20.0, category: null); + } + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + for (int i = 0; i < 3; i++) { + ledger.spend(DateTime(2026, 8, 3 + i), 60.0, category: null); + } + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + + expect(only<CategorySpikeInsight>(report), isEmpty); + expect(only<NewCategoryInsight>(report), isEmpty); + }); + + test("at most two category spikes, ties broken by key", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + for (final String category in ["c", "b", "a"]) { + ledger.flatMonth( + 2026, + month, + count: 3, + each: 20.0, + category: category, + ); + } + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + for (final String category in ["c", "b", "a"]) { + ledger.flatMonth(2026, 8, count: 3, each: 70.0, category: category); + } + + final InsightReport report = ledger.analyze( + august, + now: lateSeptember, + limit: 10, + ); + + expect(report.insights.map((insight) => insight.key), [ + "monthPace:above", + "categorySpike:a", + "categorySpike:b", + ]); + }); + + group("drops", () { + /// misc 30 × 20 and groceries 4 × 100: usual 1000, floor 60. + TestLedger withGroceries() { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 20.0); + ledger.flatMonth( + 2026, + month, + count: 4, + each: 100.0, + category: "groceries", + ); + } + return ledger; + } + + test("0.6× of a regular category fires", () { + final TestLedger ledger = withGroceries() + ..flatMonth(2026, 8, count: 40, each: 20.0) + ..flatMonth(2026, 8, count: 2, each: 100.0, category: "groceries") + ..spend(DateTime(2026, 8, 28), 40.0, category: "groceries"); + + final CategoryDropInsight drop = only<CategoryDropInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(drop.categoryUuid, "groceries"); + expect(drop.ratio, 0.6); + expect(drop.stake, 160.0); + }); + + test("just above 0.6× doesn't", () { + final TestLedger ledger = withGroceries() + ..flatMonth(2026, 8, count: 40, each: 20.0) + ..flatMonth(2026, 8, count: 2, each: 100.0, category: "groceries") + ..spend(DateTime(2026, 8, 28), 41.0, category: "groceries"); + + expect( + only<CategoryDropInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + + test("waits until the 20th in a month in progress", () { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 20.0); + ledger.flatMonth( + 2026, + month, + count: 4, + each: 100.0, + category: "groceries", + ); + } + // Twice the usual misc so the month as a whole isn't low. + ledger.flatMonth(2026, 9, count: 60, each: 20.0, throughDay: 20); + + final InsightReport early = ledger.analyze( + september, + now: DateTime(2026, 9, 19, 20), + ); + final InsightReport late = ledger.analyze( + september, + now: DateTime(2026, 9, 20, 20), + ); + + expect(only<CategoryDropInsight>(early), isEmpty); + + final CategoryDropInsight drop = only<CategoryDropInsight>(late).single; + expect(drop.current, 0.0); + expect(drop.throughDay, 20); + // Days 1, 8 and 16 of earlier months, not the full month. + expect(drop.usual, 300.0); + }); + + test("a small category can't drop", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + ledger.flatMonth(2026, month, count: 2, each: 20.0, category: "tea"); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + + expect( + only<CategoryDropInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + }); + + test("spend in a brand-new category above the floor", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 3, each: 40.0, category: "pets"); + + final NewCategoryInsight insight = only<NewCategoryInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(insight.categoryUuid, "pets"); + expect(insight.current, 120.0); + expect(insight.entryCount, 3); + }); + + test("a brand-new category under the floor stays quiet", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 2, each: 40.0, category: "pets"); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + }); + + group("dates", () { + test("comparisons cap at the end of shorter months", () { + final TestLedger ledger = TestLedger(); + for (int month = 9; month <= 14; month++) { + ledger.flatMonth(2025, month); + } + ledger.flatMonth(2026, 3); + ledger.spend(DateTime(2026, 3, 20), 1000.0, title: "Bike"); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(DateTime(2026, 3), now: DateTime(2026, 3, 31, 22)), + ).single; + + expect(pace.history.map((value) => value.month), [ + DateTime(2025, 9), + DateTime(2025, 10), + DateTime(2025, 11), + DateTime(2025, 12), + DateTime(2026, 1), + DateTime(2026, 2), + ]); + // Through the 31st: every month counts in full, February included. + expect(pace.history.map((value) => value.amount), everyElement(1000.0)); + expect(pace.throughDay, 31); + }); + + test("mid-month, earlier months count up to the same day", () { + final TestLedger ledger = TestLedger(); + for (int month = 9; month <= 14; month++) { + ledger.flatMonth(2025, month); + } + ledger.flatMonth(2026, 3, throughDay: 15); + ledger.spend(DateTime(2026, 3, 14), 1000.0, title: "Bike"); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(DateTime(2026, 3), now: DateTime(2026, 3, 15, 9)), + ).single; + + expect(pace.usual, 500.0); + expect(pace.current, 1500.0); + }); + + test("an expense late on the last evening belongs to that month", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 31, 23, 59, 59), 900.0); + + expect( + only<MonthPaceInsight>( + ledger.analyze(august, now: lateSeptember), + ).single.current, + 1900.0, + ); + }); + + test("rent paid a day early doesn't make months look odd", () { + final TestLedger ledger = TestLedger(); + for (int month = 7; month <= 15; month++) { + ledger.flatMonth(2025, month); + } + for (int month = 7; month <= 15; month++) { + // February's rent goes out on January 31st. + final DateTime date = month == 14 + ? DateTime(2026, 1, 31) + : DateTime(2025, month, 1); + ledger.spend(date, 1200.0, title: "Rent", category: "housing"); + } + + final DateTime now = DateTime(2026, 4, 10); + + for (final DateTime month in [DateTime(2026, 1), DateTime(2026, 2)]) { + expect( + only<MonthPaceInsight>(ledger.analyze(month, now: now)), + isEmpty, + reason: "$month", + ); + } + }); + + test("rent paid earlier than usual this month isn't overspending", () { + TestLedger build({required String title}) { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month); + ledger.spend( + DateTime(2026, month, 28), + 1200.0, + title: "Rent", + category: "housing", + ); + } + ledger.flatMonth(2026, 9, throughDay: 25); + ledger.spend(DateTime(2026, 9, 20), 1200.0, title: title); + return ledger; + } + + expect( + only<MonthPaceInsight>( + build(title: "Rent").analyze(september, now: lateSeptember), + ), + isEmpty, + ); + + // The same amount as a one-off is real overspending. + expect( + only<MonthPaceInsight>( + build(title: "Sofa").analyze(september, now: lateSeptember), + ).single.direction, + InsightDirection.above, + ); + }); + + test("rent that isn't due yet doesn't make the month look low", () { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month, count: 6, each: 50.0); + ledger.flatMonth(2026, month, count: 6, each: 50.0, category: "food"); + ledger.spend( + DateTime(2026, month, 28), + 1200.0, + title: "Rent", + category: "housing", + ); + } + ledger.flatMonth(2026, 9, count: 6, each: 50.0, throughDay: 25); + ledger.flatMonth( + 2026, + 9, + count: 6, + each: 50.0, + category: "food", + throughDay: 25, + ); + + expect(ledger.analyze(september, now: lateSeptember).insights, isEmpty); + }); + + test("a yearly charge seen last year isn't unusual", () { + TestLedger build({required bool paidLastYear}) { + final TestLedger ledger = TestLedger(); + for (int month = 1; month <= 20; month++) { + ledger.flatMonth(2025, month); + } + if (paidLastYear) { + ledger.spend( + DateTime(2025, 8, 14), + 1500.0, + title: "Car insurance", + category: "insurance", + ); + } + ledger.spend( + DateTime(2026, 8, 14), + 1500.0, + title: "Car insurance", + category: "insurance", + ); + return ledger; + } + + final InsightReport repeated = build( + paidLastYear: true, + ).analyze(august, now: lateAugust); + + expect(only<MonthPaceInsight>(repeated), isEmpty); + expect(only<NewCategoryInsight>(repeated), isEmpty); + expect( + only<RecurringChargeInsight>(repeated).single.series.cadence, + RecurringCadence.yearly, + ); + expect( + only<MonthPaceInsight>( + build(paidLastYear: false).analyze(august, now: lateAugust), + ), + hasLength(1), + ); + }); + }); + + group("robustness", () { + test("runs through compute(), on another isolate", () async { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 10), 1000.0, title: "Sofa"); + + final InsightReport report = await compute( + computeInsights, + InsightRequest( + transactions: ledger.transactions, + month: august, + now: lateSeptember, + hiddenTypes: const {InsightType.priceChange}, + ), + ); + + expect(report.insights.single, isA<MonthPaceInsight>()); + }); + + test("no data at all", () { + final InsightReport report = TestLedger().analyze( + september, + now: lateSeptember, + ); + + expect(report.status, InsightReportStatus.notEnoughHistory); + expect(report.insights, isEmpty); + expect(report.month, september); + }); + + test("only income", () { + final TestLedger ledger = TestLedger(); + for (int month = 1; month <= 9; month++) { + for (int i = 0; i < 12; i++) { + ledger.earn(DateTime(2026, month, 1 + i), 1000.0); + } + } + + expect( + ledger.analyze(september, now: lateSeptember).status, + InsightReportStatus.notEnoughHistory, + ); + }); + + test("a future month says so", () { + expect( + TestLedger().analyze(DateTime(2026, 10), now: lateSeptember).status, + InsightReportStatus.futureMonth, + ); + }); + + test("transfers, pending and zero amounts are ignored", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 5), 5000.0, isTransfer: true); + ledger.spend(DateTime(2026, 8, 6), 5000.0, isPending: true); + ledger.spend(DateTime(2026, 8, 7), 0.0); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("a refunded purchase isn't spend", () { + TestLedger build({double? refund}) { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 3), 600.0, title: "Jacket"); + if (refund != null) { + ledger.earn( + DateTime(2026, 8, 12), + refund, + title: "JACKET", + category: "misc", + ); + } + return ledger; + } + + expect( + build(refund: 600.0).analyze(august, now: lateSeptember).insights, + isEmpty, + ); + expect( + only<MonthPaceInsight>( + build().analyze(august, now: lateSeptember), + ).single.current, + 1600.0, + ); + expect( + only<MonthPaceInsight>( + build(refund: 300.0).analyze(august, now: lateSeptember), + ), + hasLength(1), + ); + }); + + test("a refund before the purchase doesn't cancel it", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.earn(DateTime(2026, 8, 1), 600.0, title: "Jacket"); + ledger.spend(DateTime(2026, 8, 3), 600.0, title: "Jacket"); + + expect( + only<MonthPaceInsight>(ledger.analyze(august, now: lateSeptember)), + hasLength(1), + ); + }); + + test("a huge outlier in the baseline doesn't make a normal month low", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.spend(DateTime(2026, 4, 10), 50000.0, title: "Car"); + ledger.flatMonth(2026, 8); + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + + expect(report.usualMonthTotal, 1000.0); + expect(report.insights, isEmpty); + }); + + test("a huge outlier this month is named, with finite numbers", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 10), 1e9, title: "Yacht"); + + final MonthPaceInsight pace = + ledger.analyze(august, now: lateSeptember).insights.first + as MonthPaceInsight; + + expect(pace.attribution?.title, "Yacht"); + expect(pace.ratioWithoutAttribution, 1.0); + expect(numbersOf(pace).every((number) => number.isFinite), isTrue); + }); + + test("non-finite amounts are skipped", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 10), double.infinity); + ledger.spend(DateTime(2026, 8, 11), double.nan); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + }); + + group("recurring charges", () { + /// 30 × 30 a month (floor 90) plus [title] on the [day]th. + TestLedger withCharge( + String title, + List<double> amounts, { + int day = 15, + String? recurringUuid, + }) { + final TestLedger ledger = TestLedger(); + for (int i = 0; i < amounts.length; i++) { + final int month = 9 - amounts.length + i; + ledger.flatMonth(2026, month, count: 30, each: 30.0); + ledger.spend( + DateTime(2026, month, day), + amounts[i], + title: title, + category: "subscriptions", + recurringUuid: recurringUuid, + ); + } + return ledger; + } + + test("an untracked monthly charge is suggested once charged", () { + final TestLedger ledger = withCharge("Netflix", List.filled(6, 15.49)); + + final RecurringChargeInsight insight = only<RecurringChargeInsight>( + ledger.analyze(august, now: lateAugust), + ).single; + + expect(insight.key, "recurringCharge:title:netflix"); + expect(insight.stake, moreOrLessEquals(15.49 * 12)); + expect(insight.series.occurrences, hasLength(6)); + expect(insight.series.categoryUuid, "subscriptions"); + expect(insight.transactionUuids, hasLength(6)); + }); + + test("not before this month's charge", () { + final TestLedger ledger = withCharge("Netflix", List.filled(6, 15.49)); + ledger.flatMonth(2026, 9, count: 30, each: 30.0, throughDay: 10); + + expect( + only<RecurringChargeInsight>( + ledger.analyze(september, now: DateTime(2026, 9, 10)), + ), + isEmpty, + ); + }); + + test("already tracked charges aren't suggested", () { + final TestLedger generated = withCharge( + "Netflix", + List.filled(6, 15.49), + recurringUuid: "r-netflix", + ); + final TestLedger manual = withCharge("Netflix", List.filled(6, 15.49)); + + expect(generated.analyze(august, now: lateAugust).insights, isEmpty); + expect( + manual + .analyze( + august, + now: lateAugust, + templates: const [ + InsightRecurringTemplate(title: "Netflix", amount: 15.49), + ], + ) + .insights, + isEmpty, + ); + }); + + test("cheap charges under the floor aren't suggested", () { + final TestLedger ledger = withCharge("App", List.filled(6, 2.99)); + + expect(ledger.analyze(august, now: lateAugust).insights, isEmpty); + }); + + test("a steady variable bill is suggested", () { + final TestLedger ledger = withCharge("Electricity", [ + 82.0, + 140.0, + 95.0, + 61.0, + 120.0, + 88.0, + ]); + + final RecurringChargeInsight insight = only<RecurringChargeInsight>( + ledger.analyze(august, now: lateAugust), + ).single; + + expect(insight.series.isFixedPrice, isFalse); + }); + + test("an erratic variable bill isn't suggested", () { + final TestLedger ledger = withCharge("Electricity", [ + 50.0, + 140.0, + 60.0, + 130.0, + 45.0, + 120.0, + ]); + + expect( + only<RecurringChargeInsight>(ledger.analyze(august, now: lateAugust)), + isEmpty, + ); + }); + + test("a variable bill needs 4 charges to be suggested", () { + final TestLedger ledger = withCharge("Electricity", [82.0, 140.0, 95.0]); + + expect( + only<RecurringChargeInsight>(ledger.analyze(august, now: lateAugust)), + isEmpty, + ); + }); + + test("a variable template tracks the bill regardless of amount", () { + final TestLedger ledger = withCharge("Electricity", [ + 82.0, + 140.0, + 95.0, + 61.0, + 120.0, + 88.0, + ]); + + expect( + only<RecurringChargeInsight>( + ledger.analyze( + august, + now: lateAugust, + templates: const [ + InsightRecurringTemplate( + title: "Electricity", + amount: 300.0, + variableAmount: true, + ), + ], + ), + ), + isEmpty, + ); + }); + + test("only the biggest suggestion per month", () { + final TestLedger ledger = withCharge("Netflix", List.filled(6, 15.49)); + for (int month = 3; month <= 8; month++) { + ledger.spend(DateTime(2026, month, 3), 11.99, title: "Spotify"); + } + + final List<RecurringChargeInsight> suggestions = + only<RecurringChargeInsight>( + ledger.analyze(august, now: lateAugust, limit: 10), + ); + + expect(suggestions.single.series.title, "Netflix"); + }); + + test("a price change shows in its month, then the suggestion returns", () { + final TestLedger ledger = withCharge("Netflix", [ + 15.49, + 15.49, + 15.49, + 15.49, + 15.49, + 17.99, + ]); + + final InsightReport changed = ledger.analyze(august, now: lateSeptember); + final PriceChangeInsight change = only<PriceChangeInsight>( + changed, + ).single; + + expect(change.previousAmount, 15.49); + expect(change.currentAmount, 17.99); + expect(change.stake, moreOrLessEquals(2.5 * 12)); + expect(change.changedOn, DateTime(2026, 8, 15)); + expect(only<RecurringChargeInsight>(changed), isEmpty); + + ledger.flatMonth(2026, 9, count: 30, each: 30.0, throughDay: 25); + ledger.spend(DateTime(2026, 9, 15), 17.99, title: "Netflix"); + + final InsightReport next = ledger.analyze(september, now: lateSeptember); + expect(only<PriceChangeInsight>(next), isEmpty); + expect(only<RecurringChargeInsight>(next), hasLength(1)); + }); + + test("tracked series still report price changes", () { + final TestLedger ledger = withCharge("Netflix", [ + 15.49, + 15.49, + 15.49, + 17.99, + ], recurringUuid: "r-netflix"); + + expect( + only<PriceChangeInsight>(ledger.analyze(august, now: lateSeptember)), + hasLength(1), + ); + }); + + test("tiny price moves aren't changes", () { + // 6% but only 0.60, and 2% of 100. + for (final List<double> amounts in [ + [10.0, 10.0, 10.0, 10.6], + [100.0, 100.0, 100.0, 102.0], + ]) { + expect( + only<PriceChangeInsight>( + withCharge("Gym", amounts).analyze(august, now: lateSeptember), + ), + isEmpty, + reason: "$amounts", + ); + } + }); + + test("a charge at the old price once isn't enough history", () { + final TestLedger ledger = withCharge("Gym", [30.0, 30.0, 35.0]); + + expect( + only<PriceChangeInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + + test("a yearly subscription is suggested in its month", () { + final TestLedger ledger = TestLedger(); + for (int month = 1; month <= 20; month++) { + ledger.flatMonth(2025, month, count: 30, each: 30.0); + } + ledger.spend(DateTime(2025, 8, 20), 139.0, title: "Prime"); + ledger.spend(DateTime(2026, 8, 20), 139.0, title: "Prime"); + + final InsightReport report = ledger.analyze(august, now: lateAugust); + final RecurringChargeInsight insight = + report.insights.single as RecurringChargeInsight; + + expect(insight.series.cadence, RecurringCadence.yearly); + expect(insight.stake, 139.0); + }); + + test("suggestions still show before the 7th", () { + final TestLedger ledger = withCharge( + "Netflix", + List.filled(6, 15.49), + day: 3, + ); + ledger.flatMonth(2026, 9, count: 30, each: 30.0, throughDay: 4); + ledger.spend(DateTime(2026, 9, 3), 15.49, title: "Netflix"); + + final InsightReport report = ledger.analyze( + september, + now: DateTime(2026, 9, 4), + ); + + expect(report.status, InsightReportStatus.tooEarly); + expect(only<RecurringChargeInsight>(report), hasLength(1)); + }); + }); + + group("ranking", () { + /// Dining makes up the whole month's jump. + TestLedger diningExplainsMonth() { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + ledger.flatMonth(2026, month, count: 4, each: 25.0, category: "dining"); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 4, each: 25.0, category: "dining"); + ledger.flatMonth(2026, 8, count: 5, each: 80.0, category: "dining"); + return ledger; + } + + test("a category spike that explains the month folds into it", () { + final InsightReport report = diningExplainsMonth().analyze( + august, + now: lateSeptember, + ); + + final MonthPaceInsight pace = report.insights.single as MonthPaceInsight; + expect(pace.dominantDriver?.categoryUuid, "dining"); + expect(pace.dominantDriver?.delta, 400.0); + }); + + test("hiding a type lets the next one through", () { + final InsightReport report = diningExplainsMonth().analyze( + august, + now: lateSeptember, + hiddenTypes: {InsightType.monthPace}, + ); + + expect(report.insights.single, isA<CategorySpikeInsight>()); + }); + + test("never more than the limit, highest stake first", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + for (final String category in ["a", "b", "c", "d"]) { + ledger.flatMonth( + 2026, + month, + count: 3, + each: 20.0, + category: category, + ); + } + ledger.spend(DateTime(2026, month, 3), 30.0, title: "Gym"); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 3, each: 90.0, category: "a"); + ledger.flatMonth(2026, 8, count: 3, each: 70.0, category: "b"); + ledger.flatMonth(2026, 8, count: 3, each: 60.0, category: "c"); + ledger.flatMonth(2026, 8, count: 3, each: 20.0, category: "d"); + ledger.flatMonth(2026, 8, count: 3, each: 50.0, category: "pets"); + ledger.spend(DateTime(2026, 8, 3), 30.0, title: "Gym"); + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + final List<double> scores = report.insights + .map((insight) => insight.score) + .toList(); + + expect(report.insights, hasLength(3)); + expect( + scores, + orderedEquals([...scores]..sort((a, b) => b.compareTo(a))), + ); + expect(report.insights.first, isA<MonthPaceInsight>()); + + final InsightReport all = ledger.analyze( + august, + now: lateSeptember, + limit: 10, + ); + expect(all.insights.length, greaterThan(3)); + expect(all.insights.take(3).map((insight) => insight.key), [ + for (final Insight insight in report.insights) insight.key, + ]); + }); + + test("the same input in any order gives the same output", () { + final math.Random random = math.Random(42); + final TestLedger ledger = TestLedger(); + for (int month = 1; month <= 8; month++) { + ledger.steadyMonth(2026, month, random); + } + ledger.steadyMonth(2026, 9, random, throughDay: 25); + ledger.spend(DateTime(2026, 9, 12), 1500.0, title: "Flight"); + ledger.flatMonth(2026, 9, count: 4, each: 90.0, category: "dining"); + + String fingerprint(List<InsightTransaction> transactions) => + computeInsights( + InsightRequest( + transactions: transactions, + month: september, + now: lateSeptember, + limit: 10, + ), + ).insights + .map((insight) => "${insight.key}@${insight.score}") + .join(","); + + final String expected = fingerprint(ledger.transactions); + + for (int seed = 0; seed < 5; seed++) { + expect( + fingerprint([...ledger.transactions]..shuffle(math.Random(seed))), + expected, + ); + } + }); + + group("cooldown", () { + TestLedger overspending() { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 9, throughDay: 20); + ledger.spend(DateTime(2026, 9, 10), 1000.0, title: "Sofa"); + return ledger; + } + + final DateTime now = DateTime(2026, 9, 20); + + List<Insight> seen(DateTime shownAt, double stake) => overspending() + .analyze( + september, + now: now, + sightings: [ + InsightSighting( + key: "monthPace:above", + shownAt: shownAt, + stake: stake, + ), + ], + ) + .insights; + + test("shown last month, recently: suppressed", () { + expect(seen(DateTime(2026, 8, 28), 900.0), isEmpty); + }); + + test("unless it grew by half", () { + expect(seen(DateTime(2026, 8, 28), 600.0), hasLength(1)); + }); + + test("shown this month: stays, so the card is stable", () { + expect(seen(DateTime(2026, 9, 12), 900.0), hasLength(1)); + }); + + test("shown over 28 days ago: back", () { + expect(seen(DateTime(2026, 8, 22), 900.0), hasLength(1)); + }); + + test("closed months ignore it", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 10), 1000.0); + + expect( + ledger + .analyze( + august, + now: DateTime(2026, 9, 3), + sightings: [ + InsightSighting( + key: "monthPace:above", + shownAt: DateTime(2026, 7, 30), + stake: 1000.0, + ), + ], + ) + .insights, + hasLength(1), + ); + }); + }); + }); + + group("demo-like ledger", () { + /// Jul 2024 through [now], rent paid at a different hour each month + /// unless [rentHour] is given. Rent goes from 1,590 to 1,690 in + /// [raisedIn], if any. + TestLedger demo(DateTime now, {int? rentHour, DateTime? raisedIn}) { + final math.Random random = math.Random(11); + final TestLedger ledger = TestLedger(); + const List<int> hours = [7, 12, 19, 21, 23]; + + for (int i = 0; ; i++) { + final DateTime month = DateTime(2024, 7 + i); + if (month.isAfter(now)) break; + + final bool current = month.year == now.year && month.month == now.month; + + ledger.demoMonth( + month.year, + month.month, + random, + rentHour: rentHour ?? hours[i % hours.length], + rent: raisedIn == null || !month.isBefore(raisedIn) ? 1690.0 : 1590.0, + throughDay: current ? now.day : 31, + ); + } + + return ledger; + } + + void trip(TestLedger ledger, int month) { + ledger.spend(DateTime(2026, month, 10, 8), 320.0, title: "Flight"); + for (int day = 10; day < 14; day++) { + ledger.spend(DateTime(2026, month, day, 22), 210.0, title: "Hotel"); + } + ledger.spend(DateTime(2026, month, 14, 18), 320.0, title: "Flight"); + } + + test("suggestions only show while their month is in progress", () { + final TestLedger ledger = demo(lateSeptember); + + for (int month = 10; month <= 20; month++) { + expect( + only<RecurringChargeInsight>( + ledger.analyze(DateTime(2025, month), now: lateSeptember), + ), + isEmpty, + reason: "${DateTime(2025, month)}", + ); + } + + expect( + only<RecurringChargeInsight>( + ledger.analyze(september, now: lateSeptember), + ).single.series.title, + "Rent", + ); + }); + + test("this month's charge is in its series, whatever time it was paid", () { + for (final int hour in [0, 7, 19, 23]) { + for (final DateTime now in [ + lateSeptember, + DateTime(2026, 9, 1, 23, 59, 59), + ]) { + final TestLedger ledger = demo(now, rentHour: hour); + final InsightTransaction rent = ledger.transactions.lastWhere( + (transaction) => transaction.title == "Rent", + ); + + final RecurringChargeInsight insight = only<RecurringChargeInsight>( + ledger.analyze(september, now: now), + ).single; + + expect(rent.date, DateTime(2026, 9, 1, hour, 59), reason: "$now"); + expect(insight.series.occurrences, hasLength(27), reason: "$now"); + expect( + insight.series.occurrences.last.transactionUuid, + rent.uuid, + reason: "$hour:59, $now", + ); + expect(insight.transactionUuids.first, rent.uuid); + } + } + }); + + test("a closed month's series ends with that month", () { + final TestLedger ledger = demo(lateSeptember, raisedIn: august); + + final PriceChangeInsight change = only<PriceChangeInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(change.previousAmount, 1590.0); + expect(change.currentAmount, 1690.0); + expect(change.series.occurrences, hasLength(26)); + expect(change.changedOn.month, 8); + }); + + test("a yearly rent raise shows, even after an earlier one", () { + final TestLedger ledger = TestLedger(); + final math.Random random = math.Random(5); + + for (int i = 0; i < 27; i++) { + final DateTime month = DateTime(2024, 7 + i); + ledger.demoMonth( + month.year, + month.month, + random, + rent: i < 3 + ? 1480.0 + : i < 15 + ? 1590.0 + : 1690.0, + throughDay: i == 26 ? 25 : 31, + ); + } + + final PriceChangeInsight change = only<PriceChangeInsight>( + ledger.analyze(DateTime(2025, 10), now: lateSeptember), + ).single; + expect(change.previousAmount, 1590.0); + expect(change.currentAmount, 1690.0); + + expect( + only<RecurringChargeInsight>( + ledger.analyze(september, now: lateSeptember), + ).single.series.typicalAmount, + 1690.0, + ); + }); + + test("rent never explains an unusual month", () { + for (final DateTime month in [DateTime(2026, 7), september]) { + final TestLedger ledger = demo(lateSeptember); + trip(ledger, month.month); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(month, now: lateSeptember), + ).single; + + expect(pace.direction, InsightDirection.above, reason: "$month"); + expect(pace.attribution, isNull, reason: "$month"); + expect(pace.dominantDriver?.categoryUuid, isNot("rent")); + } + }); + + test("a big one-off purchase still explains it", () { + final TestLedger ledger = demo(lateSeptember); + ledger.spend(DateTime(2026, 7, 12, 20), 1500.0, title: "Laptop"); + + expect( + only<MonthPaceInsight>( + ledger.analyze(DateTime(2026, 7), now: lateSeptember), + ).single.attribution?.title, + "Laptop", + ); + }); + }); +} diff --git a/test/unit/insights/insight_formatter_test.dart b/test/unit/insights/insight_formatter_test.dart new file mode 100644 index 00000000..6f019b9c --- /dev/null +++ b/test/unit/insights/insight_formatter_test.dart @@ -0,0 +1,41 @@ +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + const InsightFormatter format = InsightFormatter(currency: "USD"); + + test("small amounts keep their cents", () { + expect(format.money(11.99), "\$11.99"); + }); + + test("amounts over 100 drop the cents", () { + expect(format.money(1905.4), "\$1,905"); + }); + + test("big amounts are abbreviated unless full amounts are preferred", () { + expect(format.money(12400.0), "\$12.4K"); + expect( + const InsightFormatter( + currency: "USD", + preferFullAmounts: true, + ).money(12400.0), + "\$12,400", + ); + }); + + test("amounts are always positive", () { + expect(format.money(-226.0), "\$226"); + }); + + test("privacy mode hides the digits", () { + expect( + const InsightFormatter(currency: "USD", obscure: true).money(486.0), + "\$***", + ); + }); + + test("percents and ratios", () { + expect(format.percent(-0.274), "27%"); + expect(format.ratio(1.87), "1.9×"); + }); +} diff --git a/test/unit/insights/insight_math_test.dart b/test/unit/insights/insight_math_test.dart new file mode 100644 index 00000000..70a29a18 --- /dev/null +++ b/test/unit/insights/insight_math_test.dart @@ -0,0 +1,169 @@ +import "package:flow/data/insights/insight_math.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("median", () { + test("odd, even and empty", () { + expect(median([3.0, 1.0, 2.0]), 2.0); + expect(median([4.0, 1.0, 3.0, 2.0]), 2.5); + expect(median(<double>[]), 0.0); + }); + + test("one huge value doesn't move it", () { + expect(median([100.0, 110.0, 90.0, 105.0, 50000.0]), 105.0); + }); + }); + + group("coefficientOfVariation", () { + test("constant values have none", () { + expect(coefficientOfVariation([9.99, 9.99, 9.99]), 0.0); + }); + + test("fewer than two values, or a zero mean, read as 0", () { + expect(coefficientOfVariation([5.0]), 0.0); + expect(coefficientOfVariation([0.0, 0.0]), 0.0); + }); + + test("measures spread relative to the mean", () { + expect(coefficientOfVariation([90.0, 110.0]), moreOrLessEquals(0.1)); + }); + }); + + group("months", () { + test("index round-trips across year boundaries", () { + final int december = monthIndexOf(DateTime(2025, 12, 31, 23, 59)); + + expect(monthStartOf(december), DateTime(2025, 12)); + expect(monthStartOf(december + 1), DateTime(2026, 1)); + expect(monthStartOf(december - 12), DateTime(2024, 12)); + }); + + test("days in month know about leap years", () { + expect(daysInMonth(monthIndexOf(DateTime(2026, 2))), 28); + expect(daysInMonth(monthIndexOf(DateTime(2024, 2))), 29); + expect(daysInMonth(monthIndexOf(DateTime(2100, 2))), 28); + expect(daysInMonth(monthIndexOf(DateTime(2000, 2))), 29); + expect(daysInMonth(monthIndexOf(DateTime(2026, 4))), 30); + expect(daysInMonth(monthIndexOf(DateTime(2026, 12))), 31); + }); + + test("charge day clamps to short months", () { + expect(chargeDayOf(monthIndexOf(DateTime(2026, 2)), 31).day, 28); + expect(chargeDayOf(monthIndexOf(DateTime(2024, 2)), 31).day, 29); + expect(chargeDayOf(monthIndexOf(DateTime(2026, 4)), 31).day, 30); + expect(chargeDayOf(monthIndexOf(DateTime(2026, 5)), 31).day, 31); + }); + }); + + group("dayDifference", () { + test("ignores time of day", () { + expect( + dayDifference(DateTime(2026, 3, 1, 23, 59), DateTime(2026, 3, 2)), + 1, + ); + expect( + dayDifference(DateTime(2026, 3, 2), DateTime(2026, 3, 1, 23, 59)), + -1, + ); + }); + + test("isn't fooled by daylight saving transitions", () { + // US and EU spring-forward and fall-back weekends of 2026. + expect(dayDifference(DateTime(2026, 3, 7), DateTime(2026, 3, 9)), 2); + expect(dayDifference(DateTime(2026, 3, 28), DateTime(2026, 3, 30)), 2); + expect(dayDifference(DateTime(2026, 10, 24), DateTime(2026, 10, 26)), 2); + expect(dayDifference(DateTime(2026, 10, 31), DateTime(2026, 11, 2)), 2); + expect(dayDifference(DateTime(2026, 1, 1), DateTime(2027, 1, 1)), 365); + }); + + test("treats UTC and local dates by their calendar fields", () { + expect( + dayDifference(DateTime.utc(2026, 3, 29), DateTime(2026, 3, 30)), + 1, + ); + }); + }); + + group("nearestSlot", () { + test( + "a charge on the 28th of February belongs to February for the 31st", + () { + final ({int slot, int offset}) result = nearestSlot( + DateTime(2026, 2, 28), + 31, + ); + + expect(monthStartOf(result.slot), DateTime(2026, 2)); + expect(result.offset, 0); + }, + ); + + test("a charge paid a day early belongs to the next month", () { + final ({int slot, int offset}) result = nearestSlot( + DateTime(2026, 1, 31), + 1, + ); + + expect(monthStartOf(result.slot), DateTime(2026, 2)); + expect(result.offset, -1); + }); + + test("a charge slipping into the next month stays with its own", () { + final ({int slot, int offset}) result = nearestSlot( + DateTime(2026, 3, 2), + 31, + ); + + expect(monthStartOf(result.slot), DateTime(2026, 2)); + expect(result.offset, 2); + }); + + test("matches the nearest calendar charge day, every day and anchor", () { + for ( + DateTime date = DateTime(2023, 12, 1, 12); + date.isBefore(DateTime(2025, 3, 1)); + date = DateTime(date.year, date.month, date.day + 1, 12) + ) { + for (int anchorDay = 1; anchorDay <= 31; anchorDay++) { + final int index = monthIndexOf(date); + final List<int> offsets = [ + for (int slot = index - 1; slot <= index + 1; slot++) + dayDifference(chargeDayOf(slot, anchorDay), date), + ]; + int best = 0; + for (int i = 1; i < offsets.length; i++) { + if (offsets[i].abs() < offsets[best].abs()) best = i; + } + + expect(nearestSlot(date, anchorDay), ( + slot: index - 1 + best, + offset: offsets[best], + ), reason: "$date, anchor $anchorDay"); + } + } + }); + }); + + group("normalizeTitle", () { + test("lowercases, trims and drops punctuation", () { + expect(normalizeTitle(" Spotify "), "spotify"); + expect(normalizeTitle("SPOTIFY*Premium"), "spotify premium"); + }); + + test("drops tokens with digits, like references and dates", () { + expect(normalizeTitle("NETFLIX #1234"), "netflix"); + expect(normalizeTitle("Rent 2026-09"), "rent"); + }); + + test("keeps non-Latin letters", () { + expect(normalizeTitle("Түрээс"), "түрээс"); + }); + + test("is null when nothing is left", () { + expect(normalizeTitle(null), isNull); + expect(normalizeTitle(""), isNull); + expect(normalizeTitle("12345"), isNull); + expect(normalizeTitle(" -- "), isNull); + }); + }); +} diff --git a/test/unit/insights/insight_performance_test.dart b/test/unit/insights/insight_performance_test.dart new file mode 100644 index 00000000..10f33db7 --- /dev/null +++ b/test/unit/insights/insight_performance_test.dart @@ -0,0 +1,98 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight_engine.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "insight_test_data.dart"; + +void main() { + final DateTime now = DateTime(2026, 9, 25, 15, 30); + + /// USD to EUR, so nothing is a round number. + const double rate = 0.8537; + + const List<String> places = [ + "Corner bakery", + "Green grocer", + "Noodle house", + "Book nook", + "Hardware store", + "Pharmacy", + "Taxi", + "Cinema", + "Florist", + "Pet shop", + "Deli", + "Bike repair", + "Laundromat", + "Tea room", + "Bistro", + "Kiosk", + "Butcher", + "Stationery", + "Car wash", + "Market stall", + ]; + + /// Three years of converted daily spend over many titles, each with its + /// own price range, so most of it goes through recurring detection. + TestLedger heavyLedger() { + final math.Random random = math.Random(7); + final TestLedger ledger = TestLedger(); + + for ( + DateTime day = DateTime(2023, 9, 1); + day.isBefore(now); + day = DateTime(day.year, day.month, day.day + 1) + ) { + DateTime at() => DateTime( + day.year, + day.month, + day.day, + 7 + random.nextInt(15), + random.nextInt(60), + ); + + if (day.day == 1) { + ledger.spend(at(), 1690.0 * rate, title: "Rent", category: "rent"); + } + if (day.day == 9) { + ledger.spend(at(), 11.99 * rate, title: "Spotify", category: "subs"); + } + + for (int i = 0; i < 8; i++) { + final int place = random.nextInt(places.length); + + ledger.spend( + at(), + (5.0 + place * 4) * (0.5 + random.nextDouble() * 1.5) * rate, + title: places[place], + category: "category ${place % 6}", + ); + } + } + + return ledger; + } + + // Took over a second while recurring detection built local DateTimes, + // each a time zone lookup (and ten seconds on an iOS simulator). + test("a heavy ledger computes quickly", () { + final TestLedger ledger = heavyLedger(); + + // Warm up, so JIT compilation isn't measured. + ledger.analyze(DateTime(2026, 8), now: now); + + final Stopwatch stopwatch = Stopwatch()..start(); + final InsightReport complete = ledger.analyze(DateTime(2026, 8), now: now); + final InsightReport inProgress = ledger.analyze( + DateTime(2026, 9), + now: now, + ); + stopwatch.stop(); + + expect(complete.status, InsightReportStatus.ready); + expect(inProgress.status, InsightReportStatus.ready); + expect(stopwatch.elapsedMilliseconds, lessThan(500)); + }); +} diff --git a/test/unit/insights/insight_properties_test.dart b/test/unit/insights/insight_properties_test.dart new file mode 100644 index 00000000..7d0b1e7f --- /dev/null +++ b/test/unit/insights/insight_properties_test.dart @@ -0,0 +1,163 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight_engine.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "insight_test_data.dart"; + +/// Seeded random ledgers: the engine must stay sane whatever it's fed. +void main() { + const List<String?> titles = [ + null, + "Coffee", + "Grocer", + "Spotify", + "Rent", + "Uber", + "Amazon", + "Gym", + ]; + const List<String?> categories = [null, "food", "fun", "bills", "travel"]; + + TestLedger randomLedger(math.Random random, DateTime now) { + final TestLedger ledger = TestLedger(); + final int months = random.nextInt(16); + + for (int back = months; back >= 0; back--) { + final int count = random.nextInt(60); + + for (int i = 0; i < count; i++) { + final DateTime date = DateTime( + now.year, + now.month - back, + 1 + random.nextInt(31), + random.nextInt(24), + random.nextInt(60), + ); + if (date.isAfter(now)) continue; + + final double roll = random.nextDouble(); + final double amount = switch (roll) { + < 0.01 => random.nextDouble() * 1e7, + < 0.03 => 0.0, + _ => math.exp(random.nextDouble() * 7), + }; + + if (random.nextDouble() < 0.1) { + ledger.earn( + date, + amount, + title: titles[random.nextInt(titles.length)], + category: categories[random.nextInt(categories.length)], + ); + } else { + ledger.spend( + date, + amount, + title: titles[random.nextInt(titles.length)], + category: categories[random.nextInt(categories.length)], + isTransfer: random.nextDouble() < 0.03, + isPending: random.nextDouble() < 0.03, + ); + } + } + } + + return ledger; + } + + test("random ledgers: finite numbers, at most 3, ranked, unique", () { + int produced = 0; + + for (int seed = 0; seed < 300; seed++) { + final math.Random random = math.Random(seed); + final DateTime now = DateTime( + 2024 + random.nextInt(3), + 1 + random.nextInt(12), + 1 + random.nextInt(28), + random.nextInt(24), + ); + final TestLedger ledger = randomLedger(random, now); + final DateTime month = DateTime(now.year, now.month - random.nextInt(3)); + + final InsightReport report = ledger.analyze(month, now: now); + + expect(report.insights.length, lessThanOrEqualTo(3), reason: "$seed"); + produced += report.insights.length; + + final Set<String> keys = {}; + double previous = double.infinity; + + for (final Insight insight in report.insights) { + expect( + numbersOf(insight).every((number) => number.isFinite), + isTrue, + reason: "seed $seed: ${insight.key}", + ); + expect(insight.stake, greaterThanOrEqualTo(0.0), reason: "$seed"); + expect(insight.score, lessThanOrEqualTo(previous), reason: "$seed"); + expect(keys.add(insight.key), isTrue, reason: "$seed"); + previous = insight.score; + } + + final double? usual = report.usualMonthTotal; + if (usual != null) expect(usual.isFinite && usual > 0, isTrue); + } + + // Otherwise the checks above prove nothing. + expect(produced, greaterThan(50)); + }); + + test("identical months never trigger a comparison", () { + for (int seed = 0; seed < 100; seed++) { + final math.Random random = math.Random(seed); + final int count = 10 + random.nextInt(40); + final List<({int day, double amount, String? category})> pattern = [ + for (int i = 0; i < count; i++) + ( + day: 1 + random.nextInt(28), + amount: math.exp(random.nextDouble() * 6), + category: categories[random.nextInt(categories.length)], + ), + ]; + + final DateTime now = DateTime(2026, 9, 1 + random.nextInt(28), 23); + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 9; month++) { + for (final entry in pattern) { + if (month == 9 && entry.day > now.day) continue; + + ledger.spend( + DateTime(2026, month, entry.day, 12), + entry.amount, + category: entry.category, + ); + } + } + + for (final DateTime month in [DateTime(2026, 8), DateTime(2026, 9)]) { + expect( + ledger.analyze(month, now: now).insights, + isEmpty, + reason: "seed $seed, $month", + ); + } + } + }); + + test("a limit of zero returns nothing", () { + final TestLedger ledger = TestLedger(); + for (int month = 2; month <= 8; month++) { + ledger.flatMonth(2026, month); + } + ledger.spend(DateTime(2026, 8, 3), 5000.0); + + expect( + ledger + .analyze(DateTime(2026, 8), now: DateTime(2026, 9, 2), limit: 0) + .insights, + isEmpty, + ); + }); +} diff --git a/test/unit/insights/insight_test_data.dart b/test/unit/insights/insight_test_data.dart new file mode 100644 index 00000000..d02dc48f --- /dev/null +++ b/test/unit/insights/insight_test_data.dart @@ -0,0 +1,242 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight_engine.dart"; + +/// Builds [InsightTransaction]s with predictable, sortable uuids. +class TestLedger { + final List<InsightTransaction> transactions = []; + + int _counter = 0; + + String _nextUuid() => "t${(_counter++).toString().padLeft(6, "0")}"; + + InsightTransaction spend( + DateTime date, + double amount, { + String? title, + String? category = "misc", + String? account = "checking", + String? recurringUuid, + bool isTransfer = false, + bool isPending = false, + }) => _add( + InsightTransaction( + uuid: _nextUuid(), + date: date, + amount: -amount.abs(), + title: title, + categoryUuid: category, + accountUuid: account, + recurringUuid: recurringUuid, + isTransfer: isTransfer, + isPending: isPending, + ), + ); + + InsightTransaction earn( + DateTime date, + double amount, { + String? title, + String? category = "salary", + }) => _add( + InsightTransaction( + uuid: _nextUuid(), + date: date, + amount: amount.abs(), + title: title, + categoryUuid: category, + accountUuid: "checking", + ), + ); + + InsightTransaction _add(InsightTransaction transaction) { + transactions.add(transaction); + return transaction; + } + + /// [count] untitled expenses of [each], spread over the month. + void flatMonth( + int year, + int month, { + int count = 10, + double each = 100.0, + String category = "misc", + int throughDay = 31, + }) { + final int days = DateTime(year, month + 1, 0).day; + + for (int i = 0; i < count; i++) { + final int day = 1 + (i * days) ~/ count; + if (day > throughDay) continue; + + spend(DateTime(year, month, day, 12), each, category: category); + } + } + + /// A realistic month: tracked rent, Spotify, groceries, dining, transport + /// and coffee, with seeded noise. + void steadyMonth( + int year, + int month, + math.Random random, { + int throughDay = 31, + }) { + final int days = DateTime(year, month + 1, 0).day; + + void add( + int day, + double amount, + String title, + String category, { + String? recurringUuid, + }) { + if (day > throughDay) return; + + spend( + DateTime(year, month, math.min(day, days), 12), + amount, + title: title, + category: category, + recurringUuid: recurringUuid, + ); + } + + add(1, 1200.0, "Rent", "housing", recurringUuid: "rent"); + add(3, 11.99, "Spotify", "subscriptions"); + + for (int i = 0; i < 4; i++) { + add(2 + i * 7, 70.0 + random.nextDouble() * 20, "Grocer", "groceries"); + } + + const List<String> diners = ["Noodle bar", "Pizzeria", "Taqueria", "Cafe"]; + for (int i = 0; i < 5; i++) { + add( + 4 + i * 6, + 25.0 + random.nextDouble() * 15, + diners[random.nextInt(diners.length)], + "dining", + ); + } + + for (int i = 0; i < 6; i++) { + add(2 + i * 5, 12.0 + random.nextDouble() * 6, "Metro", "transport"); + } + + for (int i = 0; i < 10; i++) { + add(1 + i * 3, 4.5, "Coffee", "coffee"); + } + } + + /// A month like the app's demo data: untracked rent of 1,690 on the 1st + /// at [rentHour]:59, subscriptions, bills and daily spend, with seeded + /// noise. + void demoMonth( + int year, + int month, + math.Random random, { + int rentHour = 21, + double rent = 1690.0, + int throughDay = 31, + }) { + final int days = DateTime(year, month + 1, 0).day; + + void add(DateTime date, double amount, String title, String category) { + if (date.day > throughDay) return; + + spend(date, amount, title: title, category: category); + } + + DateTime at(int day) => + DateTime(year, month, day, 7 + random.nextInt(15), random.nextInt(60)); + + add(DateTime(year, month, 1, rentHour, 59), rent, "Rent", "rent"); + add(at(1), 39.0, "Gym membership", "fitness"); + add(at(2), 2.99, "iCloud+", "subscriptions"); + add(at(5), 17.99, "Netflix", "subscriptions"); + add(at(6), 59.0, "Internet", "utils"); + add(at(9), 11.99, "Spotify", "subscriptions"); + add(at(12), 20.0, "ChatGPT Plus", "subscriptions"); + add(at(18), 42.0 + random.nextDouble() * 10, "Phone bill", "utils"); + add(at(20), 45.0 + random.nextDouble() * 30, "Electricity", "utils"); + add(at(22), 22.0 + random.nextDouble() * 16, "Water", "utils"); + + const List<String> diners = ["Lunch", "Dinner", "Pizza", "Sushi", "Ramen"]; + + for (int day = 1; day <= days; day++) { + if (random.nextDouble() < 0.55) { + add(at(day), 3.25 + random.nextDouble() * 3.5, "Latte", "drinks"); + } + if (random.nextDouble() < 0.25) { + add(at(day), 16.0 + random.nextDouble() * 80, "Groceries", "food"); + } + if (random.nextDouble() < 0.3) { + add( + at(day), + 10.0 + random.nextDouble() * 46, + diners[random.nextInt(diners.length)], + "dining", + ); + } + if (random.nextDouble() < 0.4) { + add(at(day), 2.5 + random.nextDouble() * 13.5, "Subway", "transport"); + } + } + } + + InsightReport analyze( + DateTime month, { + required DateTime now, + List<InsightRecurringTemplate> templates = const [], + Set<InsightType> hiddenTypes = const {}, + List<InsightSighting> sightings = const [], + int limit = 3, + }) => computeInsights( + InsightRequest( + transactions: transactions, + month: month, + now: now, + recurringTemplates: templates, + hiddenTypes: hiddenTypes, + sightings: sightings, + limit: limit, + ), + ); +} + +/// Every number an insight carries, for NaN/infinity checks. +List<double> numbersOf(Insight insight) => [ + insight.score, + insight.stake, + ...switch (insight) { + MonthPaceInsight() => [ + insight.current, + insight.usual, + insight.ratio, + ?insight.ratioWithoutAttribution, + ...insight.history.map((value) => value.amount), + ...insight.drivers.expand((d) => [d.current, d.usual]), + ], + CategorySpikeInsight() => [ + insight.current, + insight.usual, + insight.ratio, + ...insight.history.map((value) => value.amount), + ], + CategoryDropInsight() => [ + insight.current, + insight.usual, + insight.ratio, + ...insight.history.map((value) => value.amount), + ], + NewCategoryInsight() => [insight.current], + RecurringChargeInsight() => [ + insight.series.typicalAmount, + insight.series.annualCost, + ], + PriceChangeInsight() => [ + insight.previousAmount, + insight.currentAmount, + insight.ratio, + ], + }, +]; diff --git a/test/unit/insights/insights_preferences_test.dart b/test/unit/insights/insights_preferences_test.dart new file mode 100644 index 00000000..8cbc3d7e --- /dev/null +++ b/test/unit/insights/insights_preferences_test.dart @@ -0,0 +1,69 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/prefs/insights_preferences.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +void main() { + final InsightsLocalPreferences preferences = + InsightsLocalPreferences.initialize(_MemoryPreferences()); + + setUp(() async { + await preferences.hiddenTypes.set([]); + await preferences.sightings.set([]); + }); + + test("hidden types round trip and ignore unknown names", () async { + await preferences.setHidden(.monthPace, true); + await preferences.setHidden(.priceChange, true); + await preferences.hiddenTypes.addItem("somethingRemoved"); + + expect(preferences.hiddenTypeSet, { + InsightType.monthPace, + InsightType.priceChange, + }); + + await preferences.setHidden(.monthPace, false); + expect(preferences.hiddenTypeSet, {InsightType.priceChange}); + }); + + test("sightings keep the latest per key and forget old ones", () async { + const NewCategoryInsight pets = NewCategoryInsight( + score: 0.1, + categoryUuid: "pets", + current: 140.0, + entryCount: 2, + transactionUuids: [], + ); + const NewCategoryInsight garden = NewCategoryInsight( + score: 0.1, + categoryUuid: "garden", + current: 90.0, + entryCount: 1, + transactionUuids: [], + ); + + await preferences.recordSightings([pets, garden], DateTime(2026, 6, 1)); + await preferences.recordSightings([pets], DateTime(2026, 8, 20)); + + final List<InsightSighting> sightings = preferences.sightingList; + expect(sightings.map((sighting) => sighting.key), [pets.key]); + expect(sightings.single.shownAt, DateTime(2026, 8, 20)); + expect(sightings.single.stake, 140.0); + }); +} + +class _MemoryPreferences extends Fake implements SharedPreferencesWithCache { + final Map<String, Object> _values = {}; + + @override + bool containsKey(String key) => _values.containsKey(key); + + @override + List<String>? getStringList(String key) => + (_values[key] as List<String>?)?.toList(); + + @override + Future<void> setStringList(String key, List<String> value) async { + _values[key] = value.toList(); + } +} diff --git a/test/unit/insights/recurring_series_test.dart b/test/unit/insights/recurring_series_test.dart new file mode 100644 index 00000000..1f4688bd --- /dev/null +++ b/test/unit/insights/recurring_series_test.dart @@ -0,0 +1,515 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/insights/insight_math.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "insight_test_data.dart"; + +void main() { + List<RecurringSeries> detect( + TestLedger ledger, { + List<InsightRecurringTemplate> templates = const [], + }) => detectRecurringSeries(ledger.transactions, templates: templates); + + /// Charges [title] on each of [dates]. + TestLedger charges( + List<DateTime> dates, { + String title = "Spotify", + double amount = 11.99, + List<double>? amounts, + }) { + final TestLedger ledger = TestLedger(); + + for (int i = 0; i < dates.length; i++) { + ledger.spend(dates[i], amounts?[i] ?? amount, title: title); + } + + return ledger; + } + + List<DateTime> monthly(int day, {int from = 1, int count = 6}) => [ + for (int i = 0; i < count; i++) DateTime(2026, from + i, day, 9), + ]; + + group("monthly", () { + test("exact monthly charges", () { + final List<RecurringSeries> found = detect(charges(monthly(3))); + + expect(found, hasLength(1)); + expect(found.single.cadence, RecurringCadence.monthly); + expect(found.single.anchorDay, 3); + expect(found.single.isFixedPrice, isTrue); + expect(found.single.priceChangeIndex, isNull); + expect(found.single.typicalAmount, 11.99); + expect(found.single.annualCost, moreOrLessEquals(143.88)); + expect(found.single.key, "title:spotify"); + }); + + test("up to 3 days of jitter, like weekends", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2026, 1, 3), + DateTime(2026, 2, 5), + DateTime(2026, 3, 2), + DateTime(2026, 4, 6), + DateTime(2026, 5, 3), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + }); + + test("wraps around month starts (30th, 1st, 2nd, 31st)", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2025, 12, 30), + DateTime(2026, 2, 1), + DateTime(2026, 3, 2), + DateTime(2026, 3, 31), + DateTime(2026, 5, 1), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + }); + + test("too much jitter isn't monthly", () { + expect( + detect( + charges([ + DateTime(2026, 1, 3), + DateTime(2026, 2, 11), + DateTime(2026, 3, 1), + DateTime(2026, 4, 12), + ]), + ), + isEmpty, + ); + }); + + test("month ends: Jan 31 -> Feb 28 -> Mar 31 -> Apr 30", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2026, 1, 31), + DateTime(2026, 2, 28), + DateTime(2026, 3, 31), + DateTime(2026, 4, 30), + DateTime(2026, 5, 31), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + expect(found.single.anchorDay, 31); + }); + + test("leap years: Jan 31 -> Feb 29 -> Mar 31", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2024, 1, 31), + DateTime(2024, 2, 29), + DateTime(2024, 3, 31), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + }); + + test("one charge paid ten days early in a long series is tolerated", () { + final List<RecurringSeries> found = detect( + charges([...monthly(28, count: 5), DateTime(2026, 6, 18)]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + expect(found.single.anchorDay, 28); + expect( + monthStartOf(found.single.periodOf(found.single.occurrences.last)), + DateTime(2026, 6), + ); + }); + + test("but not in a short one", () { + expect( + detect(charges([...monthly(28, count: 2), DateTime(2026, 3, 18)])), + isEmpty, + ); + }); + + test("a skipped month is fine", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2026, 1, 3), + DateTime(2026, 2, 3), + DateTime(2026, 4, 3), + DateTime(2026, 5, 3), + DateTime(2026, 6, 3), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + }); + + test("every other month isn't monthly", () { + expect( + detect( + charges([for (int i = 0; i < 5; i++) DateTime(2026, 1 + i * 2, 3)]), + ), + isEmpty, + ); + }); + + test("every 28 days isn't monthly", () { + expect( + detect( + charges([ + for (int i = 0; i < 8; i++) + DateTime(2026, 1, 1).add(Duration(days: 28 * i)), + ]), + ), + isEmpty, + ); + }); + + test("two charges needed at least three times", () { + expect(detect(charges(monthly(3, count: 2))), isEmpty); + }); + + test("a price increase is one series with a change at the end", () { + final List<RecurringSeries> found = detect( + charges( + monthly(15), + amounts: [15.49, 15.49, 15.49, 15.49, 15.49, 17.99], + ), + ); + + expect(found.single.isFixedPrice, isTrue); + expect(found.single.priceChangeIndex, 5); + expect(found.single.previousAmount, 15.49); + expect(found.single.typicalAmount, 17.99); + }); + + test("an older price change settles on the new price", () { + final List<RecurringSeries> found = detect( + charges( + monthly(15), + amounts: [15.49, 15.49, 15.49, 17.99, 17.99, 17.99], + ), + ); + + expect(found.single.priceChangeIndex, 3); + expect(found.single.typicalAmount, 17.99); + }); + + test("a second price change compares with the price right before", () { + final List<RecurringSeries> found = detect( + charges( + monthly(1, count: 25), + title: "Rent", + amounts: [ + ...List.filled(12, 1480.0), + ...List.filled(12, 1590.0), + 1690.0, + ], + ), + ); + + expect(found.single.priceChangeIndex, 24); + expect(found.single.previousAmount, 1590.0); + expect(found.single.typicalAmount, 1690.0); + }); + + test("a bill that wobbles has no price to change", () { + final List<RecurringSeries> found = detect( + charges( + monthly(22, count: 27), + title: "Water", + amounts: [ + ...[30.63, 34.27, 33.31, 28.06, 36.19, 27.73, 31.47, 31.39], + ...[35.95, 37.63, 31.4, 29.23, 36.51, 29.13, 26.52, 28.64], + ...[29.96, 27.48, 27.32, 32.55, 32.04, 33.84, 32.46, 32.07], + ...[33.06, 32.38, 22.12], + ], + ), + ); + + expect(found.single.priceChangeIndex, isNull); + }); + + test("after two price changes, the latest price is typical", () { + final List<RecurringSeries> found = detect( + charges( + monthly(1, count: 26), + title: "Rent", + amounts: [ + ...List.filled(12, 1480.0), + ...List.filled(12, 1590.0), + 1690.0, + 1690.0, + ], + ), + ); + + expect(found.single.priceChangeIndex, 24); + expect(found.single.typicalAmount, 1690.0); + }); + + test("small currency wobble is still one fixed price", () { + final List<RecurringSeries> found = detect( + charges( + monthly(15), + amounts: [14.02, 14.31, 13.87, 14.12, 14.40, 13.95], + ), + ); + + expect(found.single.isFixedPrice, isTrue); + expect(found.single.priceChangeIndex, isNull); + }); + + test("a variable bill is monthly but not fixed price", () { + final List<RecurringSeries> found = detect( + charges( + monthly(20), + title: "Electricity", + amounts: [82.0, 140.0, 95.0, 61.0, 120.0, 88.0], + ), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + expect(found.single.isFixedPrice, isFalse); + }); + + test("wildly different amounts aren't a series", () { + expect( + detect( + charges( + monthly(20), + title: "Amazon", + amounts: [5.0, 300.0, 18.0, 90.0, 2.0, 700.0], + ), + ), + isEmpty, + ); + }); + + test("a double charge in one month breaks the pattern", () { + expect( + detect( + charges([ + DateTime(2026, 1, 3), + DateTime(2026, 2, 3), + DateTime(2026, 2, 4), + DateTime(2026, 3, 3), + ]), + ), + isEmpty, + ); + }); + }); + + group("weekly and yearly", () { + test("weekly isn't mislabeled as monthly", () { + final List<RecurringSeries> found = detect( + charges([ + for (int i = 0; i < 8; i++) DateTime(2026, 3, 2 + i * 7, 18), + ], title: "Yoga class"), + ); + + expect(found.single.cadence, RecurringCadence.weekly); + expect(found.single.annualCost, moreOrLessEquals(11.99 * 52)); + }); + + test("weekly across daylight saving still reads 7-day gaps", () { + final List<RecurringSeries> found = detect( + charges([ + for (int i = 0; i < 4; i++) DateTime(2026, 3, 22 + i * 7, 0, 30), + ], title: "Yoga class"), + ); + + expect(found.single.cadence, RecurringCadence.weekly); + }); + + test("every two weeks is neither weekly nor monthly", () { + expect( + detect( + charges([for (int i = 0; i < 8; i++) DateTime(2026, 1, 5 + i * 14)]), + ), + isEmpty, + ); + }); + + test("yearly isn't mislabeled as monthly", () { + final List<RecurringSeries> found = detect( + charges( + [DateTime(2024, 8, 14), DateTime(2025, 8, 14), DateTime(2026, 8, 13)], + title: "Prime", + amount: 139.0, + ), + ); + + expect(found.single.cadence, RecurringCadence.yearly); + expect(found.single.annualCost, 139.0); + }); + + test("two yearly charges are enough when the amount holds", () { + final List<RecurringSeries> found = detect( + charges( + [DateTime(2025, 8, 14), DateTime(2026, 8, 14)], + title: "Prime", + amount: 139.0, + ), + ); + + expect(found.single.cadence, RecurringCadence.yearly); + }); + + test("two yearly charges with different amounts aren't a series", () { + expect( + detect( + charges( + [DateTime(2025, 8, 14), DateTime(2026, 8, 14)], + title: "Dentist", + amounts: [120.0, 480.0], + ), + ), + isEmpty, + ); + }); + }); + + group("grouping", () { + test("two merchants with the same amount stay apart", () { + final TestLedger ledger = TestLedger(); + + for (final DateTime date in monthly(5)) { + ledger.spend(date, 9.99, title: "Netflix"); + ledger.spend(date, 9.99, title: "Hulu"); + } + + final List<RecurringSeries> found = detect(ledger); + + expect(found.map((series) => series.key), [ + "title:hulu", + "title:netflix", + ]); + }); + + test("one title with two plans splits into two series", () { + final TestLedger ledger = TestLedger(); + + for (int i = 0; i < 5; i++) { + ledger.spend(DateTime(2026, 1 + i, 5), 2.99, title: "Apple"); + ledger.spend(DateTime(2026, 1 + i, 12), 10.99, title: "Apple"); + } + + final List<RecurringSeries> found = detect(ledger); + + expect(found, hasLength(2)); + expect( + found.map((series) => series.typicalAmount), + containsAll([2.99, 10.99]), + ); + expect(found.map((series) => series.anchorDay), containsAll([5, 12])); + }); + + test("titles group after normalization", () { + final TestLedger ledger = charges(monthly(3, count: 2)); + ledger.spend(DateTime(2026, 3, 3), 11.99, title: "SPOTIFY #88213"); + + expect(detect(ledger).single.occurrences, hasLength(3)); + }); + + test("untitled charges are never grouped", () { + final TestLedger ledger = TestLedger(); + for (final DateTime date in monthly(3)) { + ledger.spend(date, 11.99); + } + + expect(detect(ledger), isEmpty); + }); + + test("Flow recurring transactions are tracked", () { + final TestLedger ledger = TestLedger(); + for (final DateTime date in monthly(1)) { + ledger.spend(date, 1200.0, title: "Rent", recurringUuid: "r-1"); + } + + final RecurringSeries series = detect(ledger).single; + + expect(series.key, "recurring:r-1"); + expect(series.isTracked, isTrue); + }); + + test("a matching recurring template marks the series as tracked", () { + final List<RecurringSeries> found = detect( + charges(monthly(3)), + templates: const [ + InsightRecurringTemplate(title: "spotify", amount: -11.99), + ], + ); + + expect(found.single.isTracked, isTrue); + }); + + test("a tracked variable bill has no price change", () { + final TestLedger ledger = TestLedger(); + final List<double> amounts = [40.0, 40.0, 40.0, 60.0, 60.0, 60.0]; + final List<DateTime> dates = monthly(3); + + for (int i = 0; i < dates.length; i++) { + ledger.spend( + dates[i], + amounts[i], + title: "Water", + recurringUuid: "r-1", + ); + } + + expect(detect(ledger).single.priceChangeIndex, 3); + + final RecurringSeries series = detect( + ledger, + templates: const [ + InsightRecurringTemplate( + title: "Water", + amount: 40.0, + uuid: "r-1", + variableAmount: true, + ), + ], + ).single; + + expect(series.isFixedPrice, isFalse); + expect(series.priceChangeIndex, isNull); + }); + + test("a template with another title or amount doesn't match", () { + final List<RecurringSeries> found = detect( + charges(monthly(3)), + templates: const [ + InsightRecurringTemplate(title: "Spotify", amount: 49.99), + InsightRecurringTemplate(title: "Netflix", amount: 11.99), + ], + ); + + expect(found.single.isTracked, isFalse); + }); + + test("the most common category and account win", () { + final TestLedger ledger = TestLedger(); + final List<DateTime> dates = monthly(3); + + for (int i = 0; i < dates.length; i++) { + ledger.spend( + dates[i], + 11.99, + title: "Spotify", + category: i == 0 ? "fun" : "subscriptions", + account: i < 4 ? "card" : "checking", + ); + } + + final RecurringSeries series = detect(ledger).single; + + expect(series.categoryUuid, "subscriptions"); + expect(series.accountUuid, "card"); + }); + }); +} diff --git a/test/unit/recurring_schedule_test.dart b/test/unit/recurring_schedule_test.dart new file mode 100644 index 00000000..aa2a719e --- /dev/null +++ b/test/unit/recurring_schedule_test.dart @@ -0,0 +1,328 @@ +import "dart:io"; + +import "package:flow/entity/account.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/entity/transaction/extensions/default/recurring.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/prefs/pending_transactions.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:path/path.dart" as path; +import "package:recurrence/recurrence.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +import "../objectbox_erase.dart"; + +void main() { + late ObjectBox obx; + late Account account; + late Account other; + + final String directory = path.join( + Directory.current.path, + ".objectbox_test_recurring_schedule", + ); + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + PendingTransactionsLocalPreferences.initialize(_MemoryPreferences()); + + obx = await ObjectBox.initialize( + customDirectory: directory, + subdirectory: "recurring_schedule", + ); + + account = Account(name: "Card", currency: "USD", iconCode: "irrelevant"); + other = Account(name: "Savings", currency: "USD", iconCode: "irrelevant"); + obx.box<Account>().putMany([account, other]); + }); + + tearDownAll(() async { + await testCleanupObject( + instance: obx, + directory: path.join(directory, "recurring_schedule"), + ); + }); + + setUp(() { + obx.box<Transaction>().removeAll(); + obx.box<RecurringTransaction>().removeAll(); + }); + + /// A series the way saving a transaction sets it up: the first transaction + /// is logged at [start], and the rest is generated as of [now]. + Future<RecurringTransaction> series({ + required DateTime start, + required DateTime now, + required List<RecurrenceRule> rules, + DateTime? end, + Account? transferTo, + }) async { + final Recurrence recurrence = Recurrence( + range: CustomTimeRange(start, end ?? Moment.maxValue), + rules: rules, + ); + + final RecurringTransaction rule = RecurringTransaction( + jsonTransactionTemplate: "", + rules: recurrence.rules.map((rule) => rule.serialize()).toList(), + range: recurrence.range.encodeShort(), + transferToAccountUuid: transferTo?.uuid, + lastGeneratedTransactionDate: start, + ); + + final Recurring extension = Recurring( + uuid: rule.uuid, + initialTransactionDate: start, + ); + + late final int id; + + if (transferTo == null) { + id = account.createAndSaveTransaction( + amount: -40.0, + title: "Rent", + transactionDate: start, + extensions: [extension], + ); + } else { + (id, _) = account.transferTo( + targetAccount: transferTo, + amount: 40.0, + transactionDate: start, + extensions: [extension], + ); + } + + rule.template = obx.box<Transaction>().get(id)!; + obx.box<RecurringTransaction>().put(rule); + + await RecurringTransactionsService().synchronizeAll(anchor: now); + + return rule; + } + + /// Dates of the transactions in [rule]'s series, one per occurrence + List<DateTime> dates(RecurringTransaction rule) => + obx + .box<Transaction>() + .getAll() + .where( + (transaction) => + transaction.extensions.recurring?.uuid == rule.uuid && + !(transaction.isTransfer && transaction.amount > 0), + ) + .map((transaction) => transaction.transactionDate.date) + .toList() + ..sort(); + + test("Monthly keeps its day after a start in the past", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 9, 30, 14, 30), + now: DateTime(2026, 10, 2, 9), + rules: [RecurrenceRule.monthly(30)], + ); + + expect(dates(rule), [DateTime(2026, 9, 30), DateTime(2026, 10, 30)]); + }); + + test("Only the one ahead is pending", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 7, 30), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + ); + + final Iterable<Transaction> pending = obx.box<Transaction>().getAll().where( + (transaction) => transaction.isPending == true, + ); + + expect(dates(rule), hasLength(4)); + expect(pending.single.transactionDate.date, DateTime(2026, 10, 30)); + }); + + test("Catches up on a start months ago, plus one ahead", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 6, 15, 8), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(15)], + ); + + expect(dates(rule), [ + DateTime(2026, 6, 15), + DateTime(2026, 7, 15), + DateTime(2026, 8, 15), + DateTime(2026, 9, 15), + DateTime(2026, 10, 15), + ]); + }); + + test("Monthly on the 31st clamps to shorter months", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 12, 31, 10), + now: DateTime(2027, 4, 15), + rules: [RecurrenceRule.monthly(31)], + ); + + expect(dates(rule), [ + DateTime(2026, 12, 31), + DateTime(2027, 1, 31), + DateTime(2027, 2, 28), + DateTime(2027, 3, 31), + DateTime(2027, 4, 30), + ]); + }); + + test("Weekly keeps its weekday", () async { + final DateTime start = DateTime(2026, 9, 16, 18); + + final RecurringTransaction rule = await series( + start: start, + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.weekly(start.weekday)], + ); + + expect(dates(rule), [ + DateTime(2026, 9, 16), + DateTime(2026, 9, 23), + DateTime(2026, 9, 30), + DateTime(2026, 10, 7), + ]); + }); + + test("Every 2 weeks counts from the start", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 9, 1, 12), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.interval(const Duration(days: 14))], + ); + + expect(dates(rule), [ + DateTime(2026, 9, 1), + DateTime(2026, 9, 15), + DateTime(2026, 9, 29), + DateTime(2026, 10, 13), + ]); + }); + + test("Yearly keeps its date", () async { + final RecurringTransaction rule = await series( + start: DateTime(2024, 9, 30), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.yearly(9, 30)], + ); + + expect(dates(rule), [ + DateTime(2024, 9, 30), + DateTime(2025, 9, 30), + DateTime(2026, 9, 30), + DateTime(2027, 9, 30), + ]); + }); + + test("Stops at the end of the range", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 6, 30, 9), + end: DateTime(2026, 8, 30, 9), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + ); + + expect(dates(rule), [ + DateTime(2026, 6, 30), + DateTime(2026, 7, 30), + DateTime(2026, 8, 30), + ]); + }); + + test("A start in the future generates nothing more yet", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 10, 30), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + ); + + expect(dates(rule), [DateTime(2026, 10, 30)]); + }); + + test("Synchronizing again doesn't duplicate", () async { + final DateTime now = DateTime(2026, 10, 2); + + final RecurringTransaction rule = await series( + start: DateTime(2026, 8, 30), + now: now, + rules: [RecurrenceRule.monthly(30)], + ); + + final List<DateTime> before = dates(rule); + + await RecurringTransactionsService().synchronizeAll(anchor: now); + await RecurringTransactionsService().synchronizeAll(anchor: now); + + expect(before, hasLength(3)); + expect(dates(rule), before); + }); + + test("Generates the next one once time passes", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 9, 30), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + ); + + await RecurringTransactionsService().synchronizeAll( + anchor: DateTime(2026, 10, 30, 12), + ); + + expect(dates(rule), [ + DateTime(2026, 9, 30), + DateTime(2026, 10, 30), + DateTime(2026, 11, 30), + ]); + }); + + test("Transfers follow the schedule on both sides", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 8, 30, 7), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + transferTo: other, + ); + + expect(dates(rule), [ + DateTime(2026, 8, 30), + DateTime(2026, 9, 30), + DateTime(2026, 10, 30), + ]); + expect( + obx.box<Transaction>().getAll().where((x) => x.amount > 0), + hasLength(3), + ); + }); +} + +class _MemoryPreferences extends Fake implements SharedPreferencesWithCache { + final Map<String, Object> _values = {}; + + @override + bool containsKey(String key) => _values.containsKey(key); + + @override + Object? get(String key) => _values[key]; + + @override + bool? getBool(String key) => _values[key] as bool?; + + @override + int? getInt(String key) => _values[key] as int?; + + @override + Future<void> setBool(String key, bool value) async => _values[key] = value; + + @override + Future<void> setInt(String key, int value) async => _values[key] = value; +} diff --git a/test/unit/utils/recurrence_test.dart b/test/unit/utils/recurrence_test.dart new file mode 100644 index 00000000..85b8e23b --- /dev/null +++ b/test/unit/utils/recurrence_test.dart @@ -0,0 +1,75 @@ +import "package:flow/utils/extensions/recurrence.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; + +void main() { + Recurrence startingOct2(RecurrenceRule rule, [DateTime? end]) => Recurrence( + range: CustomTimeRange(DateTime(2026, 10, 2, 9), end ?? Moment.maxValue), + rules: [rule], + ); + + final DateTime sep30 = DateTime(2026, 9, 30, 14, 30); + + test("startingAt moves the start, even earlier", () { + final Recurrence moved = startingOct2( + RecurrenceRule.monthly(2), + ).startingAt(sep30); + + expect(moved.range.from, sep30); + expect(moved.range.to, Moment.maxValue); + }); + + test("startingAt takes monthly, weekly and yearly rules along", () { + expect(startingOct2(RecurrenceRule.monthly(2)).startingAt(sep30).rules, [ + RecurrenceRule.monthly(30), + ]); + expect( + startingOct2( + RecurrenceRule.weekly(DateTime.friday), + ).startingAt(sep30).rules, + [RecurrenceRule.weekly(DateTime.wednesday)], + ); + expect(startingOct2(RecurrenceRule.yearly(10, 2)).startingAt(sep30).rules, [ + RecurrenceRule.yearly(9, 30), + ]); + }); + + test("startingAt leaves intervals alone", () { + final RecurrenceRule every2Weeks = RecurrenceRule.interval( + const Duration(days: 14), + ); + + expect(startingOct2(every2Weeks).startingAt(sep30).rules, [every2Weeks]); + }); + + test("startingAt lists occurrences on the new day", () { + final Recurrence moved = startingOct2( + RecurrenceRule.monthly(2), + ).startingAt(sep30); + + final List<DateTime> next = moved.occurrences( + subrange: CustomTimeRange(sep30, DateTime(2027, 1, 1)), + ); + + expect(next.map((date) => date.day).toSet(), {30}); + }); + + test("startingAt keeps an end that's still ahead", () { + final DateTime end = DateTime(2027, 1, 2, 9); + + expect( + startingOct2(RecurrenceRule.monthly(2), end).startingAt(sep30).range.to, + end, + ); + }); + + test("startingAt past the end runs indefinitely", () { + final Recurrence moved = startingOct2( + RecurrenceRule.monthly(2), + DateTime(2026, 10, 20), + ).startingAt(DateTime(2026, 11, 5)); + + expect(moved.range.to, Moment.maxValue); + }); +} diff --git a/test/unit/variable_recurring_test.dart b/test/unit/variable_recurring_test.dart new file mode 100644 index 00000000..fd352414 --- /dev/null +++ b/test/unit/variable_recurring_test.dart @@ -0,0 +1,175 @@ +import "dart:io"; + +import "package:flow/entity/account.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flow/services/transactions.dart"; +import "package:flow/utils/extensions/recurring_transaction.dart"; +import "package:flow/utils/extensions/transaction.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path/path.dart" as path; +import "package:recurrence/recurrence.dart"; + +import "../objectbox_erase.dart"; + +void main() { + late ObjectBox obx; + late Account account; + late Account other; + + final String directory = path.join( + Directory.current.path, + ".objectbox_test_variable_recurring", + ); + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + obx = await ObjectBox.initialize( + customDirectory: directory, + subdirectory: "variable_recurring", + ); + + account = Account(name: "Card", currency: "USD", iconCode: "irrelevant"); + other = Account(name: "Savings", currency: "EUR", iconCode: "irrelevant"); + obx.box<Account>().putMany([account, other]); + }); + + tearDownAll(() async { + await testCleanupObject( + instance: obx, + directory: path.join(directory, "variable_recurring"), + ); + }); + + setUp(() { + obx.box<Transaction>().removeAll(); + obx.box<RecurringTransaction>().removeAll(); + }); + + /// A monthly rule that started 3 months ago, created from a -40 charge. + Future<RecurringTransaction> monthlyRule({required bool variable}) async { + final DateTime start = DateTime.now().subtract(const Duration(days: 90)); + + final int id = account.createAndSaveTransaction( + amount: -40.0, + title: "Electricity", + transactionDate: start, + ); + final Transaction first = obx.box<Transaction>().get(id)!; + + final Recurrence recurrence = Recurrence.fromIndefinitely( + rules: [RecurrenceRule.monthly(start.day)], + start: start, + ); + + final RecurringTransaction rule = RecurringTransaction( + jsonTransactionTemplate: "", + rules: recurrence.rules.map((rule) => rule.serialize()).toList(), + range: recurrence.range.encodeShort(), + variableAmount: variable, + lastGeneratedTransactionDate: start, + )..template = first; + + obx.box<RecurringTransaction>().put(rule); + + await RecurringTransactionsService().synchronizeAll(); + + return rule; + } + + List<Transaction> generated(RecurringTransaction rule) => + obx.box<Transaction>().getAll().where((transaction) { + return transaction.extensions.recurring?.uuid == rule.uuid; + }).toList() + ..sort((a, b) => a.transactionDate.compareTo(b.transactionDate)); + + test("Variable amounts are generated as pending estimates", () async { + final RecurringTransaction rule = await monthlyRule(variable: true); + final List<Transaction> items = generated(rule); + + expect(items, isNotEmpty); + for (final Transaction item in items) { + expect(item.isPending, isTrue); + expect(item.amount, -40.0); + expect(item.isAmountEstimate, isTrue); + } + }); + + test("Fixed amounts aren't estimates", () async { + final RecurringTransaction rule = await monthlyRule(variable: false); + final List<Transaction> items = generated(rule); + + expect(items, isNotEmpty); + expect(items.where((item) => item.isAmountEstimate), isEmpty); + }); + + test( + "Confirming takes the actual amount, and it's the next estimate", + () async { + final RecurringTransaction rule = await monthlyRule(variable: true); + final Transaction first = generated(rule).first; + + expect(first.confirmWithAmount(55.0, false), isTrue); + + final Transaction confirmed = TransactionsService().getOneSync(first.id)!; + expect(confirmed.amount, -55.0); + expect(confirmed.isPending, isFalse); + expect(confirmed.isAmountEstimate, isFalse); + + expect(rule.estimateAmount(rule.template, generated(rule)), -55.0); + }, + ); + + test("Estimate ignores pending ones and falls back to the template", () { + final RecurringTransaction rule = RecurringTransaction( + jsonTransactionTemplate: "{}", + rules: [], + range: "", + variableAmount: true, + ); + final Transaction template = Transaction( + uuid: "t", + amount: -40.0, + currency: "USD", + ); + + expect( + rule.estimateAmount(template, [ + Transaction(uuid: "t", amount: -99.0, currency: "USD", isPending: true), + ]), + -40.0, + ); + + rule.variableAmount = false; + expect( + rule.estimateAmount(template, [ + Transaction(uuid: "t", amount: -99.0, currency: "USD"), + ]), + -40.0, + ); + }); + + test("Confirming a transfer updates both sides", () { + final (int fromId, int toId) = account.transferTo( + targetAccount: other, + amount: 100.0, + conversionRate: 0.5, + isPending: true, + ); + + final Transaction from = TransactionsService().getOneSync(fromId)!; + expect(from.confirmWithAmount(120.0, false), isTrue); + + final Transaction fromAfter = TransactionsService().getOneSync(fromId)!; + final Transaction toAfter = TransactionsService().getOneSync(toId)!; + + expect(fromAfter.amount, -120.0); + expect(toAfter.amount, 60.0); + expect(fromAfter.isPending, isFalse); + expect(toAfter.isPending, isFalse); + }); +} diff --git a/test/widget/insight_history_chart_test.dart b/test/widget/insight_history_chart_test.dart new file mode 100644 index 00000000..adcea4f5 --- /dev/null +++ b/test/widget/insight_history_chart_test.dart @@ -0,0 +1,62 @@ +import "package:fl_chart/fl_chart.dart"; +import "package:flow/theme/flow_custom_colors.dart"; +import "package:flow/widgets/insights/insight_history_chart.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + Future<void> pump(WidgetTester tester, List<InsightChartBar> bars) => + tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: const [ + FlowCustomColors( + income: Color(0xFF32CC70), + expense: Color(0xFFC42525), + semi: Color(0xFF888888), + ), + ], + ), + home: Scaffold( + body: SizedBox( + width: 320.0, + child: InsightHistoryChart( + bars: bars, + reference: 100.0, + referenceLabel: "usual \$100", + formatAmount: (amount) => "\$${amount.round()}", + ), + ), + ), + ), + ); + + testWidgets("labels every bar and highlights the last one", (tester) async { + await pump(tester, [ + (label: "Jul", amount: 90.0, muted: false), + (label: "Aug", amount: 10.0, muted: true), + (label: "Sep", amount: 180.0, muted: false), + ]); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text("Jul"), findsOneWidget); + expect(find.text("Sep"), findsOneWidget); + + final BarChart chart = tester.widget(find.byType(BarChart)); + final List<Color> colors = chart.data.barGroups + .map((group) => group.barRods.single.color!) + .toList(); + + expect(colors.last, isNot(colors.first)); + expect(colors[1], isNot(colors.first)); + expect(chart.data.maxY, greaterThan(180.0)); + }); + + testWidgets("a single empty bar still renders", (tester) async { + await pump(tester, [(label: "Sep", amount: 0.0, muted: false)]); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widget/new_transaction_button_test.dart b/test/widget/new_transaction_button_test.dart new file mode 100644 index 00000000..ffa3dc75 --- /dev/null +++ b/test/widget/new_transaction_button_test.dart @@ -0,0 +1,571 @@ +import "dart:async"; + +import "package:flow/data/flow_button_type.dart"; +import "package:flow/entity/user_preferences.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/prefs/eny_preferences.dart"; +import "package:flow/services/integrations/eny.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/color_themes/registry.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/home/navbar.dart"; +import "package:flow/widgets/home/navbar/new_transaction_button.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/gestures.dart"; +import "package:flutter/material.dart"; +import "package:flutter/semantics.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:go_router/go_router.dart"; +import "package:logging/logging.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:pie_menu/pie_menu.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +/// Guards for the central + button on the home navbar. +/// +/// [PieMenu] renders the button but drops its radial menu under accessible +/// navigation, and treats `pointerSize / 2` as the movement allowed before a +/// release closes the menu. Both left the button looking fine while doing +/// nothing, so these tests drive real pointers and semantics actions. +/// +/// [PieMenu] keeps action widgets mounted after the menu closes, so finding an +/// action icon doesn't prove the menu is open. Check `onMenuToggle` instead. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await FlowLocalizations(const Locale("en")).load(); + EnyLocalPreferences.initialize(_MemoryPreferences()); + }); + + List<LogRecord> captureLogs() { + final List<LogRecord> records = []; + final Level previousLevel = Logger.root.level; + + Logger.root.level = Level.ALL; + final StreamSubscription<LogRecord> subscription = Logger.root.onRecord + .where((record) => record.loggerName == "NewTransactionButton") + .listen(records.add); + + addTearDown(() async { + await subscription.cancel(); + Logger.root.level = previousLevel; + }); + + return records; + } + + testWidgets("accessible navigation can select a transaction type", ( + tester, + ) async { + final List<FlowButtonType> selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: true, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + + expect(find.text("Expense"), findsOneWidget); + expect(find.text("Income"), findsOneWidget); + expect(find.text("Transfer"), findsOneWidget); + expect(find.text("Eny"), findsNothing); + + await tester.tap(find.text("Income")); + await tester.pumpAndSettle(); + + expect(selected, [FlowButtonType.income]); + expect(find.byType(BottomSheet), findsNothing); + }); + + testWidgets("diagnostics detect touches excluded from the button hit test", ( + tester, + ) async { + final List<LogRecord> records = captureLogs(); + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: (_) {}, + ignoreTransactionButton: true, + ), + ); + await tester.pumpAndSettle(); + + await tester.tapAt(tester.getCenter(find.byType(NewTransactionButton))); + await tester.pumpAndSettle(); + + expect( + records.any( + (record) => record.message.contains("missed the button hit test"), + ), + isTrue, + ); + expect( + records.any((record) => record.message.contains("PointerDownEvent:")), + isFalse, + ); + }); + + testWidgets("diagnostics do not report working taps as missed hits", ( + tester, + ) async { + final List<LogRecord> records = captureLogs(); + await tester.pumpWidget( + _testApp(accessibleNavigation: false, onActionTap: (_) {}), + ); + await tester.pumpAndSettle(); + + await tester.tapAt(tester.getCenter(find.byType(NewTransactionButton))); + await tester.pumpAndSettle(); + + expect( + records.any((record) => record.message.contains("PointerDownEvent:")), + isTrue, + ); + expect(records.any((record) => record.level >= Level.WARNING), isFalse); + }); + + testWidgets("screen-reader activation opens the transaction picker", ( + tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + final List<FlowButtonType> selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: true, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + final SemanticsNode node = tester.getSemantics( + find.bySemanticsLabel("New transaction"), + ); + expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); + + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(find.text("Expense"), findsOneWidget); + + await tester.tap(find.text("Expense")); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.expense]); + + semantics.dispose(); + }); + + testWidgets("cancel creates nothing and the picker can reopen", ( + tester, + ) async { + final List<FlowButtonType> selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: true, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text("Cancel")); + await tester.pumpAndSettle(); + + expect(selected, isEmpty); + expect(find.byType(BottomSheet), findsNothing); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text("Transfer")); + await tester.pumpAndSettle(); + + expect(selected, [FlowButtonType.transfer]); + }); + + testWidgets("accessible picker preserves custom order and connected Eny", ( + tester, + ) async { + final UserPreferences preferences = UserPreferencesService().value; + final String? previousOrder = preferences.transactionButtonOrderJoined; + addTearDown(() async { + preferences.transactionButtonOrderJoined = previousOrder; + await EnyService().setApiKey(apiKey: null); + }); + + preferences.transactionButtonOrder = [ + FlowButtonType.expense, + FlowButtonType.eny, + FlowButtonType.transfer, + FlowButtonType.income, + ]; + await EnyService().setApiKey(apiKey: "eny-test"); + + final List<FlowButtonType> selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: true, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + + expect( + tester + .widgetList<ListTile>(find.byType(ListTile)) + .map((tile) => (tile.title! as Text).data), + ["Expense", "Eny", "Transfer", "Income"], + ); + + await tester.tap(find.text("Eny")); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.eny]); + }); + + testWidgets("normal tap still opens radial actions and selects once", ( + tester, + ) async { + final List<FlowButtonType> selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: false, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsNothing); + expect(find.byIcon(FlowButtonType.expense.icon), findsOneWidget); + expect(selected, isEmpty); + + // PieCanvas handles raw pointer events over its painted action widgets. + await tester.tapAt( + tester.getCenter(find.byIcon(FlowButtonType.expense.icon)), + ); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.expense]); + }); + + for (final double drift in [2.0, 8.0, 16.0]) { + testWidgets("a touch drifting $drift pixels keeps the menu open", ( + tester, + ) async { + final List<FlowButtonType> selected = []; + final List<bool> menuStates = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: selected.add, + onMenuToggle: menuStates.add, + ), + ); + await tester.pumpAndSettle(); + + final Offset start = tester.getCenter(find.byIcon(Symbols.add_rounded)); + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + expect(menuStates.last, isTrue); + + await gesture.moveTo(start + Offset(drift, 0.0)); + await tester.pump(const Duration(milliseconds: 16)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(selected, isEmpty); + expect(menuStates.last, isTrue); + expect(find.byIcon(FlowButtonType.expense.icon), findsOneWidget); + }); + } + + testWidgets("menu release tolerance respects Android touch slop", ( + tester, + ) async { + final List<bool> menuStates = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: (_) {}, + onMenuToggle: menuStates.add, + gestureSettings: const DeviceGestureSettings(touchSlop: 32.0), + ), + ); + await tester.pumpAndSettle(); + + final Offset start = tester.getCenter(find.byIcon(Symbols.add_rounded)); + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + await gesture.moveTo(start + const Offset(24.0, 0.0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(menuStates.last, isTrue); + }); + + testWidgets("a cancelled touch can be dismissed and the button reused", ( + tester, + ) async { + final List<FlowButtonType> selected = []; + final List<bool> menuStates = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: selected.add, + onMenuToggle: menuStates.add, + ), + ); + await tester.pumpAndSettle(); + + final Offset start = tester.getCenter(find.byIcon(Symbols.add_rounded)); + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + await gesture.cancel(); + await tester.pumpAndSettle(); + + // PieMenu keeps an open menu after cancellation; the next tap dismisses it. + await tester.tapAt(start); + await tester.pumpAndSettle(); + expect(selected, isEmpty); + expect(menuStates.last, isFalse); + + await tester.tapAt(start); + await tester.pumpAndSettle(); + expect(menuStates.last, isTrue); + + await tester.tapAt( + tester.getCenter(find.byIcon(FlowButtonType.expense.icon)), + ); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.expense]); + }); + + for (final Size size in [ + const Size(320.0, 640.0), + const Size(393.0, 873.0), + ]) { + testWidgets("the full button receives taps in a $size navbar", ( + tester, + ) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final List<bool> menuStates = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: (_) {}, + onMenuToggle: menuStates.add, + padding: const EdgeInsets.only(top: 24.0, bottom: 48.0), + ), + ); + await tester.pumpAndSettle(); + + final Rect rect = tester.getRect(find.byType(NewTransactionButton)); + expect(rect.bottom, lessThanOrEqualTo(size.height - 48.0)); + + for (final Offset offset in const [ + Offset.zero, + Offset(-24.0, 0.0), + Offset(24.0, 0.0), + Offset(0.0, -24.0), + Offset(0.0, 24.0), + ]) { + await tester.tapAt(rect.center + offset); + await tester.pumpAndSettle(); + expect(menuStates.last, isTrue); + + await tester.tapAt(rect.center); + await tester.pumpAndSettle(); + expect(menuStates.last, isFalse); + } + }); + } + + testWidgets("drag opens when early movement arrives before a frame", ( + tester, + ) async { + final List<FlowButtonType> selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: false, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + final Offset start = tester.getCenter(find.byIcon(Symbols.add_rounded)); + final TestGesture gesture = await tester.startGesture(start); + await gesture.moveTo(start + const Offset(0.0, -8.0)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pumpAndSettle(); + expect(find.byIcon(FlowButtonType.income.icon), findsOneWidget); + + await gesture.moveTo( + tester.getCenter(find.byIcon(FlowButtonType.income.icon)), + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.income]); + }); + + testWidgets("dragging from the button selects a radial action once", ( + tester, + ) async { + final List<FlowButtonType> selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: false, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byIcon(Symbols.add_rounded)), + ); + await tester.pumpAndSettle(); + await gesture.moveTo( + tester.getCenter(find.byIcon(FlowButtonType.income.icon)), + ); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(selected, [FlowButtonType.income]); + expect(find.byType(BottomSheet), findsNothing); + }); + + testWidgets("accessible choices remain usable with large text", ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320.0, 640.0)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final List<FlowButtonType> selected = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: true, + textScaler: const TextScaler.linear(2.5), + onActionTap: selected.add, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text("Expense"), + 100.0, + scrollable: find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text("Expense")); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(selected, [FlowButtonType.expense]); + }); +} + +Widget _testApp({ + required bool accessibleNavigation, + required ValueChanged<FlowButtonType> onActionTap, + TextScaler textScaler = .noScaling, + ValueChanged<bool>? onMenuToggle, + DeviceGestureSettings gestureSettings = const DeviceGestureSettings(), + EdgeInsets padding = .zero, + bool ignoreTransactionButton = false, +}) { + final ThemeFactory theme = ThemeFactory(flowLights.schemes.first); + + // Mirrors HomePage: the navbar sits over a TabBarView, under the app-wide + // unlock tap, and sheets are popped through GoRouter. + final Widget home = GestureDetector( + onTap: () {}, + child: PieCanvas( + theme: theme.pieTheme, + onMenuToggle: onMenuToggle, + child: DefaultTabController( + length: 4, + child: Stack( + children: [ + const Scaffold( + body: TabBarView( + children: [SizedBox(), SizedBox(), SizedBox(), SizedBox()], + ), + ), + Positioned( + bottom: 16.0, + left: 0.0, + right: 0.0, + child: SafeArea( + child: Frame( + child: Stack( + alignment: Alignment.center, + children: [ + Navbar(onTap: (_) {}), + IgnorePointer( + ignoring: ignoreTransactionButton, + child: NewTransactionButton(onActionTap: onActionTap), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + + return MaterialApp.router( + theme: theme.materialTheme, + localizationsDelegates: const [_TestLocalizationsDelegate()], + routerConfig: GoRouter( + routes: [GoRoute(path: "/", builder: (context, state) => home)], + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + accessibleNavigation: accessibleNavigation, + textScaler: textScaler, + gestureSettings: gestureSettings, + padding: padding, + viewPadding: padding, + ), + child: child!, + ), + ); +} + +/// [FlowLocalizations.delegate] also syncs home widgets over a platform +/// channel, which isn't there in widget tests. +class _TestLocalizationsDelegate + extends LocalizationsDelegate<FlowLocalizations> { + const _TestLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future<FlowLocalizations> load(Locale locale) => + SynchronousFuture(FlowLocalizations(locale)); + + @override + bool shouldReload(_TestLocalizationsDelegate old) => false; +} + +class _MemoryPreferences extends Fake implements SharedPreferencesWithCache { + final Map<String, Object> _values = {}; + + @override + Object? get(String key) => _values[key]; + + @override + String? getString(String key) => _values[key] as String?; + + @override + List<String>? getStringList(String key) => _values[key] as List<String>?; + + @override + Future<void> setString(String key, String value) async { + _values[key] = value; + } + + @override + Future<void> remove(String key) async { + _values.remove(key); + } +} diff --git a/test/widget/select_recurrence_test.dart b/test/widget/select_recurrence_test.dart new file mode 100644 index 00000000..56531e55 --- /dev/null +++ b/test/widget/select_recurrence_test.dart @@ -0,0 +1,192 @@ +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/routes/transaction_page/select_recurrence.dart"; +import "package:flow/theme/flow_custom_colors.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await FlowLocalizations(const Locale("en")).load(); + }); + + Future<List<Recurrence>> pump( + WidgetTester tester, { + Recurrence? initialValue, + DateTime? defaultStart, + TimeRange? startBounds, + }) async { + final List<Recurrence> changes = []; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [_TestLocalizationsDelegate()], + theme: ThemeData( + extensions: const [ + FlowCustomColors( + income: Color(0xFF32CC70), + expense: Color(0xFFFF4040), + semi: Color(0xFF888888), + ), + ], + ), + home: Scaffold( + body: SingleChildScrollView( + child: SelectRecurrence( + initialValue: initialValue, + defaultStart: defaultStart, + onChanged: changes.add, + startBounds: startBounds, + ), + ), + ), + ), + ); + await tester.pump(); + + return changes; + } + + testWidgets("reports the default, so saving it untouched works", ( + tester, + ) async { + final List<Recurrence> changes = await pump(tester); + + expect(changes, hasLength(1)); + expect(changes.single.rules.single, isA<MonthlyRecurrenceRule>()); + }); + + testWidgets("doesn't report an initial value back", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [MonthlyRecurrenceRule(day: 3)], + start: DateTime(2026, 1, 3), + ), + ); + + expect(changes, isEmpty); + }); + + /// Picks [day] of the shown month as the start, skipping the time + Future<void> pickStart(WidgetTester tester, int day) async { + await tester.tap(find.byType(ListTile).at(1)); + await tester.pumpAndSettle(); + await tester.tap(find.text("$day")); + await tester.tap(find.text("OK")); + await tester.pumpAndSettle(); + await tester.tap(find.text("Cancel")); + await tester.pumpAndSettle(); + } + + testWidgets("default starts at the given date, on its day", (tester) async { + final List<Recurrence> changes = await pump( + tester, + defaultStart: DateTime(2026, 9, 30, 14, 30), + ); + + expect(changes.single.range.from, DateTime(2026, 9, 30, 14, 30)); + expect(changes.single.rules.single, MonthlyRecurrenceRule(day: 30)); + }); + + testWidgets("moving the start moves the monthly day with it", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [MonthlyRecurrenceRule(day: 2)], + start: DateTime(2026, 10, 2), + ), + startBounds: TimeRange.allTime(), + ); + + await pickStart(tester, 30); + + expect(changes.last.range.from, DateTime(2026, 10, 30)); + expect(changes.last.rules.single, MonthlyRecurrenceRule(day: 30)); + }); + + testWidgets("moving the start moves the weekday with it", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [WeeklyRecurrenceRule(weekday: DateTime.friday)], + start: DateTime(2026, 10, 2), + ), + startBounds: TimeRange.allTime(), + ); + + await pickStart(tester, 7); + + expect( + changes.last.rules.single, + WeeklyRecurrenceRule(weekday: DateTime.wednesday), + ); + }); + + testWidgets("moving the start moves the yearly date with it", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [RecurrenceRule.yearly(10, 2)], + start: DateTime(2026, 10, 2), + ), + startBounds: TimeRange.allTime(), + ); + + await pickStart(tester, 30); + + expect(changes.last.rules.single, RecurrenceRule.yearly(10, 30)); + }); + + testWidgets("a start past the end drops the end", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence( + range: CustomTimeRange(DateTime(2026, 10, 2), DateTime(2026, 10, 20)), + rules: [MonthlyRecurrenceRule(day: 2)], + ), + startBounds: TimeRange.allTime(), + ); + + await pickStart(tester, 30); + + expect(changes.last.range.to, Moment.maxValue); + }); + + testWidgets("start can't be moved without bounds", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [MonthlyRecurrenceRule(day: 2)], + start: DateTime(2026, 10, 2), + ), + ); + + await tester.tap(find.byType(ListTile).at(1)); + await tester.pumpAndSettle(); + + expect(find.byType(DatePickerDialog), findsNothing); + expect(changes, isEmpty); + }); +} + +/// [FlowLocalizations.delegate] also syncs home widgets over a platform +/// channel, which isn't there in widget tests. +class _TestLocalizationsDelegate + extends LocalizationsDelegate<FlowLocalizations> { + const _TestLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future<FlowLocalizations> load(Locale locale) => + SynchronousFuture(FlowLocalizations(locale)); + + @override + bool shouldReload(_TestLocalizationsDelegate old) => false; +} diff --git a/test/widget/worth_knowing_view_test.dart b/test/widget/worth_knowing_view_test.dart new file mode 100644 index 00000000..8e5bdc4c --- /dev/null +++ b/test/widget/worth_knowing_view_test.dart @@ -0,0 +1,400 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/providers/categories_provider.dart"; +import "package:flow/theme/flow_custom_colors.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flow/widgets/insights/worth_knowing_view.dart"; +import "package:flutter/foundation.dart" hide Category; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await FlowLocalizations(const Locale("en")).load(); + }); + + const Color expense = Color(0xFFC42525); + const Color income = Color(0xFF32CC70); + + const InsightFormatter usd = InsightFormatter(currency: "USD"); + + final Category dining = Category.preset( + name: "Dining out", + iconCode: FlowIconData.icon(Symbols.restaurant_rounded).toString(), + uuid: "dining", + ); + + final DateTime september = DateTime(2026, 9); + + InsightReport report( + List<Insight> insights, { + InsightReportStatus status = .ready, + int? throughDay = 25, + }) => InsightReport( + month: september, + status: status, + insights: insights, + usualMonthTotal: 1000.0, + throughDay: throughDay, + ); + + MonthPaceInsight pace({ + InsightDirection direction = .above, + double current = 1270.0, + InsightAttribution? attribution, + List<InsightCategoryDelta> drivers = const [], + int? throughDay = 25, + }) => MonthPaceInsight( + score: 0.27, + direction: direction, + current: current, + usual: 1000.0, + throughDay: throughDay, + history: [ + for (int month = 3; month <= 8; month++) + InsightMonthValue( + month: DateTime(2026, month), + amount: 1000.0, + counted: true, + ), + ], + attribution: attribution, + drivers: drivers, + transactionUuids: const ["a"], + ); + + final RecurringChargeInsight spotify = RecurringChargeInsight( + score: 0.2, + series: RecurringSeries( + key: "title:spotify", + title: "Spotify", + cadence: .monthly, + anchorDay: 3, + occurrences: [ + for (int month = 6; month <= 9; month++) + RecurringOccurrence( + transactionUuid: "s$month", + date: DateTime(2026, month, 3), + amount: 11.99, + ), + ], + isFixedPrice: true, + priceChangeIndex: null, + categoryUuid: null, + accountUuid: null, + isTracked: false, + ), + ); + + Future<void> pump( + WidgetTester tester, + InsightReport? report, { + InsightFormatter format = usd, + int hiddenTypeCount = 0, + bool stale = false, + ValueChanged<Insight>? onOpen, + ValueChanged<RecurringChargeInsight>? onTrack, + VoidCallback? onManageHidden, + }) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [_TestLocalizationsDelegate()], + theme: ThemeData( + extensions: const [ + FlowCustomColors( + income: income, + expense: expense, + semi: Color(0xFF888888), + ), + ], + ), + home: CategoriesProvider( + [dining], + child: Scaffold( + body: SingleChildScrollView( + child: WorthKnowingView( + report: report, + format: format, + hiddenTypeCount: hiddenTypeCount, + stale: stale, + onOpen: onOpen ?? (_) {}, + onTrack: onTrack ?? (_) {}, + onManageHidden: onManageHidden ?? () {}, + ), + ), + ), + ), + ), + ); + } + + Finder text(String value) => find.text(value, findRichText: true); + + group("visibility", () { + for (final InsightReportStatus status in [ + InsightReportStatus.notEnoughHistory, + InsightReportStatus.futureMonth, + ]) { + testWidgets("${status.name} renders nothing", (tester) async { + await pump(tester, report(const [], status: status)); + + expect(find.byType(Surface), findsNothing); + }); + } + + testWidgets("loading keeps the header, without rows", (tester) async { + await pump(tester, null); + + expect(find.text("WORTH KNOWING"), findsOneWidget); + expect(find.byType(InkWell), findsNothing); + }); + + testWidgets("hidden kinds can be managed from the header", (tester) async { + int taps = 0; + await pump( + tester, + report(const []), + hiddenTypeCount: 2, + onManageHidden: () => taps++, + ); + + await tester.tap(find.byIcon(Symbols.visibility_off_rounded)); + expect(taps, 1); + }); + + testWidgets("the restore button only shows when something is hidden", ( + tester, + ) async { + await pump(tester, report(const [])); + + expect(find.byIcon(Symbols.visibility_off_rounded), findsNothing); + }); + }); + + group("empty state", () { + testWidgets("an in-progress month says so far", (tester) async { + await pump(tester, report(const [])); + + expect(text("Nothing unusual in September so far."), findsOneWidget); + }); + + testWidgets("a past month doesn't", (tester) async { + await pump(tester, report(const [], throughDay: null)); + + expect(text("Nothing unusual in September."), findsOneWidget); + }); + + testWidgets("too early still shows the calm line", (tester) async { + await pump(tester, report(const [], status: .tooEarly)); + + expect(text("Nothing unusual in September so far."), findsOneWidget); + }); + }); + + group("sentences", () { + testWidgets("a month above names the purchase behind it", (tester) async { + await pump( + tester, + report([ + pace( + attribution: InsightAttribution( + transactionUuid: "a", + title: "Flight to Tokyo", + categoryUuid: null, + date: DateTime(2026, 9, 12), + amount: 330.0, + ), + ), + ]), + ); + + expect( + text("September is 27% above your usual by the 25th."), + findsOneWidget, + ); + expect( + text("Mostly Flight to Tokyo (\$330). Without it, 6% under."), + findsOneWidget, + ); + }); + + testWidgets("a closed month below falls back to a category", ( + tester, + ) async { + await pump( + tester, + report([ + pace( + direction: .below, + current: 700.0, + throughDay: null, + drivers: const [ + InsightCategoryDelta( + categoryUuid: "dining", + current: 50.0, + usual: 300.0, + ), + ], + ), + ], throughDay: null), + ); + + expect(text("September was 30% below your usual."), findsOneWidget); + expect(text("Mostly Dining out, \$250 less than usual."), findsOneWidget); + }); + + testWidgets("a category spike counts its entries", (tester) async { + await pump( + tester, + report([ + CategorySpikeInsight( + score: 0.2, + categoryUuid: "dining", + current: 486.0, + usual: 260.0, + entryCount: 11, + history: const [], + attribution: null, + transactionUuids: const [], + ), + const NewCategoryInsight( + score: 0.1, + categoryUuid: "gone", + current: 140.0, + entryCount: 1, + transactionUuids: [], + ), + ]), + ); + + expect(text("Dining out is 1.9× your usual."), findsOneWidget); + expect(text("\$486 vs \$260 typical · 11 entries"), findsOneWidget); + expect(text("First Uncategorized spending in 6 months."), findsOneWidget); + expect(text("\$140 · 1 entry"), findsOneWidget); + }); + + testWidgets("subscriptions keep their cents", (tester) async { + await pump(tester, report([spotify])); + + expect(text("Spotify looks like a monthly charge."), findsOneWidget); + expect(text("\$11.99 around the 3rd · 4 times so far"), findsOneWidget); + }); + + testWidgets("privacy mode obscures amounts", (tester) async { + await pump( + tester, + report([spotify]), + format: const InsightFormatter(currency: "USD", obscure: true), + ); + + expect(text("\$**.** around the 3rd · 4 times so far"), findsOneWidget); + }); + }); + + group("interaction", () { + testWidgets("tapping a row opens it", (tester) async { + final List<Insight> opened = []; + final MonthPaceInsight insight = pace(); + await pump(tester, report([insight]), onOpen: opened.add); + + await tester.tap(text("September is 27% above your usual by the 25th.")); + expect(opened, [insight]); + }); + + testWidgets("track as recurring doesn't open the row", (tester) async { + final List<Insight> opened = []; + final List<RecurringChargeInsight> tracked = []; + await pump( + tester, + report([spotify]), + onOpen: opened.add, + onTrack: tracked.add, + ); + + await tester.tap(find.text("Track as recurring")); + expect(tracked, [spotify]); + expect(opened, isEmpty); + }); + + testWidgets("a previous month's rows can't be opened while loading", ( + tester, + ) async { + final List<Insight> opened = []; + final List<RecurringChargeInsight> tracked = []; + await pump( + tester, + report([spotify]), + stale: true, + onOpen: opened.add, + onTrack: tracked.add, + ); + + await tester.tap( + text("Spotify looks like a monthly charge."), + warnIfMissed: false, + ); + await tester.tap(find.text("Track as recurring"), warnIfMissed: false); + expect(opened, isEmpty); + expect(tracked, isEmpty); + }); + + testWidgets("rows are capped by the engine, not the view", (tester) async { + await pump(tester, report([pace(), spotify])); + + expect(find.byType(Divider), findsOneWidget); + }); + }); + + group("change colors", () { + Color? valueColor(WidgetTester tester, String value) { + final RichText rich = tester + .widgetList<RichText>(find.byType(RichText)) + .firstWhere((widget) => widget.text.toPlainText().contains(value)); + TextStyle? found; + rich.text.visitChildren((span) { + if (span is TextSpan && span.text == value) found = span.style; + return found == null; + }); + return found?.color; + } + + testWidgets("spending more uses the expense color by default", ( + tester, + ) async { + await pump(tester, report([pace()])); + + expect(valueColor(tester, "27%"), expense); + }); + + testWidgets("spending less uses the income color by default", ( + tester, + ) async { + await pump(tester, report([pace(direction: .below, current: 700.0)])); + + expect(valueColor(tester, "30%"), income); + }); + }); +} + +/// [FlowLocalizations.delegate] also syncs home widgets over a platform +/// channel, which isn't there in widget tests. +class _TestLocalizationsDelegate + extends LocalizationsDelegate<FlowLocalizations> { + const _TestLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future<FlowLocalizations> load(Locale locale) => + SynchronousFuture(FlowLocalizations(locale)); + + @override + bool shouldReload(_TestLocalizationsDelegate old) => false; +}