From 8ee064b80e345d1e2fb52499a56d712a4d132870 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 9 Jun 2026 16:52:30 +0800 Subject: [PATCH 01/19] Disable Jetifier to fix release build OOM JetifyTransform OOM'd (Java heap space) jetifying the Flutter engine jar during bundleRelease. checkJetifier confirms no module uses legacy support libraries, so Jetifier is unneeded. Removing the flag (deprecated in AGP 9, removed in AGP 10) drops the transform entirely and fixes the build. --- android/gradle.properties | 1 - 1 file changed, 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 6eec59b9..a5b19faf 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,5 @@ org.gradle.jvmargs=-Xmx2560M android.useAndroidX=true -android.enableJetifier=true android.defaults.buildfeatures.resvalues=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false android.enableAppCompileTimeRClass=false From cf36741d69c34190b4d77dfc9a66536f9313702f Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 9 Jun 2026 16:52:30 +0800 Subject: [PATCH 02/19] Disable Jetifier to fix release build OOM JetifyTransform OOM'd (Java heap space) jetifying the Flutter engine jar during bundleRelease. checkJetifier confirms no module uses legacy support libraries, so Jetifier is unneeded. Removing the flag (deprecated in AGP 9, removed in AGP 10) drops the transform entirely and fixes the build. --- android/gradle.properties | 1 - 1 file changed, 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 6eec59b9..a5b19faf 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,5 @@ org.gradle.jvmargs=-Xmx2560M android.useAndroidX=true -android.enableJetifier=true android.defaults.buildfeatures.resvalues=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false android.enableAppCompileTimeRClass=false From 5b6c638b6b5b24e33db6e390205a47ece515a080 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 9 Jun 2026 16:59:24 +0800 Subject: [PATCH 03/19] aoaoaoao --- pubspec.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 02003d7f..df6e5507 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1170,10 +1170,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.18.3" mgrs_dart: dependency: transitive description: @@ -1895,26 +1895,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -2031,10 +2031,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.0" vm_service: dependency: transitive description: From 59f81c9c2d2853dd99ee647d0da0be9f187ea681 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 9 Jun 2026 17:39:07 +0800 Subject: [PATCH 04/19] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ced1f1..3a768a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog -## next +## 0.23.0 ### New features * Added Traditional Chinese (Taiwan) localizations +* Revamped the stats tab, and added insights ## 0.22.0 From a9241e73b081863085b781d70bfd7cdfebaf9a37 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 12 Jun 2026 13:26:10 +0800 Subject: [PATCH 05/19] aoaoao --- android/build.gradle.kts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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") { From fe3ac2d9e47a11b2d5fbfea6a74efa79fadbc48a Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 12 Jun 2026 13:49:06 +0800 Subject: [PATCH 06/19] Raise Gradle daemon heap to 5g to fix R8 release OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit minifyReleaseWithR8 OOM'd (Java heap space) on CI; R8 runs in the Gradle daemon and 2560M was too small for full-mode shrinking on this app. Bump to 5g (ubuntu-latest has 16 GB) — same root cause as the earlier Jetifier OOM, now addressed at the heap cap instead of by removing transforms. --- android/gradle.properties | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index a5b19faf..caad2048 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,6 @@ -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.defaults.buildfeatures.resvalues=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false From 44f2a37acd0dd3b1002dc942d8f567a5a0acac87 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 14 Jun 2026 17:00:58 +0800 Subject: [PATCH 07/19] Update build-fat-apk-android-beta.yaml --- .../workflows/build-fat-apk-android-beta.yaml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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 From 1900032e0fee5a77e8d39755fa43d2f2ebc6fc1e Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 14 Jun 2026 17:38:28 +0800 Subject: [PATCH 08/19] Update pubspec.lock --- pubspec.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 02003d7f..e9cfd1b9 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: @@ -1170,10 +1170,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.18.3" mgrs_dart: dependency: transitive description: @@ -1895,26 +1895,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -2031,10 +2031,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.0" vm_service: dependency: transitive description: From 2ad8dd0a782b265d9a349836329b02e43a7b1507 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 14 Jun 2026 18:40:46 +0800 Subject: [PATCH 09/19] pin home widget lower version --- pubspec.lock | 24 ++++++++++++------------ pubspec.yaml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index e9cfd1b9..8d476dac 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1170,10 +1170,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1895,26 +1895,26 @@ packages: dependency: "direct dev" description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.17" timezone: dependency: "direct main" description: @@ -2031,10 +2031,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3f095309..8a2b1ece 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 From e749803079ca5b6ccc6f05261c36ffb0257ac686 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 15 Jun 2026 14:26:56 +0800 Subject: [PATCH 10/19] hotfix --- CHANGELOG.md | 6 ++++++ lib/widgets/sheets/select_flow_icon_sheet.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a768a1b..417d9fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.23.1 + +### Fixes + +* Fixed selecting icons + ## 0.23.0 ### New features 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/pubspec.yaml b/pubspec.yaml index 8a2b1ece..946cd298 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.1+349" environment: sdk: ">=3.10.0 <4.0.0" From bf7d66a4c533f67a53b94f77d9d07e9dfec8fe1b Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 15 Jun 2026 14:28:33 +0800 Subject: [PATCH 11/19] update lock --- pubspec.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 8d476dac..e9cfd1b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1170,10 +1170,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.18.3" mgrs_dart: dependency: transitive description: @@ -1895,26 +1895,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -2031,10 +2031,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.0" vm_service: dependency: transitive description: From 811746057c14b677780ee1709d87bbf6428eb0b7 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 15 Jun 2026 14:59:37 +0800 Subject: [PATCH 12/19] highlight selected icon --- .../select_icon_flow_icon_sheet.dart | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) 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, From a3327d87250e61612e6e3b34b061c297c4461595 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 15 Jun 2026 14:59:46 +0800 Subject: [PATCH 13/19] bump ver --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 946cd298..ad5a30d6 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.1+349" +version: "0.23.1+350" environment: sdk: ">=3.10.0 <4.0.0" From f0b31f49ef5e5adc9418b6738ba733d40d505177 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 15 Jun 2026 21:04:56 +0800 Subject: [PATCH 14/19] realistic demo data :) --- lib/data/setup/demo_data.dart | 1094 +++++++++++++++++++++++++++++++++ lib/objectbox.dart | 308 +++++----- test/demo_data_test.dart | 134 ++++ 3 files changed, 1367 insertions(+), 169 deletions(-) create mode 100644 lib/data/setup/demo_data.dart create mode 100644 test/demo_data_test.dart 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/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/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); + }); +} From 963b6a5be416951113d227958ceb60be5fd0ccf7 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 21 Jun 2026 13:10:49 +0800 Subject: [PATCH 15/19] rc 0.23.2 --- .gitignore | 3 +- CHANGELOG.md | 11 ++ assets/l10n/ar.json | 2 + assets/l10n/be_BY.json | 2 + assets/l10n/cs_CZ.json | 2 + assets/l10n/de_DE.json | 2 + assets/l10n/en.json | 2 + assets/l10n/es_ES.json | 2 + assets/l10n/fa_IR.json | 2 + assets/l10n/fr_FR.json | 2 + assets/l10n/it_IT.json | 2 + assets/l10n/mn_MN.json | 2 + assets/l10n/pl_PL.json | 2 + assets/l10n/ru_RU.json | 2 + assets/l10n/tr_TR.json | 2 + assets/l10n/uk_UA.json | 2 + assets/l10n/zh_TW.json | 2 + ios/Tips.storekit | 71 ++++++++++ lib/constants.dart | 14 ++ lib/main.dart | 10 ++ lib/routes/support_page.dart | 36 ++--- lib/services/in_app_purchase.dart | 126 ++++++++++++++++++ .../primary_currency_dependent_state.dart | 32 ++++- lib/widgets/location_picker_sheet.dart | 111 +++++++++------ lib/widgets/tip_card.dart | 106 +++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 ++++++-- pubspec.yaml | 3 +- 28 files changed, 534 insertions(+), 77 deletions(-) create mode 100644 ios/Tips.storekit create mode 100644 lib/services/in_app_purchase.dart create mode 100644 lib/widgets/tip_card.dart 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 417d9fd2..0d93d108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 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 diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index be09e1ac..eb259437 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index f1c227b2..dd48a44a 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 9cbfb159..8a509a9e 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -537,6 +537,8 @@ "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.", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 16948fd3..4ee457b8 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -537,6 +537,8 @@ "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.", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b6dddb2e..58961afc 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 30c5b27a..4d3a7fbd 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index b96d0369..4f5d969a 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 130fe32a..b619fd17 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index fe4ad100..6a9834e2 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 9516060c..9d7049ca 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -537,6 +537,8 @@ "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 гэх мэт программуудад нээж харахад тохиромжтой", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index b180c8e0..0f376f4a 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 925131f6..8a48fbad 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index c4bcdb45..a5114e7b 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 616e61ba..f4a19f4b 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -537,6 +537,8 @@ "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", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 32b6ecf9..5c6fdd9d 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -537,6 +537,8 @@ "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 試算表等軟體中開啟", 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..65e77008 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -29,6 +29,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/main.dart b/lib/main.dart index 7a93f184..f7567f9e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,6 +34,7 @@ import "package:flow/providers/transaction_tags_provider.dart"; import "package:flow/routes.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 +129,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(); diff --git a/lib/routes/support_page.dart b/lib/routes/support_page.dart index 3ce5408c..93c55c82 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), @@ -95,22 +115,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/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/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/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/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/pubspec.lock b/pubspec.lock index e9cfd1b9..7a09d3f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: @@ -1170,10 +1202,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1194,10 +1226,10 @@ packages: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1895,26 +1927,26 @@ packages: dependency: "direct dev" description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.17" timezone: dependency: "direct main" description: @@ -2031,10 +2063,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ad5a30d6..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.1+350" +version: "0.23.2+351" environment: sdk: ">=3.10.0 <4.0.0" @@ -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 From 0eaf80a18b3f6568bb0cf67b85d744036b85e4a4 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 21 Jun 2026 16:23:28 +0800 Subject: [PATCH 16/19] remove mentions of discord & github issues --- README.md | 3 +-- assets/l10n/ar.json | 6 +----- assets/l10n/be_BY.json | 6 +----- assets/l10n/cs_CZ.json | 6 +----- assets/l10n/de_DE.json | 6 +----- assets/l10n/en.json | 6 +----- assets/l10n/es_ES.json | 6 +----- assets/l10n/fa_IR.json | 6 +----- assets/l10n/fr_FR.json | 6 +----- assets/l10n/it_IT.json | 6 +----- assets/l10n/mn_MN.json | 6 +----- assets/l10n/pl_PL.json | 6 +----- assets/l10n/ru_RU.json | 6 +----- assets/l10n/tr_TR.json | 6 +----- assets/l10n/uk_UA.json | 6 +----- assets/l10n/zh_TW.json | 6 +----- lib/constants.dart | 4 ---- lib/routes/home/profile_tab.dart | 5 ----- lib/routes/support_page.dart | 14 -------------- pubspec.lock | 24 ++++++++++++------------ 20 files changed, 28 insertions(+), 112 deletions(-) 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/assets/l10n/ar.json b/assets/l10n/ar.json index eb259437..ca63bc23 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -532,9 +532,6 @@ "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": "تعذر بدء عملية الشراء. الرجاء المحاولة مرة أخرى.", @@ -644,7 +641,6 @@ "tabs.profile.community": "المجتمع", "tabs.profile.guide": "دليل الاستخدام", "tabs.profile.import": "الاستيراد", - "tabs.profile.joinDiscord": "انضم إلى Discord الخاص بـ Flow", "tabs.profile.other": "أخرى", "tabs.profile.preferences": "التفضيلات", "tabs.profile.recommend": "أوصي بـ Flow", @@ -892,4 +888,4 @@ "transactions.query.noResult": "لا توجد معاملات للعرض", "transactions.query.noResult.description": "حاول تحديث الفلاتر", "visitGitHubRepo": "زيارة المستودع على جيثب" -} \ No newline at end of file +} diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index dd48a44a..cd1ea39f 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -532,9 +532,6 @@ "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": "Не ўдалося пачаць пакупку. Калі ласка, паспрабуйце яшчэ раз.", @@ -642,7 +639,6 @@ "tabs.profile.community": "Супольнасць", "tabs.profile.guide": "Кіраўніцтва карыстальніка", "tabs.profile.import": "Імпарт", - "tabs.profile.joinDiscord": "Далучайцеся да Discord Flow", "tabs.profile.other": "Іншае", "tabs.profile.preferences": "Налады", "tabs.profile.recommend": "Рэкамендаваць Flow", @@ -911,4 +907,4 @@ "transactions.query.noResult": "Няма транзакцый для паказу", "transactions.query.noResult.description": "Паспрабуйце абнавіць фільтры", "visitGitHubRepo": "Наведаць рэпазіторый на GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 8a509a9e..5e040dde 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -532,9 +532,6 @@ "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.", @@ -641,7 +638,6 @@ "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", @@ -900,4 +896,4 @@ "transactions.query.noResult": "Nebyly nalezeny žádné transakce.", "transactions.query.noResult.description": "Zkuste upravit filtry.", "visitGitHubRepo": "Navštivte repozitář na GitHubu" -} \ No newline at end of file +} diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 4ee457b8..cefd2017 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -532,9 +532,6 @@ "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.", @@ -640,7 +637,6 @@ "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", @@ -889,4 +885,4 @@ "transactions.query.noResult": "Keine Buchungen zum Anzeigen.", "transactions.query.noResult.description": "Versuche, die Filter anzupassen.", "visitGitHubRepo": "Repo auf GitHub besuchen" -} \ No newline at end of file +} diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 58961afc..e2535664 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -532,9 +532,6 @@ "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.", @@ -640,7 +637,6 @@ "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", @@ -884,4 +880,4 @@ "transactions.query.noResult": "No transactions to show", "transactions.query.noResult.description": "Try updating the filters", "visitGitHubRepo": "Visit repo on GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 4d3a7fbd..c655f99a 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -532,9 +532,6 @@ "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.", @@ -640,7 +637,6 @@ "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", @@ -890,4 +886,4 @@ "transactions.query.noResult": "No hay transacciones para mostrar", "transactions.query.noResult.description": "Intenta actualizar los filtros", "visitGitHubRepo": "Visitar repositorio en GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 4f5d969a..078837c8 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -532,9 +532,6 @@ "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": "نمی‌توان خرید را شروع کرد. لطفاً دوباره تلاش کنید.", @@ -640,7 +637,6 @@ "tabs.profile.community": "جامعه", "tabs.profile.guide": "راهنمای استفاده", "tabs.profile.import": "ایمپورت", - "tabs.profile.joinDiscord": "عضویت در Discord Flow", "tabs.profile.other": "سایر", "tabs.profile.preferences": "تنظیمات", "tabs.profile.recommend": "پیشنهاد Flow", @@ -884,4 +880,4 @@ "transactions.query.noResult": "هیچ تراکنشی برای نمایش نیست", "transactions.query.noResult.description": "فیلترها را به‌روزرسانی کنید", "visitGitHubRepo": "مشاهده ریپو در GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index b619fd17..4b4e0193 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -532,9 +532,6 @@ "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.", @@ -640,7 +637,6 @@ "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", @@ -890,4 +886,4 @@ "transactions.query.noResult": "Aucune transaction à afficher", "transactions.query.noResult.description": "Essayez de mettre à jour les filtres", "visitGitHubRepo": "Visitez le dépôt sur GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 6a9834e2..f856f34d 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -532,9 +532,6 @@ "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.", @@ -640,7 +637,6 @@ "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", @@ -890,4 +886,4 @@ "transactions.query.noResult": "Nessuna transazione da mostrare", "transactions.query.noResult.description": "Prova ad aggiornare i filtri", "visitGitHubRepo": "Visita la repo su GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 9d7049ca..925c2f58 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -532,9 +532,6 @@ "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": "Худалдан авалтыг эхлүүлэхэд алдаа гарлаа. Дахин оролдоно уу.", @@ -640,7 +637,6 @@ "tabs.profile.community": "Холбоо", "tabs.profile.guide": "Ашиглах заавар", "tabs.profile.import": "Сэргээх", - "tabs.profile.joinDiscord": "Discord-д нэгдэх", "tabs.profile.other": "Бусад", "tabs.profile.preferences": "Тохиргоо", "tabs.profile.recommend": "Flow-г санал болгох", @@ -884,4 +880,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 0f376f4a..df35cf9b 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -532,9 +532,6 @@ "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.", @@ -642,7 +639,6 @@ "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", @@ -905,4 +901,4 @@ "transactions.query.noResult": "Brak transakcji do wyświetlenia", "transactions.query.noResult.description": "Spróbuj zaktualizować filtry", "visitGitHubRepo": "Odwiedź repozytorium GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 8a48fbad..a00a3389 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -532,9 +532,6 @@ "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": "Не удалось начать покупку. Пожалуйста, попробуйте еще раз.", @@ -642,7 +639,6 @@ "tabs.profile.community": "Сообщество", "tabs.profile.guide": "Руководство по использованию", "tabs.profile.import": "Импорт", - "tabs.profile.joinDiscord": "Присоединиться к Flow Discord", "tabs.profile.other": "Другое", "tabs.profile.preferences": "Настройки", "tabs.profile.recommend": "Рекомендовать Flow", @@ -911,4 +907,4 @@ "transactions.query.noResult": "Нет транзакций для отображения", "transactions.query.noResult.description": "Попробуйте обновить фильтры", "visitGitHubRepo": "Посетить репозиторий на GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index a5114e7b..925a466c 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -532,9 +532,6 @@ "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.", @@ -640,7 +637,6 @@ "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", @@ -884,4 +880,4 @@ "transactions.query.noResult": "Gösterilecek işlem yok", "transactions.query.noResult.description": "Filtreleri güncellemeyi deneyin", "visitGitHubRepo": "GitHub'da depoyu ziyaret edin" -} \ No newline at end of file +} diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index f4a19f4b..16140a9f 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -532,9 +532,6 @@ "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": "Не вдалося розпочати покупку. Будь ласка, спробуйте ще раз.", @@ -642,7 +639,6 @@ "tabs.profile.community": "Спільнота", "tabs.profile.guide": "Посібник з користування", "tabs.profile.import": "Імпорт", - "tabs.profile.joinDiscord": "Приєднатися до Flow Discord", "tabs.profile.other": "Інше", "tabs.profile.preferences": "Налаштування", "tabs.profile.recommend": "Рекомендувати Flow", @@ -911,4 +907,4 @@ "transactions.query.noResult": "Немає транзакцій для відображення", "transactions.query.noResult.description": "Спробуйте оновити фільтри", "visitGitHubRepo": "Відвідати репозиторій на GitHub" -} \ No newline at end of file +} diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 5c6fdd9d..f67886b8 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -532,9 +532,6 @@ "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": "無法開始購買。請再試一次。", @@ -640,7 +637,6 @@ "tabs.profile.community": "社群", "tabs.profile.guide": "使用指南", "tabs.profile.import": "匯入", - "tabs.profile.joinDiscord": "加入 Flow Discord", "tabs.profile.other": "其他", "tabs.profile.preferences": "偏好設定", "tabs.profile.recommend": "推薦 Flow", @@ -884,4 +880,4 @@ "transactions.query.noResult": "沒有可顯示的交易", "transactions.query.noResult.description": "嘗試更新您的過濾條件", "visitGitHubRepo": "前往 GitHub 查看" -} \ No newline at end of file +} diff --git a/lib/constants.dart b/lib/constants.dart index 65e77008..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"); diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index 6cceade7..21eea3d5 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -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/support_page.dart b/lib/routes/support_page.dart index 93c55c82..e28923df 100644 --- a/lib/routes/support_page.dart +++ b/lib/routes/support_page.dart @@ -90,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), diff --git a/pubspec.lock b/pubspec.lock index 7a09d3f2..ff158389 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1202,10 +1202,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1226,10 +1226,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.18.3" mgrs_dart: dependency: transitive description: @@ -1927,26 +1927,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -2063,10 +2063,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.0" vm_service: dependency: transitive description: From ed5c40c13d009a729e39222e7c7a89903204e9e8 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 10 Jul 2026 14:17:28 +0800 Subject: [PATCH 17/19] budgets: renewal, v2 backup import/export, and l10n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP budgets feature — budget entity/service with renewal logic, budgets/budget pages and widgets, v2 backup round-trip support, and localized strings across all locales. Claude-Session: https://claude.ai/code/session_01UrVcjBfGtX7DGMR3bAKeU9 --- assets/l10n/ar.json | 14 + assets/l10n/be_BY.json | 14 + assets/l10n/cs_CZ.json | 14 + assets/l10n/de_DE.json | 14 + assets/l10n/en.json | 14 + assets/l10n/es_ES.json | 14 + assets/l10n/fa_IR.json | 14 + assets/l10n/fr_FR.json | 14 + assets/l10n/it_IT.json | 14 + assets/l10n/mn_MN.json | 14 + assets/l10n/pl_PL.json | 14 + assets/l10n/ru_RU.json | 14 + assets/l10n/tr_TR.json | 14 + assets/l10n/uk_UA.json | 14 + assets/l10n/zh_TW.json | 14 + lib/entity/budget.dart | 6 +- lib/main.dart | 8 + lib/routes.dart | 16 + lib/routes/budget_page.dart | 375 ++++++++++++++++++ lib/routes/budgets_page.dart | 71 ++++ lib/routes/home/profile_tab.dart | 10 +- lib/services/budget.dart | 89 ++++- lib/sync/export/export_v2.dart | 5 + lib/sync/import/import_v2.dart | 49 +++ lib/sync/model/model_v2.dart | 5 + lib/sync/model/model_v2.g.dart | 4 + lib/widgets/budgets/budget_card.dart | 76 ++++ .../import_wizard/v2/backup_info_v2.dart | 12 + macos/Podfile.lock | 14 +- pubspec.lock | 24 +- test/backup/v2_roundtrip_test.dart | 54 +++ test/unit/budget_renewal_test.dart | 125 ++++++ 32 files changed, 1122 insertions(+), 31 deletions(-) create mode 100644 lib/routes/budget_page.dart create mode 100644 lib/routes/budgets_page.dart create mode 100644 lib/widgets/budgets/budget_card.dart create mode 100644 test/unit/budget_renewal_test.dart diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index ca63bc23..d34d57b9 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -34,6 +34,19 @@ "accounts": "الحسابات", "appName": "Flow", "appShortDesc": "متتبع التمويل الشخصي الخاص بك", + "budget.amount": "مبلغ الميزانية", + "budget.amount.required": "يجب أن يكون مبلغ الميزانية أكبر من صفر", + "budget.categories": "الفئات", + "budget.categories.all": "تُحتسب جميع النفقات", + "budget.categories.description": "تُحتسب في هذه الميزانية نفقات الفئات المحددة فقط. لا تحدد أي فئة لاحتساب جميع النفقات.", + "budget.delete": "حذف الميزانية", + "budget.delete.description": "لن تتأثر المعاملات. هذا الإجراء لا رجعة فيه!", + "budget.name": "اسم الميزانية", + "budget.period": "الفترة", + "budget.renewAutomatically": "تجديد تلقائي", + "budget.renewAutomatically.description": "عند انتهاء الفترة، تنتقل الميزانية إلى الفترة الجديدة. على سبيل المثال، تصبح ميزانية يوليو ميزانية أغسطس.", + "budgets": "الميزانيات", + "budgets.new": "ميزانية جديدة", "categories": "الفئات", "categories.addFromPresets": "إضافة من الإعدادات المسبقة", "categories.noCategories": "ليس لديك أي فئات", @@ -113,6 +126,7 @@ "enum.ImportV2Progress@success": "نجاح", "enum.ImportV2Progress@waitingConfirmation": "في انتظار التأكيد", "enum.ImportV2Progress@writingAccounts": "يجري كتابة الحسابات", + "enum.ImportV2Progress@writingBudgets": "يجري كتابة الميزانيات", "enum.ImportV2Progress@writingCategories": "يجري كتابة الفئات", "enum.ImportV2Progress@writingFileAttachments": "جاري كتابة المرفقات", "enum.ImportV2Progress@writingProfile": "يجري كتابة بيانات الملف الشخصي", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index cd1ea39f..830c6b7e 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -34,6 +34,19 @@ "accounts": "Рахункі", "appName": "Flow", "appShortDesc": "Ваш асабісты фінансавы трэкер", + "budget.amount": "Сума бюджэту", + "budget.amount.required": "Сума бюджэту павінна быць больш за нуль", + "budget.categories": "Катэгорыі", + "budget.categories.all": "Улічваюцца ўсе выдаткі", + "budget.categories.description": "У гэты бюджэт уваходзяць толькі выдаткі выбраных катэгорый. Не выбірайце нічога, каб улічваць усе выдаткі.", + "budget.delete": "Выдаліць бюджэт", + "budget.delete.description": "Транзакцыі не будуць закрануты. Гэта дзеянне незваротнае!", + "budget.name": "Назва бюджэту", + "budget.period": "Перыяд", + "budget.renewAutomatically": "Аднаўляць аўтаматычна", + "budget.renewAutomatically.description": "Калі перыяд заканчваецца, бюджэт пераходзіць на новы перыяд. Напрыклад, бюджэт ліпеня становіцца бюджэтам жніўня.", + "budgets": "Бюджэты", + "budgets.new": "Новы бюджэт", "categories": "Катэгорыі", "categories.addFromPresets": "Дадаць з шаблонаў", "categories.noCategories": "У вас няма катэгорый", @@ -113,6 +126,7 @@ "enum.ImportV2Progress@success": "Паспяхова", "enum.ImportV2Progress@waitingConfirmation": "Чаканне пацверджання", "enum.ImportV2Progress@writingAccounts": "Запіс рахункаў", + "enum.ImportV2Progress@writingBudgets": "Запіс бюджэтаў", "enum.ImportV2Progress@writingCategories": "Запіс катэгорый", "enum.ImportV2Progress@writingFileAttachments": "Запіс прымацаваных файлаў", "enum.ImportV2Progress@writingProfile": "Запіс даных профілю", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 5e040dde..7b483f17 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -34,6 +34,19 @@ "accounts": "Účty", "appName": "Flow", "appShortDesc": "Váš osobní správce financí", + "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.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.name": "Název rozpočtu", + "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.", + "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 +126,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", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index cefd2017..05f79621 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -34,6 +34,19 @@ "accounts": "Konten", "appName": "Flow", "appShortDesc": "Dein persönlicher Finanz-Tracker", + "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.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.name": "Budgetname", + "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.", + "budgets": "Budgets", + "budgets.new": "Neues Budget", "categories": "Kategorien", "categories.addFromPresets": "Aus Vorlagen hinzufügen", "categories.noCategories": "Du hast keine Kategorien.", @@ -113,6 +126,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", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e2535664..1e864888 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -34,6 +34,19 @@ "accounts": "Accounts", "appName": "Flow", "appShortDesc": "Your personal finance tracker", + "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.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.name": "Budget name", + "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.", + "budgets": "Budgets", + "budgets.new": "New budget", "categories": "Categories", "categories.addFromPresets": "Add from presets", "categories.noCategories": "You don't have any categories", @@ -113,6 +126,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", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index c655f99a..c3be60ef 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -34,6 +34,19 @@ "accounts": "Cuentas", "appName": "Flow", "appShortDesc": "Tu rastreador de finanzas personales", + "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.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.name": "Nombre del presupuesto", + "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.", + "budgets": "Presupuestos", + "budgets.new": "Nuevo presupuesto", "categories": "Categorías", "categories.addFromPresets": "Añadir desde preestablecidos", "categories.noCategories": "No tienes ninguna categoría", @@ -113,6 +126,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", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 078837c8..f7f206f1 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -34,6 +34,19 @@ "accounts": "حساب‌ها", "appName": "Flow", "appShortDesc": "مدیریت مالی شخصی شما", + "budget.amount": "مبلغ بودجه", + "budget.amount.required": "مبلغ بودجه باید بیشتر از صفر باشد", + "budget.categories": "دسته‌بندی‌ها", + "budget.categories.all": "همه هزینه‌ها حساب می‌شوند", + "budget.categories.description": "فقط هزینه‌های دسته‌بندی‌های انتخاب‌شده در این بودجه حساب می‌شوند. برای احتساب همه هزینه‌ها هیچ‌کدام را انتخاب نکنید.", + "budget.delete": "حذف بودجه", + "budget.delete.description": "تراکنش‌ها تغییری نمی‌کنند. این عمل غیرقابل بازگشت است!", + "budget.name": "نام بودجه", + "budget.period": "دوره", + "budget.renewAutomatically": "تمدید خودکار", + "budget.renewAutomatically.description": "با پایان دوره، بودجه به دوره جدید منتقل می‌شود. برای مثال، بودجه ژوئیه به بودجه اوت تبدیل می‌شود.", + "budgets": "بودجه‌ها", + "budgets.new": "بودجه جدید", "categories": "دسته‌بندی‌ها", "categories.addFromPresets": "افزودن از پیش‌فرض‌ها", "categories.noCategories": "هیچ دسته‌بندی‌ای ندارید", @@ -113,6 +126,7 @@ "enum.ImportV2Progress@success": "موفق", "enum.ImportV2Progress@waitingConfirmation": "در انتظار تأیید", "enum.ImportV2Progress@writingAccounts": "در حال نوشتن حساب‌ها", + "enum.ImportV2Progress@writingBudgets": "در حال نوشتن بودجه‌ها", "enum.ImportV2Progress@writingCategories": "در حال نوشتن دسته‌بندی‌ها", "enum.ImportV2Progress@writingFileAttachments": "در حال نوشتن پیوست‌های فایل", "enum.ImportV2Progress@writingProfile": "در حال نوشتن اطلاعات پروفایل", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 4b4e0193..dd2ac8fa 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -34,6 +34,19 @@ "accounts": "Comptes", "appName": "Flow", "appShortDesc": "Votre suivi financier personnel", + "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.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.name": "Nom 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.", + "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 +126,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", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index f856f34d..13261cd2 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -34,6 +34,19 @@ "accounts": "Conti", "appName": "Flow", "appShortDesc": "Il vostro tracker di finanza personale", + "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.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.name": "Nome del 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.", + "budgets": "Budget", + "budgets.new": "Nuovo budget", "categories": "Categorie", "categories.addFromPresets": "Aggiungi dai preset", "categories.noCategories": "Non hai nessuna categoria", @@ -113,6 +126,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", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 925c2f58..98aaca93 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -34,6 +34,19 @@ "accounts": "Данснууд", "appName": "Flow", "appShortDesc": "Таны хувийн санхүүч", + "budget.amount": "Төсвийн дүн", + "budget.amount.required": "Төсвийн дүн тэгээс их байх ёстой", + "budget.categories": "Ангиллууд", + "budget.categories.all": "Бүх зарлага тооцогдоно", + "budget.categories.description": "Зөвхөн сонгосон ангиллын зарлага энэ төсөвт тооцогдоно. Юу ч сонгоогүй бол бүх зарлага тооцогдоно.", + "budget.delete": "Төсвийг устгах", + "budget.delete.description": "Гүйлгээнүүдэд нөлөөлөхгүй. Энэ үйлдлийг буцаах боломжгүй!", + "budget.name": "Төсвийн нэр", + "budget.period": "Хугацаа", + "budget.renewAutomatically": "Автоматаар шинэчлэх", + "budget.renewAutomatically.description": "Хугацаа дуусахад төсөв дараагийн хугацаа руу шилжинэ. Жишээ нь, 7-р сарын төсөв 8-р сарын төсөв болно.", + "budgets": "Төсвүүд", + "budgets.new": "Шинэ төсөв", "categories": "Ангиллууд", "categories.addFromPresets": "Загваруудаас нэмэх", "categories.noCategories": "Танд үүсгэсэн ангилал алга байна", @@ -113,6 +126,7 @@ "enum.ImportV2Progress@success": "Амжилттай", "enum.ImportV2Progress@waitingConfirmation": "Баталгаажуулалт хүлээж байна", "enum.ImportV2Progress@writingAccounts": "Данснуудыг бичиж байна", + "enum.ImportV2Progress@writingBudgets": "Төсвүүдийг бичиж байна", "enum.ImportV2Progress@writingCategories": "Ангиллуудыг бичиж байна", "enum.ImportV2Progress@writingFileAttachments": "Файлын хавсралтуудыг бичиж байна", "enum.ImportV2Progress@writingProfile": "Бүртгэлийг бичиж байна", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index df35cf9b..21ffaaf4 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -34,6 +34,19 @@ "accounts": "Konta", "appName": "Flow", "appShortDesc": "Twój osobisty asystent finansowy", + "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.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.name": "Nazwa budżetu", + "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.", + "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 +126,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", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index a00a3389..23d64a76 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -34,6 +34,19 @@ "accounts": "Счета", "appName": "Flow", "appShortDesc": "Ваш личный финансовый трекер", + "budget.amount": "Сумма бюджета", + "budget.amount.required": "Сумма бюджета должна быть больше нуля", + "budget.categories": "Категории", + "budget.categories.all": "Учитываются все расходы", + "budget.categories.description": "В этот бюджет входят только расходы выбранных категорий. Не выбирайте ничего, чтобы учитывать все расходы.", + "budget.delete": "Удалить бюджет", + "budget.delete.description": "Транзакции не будут затронуты. Это действие необратимо!", + "budget.name": "Название бюджета", + "budget.period": "Период", + "budget.renewAutomatically": "Продлевать автоматически", + "budget.renewAutomatically.description": "Когда период заканчивается, бюджет переходит на новый период. Например, бюджет июля становится бюджетом августа.", + "budgets": "Бюджеты", + "budgets.new": "Новый бюджет", "categories": "Категории", "categories.addFromPresets": "Добавить из предустановок", "categories.noCategories": "У вас нет категорий", @@ -113,6 +126,7 @@ "enum.ImportV2Progress@success": "Успех", "enum.ImportV2Progress@waitingConfirmation": "Ожидание подтверждения", "enum.ImportV2Progress@writingAccounts": "Запись счетов", + "enum.ImportV2Progress@writingBudgets": "Запись бюджетов", "enum.ImportV2Progress@writingCategories": "Запись категорий", "enum.ImportV2Progress@writingFileAttachments": "Запись файловых вложений", "enum.ImportV2Progress@writingProfile": "Запись данных профиля", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index 925a466c..80875860 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -34,6 +34,19 @@ "accounts": "Hesap", "appName": "Flow", "appShortDesc": "Kişisel finans takipçiniz", + "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.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.name": "Bütçe adı", + "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.", + "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 +126,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ı", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 16140a9f..863e5b15 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -34,6 +34,19 @@ "accounts": "Рахунки", "appName": "Flow", "appShortDesc": "Ваш особистий фінансовий трекер", + "budget.amount": "Сума бюджету", + "budget.amount.required": "Сума бюджету має бути більшою за нуль", + "budget.categories": "Категорії", + "budget.categories.all": "Враховуються всі витрати", + "budget.categories.description": "До цього бюджету входять лише витрати вибраних категорій. Не вибирайте нічого, щоб враховувати всі витрати.", + "budget.delete": "Видалити бюджет", + "budget.delete.description": "Транзакції не будуть змінені. Ця дія незворотна!", + "budget.name": "Назва бюджету", + "budget.period": "Період", + "budget.renewAutomatically": "Поновлювати автоматично", + "budget.renewAutomatically.description": "Коли період закінчується, бюджет переходить на новий період. Наприклад, бюджет липня стає бюджетом серпня.", + "budgets": "Бюджети", + "budgets.new": "Новий бюджет", "categories": "Категорії", "categories.addFromPresets": "Додати з передустановок", "categories.noCategories": "У вас немає категорій", @@ -113,6 +126,7 @@ "enum.ImportV2Progress@success": "Успіх", "enum.ImportV2Progress@waitingConfirmation": "Очікування підтвердження", "enum.ImportV2Progress@writingAccounts": "Запис рахунків", + "enum.ImportV2Progress@writingBudgets": "Запис бюджетів", "enum.ImportV2Progress@writingCategories": "Запис категорій", "enum.ImportV2Progress@writingFileAttachments": "Запис файлових вкладень", "enum.ImportV2Progress@writingProfile": "Запис даних профілю", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index f67886b8..5e518041 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -34,6 +34,19 @@ "accounts": "帳戶", "appName": "Flow", "appShortDesc": "您的個人理財追蹤器", + "budget.amount": "預算金額", + "budget.amount.required": "預算金額必須大於零", + "budget.categories": "分類", + "budget.categories.all": "計入所有支出", + "budget.categories.description": "只有所選分類的支出會計入此預算。若未選擇任何分類,則計入所有支出。", + "budget.delete": "刪除預算", + "budget.delete.description": "交易不會受到影響。此操作無法復原!", + "budget.name": "預算名稱", + "budget.period": "期間", + "budget.renewAutomatically": "自動續期", + "budget.renewAutomatically.description": "期間結束後,預算會進入新的期間。例如,七月的預算會變成八月的預算。", + "budgets": "預算", + "budgets.new": "新增預算", "categories": "分類", "categories.addFromPresets": "從預設新增", "categories.noCategories": "您目前沒有任何分類", @@ -113,6 +126,7 @@ "enum.ImportV2Progress@success": "成功", "enum.ImportV2Progress@waitingConfirmation": "等待確認中", "enum.ImportV2Progress@writingAccounts": "正在寫入帳戶", + "enum.ImportV2Progress@writingBudgets": "正在寫入預算", "enum.ImportV2Progress@writingCategories": "正在寫入分類", "enum.ImportV2Progress@writingFileAttachments": "正在寫入檔案附件", "enum.ImportV2Progress@writingProfile": "正在寫入個人資料", 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 f7567f9e..dd4c852f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,6 +32,7 @@ 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"; @@ -260,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/routes.dart b/lib/routes.dart index d35ab201..83a54b9f 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"; @@ -222,6 +224,20 @@ 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(), diff --git a/lib/routes/budget_page.dart b/lib/routes/budget_page.dart new file mode 100644 index 00000000..67165e3b --- /dev/null +++ b/lib/routes/budget_page.dart @@ -0,0 +1,375 @@ +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/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/list_header.dart"; +import "package:flow/widgets/general/money_text.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); + + if (!widget.isNewBudget && _currentlyEditing == null) { + error = "Budget with id ${widget.budgetId} was not found"; + } else { + _nameTextController = TextEditingController( + text: _currentlyEditing?.name, + ); + _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(); + + const EdgeInsets contentPadding = EdgeInsets.symmetric(horizontal: 16.0); + + final bool pageable = _timeRange is PageableRange; + + 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: MainAxisSize.min, + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + Padding( + padding: contentPadding, + child: TextFormField( + controller: _nameTextController, + validator: validateNameField, + decoration: InputDecoration( + label: Text("budget.name".t(context)), + focusColor: context.colorScheme.secondary, + ), + ), + ), + const SizedBox(height: 16.0), + ListTile( + title: Text("budget.amount".t(context)), + leading: const Icon(Symbols.money_bag_rounded), + trailing: MoneyText(Money(_amount, _currency)), + onTap: inputAmount, + ), + ListTile( + title: Text("currency".t(context)), + leading: const Icon(Symbols.universal_currency_alt_rounded), + trailing: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4.0, + children: [Text(_currency), const LeChevron()], + ), + onTap: selectCurrency, + ), + ListTile( + title: Text("budget.categories".t(context)), + leading: const Icon(Symbols.category_rounded), + subtitle: Text( + _categories.isEmpty + ? "budget.categories.all".t(context) + : _categories + .map((category) => category.name) + .join(", "), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: const LeChevron(), + onTap: selectCategories, + ), + const SizedBox(height: 8.0), + Frame( + child: InfoText( + child: Text("budget.categories.description".t(context)), + ), + ), + const SizedBox(height: 16.0), + ListHeader("budget.period".t(context)), + const SizedBox(height: 8.0), + Frame( + child: TimeRangeSelector( + initialValue: _timeRange, + onChanged: updateTimeRange, + ), + ), + CheckboxListTile( + value: pageable && _renewAutomatically, + onChanged: pageable ? updateRenewAutomatically : null, + title: Text("budget.renewAutomatically".t(context)), + ), + const SizedBox(height: 8.0), + Frame( + child: InfoText( + child: Text( + "budget.renewAutomatically.description".t(context), + ), + ), + ), + if (_currentlyEditing != null) ...[ + const SizedBox(height: 36.0), + DeleteButton( + onTap: _deleteBudget, + label: Text("budget.delete".t(context)), + ), + const SizedBox(height: 16.0), + ], + ], + ), + ), + ), + ), + ); + } + + 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 21eea3d5..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), diff --git a/lib/services/budget.dart b/lib/services/budget.dart index c88ecab0..0ef6e43d 100644 --- a/lib/services/budget.dart +++ b/lib/services/budget.dart @@ -1,9 +1,94 @@ +import "package:flow/data/exchange_rates.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); + } + + /// 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/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/widgets/budgets/budget_card.dart b/lib/widgets/budgets/budget_card.dart new file mode 100644 index 00000000..8fd01da5 --- /dev/null +++ b/lib/widgets/budgets/budget_card.dart @@ -0,0 +1,76 @@ +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/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/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: 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/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/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 ff158389..7a09d3f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1202,10 +1202,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1226,10 +1226,10 @@ packages: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1927,26 +1927,26 @@ packages: dependency: "direct dev" description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.17" timezone: dependency: "direct main" description: @@ -2063,10 +2063,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/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/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()); + }); +} From 62adc9ca05d82a096de0c4f37de65cdbf2bde69d Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 14 Jul 2026 20:20:18 +0800 Subject: [PATCH 18/19] budgets: all-budgets overview, alerts, recommendations, redesigned editor - New /stats/budgets overview showing a status roll-up (how many budgets are over or nearing their limit + the worst offender) instead of a summed money total: budgets are independent and can overlap or span different periods, so a combined spent-vs-limit total would double-count and mislead - Bento preview tile mirroring the same status glance - Rule-based, on-device budget insights/recommendations (BudgetProgress) - Budget-alert internal notifications when a budget is near or over its limit - Redesigned budget create/edit page (hero amount + grouped cards) and a category-chips selector - TimeRangeSelector gains an overridable backgroundColor so it can sit inside a colored card without painting a mismatched band - Harden computeAllProgress: isolate per-budget failures so one corrupt imported budget can't sink the whole overview/bento - l10n keys for the above Claude-Session: https://claude.ai/code/session_01UefANHDTnydMK3d4rSLQ2o --- assets/l10n/en.json | 37 ++ .../actionable_notification.dart | 16 + lib/data/budget_progress.dart | 209 ++++++++++ lib/prefs/transitive.dart | 6 + lib/routes.dart | 15 +- lib/routes/budget_page.dart | 307 +++++++++++---- lib/routes/stats/budgets_overview_page.dart | 364 ++++++++++++++++++ lib/routes/stats/insights_page.dart | 3 + lib/services/actionable_notifications.dart | 47 +++ lib/services/budget.dart | 102 +++++ lib/widgets/budgets/budget_card.dart | 15 +- .../budgets/budget_category_chips.dart | 99 +++++ .../home/stats/bento/analytics_bento.dart | 7 +- lib/widgets/home/stats/bento/budget_tile.dart | 151 ++++++++ .../budget_alert_notification.dart | 47 +++ .../internal_notification_section.dart | 5 + lib/widgets/time_range_selector.dart | 8 +- 17 files changed, 1346 insertions(+), 92 deletions(-) create mode 100644 lib/data/budget_progress.dart create mode 100644 lib/routes/stats/budgets_overview_page.dart create mode 100644 lib/widgets/budgets/budget_category_chips.dart create mode 100644 lib/widgets/home/stats/bento/budget_tile.dart create mode 100644 lib/widgets/internal_notifications/budget_alert_notification.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 1e864888..c8e28a9b 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -34,17 +34,45 @@ "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", @@ -657,6 +685,15 @@ "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/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/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 83a54b9f..21badd1b 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -51,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"; @@ -224,19 +225,15 @@ final GoRouter router = GoRouter( path: "/accounts", builder: (context, state) => const AccountsPage(), ), - GoRoute( - path: "/budgets", - builder: (context, state) => const BudgetsPage(), - ), + 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, - ), + builder: (context, state) => + BudgetPage(budgetId: int.tryParse(state.pathParameters["id"]!) ?? -1), ), GoRoute( path: "/transactionTags", @@ -540,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 index 67165e3b..ae7bb35a 100644 --- a/lib/routes/budget_page.dart +++ b/lib/routes/budget_page.dart @@ -10,13 +10,14 @@ 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/list_header.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"; @@ -62,12 +63,14 @@ class _BudgetPageState extends State { ? 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 { - _nameTextController = TextEditingController( - text: _currentlyEditing?.name, - ); _amount = _currentlyEditing?.amount ?? 0.0; _currency = _currentlyEditing?.currency ?? @@ -90,10 +93,6 @@ class _BudgetPageState extends State { Widget build(BuildContext context) { if (error != null) return const ErrorPage(); - const EdgeInsets contentPadding = EdgeInsets.symmetric(horizontal: 16.0); - - final bool pageable = _timeRange is PageableRange; - return Scaffold( appBar: AppBar( leadingWidth: 40.0, @@ -111,89 +110,31 @@ class _BudgetPageState extends State { child: Form( key: _formKey, child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: .start, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ - const SizedBox(height: 16.0), - Padding( - padding: contentPadding, - child: TextFormField( - controller: _nameTextController, - validator: validateNameField, - decoration: InputDecoration( - label: Text("budget.name".t(context)), - focusColor: context.colorScheme.secondary, - ), - ), - ), - const SizedBox(height: 16.0), - ListTile( - title: Text("budget.amount".t(context)), - leading: const Icon(Symbols.money_bag_rounded), - trailing: MoneyText(Money(_amount, _currency)), - onTap: inputAmount, - ), - ListTile( - title: Text("currency".t(context)), - leading: const Icon(Symbols.universal_currency_alt_rounded), - trailing: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4.0, - children: [Text(_currency), const LeChevron()], - ), - onTap: selectCurrency, - ), - ListTile( - title: Text("budget.categories".t(context)), - leading: const Icon(Symbols.category_rounded), - subtitle: Text( - _categories.isEmpty - ? "budget.categories.all".t(context) - : _categories - .map((category) => category.name) - .join(", "), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - trailing: const LeChevron(), - onTap: selectCategories, - ), - const SizedBox(height: 8.0), + 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: 16.0), - ListHeader("budget.period".t(context)), - const SizedBox(height: 8.0), - Frame( - child: TimeRangeSelector( - initialValue: _timeRange, - onChanged: updateTimeRange, - ), - ), - CheckboxListTile( - value: pageable && _renewAutomatically, - onChanged: pageable ? updateRenewAutomatically : null, - title: Text("budget.renewAutomatically".t(context)), - ), - const SizedBox(height: 8.0), - Frame( - child: InfoText( - child: Text( - "budget.renewAutomatically.description".t(context), - ), - ), - ), + const SizedBox(height: 10.0), + _buildPeriodCard(context), if (_currentlyEditing != null) ...[ const SizedBox(height: 36.0), - DeleteButton( - onTap: _deleteBudget, - label: Text("budget.delete".t(context)), + Center( + child: DeleteButton( + onTap: _deleteBudget, + label: Text("budget.delete".t(context)), + ), ), - const SizedBox(height: 16.0), ], + const SizedBox(height: 16.0), ], ), ), @@ -202,6 +143,210 @@ class _BudgetPageState extends State { ); } + 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, 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/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 0ef6e43d..ff08149e 100644 --- a/lib/services/budget.dart +++ b/lib/services/budget.dart @@ -1,4 +1,6 @@ +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"; @@ -56,6 +58,106 @@ class BudgetService { 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. diff --git a/lib/widgets/budgets/budget_card.dart b/lib/widgets/budgets/budget_card.dart index 8fd01da5..7b73187a 100644 --- a/lib/widgets/budgets/budget_card.dart +++ b/lib/widgets/budgets/budget_card.dart @@ -3,10 +3,12 @@ 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"; @@ -54,7 +56,18 @@ class BudgetCard extends StatelessWidget { ), subtitle: _periodLabel(), onTap: () => context.push("/budgets/${budget.id}"), - child: BulletChart(value: spent, target: budget.amount), + 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), + ], + ), ); }, ); 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/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/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: [ From 6f9320509a6b7d4f203a482fec52c3f89891ddc8 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Wed, 15 Jul 2026 15:00:27 +0800 Subject: [PATCH 19/19] l10n: translations --- assets/l10n/ar.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/be_BY.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/cs_CZ.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/de_DE.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/en.json | 2 +- assets/l10n/es_ES.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/fa_IR.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/fr_FR.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/it_IT.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/mn_MN.json | 37 +++++++++++++++++++++++++++++++++++++ assets/l10n/pl_PL.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/ru_RU.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/tr_TR.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/uk_UA.json | 39 ++++++++++++++++++++++++++++++++++++++- assets/l10n/zh_TW.json | 39 ++++++++++++++++++++++++++++++++++++++- 15 files changed, 532 insertions(+), 14 deletions(-) diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index d34d57b9..0b66d6ab 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -34,17 +34,45 @@ "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": "الفئات", @@ -661,6 +689,15 @@ "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": "المنفق في {}", @@ -902,4 +939,4 @@ "transactions.query.noResult": "لا توجد معاملات للعرض", "transactions.query.noResult.description": "حاول تحديث الفلاتر", "visitGitHubRepo": "زيارة المستودع على جيثب" -} +} \ No newline at end of file diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 830c6b7e..428684df 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -34,17 +34,45 @@ "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": "Катэгорыі", @@ -659,6 +687,15 @@ "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": "Патрачана ў {}", @@ -921,4 +958,4 @@ "transactions.query.noResult": "Няма транзакцый для паказу", "transactions.query.noResult.description": "Паспрабуйце абнавіць фільтры", "visitGitHubRepo": "Наведаць рэпазіторый на GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 7b483f17..176353e8 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -34,17 +34,45 @@ "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", @@ -658,6 +686,15 @@ "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 {}", @@ -910,4 +947,4 @@ "transactions.query.noResult": "Nebyly nalezeny žádné transakce.", "transactions.query.noResult.description": "Zkuste upravit filtry.", "visitGitHubRepo": "Navštivte repozitář na GitHubu" -} +} \ No newline at end of file diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 05f79621..02b89ee4 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -34,17 +34,45 @@ "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", @@ -657,6 +685,15 @@ "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 {}", @@ -899,4 +936,4 @@ "transactions.query.noResult": "Keine Buchungen zum Anzeigen.", "transactions.query.noResult.description": "Versuche, die Filter anzupassen.", "visitGitHubRepo": "Repo auf GitHub besuchen" -} +} \ No newline at end of file diff --git a/assets/l10n/en.json b/assets/l10n/en.json index c8e28a9b..4e744308 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -931,4 +931,4 @@ "transactions.query.noResult": "No transactions to show", "transactions.query.noResult.description": "Try updating the filters", "visitGitHubRepo": "Visit repo on GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index c3be60ef..abb39158 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -34,17 +34,45 @@ "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", @@ -657,6 +685,15 @@ "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 {}", @@ -900,4 +937,4 @@ "transactions.query.noResult": "No hay transacciones para mostrar", "transactions.query.noResult.description": "Intenta actualizar los filtros", "visitGitHubRepo": "Visitar repositorio en GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index f7f206f1..b02c1042 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -34,17 +34,45 @@ "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": "دسته‌بندی‌ها", @@ -657,6 +685,15 @@ "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": "هزینه شده در {}", @@ -894,4 +931,4 @@ "transactions.query.noResult": "هیچ تراکنشی برای نمایش نیست", "transactions.query.noResult.description": "فیلترها را به‌روزرسانی کنید", "visitGitHubRepo": "مشاهده ریپو در GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index dd2ac8fa..ae6462cd 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -34,17 +34,45 @@ "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", @@ -657,6 +685,15 @@ "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 {}", @@ -900,4 +937,4 @@ "transactions.query.noResult": "Aucune transaction à afficher", "transactions.query.noResult.description": "Essayez de mettre à jour les filtres", "visitGitHubRepo": "Visitez le dépôt sur GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 13261cd2..91085de7 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -34,17 +34,45 @@ "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", @@ -657,6 +685,15 @@ "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 {}", @@ -900,4 +937,4 @@ "transactions.query.noResult": "Nessuna transazione da mostrare", "transactions.query.noResult.description": "Prova ad aggiornare i filtri", "visitGitHubRepo": "Visita la repo su GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 98aaca93..eb137d77 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -34,17 +34,45 @@ "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": "Ангиллууд", @@ -657,6 +685,15 @@ "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/pl_PL.json b/assets/l10n/pl_PL.json index 21ffaaf4..84c7650a 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -34,17 +34,45 @@ "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", @@ -659,6 +687,15 @@ "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 {}", @@ -915,4 +952,4 @@ "transactions.query.noResult": "Brak transakcji do wyświetlenia", "transactions.query.noResult.description": "Spróbuj zaktualizować filtry", "visitGitHubRepo": "Odwiedź repozytorium GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 23d64a76..c885dce0 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -34,17 +34,45 @@ "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": "Категории", @@ -659,6 +687,15 @@ "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": "Потрачено в {}", @@ -921,4 +958,4 @@ "transactions.query.noResult": "Нет транзакций для отображения", "transactions.query.noResult.description": "Попробуйте обновить фильтры", "visitGitHubRepo": "Посетить репозиторий на GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index 80875860..8c5f71e9 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -34,17 +34,45 @@ "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", @@ -657,6 +685,15 @@ "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ı", @@ -894,4 +931,4 @@ "transactions.query.noResult": "Gösterilecek işlem yok", "transactions.query.noResult.description": "Filtreleri güncellemeyi deneyin", "visitGitHubRepo": "GitHub'da depoyu ziyaret edin" -} +} \ No newline at end of file diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 863e5b15..4462caf5 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -34,17 +34,45 @@ "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": "Категорії", @@ -659,6 +687,15 @@ "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": "Витрачено в {}", @@ -921,4 +958,4 @@ "transactions.query.noResult": "Немає транзакцій для відображення", "transactions.query.noResult.description": "Спробуйте оновити фільтри", "visitGitHubRepo": "Відвідати репозиторій на GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 5e518041..4fe94270 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -34,17 +34,45 @@ "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": "分類", @@ -657,6 +685,15 @@ "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": "在 {} 的支出", @@ -894,4 +931,4 @@ "transactions.query.noResult": "沒有可顯示的交易", "transactions.query.noResult.description": "嘗試更新您的過濾條件", "visitGitHubRepo": "前往 GitHub 查看" -} +} \ No newline at end of file