diff --git a/.github/workflows/build-fat-apk-android-beta.yaml b/.github/workflows/build-fat-apk-android-beta.yaml index 520b5cd8..43939586 100644 --- a/.github/workflows/build-fat-apk-android-beta.yaml +++ b/.github/workflows/build-fat-apk-android-beta.yaml @@ -3,6 +3,11 @@ on: release: types: [published] workflow_dispatch: + inputs: + tag: + description: "Release tag to build from and attach the APK to (e.g. 0.23.0)" + required: true + type: string jobs: build: @@ -11,6 +16,8 @@ jobs: permissions: write-all steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} - name: Setup JDK 17 uses: actions/setup-java@v4.0.0 with: @@ -21,7 +28,12 @@ jobs: with: channel: stable - name: Set release tag name - run: echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + run: | + if [ -n "${{ github.event.inputs.tag }}" ]; then + echo "RELEASE_TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV + else + echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + fi - run: flutter pub get - name: Run tests run: | @@ -37,5 +49,5 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release upload ${{ env.RELEASE_TAG }} ./build/app/outputs/flutter-apk/app-release.apk - gh release upload ${{ env.RELEASE_TAG }} ./build/app/outputs/flutter-apk/app-release.apk.sha1 + gh release upload ${{ env.RELEASE_TAG }} ./build/app/outputs/flutter-apk/app-release.apk --clobber + gh release upload ${{ env.RELEASE_TAG }} ./build/app/outputs/flutter-apk/app-release.apk.sha1 --clobber diff --git a/.gitignore b/.gitignore index f264ca09..5f36577a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ app.*.map.json # ObjectBox unit testing .objectbox_test +.objectbox_test_demo # Objectbox library @@ -56,4 +57,4 @@ lib/libobjectbox.dylib lib/libobjectbox.so # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ced1f1..0d93d108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,28 @@ # Changelog -## next +## 0.23.2 + +### Fixes + +* Fixed stats tab may be out of sync + +### Changes + +* Location picker now features a "current location" button +* Added "Tip the creator" in-app purchase for iOS + +## 0.23.1 + +### Fixes + +* Fixed selecting icons + +## 0.23.0 ### New features * Added Traditional Chinese (Taiwan) localizations +* Revamped the stats tab, and added insights ## 0.22.0 diff --git a/README.md b/README.md index 629a2e89..422e377c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ [![Buy me a coffee](https://img.shields.io/badge/buy_me_a_coffee-sadespresso-f5ccff?logo=buy-me-a-coffee&logoColor=white&style=for-the-badge)](https://buymeacoffee.com/sadespresso) [![Website](https://img.shields.io/badge/Website-flow.gege.mn-f5ccff?style=for-the-badge)](https://flow.gege.mn)  -[![Flow's GitHub repo](https://img.shields.io/badge/GitHub-flow--mn/flow-f5ccff?logo=github&logoColor=white&style=for-the-badge)](https://github.com/flow-mn/flow)  -[![Join Flow Discord server](https://img.shields.io/badge/Discord-Flow-f5ccff?logo=discord&logoColor=white&style=for-the-badge)](https://discord.gg/Ndh9VDeZa4) +[![Flow's GitHub repo](https://img.shields.io/badge/GitHub-flow--mn/flow-f5ccff?logo=github&logoColor=white&style=for-the-badge)](https://github.com/flow-mn/flow) ## Preface diff --git a/android/build.gradle.kts b/android/build.gradle.kts index a344f23c..8d30511a 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -12,6 +12,31 @@ subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) project.evaluationDependsOn(":app") + + plugins.withId("com.android.library") { + if (!project.plugins.hasPlugin("org.jetbrains.kotlin.android")) { + project.apply(plugin = "org.jetbrains.kotlin.android") + + // Those same plugins also skip their `kotlinOptions` block under + // AGP 9, so the force-applied Kotlin compiler defaults to the + // JDK's target (e.g. 21) and clashes with their Java target + // ("Inconsistent JVM-target compatibility"). Align Kotlin's + // jvmTarget to whatever Java target the module declares. + project.afterEvaluate { + val javaTarget = project.tasks + .withType(org.gradle.api.tasks.compile.JavaCompile::class.java) + .map { it.targetCompatibility } + .firstOrNull() ?: "1.8" + project.tasks + .withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java) + .configureEach { + compilerOptions.jvmTarget.set( + org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(javaTarget) + ) + } + } + } + } } tasks.register("clean") { diff --git a/android/gradle.properties b/android/gradle.properties index 6eec59b9..caad2048 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,7 @@ -org.gradle.jvmargs=-Xmx2560M +# R8 release minification runs in the Gradle daemon; 2560M OOM'd in +# minifyReleaseWithR8 on CI. ubuntu-latest has 16 GB, so give the daemon room. +org.gradle.jvmargs=-Xmx5120M -XX:MaxMetaspaceSize=2g android.useAndroidX=true -android.enableJetifier=true android.defaults.buildfeatures.resvalues=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false android.enableAppCompileTimeRClass=false diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index be09e1ac..0b66d6ab 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -34,6 +34,47 @@ "accounts": "الحسابات", "appName": "Flow", "appShortDesc": "متتبع التمويل الشخصي الخاص بك", + "budget.alert.action": "مراجعة", + "budget.alert.left": "{amount} متبقي", + "budget.alert.near": "{name}: {percent}% مستخدم", + "budget.alert.over": "{name}: تجاوز الميزانية", + "budget.alert.overBy": "تجاوز بمقدار {amount}", + "budget.amount": "مبلغ الميزانية", + "budget.amount.required": "يجب أن يكون مبلغ الميزانية أكبر من صفر", + "budget.categories": "الفئات", + "budget.categories.all": "تُحتسب جميع النفقات", + "budget.categories.allShort": "جميع النفقات", + "budget.categories.description": "تُحتسب في هذه الميزانية نفقات الفئات المحددة فقط. لا تحدد أي فئة لاحتساب جميع النفقات.", + "budget.delete": "حذف الميزانية", + "budget.delete.description": "لن تتأثر المعاملات. هذا الإجراء لا رجعة فيه!", + "budget.insight.nearingLimit": "{name} تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", + "budget.insight.onTrack": "{name} على المسار الصحيح — {amount} متبقي.", + "budget.insight.over": "أنت متجاوز بمقدار {amount} في {name}. قلل الإنفاق أو ارفع الحد.", + "budget.insight.overpacing": "بهذه الوتيرة، سيتجاوز {name} الحد بمقدار {amount}.", + "budget.insight.underspending": "{name} لديه متسع — {amount} غير مُنفق.", + "budget.name": "اسم الميزانية", + "budget.overview.allHealthy": "كل شيء على المسار الصحيح. عمل رائع.", + "budget.overview.budgetsTracked": "{count} ميزانيات متبعة", + "budget.overview.budgetsTracked.one": "{count} ميزانية متبعة", + "budget.overview.create": "ميزانية جديدة", + "budget.overview.empty": "لا توجد ميزانيات بعد", + "budget.overview.empty.description": "أنشئ ميزانية لمعرفة كيف يتماشى إنفاقك مع حدودك.", + "budget.overview.missingRates": "تم تخطي بعض المبالغ (أسعار صرف مفقودة).", + "budget.overview.nearingLimit": "{count} ميزانيات تقترب من حدها", + "budget.overview.nearingLimit.one": "{count} ميزانية تقترب من حدها", + "budget.overview.nothingOver": "لا توجد تجاوزات للحد", + "budget.overview.overLimit": "{count} ميزانيات تجاوزت الحد", + "budget.overview.overLimit.one": "{count} ميزانية تجاوزت الحد", + "budget.overview.perBudget": "ميزانياتك", + "budget.overview.recommendations": "توصيات", + "budget.overview.summary": "الحالة", + "budget.overview.title": "نظرة عامة على الميزانية", + "budget.period": "الفترة", + "budget.renewAutomatically": "تجديد تلقائي", + "budget.renewAutomatically.description": "عند انتهاء الفترة، تنتقل الميزانية إلى الفترة الجديدة. على سبيل المثال، تصبح ميزانية يوليو ميزانية أغسطس.", + "budget.scope": "النطاق", + "budgets": "الميزانيات", + "budgets.new": "ميزانية جديدة", "categories": "الفئات", "categories.addFromPresets": "إضافة من الإعدادات المسبقة", "categories.noCategories": "ليس لديك أي فئات", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "نجاح", "enum.ImportV2Progress@waitingConfirmation": "في انتظار التأكيد", "enum.ImportV2Progress@writingAccounts": "يجري كتابة الحسابات", + "enum.ImportV2Progress@writingBudgets": "يجري كتابة الميزانيات", "enum.ImportV2Progress@writingCategories": "يجري كتابة الفئات", "enum.ImportV2Progress@writingFileAttachments": "جاري كتابة المرفقات", "enum.ImportV2Progress@writingProfile": "يجري كتابة بيانات الملف الشخصي", @@ -532,11 +574,10 @@ "support.leaveAReview": "اترك تقييمًا", "support.leaveAReview.action": "قيم Flow", "support.leaveAReview.description": "يمكنك تقييم Flow وترك مراجعة على {appStore}", - "support.requestFeatures": "اقترح أفكارًا", - "support.requestFeatures.action": "زيارة متعقب القضايا", - "support.requestFeatures.description": "يمكنك أيضًا دعمنا بتقديم الملاحظات وأفكار الاقتراحات لتحسين Flow.", "support.starOnGitHub": "أضف نجمة على جيثب", "support.starOnGitHub.description": "إضافة نجمة لـ Flow على جيثب تساعد الآخرين في اكتشافه", + "support.tip.error": "تعذر بدء عملية الشراء. الرجاء المحاولة مرة أخرى.", + "support.tip.thankYou": "شكرًا لدعمك لـ Flow! 💜", "sync.export": "تصدير", "sync.export.asCSV": "كملف CSV", "sync.export.asCSV.description": "لا يمكن استخدامه للاستعادة/الاستيراد! مثالي للفتح في برامج مثل Google Sheets", @@ -642,13 +683,21 @@ "tabs.profile.community": "المجتمع", "tabs.profile.guide": "دليل الاستخدام", "tabs.profile.import": "الاستيراد", - "tabs.profile.joinDiscord": "انضم إلى Discord الخاص بـ Flow", "tabs.profile.other": "أخرى", "tabs.profile.preferences": "التفضيلات", "tabs.profile.recommend": "أوصي بـ Flow", "tabs.profile.support": "دعم Flow", "tabs.profile.withLoveFromTheCreator": "مع 🤍 من sadespresso", "tabs.stats": "الإحصائيات", + "tabs.stats.analytics.budgets": "الميزانيات", + "tabs.stats.analytics.budgets.empty": "حدد ميزانية للإنفاق", + "tabs.stats.analytics.budgets.nearingCount": "{count} ميزانيات تقترب من الحد", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} ميزانية تقترب من الحد", + "tabs.stats.analytics.budgets.onTrack": "الكل على المسار الصحيح", + "tabs.stats.analytics.budgets.overCount": "{count} ميزانيات تجاوزت الحد", + "tabs.stats.analytics.budgets.overCount.one": "{count} ميزانية تجاوزت الحد", + "tabs.stats.analytics.budgets.tracked": "{count} ميزانيات", + "tabs.stats.analytics.budgets.tracked.one": "{count} ميزانية", "tabs.stats.analytics.calendar": "التقويم", "tabs.stats.analytics.calendar.priciestDay": "أغلى يوم لديك هو {value}.", "tabs.stats.analytics.calendar.spentIn": "المنفق في {}", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index f1c227b2..428684df 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -34,6 +34,47 @@ "accounts": "Рахункі", "appName": "Flow", "appShortDesc": "Ваш асабісты фінансавы трэкер", + "budget.alert.action": "Праверыць", + "budget.alert.left": "{amount} засталося", + "budget.alert.near": "{name}: выкарыстана {percent}%", + "budget.alert.over": "{name}: перавышаны бюджэт", + "budget.alert.overBy": "Перавышэнне на {amount}", + "budget.amount": "Сума бюджэту", + "budget.amount.required": "Сума бюджэту павінна быць больш за нуль", + "budget.categories": "Катэгорыі", + "budget.categories.all": "Улічваюцца ўсе выдаткі", + "budget.categories.allShort": "Усе выдаткі", + "budget.categories.description": "У гэты бюджэт уваходзяць толькі выдаткі выбраных катэгорый. Не выбірайце нічога, каб улічваць усе выдаткі.", + "budget.delete": "Выдаліць бюджэт", + "budget.delete.description": "Транзакцыі не будуць закрануты. Гэта дзеянне незваротнае!", + "budget.insight.nearingLimit": "{name} выкарыстана на {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", + "budget.insight.onTrack": "{name} у парадку — засталося {amount}.", + "budget.insight.over": "Вы перавысілі ліміт па {name} на {amount}. Зменшыце выдаткі або павялічце ліміт.", + "budget.insight.overpacing": "Пры такім тэмпе {name} перавысіць ліміт на {amount}.", + "budget.insight.underspending": "{name} мае запас — не выдаткавана {amount}.", + "budget.name": "Назва бюджэту", + "budget.overview.allHealthy": "Усё ідзе па плане. Выдатная праца.", + "budget.overview.budgetsTracked": "{count} бюджэтаў адсочваецца", + "budget.overview.budgetsTracked.one": "{count} бюджэт адсочваецца", + "budget.overview.create": "Новы бюджэт", + "budget.overview.empty": "Пакуль няма бюджэтаў", + "budget.overview.empty.description": "Стварыце бюджэт, каб бачыць, як вашы выдаткі адпавядаюць лімітам.", + "budget.overview.missingRates": "Некаторыя сумы прапушчаныя (адсутнічаюць курсы абмену).", + "budget.overview.nearingLimit": "{count} набліжаюцца да свайго ліміту", + "budget.overview.nearingLimit.one": "{count} набліжаецца да свайго ліміту", + "budget.overview.nothingOver": "Няма перавышэння ліміту", + "budget.overview.overLimit": "{count} перавысілі ліміт", + "budget.overview.overLimit.one": "{count} перавысіў ліміт", + "budget.overview.perBudget": "Вашы бюджэты", + "budget.overview.recommendations": "Рэкамендацыі", + "budget.overview.summary": "Статус", + "budget.overview.title": "Агляд бюджэтаў", + "budget.period": "Перыяд", + "budget.renewAutomatically": "Аднаўляць аўтаматычна", + "budget.renewAutomatically.description": "Калі перыяд заканчваецца, бюджэт пераходзіць на новы перыяд. Напрыклад, бюджэт ліпеня становіцца бюджэтам жніўня.", + "budget.scope": "Ахоп", + "budgets": "Бюджэты", + "budgets.new": "Новы бюджэт", "categories": "Катэгорыі", "categories.addFromPresets": "Дадаць з шаблонаў", "categories.noCategories": "У вас няма катэгорый", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Паспяхова", "enum.ImportV2Progress@waitingConfirmation": "Чаканне пацверджання", "enum.ImportV2Progress@writingAccounts": "Запіс рахункаў", + "enum.ImportV2Progress@writingBudgets": "Запіс бюджэтаў", "enum.ImportV2Progress@writingCategories": "Запіс катэгорый", "enum.ImportV2Progress@writingFileAttachments": "Запіс прымацаваных файлаў", "enum.ImportV2Progress@writingProfile": "Запіс даных профілю", @@ -532,11 +574,10 @@ "support.leaveAReview": "Пакінуць водгук", "support.leaveAReview.action": "Ацаніць Flow", "support.leaveAReview.description": "Вы можаце ацаніць Flow і пакінуць водгук у {appStore}", - "support.requestFeatures": "Падзяліцца ідэямі", - "support.requestFeatures.action": "Наведаць трэкер задач", - "support.requestFeatures.description": "Вы таксама можаце падтрымаць нас, пакінуўшы водгук і прапанаваўшы ідэі па паляпшэнні Flow.", "support.starOnGitHub": "Паставіць зорку на GitHub", "support.starOnGitHub.description": "Зорка для Flow на GitHub дапамагае людзям знаходзіць Flow", + "support.tip.error": "Не ўдалося пачаць пакупку. Калі ласка, паспрабуйце яшчэ раз.", + "support.tip.thankYou": "Дзякуй за падтрымку Flow! 💜", "sync.export": "Экспарт", "sync.export.asCSV": "Як CSV", "sync.export.asCSV.description": "Немагчыма выкарыстоўваць для аднаўлення/імпарту! Ідэальна для адкрыцця ў такіх праграмах, як Google Sheets", @@ -640,13 +681,21 @@ "tabs.profile.community": "Супольнасць", "tabs.profile.guide": "Кіраўніцтва карыстальніка", "tabs.profile.import": "Імпарт", - "tabs.profile.joinDiscord": "Далучайцеся да Discord Flow", "tabs.profile.other": "Іншае", "tabs.profile.preferences": "Налады", "tabs.profile.recommend": "Рэкамендаваць Flow", "tabs.profile.support": "Падтрымаць Flow", "tabs.profile.withLoveFromTheCreator": "з 🤍 ад sadespresso", "tabs.stats": "Статыстыка", + "tabs.stats.analytics.budgets": "Бюджэты", + "tabs.stats.analytics.budgets.empty": "Усталюйце бюджэт выдаткаў", + "tabs.stats.analytics.budgets.nearingCount": "{count} набліжаюцца да ліміту", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} набліжаецца да ліміту", + "tabs.stats.analytics.budgets.onTrack": "Усё па плане", + "tabs.stats.analytics.budgets.overCount": "{count} перавысілі ліміт", + "tabs.stats.analytics.budgets.overCount.one": "{count} перавысіў ліміт", + "tabs.stats.analytics.budgets.tracked": "{count} бюджэтаў", + "tabs.stats.analytics.budgets.tracked.one": "{count} бюджэт", "tabs.stats.analytics.calendar": "Каляндар", "tabs.stats.analytics.calendar.priciestDay": "Ваш самы дарагі дзень — {value}.", "tabs.stats.analytics.calendar.spentIn": "Патрачана ў {}", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 9cbfb159..176353e8 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -34,6 +34,47 @@ "accounts": "Účty", "appName": "Flow", "appShortDesc": "Váš osobní správce financí", + "budget.alert.action": "Zkontrolovat", + "budget.alert.left": "Zbývá {amount}", + "budget.alert.near": "{name}: využito {percent}%", + "budget.alert.over": "{name}: nad rozpočtem", + "budget.alert.overBy": "Překročeno o {amount}", + "budget.amount": "Částka rozpočtu", + "budget.amount.required": "Částka rozpočtu musí být větší než nula", + "budget.categories": "Kategorie", + "budget.categories.all": "Počítají se všechny výdaje", + "budget.categories.allShort": "Všechny výdaje", + "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.insight.over": "U {name} jste překročili 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.underspending": "{name} má 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ů", + "budget.overview.budgetsTracked.one": "{count} sledovaný rozpočet", + "budget.overview.create": "Nový rozpočet", + "budget.overview.empty": "Zatím žádné rozpočty", + "budget.overview.empty.description": "Vytvořte rozpočet, abyste viděli, jak se vaše výdaje drží vůči limitům.", + "budget.overview.missingRates": "Některé částky byly přeskočeny (chybí směnné kurzy).", + "budget.overview.nearingLimit": "{count} rozpočtů se blíží limitu", + "budget.overview.nearingLimit.one": "{count} rozpočet se blíží limitu", + "budget.overview.nothingOver": "Nic nepřekračuje limit", + "budget.overview.overLimit": "{count} rozpočtů překročeno", + "budget.overview.overLimit.one": "{count} rozpočet překročen", + "budget.overview.perBudget": "Vaše rozpočty", + "budget.overview.recommendations": "Doporučení", + "budget.overview.summary": "Stav", + "budget.overview.title": "Přehled rozpočtů", + "budget.period": "Období", + "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", + "budgets": "Rozpočty", + "budgets.new": "Nový rozpočet", "categories": "Kategorie", "categories.addFromPresets": "Přidat z předvoleb", "categories.noCategories": "Nemáte žádné kategorie.", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Úspěšně dokončeno", "enum.ImportV2Progress@waitingConfirmation": "Čekání na potvrzení", "enum.ImportV2Progress@writingAccounts": "Zapisování účtů", + "enum.ImportV2Progress@writingBudgets": "Zapisování rozpočtů", "enum.ImportV2Progress@writingCategories": "Zapisování kategorií", "enum.ImportV2Progress@writingFileAttachments": "Zapisování příloh", "enum.ImportV2Progress@writingProfile": "Zapisování profilu", @@ -532,11 +574,10 @@ "support.leaveAReview": "Zanechte hodnocení", "support.leaveAReview.action": "Ohodnotit Flow", "support.leaveAReview.description": "Můžete ohodnotit Flow a zanechat recenzi na {appStore}.", - "support.requestFeatures": "Navrhněte vylepšení", - "support.requestFeatures.action": "Navštívit sledování problémů", - "support.requestFeatures.description": "Můžete nám také pomoci tím, že nám poskytnete zpětnou vazbu a navrhnete nápady, jak Flow vylepšit.", "support.starOnGitHub": "Dejte hvězdičku na GitHubu", "support.starOnGitHub.description": "Označení projektu Flow hvězdičkou na GitHubu pomáhá ostatním lidem objevit tuto aplikaci.", + "support.tip.error": "Nepodařilo se spustit nákup. Zkuste to prosím znovu.", + "support.tip.thankYou": "Děkujeme, že podporujete Flow! 💜", "sync.export": "Exportovat", "sync.export.asCSV": "jako CSV", "sync.export.asCSV.description": "Nelze použít pro obnovu/import! Ideální pro otevření v tabulkových procesorech.", @@ -639,13 +680,21 @@ "tabs.profile.community": "Komunita", "tabs.profile.guide": "Uživatelská příručka", "tabs.profile.import": "Importovat", - "tabs.profile.joinDiscord": "Připojte se na Flow Discord", "tabs.profile.other": "Ostatní", "tabs.profile.preferences": "Předvolby", "tabs.profile.recommend": "Doporučit Flow", "tabs.profile.support": "Podpořit Flow", "tabs.profile.withLoveFromTheCreator": "s 🤍 od sadespresso", "tabs.stats": "Statistiky", + "tabs.stats.analytics.budgets": "Rozpočty", + "tabs.stats.analytics.budgets.empty": "Nastavte rozpočet na výdaje", + "tabs.stats.analytics.budgets.nearingCount": "{count} se blíží limitu", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} se blíží limitu", + "tabs.stats.analytics.budgets.onTrack": "Vše v pořádku", + "tabs.stats.analytics.budgets.overCount": "{count} nad limitem", + "tabs.stats.analytics.budgets.overCount.one": "{count} nad limitem", + "tabs.stats.analytics.budgets.tracked": "{count} rozpočtů", + "tabs.stats.analytics.budgets.tracked.one": "{count} rozpočet", "tabs.stats.analytics.calendar": "Kalendář", "tabs.stats.analytics.calendar.priciestDay": "Váš nejdražší den je {value}.", "tabs.stats.analytics.calendar.spentIn": "Utraceno v {}", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 16948fd3..02b89ee4 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -34,6 +34,47 @@ "accounts": "Konten", "appName": "Flow", "appShortDesc": "Dein persönlicher Finanz-Tracker", + "budget.alert.action": "Überprüfen", + "budget.alert.left": "{amount} übrig", + "budget.alert.near": "{name}: {percent}% verwendet", + "budget.alert.over": "{name}: Budget überschritten", + "budget.alert.overBy": "Überschreitung um {amount}", + "budget.amount": "Budgetbetrag", + "budget.amount.required": "Der Budgetbetrag muss größer als null sein", + "budget.categories": "Kategorien", + "budget.categories.all": "Alle Ausgaben zählen", + "budget.categories.allShort": "Alle Ausgaben", + "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.insight.over": "Sie liegen bei {name} um {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.underspending": "{name} hat 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", + "budget.overview.budgetsTracked.one": "{count} Budget verfolgt", + "budget.overview.create": "Neues Budget", + "budget.overview.empty": "Noch keine Budgets", + "budget.overview.empty.description": "Erstellen Sie ein Budget, um zu sehen, wie Ihre Ausgaben im Vergleich zu Ihren Limits liegen.", + "budget.overview.missingRates": "Einige Beträge wurden übersprungen (fehlende Wechselkurse).", + "budget.overview.nearingLimit": "{count} nähern sich ihrem Limit", + "budget.overview.nearingLimit.one": "{count} nähert sich dem Limit", + "budget.overview.nothingOver": "Nichts über dem Limit", + "budget.overview.overLimit": "{count} über dem Limit", + "budget.overview.overLimit.one": "{count} über dem Limit", + "budget.overview.perBudget": "Ihre Budgets", + "budget.overview.recommendations": "Empfehlungen", + "budget.overview.summary": "Zusammenfassung", + "budget.overview.title": "Budgetübersicht", + "budget.period": "Zeitraum", + "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", + "budgets": "Budgets", + "budgets.new": "Neues Budget", "categories": "Kategorien", "categories.addFromPresets": "Aus Vorlagen hinzufügen", "categories.noCategories": "Du hast keine Kategorien.", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Erfolgreich", "enum.ImportV2Progress@waitingConfirmation": "Warte auf Bestätigung", "enum.ImportV2Progress@writingAccounts": "Konten werden erstellt", + "enum.ImportV2Progress@writingBudgets": "Budgets werden geschrieben", "enum.ImportV2Progress@writingCategories": "Kategorien werden erstellt", "enum.ImportV2Progress@writingFileAttachments": "Dateianhänge werden gespeichert", "enum.ImportV2Progress@writingProfile": "Profildaten werden erstellt", @@ -532,11 +574,10 @@ "support.leaveAReview": "Bewerte die App", "support.leaveAReview.action": "Flow bewerten", "support.leaveAReview.description": "Du kannst Flow bewerten und eine Rezension im {appStore} schreiben.", - "support.requestFeatures": "Gib uns Ideen", - "support.requestFeatures.action": "Zum Issue Tracker gehen", - "support.requestFeatures.description": "Du kannst uns auch unterstützen, indem du Feedback gibst und Ideen vorschlägst, um Flow besser zu machen.", "support.starOnGitHub": "Flow auf GitHub markieren", "support.starOnGitHub.description": "Wenn du Flow auf GitHub markierst (Stern gibst), hilft das anderen, Flow zu entdecken.", + "support.tip.error": "Der Kauf konnte nicht gestartet werden. Bitte versuchen Sie es erneut.", + "support.tip.thankYou": "Vielen Dank, dass Sie Flow unterstützen! 💜", "sync.export": "Exportieren", "sync.export.asCSV": "Als CSV", "sync.export.asCSV.description": "Kann nicht zum Wiederherstellen/Importieren benutzt werden! Gut zum Öffnen in Programmen wie Google Sheets.", @@ -638,13 +679,21 @@ "tabs.profile.community": "Community", "tabs.profile.guide": "Nutzungsleitfaden", "tabs.profile.import": "Importieren", - "tabs.profile.joinDiscord": "Flow Discord beitreten", "tabs.profile.other": "Sonstiges", "tabs.profile.preferences": "Einstellungen", "tabs.profile.recommend": "Flow weiterempfehlen", "tabs.profile.support": "Flow unterstützen", "tabs.profile.withLoveFromTheCreator": "mit 🤍 von sadespresso", "tabs.stats": "Statistiken", + "tabs.stats.analytics.budgets": "Budgets", + "tabs.stats.analytics.budgets.empty": "Ein Ausgabenbudget festlegen", + "tabs.stats.analytics.budgets.nearingCount": "{count} nähern sich dem Limit", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} nähert sich dem Limit", + "tabs.stats.analytics.budgets.onTrack": "Alles auf Kurs", + "tabs.stats.analytics.budgets.overCount": "{count} über dem Limit", + "tabs.stats.analytics.budgets.overCount.one": "{count} über dem Limit", + "tabs.stats.analytics.budgets.tracked": "{count} Budgets", + "tabs.stats.analytics.budgets.tracked.one": "{count} Budget", "tabs.stats.analytics.calendar": "Kalender", "tabs.stats.analytics.calendar.priciestDay": "Ihr teuerster Tag ist {value}.", "tabs.stats.analytics.calendar.spentIn": "Ausgegeben in {}", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b6dddb2e..4e744308 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -34,6 +34,47 @@ "accounts": "Accounts", "appName": "Flow", "appShortDesc": "Your personal finance tracker", + "budget.alert.action": "Review", + "budget.alert.left": "{amount} left", + "budget.alert.near": "{name}: {percent}% used", + "budget.alert.over": "{name}: over budget", + "budget.alert.overBy": "Over by {amount}", + "budget.amount": "Budget amount", + "budget.amount.required": "Budget amount must be more than zero", + "budget.categories": "Categories", + "budget.categories.all": "All spending counts", + "budget.categories.allShort": "All spending", + "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.insight.over": "You're {amount} over on {name}. Trim spending or raise the limit.", + "budget.insight.overpacing": "At this pace, {name} will overshoot by {amount}.", + "budget.insight.underspending": "{name} has plenty of room — {amount} unspent.", + "budget.name": "Budget name", + "budget.overview.allHealthy": "Everything's on track. Nice work.", + "budget.overview.budgetsTracked": "{count} budgets tracked", + "budget.overview.budgetsTracked.one": "{count} budget tracked", + "budget.overview.create": "New budget", + "budget.overview.empty": "No budgets yet", + "budget.overview.empty.description": "Create a budget to see how your spending tracks against your limits.", + "budget.overview.missingRates": "Some amounts were skipped (missing exchange rates).", + "budget.overview.nearingLimit": "{count} nearing their limit", + "budget.overview.nearingLimit.one": "{count} nearing its limit", + "budget.overview.nothingOver": "Nothing over limit", + "budget.overview.overLimit": "{count} over limit", + "budget.overview.overLimit.one": "{count} over limit", + "budget.overview.perBudget": "Your budgets", + "budget.overview.recommendations": "Recommendations", + "budget.overview.summary": "Status", + "budget.overview.title": "Budget overview", + "budget.period": "Period", + "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", + "budgets": "Budgets", + "budgets.new": "New budget", "categories": "Categories", "categories.addFromPresets": "Add from presets", "categories.noCategories": "You don't have any categories", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Success", "enum.ImportV2Progress@waitingConfirmation": "Waiting for confirmation", "enum.ImportV2Progress@writingAccounts": "Writing accounts", + "enum.ImportV2Progress@writingBudgets": "Writing budgets", "enum.ImportV2Progress@writingCategories": "Writing categories", "enum.ImportV2Progress@writingFileAttachments": "Writing file attachments", "enum.ImportV2Progress@writingProfile": "Writing profile data", @@ -532,11 +574,10 @@ "support.leaveAReview": "Leave a review", "support.leaveAReview.action": "Rate Flow", "support.leaveAReview.description": "You can rate Flow and leave a review on {appStore}", - "support.requestFeatures": "Give us ideas", - "support.requestFeatures.action": "Visit issue tracker", - "support.requestFeatures.description": "You can also support us by giving feedback, and suggestion ideas to make Flow better.", "support.starOnGitHub": "Star on GitHub", "support.starOnGitHub.description": "Starring Flow on GitHub helps people discover Flow", + "support.tip.error": "Couldn't start the purchase. Please try again.", + "support.tip.thankYou": "Thank you for supporting Flow! 💜", "sync.export": "Export", "sync.export.asCSV": "As CSV", "sync.export.asCSV.description": "Cannot be used for restore/import! Ideal for opening in software like Google Sheets", @@ -638,13 +679,21 @@ "tabs.profile.community": "Community", "tabs.profile.guide": "Usage guide", "tabs.profile.import": "Import", - "tabs.profile.joinDiscord": "Join Flow Discord", "tabs.profile.other": "Other", "tabs.profile.preferences": "Preferences", "tabs.profile.recommend": "Recommend Flow", "tabs.profile.support": "Support Flow", "tabs.profile.withLoveFromTheCreator": "with 🤍 from sadespresso", "tabs.stats": "Stats", + "tabs.stats.analytics.budgets": "Budgets", + "tabs.stats.analytics.budgets.empty": "Set a spending budget", + "tabs.stats.analytics.budgets.nearingCount": "{count} nearing limit", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} nearing limit", + "tabs.stats.analytics.budgets.onTrack": "All on track", + "tabs.stats.analytics.budgets.overCount": "{count} over limit", + "tabs.stats.analytics.budgets.overCount.one": "{count} over limit", + "tabs.stats.analytics.budgets.tracked": "{count} budgets", + "tabs.stats.analytics.budgets.tracked.one": "{count} budget", "tabs.stats.analytics.calendar": "Calendar", "tabs.stats.analytics.calendar.priciestDay": "Your priciest day is {value}.", "tabs.stats.analytics.calendar.spentIn": "Spent in {}", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 30c5b27a..abb39158 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -34,6 +34,47 @@ "accounts": "Cuentas", "appName": "Flow", "appShortDesc": "Tu rastreador de finanzas personales", + "budget.alert.action": "Revisar", + "budget.alert.left": "Quedan {amount}", + "budget.alert.near": "{name}: {percent}% gastado", + "budget.alert.over": "{name}: por encima del presupuesto", + "budget.alert.overBy": "Excedido en {amount}", + "budget.amount": "Importe del presupuesto", + "budget.amount.required": "El importe del presupuesto debe ser mayor que cero", + "budget.categories": "Categorías", + "budget.categories.all": "Cuentan todos los gastos", + "budget.categories.allShort": "Todos los gastos", + "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.insight.over": "Has excedido {amount} en {name}. Reduce el gasto o aumenta el límite.", + "budget.insight.overpacing": "A este ritmo, {name} se excederá en {amount}.", + "budget.insight.underspending": "{name} tiene 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", + "budget.overview.budgetsTracked.one": "{count} presupuesto registrado", + "budget.overview.create": "Nuevo presupuesto", + "budget.overview.empty": "Aún no hay presupuestos", + "budget.overview.empty.description": "Crea un presupuesto para ver cómo se comparan tus gastos con tus límites.", + "budget.overview.missingRates": "Se omitieron algunas cantidades (faltan tasas de cambio).", + "budget.overview.nearingLimit": "{count} a punto de alcanzar su límite", + "budget.overview.nearingLimit.one": "{count} a punto de alcanzar su límite", + "budget.overview.nothingOver": "Nada supera el límite", + "budget.overview.overLimit": "{count} por encima del límite", + "budget.overview.overLimit.one": "{count} por encima del límite", + "budget.overview.perBudget": "Tus presupuestos", + "budget.overview.recommendations": "Recomendaciones", + "budget.overview.summary": "Estado", + "budget.overview.title": "Resumen de presupuestos", + "budget.period": "Período", + "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", + "budgets": "Presupuestos", + "budgets.new": "Nuevo presupuesto", "categories": "Categorías", "categories.addFromPresets": "Añadir desde preestablecidos", "categories.noCategories": "No tienes ninguna categoría", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Éxito", "enum.ImportV2Progress@waitingConfirmation": "Esperando confirmación", "enum.ImportV2Progress@writingAccounts": "Escribiendo cuentas", + "enum.ImportV2Progress@writingBudgets": "Escribiendo presupuestos", "enum.ImportV2Progress@writingCategories": "Escribiendo categorías", "enum.ImportV2Progress@writingFileAttachments": "Escribiendo archivos adjuntos", "enum.ImportV2Progress@writingProfile": "Escribiendo datos del perfil", @@ -532,11 +574,10 @@ "support.leaveAReview": "Dejar una reseña", "support.leaveAReview.action": "Valorar Flow", "support.leaveAReview.description": "Puedes valorar Flow y dejar una reseña en {appStore}", - "support.requestFeatures": "Danos ideas", - "support.requestFeatures.action": "Visitar el seguimiento de incidencias", - "support.requestFeatures.description": "También puedes apoyarnos dando tu opinión y sugiriendo ideas para mejorar Flow.", "support.starOnGitHub": "Marcar con estrella en GitHub", "support.starOnGitHub.description": "Marcar Flow con una estrella en GitHub ayuda a que la gente descubra Flow", + "support.tip.error": "No se pudo iniciar la compra. Por favor, inténtalo de nuevo.", + "support.tip.thankYou": "¡Gracias por apoyar Flow! 💜", "sync.export": "Exportar", "sync.export.asCSV": "Como CSV", "sync.export.asCSV.description": "¡No se puede usar para restaurar/importar! Ideal para abrir en software como Google Sheets", @@ -638,13 +679,21 @@ "tabs.profile.community": "Comunidad", "tabs.profile.guide": "Guía de uso", "tabs.profile.import": "Importar", - "tabs.profile.joinDiscord": "Unirse al Discord de Flow", "tabs.profile.other": "Otros", "tabs.profile.preferences": "Preferencias", "tabs.profile.recommend": "Recomendar Flow", "tabs.profile.support": "Apoyar Flow", "tabs.profile.withLoveFromTheCreator": "con 🤍 de sadespresso", "tabs.stats": "Estadísticas", + "tabs.stats.analytics.budgets": "Presupuestos", + "tabs.stats.analytics.budgets.empty": "Establece un presupuesto de gastos", + "tabs.stats.analytics.budgets.nearingCount": "{count} a punto de alcanzar el límite", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} a punto de alcanzar el límite", + "tabs.stats.analytics.budgets.onTrack": "Todo en orden", + "tabs.stats.analytics.budgets.overCount": "{count} por encima del límite", + "tabs.stats.analytics.budgets.overCount.one": "{count} por encima del límite", + "tabs.stats.analytics.budgets.tracked": "{count} presupuestos", + "tabs.stats.analytics.budgets.tracked.one": "{count} presupuesto", "tabs.stats.analytics.calendar": "Calendario", "tabs.stats.analytics.calendar.priciestDay": "Tu día más caro es {value}.", "tabs.stats.analytics.calendar.spentIn": "Gastado en {}", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index b96d0369..b02c1042 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -34,6 +34,47 @@ "accounts": "حساب‌ها", "appName": "Flow", "appShortDesc": "مدیریت مالی شخصی شما", + "budget.alert.action": "بررسی", + "budget.alert.left": "{amount} باقی‌مانده", + "budget.alert.near": "{name}: {percent}% مصرف شده", + "budget.alert.over": "{name}: بیش از بودجه", + "budget.alert.overBy": "بیش از بودجه به میزان {amount}", + "budget.amount": "مبلغ بودجه", + "budget.amount.required": "مبلغ بودجه باید بیشتر از صفر باشد", + "budget.categories": "دسته‌بندی‌ها", + "budget.categories.all": "همه هزینه‌ها حساب می‌شوند", + "budget.categories.allShort": "تمام هزینه‌ها", + "budget.categories.description": "فقط هزینه‌های دسته‌بندی‌های انتخاب‌شده در این بودجه حساب می‌شوند. برای احتساب همه هزینه‌ها هیچ‌کدام را انتخاب نکنید.", + "budget.delete": "حذف بودجه", + "budget.delete.description": "تراکنش‌ها تغییری نمی‌کنند. این عمل غیرقابل بازگشت است!", + "budget.insight.nearingLimit": "{name}، {percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", + "budget.insight.onTrack": "{name} در مسیر درست است — {amount} باقی‌مانده.", + "budget.insight.over": "شما در {name} {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", + "budget.insight.overpacing": "با این روند، {name} تا {amount} بیش از بودجه خواهد شد.", + "budget.insight.underspending": "{name} فضای زیادی دارد — {amount} خرج‌نشده.", + "budget.name": "نام بودجه", + "budget.overview.allHealthy": "همه چیز طبق برنامه است. آفرین.", + "budget.overview.budgetsTracked": "{count} بودجه پیگیری شدند", + "budget.overview.budgetsTracked.one": "{count} بودجه پیگیری شد", + "budget.overview.create": "بودجه جدید", + "budget.overview.empty": "هنوز هیچ بودجه‌ای ندارید", + "budget.overview.empty.description": "یک بودجه ایجاد کنید تا ببینید هزینه‌هایتان نسبت به سقف‌ها چگونه پیش می‌روند.", + "budget.overview.missingRates": "برخی مقادیر نادیده گرفته شدند (نرخ تبدیل ارز موجود نبود).", + "budget.overview.nearingLimit": "{count} در آستانهٔ رسیدن به حد خود هستند", + "budget.overview.nearingLimit.one": "{count} در آستانهٔ رسیدن به حد خود است", + "budget.overview.nothingOver": "هیچ موردی بیشتر از حد نیست", + "budget.overview.overLimit": "{count} مورد از حد عبور کرده‌اند", + "budget.overview.overLimit.one": "{count} مورد از حد عبور کرده است", + "budget.overview.perBudget": "بودجه‌های شما", + "budget.overview.recommendations": "توصیه‌ها", + "budget.overview.summary": "وضعیت", + "budget.overview.title": "نمای کلی بودجه", + "budget.period": "دوره", + "budget.renewAutomatically": "تمدید خودکار", + "budget.renewAutomatically.description": "با پایان دوره، بودجه به دوره جدید منتقل می‌شود. برای مثال، بودجه ژوئیه به بودجه اوت تبدیل می‌شود.", + "budget.scope": "محدوده", + "budgets": "بودجه‌ها", + "budgets.new": "بودجه جدید", "categories": "دسته‌بندی‌ها", "categories.addFromPresets": "افزودن از پیش‌فرض‌ها", "categories.noCategories": "هیچ دسته‌بندی‌ای ندارید", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "موفق", "enum.ImportV2Progress@waitingConfirmation": "در انتظار تأیید", "enum.ImportV2Progress@writingAccounts": "در حال نوشتن حساب‌ها", + "enum.ImportV2Progress@writingBudgets": "در حال نوشتن بودجه‌ها", "enum.ImportV2Progress@writingCategories": "در حال نوشتن دسته‌بندی‌ها", "enum.ImportV2Progress@writingFileAttachments": "در حال نوشتن پیوست‌های فایل", "enum.ImportV2Progress@writingProfile": "در حال نوشتن اطلاعات پروفایل", @@ -532,11 +574,10 @@ "support.leaveAReview": "ثبت نظر", "support.leaveAReview.action": "امتیاز دادن به Flow", "support.leaveAReview.description": "می‌توانید در {appStore} به Flow امتیاز بدهید و نظر ثبت کنید", - "support.requestFeatures": "ایده بدهید", - "support.requestFeatures.action": "رفتن به Issue Tracker", - "support.requestFeatures.description": "می‌توانید با بازخورد و پیشنهاد ایده‌ها هم از Flow حمایت کنید.", "support.starOnGitHub": "ستاره دادن در GitHub", "support.starOnGitHub.description": "ستاره دادن به Flow در GitHub باعث می‌شود افراد بیشتری آن را پیدا کنند", + "support.tip.error": "نمی‌توان خرید را شروع کرد. لطفاً دوباره تلاش کنید.", + "support.tip.thankYou": "از حمایت شما از Flow سپاسگزاریم! 💜", "sync.export": "خروجی گرفتن", "sync.export.asCSV": "به‌صورت CSV", "sync.export.asCSV.description": "برای بازیابی/ایمپورت قابل استفاده نیست! مناسب برای باز کردن در ابزارهایی مثل Google Sheets", @@ -638,13 +679,21 @@ "tabs.profile.community": "جامعه", "tabs.profile.guide": "راهنمای استفاده", "tabs.profile.import": "ایمپورت", - "tabs.profile.joinDiscord": "عضویت در Discord Flow", "tabs.profile.other": "سایر", "tabs.profile.preferences": "تنظیمات", "tabs.profile.recommend": "پیشنهاد Flow", "tabs.profile.support": "حمایت از Flow", "tabs.profile.withLoveFromTheCreator": "با 🤍 از sadespresso", "tabs.stats": "آمار", + "tabs.stats.analytics.budgets": "بودجه‌ها", + "tabs.stats.analytics.budgets.empty": "یک بودجه هزینه‌ای تنظیم کنید", + "tabs.stats.analytics.budgets.nearingCount": "{count} در آستانهٔ رسیدن به حد هستند", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} در آستانهٔ رسیدن به حد است", + "tabs.stats.analytics.budgets.onTrack": "همه طبق برنامه هستند", + "tabs.stats.analytics.budgets.overCount": "{count} مورد از حد عبور کرده‌اند", + "tabs.stats.analytics.budgets.overCount.one": "{count} مورد از حد عبور کرده است", + "tabs.stats.analytics.budgets.tracked": "{count} بودجه", + "tabs.stats.analytics.budgets.tracked.one": "{count} بودجه", "tabs.stats.analytics.calendar": "تقویم", "tabs.stats.analytics.calendar.priciestDay": "گران‌ترین روز شما {value} است.", "tabs.stats.analytics.calendar.spentIn": "هزینه شده در {}", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 130fe32a..ae6462cd 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -34,6 +34,47 @@ "accounts": "Comptes", "appName": "Flow", "appShortDesc": "Votre suivi financier personnel", + "budget.alert.action": "Consulter", + "budget.alert.left": "{amount} restants", + "budget.alert.near": "{name} : {percent}% utilisé", + "budget.alert.over": "{name} : budget dépassé", + "budget.alert.overBy": "Dépassé de {amount}", + "budget.amount": "Montant du budget", + "budget.amount.required": "Le montant du budget doit être supérieur à zéro", + "budget.categories": "Catégories", + "budget.categories.all": "Toutes les dépenses comptent", + "budget.categories.allShort": "Toutes les dépenses", + "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.insight.over": "Vous êtes en dépassement de {amount} pour {name}. Réduisez les dépenses ou augmentez la limite.", + "budget.insight.overpacing": "À ce rythme, {name} dépassera la limite de {amount}.", + "budget.insight.underspending": "{name} a 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", + "budget.overview.budgetsTracked.one": "{count} budget suivi", + "budget.overview.create": "Nouveau budget", + "budget.overview.empty": "Aucun budget pour le moment", + "budget.overview.empty.description": "Créez un budget pour voir comment vos dépenses se comparent à vos limites.", + "budget.overview.missingRates": "Certaines sommes ont été ignorées (taux de change manquants).", + "budget.overview.nearingLimit": "{count} approchent de leur limite", + "budget.overview.nearingLimit.one": "{count} approche de sa limite", + "budget.overview.nothingOver": "Aucun dépassement de limite", + "budget.overview.overLimit": "{count} dépassent la limite", + "budget.overview.overLimit.one": "{count} dépasse la limite", + "budget.overview.perBudget": "Vos budgets", + "budget.overview.recommendations": "Recommandations", + "budget.overview.summary": "État", + "budget.overview.title": "Aperçu du budget", + "budget.period": "Période", + "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", + "budgets": "Budgets", + "budgets.new": "Nouveau budget", "categories": "Catégories", "categories.addFromPresets": "Ajouter à partir des préréglages", "categories.noCategories": "Vous n'avez pas de catégories", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Succès", "enum.ImportV2Progress@waitingConfirmation": "En attente de confirmation", "enum.ImportV2Progress@writingAccounts": "Écriture des comptes", + "enum.ImportV2Progress@writingBudgets": "Écriture des budgets", "enum.ImportV2Progress@writingCategories": "Écriture des catégories", "enum.ImportV2Progress@writingFileAttachments": "Écriture des pièces jointes", "enum.ImportV2Progress@writingProfile": "Écriture des données de profil", @@ -532,11 +574,10 @@ "support.leaveAReview": "Laisser un avis", "support.leaveAReview.action": "Évaluer Flow", "support.leaveAReview.description": "Vous pouvez évaluer Flow et laisser un avis sur {appStore}", - "support.requestFeatures": "Donnez-nous des idées", - "support.requestFeatures.action": "Visiter le traqueur de problèmes", - "support.requestFeatures.description": "Vous pouvez également nous soutenir en donnant des retours et en suggérant des idées pour améliorer Flow.", "support.starOnGitHub": "Étoile sur GitHub", "support.starOnGitHub.description": "Étoiler Flow sur GitHub aide les gens à découvrir Flow", + "support.tip.error": "Impossible de démarrer l'achat. Veuillez réessayer.", + "support.tip.thankYou": "Merci de soutenir Flow ! 💜", "sync.export": "Exporter", "sync.export.asCSV": "En CSV", "sync.export.asCSV.description": "Ne peut pas être utilisé pour la restauration/importation! Idéal pour ouvrir dans des logiciels comme Google Sheets", @@ -638,13 +679,21 @@ "tabs.profile.community": "Communauté", "tabs.profile.guide": "Guide d’utilisation", "tabs.profile.import": "Importer", - "tabs.profile.joinDiscord": "Rejoindre le Discord de Flow", "tabs.profile.other": "Autres", "tabs.profile.preferences": "Préférences", "tabs.profile.recommend": "Recommander Flow", "tabs.profile.support": "Soutenir Flow", "tabs.profile.withLoveFromTheCreator": "avec 🤍 de sadespresso", "tabs.stats": "Statistiques", + "tabs.stats.analytics.budgets": "Budgets", + "tabs.stats.analytics.budgets.empty": "Définissez un budget de dépenses", + "tabs.stats.analytics.budgets.nearingCount": "{count} approchent de leur limite", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} approche de la limite", + "tabs.stats.analytics.budgets.onTrack": "Tout est en ordre", + "tabs.stats.analytics.budgets.overCount": "{count} dépassent la limite", + "tabs.stats.analytics.budgets.overCount.one": "{count} dépasse la limite", + "tabs.stats.analytics.budgets.tracked": "{count} budgets", + "tabs.stats.analytics.budgets.tracked.one": "{count} budget", "tabs.stats.analytics.calendar": "Calendrier", "tabs.stats.analytics.calendar.priciestDay": "Votre jour le plus coûteux est {value}.", "tabs.stats.analytics.calendar.spentIn": "Dépensé en {}", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index fe4ad100..91085de7 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -34,6 +34,47 @@ "accounts": "Conti", "appName": "Flow", "appShortDesc": "Il vostro tracker di finanza personale", + "budget.alert.action": "Rivedi", + "budget.alert.left": "{amount} rimanenti", + "budget.alert.near": "{name}: {percent}% utilizzato", + "budget.alert.over": "{name}: oltre il budget", + "budget.alert.overBy": "Superato di {amount}", + "budget.amount": "Importo del budget", + "budget.amount.required": "L'importo del budget deve essere maggiore di zero", + "budget.categories": "Categorie", + "budget.categories.all": "Contano tutte le spese", + "budget.categories.allShort": "Tutte le spese", + "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.insight.over": "Hai superato di {amount} il budget per {name}. Riduci le spese o aumenta il limite.", + "budget.insight.overpacing": "A questo ritmo, {name} sforerà di {amount}.", + "budget.insight.underspending": "{name} ha molto margine — {amount} non spesi.", + "budget.name": "Nome del budget", + "budget.overview.allHealthy": "Tutto procede bene. Ottimo lavoro.", + "budget.overview.budgetsTracked": "{count} budget monitorati", + "budget.overview.budgetsTracked.one": "{count} budget monitorato", + "budget.overview.create": "Nuovo budget", + "budget.overview.empty": "Ancora nessun budget", + "budget.overview.empty.description": "Crea un budget per vedere come le tue spese si confrontano con i limiti.", + "budget.overview.missingRates": "Alcuni importi sono stati ignorati (mancano i tassi di cambio).", + "budget.overview.nearingLimit": "{count} vicini al limite", + "budget.overview.nearingLimit.one": "{count} vicino al limite", + "budget.overview.nothingOver": "Nessun superamento del limite", + "budget.overview.overLimit": "{count} oltre il limite", + "budget.overview.overLimit.one": "{count} oltre il limite", + "budget.overview.perBudget": "I tuoi budget", + "budget.overview.recommendations": "Consigli", + "budget.overview.summary": "Stato", + "budget.overview.title": "Panoramica dei budget", + "budget.period": "Periodo", + "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", + "budgets": "Budget", + "budgets.new": "Nuovo budget", "categories": "Categorie", "categories.addFromPresets": "Aggiungi dai preset", "categories.noCategories": "Non hai nessuna categoria", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Operazione completata", "enum.ImportV2Progress@waitingConfirmation": "In attesa di conferma", "enum.ImportV2Progress@writingAccounts": "Scrittura dei conti", + "enum.ImportV2Progress@writingBudgets": "Scrittura dei budget", "enum.ImportV2Progress@writingCategories": "Scrittura delle categorie", "enum.ImportV2Progress@writingFileAttachments": "Scrittura dei file allegati", "enum.ImportV2Progress@writingProfile": "Scrittura dei dati del profilo", @@ -532,11 +574,10 @@ "support.leaveAReview": "Lascia una recensione", "support.leaveAReview.action": "Valuta Flow", "support.leaveAReview.description": "Puoi valutare Flow e lasciare una recensione su {appStore}", - "support.requestFeatures": "Dacci idee", - "support.requestFeatures.action": "Visita il tracker di problemi", - "support.requestFeatures.description": "Puoi anche supportarci dando feedback e suggerendo idee per migliorare Flow.", "support.starOnGitHub": "Metti una stella su GitHub", "support.starOnGitHub.description": "Mettere una stella a Flow su GitHub aiuta le persone a scoprire Flow", + "support.tip.error": "Impossibile avviare l'acquisto. Riprova.", + "support.tip.thankYou": "Grazie per il tuo supporto a Flow! 💜", "sync.export": "Esporta", "sync.export.asCSV": "Come CSV", "sync.export.asCSV.description": "Non può essere utilizzato per il ripristino/importazione! Ideale per l'apertura in software come Google Sheets", @@ -638,13 +679,21 @@ "tabs.profile.community": "Comunità", "tabs.profile.guide": "Guida all'uso", "tabs.profile.import": "Importa", - "tabs.profile.joinDiscord": "Unisciti a Flow Discord", "tabs.profile.other": "Altro", "tabs.profile.preferences": "Preferenze", "tabs.profile.recommend": "Raccomanda Flow", "tabs.profile.support": "Supporta Flow", "tabs.profile.withLoveFromTheCreator": "con 🤍 da sadespresso", "tabs.stats": "Statistiche", + "tabs.stats.analytics.budgets": "Budget", + "tabs.stats.analytics.budgets.empty": "Imposta un budget di spesa", + "tabs.stats.analytics.budgets.nearingCount": "{count} vicini al limite", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} vicino al limite", + "tabs.stats.analytics.budgets.onTrack": "Tutto in ordine", + "tabs.stats.analytics.budgets.overCount": "{count} oltre il limite", + "tabs.stats.analytics.budgets.overCount.one": "{count} oltre il limite", + "tabs.stats.analytics.budgets.tracked": "{count} budget", + "tabs.stats.analytics.budgets.tracked.one": "{count} budget", "tabs.stats.analytics.calendar": "Calendario", "tabs.stats.analytics.calendar.priciestDay": "Il giorno più costoso è {value}.", "tabs.stats.analytics.calendar.spentIn": "Speso in {}", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 9516060c..eb137d77 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -34,6 +34,47 @@ "accounts": "Данснууд", "appName": "Flow", "appShortDesc": "Таны хувийн санхүүч", + "budget.alert.action": "Шалгах", + "budget.alert.left": "{amount} үлдсэн", + "budget.alert.near": "{name}: {percent}% зарцуулсан", + "budget.alert.over": "{name}: төсөв давсан", + "budget.alert.overBy": "{amount}-р давсан", + "budget.amount": "Төсвийн дүн", + "budget.amount.required": "Төсвийн дүн тэгээс их байх ёстой", + "budget.categories": "Ангиллууд", + "budget.categories.all": "Бүх зарлага тооцогдоно", + "budget.categories.allShort": "Бүх зарцуулалт", + "budget.categories.description": "Зөвхөн сонгосон ангиллын зарлага энэ төсөвт тооцогдоно. Юу ч сонгоогүй бол бүх зарлага тооцогдоно.", + "budget.delete": "Төсвийг устгах", + "budget.delete.description": "Гүйлгээнүүдэд нөлөөлөхгүй. Энэ үйлдлийг буцаах боломжгүй!", + "budget.insight.nearingLimit": "{name} нь {percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", + "budget.insight.onTrack": "{name} хэвийн байна — {amount} үлдсэн.", + "budget.insight.over": "Та {name} төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", + "budget.insight.overpacing": "Энэ янзаараа {name} төсөв {amount}-р хэтрэх болно.", + "budget.insight.underspending": "{name} төсөвт {amount} зарцуулагдаагүй үлджээ.", + "budget.name": "Төсвийн нэр", + "budget.overview.allHealthy": "Бүх зүйл төсвийг дагуу. Янзтай!", + "budget.overview.budgetsTracked": "{count} төсөв хянагдаж байна", + "budget.overview.budgetsTracked.one": "{count} төсөв хянагдаж байна", + "budget.overview.create": "Шинэ төсөв", + "budget.overview.empty": "Одоогоор төсөв байхгүй", + "budget.overview.empty.description": "Та зарлагаа илүү бүтээмжтэй хянахыг хүсвэл төсөв үүсгээрэй.", + "budget.overview.missingRates": "Зарим дүн орхигдсон (ханшийн мэдээлэл алга байна).", + "budget.overview.nearingLimit": "{count} нь хязгаарт дөхөж байна", + "budget.overview.nearingLimit.one": "{count} нь хязгаарт дөхөж байна", + "budget.overview.nothingOver": "Хэтэрсэн төсөв алга", + "budget.overview.overLimit": "{count} нь төсөв хэтэрсэн", + "budget.overview.overLimit.one": "{count} нь төсөв хэтэрсэн", + "budget.overview.perBudget": "Таны төсвүүд", + "budget.overview.recommendations": "Зөвлөмжүүд", + "budget.overview.summary": "Төлөв", + "budget.overview.title": "Төсвийн тойм", + "budget.period": "Хугацаа", + "budget.renewAutomatically": "Автоматаар шинэчлэх", + "budget.renewAutomatically.description": "Хугацаа дуусахад төсөв дараагийн хугацаа руу шилжинэ. Жишээ нь, 7-р сарын төсөв 8-р сарын төсөв болно.", + "budget.scope": "Хүрээ", + "budgets": "Төсвүүд", + "budgets.new": "Шинэ төсөв", "categories": "Ангиллууд", "categories.addFromPresets": "Загваруудаас нэмэх", "categories.noCategories": "Танд үүсгэсэн ангилал алга байна", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Амжилттай", "enum.ImportV2Progress@waitingConfirmation": "Баталгаажуулалт хүлээж байна", "enum.ImportV2Progress@writingAccounts": "Данснуудыг бичиж байна", + "enum.ImportV2Progress@writingBudgets": "Төсвүүдийг бичиж байна", "enum.ImportV2Progress@writingCategories": "Ангиллуудыг бичиж байна", "enum.ImportV2Progress@writingFileAttachments": "Файлын хавсралтуудыг бичиж байна", "enum.ImportV2Progress@writingProfile": "Бүртгэлийг бичиж байна", @@ -532,11 +574,10 @@ "support.leaveAReview": "Үнэлгээ өгөх", "support.leaveAReview.action": "Flow-г үнэлэх", "support.leaveAReview.description": "Та {appStore}-д үнэлгээ өгч, сэтгэгдэл үлдээх боломжтой", - "support.requestFeatures": "Санаагаа хуваалц", - "support.requestFeatures.action": "Төлөвлөгөө/асуудлууд харах", - "support.requestFeatures.description": "Та өөрийн санал хүсэлтээ бидэнд хэлснээр Flow-г сайжруулахад туслах боломжтой.", "support.starOnGitHub": "GitHub репод од өгөх", "support.starOnGitHub.description": "Од нь Flow-г илүү олон хүнд хүрэхэд туслана", + "support.tip.error": "Худалдан авалтыг эхлүүлэхэд алдаа гарлаа. Дахин оролдоно уу.", + "support.tip.thankYou": "Flow-ийг дэмжсэнд баярлалаа! 💜", "sync.export": "Нөөцлөх", "sync.export.asCSV": "CSV хүснэгтээр", "sync.export.asCSV.description": "Буцааж сэргээх боломжгүй! Google Sheets гэх мэт программуудад нээж харахад тохиромжтой", @@ -638,13 +679,21 @@ "tabs.profile.community": "Холбоо", "tabs.profile.guide": "Ашиглах заавар", "tabs.profile.import": "Сэргээх", - "tabs.profile.joinDiscord": "Discord-д нэгдэх", "tabs.profile.other": "Бусад", "tabs.profile.preferences": "Тохиргоо", "tabs.profile.recommend": "Flow-г санал болгох", "tabs.profile.support": "Flow-г дэмжих", "tabs.profile.withLoveFromTheCreator": "sadespresso хайр шингээж бүтээв 🤍", "tabs.stats": "Тоо", + "tabs.stats.analytics.budgets": "Төсөвүүд", + "tabs.stats.analytics.budgets.empty": "Зарцуулалтын төсөв тогтоох", + "tabs.stats.analytics.budgets.nearingCount": "{count} нь хязгаарт дөхөж байна", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} нь хязгаарт дөхөж байна", + "tabs.stats.analytics.budgets.onTrack": "Бүгд хэвийн байна", + "tabs.stats.analytics.budgets.overCount": "{count} нь хязгаараас давсан", + "tabs.stats.analytics.budgets.overCount.one": "{count} нь хязгаараас давсан", + "tabs.stats.analytics.budgets.tracked": "{count} төсөв", + "tabs.stats.analytics.budgets.tracked.one": "{count} төсөв", "tabs.stats.analytics.calendar": "Календар", "tabs.stats.analytics.calendar.priciestDay": "Таны хамгийн их зарцуулалттай өдөр нь {value}.", "tabs.stats.analytics.calendar.spentIn": "{} дотор зарцуулсан", @@ -882,4 +931,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 b180c8e0..84c7650a 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -34,6 +34,47 @@ "accounts": "Konta", "appName": "Flow", "appShortDesc": "Twój osobisty asystent finansowy", + "budget.alert.action": "Przejrzyj", + "budget.alert.left": "{amount} pozostało", + "budget.alert.near": "{name}: wykorzystano {percent}%", + "budget.alert.over": "{name}: przekroczono budżet", + "budget.alert.overBy": "Przekroczono o {amount}", + "budget.amount": "Kwota budżetu", + "budget.amount.required": "Kwota budżetu musi być większa od zera", + "budget.categories": "Kategorie", + "budget.categories.all": "Liczą się wszystkie wydatki", + "budget.categories.allShort": "Wszystkie wydatki", + "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.insight.over": "Masz przekroczenie o {amount} w {name}. Ogranicz wydatki lub zwiększ limit.", + "budget.insight.overpacing": "W tym tempie {name} przekroczy limit o {amount}.", + "budget.insight.underspending": "{name} ma 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", + "budget.overview.budgetsTracked.one": "{count} śledzony budżet", + "budget.overview.create": "Nowy budżet", + "budget.overview.empty": "Brak budżetów", + "budget.overview.empty.description": "Utwórz budżet, aby zobaczyć, jak Twoje wydatki mają się do limitów.", + "budget.overview.missingRates": "Niektóre kwoty zostały pominięte (brak kursów wymiany).", + "budget.overview.nearingLimit": "{count} zbliżają się do swojego limitu", + "budget.overview.nearingLimit.one": "{count} zbliża się do swojego limitu", + "budget.overview.nothingOver": "Nic nie przekracza limitu", + "budget.overview.overLimit": "{count} przekroczonych budżetów", + "budget.overview.overLimit.one": "{count} przekroczony budżet", + "budget.overview.perBudget": "Twoje budżety", + "budget.overview.recommendations": "Rekomendacje", + "budget.overview.summary": "Status", + "budget.overview.title": "Przegląd budżetów", + "budget.period": "Okres", + "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", + "budgets": "Budżety", + "budgets.new": "Nowy budżet", "categories": "Kategorie", "categories.addFromPresets": "Dodaj z gotowych szablonów", "categories.noCategories": "Nie masz żadnych kategorii", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Sukces", "enum.ImportV2Progress@waitingConfirmation": "Oczekiwanie na potwierdzenie", "enum.ImportV2Progress@writingAccounts": "Zapisywanie kont", + "enum.ImportV2Progress@writingBudgets": "Zapisywanie budżetów", "enum.ImportV2Progress@writingCategories": "Zapisywanie kategorii", "enum.ImportV2Progress@writingFileAttachments": "Zapisywanie załączników", "enum.ImportV2Progress@writingProfile": "Zapisywanie danych profilu", @@ -532,11 +574,10 @@ "support.leaveAReview": "Zostaw opinię", "support.leaveAReview.action": "Oceń Flow", "support.leaveAReview.description": "Możesz ocenić Flow i wystawić recenzję w {appStore}", - "support.requestFeatures": "Zgłoś pomysły", - "support.requestFeatures.action": "Odwiedź tracker błędów", - "support.requestFeatures.description": "Możesz także nas wesprzeć, przesyłając nam opinie i nowe pomysły, by ulepszyć aplikację.", "support.starOnGitHub": "Zostaw gwiazdkę na GitHub", "support.starOnGitHub.description": "Zostawienie gwiazdki dla Flow na GitHubie ułatwia odkrycie aplikacji innym osobom", + "support.tip.error": "Nie udało się rozpocząć zakupu. Spróbuj ponownie.", + "support.tip.thankYou": "Dziękujemy za wsparcie Flow! 💜", "sync.export": "Eksportuj", "sync.export.asCSV": "Jako plik CSV", "sync.export.asCSV.description": "Nie można wykorzystać do przywracania danych! Idealne do otwierania w programach takich jak Arkusze Google", @@ -640,13 +681,21 @@ "tabs.profile.community": "Społeczność", "tabs.profile.guide": "Przewodnik", "tabs.profile.import": "Import", - "tabs.profile.joinDiscord": "Dołącz do Flow na Discordzie", "tabs.profile.other": "Inne", "tabs.profile.preferences": "Preferencje", "tabs.profile.recommend": "Poleć Flow", "tabs.profile.support": "Wsparcie Flow", "tabs.profile.withLoveFromTheCreator": "z 🤍 od sadespresso", "tabs.stats": "Statystyki", + "tabs.stats.analytics.budgets": "Budżety", + "tabs.stats.analytics.budgets.empty": "Ustaw budżet wydatków", + "tabs.stats.analytics.budgets.nearingCount": "{count} zbliżają się do limitu", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} zbliża się do limitu", + "tabs.stats.analytics.budgets.onTrack": "Wszystko na dobrej drodze", + "tabs.stats.analytics.budgets.overCount": "{count} przekroczonych budżetów", + "tabs.stats.analytics.budgets.overCount.one": "{count} przekroczony budżet", + "tabs.stats.analytics.budgets.tracked": "{count} budżetów", + "tabs.stats.analytics.budgets.tracked.one": "{count} budżet", "tabs.stats.analytics.calendar": "Kalendarz", "tabs.stats.analytics.calendar.priciestDay": "Twój najdroższy dzień to {value}.", "tabs.stats.analytics.calendar.spentIn": "Wydano w {}", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 925131f6..c885dce0 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -34,6 +34,47 @@ "accounts": "Счета", "appName": "Flow", "appShortDesc": "Ваш личный финансовый трекер", + "budget.alert.action": "Проверить", + "budget.alert.left": "{amount} осталось", + "budget.alert.near": "{name}: использовано {percent}%", + "budget.alert.over": "{name}: превышен лимит", + "budget.alert.overBy": "Превышено на {amount}", + "budget.amount": "Сумма бюджета", + "budget.amount.required": "Сумма бюджета должна быть больше нуля", + "budget.categories": "Категории", + "budget.categories.all": "Учитываются все расходы", + "budget.categories.allShort": "Все расходы", + "budget.categories.description": "В этот бюджет входят только расходы выбранных категорий. Не выбирайте ничего, чтобы учитывать все расходы.", + "budget.delete": "Удалить бюджет", + "budget.delete.description": "Транзакции не будут затронуты. Это действие необратимо!", + "budget.insight.nearingLimit": "{name}: потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", + "budget.insight.onTrack": "{name} в норме — осталось {amount}.", + "budget.insight.over": "По {name} вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", + "budget.insight.overpacing": "С таким темпом {name} превысит лимит на {amount}.", + "budget.insight.underspending": "{name} имеет запас — не потрачено {amount}.", + "budget.name": "Название бюджета", + "budget.overview.allHealthy": "Все в порядке. Отличная работа.", + "budget.overview.budgetsTracked": "{count} отслеживаемых бюджетов", + "budget.overview.budgetsTracked.one": "{count} отслеживаемый бюджет", + "budget.overview.create": "Новый бюджет", + "budget.overview.empty": "Пока нет бюджетов", + "budget.overview.empty.description": "Создайте бюджет, чтобы увидеть, как ваши расходы соотносятся с лимитами.", + "budget.overview.missingRates": "Некоторые суммы пропущены (отсутствуют курсы обмена).", + "budget.overview.nearingLimit": "{count} приближаются к лимиту", + "budget.overview.nearingLimit.one": "{count} приближается к лимиту", + "budget.overview.nothingOver": "Превышений нет", + "budget.overview.overLimit": "{count} превышают лимит", + "budget.overview.overLimit.one": "{count} превышает лимит", + "budget.overview.perBudget": "Ваши бюджеты", + "budget.overview.recommendations": "Рекомендации", + "budget.overview.summary": "Статус", + "budget.overview.title": "Обзор бюджетов", + "budget.period": "Период", + "budget.renewAutomatically": "Продлевать автоматически", + "budget.renewAutomatically.description": "Когда период заканчивается, бюджет переходит на новый период. Например, бюджет июля становится бюджетом августа.", + "budget.scope": "Область", + "budgets": "Бюджеты", + "budgets.new": "Новый бюджет", "categories": "Категории", "categories.addFromPresets": "Добавить из предустановок", "categories.noCategories": "У вас нет категорий", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Успех", "enum.ImportV2Progress@waitingConfirmation": "Ожидание подтверждения", "enum.ImportV2Progress@writingAccounts": "Запись счетов", + "enum.ImportV2Progress@writingBudgets": "Запись бюджетов", "enum.ImportV2Progress@writingCategories": "Запись категорий", "enum.ImportV2Progress@writingFileAttachments": "Запись файловых вложений", "enum.ImportV2Progress@writingProfile": "Запись данных профиля", @@ -532,11 +574,10 @@ "support.leaveAReview": "Оставить отзыв", "support.leaveAReview.action": "Оценить Flow", "support.leaveAReview.description": "Вы можете оценить Flow и оставить отзыв в {appStore}", - "support.requestFeatures": "Поделитесь идеями", - "support.requestFeatures.action": "Посетить трекер проблем", - "support.requestFeatures.description": "Вы также можете поддержать нас, оставив отзыв и предложив идеи по улучшению Flow.", "support.starOnGitHub": "Отметить на GitHub", "support.starOnGitHub.description": "Отметка Flow на GitHub помогает людям узнать о Flow", + "support.tip.error": "Не удалось начать покупку. Пожалуйста, попробуйте еще раз.", + "support.tip.thankYou": "Спасибо за поддержку Flow! 💜", "sync.export": "Экспорт", "sync.export.asCSV": "Как CSV", "sync.export.asCSV.description": "Не может использоваться для восстановления/импорта! Идеально для открытия в программах типа Google Sheets", @@ -640,13 +681,21 @@ "tabs.profile.community": "Сообщество", "tabs.profile.guide": "Руководство по использованию", "tabs.profile.import": "Импорт", - "tabs.profile.joinDiscord": "Присоединиться к Flow Discord", "tabs.profile.other": "Другое", "tabs.profile.preferences": "Настройки", "tabs.profile.recommend": "Рекомендовать Flow", "tabs.profile.support": "Поддержать Flow", "tabs.profile.withLoveFromTheCreator": "с 🤍 от sadespresso", "tabs.stats": "Статистика", + "tabs.stats.analytics.budgets": "Бюджеты", + "tabs.stats.analytics.budgets.empty": "Установите бюджет расходов", + "tabs.stats.analytics.budgets.nearingCount": "{count} приближаются к лимиту", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} приближается к лимиту", + "tabs.stats.analytics.budgets.onTrack": "Все в порядке", + "tabs.stats.analytics.budgets.overCount": "{count} превышают лимит", + "tabs.stats.analytics.budgets.overCount.one": "{count} превышает лимит", + "tabs.stats.analytics.budgets.tracked": "{count} бюджетов", + "tabs.stats.analytics.budgets.tracked.one": "{count} бюджет", "tabs.stats.analytics.calendar": "Календарь", "tabs.stats.analytics.calendar.priciestDay": "Ваш самый дорогой день — {value}.", "tabs.stats.analytics.calendar.spentIn": "Потрачено в {}", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index c4bcdb45..8c5f71e9 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -34,6 +34,47 @@ "accounts": "Hesap", "appName": "Flow", "appShortDesc": "Kişisel finans takipçiniz", + "budget.alert.action": "Gözden geçir", + "budget.alert.left": "{amount} kaldı", + "budget.alert.near": "{name}: {percent}% kullanıldı", + "budget.alert.over": "{name}: bütçeyi aştı", + "budget.alert.overBy": "{amount} aşıldı", + "budget.amount": "Bütçe tutarı", + "budget.amount.required": "Bütçe tutarı sıfırdan büyük olmalıdır", + "budget.categories": "Kategoriler", + "budget.categories.all": "Tüm harcamalar sayılır", + "budget.categories.allShort": "Tüm harcamalar", + "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.insight.over": "{name} bütçesini {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.underspending": "{name} için 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", + "budget.overview.budgetsTracked.one": "{count} bütçe takip ediliyor", + "budget.overview.create": "Yeni bütçe", + "budget.overview.empty": "Henüz bütçe yok", + "budget.overview.empty.description": "Harcamalarınızın limitlerinize karşı nasıl ilerlediğini görmek için bir bütçe oluşturun.", + "budget.overview.missingRates": "Bazı tutarlar atlandı (döviz kurları eksik).", + "budget.overview.nearingLimit": "{count} limitine yaklaşıyor", + "budget.overview.nearingLimit.one": "{count} limitine yaklaşıyor", + "budget.overview.nothingOver": "Limit aşılanmadı", + "budget.overview.overLimit": "{count} limit aşıldı", + "budget.overview.overLimit.one": "{count} limit aşıldı", + "budget.overview.perBudget": "Bütçeleriniz", + "budget.overview.recommendations": "Öneriler", + "budget.overview.summary": "Durum", + "budget.overview.title": "Bütçe genel bakışı", + "budget.period": "Dönem", + "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", + "budgets": "Bütçeler", + "budgets.new": "Yeni bütçe", "categories": "Kategori", "categories.addFromPresets": "Ön ayarlardan ekle", "categories.noCategories": "Herhangi bir kategoriniz yok", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Başarılı", "enum.ImportV2Progress@waitingConfirmation": "Onay bekleniyor", "enum.ImportV2Progress@writingAccounts": "Hesap yazma", + "enum.ImportV2Progress@writingBudgets": "Bütçeler yazılıyor", "enum.ImportV2Progress@writingCategories": "Yazma kategorileri", "enum.ImportV2Progress@writingFileAttachments": "Dosya ekleri yazılıyor", "enum.ImportV2Progress@writingProfile": "Profil verilerinin yazılması", @@ -532,11 +574,10 @@ "support.leaveAReview": "Bir inceleme bırakın", "support.leaveAReview.action": "Flow'u değerlendir", "support.leaveAReview.description": "Flow'u değerlendirebilir ve {appStore} üzerinde bir inceleme bırakabilirsiniz", - "support.requestFeatures": "Bize fikir verin", - "support.requestFeatures.action": "Sorun izleyiciyi ziyaret edin", - "support.requestFeatures.description": "Flow'u daha iyi hale getirmek için geri bildirim ve öneri fikirleri vererek de bize destek olabilirsiniz.", "support.starOnGitHub": "GitHub'da yıldız verin", "support.starOnGitHub.description": "Flow'a GitHub'da yıldız vermek, insanların Flow'u keşfetmesine yardımcı olur", + "support.tip.error": "Satın alma başlatılamadı. Lütfen tekrar deneyin.", + "support.tip.thankYou": "Flow'u desteklediğiniz için teşekkür ederiz! 💜", "sync.export": "Dışa aktarma", "sync.export.asCSV": "CSV olarak", "sync.export.asCSV.description": "Geri yükleme/içe aktarma için kullanılamaz! Google E-Tablolar gibi yazılımlarda açmak için ideal", @@ -638,13 +679,21 @@ "tabs.profile.community": "Topluluk", "tabs.profile.guide": "Kullanım kılavuzu", "tabs.profile.import": "İçe aktarmak", - "tabs.profile.joinDiscord": "Flow Discord'a Katılın", "tabs.profile.other": "Diğer", "tabs.profile.preferences": "Tercihler", "tabs.profile.recommend": "Flow'u öner", "tabs.profile.support": "Flow Destek", "tabs.profile.withLoveFromTheCreator": "sadespresso'dan 🤍", "tabs.stats": "İstatistik", + "tabs.stats.analytics.budgets": "Bütçeler", + "tabs.stats.analytics.budgets.empty": "Harcamalar için bütçe belirleyin", + "tabs.stats.analytics.budgets.nearingCount": "{count} limitine yaklaşıyor", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} limitine yaklaşıyor", + "tabs.stats.analytics.budgets.onTrack": "Hepsi yolunda", + "tabs.stats.analytics.budgets.overCount": "{count} limit aşıldı", + "tabs.stats.analytics.budgets.overCount.one": "{count} limit aşıldı", + "tabs.stats.analytics.budgets.tracked": "{count} bütçe", + "tabs.stats.analytics.budgets.tracked.one": "{count} bütçe", "tabs.stats.analytics.calendar": "Takvim", "tabs.stats.analytics.calendar.priciestDay": "En pahalı gününüz {value}.", "tabs.stats.analytics.calendar.spentIn": "{} içinde harcandı", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 616e61ba..4462caf5 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -34,6 +34,47 @@ "accounts": "Рахунки", "appName": "Flow", "appShortDesc": "Ваш особистий фінансовий трекер", + "budget.alert.action": "Переглянути", + "budget.alert.left": "{amount} залишилося", + "budget.alert.near": "{name}: використано {percent}%", + "budget.alert.over": "{name}: перевищено бюджет", + "budget.alert.overBy": "Перевищено на {amount}", + "budget.amount": "Сума бюджету", + "budget.amount.required": "Сума бюджету має бути більшою за нуль", + "budget.categories": "Категорії", + "budget.categories.all": "Враховуються всі витрати", + "budget.categories.allShort": "Усі витрати", + "budget.categories.description": "До цього бюджету входять лише витрати вибраних категорій. Не вибирайте нічого, щоб враховувати всі витрати.", + "budget.delete": "Видалити бюджет", + "budget.delete.description": "Транзакції не будуть змінені. Ця дія незворотна!", + "budget.insight.nearingLimit": "{name} використано на {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", + "budget.insight.onTrack": "{name} в рамках бюджету — залишилося {amount}.", + "budget.insight.over": "Ви перевищили бюджет {name} на {amount}. Зменшіть витрати або підвищіть ліміт.", + "budget.insight.overpacing": "При такому темпі {name} перевищить ліміт на {amount}.", + "budget.insight.underspending": "{name} має запас — не витрачено {amount}.", + "budget.name": "Назва бюджету", + "budget.overview.allHealthy": "Усе йде за планом. Чудова робота.", + "budget.overview.budgetsTracked": "Відстежується {count} бюджетів", + "budget.overview.budgetsTracked.one": "Відстежується {count} бюджет", + "budget.overview.create": "Новий бюджет", + "budget.overview.empty": "Бюджетів ще немає", + "budget.overview.empty.description": "Створіть бюджет, щоб бачити, як ваші витрати співвідносяться з лімітами.", + "budget.overview.missingRates": "Деякі суми було пропущено (відсутні курси обміну).", + "budget.overview.nearingLimit": "{count} наближаються до ліміту", + "budget.overview.nearingLimit.one": "{count} наближається до ліміту", + "budget.overview.nothingOver": "Немає перевищень ліміту.", + "budget.overview.overLimit": "{count} перевищують ліміт", + "budget.overview.overLimit.one": "{count} перевищує ліміт", + "budget.overview.perBudget": "Ваші бюджети", + "budget.overview.recommendations": "Рекомендації", + "budget.overview.summary": "Статус", + "budget.overview.title": "Огляд бюджету", + "budget.period": "Період", + "budget.renewAutomatically": "Поновлювати автоматично", + "budget.renewAutomatically.description": "Коли період закінчується, бюджет переходить на новий період. Наприклад, бюджет липня стає бюджетом серпня.", + "budget.scope": "Охоплення", + "budgets": "Бюджети", + "budgets.new": "Новий бюджет", "categories": "Категорії", "categories.addFromPresets": "Додати з передустановок", "categories.noCategories": "У вас немає категорій", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "Успіх", "enum.ImportV2Progress@waitingConfirmation": "Очікування підтвердження", "enum.ImportV2Progress@writingAccounts": "Запис рахунків", + "enum.ImportV2Progress@writingBudgets": "Запис бюджетів", "enum.ImportV2Progress@writingCategories": "Запис категорій", "enum.ImportV2Progress@writingFileAttachments": "Запис файлових вкладень", "enum.ImportV2Progress@writingProfile": "Запис даних профілю", @@ -532,11 +574,10 @@ "support.leaveAReview": "Залишити відгук", "support.leaveAReview.action": "Оцінити Flow", "support.leaveAReview.description": "Ви можете оцінити Flow та залишити відгук у {appStore}", - "support.requestFeatures": "Поділіться ідеями", - "support.requestFeatures.action": "Відвідати трекер проблем", - "support.requestFeatures.description": "Ви також можете підтримати нас, залишивши відгук та запропонувавши ідеї щодо покращення Flow.", "support.starOnGitHub": "Відзначити на GitHub", "support.starOnGitHub.description": "Відзнака Flow на GitHub допомагає людям дізнатися про Flow", + "support.tip.error": "Не вдалося розпочати покупку. Будь ласка, спробуйте ще раз.", + "support.tip.thankYou": "Дякуємо, що підтримуєте Flow! 💜", "sync.export": "Експорт", "sync.export.asCSV": "Як CSV", "sync.export.asCSV.description": "Не може використовуватися для відновлення/імпорту! Ідеально для відкриття в програмах типу Google Sheets", @@ -640,13 +681,21 @@ "tabs.profile.community": "Спільнота", "tabs.profile.guide": "Посібник з користування", "tabs.profile.import": "Імпорт", - "tabs.profile.joinDiscord": "Приєднатися до Flow Discord", "tabs.profile.other": "Інше", "tabs.profile.preferences": "Налаштування", "tabs.profile.recommend": "Рекомендувати Flow", "tabs.profile.support": "Підтримати Flow", "tabs.profile.withLoveFromTheCreator": "з 🤍 від sadespresso", "tabs.stats": "Статистика", + "tabs.stats.analytics.budgets": "Бюджети", + "tabs.stats.analytics.budgets.empty": "Встановіть бюджет витрат", + "tabs.stats.analytics.budgets.nearingCount": "{count} наближаються до ліміту", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} наближається до ліміту", + "tabs.stats.analytics.budgets.onTrack": "Усе в межах ліміту", + "tabs.stats.analytics.budgets.overCount": "{count} перевищують ліміт", + "tabs.stats.analytics.budgets.overCount.one": "{count} перевищує ліміт", + "tabs.stats.analytics.budgets.tracked": "{count} бюджетів", + "tabs.stats.analytics.budgets.tracked.one": "{count} бюджет", "tabs.stats.analytics.calendar": "Календар", "tabs.stats.analytics.calendar.priciestDay": "Ваш найдорожчий день — {value}.", "tabs.stats.analytics.calendar.spentIn": "Витрачено в {}", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 32b6ecf9..4fe94270 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -34,6 +34,47 @@ "accounts": "帳戶", "appName": "Flow", "appShortDesc": "您的個人理財追蹤器", + "budget.alert.action": "查看", + "budget.alert.left": "{amount} 剩餘", + "budget.alert.near": "{name}:已使用 {percent}%", + "budget.alert.over": "{name}:超出預算", + "budget.alert.overBy": "超過 {amount}", + "budget.amount": "預算金額", + "budget.amount.required": "預算金額必須大於零", + "budget.categories": "分類", + "budget.categories.all": "計入所有支出", + "budget.categories.allShort": "所有支出", + "budget.categories.description": "只有所選分類的支出會計入此預算。若未選擇任何分類,則計入所有支出。", + "budget.delete": "刪除預算", + "budget.delete.description": "交易不會受到影響。此操作無法復原!", + "budget.insight.nearingLimit": "{name} 已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", + "budget.insight.onTrack": "{name} 進度正常 — {amount} 剩餘。", + "budget.insight.over": "你在 {name} 超出 {amount}。請減少支出或提高上限。", + "budget.insight.overpacing": "照此速度,{name} 將超出 {amount}。", + "budget.insight.underspending": "{name} 空間充足 — 尚有 {amount} 未使用。", + "budget.name": "預算名稱", + "budget.overview.allHealthy": "一切正常,做得好。", + "budget.overview.budgetsTracked": "追蹤 {count} 個預算", + "budget.overview.budgetsTracked.one": "追蹤 {count} 個預算", + "budget.overview.create": "新增預算", + "budget.overview.empty": "尚無預算", + "budget.overview.empty.description": "建立預算以檢視你的支出是否在限額內。", + "budget.overview.missingRates": "某些金額被略過(缺少匯率)。", + "budget.overview.nearingLimit": "{count} 個接近上限", + "budget.overview.nearingLimit.one": "{count} 個接近上限", + "budget.overview.nothingOver": "沒有超出限額", + "budget.overview.overLimit": "{count} 個超出限額", + "budget.overview.overLimit.one": "{count} 個超出限額", + "budget.overview.perBudget": "你的預算", + "budget.overview.recommendations": "建議", + "budget.overview.summary": "狀態", + "budget.overview.title": "預算總覽", + "budget.period": "期間", + "budget.renewAutomatically": "自動續期", + "budget.renewAutomatically.description": "期間結束後,預算會進入新的期間。例如,七月的預算會變成八月的預算。", + "budget.scope": "範圍", + "budgets": "預算", + "budgets.new": "新增預算", "categories": "分類", "categories.addFromPresets": "從預設新增", "categories.noCategories": "您目前沒有任何分類", @@ -113,6 +154,7 @@ "enum.ImportV2Progress@success": "成功", "enum.ImportV2Progress@waitingConfirmation": "等待確認中", "enum.ImportV2Progress@writingAccounts": "正在寫入帳戶", + "enum.ImportV2Progress@writingBudgets": "正在寫入預算", "enum.ImportV2Progress@writingCategories": "正在寫入分類", "enum.ImportV2Progress@writingFileAttachments": "正在寫入檔案附件", "enum.ImportV2Progress@writingProfile": "正在寫入個人資料", @@ -532,11 +574,10 @@ "support.leaveAReview": "留下評論", "support.leaveAReview.action": "為 Flow 評分", "support.leaveAReview.description": "您可以在 {appStore} 為 Flow 評分並留下您的評論", - "support.requestFeatures": "提供建議", - "support.requestFeatures.action": "造訪問題追蹤區", - "support.requestFeatures.description": "您也可以透過提供反饋和功能建議來支持我們,讓 Flow 變得更好。", "support.starOnGitHub": "在 GitHub 上給顆星星", "support.starOnGitHub.description": "在 GitHub 上給 Flow 點擊星星有助於讓更多人發現我們", + "support.tip.error": "無法開始購買。請再試一次。", + "support.tip.thankYou": "感謝您支持 Flow!💜", "sync.export": "匯出", "sync.export.asCSV": "匯出為 CSV", "sync.export.asCSV.description": "不可用於還原/匯入!適合在 Google 試算表等軟體中開啟", @@ -638,13 +679,21 @@ "tabs.profile.community": "社群", "tabs.profile.guide": "使用指南", "tabs.profile.import": "匯入", - "tabs.profile.joinDiscord": "加入 Flow Discord", "tabs.profile.other": "其他", "tabs.profile.preferences": "偏好設定", "tabs.profile.recommend": "推薦 Flow", "tabs.profile.support": "支持 Flow", "tabs.profile.withLoveFromTheCreator": "來自 sadespresso 🤍 的作品", "tabs.stats": "統計", + "tabs.stats.analytics.budgets": "預算", + "tabs.stats.analytics.budgets.empty": "設定支出預算", + "tabs.stats.analytics.budgets.nearingCount": "{count} 接近上限", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} 接近上限", + "tabs.stats.analytics.budgets.onTrack": "全部進度正常", + "tabs.stats.analytics.budgets.overCount": "{count} 超出限額", + "tabs.stats.analytics.budgets.overCount.one": "{count} 超出限額", + "tabs.stats.analytics.budgets.tracked": "{count} 個預算", + "tabs.stats.analytics.budgets.tracked.one": "{count} 個預算", "tabs.stats.analytics.calendar": "日曆", "tabs.stats.analytics.calendar.priciestDay": "你花費最高的一天是 {value}。", "tabs.stats.analytics.calendar.spentIn": "在 {} 的支出", diff --git a/ios/Tips.storekit b/ios/Tips.storekit new file mode 100644 index 00000000..17e022ff --- /dev/null +++ b/ios/Tips.storekit @@ -0,0 +1,71 @@ +{ + "identifier" : "F1040000", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "1.99", + "familyShareable" : false, + "internalID" : "F1040001", + "localizations" : [ + { + "description" : "Leave a small tip to support Flow's development.", + "displayName" : "Small Tip", + "locale" : "en_US" + } + ], + "productID" : "mn.flow.flow.tip.small", + "referenceName" : "Flow - Small Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "4.99", + "familyShareable" : false, + "internalID" : "F1040002", + "localizations" : [ + { + "description" : "Leave a medium tip to support Flow's development.", + "displayName" : "Medium Tip", + "locale" : "en_US" + } + ], + "productID" : "mn.flow.flow.tip.medium", + "referenceName" : "Flow - Medium Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "F1040003", + "localizations" : [ + { + "description" : "Leave a large tip to support Flow's development.", + "displayName" : "Large Tip", + "locale" : "en_US" + } + ], + "productID" : "mn.flow.flow.tip.large", + "referenceName" : "Flow - Large Tip", + "type" : "Consumable" + } + ], + "settings" : { + "_applicationInternalID" : "6477741670", + "_developerTeamID" : "", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 0, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + + ] + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/lib/constants.dart b/lib/constants.dart index 7ac98ea3..08010530 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -8,14 +8,10 @@ const bool debugBuild = false; bool get flowDebugMode => kDebugMode || debugBuild; -final Uri discordInviteLink = Uri.parse("https://discord.gg/Ndh9VDeZa4"); final Uri maintainerKoFiLink = Uri.parse("https://flow.gege.mn/donate"); final Uri website = Uri.parse("https://flow.gege.mn"); final Uri guideUrl = Uri.parse("https://flow.gege.mn/docs"); final Uri flowGitHubRepoLink = Uri.parse("https://github.com/flow-mn/flow"); -final Uri flowGitHubIssuesLink = Uri.parse( - "https://github.com/flow-mn/flow/issues", -); final Uri maintainerGitHubLink = Uri.parse("https://github.com/sadespresso"); final Uri enyHomeLink = Uri.parse("https://eny.gege.mn"); final Uri enyDashboardLink = Uri.parse("https://eny.gege.mn/dash"); @@ -29,6 +25,20 @@ const double sukhbaatarSquareCenterLong = 106.917604; const String appleAppStoreId = "6477741670"; +/// Consumable "tip the creator" in-app purchase product IDs. +/// +/// These must match the products configured in App Store Connect exactly. +/// Tips unlock nothing — they exist purely to support development. iOS only. +const String tipSmallProductId = "mn.flow.flow.tip.small"; +const String tipMediumProductId = "mn.flow.flow.tip.medium"; +const String tipLargeProductId = "mn.flow.flow.tip.large"; + +const Set tipProductIds = { + tipSmallProductId, + tipMediumProductId, + tipLargeProductId, +}; + final Uri csvImportTemplateUrl = Uri.parse( "https://docs.google.com/spreadsheets/d/1wxdJ1T8PSvzayxvGs7bVyqQ9Zu0DPQ1YwiBLy1FluqE/edit?usp=sharing", ); diff --git a/lib/data/actionable_nofications/actionable_notification.dart b/lib/data/actionable_nofications/actionable_notification.dart index 66862dd0..bd0c57a5 100644 --- a/lib/data/actionable_nofications/actionable_notification.dart +++ b/lib/data/actionable_nofications/actionable_notification.dart @@ -1,3 +1,4 @@ +import "package:flow/data/budget_progress.dart"; import "package:flow/data/flow_icon.dart"; import "package:flow/entity/backup_entry.dart"; import "package:material_symbols_icons_flow/symbols.dart"; @@ -6,6 +7,7 @@ import "package:simple_icons_flow/simple_icons_flow.dart"; enum ActionableNotificationPriority { low(0), medium(10), + mediumHigh(15), high(20), critical(30); @@ -62,6 +64,20 @@ class RateApp extends ActionableNotification { RateApp({required this.payload}); } +class BudgetAlert extends ActionableNotification { + @override + final FlowIconData icon = const IconFlowIcon(Symbols.money_bag_rounded); + + @override + final BudgetProgress payload; + + @override + final ActionableNotificationPriority priority = + ActionableNotificationPriority.mediumHigh; + + BudgetAlert({required this.payload}); +} + class AutoBackupReminder extends ActionableNotification { @override final FlowIconData icon = const IconFlowIcon(Symbols.cloud_upload); diff --git a/lib/data/budget_progress.dart b/lib/data/budget_progress.dart new file mode 100644 index 00000000..d49cc472 --- /dev/null +++ b/lib/data/budget_progress.dart @@ -0,0 +1,209 @@ +import "package:flow/data/money.dart"; +import "package:flow/entity/budget.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// How a budget is tracking against its limit, as a coarse three-way status. +enum BudgetStatus { + /// Under the warning threshold. + healthy, + + /// At or past the warning threshold, but not yet over the limit. + warning, + + /// Spending has reached or exceeded the budgeted amount. + over, +} + +/// Whether the current spend rate projects to land under, on, or over the +/// limit by the end of the period. +enum BudgetPace { + under, + on, + over, +} + +/// The single most relevant rule-based takeaway for a budget. The UI maps +/// each value to a localized sentence (see `budget.insight.*`). +enum BudgetInsightType { + /// Already over the limit. + over, + + /// Close to the limit ([BudgetStatus.warning]) but not over. + nearingLimit, + + /// Healthy now, but the current pace projects an overrun. + overpacing, + + /// Comfortably on track. + onTrack, + + /// Well under the limit with much of the period elapsed. + underspending, +} + +/// A pure, point-in-time view of how a [Budget] is tracking against its limit +/// for its current period. +/// +/// All money is expressed in the budget's own [Budget.currency]. Construct via +/// `BudgetService.computeProgress` rather than directly. +class BudgetProgress { + final Budget budget; + + /// Absolute spend within the period, in [Budget.currency]. + final Money spent; + + /// The budgeted amount, in [Budget.currency]. + final Money limit; + + /// True when a foreign-currency transaction couldn't be converted, so + /// [spent] is an undercount. + final bool hasMissingData; + + /// The moment this snapshot was computed against. + final DateTime asOf; + + const BudgetProgress({ + required this.budget, + required this.spent, + required this.limit, + required this.asOf, + this.hasMissingData = false, + }); + + /// The budget's currency, shared by [spent] and [limit]. + String get currency => limit.currency; + + /// Spent / limit. `0` when the limit is non-positive. + double get ratio => limit.amount > 0 ? (spent.amount / limit.amount) : 0.0; + + /// [ratio] as a whole-number percentage, e.g. `92`. + int get percent => (ratio * 100).round(); + + /// Remaining headroom; negative once over budget. + Money get remaining => limit - spent; + + /// How far over the limit, clamped to non-negative. + Money get overBy { + final Money diff = spent - limit; + return diff.amount > 0 ? diff : Money(0.0, currency); + } + + /// Fraction of the budget's period that has elapsed at [asOf], clamped to + /// 0..1. A period entirely in the past reads as `1`. + double get periodElapsed { + final TimeRange range = budget.timeRange; + final int total = range.to.difference(range.from).inSeconds; + if (total <= 0) return 1.0; + final int elapsed = asOf.difference(range.from).inSeconds; + return (elapsed / total).clamp(0.0, 1.0); + } + + /// Whole days remaining in the period; `0` once it has ended. + int get daysLeft { + final DateTime to = budget.timeRange.to; + if (!to.isAfter(asOf)) return 0; + return to.difference(asOf).inDays; + } + + /// Extrapolated end-of-period ratio if spending continues at the current + /// rate. Falls back to [ratio] before any of the period has elapsed. + double get projectedRatio { + final double elapsed = periodElapsed; + if (elapsed <= 0.0) return ratio; + return ratio / elapsed; + } + + BudgetStatus get status { + if (ratio >= 1.0) return BudgetStatus.over; + if (ratio >= warningThreshold) return BudgetStatus.warning; + return BudgetStatus.healthy; + } + + BudgetPace get pace { + final double projected = projectedRatio; + if (projected > 1.05) return BudgetPace.over; + if (projected < 0.75) return BudgetPace.under; + return BudgetPace.on; + } + + /// Whether the budget's period includes [asOf] — i.e. this is the live + /// period, not a stale past one that never renewed. + bool get isCurrent { + final TimeRange range = budget.timeRange; + return !range.from.isAfter(asOf) && range.to.isAfter(asOf); + } + + /// True when the budget warrants a nudge: at/over the warning threshold in + /// its live period. + bool get needsAttention => + isCurrent && status != BudgetStatus.healthy; + + /// The single most relevant rule-based takeaway. + BudgetInsightType get primaryInsight { + switch (status) { + case BudgetStatus.over: + return BudgetInsightType.over; + case BudgetStatus.warning: + return BudgetInsightType.nearingLimit; + case BudgetStatus.healthy: + // Require enough of the period to have elapsed before projecting an + // overrun — early on, a small spend extrapolates to a wildly + // overstated (and alarming) projection. + if (isCurrent && periodElapsed > 0.2 && pace == BudgetPace.over) { + return BudgetInsightType.overpacing; + } + if (periodElapsed > 0.5 && pace == BudgetPace.under) { + return BudgetInsightType.underspending; + } + return BudgetInsightType.onTrack; + } + } + + /// Sorting weight — higher means more in need of attention. Over-budget + /// outranks warning outranks healthy; ties break by [ratio]. + double get severity => switch (status) { + BudgetStatus.over => 2.0 + ratio, + BudgetStatus.warning => 1.0 + ratio, + BudgetStatus.healthy => ratio, + }; + + /// The [ratio] at or above which a budget is considered "near" its limit. + static const double warningThreshold = 0.9; +} + +/// A status roll-up across several current [BudgetProgress]es: how many are +/// over or nearing their limit, and which one is worst. +/// +/// Deliberately carries **no summed money total**. Flow's budgets are +/// independent — they can overlap in categories (an "all spending" budget plus +/// category sub-budgets counts the same transaction twice) and span different +/// periods (a monthly grocery budget next to a yearly travel one). Summing +/// their spends and limits would double-count and mix timeframes, producing a +/// misleading number, so this is a count/status view instead. Construct via +/// `BudgetService.computeSummary`. +class BudgetsSummary { + final int budgetCount; + final int overCount; + final int warningCount; + + /// The most in-need-of-attention budget (highest [BudgetProgress.severity]), + /// or null when there are no budgets. + final BudgetProgress? worst; + + /// True when at least one budget had unconvertible foreign spend, so its + /// status may understate reality. + final bool hasMissingData; + + const BudgetsSummary({ + required this.budgetCount, + required this.overCount, + required this.warningCount, + this.worst, + this.hasMissingData = false, + }); + + /// No budget is over or nearing its limit. + bool get allHealthy => overCount == 0 && warningCount == 0; + + bool get isEmpty => budgetCount == 0; +} diff --git a/lib/data/setup/demo_data.dart b/lib/data/setup/demo_data.dart new file mode 100644 index 00000000..0e0d6cc1 --- /dev/null +++ b/lib/data/setup/demo_data.dart @@ -0,0 +1,1094 @@ +import "dart:math"; + +import "package:flow/data/flow_icon.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/entity/transaction_tag.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flutter/widgets.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:uuid/uuid.dart"; + +/// Generates a rich, realistic multi-year financial history for demo and +/// screenshot purposes. +/// +/// Unlike a fixture, this is intentionally **non-deterministic** — every run +/// produces a different (but plausible) history. Pass a seeded [Random] if you +/// ever need reproducible output (e.g. for golden tests). +/// +/// The simulation walks the timeline day-by-day so that running balances stay +/// positive and savings grow over time. Regular income/expense transactions +/// are accumulated and returned for a single batch insert via +/// [Box.putManyAsync], while money movements between accounts (monthly savings, +/// credit-card payoff, ATM withdrawals) are written immediately through +/// [AccountActions.transferTo]. +/// +/// Usage: +/// ```dart +/// final generator = DemoDataGenerator( +/// main: main, cash: cash, savings: savings, creditCard: creditCard, +/// categories: categories, tags: tagsByTitle, +/// ); +/// await box().putManyAsync(generator.generate()); +/// ``` +class DemoDataGenerator { + DemoDataGenerator({ + required this.main, + required this.cash, + required this.savings, + required this.creditCard, + required List categories, + required Map tags, + Random? random, + DateTime? now, + this.years = 3, + }) : rng = random ?? Random(), + _tags = tags, + end = now ?? DateTime.now() { + _categories = _resolveCategories(categories); + } + + final Account main; + final Account cash; + final Account savings; + final Account creditCard; + + final Map _tags; + final Random rng; + final DateTime end; + + /// How many years of history to generate, counting back from [end]. + final int years; + + late final Map _categories; + late final DateTime _start; + + /// Net monthly take-home pay at the very start of the timeline. Grows yearly. + /// + /// Set a little above total monthly spending so the checking balance never + /// underflows, while leaving a believable ~20% to save. + static const double _baseSalary = 4400; + + /// Compounding annual raise applied to [_baseSalary]. + static const double _annualRaise = 0.06; + + /// Minimum balance we try to leave in [main] before moving money out. Large + /// enough to cover the month's direct spending (rent lands on the 1st, salary + /// only on the 25th) so transfers never push the account negative. + static const double _buffer = 1800; + + /// On payday, anything in [main] above this is swept into savings, so the + /// checking balance stays in a realistic band instead of ballooning as the + /// surplus accumulates over the years. + static const double _checkingCap = 6500; + + final List _txns = []; + final Map _balances = {}; + + late final Set _tripDays; + + /// Builds the full history. Returns the regular (non-transfer) transactions + /// to be inserted in one batch. Transfers are persisted as a side effect. + List generate() { + _start = DateTime(end.year - years, end.month, end.day); + + _seedInitialBalances(); + _planTrips(); + + DateTime cursor = _start; + while (cursor.isBefore(end)) { + _runDay(cursor); + cursor = cursor.add(const Duration(days: 1)); + } + + return _txns; + } + + // --------------------------------------------------------------------------- + // Setup + // --------------------------------------------------------------------------- + + /// Maps a semantic key to the preset category's icon, so we can resolve the + /// persisted [Category] regardless of its (localized) name. + static const Map _categoryIcons = { + "eatingOut": Symbols.restaurant_rounded, + "groceries": Symbols.grocery_rounded, + "drinks": Symbols.local_cafe_rounded, + "education": Symbols.school_rounded, + "health": Symbols.health_and_safety_rounded, + "transport": Symbols.train_rounded, + "petrol": Symbols.local_gas_station_rounded, + "shopping": Symbols.shopping_cart_rounded, + "entertainment": Symbols.sports_basketball_rounded, + "onlineServices": Symbols.cloud_circle_rounded, + "gifts": Symbols.featured_seasonal_and_gifts_rounded, + "rent": Symbols.request_quote_rounded, + "utils": Symbols.valve_rounded, + "taxes": Symbols.account_balance_rounded, + "paychecks": Symbols.wallet_rounded, + "insurance": Symbols.privacy_tip_rounded, + "petCare": Symbols.pets_rounded, + "fitness": Symbols.fitness_center_rounded, + "gadgets": Symbols.devices_other_rounded, + "services": Symbols.support_agent_rounded, + "snacks": Symbols.bakery_dining_rounded, + "stationery": Symbols.note_stack_rounded, + "hobby": Symbols.sports_esports_rounded, + "donations": Symbols.volunteer_activism_rounded, + "beauty": Symbols.self_care_rounded, + "travel": Symbols.flight_rounded, + }; + + Map _resolveCategories(List categories) { + final Map byCode = { + for (final category in categories) category.iconCode: category, + }; + + final Map result = {}; + _categoryIcons.forEach((key, icon) { + final Category? match = byCode[IconFlowIcon(icon).toString()]; + if (match != null) result[key] = match; + }); + return result; + } + + void _seedInitialBalances() { + _income( + account: main, + amount: 6000, + date: _start, + title: "Initial balance", + subtype: TransactionSubtype.updateBalance.value, + ); + _income( + account: cash, + amount: 120, + date: _start, + title: "Initial balance", + subtype: TransactionSubtype.updateBalance.value, + ); + _income( + account: savings, + amount: 8500, + date: _start, + title: "Initial balance", + subtype: TransactionSubtype.updateBalance.value, + ); + } + + /// Pre-picks 1–2 trips per year (a summer trip, sometimes a winter getaway) + /// so they land as recognizable spending spikes. + void _planTrips() { + _tripDays = {}; + for (int year = _start.year; year <= end.year; year++) { + final DateTime summer = DateTime(year, 7 + rng.nextInt(2), 1 + rng.nextInt(20)); + if (_within(summer)) _tripDays.add(_dayKey(summer)); + + if (_chance(0.5)) { + final DateTime winter = DateTime(year, 12, 18 + rng.nextInt(8)); + if (_within(winter)) _tripDays.add(_dayKey(winter)); + } + } + } + + // --------------------------------------------------------------------------- + // Per-day simulation + // --------------------------------------------------------------------------- + + void _runDay(DateTime day) { + final bool weekend = + day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; + final int dom = day.day; + + _runRecurring(day); + + if (dom == 25) _runPayday(day); + if (dom == 5) _runCreditCardPayoff(day); + if (dom == 7 || dom == 21) _maybeTopUpCash(day); + + if (_tripDays.contains(_dayKey(day))) _runTrip(day); + + // December gift shopping + if (day.month == 12 && dom <= 24 && _chance(0.26)) { + _spend( + amount: _money(18, 180), + date: _at(day), + categoryKey: "gifts", + title: _pick(_giftTitles), + tagKeys: const ["gifts", "family"], + prefer: _cardOr(main, 0.8), + ); + } + + // Coffee + if (_chance(weekend ? 0.42 : 0.58)) { + _spend( + amount: _money(3.25, 6.75), + date: _at(day), + categoryKey: "drinks", + title: _pick(_coffeeTitles), + tagKeys: _chance(0.4) ? const ["coffee"] : const [], + prefer: _chance(0.6) ? [cash, main] : [main], + ); + } + + // Groceries + if (_chance(weekend ? 0.42 : 0.18)) { + _spend( + amount: _money(16, 96), + date: _at(day), + categoryKey: "groceries", + title: _pick(_groceryTitles), + tagKeys: _chance(0.3) ? const ["groceries"] : const [], + prefer: _cardOr(main, 0.4), + ); + } + + // Eating out + if (_chance(weekend ? 0.5 : 0.22)) { + _spend( + amount: _money(10, 56), + date: _at(day), + categoryKey: "eatingOut", + title: _pick(_diningTitles), + tagKeys: weekend && _chance(0.4) + ? const ["dining", "friends"] + : const ["dining"], + prefer: _chance(0.5) + ? [cash, main] + : (_chance(0.5) ? [creditCard, main] : [main]), + ); + } + + // Snacks + if (_chance(0.18)) { + _spend( + amount: _money(2, 12), + date: _at(day), + categoryKey: "snacks", + title: _pick(_snackTitles), + prefer: [cash, main], + ); + } + + // Transport + if (_chance(weekend ? 0.2 : 0.5)) { + _spend( + amount: _money(2.5, 16), + date: _at(day), + categoryKey: "transport", + title: _pick(_transportTitles), + tagKeys: const ["transport"], + prefer: [cash, main], + ); + } + + // Petrol (~ every 12 days) + if (_chance(0.08)) { + _spend( + amount: _money(34, 62), + date: _at(day), + categoryKey: "petrol", + title: _pick(_petrolTitles), + prefer: _cardOr(main, 0.5), + ); + } + + // Entertainment + if (_chance(weekend ? 0.28 : 0.06)) { + _spend( + amount: _money(8, 60), + date: _at(day), + categoryKey: "entertainment", + title: _pick(_entertainmentTitles), + tagKeys: _chance(0.3) ? const ["fun"] : const [], + prefer: _cardOr(main, 0.5), + ); + } + + // Shopping + if (_chance(0.1)) { + _spend( + amount: _money(14, 185), + date: _at(day), + categoryKey: "shopping", + title: _pick(_shoppingTitles), + tagKeys: _chance(0.5) ? const ["online"] : const [], + prefer: _cardOr(main, 0.8), + ); + } + + // Health + if (_chance(0.025)) { + _spend( + amount: _money(8, 65), + date: _at(day), + categoryKey: "health", + title: _pick(_healthTitles), + tagKeys: const ["health"], + prefer: [main], + ); + } + + // Beauty / grooming + if (_chance(0.035)) { + _spend( + amount: _money(12, 70), + date: _at(day), + categoryKey: "beauty", + title: _pick(_beautyTitles), + prefer: [main], + ); + } + + // Hobby + if (_chance(0.06)) { + _spend( + amount: _money(10, 80), + date: _at(day), + categoryKey: "hobby", + title: _pick(_hobbyTitles), + tagKeys: _chance(0.3) ? const ["fun"] : const [], + prefer: _chance(0.5) ? [cash, main] : [main], + ); + } + + // Pet care + if (_chance(0.03)) { + _spend( + amount: _money(11, 85), + date: _at(day), + categoryKey: "petCare", + title: _pick(_petTitles), + tagKeys: const ["pets"], + prefer: [main], + ); + } + + // Stationery / office + if (_chance(0.015)) { + _spend( + amount: _money(5, 40), + date: _at(day), + categoryKey: "stationery", + title: _pick(_stationeryTitles), + prefer: [main], + ); + } + + // Education + if (_chance(0.012)) { + _spend( + amount: _money(15, 200), + date: _at(day), + categoryKey: "education", + title: _pick(_educationTitles), + prefer: _cardOr(main, 0.5), + ); + } + + // Gadgets + if (_chance(0.005)) { + _spend( + amount: _money(80, 1300), + date: _at(day), + categoryKey: "gadgets", + title: _pick(_gadgetTitles), + tagKeys: const ["gadgets"], + prefer: _cardOr(main, 0.85), + ); + } + + // Rare big-ticket purchase, occasionally pulled from savings + if (_chance(0.0015) && _bal(savings) > 3500) { + final double amount = _money(600, 2400); + _transfer( + from: savings, + to: main, + amount: amount, + date: _at(day), + title: "From savings", + ); + _spend( + amount: amount * _rand(0.6, 0.95), + date: _at(day), + categoryKey: _chance(0.5) ? "gadgets" : "shopping", + title: _pick(_bigPurchaseTitles), + prefer: [main], + ); + } + } + + // --------------------------------------------------------------------------- + // Recurring & money movements + // --------------------------------------------------------------------------- + + void _runRecurring(DateTime day) { + final int dom = day.day; + final int month = day.month; + + // Subscriptions (charged to the credit card) + if (dom == 1) { + _spend( + amount: 39.00, + date: _at(day), + categoryKey: "fitness", + title: "Gym membership", + tagKeys: const ["subscription", "health"], + prefer: _cardOr(main, 0.9), + ); + } + if (dom == 2) { + _spend( + amount: 2.99, + date: _at(day), + categoryKey: "onlineServices", + title: "iCloud+", + tagKeys: const ["subscription"], + prefer: _cardOr(main, 0.9), + ); + } + if (dom == 5) { + _spend( + amount: _netflixPrice(day), + date: _at(day), + categoryKey: "onlineServices", + title: "Netflix", + tagKeys: const ["subscription"], + prefer: _cardOr(main, 0.9), + ); + } + if (dom == 9) { + _spend( + amount: 11.99, + date: _at(day), + categoryKey: "onlineServices", + title: "Spotify", + tagKeys: const ["subscription"], + prefer: _cardOr(main, 0.9), + ); + } + if (dom == 12) { + _spend( + amount: 20.00, + date: _at(day), + categoryKey: "onlineServices", + title: "ChatGPT Plus", + tagKeys: const ["subscription"], + prefer: _cardOr(main, 0.9), + ); + } + + // Bills (paid from the main account) + if (dom == 1) { + _spend( + amount: _rentPrice(day), + date: _at(day), + categoryKey: "rent", + title: "Rent", + tagKeys: const ["bills"], + prefer: [main], + ); + } + if (dom == 6) { + _spend( + amount: 59.00, + date: _at(day), + categoryKey: "utils", + title: "Internet", + tagKeys: const ["bills"], + prefer: [main], + ); + } + if (dom == 18) { + _spend( + amount: _money(42, 52), + date: _at(day), + categoryKey: "utils", + title: "Phone bill", + tagKeys: const ["bills"], + prefer: [main], + ); + } + if (dom == 20) { + _spend( + amount: _electricityPrice(day), + date: _at(day), + categoryKey: "utils", + title: "Electricity", + tagKeys: const ["bills"], + prefer: [main], + ); + } + if (dom == 22) { + _spend( + amount: _money(22, 38), + date: _at(day), + categoryKey: "utils", + title: "Water", + tagKeys: const ["bills"], + prefer: [main], + ); + } + + // Quarterly car insurance + if (dom == 14 && const [1, 4, 7, 10].contains(month)) { + _spend( + amount: _money(178, 214), + date: _at(day), + categoryKey: "insurance", + title: "Car insurance", + tagKeys: const ["bills"], + prefer: [main], + ); + } + + // Monthly donation + if (dom == 10) { + _spend( + amount: _money(15, 45), + date: _at(day), + categoryKey: "donations", + title: _pick(_donationTitles), + tagKeys: const ["charity"], + prefer: [main], + ); + } + + // Monthly savings interest (~3.5% APY) + if (dom == 28) { + final double interest = _bal(savings) * 0.035 / 12; + if (interest > 1) { + _income( + account: savings, + amount: interest, + date: _at(day), + title: "Interest", + ); + } + } + + // Annual tax refund (late March) + if (month == 3 && dom == 27) { + _income( + account: main, + amount: _money(320, 1380), + date: _at(day), + categoryKey: "taxes", + title: "Tax refund", + ); + } + } + + void _runPayday(DateTime day) { + final double yearsElapsed = day.difference(_start).inDays / 365.0; + final double salary = + _baseSalary * pow(1 + _annualRaise, yearsElapsed) * _rand(0.985, 1.015); + + _income( + account: main, + amount: salary, + date: _at(day), + categoryKey: "paychecks", + title: "Salary", + tagKeys: const ["work"], + ); + + // Year-end bonus + double savingsTarget = salary * 0.15; + if (day.month == 12) { + final double bonus = salary * _rand(0.8, 1.3); + _income( + account: main, + amount: bonus, + date: _at(day), + categoryKey: "paychecks", + title: "Year-end bonus", + tagKeys: const ["work"], + ); + savingsTarget += bonus * 0.5; + } + + // Occasional freelance income + if (_chance(0.3)) { + _income( + account: main, + amount: _money(220, 940), + date: _at(day), + categoryKey: "services", + title: _pick(_freelanceTitles), + tagKeys: const ["work", "reimbursable"], + ); + } + + // Pay yourself first — a fixed monthly contribution into savings. + _transfer( + from: main, + to: savings, + amount: min(savingsTarget, _bal(main) - _buffer), + date: _at(day), + title: "Monthly savings", + ); + + // Sweep any excess checking into savings so the balance stays realistic. + if (_bal(main) > _checkingCap) { + _transfer( + from: main, + to: savings, + amount: _bal(main) - _checkingCap, + date: _at(day), + title: "Top up savings", + ); + } + } + + void _runCreditCardPayoff(DateTime day) { + final double owed = -_bal(creditCard); + if (owed <= 0) return; + + // Pay what we can without dipping below the buffer; the rest revolves. + _transfer( + from: main, + to: creditCard, + amount: min(owed, _bal(main) - _buffer), + date: _at(day), + title: "Credit card payment", + ); + } + + void _maybeTopUpCash(DateTime day) { + if (_bal(cash) >= 100) return; + + final double amount = (_money(100, 220) / 20).roundToDouble() * 20; + _transfer( + from: main, + to: cash, + amount: amount, + date: _at(day), + title: "ATM withdrawal", + ); + } + + void _runTrip(DateTime day) { + // Outbound flight + _spend( + amount: _money(160, 520), + date: _at(day), + categoryKey: "travel", + title: "Flight", + tagKeys: const ["vacation"], + prefer: _cardOr(main, 0.9), + ); + + final int nights = 2 + rng.nextInt(4); + for (int i = 0; i < nights; i++) { + final DateTime night = day.add(Duration(days: i)); + if (!_within(night)) break; + + _spend( + amount: _money(95, 260), + date: _at(night), + categoryKey: "travel", + title: "Hotel", + tagKeys: const ["vacation"], + prefer: _cardOr(main, 0.9), + ); + + if (_chance(0.8)) { + _spend( + amount: _money(18, 80), + date: _at(night), + categoryKey: _chance(0.6) ? "eatingOut" : "entertainment", + title: _chance(0.6) ? _pick(_diningTitles) : _pick(_entertainmentTitles), + tagKeys: const ["vacation"], + prefer: _cardOr(main, 0.8), + ); + } + } + + // Return flight + final DateTime back = day.add(Duration(days: nights)); + if (_within(back)) { + _spend( + amount: _money(160, 520), + date: _at(back), + categoryKey: "travel", + title: "Flight", + tagKeys: const ["vacation"], + prefer: _cardOr(main, 0.9), + ); + } + } + + // --------------------------------------------------------------------------- + // Emit helpers + // --------------------------------------------------------------------------- + + void _spend({ + required double amount, + required DateTime date, + required String categoryKey, + String? title, + List tagKeys = const [], + List prefer = const [], + }) { + final Account account = _pickSpendAccount( + prefer.isEmpty ? [main] : prefer, + amount, + ); + _emit( + account: account, + amount: -amount, + date: date, + title: title, + categoryKey: categoryKey, + tagKeys: tagKeys, + ); + } + + void _income({ + required Account account, + required double amount, + required DateTime date, + String? title, + String? categoryKey, + List tagKeys = const [], + String? subtype, + }) { + _emit( + account: account, + amount: amount, + date: date, + title: title, + categoryKey: categoryKey, + tagKeys: tagKeys, + subtype: subtype, + ); + } + + void _emit({ + required Account account, + required double amount, + required DateTime date, + String? title, + String? categoryKey, + List tagKeys = const [], + String? subtype, + }) { + if (date.isAfter(end)) return; + + final Transaction transaction = + Transaction( + uuid: const Uuid().v4(), + amount: double.parse(amount.toStringAsFixed(2)), + currency: account.currency, + title: title, + transactionDate: date, + subtype: subtype, + ) + ..setAccount(account) + ..setCategory(categoryKey == null ? null : _categories[categoryKey]); + + final List resolvedTags = [ + for (final key in tagKeys) + if (_tags[key] != null) _tags[key]!, + ]; + if (resolvedTags.isNotEmpty) transaction.setTags(resolvedTags); + + _txns.add(transaction); + _apply(account, transaction.amount); + } + + void _transfer({ + required Account from, + required Account to, + required double amount, + required DateTime date, + String? title, + }) { + if (date.isAfter(end)) return; + + final double rounded = double.parse(amount.toStringAsFixed(2)); + if (rounded <= 0) return; + + from.transferTo( + amount: rounded, + targetAccount: to, + transactionDate: date, + title: title, + ); + + _apply(from, -rounded); + _apply(to, rounded); + } + + /// Picks the first preferred account that can cover [amount], falling back to + /// [main] (which is always topped up by the salary). + Account _pickSpendAccount(List prefer, double amount) { + for (final account in prefer) { + if (identical(account, creditCard)) { + final double owed = -_bal(creditCard); + final double limit = creditCard.creditLimit ?? 5000; + if (owed + amount <= limit * 0.92) return account; + } else if (identical(account, cash) || identical(account, savings)) { + if (_bal(account) >= amount) return account; + } else { + return account; + } + } + return main; + } + + // --------------------------------------------------------------------------- + // Balance bookkeeping + // --------------------------------------------------------------------------- + + double _bal(Account account) => _balances[account.uuid] ?? 0; + + void _apply(Account account, double delta) { + _balances[account.uuid] = _bal(account) + delta; + } + + // --------------------------------------------------------------------------- + // Pricing curves + // --------------------------------------------------------------------------- + + double _netflixPrice(DateTime day) => + day.difference(_start).inDays > 600 ? 17.99 : 15.49; + + double _rentPrice(DateTime day) { + final int elapsed = day.difference(_start).inDays; + if (elapsed > 730) return 1690; + if (elapsed > 365) return 1590; + return 1480; + } + + double _electricityPrice(DateTime day) { + double base = 52; + final int month = day.month; + if (month == 12 || month == 1 || month == 2) base += 46; // winter heating + if (month == 7 || month == 8) base += 34; // summer cooling + return _money(base - 12, base + 16); + } + + // --------------------------------------------------------------------------- + // Randomness utilities + // --------------------------------------------------------------------------- + + double _rand(double min, double max) => min + rng.nextDouble() * (max - min); + + double _money(double min, double max) => + double.parse(_rand(min, max).toStringAsFixed(2)); + + bool _chance(double probability) => rng.nextDouble() < probability; + + T _pick(List items) => items[rng.nextInt(items.length)]; + + /// A spending preference list that uses the credit card [cardProbability] of + /// the time, otherwise the given [fallback] account. + List _cardOr(Account fallback, double cardProbability) => + _chance(cardProbability) ? [creditCard, fallback] : [fallback]; + + DateTime _at(DateTime day) => DateTime( + day.year, + day.month, + day.day, + 7 + rng.nextInt(15), + rng.nextInt(60), + rng.nextInt(60), + ); + + bool _within(DateTime day) => !day.isBefore(_start) && day.isBefore(end); + + String _dayKey(DateTime day) => "${day.year}-${day.month}-${day.day}"; + + // --------------------------------------------------------------------------- + // Title pools + // --------------------------------------------------------------------------- + + static const List _coffeeTitles = [ + "Latte", + "Cappuccino", + "Iced mocha", + "Flat white", + "Cold brew", + "Americano", + "Espresso", + "Matcha latte", + "Chai latte", + ]; + + static const List _groceryTitles = [ + "Groceries", + "Supermarket", + "Whole Foods", + "Trader Joe's", + "Costco", + "Corner store", + "Farmers market", + ]; + + static const List _diningTitles = [ + "Lunch", + "Dinner", + "Brunch", + "Pizza", + "Sushi", + "Burger joint", + "Thai food", + "Ramen", + "Tacos", + "Sandwich", + "Noodles", + "Dumplings", + ]; + + static const List _snackTitles = [ + "Snack", + "Convenience store", + "Bakery", + "Ice cream", + "Smoothie", + "Donut", + ]; + + static const List _transportTitles = [ + "Subway", + "Bus fare", + "Uber", + "Lyft", + "Taxi", + "Parking", + "Train ticket", + ]; + + static const List _petrolTitles = [ + "Gas station", + "Fuel", + "Shell", + "Chevron", + ]; + + static const List _entertainmentTitles = [ + "Movie tickets", + "Concert", + "Bar", + "Bowling", + "Mini golf", + "Arcade", + "Comedy show", + "Museum", + ]; + + static const List _shoppingTitles = [ + "Amazon order", + "Clothes", + "Sneakers", + "Uniqlo", + "Zara", + "Target run", + "Home goods", + "IKEA", + "H&M", + ]; + + static const List _healthTitles = [ + "Pharmacy", + "Doctor visit", + "Dentist", + "Vitamins", + "Prescription", + "Clinic", + ]; + + static const List _beautyTitles = [ + "Haircut", + "Salon", + "Skincare", + "Barber", + "Nails", + "Spa", + ]; + + static const List _hobbyTitles = [ + "Art supplies", + "Climbing gym", + "Board game", + "Guitar strings", + "Camera gear", + "Pottery class", + "Bookstore", + ]; + + static const List _educationTitles = [ + "Online course", + "Udemy course", + "Workshop", + "E-book", + "Coursera", + ]; + + static const List _petTitles = [ + "Pet food", + "Vet visit", + "Pet supplies", + "Grooming", + "Cat litter", + ]; + + static const List _stationeryTitles = [ + "Stationery", + "Notebook", + "Pens", + "Printer paper", + "Desk supplies", + ]; + + static const List _gadgetTitles = [ + "Headphones", + "Mechanical keyboard", + "Monitor", + "Phone case", + "Smartwatch", + "SSD", + "Webcam", + "New phone", + ]; + + static const List _giftTitles = [ + "Birthday gift", + "Christmas gift", + "Holiday present", + "Gift for family", + "Anniversary gift", + "Wedding gift", + ]; + + static const List _donationTitles = [ + "Red Cross", + "Wikipedia", + "Local shelter", + "NPR", + "Charity: water", + "Patreon", + ]; + + static const List _bigPurchaseTitles = [ + "New laptop", + "Furniture", + "Standing desk", + "Mattress", + "New TV", + "Bicycle", + "Vacuum cleaner", + ]; + + static const List _freelanceTitles = [ + "Freelance project", + "Side gig", + "Consulting", + "Design commission", + "Web project", + ]; +} diff --git a/lib/entity/budget.dart b/lib/entity/budget.dart index 401c3ef7..874305cf 100644 --- a/lib/entity/budget.dart +++ b/lib/entity/budget.dart @@ -37,8 +37,10 @@ class Budget implements EntityBase { set timeRange(TimeRange value) => range = value.toString(); - /// When [true], and [timeRange] is [PageableRange], it will automatically - /// create a new budget for the next period when the current one expires. + /// When [true], and [timeRange] is [PageableRange], [timeRange] advances + /// to the current period once the previous one ends. + /// + /// See `BudgetService.renewDueBudgets` bool renewAutomatically; double amount; diff --git a/lib/main.dart b/lib/main.dart index 7a93f184..dd4c852f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,8 +32,10 @@ import "package:flow/providers/accounts_provider.dart"; import "package:flow/providers/categories_provider.dart"; import "package:flow/providers/transaction_tags_provider.dart"; import "package:flow/routes.dart"; +import "package:flow/services/budget.dart"; import "package:flow/services/currency_registry.dart"; import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/in_app_purchase.dart"; import "package:flow/services/integrations/siri_pending.dart"; import "package:flow/services/local_auth.dart"; import "package:flow/services/navigation.dart"; @@ -128,6 +130,15 @@ void main() async { CurrencyRegistryService(); + if (Platform.isIOS) { + startupLog.fine("Initializing TipService"); + unawaited( + TipService().init().catchError((error) { + startupLog.warning("Failed to initialize TipService", error); + }), + ); + } + try { startupLog.fine("Initializing user preferences service"); await UserPreferencesService().initialize(); @@ -250,6 +261,13 @@ class FlowState extends State { ); unawaited(SiriPendingService().resolveSiriTransactions()); + + unawaited( + BudgetService().renewDueBudgets().catchError((error) { + mainLogger.severe("Failed to renew due budgets", error); + return 0; + }), + ); }); _tryUnlockTempLock(); diff --git a/lib/objectbox.dart b/lib/objectbox.dart index 0e60775e..c6fd22bf 100644 --- a/lib/objectbox.dart +++ b/lib/objectbox.dart @@ -4,6 +4,7 @@ import "package:flow/constants.dart"; import "package:flow/data/flow_icon.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/budget.dart"; import "package:flow/entity/category.dart"; @@ -15,8 +16,8 @@ import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction_filter_preset.dart"; import "package:flow/entity/transaction_tag.dart"; import "package:flow/entity/user_preferences.dart"; -import "package:flow/objectbox/actions.dart"; import "package:flow/objectbox/objectbox.g.dart"; +import "package:flutter/widgets.dart"; import "package:logging/logging.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; @@ -25,6 +26,30 @@ import "package:path_provider/path_provider.dart"; final Logger _log = Logger("ObjectBox-Flow"); +/// Realistic tags attached to the generated demo transactions. Kept short and +/// recognizable so they look good in screenshots (unlike random dictionary +/// words). Keys here must match the tag keys used by [DemoDataGenerator]. +const List _demoTagTitles = [ + "work", + "subscription", + "bills", + "groceries", + "dining", + "coffee", + "vacation", + "family", + "friends", + "online", + "health", + "transport", + "fun", + "gifts", + "charity", + "pets", + "reimbursable", + "gadgets", +]; + class ObjectBox { static ObjectBox? _instance; @@ -116,188 +141,133 @@ class ObjectBox { return path.join(appDataDir.path, subdirectory); } + /// Seeds a rich, multi-year demo history for screenshots and presentations. + /// + /// Creates realistic tags, the preset categories, the preset accounts plus a + /// credit card, ~3 years of generated transactions, and a few budgets/goals + /// so every screen looks populated. See [DemoDataGenerator]. + /// + /// The generated data is **non-deterministic** — each run produces a + /// different (but plausible) history. Future createAndPutDebugData() async { if (box().count(limit: 1) > 0 || box().count(limit: 1) > 0) { return; } - await box().putAndGetManyAsync([ - TransactionTag(title: "airport"), - TransactionTag(title: "description"), - TransactionTag(title: "strategy"), - TransactionTag(title: "injury"), - TransactionTag(title: "association"), - TransactionTag(title: "skill"), - TransactionTag(title: "operation"), - TransactionTag(title: "introduction"), - TransactionTag(title: "depression"), - TransactionTag(title: "user"), - TransactionTag(title: "height"), - TransactionTag(title: "map"), - TransactionTag(title: "collection"), - TransactionTag(title: "way"), - TransactionTag(title: "story"), - TransactionTag(title: "ambition"), - TransactionTag(title: "hall"), - TransactionTag(title: "reflection"), - TransactionTag(title: "thing"), - TransactionTag(title: "election"), - TransactionTag(title: "woman"), - TransactionTag(title: "protection"), - TransactionTag(title: "membership"), - TransactionTag(title: "birthday"), - TransactionTag(title: "series"), - TransactionTag(title: "passion"), - TransactionTag(title: "pollution"), - TransactionTag(title: "argument"), - TransactionTag(title: "population"), - TransactionTag(title: "drama"), - TransactionTag(title: "combination"), - TransactionTag(title: "death"), - TransactionTag(title: "profession"), - TransactionTag(title: "philosophy"), - TransactionTag(title: "heart"), - TransactionTag(title: "hat"), - TransactionTag(title: "winner"), - TransactionTag(title: "disaster"), - TransactionTag(title: "entry"), - TransactionTag(title: "decision"), - TransactionTag(title: "topic"), - TransactionTag(title: "guest"), - TransactionTag(title: "platform"), - TransactionTag(title: "college"), - TransactionTag(title: "charity"), - TransactionTag(title: "historian"), - TransactionTag(title: "problem"), - TransactionTag(title: "tea"), - TransactionTag(title: "wife"), - TransactionTag(title: "stranger"), - TransactionTag(title: "Broadcast"), - TransactionTag(title: "Donor"), - TransactionTag(title: "Epoxy"), - TransactionTag(title: "Icecream"), - TransactionTag(title: "Juice"), - TransactionTag(title: "Photograph"), - TransactionTag(title: "Quantity"), - TransactionTag(title: "Roundabout"), - TransactionTag(title: "Shaker"), - TransactionTag(title: "Spring"), - ]); - - final categories = await box().putAndGetManyAsync( - getCategoryPresets().map((e) { - e.id = 0; - return e; - }).toList(), - ); - - final services = categories.firstWhere( - (element) => - element.iconCode == - const IconFlowIcon(Symbols.cloud_circle_rounded).toString(), - ); - final coffee = categories.firstWhere( - (element) => - element.iconCode == - const IconFlowIcon(Symbols.local_cafe_rounded).toString(), - ); - final gift = categories.firstWhere( - (element) => - element.iconCode == - const IconFlowIcon( - Symbols.featured_seasonal_and_gifts_rounded, - ).toString(), - ); - final paycheck = categories.firstWhere( - (element) => - element.iconCode == - const IconFlowIcon(Symbols.wallet_rounded).toString(), - ); - final rent = categories.firstWhere( - (element) => - element.iconCode == - const IconFlowIcon(Symbols.request_quote_rounded).toString(), - ); - - final [main, cash, savings] = getAccountPresets("USD").map((e) { + final List tags = await box() + .putAndGetManyAsync( + _demoTagTitles + .map((title) => TransactionTag(title: title)) + .toList(), + ); + final Map tagsByTitle = { + for (final tag in tags) tag.title: tag, + }; + + final List categories = await box() + .putAndGetManyAsync( + getCategoryPresets().map((e) { + e.id = 0; + return e; + }).toList(), + ); + + final List presets = getAccountPresets("USD").map((e) { e.id = 0; return e; }).toList(); + final Account creditCardPreset = + Account.preset( + name: "Credit Card", + currency: "USD", + iconCode: FlowIconData.icon(Symbols.credit_card_rounded).toString(), + uuid: "1f3c9d2e-8a47-4b6e-9c21-7d5f0a2b6e41", + type: AccountType.creditLineValue, + creditLimit: 5000, + excludeFromTotalBalance: true, + )..id = 0; + + final List accounts = await box().putAndGetManyAsync([ + ...presets, + creditCardPreset, + ]); + final [main, cash, savings, creditCard] = accounts; + + final DemoDataGenerator generator = DemoDataGenerator( + main: main, + cash: cash, + savings: savings, + creditCard: creditCard, + categories: categories, + tags: tagsByTitle, + ); - main - ..updateBalanceAndSave( - 420.69, - title: "Initial balance", - transactionDate: DateTime.now() - const Duration(days: 5), - ) - ..createAndSaveTransaction( - amount: -1.99, - title: "iCloud", - category: services, - transactionDate: DateTime.now() - const Duration(days: 4), - ) - ..createAndSaveTransaction( - amount: -15.49, - title: "Netflix", - category: services, - transactionDate: DateTime.now() - const Duration(days: 4), - ) - ..createAndSaveTransaction( - amount: -6.50, - title: "Iced Mocha", - category: coffee, - transactionDate: DateTime.now() - const Duration(days: 4), - ) - ..createAndSaveTransaction( - amount: -6.50, - title: "Iced Mocha", - category: coffee, - transactionDate: DateTime.now() - const Duration(days: 3), - ) - ..createAndSaveTransaction( - amount: -6.50, - title: "Iced Mocha", - category: coffee, - transactionDate: DateTime.now() - const Duration(days: 2), - ) - ..createAndSaveTransaction( - amount: 680.98, - title: "Paycheck (last month)", - category: paycheck, - transactionDate: DateTime.now() - const Duration(days: 1), - ) - ..createAndSaveTransaction( - amount: -99.01, - title: "Gift for Stella", - category: gift, - transactionDate: DateTime.now() - const Duration(days: 1), - ); + await box().putManyAsync(generator.generate()); - savings - ..updateBalanceAndSave( - 69420, - title: "Savings initial balance", - transactionDate: DateTime.now() - const Duration(days: 6), - ) - ..createAndSaveTransaction( - amount: -1960, - title: "Rent", - category: rent, - transactionDate: DateTime.now() - const Duration(days: 6), - ); + _createDemoBudgetsAndGoals(categories: categories, savings: savings); + } - final [main2, ..., savings2] = await box().putAndGetManyAsync([ - main, - cash, - savings, + /// Populates the budgets and goals screens with a handful of realistic + /// entries tied to the demo data. + void _createDemoBudgetsAndGoals({ + required List categories, + required Account savings, + }) { + final Map byCode = { + for (final category in categories) category.iconCode: category, + }; + Category? cat(IconData icon) => byCode[IconFlowIcon(icon).toString()]; + + final String monthlyRange = MonthTimeRange.fromDateTime( + DateTime.now(), + ).toString(); + + final Budget groceries = Budget( + name: "Groceries", + amount: 450, + currency: "USD", + range: monthlyRange, + )..setCategories([?cat(Symbols.grocery_rounded)]); + + final Budget eatingOut = Budget( + name: "Eating out", + amount: 300, + currency: "USD", + range: monthlyRange, + )..setCategories([ + ?cat(Symbols.restaurant_rounded), + ?cat(Symbols.local_cafe_rounded), + ?cat(Symbols.bakery_dining_rounded), ]); - main2.transferTo( - amount: 250, - targetAccount: savings2, - transactionDate: DateTime.now() - const Duration(days: 1), - ); + final Budget shopping = Budget( + name: "Shopping", + amount: 400, + currency: "USD", + range: monthlyRange, + )..setCategories([?cat(Symbols.shopping_cart_rounded)]); + + box().putMany([groceries, eatingOut, shopping]); + + // One goal already reached, one still in progress — shows both states. + final Goal emergencyFund = Goal( + name: "Emergency fund", + targetBalance: 20000, + currency: "USD", + range: null, + iconCode: FlowIconData.icon(Symbols.savings_rounded).toString(), + )..setAccount(savings); + + final Goal house = Goal( + name: "House down payment", + targetBalance: 90000, + currency: "USD", + range: null, + iconCode: FlowIconData.icon(Symbols.home_rounded).toString(), + )..setAccount(savings); + + box().putMany([emergencyFund, house]); } /// Deletes everything except for diff --git a/lib/prefs/transitive.dart b/lib/prefs/transitive.dart index a6ef978d..3f8067e8 100644 --- a/lib/prefs/transitive.dart +++ b/lib/prefs/transitive.dart @@ -50,6 +50,7 @@ class TransitiveLocalPreferences { late final PrimitiveSettingsEntry lastSavedAutoBackupPath; late final DateTimeSettingsEntry lastRateAppShowedAt; late final DateTimeSettingsEntry lastStarOnGitHubShowedAt; + late final DateTimeSettingsEntry lastBudgetAlertShowedAt; factory TransitiveLocalPreferences() { if (_instance == null) { @@ -125,6 +126,11 @@ class TransitiveLocalPreferences { preferences: _prefs, ); + lastBudgetAlertShowedAt = DateTimeSettingsEntry( + key: "transitive.lastBudgetAlertShowedAt", + preferences: _prefs, + ); + lastSavedAutoBackupPath = PrimitiveSettingsEntry( key: "transitive.lastSavedAutoBackupPath", preferences: _prefs, diff --git a/lib/routes.dart b/lib/routes.dart index d35ab201..21badd1b 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -4,6 +4,8 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/routes/account/account_edit_page.dart"; import "package:flow/routes/account_page.dart"; import "package:flow/routes/accounts_page.dart"; +import "package:flow/routes/budget_page.dart"; +import "package:flow/routes/budgets_page.dart"; import "package:flow/routes/categories_page.dart"; import "package:flow/routes/category/category_edit_page.dart"; import "package:flow/routes/category_page.dart"; @@ -49,6 +51,7 @@ import "package:flow/routes/setup/setup_onboarding_page.dart"; import "package:flow/routes/setup/setup_profile_page.dart"; import "package:flow/routes/setup/setup_profile_picture_page.dart"; import "package:flow/routes/setup_page.dart"; +import "package:flow/routes/stats/budgets_overview_page.dart"; import "package:flow/routes/stats/cash_flow_page.dart"; import "package:flow/routes/stats/insights_page.dart"; import "package:flow/routes/stats/net_worth_page.dart"; @@ -222,6 +225,16 @@ final GoRouter router = GoRouter( path: "/accounts", builder: (context, state) => const AccountsPage(), ), + GoRoute(path: "/budgets", builder: (context, state) => const BudgetsPage()), + GoRoute( + path: "/budgets/new", + builder: (context, state) => const BudgetPage.create(), + ), + GoRoute( + path: "/budgets/:id", + builder: (context, state) => + BudgetPage(budgetId: int.tryParse(state.pathParameters["id"]!) ?? -1), + ), GoRoute( path: "/transactionTags", builder: (context, state) => const TransactionTagsPage(), @@ -524,6 +537,10 @@ final GoRouter router = GoRouter( path: "/stats/map", builder: (context, state) => const SpendingMapPage(), ), + GoRoute( + path: "/stats/budgets", + builder: (context, state) => const BudgetsOverviewPage(), + ), GoRoute( path: "/_debug/scheduledNotifications", builder: (context, state) => DebugScheduledNotificationsPage(), diff --git a/lib/routes/budget_page.dart b/lib/routes/budget_page.dart new file mode 100644 index 00000000..ae7bb35a --- /dev/null +++ b/lib/routes/budget_page.dart @@ -0,0 +1,520 @@ +import "package:flow/entity/budget.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/form_validators.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/routes/error_page.dart"; +import "package:flow/routes/transaction_page/input_amount_sheet.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/utils.dart"; +import "package:flow/widgets/budgets/budget_category_chips.dart"; +import "package:flow/widgets/delete_button.dart"; +import "package:flow/widgets/general/directional_chevron.dart"; +import "package:flow/widgets/general/form_close_button.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/info_text.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/data/money.dart"; +import "package:flow/widgets/sheets/select_currency_sheet.dart"; +import "package:flow/widgets/time_range_selector.dart"; +import "package:flow/widgets/transaction_filter_head/select_multi_category_sheet.dart"; +import "package:flutter/foundation.dart" hide Category; +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"; + +class BudgetPage extends StatefulWidget { + final int budgetId; + + bool get isNewBudget => budgetId == 0; + + const BudgetPage({super.key, required this.budgetId}); + const BudgetPage.create({super.key}) : budgetId = 0; + + @override + State createState() => _BudgetPageState(); +} + +class _BudgetPageState extends State { + final GlobalKey _formKey = GlobalKey(); + + late final TextEditingController _nameTextController; + + late double _amount; + late String _currency; + late TimeRange _timeRange; + late bool _renewAutomatically; + late List _categories; + + Budget? _currentlyEditing; + + dynamic error; + + @override + void initState() { + super.initState(); + + _currentlyEditing = widget.isNewBudget + ? null + : ObjectBox().box().get(widget.budgetId); + + // Initialize the controller unconditionally so dispose() is always safe — + // the error branch (e.g. a since-deleted budget opened via a deep link) + // otherwise leaves this `late final` unset and dispose() would throw. + _nameTextController = TextEditingController(text: _currentlyEditing?.name); + + if (!widget.isNewBudget && _currentlyEditing == null) { + error = "Budget with id ${widget.budgetId} was not found"; + } else { + _amount = _currentlyEditing?.amount ?? 0.0; + _currency = + _currentlyEditing?.currency ?? + UserPreferencesService().primaryCurrency; + _timeRange = + _currentlyEditing?.timeRange ?? + MonthTimeRange.fromDateTime(DateTime.now()); + _renewAutomatically = _currentlyEditing?.renewAutomatically ?? true; + _categories = _currentlyEditing?.categories.toList() ?? []; + } + } + + @override + void dispose() { + _nameTextController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (error != null) return const ErrorPage(); + + return Scaffold( + appBar: AppBar( + leadingWidth: 40.0, + leading: FormCloseButton(canPop: () => !hasChanged()), + actions: [ + IconButton( + onPressed: () => save(), + icon: const Icon(Symbols.check_rounded), + tooltip: "general.save".t(context), + ), + ], + ), + body: SingleChildScrollView( + child: SafeArea( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + const SizedBox(height: 24.0), + _buildHero(context), + const SizedBox(height: 24.0), + _buildScopeCard(context), + Frame( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: InfoText( + child: Text("budget.categories.description".t(context)), + ), + ), + const SizedBox(height: 10.0), + _buildPeriodCard(context), + if (_currentlyEditing != null) ...[ + const SizedBox(height: 36.0), + Center( + child: DeleteButton( + onTap: _deleteBudget, + label: Text("budget.delete".t(context)), + ), + ), + ], + const SizedBox(height: 16.0), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHero(BuildContext context) { + return Column( + mainAxisSize: .min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + onTap: inputAmount, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + child: MoneyText( + Money(_amount, _currency), + style: context.textTheme.displayMedium, + autoSize: true, + textAlign: .center, + ), + ), + ), + ), + const SizedBox(height: 8.0), + _buildCurrencyChip(context), + const SizedBox(height: 12.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48.0), + child: TextFormField( + controller: _nameTextController, + validator: validateNameField, + textAlign: .center, + style: context.textTheme.titleMedium, + decoration: InputDecoration( + hintText: "budget.name".t(context), + hintStyle: context.textTheme.titleMedium?.semi(context), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8.0), + border: const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: context.colorScheme.onSurface.withAlpha(0x30), + ), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: context.colorScheme.primary), + ), + ), + ), + ), + ], + ); + } + + Widget _buildCurrencyChip(BuildContext context) { + return Material( + color: context.colorScheme.onSurface.withAlpha(0x14), + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: selectCurrency, + child: Padding( + padding: const EdgeInsets.fromLTRB(12.0, 4.0, 8.0, 4.0), + child: Row( + mainAxisSize: .min, + spacing: 4.0, + children: [ + const Icon(Symbols.universal_currency_alt_rounded, size: 16.0), + Text( + _currency, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + ), + ), + IconTheme.merge( + data: const IconThemeData(size: 16.0), + child: const LeChevron(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildScopeCard(BuildContext context) { + return Surface( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + builder: (context) => InkWell( + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + onTap: selectCategories, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + _buildCardHeader( + context, + icon: Symbols.category_rounded, + label: "budget.scope".t(context), + trailing: const LeChevron(), + ), + const SizedBox(height: 12.0), + BudgetCategoryChips( + categories: _categories, + allSpendingLabel: "budget.categories.allShort".t(context), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPeriodCard(BuildContext context) { + final bool pageable = _timeRange is PageableRange; + + return Surface( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + builder: (context) => Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + _buildCardHeader( + context, + icon: Symbols.calendar_month_rounded, + label: "budget.period".t(context), + ), + const SizedBox(height: 12.0), + // The selector's month button is card-colored, so it only reads + // against a contrasting fill. Frame it in a rounded `surface` inset + // — the same bg/button pairing it gets on stats pages — so it looks + // like a deliberate control group instead of a mismatched band. + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 6.0, + ), + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ), + child: TimeRangeSelector( + initialValue: _timeRange, + onChanged: updateTimeRange, + backgroundColor: Colors.transparent, + ), + ), + const SizedBox(height: 4.0), + CheckboxListTile( + value: pageable && _renewAutomatically, + onChanged: pageable ? updateRenewAutomatically : null, + title: Text("budget.renewAutomatically".t(context)), + contentPadding: EdgeInsets.zero, + ), + const SizedBox(height: 4.0), + InfoText( + child: Text("budget.renewAutomatically.description".t(context)), + ), + ], + ), + ), + ); + } + + /// Mirrors the pill header style of `InsightCard`. + Widget _buildCardHeader( + BuildContext context, { + required IconData icon, + required String label, + Widget? trailing, + }) { + final Color accent = context.colorScheme.primary; + + return Row( + children: [ + Icon(icon, color: accent, size: 20.0), + const SizedBox(width: 8.0), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), + decoration: BoxDecoration( + color: accent.withAlpha(0x28), + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + ), + child: Text( + label.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: accent, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ), + if (trailing != null) ...[const Spacer(), trailing], + ], + ); + } + + Future inputAmount() async { + final double? result = await showModalBottomSheet( + context: context, + builder: (context) => InputAmountSheet( + initialAmount: _amount.abs(), + currency: _currency, + title: "budget.amount".t(context), + allowNegative: false, + lockSign: true, + ), + isScrollControlled: true, + ); + + if (result == null) return; + + _amount = result.abs(); + + if (mounted) setState(() {}); + } + + Future selectCurrency() async { + final String? result = await showModalBottomSheet( + context: context, + builder: (context) => SelectCurrencySheet(currentlySelected: _currency), + isScrollControlled: true, + ); + + if (result == null) return; + + _currency = result; + + if (mounted) setState(() {}); + } + + Future selectCategories() async { + final Optional>? result = + await showModalBottomSheet>>( + context: context, + builder: (context) => SelectMultiCategorySheet( + categories: ObjectBox().getCategories(), + selectedUuids: _categories.map((category) => category.uuid).toList(), + ), + isScrollControlled: true, + ); + + if (result?.value case List newCategories) { + _categories = newCategories; + } + + if (mounted) setState(() {}); + } + + void updateTimeRange(TimeRange newRange) { + setState(() { + _timeRange = newRange; + }); + } + + void updateRenewAutomatically(bool? value) { + if (value == null) return; + + setState(() { + _renewAutomatically = value; + }); + } + + bool hasChanged() { + if (_currentlyEditing case Budget budget) { + return budget.name != _nameTextController.text.trim() || + budget.amount != _amount || + budget.currency != _currency || + budget.range != _timeRange.toString() || + budget.renewAutomatically != _renewAutomatically || + !setEquals( + budget.categories.map((category) => category.uuid).toSet(), + _categories.map((category) => category.uuid).toSet(), + ); + } + + return _nameTextController.text.trim().isNotEmpty || + _amount != 0.0 || + _categories.isNotEmpty; + } + + void save() { + if (_formKey.currentState?.validate() != true) return; + + if (_amount <= 0.0) { + context.showErrorToast(error: "budget.amount.required".t(context)); + return; + } + + final String trimmed = _nameTextController.text.trim(); + + if (_currentlyEditing case Budget budget) { + budget + ..name = trimmed + ..amount = _amount + ..currency = _currency + ..timeRange = _timeRange + ..renewAutomatically = _renewAutomatically + ..setCategories(_categories); + + ObjectBox().box().put(budget, mode: PutMode.update); + + context.pop(); + return; + } + + final Budget budget = Budget( + name: trimmed, + amount: _amount, + currency: _currency, + range: _timeRange.toString(), + renewAutomatically: _renewAutomatically, + )..setCategories(_categories); + + ObjectBox().box().put(budget, mode: PutMode.insert); + + context.pop(); + } + + String? validateNameField(String? value) { + final requiredValidationError = validateRequiredField(value); + if (requiredValidationError != null) { + return requiredValidationError.t(context); + } + + final String trimmed = value!.trim(); + + final Query sameNameQuery = ObjectBox() + .box() + .query( + Budget_.name + .equals(trimmed) + .and(Budget_.id.notEquals(_currentlyEditing?.id ?? 0)), + ) + .build(); + + final bool isNameUnique = sameNameQuery.count() == 0; + + sameNameQuery.close(); + + if (!isNameUnique) { + return "error.input.duplicate.accountName".t(context, trimmed); + } + + return null; + } + + Future _deleteBudget() async { + if (_currentlyEditing == null) return; + + final bool? confirmation = await context.showConfirmationSheet( + isDeletionConfirmation: true, + title: "general.delete.confirmName".t(context, _currentlyEditing!.name), + child: Text("budget.delete.description".t(context)), + ); + + if (confirmation == true) { + ObjectBox().box().remove(_currentlyEditing!.id); + + if (mounted) { + context.pop(); + GoRouter.of(context).popUntil((route) { + return route.path != "/budgets/:id"; + }); + } + } + } +} diff --git a/lib/routes/budgets_page.dart b/lib/routes/budgets_page.dart new file mode 100644 index 00000000..09a15913 --- /dev/null +++ b/lib/routes/budgets_page.dart @@ -0,0 +1,71 @@ +import "dart:async"; + +import "package:flow/entity/budget.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/prefs/local_preferences.dart"; +import "package:flow/services/budget.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/widgets/budgets/budget_card.dart"; +import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/rates_missing_error_box.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +class BudgetsPage extends StatefulWidget { + const BudgetsPage({super.key}); + + @override + State createState() => _BudgetsPageState(); +} + +class _BudgetsPageState extends State { + QueryBuilder qb() => BudgetService().allQb(); + + @override + void initState() { + super.initState(); + + // The app may have crossed a period boundary since startup. + unawaited(BudgetService().renewDueBudgets()); + } + + @override + Widget build(BuildContext context) { + final bool showMissingExchangeRatesWarning = + ExchangeRatesService().getPrimaryCurrencyRates() == null && + TransitiveLocalPreferences().usesNonPrimaryCurrency.get(); + + return Scaffold( + appBar: AppBar(title: Text("budgets".t(context))), + body: SafeArea( + child: StreamBuilder>( + stream: qb() + .watch(triggerImmediately: true) + .map((event) => event.find()), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Spinner.center(); + } + + final List budgets = snapshot.requireData; + + return ListView( + children: [ + ListTile( + title: Text("budgets.new".t(context)), + leading: const Icon(Symbols.add_rounded), + onTap: () => context.push("/budgets/new"), + ), + if (showMissingExchangeRatesWarning) + const RatesMissingErrorBox(), + ...budgets.map((budget) => BudgetCard(budget: budget)), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index 6cceade7..46dee7f9 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -71,11 +71,11 @@ class _ProfileTabState extends State { leading: const Icon(Symbols.category_rounded), onTap: () => context.push("/categories"), ), - // ListTile( - // title: Text("budgets".t(context)), - // leading: const Icon(Symbols.money_bag_rounded), - // onTap: () => context.push("/budgets"), - // ), + ListTile( + title: Text("budgets".t(context)), + leading: const Icon(Symbols.money_bag_rounded), + onTap: () => context.push("/budgets"), + ), // ListTile( // title: Text("goals".t(context)), // leading: const Icon(Symbols.savings_rounded), @@ -93,11 +93,6 @@ class _ProfileTabState extends State { ), const SizedBox(height: 32.0), ListHeader("tabs.profile.community".t(context)), - ListTile( - title: Text("tabs.profile.joinDiscord".t(context)), - leading: const Icon(SimpleIcons.discord), - onTap: () => openUrl(discordInviteLink), - ), ListTile( title: Text("tabs.profile.support".t(context)), leading: const Icon(Symbols.favorite_rounded), diff --git a/lib/routes/stats/budgets_overview_page.dart b/lib/routes/stats/budgets_overview_page.dart new file mode 100644 index 00000000..38b4ca5b --- /dev/null +++ b/lib/routes/stats/budgets_overview_page.dart @@ -0,0 +1,364 @@ +import "package:flow/data/budget_progress.dart"; +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/money.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/services/budget.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/budgets/budget_card.dart"; +import "package:flow/widgets/general/button.dart"; +import "package:flow/widgets/general/empty_state.dart"; +import "package:flow/widgets/general/list_header.dart"; +import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/stats/stats_app_bar.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +/// Budgets overview. +/// +/// Shows a status roll-up across every current budget — how many are over or +/// nearing their limit, and which one is worst — rather than a summed money +/// total: Flow's budgets are independent and can overlap or span different +/// periods, so a combined spent-vs-limit total would mislead (see +/// [BudgetsSummary]). Surfaces rule-based recommendations for the budgets most +/// in need of attention, and lists each budget's live [BudgetCard]. Budgets +/// track their own periods, so unlike most stats pages there is no time-range +/// selector. +class BudgetsOverviewPage extends StatefulWidget { + const BudgetsOverviewPage({super.key}); + + @override + State createState() => _BudgetsOverviewPageState(); +} + +class _BudgetsOverviewPageState extends State + with PrimaryCurrencyDependentState { + /// How many budgets get a recommendation row; progresses arrive sorted + /// most-urgent first, so the cut keeps the ones that matter. + static const int _maxRecommendations = 4; + + bool busy = false; + + List progresses = []; + BudgetsSummary? summary; + + @override + Widget build(BuildContext context) { + final BudgetsSummary? summary = this.summary; + + // Only budgets whose period includes now are relevant to a "this period" + // overview — a stale past-period budget (custom range, or a non-renewing + // one whose period ended) must not count toward the summary, drive + // recommendations, or appear as a live card. Its spend is historical. + final List current = progresses + .where((progress) => progress.isCurrent) + .toList(); + + // Only budgets that actually warrant a nudge get a recommendation row — + // over/nearing (needsAttention) or projected to overshoot (overpacing). + // Healthy budgets stay quiet; the summary banner speaks for them. + final List actionable = current + .where( + (progress) => + progress.needsAttention || + progress.primaryInsight == BudgetInsightType.overpacing, + ) + .toList(); + + return Scaffold( + appBar: StatsAppBar(title: "budget.overview.title".t(context)), + body: SafeArea( + child: busy && summary == null + ? const Spinner.center() + : summary == null || summary.isEmpty + ? _buildEmptyState(context) + : SingleChildScrollView( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + ListHeader("budget.overview.summary".t(context)), + const SizedBox(height: 2.0), + _buildSummaryCard( + context, + summary, + hasHeadsUp: actionable.isNotEmpty, + ), + if (actionable.isNotEmpty) ...[ + const SizedBox(height: 24.0), + ListHeader( + "budget.overview.recommendations".t(context), + ), + const SizedBox(height: 8.0), + ..._buildRecommendations(context, actionable), + ], + const SizedBox(height: 24.0), + ListHeader("budget.overview.perBudget".t(context)), + const SizedBox(height: 2.0), + ...current.map( + (progress) => BudgetCard(budget: progress.budget), + ), + const SizedBox(height: 96.0), + ], + ), + ), + ), + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Center( + child: SingleChildScrollView( + child: EmptyState( + icon: FlowIconData.icon(Symbols.money_bag_rounded), + title: Text("budget.overview.empty".t(context)), + subtitle: Text("budget.overview.empty.description".t(context)), + trailing: Button( + onTap: () => context.push("/budgets/new"), + leading: const Icon(Symbols.add_rounded), + child: Text("budget.overview.create".t(context)), + ), + ), + ), + ); + } + + /// A status banner, not a money roll-up: Flow's budgets are independent and + /// can overlap or span different periods, so a summed spent-vs-limit total + /// would double-count. This answers the only question the screen exists for + /// — is anything in trouble? — with honest counts. + Widget _buildSummaryCard( + BuildContext context, + BudgetsSummary summary, { + required bool hasHeadsUp, + }) { + return Surface( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + builder: (context) => Padding( + padding: const EdgeInsets.all(16.0), + child: summary.allHealthy + ? _buildHealthySummary(context, summary, hasHeadsUp: hasHeadsUp) + : _buildAttentionSummary(context, summary), + ), + ); + } + + Widget _buildHealthySummary( + BuildContext context, + BudgetsSummary summary, { + required bool hasHeadsUp, + }) { + return Row( + children: [ + Icon( + Symbols.check_circle_rounded, + color: context.flowColors.income, + size: 28.0, + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + // Nothing is over or nearing a limit. If a healthy budget is + // still pacing hot, a heads-up recommendation shows below, so + // stay factual here rather than congratulatory. + (hasHeadsUp + ? "budget.overview.nothingOver" + : "budget.overview.allHealthy") + .t(context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2.0), + Text( + "budget.overview.budgetsTracked".t( + context, + summary.budgetCount, + ), + style: context.textTheme.bodySmall?.semi(context), + ), + ], + ), + ), + ], + ); + } + + Widget _buildAttentionSummary(BuildContext context, BudgetsSummary summary) { + return Column( + crossAxisAlignment: .start, + children: [ + if (summary.overCount > 0) + _buildCountLine( + context, + icon: Symbols.error_circle_rounded, + tint: context.flowColors.expense, + text: "budget.overview.overLimit".t(context, summary.overCount), + ), + if (summary.warningCount > 0) ...[ + if (summary.overCount > 0) const SizedBox(height: 10.0), + _buildCountLine( + context, + icon: Symbols.warning_rounded, + // No dedicated warning hue in the palette; a softened expense reads + // as "caution" without introducing a new color. + tint: context.flowColors.expense.withAlpha(0xb0), + text: "budget.overview.nearingLimit".t( + context, + summary.warningCount, + ), + ), + ], + const SizedBox(height: 12.0), + Text( + "budget.overview.budgetsTracked".t(context, summary.budgetCount), + style: context.textTheme.bodySmall?.semi(context), + ), + if (summary.hasMissingData) ...[ + const SizedBox(height: 8.0), + Text( + "budget.overview.missingRates".t(context), + style: context.textTheme.bodySmall?.semi(context), + ), + ], + ], + ); + } + + Widget _buildCountLine( + BuildContext context, { + required IconData icon, + required Color tint, + required String text, + }) { + return Row( + children: [ + Icon(icon, color: tint, size: 22.0), + const SizedBox(width: 10.0), + Expanded( + child: Text( + text, + style: context.textTheme.titleSmall?.copyWith( + color: tint, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } + + /// [actionable] is already filtered to budgets that warrant a nudge and + /// sorted most-urgent first, so this just caps and renders it. + List _buildRecommendations( + BuildContext context, + List actionable, + ) { + return actionable + .take(_maxRecommendations) + .map( + (progress) => _buildRecommendationRow( + context, + icon: _insightIcon(progress.primaryInsight), + tint: _statusTint(context, progress.status), + message: _insightMessage(context, progress), + ), + ) + .toList(); + } + + Widget _buildRecommendationRow( + BuildContext context, { + required IconData icon, + required Color tint, + required String message, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: Row( + crossAxisAlignment: .start, + children: [ + Icon(icon, color: tint, size: 20.0), + const SizedBox(width: 12.0), + Expanded(child: Text(message, style: context.textTheme.bodyMedium)), + ], + ), + ); + } + + Color _statusTint(BuildContext context, BudgetStatus status) => + switch (status) { + BudgetStatus.over => context.flowColors.expense, + // No dedicated warning color in the palette; a softened expense reads + // as "caution" without introducing a new hue. + BudgetStatus.warning => context.flowColors.expense.withAlpha(0xb0), + BudgetStatus.healthy => context.flowColors.semi, + }; + + IconData _insightIcon(BudgetInsightType insight) => switch (insight) { + BudgetInsightType.over => Symbols.error_circle_rounded, + BudgetInsightType.nearingLimit => Symbols.warning_rounded, + BudgetInsightType.overpacing => Symbols.speed_rounded, + BudgetInsightType.onTrack => Symbols.check_circle_rounded, + BudgetInsightType.underspending => Symbols.savings_rounded, + }; + + String _insightMessage(BuildContext context, BudgetProgress progress) { + return switch (progress.primaryInsight) { + BudgetInsightType.over => "budget.insight.over".t(context, { + "amount": progress.overBy.formatted, + "name": progress.budget.name, + }), + BudgetInsightType.nearingLimit => + "budget.insight.nearingLimit".t(context, { + "name": progress.budget.name, + "percent": "${progress.percent}", + // nearingLimit only fires while the period is still live, so there is + // always time left; round the trailing partial day up to avoid "0d". + "days": "${progress.daysLeft < 1 ? 1 : progress.daysLeft}", + }), + BudgetInsightType.overpacing => "budget.insight.overpacing".t(context, { + "name": progress.budget.name, + "amount": Money( + progress.limit.amount * (progress.projectedRatio - 1), + progress.currency, + ).formatted, + }), + BudgetInsightType.onTrack => "budget.insight.onTrack".t(context, { + "name": progress.budget.name, + "amount": progress.remaining.formatted, + }), + BudgetInsightType.underspending => "budget.insight.underspending".t( + context, + {"name": progress.budget.name, "amount": progress.remaining.formatted}, + ), + }; + } + + @override + Future fetch() async { + if (!mounted) return; + setState(() { + busy = true; + }); + + try { + // The app may have crossed a period boundary since startup; advance + // auto-renewing budgets to their current period before computing. + await BudgetService().renewDueBudgets(); + + progresses = BudgetService().computeAllProgress(rates: rates); + summary = BudgetService().computeSummary( + progresses.where((progress) => progress.isCurrent).toList(), + ); + } finally { + busy = false; + if (mounted) setState(() {}); + } + } +} diff --git a/lib/routes/stats/insights_page.dart b/lib/routes/stats/insights_page.dart index 2af6fbb2..c5f4f335 100644 --- a/lib/routes/stats/insights_page.dart +++ b/lib/routes/stats/insights_page.dart @@ -1,5 +1,6 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/home/stats/bento/budget_tile.dart"; import "package:flow/widgets/home/stats/bento/calendar_tile.dart"; import "package:flow/widgets/home/stats/bento/map_tile.dart"; import "package:flow/widgets/home/stats/bento/net_worth_tile.dart"; @@ -39,6 +40,8 @@ class InsightsPage extends StatelessWidget { const SizedBox(height: 12.0), const NetWorthTile(), const SizedBox(height: 12.0), + const BudgetTile(), + const SizedBox(height: 12.0), const Row( spacing: 12.0, children: [ diff --git a/lib/routes/support_page.dart b/lib/routes/support_page.dart index 3ce5408c..e28923df 100644 --- a/lib/routes/support_page.dart +++ b/lib/routes/support_page.dart @@ -9,6 +9,7 @@ import "package:flow/theme/theme.dart"; import "package:flow/utils/utils.dart"; import "package:flow/widgets/action_card.dart"; import "package:flow/widgets/general/button.dart"; +import "package:flow/widgets/tip_card.dart"; import "package:flutter/material.dart"; import "package:in_app_review/in_app_review.dart"; import "package:material_symbols_icons_flow/symbols.dart"; @@ -40,6 +41,25 @@ class SupportPage extends StatelessWidget { children: [ Text("support.description".t(context)), const SizedBox(height: 16.0), + if (Platform.isIOS) ...[ + const TipCard(), + const SizedBox(height: 16.0), + ] else if (!Platform.isMacOS) ...[ + ActionCard( + title: "support.donateDeveloper".t(context), + subtitle: "support.donateDeveloper.description".t(context), + icon: FlowIconData.icon(Symbols.favorite_rounded), + trailing: Button( + backgroundColor: context.colorScheme.surface, + trailing: const Icon(Symbols.chevron_right_rounded), + child: Expanded( + child: Text("support.donateDeveloper.action".t(context)), + ), + onTap: () => openUrl(maintainerKoFiLink), + ), + ), + const SizedBox(height: 16.0), + ], if (supportsReview) ActionCard( title: "support.leaveAReview".t(context), @@ -70,20 +90,6 @@ class SupportPage extends StatelessWidget { ), ), const SizedBox(height: 16.0), - ActionCard( - title: "support.requestFeatures".t(context), - subtitle: "support.requestFeatures.description".t(context), - icon: FlowIconData.icon(Symbols.emoji_objects_rounded), - trailing: Button( - backgroundColor: context.colorScheme.surface, - trailing: const Icon(Symbols.chevron_right_rounded), - child: Expanded( - child: Text("support.requestFeatures.action".t(context)), - ), - onTap: () => openUrl(flowGitHubIssuesLink), - ), - ), - const SizedBox(height: 16.0), ActionCard( title: "support.contribute".t(context), subtitle: "support.contribute.description".t(context), @@ -95,22 +101,6 @@ class SupportPage extends StatelessWidget { onTap: () => openUrl(flowGitHubRepoLink), ), ), - if (!(Platform.isIOS || Platform.isMacOS)) ...[ - const SizedBox(height: 16.0), - ActionCard( - title: "support.donateDeveloper".t(context), - subtitle: "support.donateDeveloper.description".t(context), - icon: FlowIconData.icon(Symbols.favorite_rounded), - trailing: Button( - backgroundColor: context.colorScheme.surface, - trailing: const Icon(Symbols.chevron_right_rounded), - child: Expanded( - child: Text("support.donateDeveloper.action".t(context)), - ), - onTap: () => openUrl(maintainerKoFiLink), - ), - ), - ], const SizedBox(height: 16.0), ], ), diff --git a/lib/services/actionable_notifications.dart b/lib/services/actionable_notifications.dart index 42499204..36eee6cf 100644 --- a/lib/services/actionable_notifications.dart +++ b/lib/services/actionable_notifications.dart @@ -1,10 +1,15 @@ +import "dart:async"; import "dart:io"; import "package:flow/data/actionable_nofications/actionable_notification.dart"; +import "package:flow/data/budget_progress.dart"; +import "package:flow/data/exchange_rates.dart"; import "package:flow/entity/backup_entry.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/objectbox.g.dart"; import "package:flow/prefs/local_preferences.dart"; +import "package:flow/services/budget.dart"; +import "package:flow/services/exchange_rates.dart"; import "package:flow/services/sync/icloud_syncer.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/utils/should_execute_scheduled_task.dart"; @@ -52,6 +57,7 @@ class ActionableNotificationsService { /// /// Adds the following notifications: /// - Auto backup reminder + /// - Budget alert /// - Rate app /// - Star on GitHub void checkAndAddNotifications() async { @@ -65,6 +71,12 @@ class ActionableNotificationsService { return; } + tryAddBudgetAlert(); + + if (_notifications.value.isNotEmpty) { + return; + } + if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { try { final DateTime? lastRateAppShowedAt = TransitiveLocalPreferences() @@ -161,4 +173,39 @@ class ActionableNotificationsService { _log.warning("Failed to evaluate AutoBackupReminder", e); } } + + void tryAddBudgetAlert() { + try { + final DateTime? lastShown = TransitiveLocalPreferences() + .lastBudgetAlertShowedAt + .get(); + + if (!shouldExecuteScheduledTask(const Duration(days: 3), lastShown)) { + return; + } + + final ExchangeRates? rates = ExchangeRatesService() + .getPrimaryCurrencyRates(); + + final List progresses = BudgetService() + .computeAllProgress(rates: rates); + + BudgetProgress? urgent; + for (final BudgetProgress p in progresses) { + if (p.needsAttention) { + urgent = p; + break; + } + } + + if (urgent == null) return; + + add(BudgetAlert(payload: urgent)); + unawaited( + TransitiveLocalPreferences().lastBudgetAlertShowedAt.set(Moment.now()), + ); + } catch (e) { + _log.warning("Failed to evaluate BudgetAlert actionable notification", e); + } + } } diff --git a/lib/services/budget.dart b/lib/services/budget.dart index c88ecab0..ff08149e 100644 --- a/lib/services/budget.dart +++ b/lib/services/budget.dart @@ -1,9 +1,196 @@ +import "package:flow/data/budget_progress.dart"; +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/money.dart"; +import "package:flow/data/single_currency_flow.dart"; +import "package:flow/data/string_multi_filter.dart"; +import "package:flow/data/transaction_filter.dart"; +import "package:flow/data/transactions_filter/time_range.dart"; +import "package:flow/entity/budget.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/objectbox/objectbox.g.dart"; +import "package:logging/logging.dart"; +import "package:moment_dart/moment_dart.dart"; + +final Logger _log = Logger("BudgetService"); + class BudgetService { static BudgetService? _instance; factory BudgetService() => _instance ??= BudgetService._internal(); - BudgetService._internal() { - // Constructor + BudgetService._internal(); + + QueryBuilder allQb() => + ObjectBox().box().query().order(Budget_.createdDate); + + /// Expense transactions that count towards [budget] during [range] + /// (defaults to the budget's own period). + /// + /// A budget with no categories counts every expense. + QueryBuilder transactionsQb(Budget budget, {TimeRange? range}) { + final List categoriesUuids = budget.categories + .map((category) => category.uuid) + .toList(); + + return TransactionFilter( + range: TransactionFilterTimeRange.fromTimeRange( + range ?? budget.timeRange, + ), + categories: categoriesUuids.isEmpty + ? null + : StringMultiFilter.whitelist(categoriesUuids), + types: const [TransactionType.expense], + ).queryBuilder(); + } + + /// Sums [transactions] into [budget]'s currency. Pending transactions + /// don't count towards the budget. + /// + /// [SingleCurrencyFlow.hasMissingData] is set when a foreign-currency + /// transaction couldn't be converted due to missing [rates]. + SingleCurrencyFlow computeSpent( + Budget budget, + Iterable transactions, + ExchangeRates? rates, + ) { + return transactions.nonPending.flow.merge(budget.currency, rates); + } + + /// A computed [BudgetProgress] for [budget] against its current period. + /// + /// Pass [transactions] to avoid a query (e.g. when a caller already streams + /// the budget's transactions); otherwise the budget's own transactions are + /// fetched synchronously. [rates] converts foreign-currency spend into the + /// budget's currency; without it, foreign spend is flagged as missing data. + BudgetProgress computeProgress( + Budget budget, { + ExchangeRates? rates, + Iterable? transactions, + DateTime? asOf, + }) { + final Iterable txns; + if (transactions != null) { + txns = transactions; + } else { + final Query query = transactionsQb(budget).build(); + txns = query.find(); + query.close(); + } + + final SingleCurrencyFlow spentFlow = computeSpent(budget, txns, rates); + + return BudgetProgress( + budget: budget, + spent: Money(spentFlow.totalExpense.amount.abs(), budget.currency), + limit: Money(budget.amount, budget.currency), + asOf: asOf ?? DateTime.now(), + hasMissingData: spentFlow.hasMissingData, + ); + } + + /// [computeProgress] for every budget, sorted most-urgent first (see + /// [BudgetProgress.severity]). + List computeAllProgress({ + ExchangeRates? rates, + DateTime? asOf, + }) { + final DateTime now = asOf ?? DateTime.now(); + + final List progresses = []; + for (final Budget budget in ObjectBox().box().getAll()) { + try { + progresses.add(computeProgress(budget, rates: rates, asOf: now)); + } catch (e, stackTrace) { + // One malformed budget must not sink the whole overview/bento. A + // hand-edited or cross-version backup can carry an unparseable range + // ([Budget.timeRange] throws) or an invalid currency ([Money] throws); + // skip it and keep computing the rest. + _log.warning( + "Skipped budget '${budget.name}' while computing progress", + e, + stackTrace, + ); + } + } + + progresses.sort((a, b) => b.severity.compareTo(a.severity)); + + return progresses; + } + + /// Rolls [progresses] up into a status summary: counts of over / nearing + /// budgets plus the single worst offender. + /// + /// No currency conversion happens here — this is a count/status view, not a + /// money total. Summing independent, possibly-overlapping budgets (or ones on + /// different periods) would double-count, so [BudgetsSummary] intentionally + /// omits a combined total. Pass already-current progresses; order doesn't + /// matter (the worst is picked by [BudgetProgress.severity]). + BudgetsSummary computeSummary(List progresses) { + int over = 0; + int warning = 0; + bool missing = false; + BudgetProgress? worst; + + for (final BudgetProgress p in progresses) { + switch (p.status) { + case BudgetStatus.over: + over++; + case BudgetStatus.warning: + warning++; + case BudgetStatus.healthy: + break; + } + + if (p.hasMissingData) missing = true; + + if (worst == null || p.severity > worst.severity) worst = p; + } + + return BudgetsSummary( + budgetCount: progresses.length, + overCount: over, + warningCount: warning, + worst: worst, + hasMissingData: missing, + ); + } + + /// Advances every auto-renewing budget whose period has ended to the + /// period containing the current moment. No-op for budgets on a + /// non-pageable (custom) range. + /// + /// Idempotent; safe to call at startup and before rendering budgets. + /// + /// Returns the number of budgets that were advanced. + Future renewDueBudgets() async { + final DateTime now = DateTime.now(); + final List budgets = await ObjectBox().box().getAllAsync(); + final List renewed = []; + + for (final Budget budget in budgets) { + if (!budget.renewAutomatically) continue; + + TimeRange range = budget.timeRange; + if (range is! PageableRange) continue; + + if (!range.to.isBefore(now)) continue; + + while (range.to.isBefore(now)) { + range = (range as PageableRange).next; + } + + budget.timeRange = range; + renewed.add(budget); + } + + if (renewed.isNotEmpty) { + await ObjectBox().box().putManyAsync(renewed); + _log.fine("Renewed ${renewed.length} budget(s) to their current period"); + } + + return renewed.length; } } diff --git a/lib/services/in_app_purchase.dart b/lib/services/in_app_purchase.dart new file mode 100644 index 00000000..83ebd74a --- /dev/null +++ b/lib/services/in_app_purchase.dart @@ -0,0 +1,126 @@ +import "dart:async"; + +import "package:flow/constants.dart"; +import "package:flutter/foundation.dart"; +import "package:in_app_purchase/in_app_purchase.dart"; +import "package:logging/logging.dart"; + +final Logger _log = Logger("TipService"); + +class TipService { + static TipService? _instance; + + factory TipService() => _instance ??= TipService._internal(); + + TipService._internal(); + + final InAppPurchase _iap = InAppPurchase.instance; + + StreamSubscription>? _subscription; + + final ValueNotifier?> products = + ValueNotifier?>(null); + + final ValueNotifier pending = ValueNotifier(null); + + final StreamController _onTipController = + StreamController.broadcast(); + + Stream get onTip => _onTipController.stream; + + bool _available = false; + bool get available => _available; + + Future init() async { + _available = await _iap.isAvailable(); + + if (!_available) { + _log.info("In-app purchases are not available on this device"); + products.value = const []; + return; + } + + _subscription ??= _iap.purchaseStream.listen( + _onPurchaseUpdate, + onError: (Object error) => _log.warning("Purchase stream error", error), + ); + + await loadProducts(); + } + + Future loadProducts() async { + if (!_available) return; + + final ProductDetailsResponse response = await _iap.queryProductDetails( + tipProductIds, + ); + + if (response.error != null) { + _log.warning("Failed to query tip products", response.error); + } + if (response.notFoundIDs.isNotEmpty) { + _log.warning("Tip products not found: ${response.notFoundIDs}"); + } + + products.value = response.productDetails + ..sort((a, b) => a.rawPrice.compareTo(b.rawPrice)); + } + + Future tip(ProductDetails product) async { + if (!_available || pending.value != null) return; + + pending.value = product.id; + + try { + await _iap.buyConsumable( + purchaseParam: PurchaseParam(productDetails: product), + ); + } catch (e) { + _log.warning("Failed to start purchase for ${product.id}", e); + pending.value = null; + rethrow; + } + } + + Future _onPurchaseUpdate(List purchases) async { + for (final PurchaseDetails purchase in purchases) { + switch (purchase.status) { + case PurchaseStatus.pending: + break; + case PurchaseStatus.purchased: + case PurchaseStatus.restored: + _handleSuccess(purchase); + case PurchaseStatus.error: + _log.warning("Purchase error", purchase.error); + pending.value = null; + case PurchaseStatus.canceled: + pending.value = null; + } + + // Consumables MUST be completed, otherwise StoreKit keeps redelivering + // the transaction on every launch. + if (purchase.pendingCompletePurchase) { + await _iap.completePurchase(purchase); + } + } + } + + void _handleSuccess(PurchaseDetails purchase) { + pending.value = null; + + _log.info("Tip received: ${purchase.productID}"); + + final ProductDetails? product = products.value + ?.where((p) => p.id == purchase.productID) + .firstOrNull; + + if (product != null) { + _onTipController.add(product); + } + } + + void dispose() { + _subscription?.cancel(); + _onTipController.close(); + } +} diff --git a/lib/sync/export/export_v2.dart b/lib/sync/export/export_v2.dart index 56f60351..74a5b4e1 100644 --- a/lib/sync/export/export_v2.dart +++ b/lib/sync/export/export_v2.dart @@ -5,6 +5,7 @@ import "package:archive/archive_io.dart"; import "package:flow/constants.dart"; import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/account.dart"; +import "package:flow/entity/budget.dart"; import "package:flow/entity/category.dart"; import "package:flow/entity/file_attachment.dart"; import "package:flow/entity/profile.dart"; @@ -56,6 +57,9 @@ Future generateBackupJSONContentV2() async { .getAllAsync(); syncLogger.fine("Finished fetching file attachments"); + final List budgets = await ObjectBox().box().getAllAsync(); + syncLogger.fine("Finished fetching budgets"); + final DateTime exportDate = DateTime.now().toUtc(); final Query firstProfileQuery = ObjectBox() @@ -101,6 +105,7 @@ Future generateBackupJSONContentV2() async { userPreferences: userPreferences, recurringTransactions: recurringTransactions, attachments: attachments, + budgets: budgets, primaryCurrency: userPreferences?.primaryCurrency ?? UserPreferencesService().primaryCurrency, diff --git a/lib/sync/import/import_v2.dart b/lib/sync/import/import_v2.dart index 32b88dec..6f17318d 100644 --- a/lib/sync/import/import_v2.dart +++ b/lib/sync/import/import_v2.dart @@ -3,6 +3,7 @@ import "dart:io"; import "package:flow/entity/account.dart"; import "package:flow/entity/backup_entry.dart"; +import "package:flow/entity/budget.dart"; import "package:flow/entity/category.dart"; import "package:flow/entity/file_attachment.dart"; import "package:flow/entity/profile.dart"; @@ -198,6 +199,19 @@ class ImportV2 extends Importer { ); } + // Resurrect [Budget]s + // + // Resolve ToMany [categories] by `uuid`. Categories are + // already in place by this point. + if (data.budgets?.isNotEmpty == true) { + progressNotifier.value = ImportV2Progress.writingBudgets; + final List budgets = data.budgets! + .map(_resolveCategoriesForBudget) + .toList(); + await ObjectBox().box().putManyAsync(budgets); + _log.fine("Imported ${budgets.length} budgets"); + } + if (data.profile != null) { try { await ObjectBox().box().removeAllAsync(); @@ -403,6 +417,40 @@ class ImportV2 extends Importer { return transaction; } + Budget _resolveCategoriesForBudget(Budget budget) { + final List categoriesUuids = budget.categoriesUuids ?? []; + + if (categoriesUuids.isEmpty) { + return budget; + } + + final Query categoryQuery = ObjectBox() + .box() + .query(Category_.uuid.oneOf(categoriesUuids)) + .build(); + + final List foundCategories = categoryQuery.find(); + + categoryQuery.close(); + + if (foundCategories.length != categoriesUuids.length) { + final Set foundUuids = foundCategories + .map((category) => category.uuid) + .toSet(); + final Iterable missingUuids = categoriesUuids.where( + (uuid) => !foundUuids.contains(uuid), + ); + + _log.warning( + "Failed to link category to budget because: Cannot find category(s) (${missingUuids.join(", ")})", + ); + } + + budget.setCategories(foundCategories); + + return budget; + } + Transaction _resolveFileAttachmentsForTransaction(Transaction transaction) { if (transaction.attachmentsUuids?.isEmpty == true) { throw Exception("This transaction lacks `attachmentsUuids`"); @@ -446,6 +494,7 @@ enum ImportV2Progress with LocalizedEnum { writingRecurringTransactions, writingTransactions, writingTranscationFilterPresets, + writingBudgets, writingProfile, writingUserPreferences, settingPrimaryCurrency, diff --git a/lib/sync/model/model_v2.dart b/lib/sync/model/model_v2.dart index 257dac4a..064c785a 100644 --- a/lib/sync/model/model_v2.dart +++ b/lib/sync/model/model_v2.dart @@ -1,4 +1,5 @@ import "package:flow/entity/account.dart"; +import "package:flow/entity/budget.dart"; import "package:flow/entity/category.dart"; import "package:flow/entity/file_attachment.dart"; import "package:flow/entity/profile.dart"; @@ -21,6 +22,9 @@ class SyncModelV2 extends SyncModelBase { final List? transactionFilterPresets; final List? transactionTags; final List? attachments; + + /// Backups made before budgets existed lack this field. + final List? budgets; final Profile? profile; final UserPreferences? userPreferences; final String? primaryCurrency; @@ -37,6 +41,7 @@ class SyncModelV2 extends SyncModelBase { required this.transactionFilterPresets, required this.transactionTags, required this.attachments, + required this.budgets, required this.profile, required this.userPreferences, required this.primaryCurrency, diff --git a/lib/sync/model/model_v2.g.dart b/lib/sync/model/model_v2.g.dart index 1bd69d49..2b15ba15 100644 --- a/lib/sync/model/model_v2.g.dart +++ b/lib/sync/model/model_v2.g.dart @@ -32,6 +32,9 @@ SyncModelV2 _$SyncModelV2FromJson(Map json) => SyncModelV2( attachments: (json['attachments'] as List?) ?.map((e) => FileAttachment.fromJson(e as Map)) .toList(), + budgets: (json['budgets'] as List?) + ?.map((e) => Budget.fromJson(e as Map)) + .toList(), profile: json['profile'] == null ? null : Profile.fromJson(json['profile'] as Map), @@ -56,6 +59,7 @@ Map _$SyncModelV2ToJson(SyncModelV2 instance) => 'transactionFilterPresets': instance.transactionFilterPresets, 'transactionTags': instance.transactionTags, 'attachments': instance.attachments, + 'budgets': instance.budgets, 'profile': instance.profile, 'userPreferences': instance.userPreferences, 'primaryCurrency': instance.primaryCurrency, diff --git a/lib/utils/primary_currency_dependent_state.dart b/lib/utils/primary_currency_dependent_state.dart index f8bb7ff0..8b32920c 100644 --- a/lib/utils/primary_currency_dependent_state.dart +++ b/lib/utils/primary_currency_dependent_state.dart @@ -1,15 +1,24 @@ +import "dart:async"; + import "package:flow/data/exchange_rates.dart"; import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/transactions.dart"; import "package:flow/services/user_preferences.dart"; import "package:flutter/widgets.dart"; -/// Wires a [State] to the primary currency and its exchange rates. +/// Wires a [State] to the primary currency, its exchange rates, and the +/// transaction store. /// /// Exchange rates and the primary currency can settle a frame or two after a /// screen opens — and can change while it's open. Mixing this in keeps /// [primaryCurrency] and [rates] fresh and re-runs [fetch] whenever either /// changes, so the first paint is never stale and later edits are reflected. /// +/// It also re-runs [fetch] whenever a transaction is added, edited, or removed, +/// so analytics never lag behind the data they summarize. Transaction edits can +/// arrive in bursts (imports, bulk edits), so these are debounced by +/// [_transactionRefreshDebounce] into a single trailing reload. +/// /// Implementers provide [fetch]; the mixin owns the listener lifecycle and the /// two fields. [fetch] is called once after the initial values are resolved and /// again on every dependency change. @@ -17,8 +26,14 @@ mixin PrimaryCurrencyDependentState on State { late String primaryCurrency; ExchangeRates? rates; - /// Reloads this screen's data. Called on init and on every primary-currency - /// or exchange-rate change. + static const Duration _transactionRefreshDebounce = Duration( + milliseconds: 300, + ); + + Timer? _transactionDebounce; + + /// Reloads this screen's data. Called on init and on every primary-currency, + /// exchange-rate, or transaction change. Future fetch(); @override @@ -32,16 +47,19 @@ mixin PrimaryCurrencyDependentState on State { _onDependenciesChanged, ); UserPreferencesService().valueNotifier.addListener(_onDependenciesChanged); + TransactionsService().addListener(_onTransactionsChanged); } @override void dispose() { + _transactionDebounce?.cancel(); ExchangeRatesService().exchangeRatesCache.removeListener( _onDependenciesChanged, ); UserPreferencesService().valueNotifier.removeListener( _onDependenciesChanged, ); + TransactionsService().removeListener(_onTransactionsChanged); super.dispose(); } @@ -55,4 +73,12 @@ mixin PrimaryCurrencyDependentState on State { _refreshPrimaryCurrencyDependencies(); fetch(); } + + void _onTransactionsChanged() { + _transactionDebounce?.cancel(); + _transactionDebounce = Timer(_transactionRefreshDebounce, () { + if (!mounted) return; + fetch(); + }); + } } diff --git a/lib/widgets/budgets/budget_card.dart b/lib/widgets/budgets/budget_card.dart new file mode 100644 index 00000000..7b73187a --- /dev/null +++ b/lib/widgets/budgets/budget_card.dart @@ -0,0 +1,89 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/single_currency_flow.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/budget.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/services/budget.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/widgets/analytics/bullet_chart.dart"; +import "package:flow/widgets/analytics/insight_card.dart"; +import "package:flow/widgets/budgets/budget_category_chips.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// Shows a budget's spent-vs-amount for its current period. +/// +/// Watches the budget's matching transactions, so it live-updates as +/// transactions change. +class BudgetCard extends StatelessWidget { + final Budget budget; + + const BudgetCard({super.key, required this.budget}); + + @override + Widget build(BuildContext context) { + final ExchangeRates? rates = ExchangeRatesService() + .getPrimaryCurrencyRates(); + + return StreamBuilder>( + stream: BudgetService() + .transactionsQb(budget) + .watch(triggerImmediately: true) + .map((event) => event.find()), + builder: (context, snapshot) { + final SingleCurrencyFlow spentFlow = BudgetService().computeSpent( + budget, + snapshot.data ?? const [], + rates, + ); + + final double spent = spentFlow.totalExpense.amount.abs(); + + return InsightCard( + icon: Symbols.money_bag_rounded, + label: budget.name, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + MoneyText(Money(spent, budget.currency)), + const Text(" / "), + MoneyText(Money(budget.amount, budget.currency)), + ], + ), + subtitle: _periodLabel(), + onTap: () => context.push("/budgets/${budget.id}"), + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + BudgetCategoryChips( + categories: budget.categories.toList(), + allSpendingLabel: "budget.categories.allShort".t(context), + ), + const SizedBox(height: 12.0), + BulletChart(value: spent, target: budget.amount), + ], + ), + ); + }, + ); + } + + String _periodLabel() { + final TimeRange range = budget.timeRange; + + return switch (range) { + MonthTimeRange monthTimeRange => monthTimeRange.from.format( + payload: monthTimeRange.from.isAtSameYearAs(DateTime.now()) + ? "MMMM" + : "MMMM YYYY", + ), + YearTimeRange yearTimeRange => yearTimeRange.year.toString(), + _ => "${range.from.toMoment().ll} -> ${range.to.toMoment().ll}", + }; + } +} diff --git a/lib/widgets/budgets/budget_category_chips.dart b/lib/widgets/budgets/budget_category_chips.dart new file mode 100644 index 00000000..90e669b1 --- /dev/null +++ b/lib/widgets/budgets/budget_category_chips.dart @@ -0,0 +1,99 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/flow_icon.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +/// A compact, wrapping row of category pills for a budget. +/// +/// Shows up to [maxVisible] category chips (icon + name); any remainder +/// collapses into a "+N" pill. When [categories] is empty the budget counts +/// all spending, so a single [allSpendingLabel] pill is shown instead. +class BudgetCategoryChips extends StatelessWidget { + final List categories; + + /// Label for the "counts everything" pill shown when [categories] is empty. + final String allSpendingLabel; + + final int maxVisible; + + const BudgetCategoryChips({ + super.key, + required this.categories, + required this.allSpendingLabel, + this.maxVisible = 4, + }); + + @override + Widget build(BuildContext context) { + if (categories.isEmpty) { + return Wrap( + children: [ + _Pill( + icon: FlowIcon( + FlowIconData.icon(Symbols.all_inclusive_rounded), + size: 14.0, + ), + label: allSpendingLabel, + ), + ], + ); + } + + final List visible = categories.take(maxVisible).toList(); + final int overflow = categories.length - visible.length; + + return Wrap( + spacing: 6.0, + runSpacing: 6.0, + children: [ + for (final Category category in visible) + _Pill( + icon: FlowIcon( + category.icon, + size: 14.0, + colorScheme: category.colorScheme, + ), + label: category.name, + ), + if (overflow > 0) _Pill(label: "+$overflow"), + ], + ); + } +} + +class _Pill extends StatelessWidget { + final Widget? icon; + final String label; + + const _Pill({this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: icon == null ? 10.0 : 8.0, + vertical: 4.0, + ), + decoration: BoxDecoration( + color: context.colorScheme.onSurface.withAlpha(0x14), + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + ), + child: Row( + mainAxisSize: .min, + spacing: 4.0, + children: [ + ?icon, + Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colorScheme.onSurface.withAlpha(0xcc), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/home/stats/bento/analytics_bento.dart b/lib/widgets/home/stats/bento/analytics_bento.dart index fea6dbc2..531c8694 100644 --- a/lib/widgets/home/stats/bento/analytics_bento.dart +++ b/lib/widgets/home/stats/bento/analytics_bento.dart @@ -1,6 +1,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/widgets/general/frame.dart"; import "package:flow/widgets/general/list_header.dart"; +import "package:flow/widgets/home/stats/bento/budget_tile.dart"; import "package:flow/widgets/home/stats/bento/calendar_tile.dart"; import "package:flow/widgets/home/stats/bento/cash_flow_tile.dart"; import "package:flow/widgets/home/stats/bento/map_tile.dart"; @@ -19,8 +20,8 @@ import "package:moment_dart/moment_dart.dart"; /// selector, so they belong directly beneath it. /// /// Below an "Insights" header sits the **timeless** section: net worth, -/// wrapped, the spending calendar, recurring, and the spending map each show -/// their own natural window and ignore the selected month. Grouping them apart +/// wrapped, budgets, the spending calendar, recurring, and the spending map +/// each show their own natural window and ignore the selected month. Grouping them apart /// keeps the range selector from implying control it doesn't have. The same /// pages are also reachable from Profile → Insights. class AnalyticsBento extends StatelessWidget { @@ -61,6 +62,8 @@ class AnalyticsBento extends StatelessWidget { const SizedBox(height: 12.0), const NetWorthTile(), const SizedBox(height: 12.0), + const BudgetTile(), + const SizedBox(height: 12.0), const Row( spacing: 12.0, children: [ diff --git a/lib/widgets/home/stats/bento/budget_tile.dart b/lib/widgets/home/stats/bento/budget_tile.dart new file mode 100644 index 00000000..4ac2101d --- /dev/null +++ b/lib/widgets/home/stats/bento/budget_tile.dart @@ -0,0 +1,151 @@ +import "package:flow/data/budget_progress.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/services/budget.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/analytics/bullet_chart.dart"; +import "package:flow/widgets/home/stats/bento/bento_tile.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +/// Bento preview of budgets: a status glance across every budget's current +/// period — all-clear, or the worst offender with how many are over/nearing. +/// Range-independent, since each budget tracks its own period regardless of the +/// Stats range selector. +/// +/// Deliberately not a summed spent-vs-limit bar: Flow's budgets are independent +/// and can overlap or span different periods, so a combined total would +/// double-count (see [BudgetsSummary]). +class BudgetTile extends StatefulWidget { + const BudgetTile({super.key}); + + @override + State createState() => _BudgetTileState(); +} + +class _BudgetTileState extends State + with PrimaryCurrencyDependentState { + bool busy = true; + bool loaded = false; + + BudgetsSummary? summary; + + @override + Widget build(BuildContext context) { + final BudgetsSummary? summary = this.summary; + + return BentoTile( + label: "tabs.stats.analytics.budgets".t(context), + icon: Symbols.money_bag_rounded, + height: 158.0, + busy: busy && !loaded, + onTap: () => context.push("/stats/budgets"), + child: summary == null || summary.isEmpty + ? Text( + "tabs.stats.analytics.budgets.empty".t(context), + style: context.textTheme.bodySmall?.semi(context), + ) + : summary.allHealthy + ? _buildHealthy(context, summary) + : _buildAttention(context, summary), + ); + } + + Widget _buildHealthy(BuildContext context, BudgetsSummary summary) { + return Column( + crossAxisAlignment: .start, + mainAxisAlignment: .center, + children: [ + Text( + "tabs.stats.analytics.budgets.onTrack".t(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.headlineSmall?.copyWith( + color: context.flowColors.income, + ), + ), + const SizedBox(height: 4.0), + Text( + "tabs.stats.analytics.budgets.tracked".t(context, summary.budgetCount), + style: context.textTheme.bodySmall?.semi(context), + ), + ], + ); + } + + Widget _buildAttention(BuildContext context, BudgetsSummary summary) { + final BudgetProgress? worst = summary.worst; + final bool anyOver = summary.overCount > 0; + final Color tint = anyOver + ? context.flowColors.expense + // No dedicated warning hue in the palette; a softened expense reads as + // "caution" without a new color. + : context.flowColors.expense.withAlpha(0xb0); + + return Column( + crossAxisAlignment: .start, + mainAxisAlignment: .center, + children: [ + if (worst != null) ...[ + Row( + children: [ + Expanded( + child: Text( + worst.budget.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 8.0), + Text( + "${worst.percent}%", + style: context.textTheme.labelLarge?.copyWith( + color: tint, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6.0), + BulletChart( + value: worst.spent.amount, + target: worst.limit.amount, + height: 12.0, + ), + const SizedBox(height: 8.0), + ], + Text( + anyOver + ? "tabs.stats.analytics.budgets.overCount".t( + context, + summary.overCount, + ) + : "tabs.stats.analytics.budgets.nearingCount".t( + context, + summary.warningCount, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith(color: tint), + ), + ], + ); + } + + @override + Future fetch() async { + try { + final List progresses = BudgetService() + .computeAllProgress(rates: rates) + .where((progress) => progress.isCurrent) + .toList(); + summary = BudgetService().computeSummary(progresses); + loaded = true; + } finally { + busy = false; + if (mounted) setState(() {}); + } + } +} diff --git a/lib/widgets/import_wizard/v2/backup_info_v2.dart b/lib/widgets/import_wizard/v2/backup_info_v2.dart index fd4229a9..cb238797 100644 --- a/lib/widgets/import_wizard/v2/backup_info_v2.dart +++ b/lib/widgets/import_wizard/v2/backup_info_v2.dart @@ -99,6 +99,18 @@ class BackupInfoV2 extends StatelessWidget { ), ), ], + if (importer.data.budgets?.isNotEmpty == true) ...[ + const SizedBox(height: 8.0), + ImportItemListTile( + icon: FlowIconData.icon(Symbols.money_bag_rounded), + label: Text( + "sync.import.syncData.parsedEstimate.budgetCount".t( + context, + importer.data.budgets?.length ?? 0, + ), + ), + ), + ], if (importer.data.attachments?.isNotEmpty == true) ...[ const SizedBox(height: 8.0), ImportItemListTile( diff --git a/lib/widgets/internal_notifications/budget_alert_notification.dart b/lib/widgets/internal_notifications/budget_alert_notification.dart new file mode 100644 index 00000000..ea3df3b0 --- /dev/null +++ b/lib/widgets/internal_notifications/budget_alert_notification.dart @@ -0,0 +1,47 @@ +import "package:flow/data/actionable_nofications/actionable_notification.dart"; +import "package:flow/data/budget_progress.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/widgets/internal_notifications/internal_notification_list_tile.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +class BudgetAlertNotification extends StatelessWidget { + final BudgetAlert notification; + final VoidCallback? onDismiss; + + const BudgetAlertNotification({ + super.key, + required this.notification, + this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + final BudgetProgress p = notification.payload; + + final bool over = p.status == BudgetStatus.over; + + return ActionableNotificationListTile( + onDismiss: onDismiss, + icon: notification.icon, + title: over + ? "budget.alert.over".t(context, {"name": p.budget.name}) + : "budget.alert.near".t(context, { + "name": p.budget.name, + "percent": "${p.percent}", + }), + subtitle: over + ? "budget.alert.overBy".t(context, {"amount": p.overBy.formatted}) + : "budget.alert.left".t(context, {"amount": p.remaining.formatted}), + action: TextButton.icon( + onPressed: () { + onDismiss?.call(); + context.push("/budgets/${p.budget.id}"); + }, + label: Text("budget.alert.action".t(context)), + icon: Icon(Symbols.arrow_forward_rounded), + ), + ); + } +} diff --git a/lib/widgets/internal_notifications/internal_notification_section.dart b/lib/widgets/internal_notifications/internal_notification_section.dart index 87044ed6..57c659d3 100644 --- a/lib/widgets/internal_notifications/internal_notification_section.dart +++ b/lib/widgets/internal_notifications/internal_notification_section.dart @@ -1,5 +1,6 @@ import "package:flow/data/actionable_nofications/actionable_notification.dart"; import "package:flow/widgets/internal_notifications/auto_backup_reminder.dart"; +import "package:flow/widgets/internal_notifications/budget_alert_notification.dart"; import "package:flow/widgets/internal_notifications/rate_app_notification.dart"; import "package:flow/widgets/internal_notifications/star_on_github_notification.dart"; import "package:flow/widgets/internal_notifications/turn_on_icloud_sync_reminder.dart"; @@ -33,6 +34,10 @@ class ActionableNotificationSection extends StatelessWidget { notification: notification, onDismiss: onDismiss, ), + BudgetAlert notification => BudgetAlertNotification( + notification: notification, + onDismiss: onDismiss, + ), _ => SizedBox.shrink(), }; } diff --git a/lib/widgets/location_picker_sheet.dart b/lib/widgets/location_picker_sheet.dart index 5a895a68..7a07571b 100644 --- a/lib/widgets/location_picker_sheet.dart +++ b/lib/widgets/location_picker_sheet.dart @@ -2,17 +2,18 @@ import "dart:io"; import "package:flow/constants.dart"; import "package:flow/l10n/extensions.dart"; -import "package:flow/prefs/local_preferences.dart"; import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/modal_overflow_bar.dart"; import "package:flow/widgets/general/modal_sheet.dart"; import "package:flow/widgets/open_street_map.dart"; import "package:flutter/material.dart"; +import "package:flutter_map/flutter_map.dart"; import "package:geolocator/geolocator.dart"; import "package:go_router/go_router.dart"; import "package:latlong2/latlong.dart"; import "package:logging/logging.dart"; import "package:material_symbols_icons_flow/symbols.dart"; +import "package:permission_handler/permission_handler.dart"; final Logger _log = Logger("LocationPickerSheet"); @@ -31,9 +32,13 @@ class LocationPickerSheet extends StatefulWidget { } class _LocationPickerSheetState extends State { + final MapController _mapController = MapController(); + late LatLng center; - bool useCurrentLocationWhenAvailable = false; + bool _locationBusy = false; + + bool get _myLocationAvailable => Platform.isAndroid || Platform.isIOS; @override void initState() { @@ -43,9 +48,12 @@ class _LocationPickerSheetState extends State { widget.latitude ?? sukhbaatarSquareCenterLat, widget.longitude ?? sukhbaatarSquareCenterLong, ); + } - useCurrentLocationWhenAvailable = - widget.latitude == null || widget.longitude == null; + @override + void dispose() { + _mapController.dispose(); + super.dispose(); } @override @@ -75,55 +83,70 @@ class _LocationPickerSheetState extends State { ), child: SizedBox( height: MediaQuery.of(context).size.height * .75, - child: OpenStreetMap( - center: center, - onTap: (pos) => setState(() { - useCurrentLocationWhenAvailable = false; - center = pos; - }), + child: Stack( + children: [ + OpenStreetMap( + mapController: _mapController, + center: center, + onTap: (pos) => setState(() => center = pos), + ), + if (_myLocationAvailable) + Positioned( + right: 16.0, + bottom: 16.0, + child: FloatingActionButton.small( + heroTag: null, + tooltip: "transaction.tags.location.useCurrent".t(context), + onPressed: _locationBusy ? null : _useMyLocation, + child: _locationBusy + ? const SizedBox.square( + dimension: 20.0, + child: CircularProgressIndicator(strokeWidth: 2.0), + ) + : const Icon(Symbols.my_location_rounded), + ), + ), + ], ), ), ); } - void tryFetchLocation() { - if (Platform.isLinux) return; - if (LocalPreferences().enableGeo.get() != true) return; - if (LocalPreferences().autoAttachTransactionGeo.get() != true) return; + Future _useMyLocation() async { + if (_locationBusy) return; - Geolocator.getLastKnownPosition() - .then((lastKnown) { - if (lastKnown == null) { - return; - } + setState(() => _locationBusy = true); - if (!useCurrentLocationWhenAvailable) return; + try { + final PermissionStatus status = await Permission.locationWhenInUse + .request(); + switch (status) { + case PermissionStatus.limited: + case PermissionStatus.granted: + break; + default: if (mounted) { - setState(() { - center = LatLng(lastKnown.latitude, lastKnown.longitude); - }); - } - }) - .catchError((e) { - _log.warning("Failed to get last known location", e); - }); - - Geolocator.getCurrentPosition() - .then((current) { - if (!useCurrentLocationWhenAvailable) return; - - center = LatLng(current.latitude, current.longitude); - }) - .catchError((e) { - _log.warning("Failed to get current location", e); - }) - .whenComplete(() { - if (mounted) { - setState(() { - useCurrentLocationWhenAvailable = false; - }); + context.showErrorToast( + error: "preferences.transactions.geo.auto.permissionDenied".t( + context, + ), + ); } - }); + return; + } + + final Position position = await Geolocator.getCurrentPosition(); + if (!mounted) return; + + final LatLng point = LatLng(position.latitude, position.longitude); + setState(() => center = point); + _mapController.move(point, _mapController.camera.zoom); + } catch (e, stackTrace) { + _log.warning("Failed to get current location", e, stackTrace); + } finally { + _locationBusy = false; + if (mounted) setState(() {}); + } } } diff --git a/lib/widgets/sheets/select_flow_icon_sheet.dart b/lib/widgets/sheets/select_flow_icon_sheet.dart index 7fa41431..a366719e 100644 --- a/lib/widgets/sheets/select_flow_icon_sheet.dart +++ b/lib/widgets/sheets/select_flow_icon_sheet.dart @@ -54,7 +54,7 @@ class _SelectFlowIconSheetState extends State } void _selectIcon() async { - final FlowIconData? result = await showModalBottomSheet( + final FlowIconData? result = await showModalBottomSheet( context: context, builder: (context) => SelectIconFlowIconSheet(initialValue: widget.current), diff --git a/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart b/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart index 3a7cf18f..1eb4aca0 100644 --- a/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart +++ b/lib/widgets/sheets/select_flow_icon_sheet/select_icon_flow_icon_sheet.dart @@ -92,22 +92,38 @@ class _SelectIconFlowIconSheetState extends State controller: _controller, children: [ GridView.builder( - itemBuilder: (context, index) => IconButton( - onPressed: () => updateSimpleIcon(simpleIconsResult[index].key), - icon: Icon(simpleIconsResult[index].value), - iconSize: 48.0, - ), + itemBuilder: (context, index) { + final bool selected = + value is IconFlowIcon && + (value as IconFlowIcon).iconData == + simpleIconsResult[index].value; + + return IconButton( + onPressed: () => updateSimpleIcon(simpleIconsResult[index].key), + icon: Icon(simpleIconsResult[index].value), + iconSize: 48.0, + isSelected: selected, + ); + }, itemCount: simpleIconsResult.length, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 72.0, ), ), GridView.builder( - itemBuilder: (context, index) => IconButton( - onPressed: () => updateIcon(materialSymbolsResult[index]), - icon: Icon(materialSymbolsResult[index]), - iconSize: 48.0, - ), + itemBuilder: (context, index) { + final bool selected = + value is IconFlowIcon && + (value as IconFlowIcon).iconData == + materialSymbolsResult[index]; + + return IconButton( + onPressed: () => updateIcon(materialSymbolsResult[index]), + icon: Icon(materialSymbolsResult[index]), + iconSize: 48.0, + isSelected: selected, + ); + }, itemCount: materialSymbolsResult.length, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 72.0, diff --git a/lib/widgets/time_range_selector.dart b/lib/widgets/time_range_selector.dart index c6c12020..01b7c8fd 100644 --- a/lib/widgets/time_range_selector.dart +++ b/lib/widgets/time_range_selector.dart @@ -13,10 +13,16 @@ class TimeRangeSelector extends StatefulWidget { final Function(TimeRange) onChanged; + /// Fill painted behind the selector. Defaults to [ColorScheme.surface] so it + /// blends into a scaffold; pass [Colors.transparent] when hosting it inside a + /// card or other colored container so it doesn't paint a mismatched band. + final Color? backgroundColor; + const TimeRangeSelector({ super.key, required this.initialValue, required this.onChanged, + this.backgroundColor, }); @override @@ -59,7 +65,7 @@ class _TimeRangeSelectorState extends State { final TextDirection textDirection = Directionality.of(context); return Container( - color: context.colorScheme.surface, + color: widget.backgroundColor ?? context.colorScheme.surface, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/tip_card.dart b/lib/widgets/tip_card.dart new file mode 100644 index 00000000..04e04484 --- /dev/null +++ b/lib/widgets/tip_card.dart @@ -0,0 +1,106 @@ +import "dart:async"; + +import "package:flow/data/flow_icon.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/services/in_app_purchase.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/utils.dart"; +import "package:flow/widgets/action_card.dart"; +import "package:flow/widgets/general/button.dart"; +import "package:flutter/material.dart"; +import "package:in_app_purchase/in_app_purchase.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +class TipCard extends StatefulWidget { + const TipCard({super.key}); + + @override + State createState() => _TipCardState(); +} + +class _TipCardState extends State { + final TipService _tipService = TipService(); + + StreamSubscription? _onTipSubscription; + + @override + void initState() { + super.initState(); + + _onTipSubscription = _tipService.onTip.listen(_handleTip); + + if (_tipService.products.value == null) { + unawaited(_tipService.init()); + } + } + + @override + void dispose() { + _onTipSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder?>( + valueListenable: _tipService.products, + builder: (context, products, _) { + if (products == null || products.isEmpty) { + return const SizedBox.shrink(); + } + + return ActionCard( + title: "support.donateDeveloper".t(context), + subtitle: "support.donateDeveloper.description".t(context), + icon: FlowIconData.icon(Symbols.favorite_rounded), + trailing: ValueListenableBuilder( + valueListenable: _tipService.pending, + builder: (context, pending, _) { + return Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: [ + for (final ProductDetails product in products) + Button( + backgroundColor: context.colorScheme.surface, + onTap: pending == null ? () => _tip(product) : null, + child: Stack( + alignment: Alignment.center, + children: [ + Opacity( + opacity: pending == product.id ? 0.0 : 1.0, + child: Text(product.price), + ), + if (pending == product.id) + const SizedBox.square( + dimension: 16.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + }, + ); + } + + void _tip(ProductDetails product) async { + try { + await _tipService.tip(product); + } catch (e) { + if (!mounted) return; + context.showErrorToast(error: "support.tip.error".t(context)); + } + } + + void _handleTip(ProductDetails product) { + if (!mounted) return; + context.showToast(text: "support.tip.thankYou".t(context)); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7d05ee1f..535eefb0 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,6 +17,7 @@ import flutter_local_notifications import flutter_timezone import geolocator_apple import icloud_storage +import in_app_purchase_storekit import in_app_review import local_auth_darwin import objectbox_flutter_libs @@ -42,6 +43,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) IcloudStoragePlugin.register(with: registry.registrar(forPlugin: "IcloudStoragePlugin")) + InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index cac0cd66..9662bfc3 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,21 +1,18 @@ PODS: - file_saver (0.0.1): - FlutterMacOS - - flutter_contacts (0.0.1): - - FlutterMacOS - FlutterMacOS (1.0.0) - icloud_storage (0.0.1): - FlutterMacOS - - ObjectBox (5.2.0) + - ObjectBox (5.3.0) - objectbox_flutter_libs (0.0.1): - FlutterMacOS - - ObjectBox (= 5.2.0) + - ObjectBox (= 5.3.0) - screen_retriever_macos (0.0.1): - FlutterMacOS DEPENDENCIES: - file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`) - - flutter_contacts (from `Flutter/ephemeral/.symlinks/plugins/flutter_contacts/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - icloud_storage (from `Flutter/ephemeral/.symlinks/plugins/icloud_storage/macos`) - objectbox_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos`) @@ -28,8 +25,6 @@ SPEC REPOS: EXTERNAL SOURCES: file_saver: :path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos - flutter_contacts: - :path: Flutter/ephemeral/.symlinks/plugins/flutter_contacts/macos FlutterMacOS: :path: Flutter/ephemeral icloud_storage: @@ -41,11 +36,10 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: file_saver: e35bd97de451dde55ff8c38862ed7ad0f3418d0f - flutter_contacts: 22579d648c4278eefa588e8392499f10540a24b2 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 icloud_storage: eb5b0f20687cf5a4fabc0b541f3b079cd6df7dcb - ObjectBox: 946c1e8586aaa61e21b6661bab4948d06fdd51c4 - objectbox_flutter_libs: 52ffb4ad42870c47886c2c6a7528e76b770af6d9 + ObjectBox: 2aae8ad350012cb53c6e7848064307cf68617f73 + objectbox_flutter_libs: c6ec3df598aa194da1cdf929fc9640bbc8f58be1 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f PODFILE CHECKSUM: 3d7cd7f90b4de5948ed6e76869b40159643b2f0c diff --git a/pubspec.lock b/pubspec.lock index 02003d7f..7a09d3f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -842,10 +842,10 @@ packages: dependency: "direct main" description: name: home_widget - sha256: "7a32f7d6a3afd542126fb0004acba939a41ee57d874172926212774fbce684d3" + sha256: fece84c7464099873c3ace38394ad7053509a4b0e36e57a1105a6aa31f47f23c url: "https://pub.dev" source: hosted - version: "0.9.2" + version: "0.9.3" hooks: dependency: transitive description: @@ -966,6 +966,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + in_app_purchase_android: + dependency: transitive + description: + name: in_app_purchase_android + sha256: eb8f551039481d1b265f12fa54f5ab5dd4f13ec5444a468b85a3793517a37fda + url: "https://pub.dev" + source: hosted + version: "0.5.1" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "0b0076cac8ce4fa7048f01e76af8b123aeb6a7c4e0dea2a5206d6664454f3e36" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "5f9d59c86c15f56429a4fdf09097c99d5b412510e1fcf80cf874fc9638fab369" + url: "https://pub.dev" + source: hosted + version: "0.4.10" in_app_review: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3f095309..735f5380 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.23.0+348" +version: "0.23.2+351" environment: sdk: ">=3.10.0 <4.0.0" @@ -41,7 +41,7 @@ dependencies: fuzzywuzzy: ^1.2.0 geolocator: ^14.0.2 go_router: ^17.1.0 - home_widget: ^0.9.0 + home_widget: ^0.9.3 http: ^1.5.0 icloud_storage: ^2.2.0 image_picker: ^1.2.1 @@ -80,6 +80,7 @@ dependencies: url_launcher: ^6.3.2 uuid: ^4.5.3 window_manager: ^0.5.1 + in_app_purchase: ^3.3.0 dev_dependencies: build_runner: ^2.7.1 diff --git a/test/backup/v2_roundtrip_test.dart b/test/backup/v2_roundtrip_test.dart index 97a5c224..9704b01e 100644 --- a/test/backup/v2_roundtrip_test.dart +++ b/test/backup/v2_roundtrip_test.dart @@ -2,12 +2,14 @@ import "dart:convert"; import "dart:io"; import "package:flow/entity/account.dart"; +import "package:flow/entity/budget.dart"; import "package:flow/entity/category.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/objectbox.dart"; import "package:flow/sync/export/export_v2.dart"; import "package:flow/sync/model/model_v2.dart"; import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; import "package:path/path.dart" as path; import "../database_test.dart" show objectboxTestRootDir; @@ -171,6 +173,58 @@ void main() async { }, ); + test( + "Every budget field + category links survive roundtrip", + () async { + final Category category = ObjectBox().box().getAll().first; + + final Budget original = Budget( + name: "Roundtrip budget", + amount: 500.0, + currency: "USD", + range: MonthTimeRange.fromDateTime(DateTime.now()).toString(), + )..setCategories([category]); + + ObjectBox().box().put(original); + + final SyncModelV2 parsed = SyncModelV2.fromJson( + jsonDecode(await generateBackupJSONContentV2()) + as Map, + ); + + expect(parsed.budgets, isNotNull); + final Budget? roundtripped = parsed.budgets! + .where((budget) => budget.uuid == original.uuid) + .firstOrNull; + + expect( + roundtripped, + isNotNull, + reason: "Budget ${original.uuid} (${original.name}) lost", + ); + expect(roundtripped!.name, original.name); + expect(roundtripped.amount, original.amount); + expect(roundtripped.currency, original.currency); + expect(roundtripped.range, original.range); + expect(roundtripped.renewAutomatically, original.renewAutomatically); + expect(roundtripped.categoriesUuids, [category.uuid]); + }, + ); + + test("Backups without budgets parse with null budgets", () { + final SyncModelV2 parsed = SyncModelV2.fromJson({ + "versionCode": 2, + "exportDate": DateTime.now().toUtc().toIso8601String(), + "username": "Test", + "appVersion": "0.0.0", + "accounts": [], + "categories": [], + "transactions": [], + }); + + expect(parsed.budgets, isNull); + }); + tearDownAll(() async { await testCleanupObject( instance: ObjectBox(), diff --git a/test/demo_data_test.dart b/test/demo_data_test.dart new file mode 100644 index 00000000..965ec429 --- /dev/null +++ b/test/demo_data_test.dart @@ -0,0 +1,134 @@ +import "dart:io"; + +import "package:flow/entity/account.dart"; +import "package:flow/entity/budget.dart"; +import "package:flow/entity/goal.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/objectbox.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path/path.dart" as path; + +import "objectbox_erase.dart"; + +/// Exercises the full demo-data seeding (`createAndPutDebugData`) end to end and +/// asserts the generated history is rich and internally consistent. +void main() { + late ObjectBox obx; + + final String directory = path.join( + Directory.current.path, + ".objectbox_test_demo", + ); + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Account/category presets are localized; load English so their (unique) + // names aren't all empty strings. + await FlowLocalizations(const Locale("en")).load(); + + obx = await ObjectBox.initialize( + customDirectory: directory, + subdirectory: "demo", + ); + + await obx.createAndPutDebugData(); + }); + + tearDownAll(() async { + await testCleanupObject( + instance: obx, + directory: path.join(directory, "demo"), + ); + }); + + test("creates the four demo accounts", () { + final List accounts = obx.box().getAll(); + expect(accounts.length, 4); + expect( + accounts.where((a) => a.accountType == AccountType.creditLine).length, + 1, + ); + }); + + test("generates a rich, multi-year transaction history", () { + final List txns = obx.box().getAll(); + + expect( + txns.length, + greaterThan(1500), + reason: "expected a dense history, got ${txns.length}", + ); + + final List dates = + txns.map((t) => t.transactionDate).toList()..sort(); + final int spanDays = dates.last.difference(dates.first).inDays; + + expect( + spanDays, + greaterThan(365 * 3 - 45), + reason: "history should span ~3 years, spanned $spanDays days", + ); + // Nothing in the future. + expect(dates.last.isAfter(DateTime.now().add(const Duration(minutes: 1))), + isFalse); + }); + + test("never leaves a debit/savings account negative", () { + for (final Account account in obx.box().getAll()) { + final double balance = account.balance.amount; + + if (account.accountType == AccountType.creditLine) { + expect(balance, lessThanOrEqualTo(0.01), reason: account.name); + expect( + balance, + greaterThanOrEqualTo(-(account.creditLimit ?? 0)), + reason: account.name, + ); + } else { + expect( + balance, + greaterThanOrEqualTo(-0.01), + reason: "${account.name} went negative: $balance", + ); + } + } + }); + + test("savings grows beyond its starting balance", () { + final Account savings = obx + .box() + .getAll() + .firstWhere((a) => a.excludeFromTotalBalance); + + // Started at 8,500 and receives monthly contributions + interest. + expect(savings.balance.amount, greaterThan(9000)); + }); + + test("transactions are mostly categorized and meaningfully tagged", () { + final List txns = obx.box().getAll(); + + final int categorized = + txns.where((t) => t.category.target != null).length; + expect(categorized, greaterThan(txns.length ~/ 2)); + + final int tagged = txns.where((t) => t.tags.isNotEmpty).length; + expect(tagged, greaterThan(100)); + }); + + test("populates budgets and goals", () { + expect(obx.box().getAll().length, greaterThanOrEqualTo(3)); + + final List goals = obx.box().getAll(); + expect(goals.length, greaterThanOrEqualTo(2)); + expect(goals.every((g) => g.account.target != null), isTrue); + }); + + test("running it again is a no-op (guarded)", () async { + final int before = obx.box().count(); + await obx.createAndPutDebugData(); + expect(obx.box().count(), before); + }); +} diff --git a/test/unit/budget_renewal_test.dart b/test/unit/budget_renewal_test.dart new file mode 100644 index 00000000..3308a964 --- /dev/null +++ b/test/unit/budget_renewal_test.dart @@ -0,0 +1,125 @@ +import "dart:io"; + +import "package:flow/entity/budget.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/services/budget.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:path/path.dart" as path; + +import "../objectbox_erase.dart"; + +/// Exercises `BudgetService.renewDueBudgets`: expired auto-renewing budgets +/// must advance in place to the period containing "now" (catching up over +/// multiple missed periods), while opted-out, custom-range, and current +/// budgets stay untouched. +void main() { + late ObjectBox obx; + + final String directory = path.join( + Directory.current.path, + ".objectbox_test_budget_renewal", + ); + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + obx = await ObjectBox.initialize( + customDirectory: directory, + subdirectory: "budget_renewal", + ); + }); + + tearDownAll(() async { + await testCleanupObject( + instance: obx, + directory: path.join(directory, "budget_renewal"), + ); + }); + + Budget put(Budget budget) { + obx.box().put(budget); + return budget; + } + + String rangeOf(Budget budget) => obx.box().get(budget.id)!.range; + + test("expired budgets advance to the current period", () async { + final MonthTimeRange currentMonth = MonthTimeRange.fromDateTime( + DateTime.now(), + ); + + final Budget lastMonth = put( + Budget( + name: "Last month", + amount: 100.0, + currency: "USD", + range: currentMonth.last.toString(), + ), + ); + + // Catch-up isn't one step: several missed periods must fast-forward + // to the current one. + final Budget manyMonthsAgo = put( + Budget( + name: "Many months ago", + amount: 100.0, + currency: "USD", + range: currentMonth.last.last.last.last.toString(), + ), + ); + + final int renewedCount = await BudgetService().renewDueBudgets(); + + expect(renewedCount, 2); + expect(rangeOf(lastMonth), currentMonth.toString()); + expect(rangeOf(manyMonthsAgo), currentMonth.toString()); + }); + + test("opted-out, custom-range, and current budgets stay untouched", () async { + final MonthTimeRange currentMonth = MonthTimeRange.fromDateTime( + DateTime.now(), + ); + + final Budget optedOut = put( + Budget( + name: "Opted out", + amount: 100.0, + currency: "USD", + range: currentMonth.last.toString(), + renewAutomatically: false, + ), + ); + + final Budget customRange = put( + Budget( + name: "Custom range", + amount: 100.0, + currency: "USD", + range: CustomTimeRange( + DateTime(2020, 1, 1), + DateTime(2020, 3, 1), + ).toString(), + ), + ); + + final Budget current = put( + Budget( + name: "Current", + amount: 100.0, + currency: "USD", + range: currentMonth.toString(), + ), + ); + + final int renewedCount = await BudgetService().renewDueBudgets(); + + expect(renewedCount, 0); + expect(rangeOf(optedOut), currentMonth.last.toString()); + expect( + rangeOf(customRange), + CustomTimeRange(DateTime(2020, 1, 1), DateTime(2020, 3, 1)).toString(), + ); + expect(rangeOf(current), currentMonth.toString()); + }); +}