From 414b818dcf80b0d030c4197bd05a8603c4541c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=B0=D1=82=D0=BC=D1=8D=D0=BD=D0=B4=20=D0=93=D0=B0?= =?UTF-8?q?=D0=BD=D0=B1=D0=B0=D0=B0=D1=82=D0=B0=D1=80?= Date: Mon, 3 Aug 2026 16:22:44 +0800 Subject: [PATCH 01/57] Feat/budgets (#752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update CONTRIBUTING.md * feat: add Simplified Chinese. * fix: fix Simplified Chinese region issue in supported languages. * fix(l10n): Chinese measure word, zh_CN typo, en double negative - zh_CN: 中位数字消费 -> 中位数消费; the stray 字 read as "median digit" - zh_CN/zh_TW: add missing measure word to upcomingCharges ({count} 笔/筆预定扣款), matching every other count string - en: deleteCloudBackupConfirmation said "This action is not irreversible!" — a double negative telling users a destructive backup deletion could be undone Claude-Session: https://claude.ai/code/session_01JUAzk892137fKesAsZbGnN * fix: correct 'occured' to 'occurred' typo in Dart and l10n files (#751) * deps: bump pie_menu to 3.8.3 Routine bump, along with the transitive test/meta/matcher/vector_math versions pub resolved with it. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ * budgets: count pending spend, and draw it as a ghost tail Pending transactions were excluded from budget spend outright. That makes a budget read healthy right up until a scheduled payment clears, at which point it lurches. Rent dated the 28th is money the period is already committed to; leaving it out means the number answers "what has cleared" while the user is asking "how much room is left". So `spent` now includes pending, and everything derived from it -- ratio, status, insights, alerts -- is measured on the committed total. The pending slice stays recoverable as `pendingSpent`, because counting it is not the same as pretending it has already gone: every bar fills solid to `confirmedRatio` and continues to `ratio` as a lighter ghost. `projectedRatio` deliberately does not extrapolate the pending part. A scheduled lump sum is already dated, so running it through the run rate would count it once for being committed and again for every day left in the period -- rent on the 28th would project a 4.5x overshoot on the 2nd with nothing actually spent. Confirmed spend is extrapolated as before, the pending remainder is added at face value. BulletChart is rewritten alongside it. The track had 10% headroom past whichever of value/target was larger, so a bar sat at ~76% of its width while the label beside it read 84%, and the same budget drew differently depending on how far over it was. It now spans exactly 0..target: an overrun fills the track and recolours instead of rescaling it. The old qualitative band went with it -- it marked the target, which is now the end of the track -- and the target tick became a pace tick, neutral rather than `primary`, since a green accent colour made it the same hue as a healthy bar. payloadVersion goes to 3: `spent`/`ratio` mean something new, and a v2 extension would draw the larger figure as though all of it had cleared. Both extensions default `confirmedRatio` to `ratio` when decoding, so the degraded case is one solid bar rather than an all-ghost one. Glance has no alpha modifier, so the Android ghost is three pre-multiplied colour resources rather than a modifier on the status colour. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ * budgets: open what the widget shows when it's tapped The Android budget widgets built a bare `Intent(context, MainActivity)`. That carries no action and no data, so every tap cold-opened the home tab -- a widget showing "Groceries, 112%" dropped you somewhere with no budget on it, and the roll-up sent you to the same place as the pinned one. The quick-entry widgets already deep-link through the `flow-mn` scheme the manifest accepts, so this just uses the same road. The intent is pinned to our own package. `flow-mn` is a custom scheme, which any installed app may register for, so without setPackage a widget tap could raise a chooser or open a Flow-lookalike. On the app side, the URI handler is an allowlist of three paths, not a catch-all forward. Anything on the device can claim a custom scheme, so forwarding an arbitrary path from one into the router would be an open redirect into any route in the app -- including the destructive ones. Unmatched URIs are logged and dropped. `/budgets/:id` still routes through the existing redirect, so an id that no longer resolves lands on the list instead of a dead page. The iOS roll-up moves from `/budgets` to `/stats/budgets` for the same reason the Android one points there: that widget is the overview in miniature, so a tap should expand what it shows rather than drop you on the adjacent list. The Android chrome changes with it. The widgets drew a rounded card inside an 8dp inset inside the launcher's own rounded container, which reads as a border around a screenshot rather than as the widget itself. One `appWidgetBackground()` surface now *is* the widget, at the system radius, so the corners match every other widget on the home screen instead of a hard-coded 16dp that only agrees with some launchers. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ * budgets: make "new budget" a button, not a list row It sat as a ListTile above the budget cards, so the one action on the page looked like the first item in the list it creates into. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ * l10n: fill in the budget strings for zh_CN zh_CN.json was contributed against develop, which has no budgets, so merging this branch left it 66 keys short of en.json and the integrity test red. Machine-translated, like every other locale here, and worth a native pass before release. Inserted in en.json's key order rather than appended: json_integrity_test compares positionally, so scripts/translate_missing alone would not have satisfied it. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ * 0.24.0+355 Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ * update lock --------- Co-authored-by: Tynox Co-authored-by: RoomWithOutRoof <166608075+Jah-yee@users.noreply.github.com> --- CHANGELOG.md | 6 +- CONTRIBUTING.md | 35 +- .../mn/flow/flow/glance/BudgetPayload.kt | 17 +- .../mn/flow/flow/glance/BudgetPinned.kt | 39 +- .../mn/flow/flow/glance/BudgetRollup.kt | 17 +- .../mn/flow/flow/glance/BudgetWidgetUi.kt | 125 ++- .../app/src/main/res/values/widget_colors.xml | 10 + assets/l10n/en.json | 6 +- assets/l10n/zh_CN.json | 949 ++++++++++++++++++ assets/l10n/zh_TW.json | 4 +- ios/Flow Widgets/BudgetPayload.swift | 19 +- ios/Flow Widgets/BudgetPinnedWidget.swift | 17 +- ios/Flow Widgets/BudgetRollupWidget.swift | 15 +- ios/Flow Widgets/BudgetWidgetStyle.swift | 53 +- lib/data/budget_progress.dart | 36 +- lib/data/budget_spec.dart | 7 +- lib/entity/transaction/wrapper.dart | 2 +- lib/l10n/supported_languages.dart | 1 + lib/main.dart | 2 +- lib/objectbox/actions.dart | 2 + lib/routes/budget_detail_page.dart | 11 +- lib/routes/budgets_page.dart | 15 +- .../button_order_preferences_page.dart | 2 +- lib/services/budget.dart | 36 +- lib/services/budget_widget_sync.dart | 11 +- lib/services/navigation.dart | 29 +- lib/widgets/analytics/bullet_chart.dart | 132 ++- lib/widgets/budgets/budget_card.dart | 24 +- lib/widgets/home/stats/bento/budget_tile.dart | 2 + pubspec.lock | 4 +- pubspec.yaml | 4 +- scripts/translate_missing.dart | 1 + test/unit/budget_isolate_test.dart | 31 +- test/unit/budget_progress_test.dart | 77 ++ test/unit/budget_widget_payload_test.dart | 30 +- test/widget/bullet_chart_test.dart | 161 +++ 36 files changed, 1762 insertions(+), 170 deletions(-) create mode 100644 assets/l10n/zh_CN.json create mode 100644 test/widget/bullet_chart_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a8b138..8db23abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,12 @@ pacing, its recent periods, and every transaction counting towards it. * Budgets overview surfaces which budgets are over or nearing their limit, and what to do about it. +* Planned and pending transactions count towards a budget — money you've + scheduled is money the period is committed to. Progress bars draw that part + in a lighter shade, so you can still see what has actually cleared. * Home screen widgets for budgets on iOS and Android, including a variant that - shows progress without revealing any amounts. + shows progress without revealing any amounts. Tapping one opens the budget + it's showing. * Budgets are included in backups, and restore from any v2 backup. * An in-app alert when a budget goes over, or gets close. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd84afd7..f2a9546f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,11 @@ # Contributing to flow -Thank you for stopping by here! There are many ways to make Flow better for -everyone. Here are few: +Thank you for stopping by! A few notes on how contributing works here: + +Flow is free, and will stay that way — I'm continuing to develop it on my own. +I'm not taking feature requests or bug report/opinion submissions right now, so +there's no issue tracker to file into. -* [Report a bug](https://github.com/flow-mn/flow/issues/new/choose) -* [Propose a feature](https://github.com/flow-mn/flow/issues/new?assignees=&labels=&projects=&template=feature_request.yaml&title=%5BFEAT%5D+) * [Contribute code](#developing) * [Translating Flow](#translating) to your own language * [Buy maintainer a coffee](https://buymeacoffee.com/sadespresso). Flow is a @@ -12,23 +13,19 @@ free and open-source software, and will stay this way. ## Developing -NOTE: A quick discussion upfront can highlight any potential issues, streamline -the merge process, and ensure you're on the right track to avoid rework. - -TIP: Look for issues with `ready` label to get started without any friction +You're welcome to submit PRs, but I highly recommend reaching out first — +Instagram ([@sadespresso](https://instagram.com/sadespresso)) or email +() — so we can coordinate before you put in the work. 1. Fork the repository -2. Pick an issue. If the fix/feature you're gonna work doesn't have an issue, - please create one first. -3. Let everyone know that you're working on it by commenting "I'm working on it" -4. Create a feature branch. For example, if you're working on - [#82](https://github.com/flow-mn/flow/issues/82), create a branch - `username/fix82` (based on `develop`). The name can be different, doesn't matter. -5. Make changes on the new branch -6. Ensure your code doesn't have any linter warnings, errors +2. Reach out first (see above) so we're aligned on the change +3. Create a feature branch off `develop`, e.g. `username/short-description` + (the name can be different, doesn't matter) +4. Make changes on the new branch +5. Ensure your code doesn't have any linter warnings, errors (Your editor will tell you, or you can run `flutter analyze`) -7. Submit a PR to `develop` branch -8. If your feature involves UI changes, add a short video demonstrating the +6. Submit a PR to `develop` branch +7. If your feature involves UI changes, add a short video demonstrating the implement change/feature ## Code guides @@ -45,7 +42,7 @@ version name `next`) When translating Flow to your language, the translation coverage must be 100%. You can follow the same steps in [Developing](#developing), and you can safely -skip lints and tests (step 6 and 7). +skip the lint check (step 5). It's highly recommended to copy [en_US.json](./assets/l10n/en.json) or any other existing translations with full coverage, and work on top of it. diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt index 541d1c77..d178b17e 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt @@ -53,8 +53,16 @@ data class BudgetEntry( * otherwise mix digit styles between this and every other string here. */ val percentLabel: String, - /** Not clamped — an over-budget entry exceeds 1.0. Clamp at the call site. */ + /** + * Not clamped — an over-budget entry exceeds 1.0. Clamp at the call site. + * Includes pending spend. + */ val ratio: Double, + /** + * The part of [ratio] that has actually cleared. Bars fill solid to here and + * carry on as a lighter ghost tail out to [ratio]. + */ + val confirmedRatio: Double, /** Colour and branching only — [statusLabel] is what the user reads. */ val status: BudgetStatus, /** @@ -117,7 +125,7 @@ data class BudgetPayload( companion object { const val PAYLOAD_KEY = "budgetsPayload" - const val SUPPORTED_VERSION = 2 + const val SUPPORTED_VERSION = 3 /** * Returns null for every unusable input — key absent, blank, malformed, or @@ -182,6 +190,11 @@ data class BudgetPayload( // blank-hero-number guard rather than a supported code path. percentLabel = entry.optStringOrNull("percentLabel") ?: "$percent%", ratio = entry.optDouble("ratio", 0.0).let { if (it.isNaN()) 0.0 else it }, + // Defaulting to `ratio` means "all of it cleared", so a payload + // missing the key draws one solid bar rather than an all-ghost one. + confirmedRatio = entry.optDouble("confirmedRatio", Double.NaN) + .let { if (it.isNaN()) entry.optDouble("ratio", 0.0) else it } + .let { if (it.isNaN()) 0.0 else it }, status = BudgetStatus.parse(entry.optStringOrNull("status")), statusLabel = entry.optStringOrNull("statusLabel"), daysLeft = entry.optInt("daysLeft", 0), diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt index 3d1a607a..da111d13 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt @@ -67,7 +67,24 @@ private fun Content( state.preferences.getString(BudgetPayload.PAYLOAD_KEY, null) ) - BudgetWidgetUi.Frame(context, padding = 12.dp) { + // A null budgetUuid means "auto"; a non-null one that no longer resolves + // means the user deleted the budget this widget was pinned to. Resolved out + // here rather than inside the frame because the tap destination depends on it. + val entry = if (payload == null) { + null + } else if (config.budgetUuid == null) { + payload.worst + } else { + payload.budgetByUuid(config.budgetUuid) + } + + // Linking by id even though the pin is stored by uuid: this id comes off the + // payload being rendered right now, so it is current by construction, and the + // app routes budgets by id. With no budget resolved there is nothing specific + // to open, so fall back to the list. + val destination = if (entry == null) "/budgets" else "/budgets/${entry.id}" + + BudgetWidgetUi.Frame(padding = 16.dp, destination = destination) { if (payload == null || payload.budgets.isEmpty()) { BudgetWidgetUi.EmptyState( context = context, @@ -77,14 +94,6 @@ private fun Content( return@Frame } - // A null budgetUuid means "auto"; a non-null one that no longer resolves - // means the user deleted the budget this widget was pinned to. - val entry = if (config.budgetUuid == null) { - payload.worst - } else { - payload.budgetByUuid(config.budgetUuid) - } - if (entry == null) { BudgetWidgetUi.EmptyState( context = context, @@ -98,7 +107,9 @@ private fun Content( return@Frame } - val barWidth = LocalSize.current.width - 8.dp * 2 - 12.dp * 2 - 4.dp + // The frame's single 16dp inset, plus 4dp of slack so the bar stays off + // the rounded corner on launchers that round more aggressively. + val barWidth = LocalSize.current.width - 16.dp * 2 - 4.dp Column(modifier = GlanceModifier.fillMaxSize()) { Text( @@ -138,7 +149,13 @@ private fun Content( ) Spacer(modifier = GlanceModifier.height(6.dp)) - BudgetWidgetUi.BudgetBar(barWidth, entry.ratio, entry.status, height = 8.dp) + BudgetWidgetUi.BudgetBar( + barWidth, + entry.ratio, + entry.status, + height = 8.dp, + confirmedRatio = entry.confirmedRatio, + ) Spacer(modifier = GlanceModifier.height(6.dp)) Text( diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetRollup.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetRollup.kt index 05087f5d..9992e89c 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetRollup.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetRollup.kt @@ -71,7 +71,9 @@ private fun Content( ) val worst = payload?.worst - BudgetWidgetUi.Frame(context, padding = 14.dp) { + // The overview, not the plain list: this widget *is* that page in + // miniature, so a tap should expand what it shows. + BudgetWidgetUi.Frame(padding = 16.dp, destination = "/stats/budgets") { if (payload == null || payload.budgets.isEmpty()) { BudgetWidgetUi.EmptyState( context = context, @@ -81,9 +83,9 @@ private fun Content( return@Frame } - // Frame padding (14dp) and the widget's own 8dp inset both eat into the - // bar; 4dp of slack keeps it off the rounded corner on tight launchers. - val barWidth = LocalSize.current.width - 8.dp * 2 - 14.dp * 2 - 4.dp + // The frame's single 16dp inset, plus 4dp of slack so the bar stays off + // the rounded corner on launchers that round more aggressively. + val barWidth = LocalSize.current.width - 16.dp * 2 - 4.dp val overCount = payload.summary.overCount val warningCount = payload.summary.warningCount @@ -193,7 +195,12 @@ private fun WorstBudget( } Spacer(modifier = GlanceModifier.height(5.dp)) - BudgetWidgetUi.BudgetBar(barWidth, entry.ratio, entry.status) + BudgetWidgetUi.BudgetBar( + barWidth, + entry.ratio, + entry.status, + confirmedRatio = entry.confirmedRatio, + ) Spacer(modifier = GlanceModifier.height(5.dp)) Row( diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetUi.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetUi.kt index 9efacedb..18d0eca7 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetUi.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetUi.kt @@ -6,10 +6,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme +import androidx.glance.LocalContext import androidx.glance.action.clickable import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.appWidgetBackground import androidx.glance.appwidget.cornerRadius import androidx.glance.background import androidx.glance.layout.Alignment @@ -26,7 +29,6 @@ import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider -import mn.flow.flow.MainActivity import mn.flow.flow.R /** @@ -49,39 +51,75 @@ object BudgetWidgetUi { BudgetStatus.HEALTHY -> ColorProvider(R.color.income_green) } - /** Every budget widget opens the app; matches the Summary widget. */ - fun launchAppIntent(context: Context): Intent = - Intent(context, MainActivity::class.java).apply { + /** + * [statusColor] at ~35%, for the pending part of a bar. Pre-multiplied + * resources rather than an alpha modifier, which Glance doesn't have. + */ + fun ghostColor(status: BudgetStatus): ColorProvider = when (status) { + BudgetStatus.OVER -> ColorProvider(R.color.budget_ghost_expense) + BudgetStatus.WARNING -> ColorProvider(R.color.budget_ghost_warning) + BudgetStatus.HEALTHY -> ColorProvider(R.color.budget_ghost_income) + } + + /** + * A deep link into the app, the way [FlowWidgetUtils.EntryButton] does it. + * + * A bare `Intent(context, MainActivity::class)` carries no action and no data, + * so it can only ever cold-open the home tab — which is what these widgets + * used to do. The manifest already accepts the `flow-mn` scheme. + * + * Pinned to our own package: `flow-mn` is a custom scheme, so any installed + * app may register it and become a resolution candidate for this tap. Without + * [Intent.setPackage] a widget tap could raise a chooser, or open a + * Flow-lookalike. [path] must be absolute — it lands after the scheme's empty + * authority, so a relative one would silently become the host and resolve to + * nothing. + */ + fun deepLinkIntent(context: Context, path: String): Intent { + require(path.startsWith("/")) { "Deep-link path must be absolute: $path" } + + return Intent(Intent.ACTION_VIEW, "flow-mn://$path".toUri()).apply { + setPackage(context.packageName) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + } /** - * The widget's outer chrome: launcher background, tap-to-open, and the 16dp - * surface card the rest of Flow's widgets sit on. + * The widget's outer chrome: one surface that *is* the widget, plus + * tap-to-open. + * + * Deliberately a single background rather than a card floating inside an + * inset: the launcher already draws a rounded, themed container behind every + * widget, so a second rounded card inside it reads as a border around a + * screenshot rather than as the widget itself. [appWidgetBackground] marks + * this box as *the* background so the launcher's own rounding and Material You + * theming apply to it, and the system radius keeps the corners identical to + * every other widget on the home screen instead of a hard-coded 16dp that + * only matches on some launchers. + * + * [destination] is the `flow-mn` path a tap opens — the pinned widget passes + * its own budget, the roll-up passes the overview it mirrors. */ @Composable - fun Frame(context: Context, padding: Dp, content: @Composable () -> Unit) { + fun Frame( + padding: Dp, + destination: String, + content: @Composable () -> Unit, + ) { Box( modifier = GlanceModifier - .background(GlanceTheme.colors.widgetBackground) .fillMaxSize() - .clickable(onClick = actionStartActivity(launchAppIntent(context))), + .appWidgetBackground() + .background(GlanceTheme.colors.widgetBackground) + .cornerRadius(android.R.dimen.system_app_widget_background_radius) + .clickable( + onClick = actionStartActivity( + deepLinkIntent(LocalContext.current, destination), + ), + ) + .padding(padding), ) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .padding(8.dp), - ) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .background(GlanceTheme.colors.surfaceVariant) - .cornerRadius(16.dp) - .padding(padding), - ) { - content() - } - } + content() } } @@ -91,10 +129,25 @@ object BudgetWidgetUi { * the percent text carry that information instead. */ @Composable - fun BudgetBar(width: Dp, ratio: Double, status: BudgetStatus, height: Dp = 6.dp) { - val clamped = ratio.coerceIn(0.0, 1.0).toFloat() + fun BudgetBar( + width: Dp, + ratio: Double, + status: BudgetStatus, + height: Dp = 6.dp, + confirmedRatio: Double = ratio, + ) { val track = width.coerceAtLeast(0.dp) - val filled = track * clamped + + // A sliver of colour reads as "barely started"; zero width reads as a + // rendering bug, so never draw less than a dot. + fun band(fraction: Double): Dp { + val clamped = fraction.coerceIn(0.0, 1.0).toFloat() + if (clamped <= 0f) return 0.dp + return minOf(track, maxOf(track * clamped, height)) + } + + val total = band(ratio) + val confirmed = band(minOf(confirmedRatio, ratio)) Box( modifier = GlanceModifier @@ -103,12 +156,22 @@ object BudgetWidgetUi { .background(ColorProvider(R.color.budget_bar_track)) .cornerRadius(height / 2), ) { - if (clamped > 0f) { + // Full-length ghost with the solid fill on top of it, so the seam between + // them is a rounded cap nested inside a rounded cap. Glance has no alpha + // modifier, so the ghost is a pre-multiplied colour resource. + if (total > confirmed) { + Box( + modifier = GlanceModifier + .width(total) + .height(height) + .background(ghostColor(status)) + .cornerRadius(height / 2), + ) {} + } + if (confirmed > 0.dp) { Box( modifier = GlanceModifier - // A sliver of colour reads as "barely started"; zero width reads as - // a rendering bug, so never draw less than a dot. - .width(maxOf(filled, height)) + .width(confirmed) .height(height) .background(statusColor(status)) .cornerRadius(height / 2), diff --git a/android/app/src/main/res/values/widget_colors.xml b/android/app/src/main/res/values/widget_colors.xml index 89ee21d0..76da14db 100644 --- a/android/app/src/main/res/values/widget_colors.xml +++ b/android/app/src/main/res/values/widget_colors.xml @@ -10,4 +10,14 @@ #B0FF4040 #1F000000 + + #5932CC70 + #59FF4040 + + #3DFF4040 diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 36be8473..34e7da2d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -243,7 +243,7 @@ "error.exchangeRates.cannotFetch": "Failed to fetch, please check your internet connection.", "error.exchangeRates.inaccurateDataDueToMissingRates": "Failed to fetch exchange rates, transaction data might not be fully accurate", "error.failedLocalAuth": "Authentication failed, please try again.", - "error.input.cropFailed": "An error occured during cropping the picture", + "error.input.cropFailed": "An error occurred during cropping the picture", "error.input.duplicate.accountName": "Name \"{}\" already in use. Try a different name.", "error.input.duplicate.budgetName": "You already have a budget named \"{}\". Try a different name.", "error.input.invalidZip": "Not a valid Flow zip file", @@ -256,7 +256,7 @@ "error.route.400": "Failed to load the page", "error.route.404": "Page not found", "error.sync.exportFailed": "Unable to export, please contact developer.", - "error.sync.fileDeleteFailed": "An error occured during backup deletion", + "error.sync.fileDeleteFailed": "An error occurred during backup deletion", "error.sync.fileNotFound": "File not found", "error.sync.invalidBackupFile": "Invalid backup file", "error.sync.safetyBackupFailed": "Unable to start import", @@ -603,7 +603,7 @@ "sync.export.asZIP": "As backup (zip)", "sync.export.asZIP.description": "Can be fully restored later. Includes attached files", "sync.export.autoBackup": "Auto-backup", - "sync.export.deleteCloudBackupConfirmation": "Deleting this backup will also delete the iCloud copy. This action is not irreversible!", + "sync.export.deleteCloudBackupConfirmation": "Deleting this backup will also delete the iCloud copy. This action is irreversible!", "sync.export.fileDeleted": "File not found", "sync.export.history": "Backup history", "sync.export.history.description": "See backups made by you, and created automatically", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json new file mode 100644 index 00000000..5d8006ec --- /dev/null +++ b/assets/l10n/zh_CN.json @@ -0,0 +1,949 @@ +{ + "account": "账户", + "account.archive": "归档", + "account.archive.description": "归档账户会将其从报表以外的所有地方隐藏。归档后,您可以永久删除该账户及其相关的交易记录。", + "account.archived": "已归档", + "account.balance": "余额", + "account.balance.upcomingDescription": "即将发生的交易不会影响当前余额", + "account.creditLimit": "信用额度", + "account.delete": "删除账户", + "account.delete.description": "删除此账户将一并删除相关的 {transactionCount} 笔交易。此操作无法撤销,且不会经过回收站!", + "account.edit": "编辑账户", + "account.edit.selectCurrency": "选择币种", + "account.excludeFromTotalBalance": "不计入总余额", + "account.excludeFromTotalBalance.description": "若勾选此选项,本账户的余额将不会计入总余额中。适用于储蓄或非个人用途的账户。", + "account.name": "账户名称", + "account.new": "新增账户", + "account.noAccounts": "您目前没有任何账户!", + "account.postTransactionBalance": "此交易后余额", + "account.primaryAccount": "主要账户", + "account.primaryAccount.changeDescription": "您可以前往其他账户的编辑页面,将其设为主要账户。", + "account.primaryAccount.description": "主要账户将作为新增交易及其他功能的默认账户。", + "account.primaryAccount.notPrimary": "非主要账户", + "account.primaryAccount.set": "设为主要账户", + "account.thisMonth": "本月", + "account.transactions": "交易记录", + "account.transactions.title": "“{account}”的交易记录", + "account.type": "账户类型", + "account.updateBalance": "更新余额", + "account.updateBalance.chooseUpdateMode": "选择更新模式", + "account.updateBalance.transactionTitle": "更新余额", + "account.updateBalance.updateAtDate": "按日期同步过去余额", + "account.updateBalance.updateAtDate.description": "如果您确切知道某个特定日期的余额,适用此选项", + "account.updateBalance.updateCurrent": "更新当前余额", + "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.status.onTrack": "进度正常", + "budget.status.nearing": "接近上限", + "budget.status.over": "超出预算", + "budget.detail.history": "最近的周期", + "budget.detail.transactions": "交易", + "budget.detail.unavailable": "当前无法获取预算数据。", + "budget.detail.daysLeft": "剩余 {} 天", + "budget.detail.daysLeft.one": "剩余 {} 天", + "budget.detail.periodEnded": "周期已结束", + "budget.insight.over.short": "已超出 {amount}。请减少支出或提高上限。", + "budget.insight.nearingLimit.short": "已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", + "budget.insight.overpacing.short": "照此速度,将超出 {amount}。", + "budget.insight.onTrack.short": "进度正常 — 剩余 {amount}。", + "budget.insight.underspending.short": "余量充足 — 尚有 {amount} 未使用。", + "budget.insight.over": "你在 {name} 已超出 {amount}。请减少支出或提高上限。", + "budget.insight.overpacing": "照此速度,{name} 将超出 {amount}。", + "budget.insight.underspending": "{name} 余量充足 — 尚有 {amount} 未使用。", + "budget.name": "预算名称", + "budget.overview.allHealthy": "一切正常,做得好。", + "budget.overview.budgetsTracked": "已跟踪 {count} 个预算", + "budget.overview.budgetsTracked.one": "已跟踪 {count} 个预算", + "budget.overview.create": "新建预算", + "budget.overview.empty": "暂无预算", + "budget.overview.empty.description": "创建预算,查看你的支出与限额的对比情况。", + "budget.overview.missingRates": "部分金额已跳过(缺少汇率)。", + "budget.overview.nearingLimit": "{count} 个接近上限", + "budget.overview.nearingLimit.one": "{count} 个接近上限", + "budget.overview.nothingOver": "没有超出限额", + "budget.overview.overLimit": "{count} 个超出限额", + "budget.overview.overLimit.one": "{count} 个超出限额", + "budget.overview.perBudget": "你的预算", + "budget.overview.recommendations": "建议", + "budget.overview.summary": "状态", + "budget.overview.title": "预算总览", + "budget.period": "周期", + "budget.renewAutomatically": "自动续期", + "budget.renewAutomatically.description": "周期结束后,预算会进入新的周期。例如,七月的预算会变成八月的预算。", + "budget.scope": "范围", + "budgets": "预算", + "budgets.new": "新建预算", + "categories": "分类", + "categories.addFromPresets": "从预设新增", + "categories.noCategories": "您目前没有任何分类", + "category": "分类", + "category.delete": "删除分类", + "category.delete.description": "删除此分类将导致 {transactionCount} 笔交易失去分类。此操作无法撤销!", + "category.name": "分类名称", + "category.new": "新增分类", + "category.new.success": "成功创建新分类", + "category.none": "无分类", + "category.skip": "无分类", + "contributors": "贡献者", + "currency": "币种", + "currency.searchHint": "搜索... (国家、币种、代码)", + "enum.AccountType": "账户类型", + "enum.AccountType@asset": "资产", + "enum.AccountType@creditLine": "信用额度 (例如:信用卡)", + "enum.AccountType@debit": "活期/支票账户", + "enum.AccountType@loan": "贷款", + "enum.AccountType@other": "其他", + "enum.AccountType@savings": "储蓄", + "enum.BackupEntryType@automated": "自动备份", + "enum.BackupEntryType@automated.description": "自动创建的备份 (例如:计划任务)", + "enum.BackupEntryType@manual": "手动", + "enum.BackupEntryType@manual.description": "由用户创建的备份", + "enum.BackupEntryType@other": "其他备份", + "enum.BackupEntryType@other.description": "其他备份", + "enum.BackupEntryType@preAccountDeletion": "预防性备份 (账户删除前)", + "enum.BackupEntryType@preAccountDeletion.description": "用户删除账户前自动创建的预防性备份", + "enum.BackupEntryType@preImport": "预防性备份 (导入前)", + "enum.BackupEntryType@preImport.description": "从先前的备份导入数据前自动创建的预防性备份", + "enum.CSVHeader": "CSV 标题", + "enum.CSVHeader@account": "账户", + "enum.CSVHeader@accountUuid": "账户 ID", + "enum.CSVHeader@amount": "金额", + "enum.CSVHeader@category": "分类", + "enum.CSVHeader@categoryUuid": "分类 ID", + "enum.CSVHeader@createdDate": "创建日期", + "enum.CSVHeader@currency": "币种", + "enum.CSVHeader@extra": "额外信息 (JSON)", + "enum.CSVHeader@latitude": "纬度", + "enum.CSVHeader@longitude": "经度", + "enum.CSVHeader@notes": "备注", + "enum.CSVHeader@subtype": "交易子类别", + "enum.CSVHeader@title": "标题", + "enum.CSVHeader@transactionDate": "交易日期", + "enum.CSVHeader@transactionDateIso8601": "交易日期 (ISO 8601)", + "enum.CSVHeader@type": "类型", + "enum.CSVHeader@uuid": "ID", + "enum.FlowButtonType": "交易类型", + "enum.FlowButtonType@eny": "Eny", + "enum.FlowButtonType@expense": "支出", + "enum.FlowButtonType@income": "收入", + "enum.FlowButtonType@transfer": "转账", + "enum.ImportCSVProgress@creatingAccounts": "正在创建账户", + "enum.ImportCSVProgress@creatingCategories": "正在创建分类", + "enum.ImportCSVProgress@creatingTransactions": "正在创建交易", + "enum.ImportCSVProgress@erasing": "正在清除当前数据", + "enum.ImportCSVProgress@error": "发生错误 ({error})", + "enum.ImportCSVProgress@parsing": "正在解析数据", + "enum.ImportCSVProgress@success": "成功", + "enum.ImportCSVProgress@waitingConfirmation": "等待确认中", + "enum.ImportV1Progress@copyingFileAttachments": "正在复制文件附件", + "enum.ImportV1Progress@erasing": "正在清除当前数据", + "enum.ImportV1Progress@error": "发生错误 ({error})", + "enum.ImportV1Progress@resolvingTransactions": "正在整理交易记录", + "enum.ImportV1Progress@success": "成功", + "enum.ImportV1Progress@waitingConfirmation": "等待确认中", + "enum.ImportV1Progress@writingAccounts": "正在写入账户", + "enum.ImportV1Progress@writingCategories": "正在写入分类", + "enum.ImportV1Progress@writingTransactions": "正在写入交易", + "enum.ImportV2Progress@copyingImages": "正在复制图片", + "enum.ImportV2Progress@erasing": "正在清除当前数据", + "enum.ImportV2Progress@error": "发生错误 ({error})", + "enum.ImportV2Progress@resolvingTransactions": "正在整理交易记录", + "enum.ImportV2Progress@settingPrimaryCurrency": "正在设置主要币种", + "enum.ImportV2Progress@success": "成功", + "enum.ImportV2Progress@waitingConfirmation": "等待确认中", + "enum.ImportV2Progress@writingAccounts": "正在写入账户", + "enum.ImportV2Progress@writingBudgets": "正在写入预算", + "enum.ImportV2Progress@writingCategories": "正在写入分类", + "enum.ImportV2Progress@writingFileAttachments": "正在写入文件附件", + "enum.ImportV2Progress@writingProfile": "正在写入个人资料", + "enum.ImportV2Progress@writingRecurringTransactions": "正在写入周期性交易", + "enum.ImportV2Progress@writingTransactionTags": "正在写入交易标签", + "enum.ImportV2Progress@writingTransactions": "正在写入交易", + "enum.ImportV2Progress@writingTranscationFilterPresets": "正在写入交易过滤预设", + "enum.ImportV2Progress@writingUserPreferences": "正在写入用户偏好设置", + "enum.PDFHeader@account": "账户", + "enum.PDFHeader@amount": "金额", + "enum.PDFHeader@category": "分类", + "enum.PDFHeader@title": "标题", + "enum.PDFHeader@transactionDate": "交易日期", + "enum.PendingTimeRange@allTime": "所有时间", + "enum.PendingTimeRange@followHome": "与首页相同", + "enum.PendingTimeRange@nextNDays": "接下来的 {n} 天", + "enum.PendingTimeRange@thisMonth": "本月", + "enum.PendingTimeRange@thisWeek": "本周", + "enum.PendingTimeRange@thisYear": "本年", + "enum.RecurrenceMode@custom": "自定义", + "enum.RecurrenceMode@every2Week": "每 2 周的 {weekday}", + "enum.RecurrenceMode@everyDay": "每天", + "enum.RecurrenceMode@everyMonth": "每个月的 {dayOfMonth}", + "enum.RecurrenceMode@everyWeek": "每周的 {weekday}", + "enum.RecurrenceMode@everyYear": "每年的 {monthAndDay}", + "enum.RecurringUpdateMode@all": "所有交易", + "enum.RecurringUpdateMode@current": "仅此笔交易", + "enum.RecurringUpdateMode@thisAndFuture": "此笔及未来的交易", + "enum.TransactionEditMode@normal": "一般", + "enum.TransactionEditMode@pending": "待处理", + "enum.TransactionEditMode@recurring": "周期性", + "enum.TransactionEntryAction": "动作", + "enum.TransactionEntryAction@attachFiles": "添加附件", + "enum.TransactionEntryAction@inputAmount": "输入金额", + "enum.TransactionEntryAction@inputNote": "输入描述", + "enum.TransactionEntryAction@inputTitle": "输入标题", + "enum.TransactionEntryAction@selectAccount": "选择账户", + "enum.TransactionEntryAction@selectCategoryOrTransferAccount": "选择分类/转账账户", + "enum.TransactionEntryAction@selectPrimaryAccount": "选择主要账户", + "enum.TransactionEntryAction@selectTags": "选择标签", + "enum.TransactionFilterRangePreset@allTime": "所有时间", + "enum.TransactionFilterRangePreset@last30Days": "过去 30 天", + "enum.TransactionFilterRangePreset@thisMonth": "本月", + "enum.TransactionFilterRangePreset@thisWeek": "本周", + "enum.TransactionFilterRangePreset@thisYear": "本年", + "enum.TransactionGroupRange": "分组单位", + "enum.TransactionGroupRange@allTime": "所有时间", + "enum.TransactionGroupRange@day": "日", + "enum.TransactionGroupRange@hour": "小时", + "enum.TransactionGroupRange@month": "月", + "enum.TransactionGroupRange@week": "周", + "enum.TransactionGroupRange@year": "年", + "enum.TransactionSearchMode": "搜索模式", + "enum.TransactionSearchMode@exact": "完全匹配", + "enum.TransactionSearchMode@none": "未命名", + "enum.TransactionSearchMode@smart": "智能搜索", + "enum.TransactionSearchMode@substring": "部分匹配", + "enum.TransactionSubtype": "类型", + "enum.TransactionSubtype#null": "默认", + "enum.TransactionSubtype@givenLoan": "借出", + "enum.TransactionSubtype@receivedLoan": "借入", + "enum.TransactionSubtype@transactionFee": "手续费", + "enum.TransactionSubtype@updateBalance": "余额更新", + "enum.TransactionTagType": "标签类型", + "enum.TransactionTagType@contact": "联系人", + "enum.TransactionTagType@generic": "一般", + "enum.TransactionTagType@location": "地点", + "enum.TransactionType": "交易类型", + "enum.TransactionType@expense": "支出", + "enum.TransactionType@income": "收入", + "enum.TransactionType@transfer": "转账", + "error.exchangeRates.cannotFetch": "获取失败,请检查您的网络连接。", + "error.exchangeRates.inaccurateDataDueToMissingRates": "获取汇率失败,交易数据可能无法完全准确。", + "error.failedLocalAuth": "身份验证失败,请再试一次。", + "error.input.cropFailed": "裁剪图片时发生错误", + "error.input.duplicate.accountName": "名称“{}”已被使用。请尝试其他名称。", + "error.input.duplicate.budgetName": "你已有名为“{}”的预算。请换一个名称。", + "error.input.invalidZip": "不是有效的 Flow zip 文件", + "error.input.mustBeNotEmpty": "请填写此字段", + "error.input.noFilePicked": "尚未选择文件", + "error.input.noImagePicked": "尚未选择图片", + "error.input.pasteFormatMismatch": "无法解析格式", + "error.input.wrongFileType": "请选择一个 {type} 文件", + "error.noConnection": "无网络连接", + "error.route.400": "加载页面失败", + "error.route.404": "找不到页面", + "error.sync.exportFailed": "无法导出,请联系开发者。", + "error.sync.fileDeleteFailed": "删除备份时发生错误", + "error.sync.fileNotFound": "找不到文件", + "error.sync.invalidBackupFile": "无效的备份文件", + "error.sync.safetyBackupFailed": "无法开始导入", + "error.transaction.missingAccount": "请选择一个账户", + "error.url.cannotOpen": "无法打开链接", + "fileAttachment": "文件", + "fileAttachment.add": "添加文件", + "fileAttachment.cleanupHangingFiles": "删除未使用的文件", + "fileAttachment.cleanupHangingFiles.description": "此动作将删除所有未链接至任何交易的文件附件。如果关联的交易在回收站中,将不会删除该文件。此操作无法撤销。", + "fileAttachment.delete": "删除文件", + "fileAttachment.delete.description": "这将删除存储在 Flow 中的副本,原始文件不受影响。您稍后可以重新加入原始文件,但无法撤销此删除动作。", + "fileAttachment.delete.success": "成功删除文件", + "fileAttachment.file": "从文件中选择", + "fileAttachment.open": "打开 {name}?", + "fileAttachment.open.description": "确定要打开此文件吗?", + "fileAttachment.photo": "选择一张照片", + "fileAttachment.photos": "选择多个媒体文件", + "fileAttachment.pick": "选择文件", + "fileAttachment.share": "分享文件", + "fileAttachment.takePhoto": "拍照", + "flowIcon.change": "更改图标", + "flowIcon.type.character": "表情符号/字母", + "flowIcon.type.character.description": "输入表情符号或字母以作为图标", + "flowIcon.type.icon": "图标", + "flowIcon.type.icon.brands": "品牌与标志", + "flowIcon.type.icon.search": "搜索图标...", + "flowIcon.type.icon.symbols": "符号", + "flowIcon.type.image": "图片", + "flowIcon.type.image.description": "选择一张图片作为图标", + "flowIcon.type.image.paste": "粘贴图片", + "flowIcon.type.image.pick": "选择图片", + "general.areYouSure": "您确定吗?", + "general.back": "返回", + "general.cancel": "取消", + "general.confirm": "确认", + "general.copy": "复制", + "general.copy.clickToCopy": "点击复制", + "general.copy.success": "已复制到剪贴板", + "general.delete": "删除", + "general.delete.all": "全部删除", + "general.delete.confirmName": "确认删除 {name}?", + "general.delete.permanentWarning": "此操作无法撤销", + "general.delete.unsavedProgress": "不保存并关闭?", + "general.delete.unsavedProgress.description": "所有变更将会丢失。", + "general.disabled": "已禁用", + "general.done": "完成", + "general.edit": "编辑", + "general.enabled": "已启用", + "general.flow": "Flow", + "general.new": "新增", + "general.next": "下一步", + "general.nextNDays": "接下来的 {n} 天", + "general.paste": "粘贴", + "general.save": "保存", + "general.search": "搜索...", + "general.select": "选择", + "general.select.all": "全选", + "general.select.clear": "清除选择", + "general.selectLocation": "选择地点", + "general.unlockToOpen": "解锁以开启 Flow", + "integrations.eny": "Eny", + "integrations.eny.connect": "连接 Eny", + "integrations.eny.connect.conflict": "已经有一个连接的账户,您要替换它吗?", + "integrations.eny.connect.success": "成功连接 Eny", + "integrations.eny.connected#false": "未连接", + "integrations.eny.connected#true": "已连接", + "integrations.eny.creditsRemaining": "剩余点数", + "integrations.eny.dashboard": "Eny 仪表板", + "integrations.eny.dashboard.description": "您可以从仪表板连接您的 Eny 账户", + "integrations.eny.disconnect": "断开连接 Eny", + "integrations.eny.invalidCredentials": "凭据无效", + "integrations.eny.invalidCredentials.configure": "设置", + "integrations.eny.invalidCredentials.description": "请尝试从仪表板重新连接 Eny", + "integrations.eny.multipleImagesNotice": "发送 {n} 张图片进行处理?", + "integrations.eny.multipleImagesNotice.checkNotice": "即使有部分图片失败,仍会消耗点数。因此,在确认前请仔细检查图片。您一次最多可发送 5 张图片。", + "integrations.eny.multipleImagesNotice.description": "将消耗 {n} 点 Eny 点数", + "integrations.eny.privacyNotice": "隐私声明", + "integrations.eny.privacyNotice.dataSharing": "您的数据将发送至:", + "integrations.eny.privacyNotice.dataSharing#eny": "Eny", + "integrations.eny.privacyNotice.dataSharing#google": "Google", + "integrations.eny.privacyNotice.description": "这是一项处理您数据的外部服务,请小心选择您发送的内容。Flow 不会代替您发送任何未经许可的数据,仅会发送您选择的图片或数据。", + "integrations.eny.privacyNotice.legal": "请查看 Eny 的服务条款及隐私政策。", + "integrations.eny.send": "发送", + "integrations.eny.sent": "正在处理收据,请稍候...", + "logs.delete": "删除日志文件", + "logs.delete.confirmation": "您确定要删除此日志文件吗?", + "logs.deleted": "成功删除日志文件", + "notifications.alarm.androidDescription": "授予“闹钟和提醒”权限(在权限列表最后),以接收准确时间的提醒。", + "notifications.alarm.permissionNotGranted": "尚未授予闹钟/提醒权限", + "notifications.openSettingsToGrantPermission": "从系统设置授予通知权限", + "notifications.permissionNotGranted": "尚未授予通知权限", + "notifications.reminderText#1": "今天有记录您的支出吗?", + "notifications.reminderText#2": "别忘了记录您的交易!", + "notifications.reminderText#3": "是时候记录您今天的支出了。", + "notifications.reminderText#4": "随时掌握财务状况,快来新增交易吧!", + "notifications.reminderText#5": "Flow 提醒您记录今天的支出!", + "notifications.reminderText#6": "更新您的交易,轻松管理个人财务。", + "notifications.reminderText#7": "今天别忘了新增您的交易记录哦!", + "preferences": "偏好设置", + "preferences.appearance": "外观", + "preferences.changeVisuals": "变更颜色/箭头", + "preferences.changeVisuals.arrow": "箭头", + "preferences.changeVisuals.clickToChange": "点击箭头或颜色即可变更", + "preferences.changeVisuals.color": "颜色", + "preferences.changeVisuals.expenseIncrease": "支出增加", + "preferences.changeVisuals.incomeIncrease": "收入增加", + "preferences.dateFormat": "日期格式", + "preferences.feedback": "问题与反馈", + "preferences.feedback.debugLogs": "查看调试日志", + "preferences.hapticFeedback": "按钮反馈", + "preferences.hapticFeedback.description": "点击时的声音/触觉震动反馈", + "preferences.integrations": "集成服务", + "preferences.language": "语言", + "preferences.language.choose": "选择语言", + "preferences.moneyFormatting": "金额格式", + "preferences.moneyFormatting.preferFull": "偏好完整金额", + "preferences.moneyFormatting.preferFull.description": "尽可能不缩写数字", + "preferences.moneyFormatting.setICUPattern": "选择自定义格式", + "preferences.moneyFormatting.setICUPattern.default": "默认", + "preferences.moneyFormatting.useCurrencySymbol": "使用货币符号", + "preferences.moneyFormatting.useCurrencySymbol.description": "例如:使用“$5”取代“5USD”", + "preferences.numpad": "数字键盘", + "preferences.numpad.layout": "数字键盘布局", + "preferences.numpad.layout.classic": "经典", + "preferences.numpad.layout.modern": "现代", + "preferences.primaryCurrency": "主要币种", + "preferences.privacy": "隐私", + "preferences.privacy.appLock": "锁定应用程序", + "preferences.privacy.appLock.description#Android": "开启应用程序需要生物识别验证", + "preferences.privacy.appLock.description#iOS": "开启应用程序需要 Face ID 或 Touch ID", + "preferences.privacy.appLock.lockAfterClosing": "关闭后锁定", + "preferences.privacy.maskAtShake": "摇晃设备时遮蔽数字 (*)", + "preferences.privacy.maskAtStartup": "启动时遮蔽数字 (*)", + "preferences.reminders": "提醒事项", + "preferences.reminders.remindDaily": "每日提醒", + "preferences.reminders.remindDaily.description": "提醒每天记录支出", + "preferences.reminders.remindDaily.expiryWarning": "如果您连续 7 天未开启 Flow,提醒将会自动停止", + "preferences.reminders.remindDaily.time": "提醒时间", + "preferences.reminders.unsupportedPlatform": "此平台不支持计划通知", + "preferences.scan": "扫描文件", + "preferences.scan.createTransactionsPerItemInScans": "扫描的每个项目创建单独交易", + "preferences.scan.createTransactionsPerItemInScans.description": "对过长的收据可能会很混乱", + "preferences.scan.markPendingThreshold": "将交易标记为待处理", + "preferences.scan.markPendingThreshold.description": "如果关闭,解析日期超过 6 小时前的交易仍会被标记为待处理", + "preferences.sync": "同步与备份", + "preferences.sync.autoBackup": "自动备份", + "preferences.sync.autoBackup.disabled": "禁用", + "preferences.sync.autoBackup.interval": "备份频率", + "preferences.sync.autoBackup.interval.description": "当您开启应用程序,且距离上次备份已经超过设置的间隔时间时,系统会自动创建备份。", + "preferences.sync.iCloud": "同步至 iCloud", + "preferences.sync.iCloud.connectionFailed": "无法连接至 iCloud!", + "preferences.sync.iCloud.connectionFailed.tips#1": "请检查您的网络连接", + "preferences.sync.iCloud.connectionFailed.tips#2": "请确保您已在设备上登录您的 Apple 账号", + "preferences.sync.iCloud.connectionFailed.tips#3": "请确保已在“系统设置 > Apple 账号 > iCloud > 云端硬盘”中开启 iCloud", + "preferences.sync.iCloud.lastSyncFailed": "上次备份至 iCloud 失败。点击此处查看修复方法。", + "preferences.sync.iCloud.lastSyncedAt": "上次同步时间 {date}", + "preferences.sync.iCloud.noOfBackupsToKeep": "保留的备份数量", + "preferences.sync.iCloud.noOfBackupsToKeep.description": "在 iCloud 中保留备份文件的最大数量。这将占用您的 iCloud 空间,因此您可能希望将此数字设低一些。在每次启动时会根据此设置删除旧备份。", + "preferences.sync.iCloud.noOfBackupsToKeep.infiniteBackups": "无限数量", + "preferences.sync.iCloud.noOfBackupsToKeep.nBackups": "{n} 个备份", + "preferences.sync.iCloud.privacyNotice": "您的数据存储在您的 iCloud 内一个仅供 Flow 访问的私人空间。其他人无法访问您的数据,Flow 亦无法访问您 iCloud 中的其他数据。", + "preferences.sync.iCloud.singleDeviceSupportDisclaimer": "此功能不支持在多个设备上同步。使用多个设备可能导致数据丢失。", + "preferences.theme": "主题", + "preferences.theme.choose": "选择主题", + "preferences.theme.enableDynamicTheme": "动态主题", + "preferences.theme.enableOledTheme": "使用 OLED 深色主题", + "preferences.theme.other": "其他主题", + "preferences.theme.themeChangesAppIcon": "应用程序图标跟随主题变更", + "preferences.transactionButtonOrder": "按钮配置", + "preferences.transactionButtonOrder.description": "变更首页新增交易按钮的顺序", + "preferences.transactionButtonOrder.guide": "拖动按钮以重新排序", + "preferences.transactionButtonOrder.widgetDescription": "此顺序将会反映在主屏幕的交易按钮小组件上", + "preferences.transactionEntryFlow": "交易输入流程", + "preferences.transactionEntryFlow.abandonUponCancelForm": "当关闭任一表单时停止流程", + "preferences.transactionEntryFlow.actions": "动作列表", + "preferences.transactionEntryFlow.actions.description": "您可以拖动来重新排序", + "preferences.transactionEntryFlow.actions.lastItem": "必须放在最后", + "preferences.transactionEntryFlow.description": "为了节省时间,Flow 可以在新增交易时自动为您开启某些表单流程。如果您偏好手动点击各个字段,可以关闭所有流程。", + "preferences.transactionEntryFlow.skipSelectedFields": "跳过已选择的字段", + "preferences.transactions": "交易", + "preferences.transactions.geo": "交易地点", + "preferences.transactions.geo.auto.description": "自动将您当前的地点附加到新交易中。即使关闭此功能,您仍可以在地图上手动选择地点。", + "preferences.transactions.geo.auto.enable": "自动附加", + "preferences.transactions.geo.auto.enabled": "已启用自动附加", + "preferences.transactions.geo.auto.permissionDenied": "尚未授予位置权限", + "preferences.transactions.geo.disableInstructions": "您可以在设置中隐藏此区块", + "preferences.transactions.geo.enable": "启用", + "preferences.transactions.listTile": "列表项目外观", + "preferences.transactions.listTile.fallbackToCategoryName": "未命名交易显示分类名称", + "preferences.transactions.listTile.leading": "开头图标", + "preferences.transactions.listTile.leading.account": "账户", + "preferences.transactions.listTile.leading.category": "分类", + "preferences.transactions.listTile.preview": "预览", + "preferences.transactions.listTile.relaxedDensity": "宽松的排版密度", + "preferences.transactions.listTile.showCategoryInList": "在账户后方显示分类", + "preferences.transactions.listTile.showExternalSource": "显示外部来源 (例如:Eny)", + "preferences.transactions.pending": "待处理交易", + "preferences.transactions.pending.homeTimeframe": "首页显示范围", + "preferences.transactions.pending.notify": "通知", + "preferences.transactions.pending.notify.earlyReminder": "提前提醒", + "preferences.transactions.pending.notify.earlyReminder.none": "无", + "preferences.transactions.pending.notify.schedulingUnsupported": "若未开启 Flow 则无法收到通知", + "preferences.transactions.pending.notify.schedulingUnsupported.description": "计划通知目前仅支持 Android、iOS 以及 macOS", + "preferences.transactions.pending.requireConfirmation": "需要确认", + "preferences.transactions.pending.requireConfirmation.description": "待处理交易在确认前,将不计入收入、支出和账户余额中", + "preferences.transactions.pending.updateDateUponConfirmation": "确认时更新日期", + "preferences.transactions.pending.updateDateUponConfirmation.description": "关闭此选项可保留原始的交易日期", + "preferences.transactions.tags": "交易标签", + "preferences.transactions.tags.contactUsageDescription": "为了方便起见,您可以使用联系人功能。这需要联系人权限。您的联系人信息不会离开您的设备,但某些数据将包含在备份中。当您更换或重置手机时,联系人链接可能会失效,但名称将会保留。", + "preferences.transfer": "转账", + "preferences.transfer.combineTransferTransaction": "排版方式", + "preferences.transfer.combineTransferTransaction.combine": "合并显示", + "preferences.transfer.combineTransferTransaction.combineSupportDisclaimer": "在某些界面下,转账仍会被分开显示", + "preferences.transfer.combineTransferTransaction.separate": "分开显示", + "preferences.transfer.description": "将转账记录合并为单一项目,且从支出/收入中排除", + "preferences.transfer.excludeTransferFromFlow": "从统计总额中排除", + "preferences.transfer.excludeTransferFromFlow.description": "不计入总支出与总收入", + "preferences.trashBin": "回收站", + "preferences.trashBin.emptyBin": "清空回收站", + "preferences.trashBin.emptyBin.description": "永久删除回收站中的所有项目。此操作无法撤销!", + "preferences.trashBin.retention": "保留期限", + "preferences.trashBin.retention.description": "交易将在保留期限后被自动删除", + "preferences.trashBin.retention.forever": "永久保留", + "preferences.trashBin.seeItems": "查看项目", + "profile.name": "名称", + "select.color": "变更颜色", + "select.color.clear": "清除颜色", + "select.color.none": "默认颜色", + "select.contact": "选择联系人", + "select.contact.editPermissions": "开启设置", + "select.contact.empty": "找不到联系人", + "select.contact.emptyPermissionSuggestion": "缺乏权限?请尝试重新加载联系人或授予权限", + "select.contact.none": "清除选择", + "select.dropFile": "选择或拖动文件", + "select.dropFile.acceptedTypes": "支持格式:{types}", + "select.dropFile.dropHere": "将文件拖动至此处", + "select.recurrence": "周期设置", + "select.recurrence.addMore": "新增更多", + "select.recurrence.from": "开始日期", + "select.recurrence.occurrences": "输入发生次数", + "select.recurrence.occurrences.n": "{n} 次", + "select.recurrence.occurrences.times.prefix": "", + "select.recurrence.occurrences.times.suffix": "次", + "select.recurrence.until": "结束日期", + "select.recurrence.until.date": "指定日期", + "select.recurrence.until.never": "永不结束", + "select.recurrence.until.noOfOccurrences": "按发生次数", + "select.time.now": "现在", + "select.time.select.month": "选择月份", + "select.time.select.year": "选择年份", + "select.timeRange": "选择时间范围", + "select.timeRange.allTime": "所有时间", + "select.timeRange.changeMode": "更多选项", + "select.timeRange.last30Days": "过去 30 天", + "select.timeRange.mode.byMonth": "按月", + "select.timeRange.mode.byWeek": "按周", + "select.timeRange.mode.byYear": "按年", + "select.timeRange.mode.custom": "自定义范围", + "select.timeRange.presets": "常用选项", + "select.timeRange.thisMonth": "本月", + "select.timeRange.thisWeek": "本周", + "select.timeRange.thisYear": "本年", + "setup.accounts.addAccount": "新增账户", + "setup.accounts.description": "创建新账户,或从默认选项中新增。您稍后也可以在“账户”标签页中变更设置。", + "setup.accounts.preset.cash": "现金账户", + "setup.accounts.preset.main": "主要账户", + "setup.accounts.preset.savings": "储蓄账户", + "setup.accounts.setup": "设置账户", + "setup.categories.description": "创建分类,或从默认选项中新增。您稍后可以在“个人资料 > 分类”中进行变更。", + "setup.categories.existing": "现有分类", + "setup.categories.preset.beauty": "美容", + "setup.categories.preset.childCare": "育儿", + "setup.categories.preset.donations": "捐款", + "setup.categories.preset.drinks": "饮品", + "setup.categories.preset.eatingOut": "外食", + "setup.categories.preset.education": "教育", + "setup.categories.preset.entertainment": "娱乐", + "setup.categories.preset.fitness": "健身", + "setup.categories.preset.gadgets": "电子产品", + "setup.categories.preset.gifts": "礼物", + "setup.categories.preset.groceries": "生鲜杂货", + "setup.categories.preset.health": "医疗保健", + "setup.categories.preset.hobby": "兴趣", + "setup.categories.preset.hygiene": "个人卫生", + "setup.categories.preset.insurance": "保险", + "setup.categories.preset.onlineServices": "线上订阅", + "setup.categories.preset.paychecks": "薪资", + "setup.categories.preset.petCare": "宠物", + "setup.categories.preset.petrol": "加油", + "setup.categories.preset.rent": "租金", + "setup.categories.preset.services": "服务", + "setup.categories.preset.shopping": "购物", + "setup.categories.preset.snacks": "零食", + "setup.categories.preset.stationery": "文具", + "setup.categories.preset.taxes": "税金", + "setup.categories.preset.transport": "交通", + "setup.categories.preset.travel": "旅行", + "setup.categories.preset.utils": "水电费", + "setup.categories.setup": "设置分类", + "setup.getStarted": "开始使用", + "setup.next": "下一步", + "setup.onboarding": "我们开始吧", + "setup.onboarding.freshStart": "重新开始", + "setup.onboarding.freshStart.description": "我是第一次使用 Flow", + "setup.onboarding.importExisting": "从备份导入", + "setup.onboarding.importExisting.description": "从先前的 Flow 备份还原数据", + "setup.onboarding.recoverICloudBackup": "从 iCloud 恢复", + "setup.onboarding.recoverICloudBackup.description": "从 iCloud 恢复 (上次同步于 {lastSynced})", + "setup.onboarding.recoverICloudBackup.description.loading": "从 iCloud 恢复 (正在加载数据...)", + "setup.onboarding.recoverICloudBackup.description.none": "从 iCloud 恢复 (找不到备份)", + "setup.primaryCurrency.choose": "选择币种", + "setup.primaryCurrency.description": "这将成为您的主要币种。您稍后可以在“偏好设置”中更改。", + "setup.primaryCurrency.setup": "选择主要币种", + "setup.profile.addPhoto": "新增照片", + "setup.profile.addPhoto.skip": "跳过", + "setup.profile.setup": "您叫什么名字?", + "setup.slides.foss.description": "完全免费且源代码公开。", + "setup.slides.foss.seeRepo": "在 GitHub 上查看项目", + "setup.slides.foss.title": "免费 & 开源", + "setup.slides.privacy": "完全掌控您的数据", + "setup.slides.privacy.description": "您的所有数据仅存储在本地(或您的私人云空间),并提供完整的数据导出选项。", + "setup.transactionTags": "选择标签", + "support": "支持", + "support.contribute": "贡献代码", + "support.contribute.description": "如果您是一位开发者,您可以参与 Flow 的开发。贡献者名单等待您的加入。", + "support.description": "Flow 是一款出于热忱、免费且为所有人开源的软件。如果您认为 Flow 很有帮助,考虑协助项目成长!您可以通过以下方式支持我们:", + "support.donateDeveloper": "赞助开发者", + "support.donateDeveloper.action": "请创作者喝杯咖啡", + "support.donateDeveloper.description": "Flow 的所有功能均免费提供,赞助开发者不会解锁任何额外功能", + "support.leaveAReview": "留下评论", + "support.leaveAReview.action": "为 Flow 评分", + "support.leaveAReview.description": "您可以在 {appStore} 为 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 表格等软件中打开", + "sync.export.asJSON": "导出为备份 (JSON)", + "sync.export.asJSON.description": "仅包含基本数据,不含图片或附加文件。", + "sync.export.asPDF": "报表 (PDF)", + "sync.export.asPDF.description": "账户报表;适合打印。这并非官方正式文件,仅供您个人使用。", + "sync.export.asZIP": "导出为备份 (ZIP)", + "sync.export.asZIP.description": "稍后可完全还原。包含附加文件", + "sync.export.autoBackup": "自动备份", + "sync.export.deleteCloudBackupConfirmation": "删除此备份将会一并删除 iCloud 上的副本。此操作无法撤销!", + "sync.export.fileDeleted": "找不到文件", + "sync.export.history": "备份历史记录", + "sync.export.history.description": "查看由您手动或自动创建的备份", + "sync.export.history.empty": "您没有任何备份", + "sync.export.history.empty.description": "手动或自动创建的备份会列在这里", + "sync.export.onDeviceWarning": "所有备份皆存储于本地设备,这意味着当您卸载 Flow 或重置设备时,所有的备份都将丢失!", + "sync.export.pdf.accounts": "账户", + "sync.export.pdf.accounts.selected": "已选择 {n} 个 (共 {total} 个)", + "sync.export.pdf.categories": "分类", + "sync.export.pdf.categories.selected": "已选择 {n} 个 (共 {total} 个)", + "sync.export.pdf.generatedAt": "生成时间", + "sync.export.pdf.header": "Flow - 个人财务记录 (非官方,{range})", + "sync.export.pdf.notice[0]": "由 ", + "sync.export.pdf.notice[1]": " 生成。这不是一份具法律效力的文件。这不是财务报表。这不是收据。这不以任何形式代表现实状况。此文件仅供个人参考使用。", + "sync.export.pdf.size": "纸张尺寸", + "sync.export.pdf.summary": "按账户统计摘要 ({range})", + "sync.export.pdf.summary.allAcounts": "所有账户", + "sync.export.pdf.summary.expense": "支出", + "sync.export.pdf.summary.flow": "净收支", + "sync.export.pdf.summary.income": "收入", + "sync.export.pdf.timeRange": "时间范围", + "sync.export.save": "保存备份", + "sync.export.save.shareTitle": "Flow 备份 ({type}, {date})", + "sync.export.savedTo": "已保存至 {path}", + "sync.export.success": "导出成功!", + "sync.export.success.filePath[0]": "已保存至 ", + "sync.export.success.filePath[1]": "", + "sync.export.type": "导出 ({type})", + "sync.import": "导入", + "sync.import.emergencyBackup": "作为预防措施,Flow 在继续之前会尝试将当前数据备份到您的设备", + "sync.import.emergencyBackup.successful": "之前的数据已备份。您可以在“备份 > 备份历史记录”中保存备份文件", + "sync.import.eraseWarning": "继续操作将会清除所有现有数据", + "sync.import.getCSVTemplate": "取得 CSV 模板", + "sync.import.other": "其他选项", + "sync.import.pickCurrencies": "为账户指派币种", + "sync.import.pickCurrencies.incomplete": "请为每个账户指派一个币种", + "sync.import.pickFile": "选择文件", + "sync.import.pickFile.description": "选择一个 Flow 备份文件以还原。支持的格式:{exts}", + "sync.import.pickFile.dropzone.active": "拖放到此处", + "sync.import.pickFile.pickOrDrop": "选择或拖放一个文件", + "sync.import.start": "开始导入", + "sync.import.success": "导入成功!", + "sync.import.syncData.createdDate": "备份日期", + "sync.import.syncData.olderBackupWarning": "因为此备份是在较旧版本的应用程序中创建的,某些数据可能无法正常还原!", + "sync.import.syncData.parsedEstimate": "预估可还原数据", + "sync.import.syncData.parsedEstimate.accountCount": "{count} 个账户", + "sync.import.syncData.parsedEstimate.budgetCount": "{count} 个预算", + "sync.import.syncData.parsedEstimate.categoryCount": "{count} 个分类", + "sync.import.syncData.parsedEstimate.fileAttachmentsCount": "{count} 个文件附件", + "sync.import.syncData.parsedEstimate.goalCount": "{count} 个目标", + "sync.import.syncData.parsedEstimate.transactionCount": "{count} 笔交易", + "sync.import.syncData.parsedEstimate.transactionFilterPresets": "{count} 个交易过滤预设", + "sync.import.syncData.parsedEstimate.transactionTagCount": "{count} 个交易标签", + "sync.import.zipWarning": "请确保导入的 ZIP 文件是由 Flow App 所生成的!", + "tabs.accounts": "账户", + "tabs.accounts.reorder": "重新排序账户", + "tabs.accounts.reorder.guide": "长按并拖动", + "tabs.home": "首页", + "tabs.home.flow": "总收支", + "tabs.home.greetings": "嗨,{name}!", + "tabs.home.last7days": "过去 7 天", + "tabs.home.noTransactions": "没有符合条件的交易", + "tabs.home.noTransactions.addSome": "点击下方的 (+) 按钮新增一笔交易", + "tabs.home.noTransactions.tryChangingFilters": "尝试更改过滤条件", + "tabs.home.pendingTransactions": "待处理 ({count})", + "tabs.home.pendingTransactions.needAttention": "有 {} 笔交易需要确认", + "tabs.home.pendingTransactions.seeAll": "查看全部", + "tabs.home.reminders.autoBackup": "已创建备份", + "tabs.home.reminders.autoBackup.subtitle": "自动创建", + "tabs.home.reminders.rateApp": "在 {store} 上为 Flow 评分!", + "tabs.home.reminders.rateApp.action": "评分", + "tabs.home.reminders.starOnGitHub": "在 GitHub 上给 Flow 颗星星", + "tabs.home.reminders.turnOnICloudSync": "您可以开启 iCloud 备份", + "tabs.home.reminders.turnOnICloudSync.action": "开启", + "tabs.home.reminders.turnOnICloudSync.subtitle": "安全可靠地免费备份您的数据", + "tabs.home.totalBalance": "总余额", + "tabs.home.transactionsCount": "{count} 笔交易", + "tabs.home.transactionsCount.one": "{count} 笔交易", + "tabs.profile": "个人", + "tabs.profile.analytics": "分析", + "tabs.profile.analytics.calendar": "支出日历", + "tabs.profile.analytics.cashFlow": "现金流(Sankey)", + "tabs.profile.analytics.map": "支出地图", + "tabs.profile.analytics.netWorth": "净资产变动", + "tabs.profile.analytics.recurring": "订阅与定期交易", + "tabs.profile.analytics.wrapped": "月度回顾", + "tabs.profile.backup": "备份", + "tabs.profile.community": "社区", + "tabs.profile.guide": "使用指南", + "tabs.profile.import": "导入", + "tabs.profile.other": "其他", + "tabs.profile.preferences": "偏好设置", + "tabs.profile.recommend": "推荐 Flow", + "tabs.profile.support": "支持 Flow", + "tabs.profile.withLoveFromTheCreator": "来自 sadespresso 🤍 的作品", + "tabs.stats": "统计", + "tabs.stats.analytics.budgets": "预算", + "tabs.stats.analytics.budgets.empty": "设置支出预算", + "tabs.stats.analytics.budgets.nearingCount": "{count} 个接近上限", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} 个接近上限", + "tabs.stats.analytics.budgets.onTrack": "全部进度正常", + "tabs.stats.analytics.budgets.overCount": "{count} 个超出限额", + "tabs.stats.analytics.budgets.overCount.one": "{count} 个超出限额", + "tabs.stats.analytics.budgets.tracked": "{count} 个预算", + "tabs.stats.analytics.budgets.tracked.one": "{count} 个预算", + "tabs.stats.analytics.calendar": "日历", + "tabs.stats.analytics.calendar.priciestDay": "你花费最高的一天是 {value}。", + "tabs.stats.analytics.calendar.spentIn": "在 {} 的支出", + "tabs.stats.analytics.cashFlow": "现金流", + "tabs.stats.analytics.cashFlow.empty": "此范围内无现金流。", + "tabs.stats.analytics.cashFlow.fromReserves": "从储备金", + "tabs.stats.analytics.cashFlow.loadFailed": "无法加载现金流。", + "tabs.stats.analytics.cashFlow.noMovement": "此范围内没有资金流动。", + "tabs.stats.analytics.down": "下降", + "tabs.stats.analytics.heatmap.less": "少", + "tabs.stats.analytics.heatmap.more": "多", + "tabs.stats.analytics.in": "流入", + "tabs.stats.analytics.inRange": "在 {} 内", + "tabs.stats.analytics.income": "收入", + "tabs.stats.analytics.map.empty": "此时间范围内没有已定位的支出。", + "tabs.stats.analytics.map.locatedCount": "{located} / {total} 笔支出有位置信息", + "tabs.stats.analytics.map.mappedShort": "已定位 · {days}天", + "tabs.stats.analytics.map.mappedSpend": "已定位支出", + "tabs.stats.analytics.map.noneYet": "尚无已定位的支出。", + "tabs.stats.analytics.map.pinnedLocation": "固定位置", + "tabs.stats.analytics.map.topPlaces": "热门地点", + "tabs.stats.analytics.map.visits": "{count} 次造访", + "tabs.stats.analytics.map.visits.one": "{count} 次造访", + "tabs.stats.analytics.missingRatesAmounts": "某些非主要货币的金额已被忽略(缺少汇率)。", + "tabs.stats.analytics.missingRatesBalances": "某些非主要货币的余额已被忽略(缺少汇率)。", + "tabs.stats.analytics.netWorth": "净资产", + "tabs.stats.analytics.netWorth.byAccount": "按账户", + "tabs.stats.analytics.netWorth.noAccounts": "没有账户可供汇总。", + "tabs.stats.analytics.netWorth.notEnoughHistory": "没有足够的历史数据来绘制趋势。", + "tabs.stats.analytics.noSpendingRange": "此范围内无支出。", + "tabs.stats.analytics.noSpendingWindow": "此时间范围内无支出。", + "tabs.stats.analytics.other": "其他", + "tabs.stats.analytics.out": "流出", + "tabs.stats.analytics.overspent": "超支", + "tabs.stats.analytics.pace": "进度", + "tabs.stats.analytics.pace.perDay": "每日平均", + "tabs.stats.analytics.pace.projected": "预估", + "tabs.stats.analytics.pace.totalSpent": "总支出", + "tabs.stats.analytics.recurring": "定期交易", + "tabs.stats.analytics.recurring.activeSummary": "{count} 个定期项目 · 未来 {days} 天", + "tabs.stats.analytics.recurring.committedOutflow": "已承诺支出", + "tabs.stats.analytics.recurring.committedShort": "已承诺 · {days}天", + "tabs.stats.analytics.recurring.defaultTitle": "定期交易", + "tabs.stats.analytics.recurring.moreNotShown": "+ {count} 项未显示", + "tabs.stats.analytics.recurring.none": "未设置任何定期交易。", + "tabs.stats.analytics.recurring.notLoggedYet": "此笔尚未被记录 — 为即将到来的预估。", + "tabs.stats.analytics.recurring.nothingDue": "未来 {days} 天内没有到期项目。", + "tabs.stats.analytics.recurring.nothingUpcoming": "近期无项目", + "tabs.stats.analytics.recurring.projectedTitle": "预估总额", + "tabs.stats.analytics.recurring.projectionsNote": "根据您的定期交易进行预估。点击已记录的项目以开启其明细。", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} 笔预定扣款", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} 笔预定扣款", + "tabs.stats.analytics.rhythm": "节奏", + "tabs.stats.analytics.saved": "已储蓄", + "tabs.stats.analytics.spending": "支出", + "tabs.stats.analytics.spendingCalendar": "支出日历", + "tabs.stats.analytics.spendingMap": "支出地图", + "tabs.stats.analytics.topCategories": "热门类别", + "tabs.stats.analytics.uncategorized": "未分类", + "tabs.stats.analytics.untitled": "未命名", + "tabs.stats.analytics.up": "上升", + "tabs.stats.analytics.wrapped": "回顾", + "tabs.stats.analytics.wrapped.biggest": "最大:{title} · {amount} · {date}", + "tabs.stats.analytics.wrapped.categorySubtitle": "{current}(本月) vs 典型值 {typical}", + "tabs.stats.analytics.wrapped.categoryTrend": "{name} 相较于你过去 3 个月的平均 {direction} {value}。", + "tabs.stats.analytics.wrapped.frequentEntry": "你最常记录的项目:{value}", + "tabs.stats.analytics.wrapped.label.category": "类别", + "tabs.stats.analytics.wrapped.label.frequent": "常见", + "tabs.stats.analytics.wrapped.label.shape": "形态", + "tabs.stats.analytics.wrapped.loggedTimes": "本月记录 {count} 次", + "tabs.stats.analytics.wrapped.medianPurchase": "你的中位数消费为 {value}。", + "tabs.stats.analytics.wrapped.noExpenses": "尚未记录支出。", + "tabs.stats.analytics.wrapped.noTransactions": "本月尚无交易。", + "tabs.stats.analytics.wrapped.spendMostOn": "你最多花在 {value}。", + "tabs.stats.analytics.wrapped.tileTeaser": "{count} 笔记录 · 最大 {amount}", + "tabs.stats.analytics.wrapped.tileTeaser.one": "{count} 笔记录 · 最大 {amount}", + "tabs.stats.analytics.wrapped.tileTeaserEmpty": "查看你的月度回顾", + "tabs.stats.analytics.wrapped.tileTitle": "你的 {month} 回顾", + "tabs.stats.analytics.wrapped.title": "{month} 回顾", + "tabs.stats.categories": "分类", + "tabs.stats.categories.seeAll": "查看所有分类", + "tabs.stats.categories.top": "最高支出", + "tabs.stats.chart.noData": "没有数据可显示", + "tabs.stats.chart.select.clickToSelect": "点击以选择", + "tabs.stats.chart.total": "总计", + "tabs.stats.insights": "分析", + "tabs.stats.intervalReport.averages.expense": "平均支出", + "tabs.stats.intervalReport.averages.flow": "平均总收支", + "tabs.stats.intervalReport.averages.income": "平均收入", + "tabs.stats.intervalReport.averages@day": "平均(每日)", + "tabs.stats.intervalReport.averages@hour": "平均(每小时)", + "tabs.stats.intervalReport.averages@month": "平均(每月)", + "tabs.stats.intervalReport.averages@week": "平均(每周)", + "tabs.stats.intervalReport.averages@year": "平均(每年)", + "tabs.stats.intervalReport.forecast": "{} 的支出预测", + "tabs.stats.intervalReport.totalExpense": "总支出 {}", + "tabs.stats.trends": "趋势", + "tabs.stats.trends.average": "平均支出", + "tabs.stats.trends.average.description": "所选时间范围内的平均支出金额", + "tabs.stats.trends.median": "中位数支出", + "tabs.stats.trends.median.description": "所选时间范围内的支出金额中位数", + "tabs.stats.trends.topSpendingTitles": "频繁交易", + "tabs.stats.trends.topSpendingTitles.description": "最常使用的交易标题", + "transaction": "交易", + "transaction.actions": "动作", + "transaction.attachments": "文件附件", + "transaction.attachments.warning": "附件将在您的备份中占用 {size} 的空间。如果您使用云端备份 (如 iCloud),将会增加空间使用量。", + "transaction.bulk.changeAccount": "变更账户", + "transaction.bulk.changeAccount.confirm": "变更 {} 笔交易的账户?", + "transaction.bulk.changeCategory": "变更分类", + "transaction.bulk.changeCategory.confirm": "变更 {} 笔交易的分类?", + "transaction.bulk.clear": "清除选择", + "transaction.bulk.confirmAll": "全部确认", + "transaction.bulk.confirmAll.confirm": "确认 {} 笔交易?", + "transaction.bulk.confirmed.success": "已确认 {} 笔交易", + "transaction.bulk.confirmed.success.one": "已确认 {} 笔交易", + "transaction.bulk.delete": "删除", + "transaction.bulk.delete.confirm": "删除 {} 笔交易?", + "transaction.bulk.deleted.success": "已将 {} 笔交易移至回收站", + "transaction.bulk.deleted.success.one": "已将 {} 笔交易移至回收站", + "transaction.bulk.disabled.currencies": "混合不同币种时无法使用", + "transaction.bulk.disabled.transfers": "选取转账项目时无法使用", + "transaction.bulk.recover": "恢复", + "transaction.bulk.recover.confirm": "恢复 {} 笔交易?", + "transaction.bulk.recovered.success": "已恢复 {} 笔交易", + "transaction.bulk.recovered.success.one": "已恢复 {} 笔交易", + "transaction.bulk.selectAll": "全选", + "transaction.bulk.selected": "已选择 {}", + "transaction.bulk.updated.success": "已更新 {} 笔交易", + "transaction.bulk.updated.success.one": "已更新 {} 笔交易", + "transaction.createdDate": "创建于", + "transaction.date": "交易日期", + "transaction.delete": "删除交易", + "transaction.deleted": "最近删除", + "transaction.description": "备注", + "transaction.description.add": "添加备注", + "transaction.description.markdownSupported": "支持 Markdown", + "transaction.description.placeholder": "关于这笔交易的详细信息...", + "transaction.description.preview": "预览", + "transaction.duplicate": "复制交易", + "transaction.duplicate.success": "已复制交易", + "transaction.edit": "编辑交易", + "transaction.edit.selectAccount": "选择账户", + "transaction.edit.selectAccount.multiple": "选择多个账户", + "transaction.edit.selectAccount.noPossibleChoice": "没有可选择的账户", + "transaction.edit.selectCategory": "选择分类", + "transaction.edit.selectCategory.multiple": "选择多个分类", + "transaction.external.added": "已新增一笔新交易", + "transaction.external.added.from": "由 {name} 新增了一笔交易", + "transaction.external.from": "从 {name} 新增", + "transaction.fallbackTitle": "未命名交易", + "transaction.location": "地点", + "transaction.location.add": "添加地点", + "transaction.location.edit": "点击地图进行编辑", + "transaction.moveToTrashBin": "移至回收站", + "transaction.moveToTrashBin.restore": "恢复交易", + "transaction.moveToTrashBin.restore.success": "交易已恢复", + "transaction.moveToTrashBin.success": "已移至回收站", + "transaction.new": "新增交易", + "transaction.pending": "待处理", + "transaction.pending.preapproved": "预先核准", + "transaction.recurring": "周期性交易", + "transaction.recurring.delete": "删除周期性交易", + "transaction.recurring.delete.deleteAllDisclaimer": "这将删除所有相关的交易,并停止创建新交易。即使从回收站恢复交易,也不会让它重新开始创建新交易。", + "transaction.recurring.edit": "编辑周期性交易", + "transaction.recurring.setup": "设置周期性交易", + "transaction.tags": "标签", + "transaction.tags.add": "添加标签", + "transaction.tags.contact.name": "联系人名称", + "transaction.tags.contact.select": "从手机选择联系人", + "transaction.tags.delete": "删除标签", + "transaction.tags.delete.description": "删除此标签将会移除 {transactionCount} 笔交易上的关联标签。此操作无法撤销!", + "transaction.tags.location.name": "地点名称", + "transaction.tags.location.useCurrent": "使用当前地点", + "transaction.tags.name": "标签名称", + "transaction.tags.new": "新增标签", + "transaction.tags.suggestionGuide": "点击建议的标签以新增", + "transaction.transfer.conversionRate": "汇率", + "transaction.transfer.from": "转出账户", + "transaction.transfer.from.select": "从此转出", + "transaction.transfer.from.title": "自 {account} 转出", + "transaction.transfer.fromToTitle": "从 {from} 转至 {to}", + "transaction.transfer.to": "转入账户", + "transaction.transfer.to.select": "转入至此", + "transaction.transfer.to.title": "转入至 {account}", + "transactionFilterPreset": "过滤预设", + "transactionFilterPreset.default": "默认选项", + "transactionFilterPreset.delete": "删除预设", + "transactionFilterPreset.invalid": "无效", + "transactionFilterPreset.invalid.description": "此预设中的某些账户/分类已丢失。请重新创建或删除该预设", + "transactionFilterPreset.makeDefault": "设为默认", + "transactionFilterPreset.saveAsNew": "另存为新预设", + "transactionFilterPreset.saveAsNew.guide": "要保存新的预设,请先变更过滤条件后再回到这里", + "transactionFilterPreset.saveAsNew.name": "预设名称", + "transactions.all": "所有交易", + "transactions.batch.assignAccountForAll": "为全部指派账户", + "transactions.batch.assignAccountIndividually": "个别指派账户", + "transactions.batch.import": "批量导入", + "transactions.batch.import.success": "成功导入 {n} 笔交易", + "transactions.batch.importN": "导入 {n} 笔交易", + "transactions.batch.review": "请审阅这些交易", + "transactions.count": "{} 笔交易", + "transactions.count.one": "{} 笔交易", + "transactions.pending": "待处理交易", + "transactions.query.clearAll": "清除过滤条件", + "transactions.query.clearSelection": "清除选择", + "transactions.query.filter.accounts": "账户", + "transactions.query.filter.accounts.all": "所有账户", + "transactions.query.filter.accounts.n": "{} 个账户", + "transactions.query.filter.categories": "分类", + "transactions.query.filter.categories.all": "所有分类", + "transactions.query.filter.categories.n": "{} 个分类", + "transactions.query.filter.currency": "币种", + "transactions.query.filter.groupBy": "分组依据", + "transactions.query.filter.hasAttachments": "附件", + "transactions.query.filter.hasAttachments#false": "没有附件", + "transactions.query.filter.hasAttachments#true": "有附件", + "transactions.query.filter.hasAttachments.all": "附件", + "transactions.query.filter.isPending": "待处理状态", + "transactions.query.filter.isPending#false": "非待处理", + "transactions.query.filter.isPending#true": "待处理", + "transactions.query.filter.isPending.all": "全部", + "transactions.query.filter.keyword": "搜索", + "transactions.query.filter.keyword.all": "搜索", + "transactions.query.filter.keyword.clear": "清除", + "transactions.query.filter.keyword.hint": "按标题搜索...", + "transactions.query.filter.keyword.includeDescription": "包含备注", + "transactions.query.filter.sort": "排序", + "transactions.query.filter.tags": "标签", + "transactions.query.filter.tags.all": "所有标签", + "transactions.query.filter.tags.n": "{} 个标签", + "transactions.query.filter.timeRange": "时间范围", + "transactions.query.filter.timeRange.all": "所有时间", + "transactions.query.filter.transactionType": "类型", + "transactions.query.noResult": "没有可显示的交易", + "transactions.query.noResult.description": "尝试更新您的过滤条件", + "visitGitHubRepo": "前往 GitHub 查看" +} diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 687adbb7..c7fa9dfa 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -759,8 +759,8 @@ "tabs.stats.analytics.recurring.nothingUpcoming": "近期無項目", "tabs.stats.analytics.recurring.projectedTitle": "預估總額", "tabs.stats.analytics.recurring.projectionsNote": "根據您的定期交易進行預估。點選已記錄的項目以開啟其明細。", - "tabs.stats.analytics.recurring.upcomingCharges": "{count} 預定扣款", - "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} 預定扣款", + "tabs.stats.analytics.recurring.upcomingCharges": "{count} 筆預定扣款", + "tabs.stats.analytics.recurring.upcomingCharges.one": "{count} 筆預定扣款", "tabs.stats.analytics.rhythm": "節奏", "tabs.stats.analytics.saved": "已儲蓄", "tabs.stats.analytics.spending": "支出", diff --git a/ios/Flow Widgets/BudgetPayload.swift b/ios/Flow Widgets/BudgetPayload.swift index dc467c6e..771396e7 100644 --- a/ios/Flow Widgets/BudgetPayload.swift +++ b/ios/Flow Widgets/BudgetPayload.swift @@ -124,8 +124,11 @@ struct BudgetItem: Codable, Identifiable { let percent: Int /// Pre-formatted percentage, e.g. "84%". Optional purely for decode safety. let percentLabel: String? - /// NOT clamped — an over-budget entry exceeds 1.0. + /// NOT clamped — an over-budget entry exceeds 1.0. Includes pending spend. let ratio: Double + /// The part of `ratio` that has actually cleared. Bars fill solid to here + /// and continue as a lighter "ghost" tail out to `ratio`. + let confirmedRatio: Double /// Drives colour and bar geometry. Never rendered — use `statusText`. let status: BudgetStatus /// May be absent (contract rule 6) — use `statusText`, never this directly. @@ -147,6 +150,7 @@ struct BudgetItem: Codable, Identifiable { percent: Int, percentLabel: String?, ratio: Double, + confirmedRatio: Double, status: BudgetStatus, statusLabel: String?, daysLeft: Int, @@ -164,6 +168,7 @@ struct BudgetItem: Codable, Identifiable { self.percent = percent self.percentLabel = percentLabel self.ratio = ratio + self.confirmedRatio = confirmedRatio self.status = status self.statusLabel = statusLabel self.daysLeft = daysLeft @@ -187,6 +192,9 @@ struct BudgetItem: Codable, Identifiable { percent = try container.decodeIfPresent(Int.self, forKey: .percent) ?? 0 percentLabel = try container.decodeIfPresent(String.self, forKey: .percentLabel) ratio = try container.decodeIfPresent(Double.self, forKey: .ratio) ?? 0 + // Falling back to `ratio` means "all of it cleared", so a payload that + // somehow omits this draws one solid bar rather than an all-ghost one. + confirmedRatio = try container.decodeIfPresent(Double.self, forKey: .confirmedRatio) ?? ratio status = try container.decodeIfPresent(BudgetStatus.self, forKey: .status) ?? .healthy statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) daysLeft = try container.decodeIfPresent(Int.self, forKey: .daysLeft) ?? 0 @@ -201,6 +209,13 @@ struct BudgetItem: Codable, Identifiable { return min(max(ratio, 0), 1) } + /// `clampedRatio`'s confirmed counterpart, never past it — a ghost tail + /// that ran backwards would render as a solid bar overhanging its own total. + var clampedConfirmedRatio: Double { + guard confirmedRatio.isFinite else { return 0 } + return min(max(confirmedRatio, 0), clampedRatio) + } + /// Never blank. Falls back to English only when the app omitted the label /// because its translations were not ready yet. var daysLeftText: String { @@ -325,7 +340,7 @@ enum BudgetPayloadStore { static let appGroupId = "group.mn.flow.flow" static let payloadKey = "budgetsPayload" /// Bump only together with `BudgetWidgetSync.payloadVersion` on the Dart side. - static let supportedVersion = 2 + static let supportedVersion = 3 /// One shared read for both widgets and for the budget picker's entity query. /// diff --git a/ios/Flow Widgets/BudgetPinnedWidget.swift b/ios/Flow Widgets/BudgetPinnedWidget.swift index 90f0197c..e49fc997 100644 --- a/ios/Flow Widgets/BudgetPinnedWidget.swift +++ b/ios/Flow Widgets/BudgetPinnedWidget.swift @@ -124,6 +124,7 @@ struct BudgetPinnedView: View { if usesRing { BudgetProgressRing( ratio: budget.clampedRatio, + confirmedRatio: budget.clampedConfirmedRatio, percentText: budget.percentText, color: tint ) @@ -133,7 +134,11 @@ struct BudgetPinnedView: View { // being legible, which is the point at which the bar is better. .frame(maxWidth: .infinity, minHeight: 36, maxHeight: .infinity) } else { - BudgetProgressBar(ratio: budget.clampedRatio, color: tint) + BudgetProgressBar( + ratio: budget.clampedRatio, + confirmedRatio: budget.clampedConfirmedRatio, + color: tint + ) } VStack(alignment: .leading, spacing: 1) { @@ -165,6 +170,7 @@ struct BudgetPinnedView: View { if usesRing { BudgetProgressRing( ratio: budget.clampedRatio, + confirmedRatio: budget.clampedConfirmedRatio, percentText: budget.percentText, color: tint, lineWidth: 11, @@ -184,7 +190,11 @@ struct BudgetPinnedView: View { } } if !usesRing { - BudgetProgressBar(ratio: budget.clampedRatio, color: tint) + BudgetProgressBar( + ratio: budget.clampedRatio, + confirmedRatio: budget.clampedConfirmedRatio, + color: tint + ) } BudgetStatusLabel( budget: budget, @@ -284,7 +294,7 @@ struct FlowBudgetPinnedWidget: Widget { } private let previewPinnedPayload = BudgetPayload( - version: 2, + version: 3, updatedAt: "2026-07-22T09:14:03.123Z", summary: BudgetSummary( budgetCount: 3, @@ -305,6 +315,7 @@ private let previewPinnedPayload = BudgetPayload( percent: 84, percentLabel: "84%", ratio: 0.84, + confirmedRatio: 0.66, status: .warning, statusLabel: "Хязгаарт дөхсөн", daysLeft: 9, diff --git a/ios/Flow Widgets/BudgetRollupWidget.swift b/ios/Flow Widgets/BudgetRollupWidget.swift index 77e5cf29..069fffe4 100644 --- a/ios/Flow Widgets/BudgetRollupWidget.swift +++ b/ios/Flow Widgets/BudgetRollupWidget.swift @@ -130,7 +130,11 @@ struct BudgetRollupView: View { .lineLimit(1) } - BudgetProgressBar(ratio: budget.clampedRatio, color: tint) + BudgetProgressBar( + ratio: budget.clampedRatio, + confirmedRatio: budget.clampedConfirmedRatio, + color: tint + ) HStack(spacing: 5) { // The word, not just the bar's colour — tinted rendering keeps @@ -190,7 +194,10 @@ struct FlowBudgetRollupWidget: Widget { ) { entry in BudgetRollupView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) - .widgetURL(URL(string: "flow-mn:///budgets")) + // The overview, not the plain list: this widget *is* the + // overview in miniature, so a tap should expand what it shows + // rather than drop you somewhere adjacent. + .widgetURL(URL(string: "flow-mn:///stats/budgets")) } .supportedFamilies([.systemMedium]) .configurationDisplayName("Budgets") @@ -204,7 +211,7 @@ struct FlowBudgetRollupWidget: Widget { BudgetRollupEntry( date: .now, payload: BudgetPayload( - version: 2, + version: 3, updatedAt: "2026-07-22T09:14:03.123Z", summary: BudgetSummary( budgetCount: 3, @@ -225,6 +232,7 @@ struct FlowBudgetRollupWidget: Widget { percent: 124, percentLabel: "124%", ratio: 1.24, + confirmedRatio: 1.24, status: .over, statusLabel: "Хязгаар хэтэрсэн", daysLeft: 9, @@ -243,6 +251,7 @@ struct FlowBudgetRollupWidget: Widget { percent: 84, percentLabel: "84%", ratio: 0.84, + confirmedRatio: 0.62, status: .warning, statusLabel: "Хязгаарт дөхсөн", daysLeft: 9, diff --git a/ios/Flow Widgets/BudgetWidgetStyle.swift b/ios/Flow Widgets/BudgetWidgetStyle.swift index 7177eaeb..0dd394e3 100644 --- a/ios/Flow Widgets/BudgetWidgetStyle.swift +++ b/ios/Flow Widgets/BudgetWidgetStyle.swift @@ -36,19 +36,44 @@ extension BudgetStatus { } /// Roll-up progress indicator. +/// +/// Draws to `ratio` faintly and to `confirmedRatio` solid, so pending spend +/// reads as committed-but-not-yet-gone. Passing them equal — the default — +/// gives the plain single-fill bar. struct BudgetProgressBar: View { let ratio: Double + var confirmedRatio: Double? = nil let color: Color var height: CGFloat = 8 + /// Anything non-zero gets at least a round dot; a hairline reads as an + /// empty bar, which means something else entirely. + private func width(_ fraction: Double, in available: CGFloat) -> CGFloat { + guard fraction > 0 else { return 0 } + return min(available, max(height, available * fraction)) + } + var body: some View { GeometryReader { geometry in + let total = width(ratio, in: geometry.size.width) + let confirmed = width(min(confirmedRatio ?? ratio, ratio), in: geometry.size.width) + ZStack(alignment: .leading) { Capsule() .fill(.fill.tertiary) - Capsule() - .fill(color) - .frame(width: max(height, geometry.size.width * ratio)) + // Full-length ghost with the solid fill layered over it, so the + // seam is a rounded cap inside a rounded cap rather than two + // capsules butting together. + if total > confirmed { + Capsule() + .fill(color.opacity(0.35)) + .frame(width: total) + } + if confirmed > 0 { + Capsule() + .fill(color) + .frame(width: confirmed) + } } } .frame(height: height) @@ -59,6 +84,8 @@ struct BudgetProgressBar: View { /// better than a hairline bar at that size. struct BudgetProgressRing: View { let ratio: Double + /// The cleared part of `ratio`. Nil draws one solid arc. + var confirmedRatio: Double? = nil let percentText: String let color: Color var lineWidth: CGFloat = 8 @@ -68,13 +95,25 @@ struct BudgetProgressRing: View { var fontSize: CGFloat = 19 var body: some View { + let confirmed = min(confirmedRatio ?? ratio, ratio) + ZStack { Circle() .stroke(.fill.tertiary, lineWidth: lineWidth) - Circle() - .trim(from: 0, to: max(0.005, ratio)) - .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .rotationEffect(.degrees(-90)) + // Ghost arc out to the committed total, solid arc over it to what + // has actually cleared. + if ratio > confirmed { + Circle() + .trim(from: 0, to: max(0.005, ratio)) + .stroke(color.opacity(0.35), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + if confirmed > 0 { + Circle() + .trim(from: 0, to: max(0.005, confirmed)) + .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } Text(percentText) .font(.system(size: fontSize, weight: .bold, design: .rounded)) .foregroundStyle(.primary) diff --git a/lib/data/budget_progress.dart b/lib/data/budget_progress.dart index 16d5aacf..a569f8c6 100644 --- a/lib/data/budget_progress.dart +++ b/lib/data/budget_progress.dart @@ -54,8 +54,21 @@ class BudgetProgress { final TimeRange range; /// Absolute spend within the period, in [Budget.currency]. + /// + /// Includes [pendingSpent]. Money you've scheduled is money the period is + /// already committed to, so it counts against the limit — every derived + /// figure here ([ratio], [status], [primaryInsight], …) is measured on the + /// committed total, not just what has cleared. final Money spent; + /// The still-pending slice of [spent] — planned or unconfirmed transactions + /// dated inside the period. + /// + /// Broken out so the UI can render it as a lighter "ghost" segment: it + /// counts, but it hasn't actually left the account yet and shouldn't look + /// like it has. + final Money pendingSpent; + /// The budgeted amount, in [Budget.currency]. final Money limit; @@ -70,6 +83,7 @@ class BudgetProgress { required this.budget, required this.range, required this.spent, + required this.pendingSpent, required this.limit, required this.asOf, this.hasMissingData = false, @@ -78,6 +92,18 @@ class BudgetProgress { /// The budget's currency, shared by [spent] and [limit]. String get currency => limit.currency; + /// The part of [spent] that has actually been confirmed. + Money get confirmedSpent => spent - pendingSpent; + + /// [confirmedSpent] / limit — where the solid part of a progress bar ends, + /// with [ratio] marking the end of the ghost segment. `0` when the limit is + /// non-positive. + double get confirmedRatio => + limit.amount > 0 ? (confirmedSpent.amount / limit.amount) : 0.0; + + /// Whether there is any pending spend worth drawing. + bool get hasPending => pendingSpent.amount > 0; + /// Spent / limit. `0` when the limit is non-positive. double get ratio => limit.amount > 0 ? (spent.amount / limit.amount) : 0.0; @@ -111,10 +137,18 @@ class BudgetProgress { /// Extrapolated end-of-period ratio if spending continues at the current /// rate. Falls back to [ratio] before any of the period has elapsed. + /// + /// Only [confirmedRatio] is run through the rate — pending spend is already + /// dated, so it's added once at face value instead. Extrapolating it would + /// count a lump sum twice: once for being committed, and again for every day + /// left in the period. Rent scheduled for the 28th would otherwise have a + /// budget projecting a 5× overshoot on the 2nd with nothing actually spent. double get projectedRatio { final double elapsed = periodElapsed; if (elapsed <= 0.0) return ratio; - return ratio / elapsed; + + final double pendingRatio = ratio - confirmedRatio; + return (confirmedRatio / elapsed) + pendingRatio; } BudgetStatus get status { diff --git a/lib/data/budget_spec.dart b/lib/data/budget_spec.dart index a425779f..a7a2f520 100644 --- a/lib/data/budget_spec.dart +++ b/lib/data/budget_spec.dart @@ -39,9 +39,13 @@ class BudgetSpec { class BudgetSpend { final int correlationId; - /// Absolute spend over the period, in the spec's currency. + /// Absolute spend over the period, in the spec's currency. Includes + /// [pendingSpent]. final double spent; + /// The still-pending slice of [spent]. + final double pendingSpent; + /// A foreign-currency transaction couldn't be converted, so [spent] is an /// undercount. final bool hasMissingData; @@ -49,6 +53,7 @@ class BudgetSpend { const BudgetSpend({ required this.correlationId, required this.spent, + required this.pendingSpent, required this.hasMissingData, }); } diff --git a/lib/entity/transaction/wrapper.dart b/lib/entity/transaction/wrapper.dart index 0fc6fd31..8ffc71ba 100644 --- a/lib/entity/transaction/wrapper.dart +++ b/lib/entity/transaction/wrapper.dart @@ -90,7 +90,7 @@ class ExtensionsWrapper { .toList(), ); } catch (e) { - _log.warning("An error occured during deserializing: $e"); + _log.warning("An error occurred during deserializing: $e"); return const ExtensionsWrapper.empty(); } } diff --git a/lib/l10n/supported_languages.dart b/lib/l10n/supported_languages.dart index 8383ac8b..7790a118 100644 --- a/lib/l10n/supported_languages.dart +++ b/lib/l10n/supported_languages.dart @@ -19,5 +19,6 @@ final Map supportedLanguages = { const Locale("uk", "UA"): ("Ukrainian (Ukraine)", "Українська (Україна)"), const Locale("ar"): ("Arabic", "العربية"), const Locale("fa", "IR"): ("Persian (Iran)", "فارسی (ایران)"), + const Locale("zh", "CN"): ("Chinese (Simplified, China Mainland)", "简体中文 (中国大陆)"), const Locale("zh", "TW"): ("Chinese (Traditional, Taiwan)", "正體中文 (台灣)"), }; diff --git a/lib/main.dart b/lib/main.dart index fa9dd807..927d3d0b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -550,7 +550,7 @@ void initializePackageVersion() async { startupLog.fine("App version: $appVersion"); startupLog.fine("Store: ${value.installerStore}"); } catch (e) { - startupLog.warning("An error was occured while fetching app version", e); + startupLog.warning("An error was occurred while fetching app version", e); } } diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index cb7a9101..3953de0d 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -714,6 +714,8 @@ extension TransactionListActions on Iterable { where((transaction) => transaction.amount > 0); Iterable get nonPending => where((transaction) => transaction.isPending != true); + Iterable get pending => + where((transaction) => transaction.isPending == true); Iterable get nonDeleted => where((transaction) => transaction.isDeleted != true); diff --git a/lib/routes/budget_detail_page.dart b/lib/routes/budget_detail_page.dart index 860eba4d..9be42688 100644 --- a/lib/routes/budget_detail_page.dart +++ b/lib/routes/budget_detail_page.dart @@ -126,7 +126,10 @@ class _BudgetDetailPageState extends State selectionController: _selection, mainHeader: header, mainHeaderPadding: EdgeInsets.zero, - transactions: transactions.nonPending.groupByDate(), + // Pending transactions count towards the total, so they + // belong in the list that explains it. The list marks + // them as pending on its own. + transactions: transactions.groupByDate(), headerBuilder: (pendingGroup, range, transactions) => TransactionListDateHeader( transactions: transactions, @@ -169,7 +172,9 @@ class _BudgetDetailPageState extends State child: Text( "budget.detail.unavailable".t(context), textAlign: .center, - style: context.textTheme.bodyMedium?.semi(context), + style: context.textTheme.bodyMedium?.semi( + context, + ), ), ), ) @@ -248,6 +253,8 @@ class _BudgetDetailPageState extends State BulletChart( value: progress.spent.amount, target: progress.limit.amount, + pending: progress.pendingSpent.amount, + paceRatio: progress.isCurrent ? progress.periodElapsed : null, height: 14.0, ), const SizedBox(height: 12.0), diff --git a/lib/routes/budgets_page.dart b/lib/routes/budgets_page.dart index 6edeea3d..776fbb26 100644 --- a/lib/routes/budgets_page.dart +++ b/lib/routes/budgets_page.dart @@ -5,6 +5,7 @@ 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/button.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/rates_missing_error_box.dart"; import "package:flutter/material.dart"; @@ -43,10 +44,16 @@ class _BudgetsPageState extends State { return ListView( children: [ - ListTile( - title: Text("budgets.new".t(context)), - leading: const Icon(Symbols.add_rounded), - onTap: () => context.push("/budgets/new"), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 6.0, + ), + child: Button( + onTap: () => context.push("/budgets/new"), + leading: const Icon(Symbols.add_rounded), + child: Text("budgets.new".t(context)), + ), ), if (showMissingExchangeRatesWarning) const RatesMissingErrorBox(), diff --git a/lib/routes/preferences/button_order_preferences_page.dart b/lib/routes/preferences/button_order_preferences_page.dart index 200f702a..934e6590 100644 --- a/lib/routes/preferences/button_order_preferences_page.dart +++ b/lib/routes/preferences/button_order_preferences_page.dart @@ -280,7 +280,7 @@ class ButtonOrderPreferencesPageState UserPreferencesService().transactionButtonOrder = copiedOrder; } catch (e) { - log("An error was occured while swapping transaction button order: $e"); + log("An error was occurred while swapping transaction button order: $e"); } finally { busy = false; diff --git a/lib/services/budget.dart b/lib/services/budget.dart index b8fc6c8c..ee07f559 100644 --- a/lib/services/budget.dart +++ b/lib/services/budget.dart @@ -111,8 +111,11 @@ class BudgetService { ).queryBuilder(); } - /// Sums [transactions] into [budget]'s currency. Pending transactions - /// don't count towards the budget. + /// Sums [transactions] into [budget]'s currency, **pending included**. + /// + /// A scheduled rent payment dated inside the period is money the period is + /// already committed to, so it counts. [computePendingSpent] recovers the + /// pending slice on its own for the UI's ghost segment. /// /// [SingleCurrencyFlow.hasMissingData] is set when a foreign-currency /// transaction couldn't be converted due to missing [rates]. @@ -121,7 +124,16 @@ class BudgetService { Iterable transactions, ExchangeRates? rates, ) { - return transactions.nonPending.flow.merge(budget.currency, rates); + return transactions.flow.merge(budget.currency, rates); + } + + /// The pending-only slice of [computeSpent], in [budget]'s currency. + SingleCurrencyFlow computePendingSpent( + Budget budget, + Iterable transactions, + ExchangeRates? rates, + ) { + return transactions.pending.flow.merge(budget.currency, rates); } /// A computed [BudgetProgress] for [budget] over [range], defaulting to its @@ -158,11 +170,20 @@ class BudgetService { } final SingleCurrencyFlow spentFlow = computeSpent(budget, txns, rates); + final SingleCurrencyFlow pendingFlow = computePendingSpent( + budget, + txns, + rates, + ); return BudgetProgress( budget: budget, range: period, spent: Money(spentFlow.totalExpense.amount.abs(), budget.currency), + pendingSpent: Money( + pendingFlow.totalExpense.amount.abs(), + budget.currency, + ), limit: Money(budget.amount, budget.currency), asOf: now, hasMissingData: spentFlow.hasMissingData, @@ -283,6 +304,7 @@ class BudgetService { budget: budget, range: currentPeriod(budget, asOf: now), spent: Money(spend.spent, budget.currency), + pendingSpent: Money(spend.pendingSpent, budget.currency), limit: Money(budget.amount, budget.currency), asOf: now, hasMissingData: spend.hasMissingData, @@ -455,6 +477,7 @@ class BudgetService { budget: budget, range: periods[i], spent: Money(spend.spent, budget.currency), + pendingSpent: Money(spend.pendingSpent, budget.currency), limit: Money(budget.amount, budget.currency), asOf: now, hasMissingData: spend.hasMissingData, @@ -570,7 +593,11 @@ BudgetSpend _spendFor(BudgetSpec spec, ExchangeRates? rates) { final List transactions = query.find(); query.close(); - final SingleCurrencyFlow spentFlow = transactions.nonPending.flow.merge( + final SingleCurrencyFlow spentFlow = transactions.flow.merge( + spec.currency, + rates, + ); + final SingleCurrencyFlow pendingFlow = transactions.pending.flow.merge( spec.currency, rates, ); @@ -578,6 +605,7 @@ BudgetSpend _spendFor(BudgetSpec spec, ExchangeRates? rates) { return BudgetSpend( correlationId: spec.correlationId, spent: spentFlow.totalExpense.amount.abs(), + pendingSpent: pendingFlow.totalExpense.amount.abs(), hasMissingData: spentFlow.hasMissingData, ); } diff --git a/lib/services/budget_widget_sync.dart b/lib/services/budget_widget_sync.dart index 53a2c625..20321b5e 100644 --- a/lib/services/budget_widget_sync.dart +++ b/lib/services/budget_widget_sync.dart @@ -49,7 +49,11 @@ class BudgetWidgetSync { /// - 2: added `uuid` to each budget. A v1 extension pins by ObjectBox id, /// which does not survive backup/restore — better it show a placeholder /// than confidently render the wrong budget. - static const int payloadVersion = 2; + /// - 3: `spent`/`ratio` now include pending spend, and `confirmedRatio` + /// marks how much of it has actually cleared so a bar can draw the rest as + /// a ghost. A v2 extension would draw the new, larger `spent` as though all + /// of it had cleared. + static const int payloadVersion = 3; /// Builds the payload without touching any platform channel. /// @@ -146,6 +150,11 @@ class BudgetWidgetSync { // render digits in one style beside payload strings in another. "percentLabel": _formatPercent(progress.percent), "ratio": progress.ratio, + // Where the solid fill stops and the ghost tail begins. Sent as a ratio + // rather than an amount because it only ever drives bar geometry — the + // widgets have no room to spell a second figure out, and a money string + // nothing renders is a "Hide amounts" leak waiting to be introduced. + "confirmedRatio": progress.confirmedRatio, "status": progress.status.name, // The status as a word. `status` alone is a machine value, and colour // can't carry it: iOS 18 tinted rendering flattens every hue to one, and diff --git a/lib/services/navigation.dart b/lib/services/navigation.dart index f5a2ff2b..5615a95e 100644 --- a/lib/services/navigation.dart +++ b/lib/services/navigation.dart @@ -67,12 +67,14 @@ class NavigationService { return; } - if (uri.pathSegments.join("/") == "transaction/new") { + final String path = uri.pathSegments.join("/"); + + if (path == "transaction/new") { NavigationService().add("/transaction/new?${uri.query}"); return; } - if (uri.pathSegments.join("/") == "integrate/eny") { + if (path == "integrate/eny") { if (uri.queryParameters["apiKey"] case String candidate when candidate.startsWith("eny")) { NavigationService().add("/integrate/eny?${uri.query}"); @@ -81,5 +83,28 @@ class NavigationService { } return; } + + // Budget home-screen widgets: the rollup opens the overview it mirrors, + // the pinned one opens the budget it is showing. + if (path == "budgets" || path == "stats/budgets") { + NavigationService().add("/$path"); + return; + } + + final List segments = uri.pathSegments; + if (segments.length == 2 && + segments.first == "budgets" && + int.tryParse(segments[1]) != null) { + // An id that no longer resolves — restored backup, deleted budget — is + // sent to the list by the router's `_budgetExistsOrList` redirect rather + // than landing on a dead page. + NavigationService().add("/budgets/${segments[1]}"); + return; + } + + // Deliberately an allowlist, not a catch-all forward: a custom URL scheme + // is claimable by anything on the device, so an arbitrary path from one + // would be an open redirect into any route in the app. + _log.warning("No route matches app link URI: $uri"); } } diff --git a/lib/widgets/analytics/bullet_chart.dart b/lib/widgets/analytics/bullet_chart.dart index ec632385..b1dbd6aa 100644 --- a/lib/widgets/analytics/bullet_chart.dart +++ b/lib/widgets/analytics/bullet_chart.dart @@ -5,13 +5,30 @@ import "package:flutter/material.dart"; /// A compact bullet chart for budget-vs-actual style comparisons. /// -/// Draws a horizontal track with a qualitative band up to [target], a measure -/// bar for [value], and a target tick at [target]. The recommended encoding -/// for a single KPI against a goal on a dense screen. +/// The track spans exactly `0 → [target]`, so the filled proportion always +/// agrees with the percentage shown next to it. A [value] past [target] fills +/// the track and recolors rather than rescaling it — an overrun is a state to +/// read off the color, not a longer bar. +/// +/// Two optional layers sit on top: +/// * [pending] draws the not-yet-confirmed slice of [value] as a lighter +/// "ghost" tail, so committed-but-uncleared money is visible without looking +/// like it has already left the account. +/// * [paceRatio] draws a reference tick — for a budget, how much of the period +/// has elapsed. Fill past the tick means spending faster than time is +/// passing. class BulletChart extends StatelessWidget { final double value; final double target; + /// The portion of [value] that is still pending. Drawn as a ghost tail + /// between the confirmed fill and [value]. + final double pending; + + /// Where to put the reference tick along the track, `0..1`. Null — or either + /// extreme, where it carries nothing — hides it. + final double? paceRatio; + /// Bar color; defaults to a sensible "over/under target" choice. final Color? barColor; @@ -21,28 +38,41 @@ class BulletChart extends StatelessWidget { super.key, required this.value, required this.target, + this.pending = 0.0, + this.paceRatio, this.barColor, this.height = 16.0, }); @override Widget build(BuildContext context) { - // Always leave headroom past whichever is larger so the bar/tick never - // pin to the very edge. - final double max = math.max(math.max(value, target), 1.0) * 1.1; final bool over = value > target; final Color bar = barColor ?? (over ? context.flowColors.expense : context.flowColors.income); final Color track = context.colorScheme.onSurface.withAlpha(0x1f); - final Color band = context.colorScheme.onSurface.withAlpha(0x14); + + final double confirmed = math.max(0.0, value - pending); return LayoutBuilder( builder: (context, constraints) { final double width = constraints.maxWidth; - final double valueWidth = (value / max).clamp(0.0, 1.0) * width; - final double targetX = (target / max).clamp(0.0, 1.0) * width; + + // A sliver a fraction of a pixel wide reads as "nothing spent", which + // is a different thing entirely — give anything non-zero at least a + // dot, the way the home-screen widget bars do. + double bandWidth(double amount) { + if (amount <= 0.0 || target <= 0.0) return 0.0; + final double raw = (amount / target).clamp(0.0, 1.0) * width; + return math.min(width, math.max(raw, height)); + } + + final double totalWidth = bandWidth(value); + final double confirmedWidth = bandWidth(confirmed); + + final double? pace = paceRatio; + final bool showPace = pace != null && pace > 0.0 && pace < 1.0; return SizedBox( height: height, @@ -58,45 +88,63 @@ class BulletChart extends StatelessWidget { ), ), ), - // Qualitative band up to the target. - Positioned( - left: 0.0, - top: 0.0, - bottom: 0.0, - child: Container( - width: targetX, - decoration: BoxDecoration( - color: band, - borderRadius: BorderRadius.all(Radius.circular(height / 2)), + // Ghost. Drawn as the *whole* bar rather than just the tail, so + // the confirmed fill can land on top of it — that way the seam + // between the two is a rounded cap nested inside a rounded cap, + // instead of two pills butting into each other. + if (totalWidth > confirmedWidth) + Positioned( + left: 0.0, + top: 0.0, + bottom: 0.0, + child: Container( + width: totalWidth, + decoration: BoxDecoration( + color: bar.withAlpha(0x59), + borderRadius: BorderRadius.all( + Radius.circular(height / 2), + ), + ), ), ), - ), - // Measure bar, inset vertically so the track reads behind it. - Positioned( - left: 0.0, - top: height * 0.28, - bottom: height * 0.28, - child: Container( - width: valueWidth, - decoration: BoxDecoration( - color: bar, - borderRadius: BorderRadius.all(Radius.circular(height / 2)), + // Full track height, so it nests inside the track's rounded caps + // instead of sitting flush against them. + if (confirmedWidth > 0.0) + Positioned( + left: 0.0, + top: 0.0, + bottom: 0.0, + child: Container( + width: confirmedWidth, + decoration: BoxDecoration( + color: bar, + borderRadius: BorderRadius.all( + Radius.circular(height / 2), + ), + ), ), ), - ), - // Target tick. - Positioned( - left: math.max(0.0, targetX - 1.0), - top: -1.0, - bottom: -1.0, - child: Container( - width: 2.5, - decoration: BoxDecoration( - color: context.colorScheme.primary, - borderRadius: const BorderRadius.all(Radius.circular(2.0)), + // Pace tick. Neutral rather than `primary`: `primary` follows the + // user's accent color, and on a green accent that made this the + // same hue as a healthy bar. + if (showPace) + Positioned( + left: math.max( + 0.0, + math.min(width - 2.0, pace * width - 1.0), + ), + top: 0.0, + bottom: 0.0, + child: Container( + width: 2.0, + decoration: BoxDecoration( + color: context.colorScheme.onSurface.withAlpha(0x8a), + borderRadius: const BorderRadius.all( + Radius.circular(1.0), + ), + ), ), ), - ), ], ), ); diff --git a/lib/widgets/budgets/budget_card.dart b/lib/widgets/budgets/budget_card.dart index c5febb47..73b021c7 100644 --- a/lib/widgets/budgets/budget_card.dart +++ b/lib/widgets/budgets/budget_card.dart @@ -1,6 +1,5 @@ +import "package:flow/data/budget_progress.dart"; import "package:flow/data/exchange_rates.dart"; -import "package:flow/data/single_currency_flow.dart"; -import "package:flow/data/money.dart"; import "package:flow/entity/budget.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/l10n/flow_localizations.dart"; @@ -35,23 +34,23 @@ class BudgetCard extends StatelessWidget { .watch(triggerImmediately: true) .map((event) => event.find()), builder: (context, snapshot) { - final SingleCurrencyFlow spentFlow = BudgetService().computeSpent( + // The stream already holds this period's transactions, so this reuses + // them rather than letting `computeProgress` run its own query. + final BudgetProgress progress = BudgetService().computeProgress( budget, - snapshot.data ?? const [], - rates, + rates: rates, + transactions: snapshot.data ?? const [], ); - 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)), + MoneyText(progress.spent), const Text(" / "), - MoneyText(Money(budget.amount, budget.currency)), + MoneyText(progress.limit), ], ), subtitle: _periodLabel(), @@ -65,7 +64,12 @@ class BudgetCard extends StatelessWidget { allSpendingLabel: "budget.categories.allShort".t(context), ), const SizedBox(height: 12.0), - BulletChart(value: spent, target: budget.amount), + BulletChart( + value: progress.spent.amount, + target: progress.limit.amount, + pending: progress.pendingSpent.amount, + paceRatio: progress.isCurrent ? progress.periodElapsed : null, + ), ], ), ); diff --git a/lib/widgets/home/stats/bento/budget_tile.dart b/lib/widgets/home/stats/bento/budget_tile.dart index 13948e6a..41bb437c 100644 --- a/lib/widgets/home/stats/bento/budget_tile.dart +++ b/lib/widgets/home/stats/bento/budget_tile.dart @@ -118,6 +118,8 @@ class _BudgetTileState extends State BulletChart( value: worst.spent.amount, target: worst.limit.amount, + pending: worst.pendingSpent.amount, + paceRatio: worst.isCurrent ? worst.periodElapsed : null, height: 12.0, ), const SizedBox(height: 8.0), diff --git a/pubspec.lock b/pubspec.lock index 7a09d3f2..1e31cd06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1490,10 +1490,10 @@ packages: dependency: "direct main" description: name: pie_menu - sha256: "7da24ee13b51f7ab5a7f8f59bf32f47fb716bcae1009fcd8a4a941962b856926" + sha256: "5d935590a2534d70202d1b47528467ea919089b2beeb775537eb39484b8adf27" url: "https://pub.dev" source: hosted - version: "3.7.0" + version: "3.8.3" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 11207372..42e43146 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.24.0+354" +version: "0.24.0+355" environment: sdk: ">=3.10.0 <4.0.0" @@ -68,7 +68,7 @@ dependencies: path_provider: ^2.1.5 pdf: ^3.12.0 permission_handler: ^12.0.1 - pie_menu: ^3.6.0 + pie_menu: ^3.8.3 recurrence: ^0.3.0 shake: ^3.0.0 share_plus: ^12.0.1 diff --git a/scripts/translate_missing.dart b/scripts/translate_missing.dart index 6cef413d..7bb34055 100644 --- a/scripts/translate_missing.dart +++ b/scripts/translate_missing.dart @@ -56,6 +56,7 @@ final Map filenameToTargetLanguageMapping = { "ru_RU.json": "Russian (Russia)", "tr_TR.json": "Turkish (Turkey)", "uk_UA.json": "Ukrainian (Ukraine)", + "zh_CN.json": "Simplified Chinese (China)", "zh_TW.json": "Traditional Chinese (Taiwan)", }; diff --git a/test/unit/budget_isolate_test.dart b/test/unit/budget_isolate_test.dart index 7f4d9210..192529a1 100644 --- a/test/unit/budget_isolate_test.dart +++ b/test/unit/budget_isolate_test.dart @@ -178,7 +178,7 @@ void main() { ); }); - test("pending transactions do not count", () async { + test("pending transactions count, and stay separable", () async { final Account account = makeAccount(); spend(account, 20.0); @@ -197,10 +197,31 @@ void main() { final List progresses = await BudgetService() .computeAllProgressAsync(); - expect( - forName(progresses, "Everything").spent.amount, - moreOrLessEquals(20.0), - ); + final BudgetProgress progress = forName(progresses, "Everything"); + + // Scheduled spending is spending the period is already committed to, so it + // counts against the limit... + expect(progress.spent.amount, moreOrLessEquals(520.0)); + expect(progress.pendingSpent.amount, moreOrLessEquals(500.0)); + // ...while staying recoverable, so the bar can draw it as a ghost tail + // rather than as money that has already gone. + expect(progress.confirmedSpent.amount, moreOrLessEquals(20.0)); + }); + + test("a budget with nothing pending reports none", () async { + final Account account = makeAccount(); + spend(account, 20.0); + + makeBudget(name: "Nothing pending"); + + final List progresses = await BudgetService() + .computeAllProgressAsync(); + + final BudgetProgress progress = forName(progresses, "Nothing pending"); + + expect(progress.spent.amount, moreOrLessEquals(20.0)); + expect(progress.pendingSpent.amount, moreOrLessEquals(0.0)); + expect(progress.hasPending, isFalse); }); test( diff --git a/test/unit/budget_progress_test.dart b/test/unit/budget_progress_test.dart index 14d88715..d4b2e66a 100644 --- a/test/unit/budget_progress_test.dart +++ b/test/unit/budget_progress_test.dart @@ -26,6 +26,7 @@ void main() { BudgetProgress progressOf({ required double spent, + double pending = 0.0, double limit = 100.0, DateTime? asOf, TimeRange? range, @@ -34,6 +35,7 @@ void main() { budget: budgetOf(amount: limit), range: range ?? june, spent: Money(spent, "USD"), + pendingSpent: Money(pending, "USD"), limit: Money(limit, "USD"), asOf: asOf ?? DateTime(2026, 6, 21), hasMissingData: hasMissingData, @@ -69,6 +71,81 @@ void main() { }); }); + group("the pending split", () { + test("pending is part of spent, not something added to it", () { + final BudgetProgress p = progressOf(spent: 60.0, pending: 25.0); + + expect(p.spent.amount, moreOrLessEquals(60.0)); + expect(p.confirmedSpent.amount, moreOrLessEquals(35.0)); + expect(p.ratio, moreOrLessEquals(0.6)); + expect(p.confirmedRatio, moreOrLessEquals(0.35)); + }); + + test("a scheduled payment can be what tips a budget over", () { + // The whole point of counting pending: 80 spent with 30 more already + // committed is not a healthy budget, however little has cleared. + final BudgetProgress p = progressOf(spent: 110.0, pending: 30.0); + + expect(p.status, BudgetStatus.over); + expect(p.primaryInsight, BudgetInsightType.over); + expect(p.confirmedSpent.amount, moreOrLessEquals(80.0)); + }); + + test("no pending leaves the confirmed figures identical to spent", () { + final BudgetProgress p = progressOf(spent: 84.0); + + expect(p.hasPending, isFalse); + expect(p.confirmedSpent.amount, moreOrLessEquals(p.spent.amount)); + expect(p.confirmedRatio, moreOrLessEquals(p.ratio)); + }); + + test("an all-pending budget has nothing confirmed to draw", () { + final BudgetProgress p = progressOf(spent: 40.0, pending: 40.0); + + expect(p.hasPending, isTrue); + expect(p.confirmedSpent.amount, moreOrLessEquals(0.0)); + expect(p.confirmedRatio, moreOrLessEquals(0.0)); + }); + + test("a scheduled lump sum is not extrapolated as a run rate", () { + // 2 of 30 days elapsed, nothing actually spent, one 30.0 payment already + // scheduled for later in the month. Running that through the rate would + // project a 4.5x overshoot and cry "overpacing" about a healthy budget. + final BudgetProgress p = progressOf( + spent: 30.0, + pending: 30.0, + asOf: DateTime(2026, 6, 3), + ); + + expect(p.projectedRatio, moreOrLessEquals(0.3)); + expect(p.pace, BudgetPace.under); + expect(p.primaryInsight, isNot(BudgetInsightType.overpacing)); + }); + + test("confirmed spend is still extrapolated normally", () { + // Same period position, but the 30.0 actually cleared — two days in and + // already 30% down really is on course to overshoot. + final BudgetProgress p = progressOf( + spent: 30.0, + asOf: DateTime(2026, 6, 3), + ); + + expect(p.projectedRatio, greaterThan(1.0)); + expect(p.pace, BudgetPace.over); + }); + + test("a non-positive limit zeroes confirmedRatio too, not just ratio", () { + final BudgetProgress p = progressOf( + spent: 50.0, + pending: 20.0, + limit: 0.0, + ); + + expect(p.ratio, 0.0); + expect(p.confirmedRatio, 0.0); + }); + }); + group("status thresholds", () { test("healthy below the warning threshold", () { expect(progressOf(spent: 89.0).status, BudgetStatus.healthy); diff --git a/test/unit/budget_widget_payload_test.dart b/test/unit/budget_widget_payload_test.dart index 699f28c3..b2c0e803 100644 --- a/test/unit/budget_widget_payload_test.dart +++ b/test/unit/budget_widget_payload_test.dart @@ -72,12 +72,13 @@ void main() { return account; } - void spend(Account account, double amount) { + void spend(Account account, double amount, {bool pending = false}) { final Transaction transaction = Transaction( amount: -amount.abs(), currency: "USD", uuid: const Uuid().v4(), transactionDate: insideThisMonth, + isPending: pending ? true : null, ); transaction.account.target = account; obx.box().put(transaction); @@ -109,7 +110,7 @@ void main() { final Map payload = await BudgetWidgetSync.buildPayload(); expect(payload["version"], BudgetWidgetSync.payloadVersion); - expect(payload["version"], 2); + expect(payload["version"], 3); }); test("no budgets yields an empty list, not a missing key", () async { @@ -145,6 +146,7 @@ void main() { expect(budget["percentLabel"], isA()); expect(budget["percentLabel"], isNotEmpty); expect(budget["ratio"], isA()); + expect(budget["confirmedRatio"], isA()); expect(budget["status"], "healthy"); expect(budget["daysLeft"], isA()); // May be absent when translations aren't loaded — see the labels test. @@ -168,12 +170,36 @@ void main() { // Everything the amount-free rendering draws from, none of it monetary. expect(budget["percent"], 92); expect(budget["ratio"], moreOrLessEquals(0.92)); + // The ghost tail is geometry, so it survives "Hide amounts" too. + expect(budget["confirmedRatio"], moreOrLessEquals(0.92)); expect(budget["status"], "warning"); expect(budget["periodLabel"], isNotEmpty); expect(budget["name"], isNotEmpty); }, ); + test("pending spend ships as a ghost split, not as a second total", () async { + makeBudget("Groceries", 100.0); + final Account account = makeAccount(); + spend(account, 30.0); + spend(account, 20.0, pending: true); + + final Map payload = await BudgetWidgetSync.buildPayload(); + + final Map budget = + (payload["budgets"] as List).single as Map; + + // `ratio` is the committed total — that is what the percentage says, and + // what the widget's bar has to reach. + expect(budget["ratio"], moreOrLessEquals(0.5)); + expect(budget["percent"], 50); + // `confirmedRatio` is where the solid fill stops and the ghost starts. + expect(budget["confirmedRatio"], moreOrLessEquals(0.3)); + // Deliberately no money field for the pending slice: nothing renders it, and + // an unrendered amount is a "Hide amounts" leak waiting to happen. + expect(budget.containsKey("pendingSpent"), isFalse); + }); + test("ratio is left unclamped so an overrun is representable", () async { makeBudget("Groceries", 100.0); spend(makeAccount(), 150.0); diff --git a/test/widget/bullet_chart_test.dart b/test/widget/bullet_chart_test.dart new file mode 100644 index 00000000..3dedd7c6 --- /dev/null +++ b/test/widget/bullet_chart_test.dart @@ -0,0 +1,161 @@ +import "package:flow/theme/flow_custom_colors.dart"; +import "package:flow/widgets/analytics/bullet_chart.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +/// Geometry guards for the budget progress bar. +/// +/// Every number a budget shows the user is a label *except* this bar, so a +/// scale bug here is invisible to every other test in the suite — it reads as a +/// bar that simply looks a bit off, which is exactly how the old `* 1.1` +/// headroom survived: it drew 9% spend at 8.2% of the track and pinned the +/// limit tick at a constant 90.9% for every under-budget budget. +void main() { + const double trackWidth = 200.0; + const double barHeight = 16.0; + + Widget wrap(Widget child) => MaterialApp( + theme: ThemeData( + extensions: const [ + FlowCustomColors( + income: Color(0xFF32CC70), + expense: Color(0xFFC42525), + semi: Color(0xFF888888), + ), + ], + ), + home: Scaffold( + body: Center( + child: SizedBox(width: trackWidth, height: barHeight, child: child), + ), + ), + ); + + /// Track first, then ghost, fill and pace tick in Stack order. `Container` + /// renders its decoration through a `DecoratedBox`, so every layer shows up + /// here — and only the layers actually drawn do. + List layers(WidgetTester tester) => tester + .widgetList(find.byType(DecoratedBox)) + .map((box) => tester.getRect(find.byWidget(box))) + .toList(); + + testWidgets("the fill is as tall as the track it sits in", (tester) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 50.0, target: 100.0)), + ); + + final List rects = layers(tester); + expect(rects, hasLength(2)); + + final Rect track = rects[0]; + final Rect fill = rects[1]; + + // The old inset was `height * 0.28` top and bottom with no left inset, so + // the fill read as a short bar shoved against the track's rounded cap. + expect(fill.height, track.height); + expect(fill.top, track.top); + expect(fill.left, track.left); + }); + + testWidgets("the fill width is the percentage, with no headroom", ( + tester, + ) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 50.0, target: 100.0)), + ); + + final List rects = layers(tester); + expect(rects[1].width, moreOrLessEquals(trackWidth / 2)); + }); + + testWidgets("an overrun fills the track rather than rescaling it", ( + tester, + ) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 150.0, target: 100.0)), + ); + + final List rects = layers(tester); + expect(rects[1].width, moreOrLessEquals(trackWidth)); + }); + + testWidgets("a tiny non-zero spend still draws at least a dot", ( + tester, + ) async { + await tester.pumpWidget(wrap(const BulletChart(value: 0.4, target: 100.0))); + + // 0.4% of 200px is under a pixel — a bar that thin reads as "nothing + // spent", which is a different thing entirely. + expect(layers(tester)[1].width, barHeight); + }); + + testWidgets("nothing spent draws no fill at all", (tester) async { + await tester.pumpWidget(wrap(const BulletChart(value: 0.0, target: 100.0))); + + expect(layers(tester), hasLength(1)); + }); + + testWidgets("pending draws a ghost tail past the confirmed fill", ( + tester, + ) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 60.0, target: 100.0, pending: 25.0)), + ); + + final List rects = layers(tester); + expect(rects, hasLength(3)); + + final Rect ghost = rects[1]; + final Rect fill = rects[2]; + + // The ghost runs the whole committed length with the solid fill layered + // over it, so the seam is a rounded cap nested inside a rounded cap. + expect(ghost.width, moreOrLessEquals(trackWidth * 0.6)); + expect(fill.width, moreOrLessEquals(trackWidth * 0.35)); + expect(ghost.left, fill.left); + }); + + testWidgets("no pending means no ghost layer is drawn", (tester) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 60.0, target: 100.0)), + ); + + expect(layers(tester), hasLength(2)); + }); + + testWidgets("the pace tick sits at the elapsed fraction", (tester) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 9.0, target: 100.0, paceRatio: 0.25)), + ); + + final List rects = layers(tester); + expect(rects, hasLength(3)); + + final Rect track = rects[0]; + final Rect tick = rects[2]; + + // A quarter of the way along — not the constant ~91% the old target tick + // parked at for every budget that wasn't over. + expect(tick.center.dx - track.left, moreOrLessEquals(trackWidth * 0.25)); + expect(tick.height, track.height); + }); + + testWidgets("the pace tick is hidden at either extreme", (tester) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 9.0, target: 100.0, paceRatio: 0.0)), + ); + expect(layers(tester), hasLength(2)); + + // A finished period would pin it to the right edge, where it says nothing. + await tester.pumpWidget( + wrap(const BulletChart(value: 9.0, target: 100.0, paceRatio: 1.0)), + ); + expect(layers(tester), hasLength(2)); + }); + + testWidgets("a non-positive target draws an empty track", (tester) async { + await tester.pumpWidget(wrap(const BulletChart(value: 50.0, target: 0.0))); + + expect(layers(tester), hasLength(1)); + }); +} From c1ae78162b75f7a37087e24c69cea6fb781ca8f5 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 3 Sep 2026 19:55:33 +0800 Subject: [PATCH 02/57] Update CHANGELOG.md --- CHANGELOG.md | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db23abd..4c684e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,21 +4,7 @@ ### Features -* **Budgets.** Set a spending limit for a period, over all spending or only - chosen categories. Budgets live under Profile → Budgets, with an overview in - the Stats tab. -* Each budget gets its own page: progress for the current period, how it's - pacing, its recent periods, and every transaction counting towards it. -* Budgets overview surfaces which budgets are over or nearing their limit, and - what to do about it. -* Planned and pending transactions count towards a budget — money you've - scheduled is money the period is committed to. Progress bars draw that part - in a lighter shade, so you can still see what has actually cleared. -* Home screen widgets for budgets on iOS and Android, including a variant that - shows progress without revealing any amounts. Tapping one opens the budget - it's showing. -* Budgets are included in backups, and restore from any v2 backup. -* An in-app alert when a budget goes over, or gets close. +* Added budgets ### Changes From 91d815e3ca41150314dbf8f1a9fc9b83541197ab Mon Sep 17 00:00:00 2001 From: Oliver Tzeng Date: Sun, 13 Sep 2026 01:00:00 +0800 Subject: [PATCH 03/57] fixed zh_TW translations and reformatted --- assets/l10n/ar.json | 22 ++++++------ assets/l10n/be_BY.json | 22 ++++++------ assets/l10n/cs_CZ.json | 22 ++++++------ assets/l10n/de_DE.json | 22 ++++++------ assets/l10n/en.json | 22 ++++++------ assets/l10n/es_ES.json | 22 ++++++------ assets/l10n/fa_IR.json | 22 ++++++------ assets/l10n/fr_FR.json | 22 ++++++------ assets/l10n/it_IT.json | 22 ++++++------ assets/l10n/mn_MN.json | 24 ++++++------- assets/l10n/pl_PL.json | 22 ++++++------ assets/l10n/ru_RU.json | 22 ++++++------ assets/l10n/tr_TR.json | 22 ++++++------ assets/l10n/uk_UA.json | 22 ++++++------ assets/l10n/zh_CN.json | 24 ++++++------- assets/l10n/zh_TW.json | 80 +++++++++++++++++++++--------------------- 16 files changed, 207 insertions(+), 207 deletions(-) diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e0d3245b..e1f16610 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -47,25 +47,22 @@ "budget.categories.description": "تُحتسب في هذه الميزانية نفقات الفئات المحددة فقط. لا تحدد أي فئة لاحتساب جميع النفقات.", "budget.delete": "حذف الميزانية", "budget.delete.description": "لن تتأثر المعاملات. هذا الإجراء لا رجعة فيه!", - "budget.insight.nearingLimit": "{name} تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", - "budget.insight.onTrack": "{name} على المسار الصحيح — {amount} متبقي.", - "budget.status.onTrack": "ضمن الحد", - "budget.status.nearing": "قارب الحد", - "budget.status.over": "تجاوز الحد", - "budget.detail.history": "الفترات الأخيرة", - "budget.detail.transactions": "المعاملات", - "budget.detail.unavailable": "أرقام الميزانية غير متوفرة حاليًا.", "budget.detail.daysLeft": "{} أيام متبقية", "budget.detail.daysLeft.one": "{} يوم متبقٍ", + "budget.detail.history": "الفترات الأخيرة", "budget.detail.periodEnded": "انتهت الفترة", - "budget.insight.over.short": "أنت متجاوز بمقدار {amount}. قلل الإنفاق أو ارفع الحد.", + "budget.detail.transactions": "المعاملات", + "budget.detail.unavailable": "أرقام الميزانية غير متوفرة حاليًا.", + "budget.insight.nearingLimit": "{name} تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", "budget.insight.nearingLimit.short": "تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", - "budget.insight.overpacing.short": "بهذه الوتيرة، ستتجاوز الحد بمقدار {amount}.", + "budget.insight.onTrack": "{name} على المسار الصحيح — {amount} متبقي.", "budget.insight.onTrack.short": "على المسار الصحيح — {amount} متبقي.", - "budget.insight.underspending.short": "لديك متسع — {amount} غير مُنفق.", "budget.insight.over": "أنت متجاوز بمقدار {amount} في {name}. قلل الإنفاق أو ارفع الحد.", + "budget.insight.over.short": "أنت متجاوز بمقدار {amount}. قلل الإنفاق أو ارفع الحد.", "budget.insight.overpacing": "بهذه الوتيرة، سيتجاوز {name} الحد بمقدار {amount}.", + "budget.insight.overpacing.short": "بهذه الوتيرة، ستتجاوز الحد بمقدار {amount}.", "budget.insight.underspending": "{name} لديه متسع — {amount} غير مُنفق.", + "budget.insight.underspending.short": "لديك متسع — {amount} غير مُنفق.", "budget.name": "اسم الميزانية", "budget.overview.allHealthy": "كل شيء على المسار الصحيح. عمل رائع.", "budget.overview.budgetsTracked": "{count} ميزانيات متبعة", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "تجديد تلقائي", "budget.renewAutomatically.description": "عند انتهاء الفترة، تنتقل الميزانية إلى الفترة الجديدة. على سبيل المثال، تصبح ميزانية يوليو ميزانية أغسطس.", "budget.scope": "النطاق", + "budget.status.nearing": "قارب الحد", + "budget.status.onTrack": "ضمن الحد", + "budget.status.over": "تجاوز الحد", "budgets": "الميزانيات", "budgets.new": "ميزانية جديدة", "categories": "الفئات", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index a4856514..12330f6f 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -47,25 +47,22 @@ "budget.categories.description": "У гэты бюджэт уваходзяць толькі выдаткі выбраных катэгорый. Не выбірайце нічога, каб улічваць усе выдаткі.", "budget.delete": "Выдаліць бюджэт", "budget.delete.description": "Транзакцыі не будуць закрануты. Гэта дзеянне незваротнае!", - "budget.insight.nearingLimit": "{name} выкарыстана на {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", - "budget.insight.onTrack": "{name} у парадку — засталося {amount}.", - "budget.status.onTrack": "У парадку", - "budget.status.nearing": "Блізка да ліміту", - "budget.status.over": "Ліміт перавышаны", - "budget.detail.history": "Апошнія перыяды", - "budget.detail.transactions": "Транзакцыі", - "budget.detail.unavailable": "Даныя бюджэту зараз недаступныя.", "budget.detail.daysLeft": "Засталося {} дзён", "budget.detail.daysLeft.one": "Застаўся {} дзень", + "budget.detail.history": "Апошнія перыяды", "budget.detail.periodEnded": "Перыяд скончыўся", - "budget.insight.over.short": "Вы перавысілі ліміт на {amount}. Зменшыце выдаткі або павялічце ліміт.", + "budget.detail.transactions": "Транзакцыі", + "budget.detail.unavailable": "Даныя бюджэту зараз недаступныя.", + "budget.insight.nearingLimit": "{name} выкарыстана на {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", "budget.insight.nearingLimit.short": "Выкарыстана {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", - "budget.insight.overpacing.short": "Пры такім тэмпе вы перавысіце ліміт на {amount}.", + "budget.insight.onTrack": "{name} у парадку — засталося {amount}.", "budget.insight.onTrack.short": "У парадку — засталося {amount}.", - "budget.insight.underspending.short": "Ёсць запас — не выдаткавана {amount}.", "budget.insight.over": "Вы перавысілі ліміт па {name} на {amount}. Зменшыце выдаткі або павялічце ліміт.", + "budget.insight.over.short": "Вы перавысілі ліміт на {amount}. Зменшыце выдаткі або павялічце ліміт.", "budget.insight.overpacing": "Пры такім тэмпе {name} перавысіць ліміт на {amount}.", + "budget.insight.overpacing.short": "Пры такім тэмпе вы перавысіце ліміт на {amount}.", "budget.insight.underspending": "{name} мае запас — не выдаткавана {amount}.", + "budget.insight.underspending.short": "Ёсць запас — не выдаткавана {amount}.", "budget.name": "Назва бюджэту", "budget.overview.allHealthy": "Усё ідзе па плане. Выдатная праца.", "budget.overview.budgetsTracked": "{count} бюджэтаў адсочваецца", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Аднаўляць аўтаматычна", "budget.renewAutomatically.description": "Калі перыяд заканчваецца, бюджэт пераходзіць на новы перыяд. Напрыклад, бюджэт ліпеня становіцца бюджэтам жніўня.", "budget.scope": "Ахоп", + "budget.status.nearing": "Блізка да ліміту", + "budget.status.onTrack": "У парадку", + "budget.status.over": "Ліміт перавышаны", "budgets": "Бюджэты", "budgets.new": "Новы бюджэт", "categories": "Катэгорыі", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index bc0073a9..25fe07cf 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -47,25 +47,22 @@ "budget.categories.description": "Do tohoto rozpočtu se počítají pouze výdaje ve vybraných kategoriích. Nevyberete-li žádnou, počítají se všechny výdaje.", "budget.delete": "Smazat rozpočet", "budget.delete.description": "Transakce nebudou ovlivněny. Tato akce je nevratná!", - "budget.insight.nearingLimit": "{name}: {percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", - "budget.insight.onTrack": "{name} je na správné cestě — zbývá {amount}.", - "budget.status.onTrack": "V pořádku", - "budget.status.nearing": "Blízko limitu", - "budget.status.over": "Nad limitem", - "budget.detail.history": "Poslední období", - "budget.detail.transactions": "Transakce", - "budget.detail.unavailable": "Údaje rozpočtu nejsou momentálně k dispozici.", "budget.detail.daysLeft": "Zbývá {} dní", "budget.detail.daysLeft.one": "Zbývá {} den", + "budget.detail.history": "Poslední období", "budget.detail.periodEnded": "Období skončilo", - "budget.insight.over.short": "Překročili jste rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", + "budget.detail.transactions": "Transakce", + "budget.detail.unavailable": "Údaje rozpočtu nejsou momentálně k dispozici.", + "budget.insight.nearingLimit": "{name}: {percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", "budget.insight.nearingLimit.short": "{percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", - "budget.insight.overpacing.short": "Při tomto tempu překročíte rozpočet o {amount}.", + "budget.insight.onTrack": "{name} je na správné cestě — zbývá {amount}.", "budget.insight.onTrack.short": "Na správné cestě — zbývá {amount}.", - "budget.insight.underspending.short": "Máte rezervu — {amount} nevyužito.", "budget.insight.over": "U {name} jste překročili rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", + "budget.insight.over.short": "Překročili jste rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", "budget.insight.overpacing": "Při tomto tempu {name} překročí rozpočet o {amount}.", + "budget.insight.overpacing.short": "Při tomto tempu překročíte rozpočet o {amount}.", "budget.insight.underspending": "{name} má rezervu — {amount} nevyužito.", + "budget.insight.underspending.short": "Máte rezervu — {amount} nevyužito.", "budget.name": "Název rozpočtu", "budget.overview.allHealthy": "Vše jde podle plánu. Dobrá práce.", "budget.overview.budgetsTracked": "{count} sledovaných rozpočtů", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Obnovovat automaticky", "budget.renewAutomatically.description": "Po skončení období rozpočet přejde do nového období. Například červencový rozpočet se stane srpnovým.", "budget.scope": "Rozsah", + "budget.status.nearing": "Blízko limitu", + "budget.status.onTrack": "V pořádku", + "budget.status.over": "Nad limitem", "budgets": "Rozpočty", "budgets.new": "Nový rozpočet", "categories": "Kategorie", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 79168597..8bd1995d 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -47,25 +47,22 @@ "budget.categories.description": "Nur Ausgaben in den ausgewählten Kategorien zählen zu diesem Budget. Ohne Auswahl zählen alle Ausgaben.", "budget.delete": "Budget löschen", "budget.delete.description": "Buchungen sind davon nicht betroffen. Diese Aktion ist unwiderruflich!", - "budget.insight.nearingLimit": "{name} ist zu {percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", - "budget.insight.onTrack": "{name} ist auf Kurs — {amount} übrig.", - "budget.status.onTrack": "Auf Kurs", - "budget.status.nearing": "Nahe am Limit", - "budget.status.over": "Über dem Limit", - "budget.detail.history": "Letzte Zeiträume", - "budget.detail.transactions": "Buchungen", - "budget.detail.unavailable": "Budgetzahlen sind derzeit nicht verfügbar.", "budget.detail.daysLeft": "Noch {} Tage", "budget.detail.daysLeft.one": "Noch {} Tag", + "budget.detail.history": "Letzte Zeiträume", "budget.detail.periodEnded": "Zeitraum beendet", - "budget.insight.over.short": "Sie liegen {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", + "budget.detail.transactions": "Buchungen", + "budget.detail.unavailable": "Budgetzahlen sind derzeit nicht verfügbar.", + "budget.insight.nearingLimit": "{name} ist zu {percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", "budget.insight.nearingLimit.short": "{percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", - "budget.insight.overpacing.short": "Bei diesem Tempo überschreiten Sie das Budget um {amount}.", + "budget.insight.onTrack": "{name} ist auf Kurs — {amount} übrig.", "budget.insight.onTrack.short": "Auf Kurs — {amount} übrig.", - "budget.insight.underspending.short": "Noch viel Spielraum — {amount} nicht ausgegeben.", "budget.insight.over": "Sie liegen bei {name} um {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", + "budget.insight.over.short": "Sie liegen {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", "budget.insight.overpacing": "Bei diesem Tempo wird {name} das Budget um {amount} überschreiten.", + "budget.insight.overpacing.short": "Bei diesem Tempo überschreiten Sie das Budget um {amount}.", "budget.insight.underspending": "{name} hat noch viel Spielraum — {amount} nicht ausgegeben.", + "budget.insight.underspending.short": "Noch viel Spielraum — {amount} nicht ausgegeben.", "budget.name": "Budgetname", "budget.overview.allHealthy": "Alles läuft nach Plan. Gut gemacht.", "budget.overview.budgetsTracked": "{count} Budgets verfolgt", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Automatisch erneuern", "budget.renewAutomatically.description": "Wenn der Zeitraum endet, geht das Budget in den neuen Zeitraum über. Zum Beispiel wird aus einem Juli-Budget ein August-Budget.", "budget.scope": "Umfang", + "budget.status.nearing": "Nahe am Limit", + "budget.status.onTrack": "Auf Kurs", + "budget.status.over": "Über dem Limit", "budgets": "Budgets", "budgets.new": "Neues Budget", "categories": "Kategorien", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 34e7da2d..7c8ff122 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -47,25 +47,22 @@ "budget.categories.description": "Only spending in the selected categories counts towards this budget. Select none to count all spending.", "budget.delete": "Delete budget", "budget.delete.description": "Transactions won't be affected. This action is irreversible!", - "budget.insight.nearingLimit": "{name} is {percent}% spent with {days}d to go. Ease up to stay under.", - "budget.insight.onTrack": "{name} is on track — {amount} left.", - "budget.status.onTrack": "On track", - "budget.status.nearing": "Nearing limit", - "budget.status.over": "Over budget", - "budget.detail.history": "Recent periods", - "budget.detail.transactions": "Transactions", - "budget.detail.unavailable": "Budget numbers aren't available right now.", "budget.detail.daysLeft": "{} days left", "budget.detail.daysLeft.one": "{} day left", + "budget.detail.history": "Recent periods", "budget.detail.periodEnded": "Period ended", - "budget.insight.over.short": "You're {amount} over. Trim spending or raise the limit.", + "budget.detail.transactions": "Transactions", + "budget.detail.unavailable": "Budget numbers aren't available right now.", + "budget.insight.nearingLimit": "{name} is {percent}% spent with {days}d to go. Ease up to stay under.", "budget.insight.nearingLimit.short": "{percent}% spent with {days}d to go. Ease up to stay under.", - "budget.insight.overpacing.short": "At this pace, you'll overshoot by {amount}.", + "budget.insight.onTrack": "{name} is on track — {amount} left.", "budget.insight.onTrack.short": "On track — {amount} left.", - "budget.insight.underspending.short": "Plenty of room — {amount} unspent.", "budget.insight.over": "You're {amount} over on {name}. Trim spending or raise the limit.", + "budget.insight.over.short": "You're {amount} over. Trim spending or raise the limit.", "budget.insight.overpacing": "At this pace, {name} will overshoot by {amount}.", + "budget.insight.overpacing.short": "At this pace, you'll overshoot by {amount}.", "budget.insight.underspending": "{name} has plenty of room — {amount} unspent.", + "budget.insight.underspending.short": "Plenty of room — {amount} unspent.", "budget.name": "Budget name", "budget.overview.allHealthy": "Everything's on track. Nice work.", "budget.overview.budgetsTracked": "{count} budgets tracked", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renew automatically", "budget.renewAutomatically.description": "When the period ends, the budget rolls over to the new period. For example, a July budget becomes an August budget.", "budget.scope": "Scope", + "budget.status.nearing": "Nearing limit", + "budget.status.onTrack": "On track", + "budget.status.over": "Over budget", "budgets": "Budgets", "budgets.new": "New budget", "categories": "Categories", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index f87e7652..8953bfd6 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -47,25 +47,22 @@ "budget.categories.description": "Solo los gastos de las categorías seleccionadas cuentan para este presupuesto. No selecciones ninguna para contar todos los gastos.", "budget.delete": "Eliminar presupuesto", "budget.delete.description": "Las transacciones no se verán afectadas. ¡Esta acción es irreversible!", - "budget.insight.nearingLimit": "{name} está al {percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", - "budget.insight.onTrack": "{name} va según lo previsto — quedan {amount}.", - "budget.status.onTrack": "En orden", - "budget.status.nearing": "Cerca del límite", - "budget.status.over": "Límite superado", - "budget.detail.history": "Períodos recientes", - "budget.detail.transactions": "Transacciones", - "budget.detail.unavailable": "Los datos del presupuesto no están disponibles ahora mismo.", "budget.detail.daysLeft": "Quedan {} días", "budget.detail.daysLeft.one": "Queda {} día", + "budget.detail.history": "Períodos recientes", "budget.detail.periodEnded": "Período finalizado", - "budget.insight.over.short": "Te has excedido {amount}. Reduce el gasto o aumenta el límite.", + "budget.detail.transactions": "Transacciones", + "budget.detail.unavailable": "Los datos del presupuesto no están disponibles ahora mismo.", + "budget.insight.nearingLimit": "{name} está al {percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", "budget.insight.nearingLimit.short": "{percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", - "budget.insight.overpacing.short": "A este ritmo, te excederás en {amount}.", + "budget.insight.onTrack": "{name} va según lo previsto — quedan {amount}.", "budget.insight.onTrack.short": "Según lo previsto — quedan {amount}.", - "budget.insight.underspending.short": "Tienes margen — {amount} sin gastar.", "budget.insight.over": "Has excedido {amount} en {name}. Reduce el gasto o aumenta el límite.", + "budget.insight.over.short": "Te has excedido {amount}. Reduce el gasto o aumenta el límite.", "budget.insight.overpacing": "A este ritmo, {name} se excederá en {amount}.", + "budget.insight.overpacing.short": "A este ritmo, te excederás en {amount}.", "budget.insight.underspending": "{name} tiene margen — {amount} sin gastar.", + "budget.insight.underspending.short": "Tienes margen — {amount} sin gastar.", "budget.name": "Nombre del presupuesto", "budget.overview.allHealthy": "Todo va según lo previsto. Buen trabajo.", "budget.overview.budgetsTracked": "{count} presupuestos registrados", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renovar automáticamente", "budget.renewAutomatically.description": "Cuando termina el período, el presupuesto pasa al nuevo período. Por ejemplo, un presupuesto de julio se convierte en uno de agosto.", "budget.scope": "Alcance", + "budget.status.nearing": "Cerca del límite", + "budget.status.onTrack": "En orden", + "budget.status.over": "Límite superado", "budgets": "Presupuestos", "budgets.new": "Nuevo presupuesto", "categories": "Categorías", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index bd9ef44c..2204f9b6 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -47,25 +47,22 @@ "budget.categories.description": "فقط هزینه‌های دسته‌بندی‌های انتخاب‌شده در این بودجه حساب می‌شوند. برای احتساب همه هزینه‌ها هیچ‌کدام را انتخاب نکنید.", "budget.delete": "حذف بودجه", "budget.delete.description": "تراکنش‌ها تغییری نمی‌کنند. این عمل غیرقابل بازگشت است!", - "budget.insight.nearingLimit": "{name}، {percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", - "budget.insight.onTrack": "{name} در مسیر درست است — {amount} باقی‌مانده.", - "budget.status.onTrack": "در مسیر درست", - "budget.status.nearing": "نزدیک به حد", - "budget.status.over": "بیش از بودجه", - "budget.detail.history": "دوره‌های اخیر", - "budget.detail.transactions": "تراکنش‌ها", - "budget.detail.unavailable": "اعداد بودجه در حال حاضر در دسترس نیستند.", "budget.detail.daysLeft": "{} روز مانده", "budget.detail.daysLeft.one": "{} روز مانده", + "budget.detail.history": "دوره‌های اخیر", "budget.detail.periodEnded": "دوره به پایان رسید", - "budget.insight.over.short": "شما {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", + "budget.detail.transactions": "تراکنش‌ها", + "budget.detail.unavailable": "اعداد بودجه در حال حاضر در دسترس نیستند.", + "budget.insight.nearingLimit": "{name}، {percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", "budget.insight.nearingLimit.short": "{percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", - "budget.insight.overpacing.short": "با این روند، تا {amount} بیش از بودجه خواهید رفت.", + "budget.insight.onTrack": "{name} در مسیر درست است — {amount} باقی‌مانده.", "budget.insight.onTrack.short": "در مسیر درست — {amount} باقی‌مانده.", - "budget.insight.underspending.short": "فضای زیادی دارید — {amount} خرج‌نشده.", "budget.insight.over": "شما در {name} {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", + "budget.insight.over.short": "شما {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", "budget.insight.overpacing": "با این روند، {name} تا {amount} بیش از بودجه خواهد شد.", + "budget.insight.overpacing.short": "با این روند، تا {amount} بیش از بودجه خواهید رفت.", "budget.insight.underspending": "{name} فضای زیادی دارد — {amount} خرج‌نشده.", + "budget.insight.underspending.short": "فضای زیادی دارید — {amount} خرج‌نشده.", "budget.name": "نام بودجه", "budget.overview.allHealthy": "همه چیز طبق برنامه است. آفرین.", "budget.overview.budgetsTracked": "{count} بودجه پیگیری شدند", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "تمدید خودکار", "budget.renewAutomatically.description": "با پایان دوره، بودجه به دوره جدید منتقل می‌شود. برای مثال، بودجه ژوئیه به بودجه اوت تبدیل می‌شود.", "budget.scope": "محدوده", + "budget.status.nearing": "نزدیک به حد", + "budget.status.onTrack": "در مسیر درست", + "budget.status.over": "بیش از بودجه", "budgets": "بودجه‌ها", "budgets.new": "بودجه جدید", "categories": "دسته‌بندی‌ها", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index f4d91732..4a941c23 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -47,25 +47,22 @@ "budget.categories.description": "Seules les dépenses des catégories sélectionnées comptent dans ce budget. N'en sélectionnez aucune pour compter toutes les dépenses.", "budget.delete": "Supprimer le budget", "budget.delete.description": "Les transactions ne seront pas affectées. Cette action est irréversible !", - "budget.insight.nearingLimit": "{name} est à {percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", - "budget.insight.onTrack": "{name} est dans les clous — {amount} restants.", - "budget.status.onTrack": "Dans les clous", - "budget.status.nearing": "Limite proche", - "budget.status.over": "Budget dépassé", - "budget.detail.history": "Périodes récentes", - "budget.detail.transactions": "Transactions", - "budget.detail.unavailable": "Les chiffres du budget ne sont pas disponibles pour le moment.", "budget.detail.daysLeft": "{} jours restants", "budget.detail.daysLeft.one": "{} jour restant", + "budget.detail.history": "Périodes récentes", "budget.detail.periodEnded": "Période terminée", - "budget.insight.over.short": "Vous êtes en dépassement de {amount}. Réduisez les dépenses ou augmentez la limite.", + "budget.detail.transactions": "Transactions", + "budget.detail.unavailable": "Les chiffres du budget ne sont pas disponibles pour le moment.", + "budget.insight.nearingLimit": "{name} est à {percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", "budget.insight.nearingLimit.short": "{percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", - "budget.insight.overpacing.short": "À ce rythme, vous dépasserez la limite de {amount}.", + "budget.insight.onTrack": "{name} est dans les clous — {amount} restants.", "budget.insight.onTrack.short": "Dans les clous — {amount} restants.", - "budget.insight.underspending.short": "Il reste de la marge — {amount} non dépensé.", "budget.insight.over": "Vous êtes en dépassement de {amount} pour {name}. Réduisez les dépenses ou augmentez la limite.", + "budget.insight.over.short": "Vous êtes en dépassement de {amount}. Réduisez les dépenses ou augmentez la limite.", "budget.insight.overpacing": "À ce rythme, {name} dépassera la limite de {amount}.", + "budget.insight.overpacing.short": "À ce rythme, vous dépasserez la limite de {amount}.", "budget.insight.underspending": "{name} a de la marge — {amount} non dépensé.", + "budget.insight.underspending.short": "Il reste de la marge — {amount} non dépensé.", "budget.name": "Nom du budget", "budget.overview.allHealthy": "Tout est sur la bonne voie. Bon travail.", "budget.overview.budgetsTracked": "{count} budgets suivis", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renouveler automatiquement", "budget.renewAutomatically.description": "À la fin de la période, le budget passe à la période suivante. Par exemple, un budget de juillet devient un budget d'août.", "budget.scope": "Périmètre", + "budget.status.nearing": "Limite proche", + "budget.status.onTrack": "Dans les clous", + "budget.status.over": "Budget dépassé", "budgets": "Budgets", "budgets.new": "Nouveau budget", "categories": "Catégories", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index b2697528..1c210f49 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -47,25 +47,22 @@ "budget.categories.description": "Solo le spese nelle categorie selezionate contano per questo budget. Non selezionarne nessuna per contare tutte le spese.", "budget.delete": "Elimina budget", "budget.delete.description": "Le transazioni non saranno interessate. Questa azione è irreversibile!", - "budget.insight.nearingLimit": "{name} ha speso il {percent}% e mancano {days} giorni. Rallenta per restare entro il limite.", - "budget.insight.onTrack": "{name} è sulla buona strada — {amount} rimanenti.", - "budget.status.onTrack": "Nei limiti", - "budget.status.nearing": "Vicino al limite", - "budget.status.over": "Oltre il limite", - "budget.detail.history": "Periodi recenti", - "budget.detail.transactions": "Transazioni", - "budget.detail.unavailable": "I dati del budget non sono al momento disponibili.", "budget.detail.daysLeft": "{} giorni rimasti", "budget.detail.daysLeft.one": "{} giorno rimasto", + "budget.detail.history": "Periodi recenti", "budget.detail.periodEnded": "Periodo terminato", - "budget.insight.over.short": "Hai superato di {amount}. Riduci le spese o aumenta il limite.", + "budget.detail.transactions": "Transazioni", + "budget.detail.unavailable": "I dati del budget non sono al momento disponibili.", + "budget.insight.nearingLimit": "{name} ha speso il {percent}% e mancano {days} giorni. Rallenta per restare entro il limite.", "budget.insight.nearingLimit.short": "{percent}% speso e mancano {days} giorni. Rallenta per restare entro il limite.", - "budget.insight.overpacing.short": "A questo ritmo, sforerai di {amount}.", + "budget.insight.onTrack": "{name} è sulla buona strada — {amount} rimanenti.", "budget.insight.onTrack.short": "Sulla buona strada — {amount} rimanenti.", - "budget.insight.underspending.short": "C'è molto margine — {amount} non spesi.", "budget.insight.over": "Hai superato di {amount} il budget per {name}. Riduci le spese o aumenta il limite.", + "budget.insight.over.short": "Hai superato di {amount}. Riduci le spese o aumenta il limite.", "budget.insight.overpacing": "A questo ritmo, {name} sforerà di {amount}.", + "budget.insight.overpacing.short": "A questo ritmo, sforerai di {amount}.", "budget.insight.underspending": "{name} ha molto margine — {amount} non spesi.", + "budget.insight.underspending.short": "C'è molto margine — {amount} non spesi.", "budget.name": "Nome del budget", "budget.overview.allHealthy": "Tutto procede bene. Ottimo lavoro.", "budget.overview.budgetsTracked": "{count} budget monitorati", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Rinnova automaticamente", "budget.renewAutomatically.description": "Al termine del periodo, il budget passa al periodo successivo. Ad esempio, un budget di luglio diventa un budget di agosto.", "budget.scope": "Ambito", + "budget.status.nearing": "Vicino al limite", + "budget.status.onTrack": "Nei limiti", + "budget.status.over": "Oltre il limite", "budgets": "Budget", "budgets.new": "Nuovo budget", "categories": "Categorie", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 5137a456..585648f8 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -47,25 +47,22 @@ "budget.categories.description": "Зөвхөн сонгосон ангиллын зарлага энэ төсөвт тооцогдоно. Юу ч сонгоогүй бол бүх зарлага тооцогдоно.", "budget.delete": "Төсвийг устгах", "budget.delete.description": "Гүйлгээнүүдэд нөлөөлөхгүй. Энэ үйлдлийг буцаах боломжгүй!", - "budget.insight.nearingLimit": "{name} нь {percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", - "budget.insight.onTrack": "{name} хэвийн байна — {amount} үлдсэн.", - "budget.status.onTrack": "Хэвийн", - "budget.status.nearing": "Хязгаарт дөхсөн", - "budget.status.over": "Хэтэрсэн", - "budget.detail.history": "Сүүлийн хугацаанууд", - "budget.detail.transactions": "Гүйлгээнүүд", - "budget.detail.unavailable": "Төсвийн тоо одоогоор гарахгүй байна.", "budget.detail.daysLeft": "{} хоног үлдлээ", "budget.detail.daysLeft.one": "{} хоног үлдлээ", + "budget.detail.history": "Сүүлийн хугацаанууд", "budget.detail.periodEnded": "Хугацаа дууссан", - "budget.insight.over.short": "Та төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", + "budget.detail.transactions": "Гүйлгээнүүд", + "budget.detail.unavailable": "Төсвийн тоо одоогоор гарахгүй байна.", + "budget.insight.nearingLimit": "{name} нь {percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", "budget.insight.nearingLimit.short": "{percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", - "budget.insight.overpacing.short": "Энэ янзаараа төсөв {amount}-р хэтрэх болно.", + "budget.insight.onTrack": "{name} хэвийн байна — {amount} үлдсэн.", "budget.insight.onTrack.short": "Хэвийн байна — {amount} үлдсэн.", - "budget.insight.underspending.short": "Төсөвт {amount} зарцуулагдаагүй үлджээ.", "budget.insight.over": "Та {name} төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", + "budget.insight.over.short": "Та төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", "budget.insight.overpacing": "Энэ янзаараа {name} төсөв {amount}-р хэтрэх болно.", + "budget.insight.overpacing.short": "Энэ янзаараа төсөв {amount}-р хэтрэх болно.", "budget.insight.underspending": "{name} төсөвт {amount} зарцуулагдаагүй үлджээ.", + "budget.insight.underspending.short": "Төсөвт {amount} зарцуулагдаагүй үлджээ.", "budget.name": "Төсвийн нэр", "budget.overview.allHealthy": "Бүх зүйл төсвийг дагуу. Янзтай!", "budget.overview.budgetsTracked": "{count} төсөв хянагдаж байна", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Автоматаар шинэчлэх", "budget.renewAutomatically.description": "Хугацаа дуусахад төсөв дараагийн хугацаа руу шилжинэ. Жишээ нь, 7-р сарын төсөв 8-р сарын төсөв болно.", "budget.scope": "Хүрээ", + "budget.status.nearing": "Хязгаарт дөхсөн", + "budget.status.onTrack": "Хэвийн", + "budget.status.over": "Хэтэрсэн", "budgets": "Төсвүүд", "budgets.new": "Шинэ төсөв", "categories": "Ангиллууд", @@ -946,4 +946,4 @@ "transactions.query.noResult": "Тохирох гүйлгээнүүд олдсонгүй", "transactions.query.noResult.description": "Шүүлтүүрээ өөрчлөөд дахин оролдоно уу", "visitGitHubRepo": "GitHub repo-р зочлох" -} +} \ No newline at end of file diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index e2f5aba4..dd6483c8 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -47,25 +47,22 @@ "budget.categories.description": "Do tego budżetu liczą się tylko wydatki z wybranych kategorii. Nie wybieraj żadnej, aby liczyć wszystkie wydatki.", "budget.delete": "Usuń budżet", "budget.delete.description": "Transakcje nie zostaną naruszone. Ta akcja jest nieodwracalna!", - "budget.insight.nearingLimit": "{name} jest wykorzystany w {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", - "budget.insight.onTrack": "{name} jest na dobrej drodze — pozostało {amount}.", - "budget.status.onTrack": "W normie", - "budget.status.nearing": "Blisko limitu", - "budget.status.over": "Limit przekroczony", - "budget.detail.history": "Ostatnie okresy", - "budget.detail.transactions": "Transakcje", - "budget.detail.unavailable": "Dane budżetu są chwilowo niedostępne.", "budget.detail.daysLeft": "Pozostało {} dni", "budget.detail.daysLeft.one": "Pozostał {} dzień", + "budget.detail.history": "Ostatnie okresy", "budget.detail.periodEnded": "Okres zakończony", - "budget.insight.over.short": "Masz przekroczenie o {amount}. Ogranicz wydatki lub zwiększ limit.", + "budget.detail.transactions": "Transakcje", + "budget.detail.unavailable": "Dane budżetu są chwilowo niedostępne.", + "budget.insight.nearingLimit": "{name} jest wykorzystany w {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", "budget.insight.nearingLimit.short": "Wykorzystano {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", - "budget.insight.overpacing.short": "W tym tempie przekroczysz limit o {amount}.", + "budget.insight.onTrack": "{name} jest na dobrej drodze — pozostało {amount}.", "budget.insight.onTrack.short": "Na dobrej drodze — pozostało {amount}.", - "budget.insight.underspending.short": "Jest dużo zapasu — pozostało {amount}.", "budget.insight.over": "Masz przekroczenie o {amount} w {name}. Ogranicz wydatki lub zwiększ limit.", + "budget.insight.over.short": "Masz przekroczenie o {amount}. Ogranicz wydatki lub zwiększ limit.", "budget.insight.overpacing": "W tym tempie {name} przekroczy limit o {amount}.", + "budget.insight.overpacing.short": "W tym tempie przekroczysz limit o {amount}.", "budget.insight.underspending": "{name} ma dużo zapasu — pozostało {amount}.", + "budget.insight.underspending.short": "Jest dużo zapasu — pozostało {amount}.", "budget.name": "Nazwa budżetu", "budget.overview.allHealthy": "Wszystko idzie zgodnie z planem. Świetna robota.", "budget.overview.budgetsTracked": "{count} śledzonych budżetów", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Odnawiaj automatycznie", "budget.renewAutomatically.description": "Gdy okres się kończy, budżet przechodzi na nowy okres. Na przykład budżet lipcowy staje się budżetem sierpniowym.", "budget.scope": "Zakres", + "budget.status.nearing": "Blisko limitu", + "budget.status.onTrack": "W normie", + "budget.status.over": "Limit przekroczony", "budgets": "Budżety", "budgets.new": "Nowy budżet", "categories": "Kategorie", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index d7bd6847..9f49d8d9 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -47,25 +47,22 @@ "budget.categories.description": "В этот бюджет входят только расходы выбранных категорий. Не выбирайте ничего, чтобы учитывать все расходы.", "budget.delete": "Удалить бюджет", "budget.delete.description": "Транзакции не будут затронуты. Это действие необратимо!", - "budget.insight.nearingLimit": "{name}: потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", - "budget.insight.onTrack": "{name} в норме — осталось {amount}.", - "budget.status.onTrack": "В норме", - "budget.status.nearing": "Близко к лимиту", - "budget.status.over": "Лимит превышен", - "budget.detail.history": "Последние периоды", - "budget.detail.transactions": "Транзакции", - "budget.detail.unavailable": "Данные бюджета сейчас недоступны.", "budget.detail.daysLeft": "Осталось {} дн.", "budget.detail.daysLeft.one": "Остался {} день", + "budget.detail.history": "Последние периоды", "budget.detail.periodEnded": "Период завершён", - "budget.insight.over.short": "Вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", + "budget.detail.transactions": "Транзакции", + "budget.detail.unavailable": "Данные бюджета сейчас недоступны.", + "budget.insight.nearingLimit": "{name}: потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", "budget.insight.nearingLimit.short": "Потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", - "budget.insight.overpacing.short": "С таким темпом вы превысите лимит на {amount}.", + "budget.insight.onTrack": "{name} в норме — осталось {amount}.", "budget.insight.onTrack.short": "В норме — осталось {amount}.", - "budget.insight.underspending.short": "Есть запас — не потрачено {amount}.", "budget.insight.over": "По {name} вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", + "budget.insight.over.short": "Вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", "budget.insight.overpacing": "С таким темпом {name} превысит лимит на {amount}.", + "budget.insight.overpacing.short": "С таким темпом вы превысите лимит на {amount}.", "budget.insight.underspending": "{name} имеет запас — не потрачено {amount}.", + "budget.insight.underspending.short": "Есть запас — не потрачено {amount}.", "budget.name": "Название бюджета", "budget.overview.allHealthy": "Все в порядке. Отличная работа.", "budget.overview.budgetsTracked": "{count} отслеживаемых бюджетов", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Продлевать автоматически", "budget.renewAutomatically.description": "Когда период заканчивается, бюджет переходит на новый период. Например, бюджет июля становится бюджетом августа.", "budget.scope": "Область", + "budget.status.nearing": "Близко к лимиту", + "budget.status.onTrack": "В норме", + "budget.status.over": "Лимит превышен", "budgets": "Бюджеты", "budgets.new": "Новый бюджет", "categories": "Категории", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index acff5519..e7949759 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -47,25 +47,22 @@ "budget.categories.description": "Bu bütçeye yalnızca seçilen kategorilerdeki harcamalar dahil edilir. Tüm harcamaları saymak için hiçbirini seçmeyin.", "budget.delete": "Bütçeyi sil", "budget.delete.description": "İşlemler etkilenmeyecek. Bu işlem geri alınamaz!", - "budget.insight.nearingLimit": "{name} için {percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", - "budget.insight.onTrack": "{name} iyi gidiyor — {amount} kaldı.", - "budget.status.onTrack": "İyi gidiyor", - "budget.status.nearing": "Limite yakın", - "budget.status.over": "Limit aşıldı", - "budget.detail.history": "Son dönemler", - "budget.detail.transactions": "İşlemler", - "budget.detail.unavailable": "Bütçe rakamları şu anda kullanılamıyor.", "budget.detail.daysLeft": "{} gün kaldı", "budget.detail.daysLeft.one": "{} gün kaldı", + "budget.detail.history": "Son dönemler", "budget.detail.periodEnded": "Dönem sona erdi", - "budget.insight.over.short": "Bütçeyi {amount} aştınız. Harcamaları kısın veya limiti artırın.", + "budget.detail.transactions": "İşlemler", + "budget.detail.unavailable": "Bütçe rakamları şu anda kullanılamıyor.", + "budget.insight.nearingLimit": "{name} için {percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", "budget.insight.nearingLimit.short": "{percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", - "budget.insight.overpacing.short": "Bu hızla bütçeyi {amount} kadar aşacaksınız.", + "budget.insight.onTrack": "{name} iyi gidiyor — {amount} kaldı.", "budget.insight.onTrack.short": "İyi gidiyor — {amount} kaldı.", - "budget.insight.underspending.short": "Fazla bütçe kaldı — {amount} harcanmadı.", "budget.insight.over": "{name} bütçesini {amount} aştınız. Harcamaları kısın veya limiti artırın.", + "budget.insight.over.short": "Bütçeyi {amount} aştınız. Harcamaları kısın veya limiti artırın.", "budget.insight.overpacing": "Bu hızla {name} bütçesi {amount} kadar aşılacak.", + "budget.insight.overpacing.short": "Bu hızla bütçeyi {amount} kadar aşacaksınız.", "budget.insight.underspending": "{name} için fazla bütçe kaldı — {amount} harcanmadı.", + "budget.insight.underspending.short": "Fazla bütçe kaldı — {amount} harcanmadı.", "budget.name": "Bütçe adı", "budget.overview.allHealthy": "Her şey yolunda. İyi iş.", "budget.overview.budgetsTracked": "{count} bütçe takip ediliyor", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Otomatik yenile", "budget.renewAutomatically.description": "Dönem sona erdiğinde bütçe yeni döneme geçer. Örneğin, temmuz bütçesi ağustos bütçesine dönüşür.", "budget.scope": "Kapsam", + "budget.status.nearing": "Limite yakın", + "budget.status.onTrack": "İyi gidiyor", + "budget.status.over": "Limit aşıldı", "budgets": "Bütçeler", "budgets.new": "Yeni bütçe", "categories": "Kategori", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 0d897f37..f101ac1c 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -47,25 +47,22 @@ "budget.categories.description": "До цього бюджету входять лише витрати вибраних категорій. Не вибирайте нічого, щоб враховувати всі витрати.", "budget.delete": "Видалити бюджет", "budget.delete.description": "Транзакції не будуть змінені. Ця дія незворотна!", - "budget.insight.nearingLimit": "{name} використано на {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", - "budget.insight.onTrack": "{name} в рамках бюджету — залишилося {amount}.", - "budget.status.onTrack": "У межах ліміту", - "budget.status.nearing": "Близько до ліміту", - "budget.status.over": "Ліміт перевищено", - "budget.detail.history": "Останні періоди", - "budget.detail.transactions": "Транзакції", - "budget.detail.unavailable": "Дані бюджету зараз недоступні.", "budget.detail.daysLeft": "Залишилося {} дн.", "budget.detail.daysLeft.one": "Залишився {} день", + "budget.detail.history": "Останні періоди", "budget.detail.periodEnded": "Період завершено", - "budget.insight.over.short": "Ви перевищили бюджет на {amount}. Зменшіть витрати або підвищіть ліміт.", + "budget.detail.transactions": "Транзакції", + "budget.detail.unavailable": "Дані бюджету зараз недоступні.", + "budget.insight.nearingLimit": "{name} використано на {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", "budget.insight.nearingLimit.short": "Використано {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", - "budget.insight.overpacing.short": "При такому темпі ви перевищите ліміт на {amount}.", + "budget.insight.onTrack": "{name} в рамках бюджету — залишилося {amount}.", "budget.insight.onTrack.short": "У рамках бюджету — залишилося {amount}.", - "budget.insight.underspending.short": "Є запас — не витрачено {amount}.", "budget.insight.over": "Ви перевищили бюджет {name} на {amount}. Зменшіть витрати або підвищіть ліміт.", + "budget.insight.over.short": "Ви перевищили бюджет на {amount}. Зменшіть витрати або підвищіть ліміт.", "budget.insight.overpacing": "При такому темпі {name} перевищить ліміт на {amount}.", + "budget.insight.overpacing.short": "При такому темпі ви перевищите ліміт на {amount}.", "budget.insight.underspending": "{name} має запас — не витрачено {amount}.", + "budget.insight.underspending.short": "Є запас — не витрачено {amount}.", "budget.name": "Назва бюджету", "budget.overview.allHealthy": "Усе йде за планом. Чудова робота.", "budget.overview.budgetsTracked": "Відстежується {count} бюджетів", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Поновлювати автоматично", "budget.renewAutomatically.description": "Коли період закінчується, бюджет переходить на новий період. Наприклад, бюджет липня стає бюджетом серпня.", "budget.scope": "Охоплення", + "budget.status.nearing": "Близько до ліміту", + "budget.status.onTrack": "У межах ліміту", + "budget.status.over": "Ліміт перевищено", "budgets": "Бюджети", "budgets.new": "Новий бюджет", "categories": "Категорії", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index 5d8006ec..dc289d26 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -47,25 +47,22 @@ "budget.categories.description": "只有所选分类的支出会计入此预算。若不选择任何分类,则计入所有支出。", "budget.delete": "删除预算", "budget.delete.description": "交易不会受到影响。此操作无法撤销!", - "budget.insight.nearingLimit": "{name} 已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", - "budget.insight.onTrack": "{name} 进度正常 — 剩余 {amount}。", - "budget.status.onTrack": "进度正常", - "budget.status.nearing": "接近上限", - "budget.status.over": "超出预算", - "budget.detail.history": "最近的周期", - "budget.detail.transactions": "交易", - "budget.detail.unavailable": "当前无法获取预算数据。", "budget.detail.daysLeft": "剩余 {} 天", "budget.detail.daysLeft.one": "剩余 {} 天", + "budget.detail.history": "最近的周期", "budget.detail.periodEnded": "周期已结束", - "budget.insight.over.short": "已超出 {amount}。请减少支出或提高上限。", + "budget.detail.transactions": "交易", + "budget.detail.unavailable": "当前无法获取预算数据。", + "budget.insight.nearingLimit": "{name} 已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", "budget.insight.nearingLimit.short": "已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", - "budget.insight.overpacing.short": "照此速度,将超出 {amount}。", + "budget.insight.onTrack": "{name} 进度正常 — 剩余 {amount}。", "budget.insight.onTrack.short": "进度正常 — 剩余 {amount}。", - "budget.insight.underspending.short": "余量充足 — 尚有 {amount} 未使用。", "budget.insight.over": "你在 {name} 已超出 {amount}。请减少支出或提高上限。", + "budget.insight.over.short": "已超出 {amount}。请减少支出或提高上限。", "budget.insight.overpacing": "照此速度,{name} 将超出 {amount}。", + "budget.insight.overpacing.short": "照此速度,将超出 {amount}。", "budget.insight.underspending": "{name} 余量充足 — 尚有 {amount} 未使用。", + "budget.insight.underspending.short": "余量充足 — 尚有 {amount} 未使用。", "budget.name": "预算名称", "budget.overview.allHealthy": "一切正常,做得好。", "budget.overview.budgetsTracked": "已跟踪 {count} 个预算", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "自动续期", "budget.renewAutomatically.description": "周期结束后,预算会进入新的周期。例如,七月的预算会变成八月的预算。", "budget.scope": "范围", + "budget.status.nearing": "接近上限", + "budget.status.onTrack": "进度正常", + "budget.status.over": "超出预算", "budgets": "预算", "budgets.new": "新建预算", "categories": "分类", @@ -946,4 +946,4 @@ "transactions.query.noResult": "没有可显示的交易", "transactions.query.noResult.description": "尝试更新您的过滤条件", "visitGitHubRepo": "前往 GitHub 查看" -} +} \ No newline at end of file diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index c7fa9dfa..29e69866 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -47,25 +47,22 @@ "budget.categories.description": "只有所選分類的支出會計入此預算。若未選擇任何分類,則計入所有支出。", "budget.delete": "刪除預算", "budget.delete.description": "交易不會受到影響。此操作無法復原!", - "budget.insight.nearingLimit": "{name} 已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", - "budget.insight.onTrack": "{name} 進度正常 — {amount} 剩餘。", - "budget.status.onTrack": "進度正常", - "budget.status.nearing": "接近上限", - "budget.status.over": "超出預算", - "budget.detail.history": "最近的期間", - "budget.detail.transactions": "交易", - "budget.detail.unavailable": "目前無法取得預算數字。", "budget.detail.daysLeft": "剩餘 {} 天", "budget.detail.daysLeft.one": "剩餘 {} 天", + "budget.detail.history": "最近的期間", "budget.detail.periodEnded": "期間已結束", - "budget.insight.over.short": "已超出 {amount}。請減少支出或提高上限。", + "budget.detail.transactions": "交易", + "budget.detail.unavailable": "目前無法取得預算數字。", + "budget.insight.nearingLimit": "{name} 已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", "budget.insight.nearingLimit.short": "已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", - "budget.insight.overpacing.short": "照此速度,將超出 {amount}。", + "budget.insight.onTrack": "{name} 進度正常 — {amount} 剩餘。", "budget.insight.onTrack.short": "進度正常 — {amount} 剩餘。", - "budget.insight.underspending.short": "空間充足 — 尚有 {amount} 未使用。", "budget.insight.over": "你在 {name} 超出 {amount}。請減少支出或提高上限。", + "budget.insight.over.short": "已超出 {amount}。請減少支出或提高上限。", "budget.insight.overpacing": "照此速度,{name} 將超出 {amount}。", + "budget.insight.overpacing.short": "照此速度,將超出 {amount}。", "budget.insight.underspending": "{name} 空間充足 — 尚有 {amount} 未使用。", + "budget.insight.underspending.short": "空間充足 — 尚有 {amount} 未使用。", "budget.name": "預算名稱", "budget.overview.allHealthy": "一切正常,做得好。", "budget.overview.budgetsTracked": "追蹤 {count} 個預算", @@ -87,10 +84,13 @@ "budget.renewAutomatically": "自動續期", "budget.renewAutomatically.description": "期間結束後,預算會進入新的期間。例如,七月的預算會變成八月的預算。", "budget.scope": "範圍", + "budget.status.nearing": "接近上限", + "budget.status.onTrack": "進度正常", + "budget.status.over": "超出預算", "budgets": "預算", "budgets.new": "新增預算", "categories": "分類", - "categories.addFromPresets": "從預設新增", + "categories.addFromPresets": "從設定檔新增", "categories.noCategories": "您目前沒有任何分類", "category": "分類", "category.delete": "刪除分類", @@ -175,7 +175,7 @@ "enum.ImportV2Progress@writingRecurringTransactions": "正在寫入週期性交易", "enum.ImportV2Progress@writingTransactionTags": "正在寫入交易標籤", "enum.ImportV2Progress@writingTransactions": "正在寫入交易", - "enum.ImportV2Progress@writingTranscationFilterPresets": "正在寫入交易過濾預設", + "enum.ImportV2Progress@writingTranscationFilterPresets": "正在寫入交易過濾設定檔", "enum.ImportV2Progress@writingUserPreferences": "正在寫入使用者偏好設定", "enum.PDFHeader@account": "帳戶", "enum.PDFHeader@amount": "金額", @@ -240,8 +240,8 @@ "enum.TransactionType@expense": "支出", "enum.TransactionType@income": "收入", "enum.TransactionType@transfer": "轉帳", - "error.exchangeRates.cannotFetch": "獲取失敗,請檢查您的網路連線。", - "error.exchangeRates.inaccurateDataDueToMissingRates": "獲取匯率失敗,交易資料可能無法完全準確。", + "error.exchangeRates.cannotFetch": "取得失敗,請檢查您的網路連線。", + "error.exchangeRates.inaccurateDataDueToMissingRates": "取得匯率失敗,交易資料可能無法完全準確。", "error.failedLocalAuth": "身分驗證失敗,請再試一次。", "error.input.cropFailed": "裁切圖片時發生錯誤", "error.input.duplicate.accountName": "名稱「{}」已被使用。請嘗試其他名稱。", @@ -277,7 +277,7 @@ "fileAttachment.pick": "選擇檔案", "fileAttachment.share": "分享檔案", "fileAttachment.takePhoto": "拍照", - "flowIcon.change": "更改圖示", + "flowIcon.change": "變更圖示", "flowIcon.type.character": "表情符號/字母", "flowIcon.type.character.description": "輸入表情符號或字母以作為圖示", "flowIcon.type.icon": "圖示", @@ -324,12 +324,12 @@ "integrations.eny.connected#false": "未連接", "integrations.eny.connected#true": "已連接", "integrations.eny.creditsRemaining": "剩餘點數", - "integrations.eny.dashboard": "Eny 儀表板", - "integrations.eny.dashboard.description": "您可以從儀表板連接您的 Eny 帳戶", + "integrations.eny.dashboard": "Eny 儀錶板", + "integrations.eny.dashboard.description": "您可以從儀錶板連接您的 Eny 帳戶", "integrations.eny.disconnect": "中斷連接 Eny", "integrations.eny.invalidCredentials": "憑證無效", "integrations.eny.invalidCredentials.configure": "設定", - "integrations.eny.invalidCredentials.description": "請嘗試從儀表板重新連接 Eny", + "integrations.eny.invalidCredentials.description": "請嘗試從儀錶板重新連接 Eny", "integrations.eny.multipleImagesNotice": "傳送 {n} 張圖片進行處理?", "integrations.eny.multipleImagesNotice.checkNotice": "即使有部分圖片失敗,仍會消耗點數。因此,在確認前請仔細檢查圖片。您一次最多可傳送 5 張圖片。", "integrations.eny.multipleImagesNotice.description": "將消耗 {n} 點 Eny 點數", @@ -396,7 +396,7 @@ "preferences.reminders.remindDaily.expiryWarning": "如果您連續 7 天未開啟 Flow,提醒將會自動停止", "preferences.reminders.remindDaily.time": "提醒時間", "preferences.reminders.unsupportedPlatform": "此平台不支援排程通知", - "preferences.scan": "掃描文件", + "preferences.scan": "掃描檔案", "preferences.scan.createTransactionsPerItemInScans": "掃描的每個項目建立單獨交易", "preferences.scan.createTransactionsPerItemInScans.description": "對過長的收據可能會很混亂", "preferences.scan.markPendingThreshold": "將交易標示為待處理", @@ -417,7 +417,7 @@ "preferences.sync.iCloud.noOfBackupsToKeep.description": "在 iCloud 中保留備份檔案的最大數量。這將佔用您的 iCloud 空間,因此您可能希望將此數字設低一些。在每次啟動時會根據此設定刪除舊備份。", "preferences.sync.iCloud.noOfBackupsToKeep.infiniteBackups": "無限數量", "preferences.sync.iCloud.noOfBackupsToKeep.nBackups": "{n} 個備份", - "preferences.sync.iCloud.privacyNotice": "您的資料存儲在您的 iCloud 內一個僅供 Flow 存取的私人空間。其他人無法存取您的資料,Flow 亦無法存取您 iCloud 中的其他資料。", + "preferences.sync.iCloud.privacyNotice": "您的資料儲存在您的 iCloud 內一個僅供 Flow 存取的私人空間。其他人無法存取您的資料,Flow 亦無法存取您 iCloud 中的其他資料。", "preferences.sync.iCloud.singleDeviceSupportDisclaimer": "此功能不支援在多個裝置上同步。使用多個裝置可能導致資料遺失。", "preferences.theme": "主題", "preferences.theme.choose": "選擇主題", @@ -425,7 +425,7 @@ "preferences.theme.enableOledTheme": "使用 OLED 深色主題", "preferences.theme.other": "其他主題", "preferences.theme.themeChangesAppIcon": "應用程式圖示跟隨主題變更", - "preferences.transactionButtonOrder": "按鈕配置", + "preferences.transactionButtonOrder": "按鈕設定", "preferences.transactionButtonOrder.description": "變更首頁新增交易按鈕的順序", "preferences.transactionButtonOrder.guide": "拖曳按鈕以重新排序", "preferences.transactionButtonOrder.widgetDescription": "此順序將會反映在主畫面的交易按鈕小工具上", @@ -529,7 +529,7 @@ "setup.categories.existing": "現有分類", "setup.categories.preset.beauty": "美容", "setup.categories.preset.childCare": "育兒", - "setup.categories.preset.donations": "捐款", + "setup.categories.preset.donations": "捐助", "setup.categories.preset.drinks": "飲品", "setup.categories.preset.eatingOut": "外食", "setup.categories.preset.education": "教育", @@ -568,12 +568,12 @@ "setup.onboarding.recoverICloudBackup.description.loading": "從 iCloud 復原 (正在載入資料...)", "setup.onboarding.recoverICloudBackup.description.none": "從 iCloud 復原 (找不到備份)", "setup.primaryCurrency.choose": "選擇幣別", - "setup.primaryCurrency.description": "這將成為您的主要幣別。您稍後可以在「偏好設定」中更改。", + "setup.primaryCurrency.description": "這將成為您的主要幣別。您稍後可以在「偏好設定」中變更。", "setup.primaryCurrency.setup": "選擇主要幣別", "setup.profile.addPhoto": "新增相片", "setup.profile.addPhoto.skip": "略過", "setup.profile.setup": "您叫什麼名字?", - "setup.slides.foss.description": "完全免費且開源代碼公開。", + "setup.slides.foss.description": "完全免費且原始碼公開。", "setup.slides.foss.seeRepo": "在 GitHub 上查看專案", "setup.slides.foss.title": "免費 & 開源", "setup.slides.privacy": "完全掌控您的資料", @@ -583,23 +583,23 @@ "support.contribute": "貢獻程式碼", "support.contribute.description": "如果您是一位開發者,您可以參與 Flow 的開發。貢獻者名單等待您的加入。", "support.description": "Flow 是一款出於熱忱、免費且為所有人開源的軟體。如果您認為 Flow 很有幫助,考慮協助專案成長!您可以透過以下方式支持我們:", - "support.donateDeveloper": "打賞開發者", + "support.donateDeveloper": "捐助開發者", "support.donateDeveloper.action": "請創作者喝杯咖啡", - "support.donateDeveloper.description": "Flow 的所有功能均免費提供,打賞開發者不會解鎖任何額外功能", + "support.donateDeveloper.description": "Flow 的所有功能均免費提供,捐助開發者不會解鎖任何額外功能", "support.leaveAReview": "留下評論", "support.leaveAReview.action": "為 Flow 評分", "support.leaveAReview.description": "您可以在 {appStore} 為 Flow 評分並留下您的評論", "support.starOnGitHub": "在 GitHub 上給顆星星", "support.starOnGitHub.description": "在 GitHub 上給 Flow 點擊星星有助於讓更多人發現我們", "support.tip.error": "無法開始購買。請再試一次。", - "support.tip.thankYou": "感謝您支持 Flow!💜", + "support.tip.thankYou": "感謝您支援 Flow!💜", "sync.export": "匯出", "sync.export.asCSV": "匯出為 CSV", "sync.export.asCSV.description": "不可用於還原/匯入!適合在 Google 試算表等軟體中開啟", "sync.export.asJSON": "匯出為備份 (JSON)", "sync.export.asJSON.description": "僅包含基本資料,不含圖片或附加檔案。", "sync.export.asPDF": "報表 (PDF)", - "sync.export.asPDF.description": "帳戶報表;適合列印。這並非官方正式文件,僅供您個人使用。", + "sync.export.asPDF.description": "帳戶報表;適合列印。這並非官方正式檔案,僅供您個人使用。", "sync.export.asZIP": "匯出為備份 (ZIP)", "sync.export.asZIP.description": "稍後可完全還原。包含附加檔案", "sync.export.autoBackup": "自動備份", @@ -617,7 +617,7 @@ "sync.export.pdf.generatedAt": "產生時間", "sync.export.pdf.header": "Flow - 個人財務紀錄 (非官方,{range})", "sync.export.pdf.notice[0]": "由 ", - "sync.export.pdf.notice[1]": " 產生。這不是一份具法律效力的文件。這不是財務報表。這不是收據。這不以任何形式代表現實狀況。此文件僅供個人參考使用。", + "sync.export.pdf.notice[1]": " 產生。這不是一份具法律效力的檔案。這不是財務報表。這不是收據。這不以任何形式代表現實狀況。此檔案僅供個人參考使用。", "sync.export.pdf.size": "紙張尺寸", "sync.export.pdf.summary": "按帳戶統計摘要 ({range})", "sync.export.pdf.summary.allAcounts": "所有帳戶", @@ -655,7 +655,7 @@ "sync.import.syncData.parsedEstimate.fileAttachmentsCount": "{count} 個檔案附件", "sync.import.syncData.parsedEstimate.goalCount": "{count} 個目標", "sync.import.syncData.parsedEstimate.transactionCount": "{count} 筆交易", - "sync.import.syncData.parsedEstimate.transactionFilterPresets": "{count} 個交易過濾預設", + "sync.import.syncData.parsedEstimate.transactionFilterPresets": "{count} 個交易過濾設定檔", "sync.import.syncData.parsedEstimate.transactionTagCount": "{count} 個交易標籤", "sync.import.zipWarning": "請確保匯入的 ZIP 檔案是由 Flow App 所產生的!", "tabs.accounts": "帳戶", @@ -667,7 +667,7 @@ "tabs.home.last7days": "過去 7 天", "tabs.home.noTransactions": "沒有符合條件的交易", "tabs.home.noTransactions.addSome": "點擊下方的 (+) 按鈕新增一筆交易", - "tabs.home.noTransactions.tryChangingFilters": "嘗試更改過濾條件", + "tabs.home.noTransactions.tryChangingFilters": "嘗試變更過濾條件", "tabs.home.pendingTransactions": "待處理 ({count})", "tabs.home.pendingTransactions.needAttention": "有 {} 筆交易需要確認", "tabs.home.pendingTransactions.seeAll": "查看全部", @@ -697,7 +697,7 @@ "tabs.profile.other": "其他", "tabs.profile.preferences": "偏好設定", "tabs.profile.recommend": "推薦 Flow", - "tabs.profile.support": "支持 Flow", + "tabs.profile.support": "支援 Flow", "tabs.profile.withLoveFromTheCreator": "來自 sadespresso 🤍 的作品", "tabs.stats": "統計", "tabs.stats.analytics.budgets": "預算", @@ -894,15 +894,15 @@ "transaction.transfer.to": "轉入帳戶", "transaction.transfer.to.select": "轉入至此", "transaction.transfer.to.title": "轉入至 {account}", - "transactionFilterPreset": "過濾預設", - "transactionFilterPreset.default": "預設選項", - "transactionFilterPreset.delete": "刪除預設", + "transactionFilterPreset": "過濾設定檔", + "transactionFilterPreset.default": "設定檔選項", + "transactionFilterPreset.delete": "刪除設定檔", "transactionFilterPreset.invalid": "無效", - "transactionFilterPreset.invalid.description": "此預設中的某些帳戶/分類已遺失。請重新建立或刪除該預設", + "transactionFilterPreset.invalid.description": "此設定檔中的某些帳戶/分類已遺失。請重新增立或刪除該設定檔", "transactionFilterPreset.makeDefault": "設為預設", - "transactionFilterPreset.saveAsNew": "另存為新預設", - "transactionFilterPreset.saveAsNew.guide": "要儲存新的預設,請先變更過濾條件後再回到這裡", - "transactionFilterPreset.saveAsNew.name": "預設名稱", + "transactionFilterPreset.saveAsNew": "另存為新設定檔", + "transactionFilterPreset.saveAsNew.guide": "要儲存新的設定檔,請先變更過濾條件後再回到這裡", + "transactionFilterPreset.saveAsNew.name": "設定檔名稱", "transactions.all": "所有交易", "transactions.batch.assignAccountForAll": "為全部指派帳戶", "transactions.batch.assignAccountIndividually": "個別指派帳戶", From f56ab8cdd60c7a9eadb99c02c79af7483c5dbd12 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 13 Sep 2026 22:53:12 +0800 Subject: [PATCH 04/57] format files --- analysis_options.yaml | 5 +++++ assets/l10n/ar.json | 22 +++++++++++----------- assets/l10n/be_BY.json | 22 +++++++++++----------- assets/l10n/cs_CZ.json | 22 +++++++++++----------- assets/l10n/de_DE.json | 22 +++++++++++----------- assets/l10n/en.json | 22 +++++++++++----------- assets/l10n/es_ES.json | 22 +++++++++++----------- assets/l10n/fa_IR.json | 22 +++++++++++----------- assets/l10n/fr_FR.json | 22 +++++++++++----------- assets/l10n/it_IT.json | 22 +++++++++++----------- assets/l10n/mn_MN.json | 24 ++++++++++++------------ assets/l10n/pl_PL.json | 22 +++++++++++----------- assets/l10n/ru_RU.json | 22 +++++++++++----------- assets/l10n/tr_TR.json | 22 +++++++++++----------- assets/l10n/uk_UA.json | 22 +++++++++++----------- assets/l10n/zh_CN.json | 24 ++++++++++++------------ assets/l10n/zh_TW.json | 22 +++++++++++----------- pubspec.lock | 28 ++++++++++++++-------------- reformat_l10n_files.sh | 2 +- 19 files changed, 198 insertions(+), 193 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 978698e8..ec57be45 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -12,3 +12,8 @@ analyzer: - "build/**" - "**/*.g.dart" - "**/*.freezed.dart" + - android/** + - ios/** + - windows/** + - macos/** + - linux/** diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e0d3245b..e1f16610 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -47,25 +47,22 @@ "budget.categories.description": "تُحتسب في هذه الميزانية نفقات الفئات المحددة فقط. لا تحدد أي فئة لاحتساب جميع النفقات.", "budget.delete": "حذف الميزانية", "budget.delete.description": "لن تتأثر المعاملات. هذا الإجراء لا رجعة فيه!", - "budget.insight.nearingLimit": "{name} تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", - "budget.insight.onTrack": "{name} على المسار الصحيح — {amount} متبقي.", - "budget.status.onTrack": "ضمن الحد", - "budget.status.nearing": "قارب الحد", - "budget.status.over": "تجاوز الحد", - "budget.detail.history": "الفترات الأخيرة", - "budget.detail.transactions": "المعاملات", - "budget.detail.unavailable": "أرقام الميزانية غير متوفرة حاليًا.", "budget.detail.daysLeft": "{} أيام متبقية", "budget.detail.daysLeft.one": "{} يوم متبقٍ", + "budget.detail.history": "الفترات الأخيرة", "budget.detail.periodEnded": "انتهت الفترة", - "budget.insight.over.short": "أنت متجاوز بمقدار {amount}. قلل الإنفاق أو ارفع الحد.", + "budget.detail.transactions": "المعاملات", + "budget.detail.unavailable": "أرقام الميزانية غير متوفرة حاليًا.", + "budget.insight.nearingLimit": "{name} تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", "budget.insight.nearingLimit.short": "تم إنفاق {percent}%، تبقى {days} يومًا. خفف الإنفاق لتبقى ضمن الحد.", - "budget.insight.overpacing.short": "بهذه الوتيرة، ستتجاوز الحد بمقدار {amount}.", + "budget.insight.onTrack": "{name} على المسار الصحيح — {amount} متبقي.", "budget.insight.onTrack.short": "على المسار الصحيح — {amount} متبقي.", - "budget.insight.underspending.short": "لديك متسع — {amount} غير مُنفق.", "budget.insight.over": "أنت متجاوز بمقدار {amount} في {name}. قلل الإنفاق أو ارفع الحد.", + "budget.insight.over.short": "أنت متجاوز بمقدار {amount}. قلل الإنفاق أو ارفع الحد.", "budget.insight.overpacing": "بهذه الوتيرة، سيتجاوز {name} الحد بمقدار {amount}.", + "budget.insight.overpacing.short": "بهذه الوتيرة، ستتجاوز الحد بمقدار {amount}.", "budget.insight.underspending": "{name} لديه متسع — {amount} غير مُنفق.", + "budget.insight.underspending.short": "لديك متسع — {amount} غير مُنفق.", "budget.name": "اسم الميزانية", "budget.overview.allHealthy": "كل شيء على المسار الصحيح. عمل رائع.", "budget.overview.budgetsTracked": "{count} ميزانيات متبعة", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "تجديد تلقائي", "budget.renewAutomatically.description": "عند انتهاء الفترة، تنتقل الميزانية إلى الفترة الجديدة. على سبيل المثال، تصبح ميزانية يوليو ميزانية أغسطس.", "budget.scope": "النطاق", + "budget.status.nearing": "قارب الحد", + "budget.status.onTrack": "ضمن الحد", + "budget.status.over": "تجاوز الحد", "budgets": "الميزانيات", "budgets.new": "ميزانية جديدة", "categories": "الفئات", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index a4856514..12330f6f 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -47,25 +47,22 @@ "budget.categories.description": "У гэты бюджэт уваходзяць толькі выдаткі выбраных катэгорый. Не выбірайце нічога, каб улічваць усе выдаткі.", "budget.delete": "Выдаліць бюджэт", "budget.delete.description": "Транзакцыі не будуць закрануты. Гэта дзеянне незваротнае!", - "budget.insight.nearingLimit": "{name} выкарыстана на {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", - "budget.insight.onTrack": "{name} у парадку — засталося {amount}.", - "budget.status.onTrack": "У парадку", - "budget.status.nearing": "Блізка да ліміту", - "budget.status.over": "Ліміт перавышаны", - "budget.detail.history": "Апошнія перыяды", - "budget.detail.transactions": "Транзакцыі", - "budget.detail.unavailable": "Даныя бюджэту зараз недаступныя.", "budget.detail.daysLeft": "Засталося {} дзён", "budget.detail.daysLeft.one": "Застаўся {} дзень", + "budget.detail.history": "Апошнія перыяды", "budget.detail.periodEnded": "Перыяд скончыўся", - "budget.insight.over.short": "Вы перавысілі ліміт на {amount}. Зменшыце выдаткі або павялічце ліміт.", + "budget.detail.transactions": "Транзакцыі", + "budget.detail.unavailable": "Даныя бюджэту зараз недаступныя.", + "budget.insight.nearingLimit": "{name} выкарыстана на {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", "budget.insight.nearingLimit.short": "Выкарыстана {percent}% — застаецца {days}d. Памяньшыце выдаткі, каб застацца ў межах.", - "budget.insight.overpacing.short": "Пры такім тэмпе вы перавысіце ліміт на {amount}.", + "budget.insight.onTrack": "{name} у парадку — засталося {amount}.", "budget.insight.onTrack.short": "У парадку — засталося {amount}.", - "budget.insight.underspending.short": "Ёсць запас — не выдаткавана {amount}.", "budget.insight.over": "Вы перавысілі ліміт па {name} на {amount}. Зменшыце выдаткі або павялічце ліміт.", + "budget.insight.over.short": "Вы перавысілі ліміт на {amount}. Зменшыце выдаткі або павялічце ліміт.", "budget.insight.overpacing": "Пры такім тэмпе {name} перавысіць ліміт на {amount}.", + "budget.insight.overpacing.short": "Пры такім тэмпе вы перавысіце ліміт на {amount}.", "budget.insight.underspending": "{name} мае запас — не выдаткавана {amount}.", + "budget.insight.underspending.short": "Ёсць запас — не выдаткавана {amount}.", "budget.name": "Назва бюджэту", "budget.overview.allHealthy": "Усё ідзе па плане. Выдатная праца.", "budget.overview.budgetsTracked": "{count} бюджэтаў адсочваецца", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Аднаўляць аўтаматычна", "budget.renewAutomatically.description": "Калі перыяд заканчваецца, бюджэт пераходзіць на новы перыяд. Напрыклад, бюджэт ліпеня становіцца бюджэтам жніўня.", "budget.scope": "Ахоп", + "budget.status.nearing": "Блізка да ліміту", + "budget.status.onTrack": "У парадку", + "budget.status.over": "Ліміт перавышаны", "budgets": "Бюджэты", "budgets.new": "Новы бюджэт", "categories": "Катэгорыі", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index bc0073a9..25fe07cf 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -47,25 +47,22 @@ "budget.categories.description": "Do tohoto rozpočtu se počítají pouze výdaje ve vybraných kategoriích. Nevyberete-li žádnou, počítají se všechny výdaje.", "budget.delete": "Smazat rozpočet", "budget.delete.description": "Transakce nebudou ovlivněny. Tato akce je nevratná!", - "budget.insight.nearingLimit": "{name}: {percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", - "budget.insight.onTrack": "{name} je na správné cestě — zbývá {amount}.", - "budget.status.onTrack": "V pořádku", - "budget.status.nearing": "Blízko limitu", - "budget.status.over": "Nad limitem", - "budget.detail.history": "Poslední období", - "budget.detail.transactions": "Transakce", - "budget.detail.unavailable": "Údaje rozpočtu nejsou momentálně k dispozici.", "budget.detail.daysLeft": "Zbývá {} dní", "budget.detail.daysLeft.one": "Zbývá {} den", + "budget.detail.history": "Poslední období", "budget.detail.periodEnded": "Období skončilo", - "budget.insight.over.short": "Překročili jste rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", + "budget.detail.transactions": "Transakce", + "budget.detail.unavailable": "Údaje rozpočtu nejsou momentálně k dispozici.", + "budget.insight.nearingLimit": "{name}: {percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", "budget.insight.nearingLimit.short": "{percent}% využito, do konce zbývá {days} dní. Zpomalte utrácení, abyste zůstali pod limitem.", - "budget.insight.overpacing.short": "Při tomto tempu překročíte rozpočet o {amount}.", + "budget.insight.onTrack": "{name} je na správné cestě — zbývá {amount}.", "budget.insight.onTrack.short": "Na správné cestě — zbývá {amount}.", - "budget.insight.underspending.short": "Máte rezervu — {amount} nevyužito.", "budget.insight.over": "U {name} jste překročili rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", + "budget.insight.over.short": "Překročili jste rozpočet o {amount}. Omezte utrácení nebo zvyšte limit.", "budget.insight.overpacing": "Při tomto tempu {name} překročí rozpočet o {amount}.", + "budget.insight.overpacing.short": "Při tomto tempu překročíte rozpočet o {amount}.", "budget.insight.underspending": "{name} má rezervu — {amount} nevyužito.", + "budget.insight.underspending.short": "Máte rezervu — {amount} nevyužito.", "budget.name": "Název rozpočtu", "budget.overview.allHealthy": "Vše jde podle plánu. Dobrá práce.", "budget.overview.budgetsTracked": "{count} sledovaných rozpočtů", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Obnovovat automaticky", "budget.renewAutomatically.description": "Po skončení období rozpočet přejde do nového období. Například červencový rozpočet se stane srpnovým.", "budget.scope": "Rozsah", + "budget.status.nearing": "Blízko limitu", + "budget.status.onTrack": "V pořádku", + "budget.status.over": "Nad limitem", "budgets": "Rozpočty", "budgets.new": "Nový rozpočet", "categories": "Kategorie", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 79168597..8bd1995d 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -47,25 +47,22 @@ "budget.categories.description": "Nur Ausgaben in den ausgewählten Kategorien zählen zu diesem Budget. Ohne Auswahl zählen alle Ausgaben.", "budget.delete": "Budget löschen", "budget.delete.description": "Buchungen sind davon nicht betroffen. Diese Aktion ist unwiderruflich!", - "budget.insight.nearingLimit": "{name} ist zu {percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", - "budget.insight.onTrack": "{name} ist auf Kurs — {amount} übrig.", - "budget.status.onTrack": "Auf Kurs", - "budget.status.nearing": "Nahe am Limit", - "budget.status.over": "Über dem Limit", - "budget.detail.history": "Letzte Zeiträume", - "budget.detail.transactions": "Buchungen", - "budget.detail.unavailable": "Budgetzahlen sind derzeit nicht verfügbar.", "budget.detail.daysLeft": "Noch {} Tage", "budget.detail.daysLeft.one": "Noch {} Tag", + "budget.detail.history": "Letzte Zeiträume", "budget.detail.periodEnded": "Zeitraum beendet", - "budget.insight.over.short": "Sie liegen {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", + "budget.detail.transactions": "Buchungen", + "budget.detail.unavailable": "Budgetzahlen sind derzeit nicht verfügbar.", + "budget.insight.nearingLimit": "{name} ist zu {percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", "budget.insight.nearingLimit.short": "{percent}% ausgeschöpft, {days} Tage verbleiben. Drosseln Sie die Ausgaben, um das Limit einzuhalten.", - "budget.insight.overpacing.short": "Bei diesem Tempo überschreiten Sie das Budget um {amount}.", + "budget.insight.onTrack": "{name} ist auf Kurs — {amount} übrig.", "budget.insight.onTrack.short": "Auf Kurs — {amount} übrig.", - "budget.insight.underspending.short": "Noch viel Spielraum — {amount} nicht ausgegeben.", "budget.insight.over": "Sie liegen bei {name} um {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", + "budget.insight.over.short": "Sie liegen {amount} über dem Budget. Reduzieren Sie die Ausgaben oder erhöhen Sie das Limit.", "budget.insight.overpacing": "Bei diesem Tempo wird {name} das Budget um {amount} überschreiten.", + "budget.insight.overpacing.short": "Bei diesem Tempo überschreiten Sie das Budget um {amount}.", "budget.insight.underspending": "{name} hat noch viel Spielraum — {amount} nicht ausgegeben.", + "budget.insight.underspending.short": "Noch viel Spielraum — {amount} nicht ausgegeben.", "budget.name": "Budgetname", "budget.overview.allHealthy": "Alles läuft nach Plan. Gut gemacht.", "budget.overview.budgetsTracked": "{count} Budgets verfolgt", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Automatisch erneuern", "budget.renewAutomatically.description": "Wenn der Zeitraum endet, geht das Budget in den neuen Zeitraum über. Zum Beispiel wird aus einem Juli-Budget ein August-Budget.", "budget.scope": "Umfang", + "budget.status.nearing": "Nahe am Limit", + "budget.status.onTrack": "Auf Kurs", + "budget.status.over": "Über dem Limit", "budgets": "Budgets", "budgets.new": "Neues Budget", "categories": "Kategorien", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 34e7da2d..7c8ff122 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -47,25 +47,22 @@ "budget.categories.description": "Only spending in the selected categories counts towards this budget. Select none to count all spending.", "budget.delete": "Delete budget", "budget.delete.description": "Transactions won't be affected. This action is irreversible!", - "budget.insight.nearingLimit": "{name} is {percent}% spent with {days}d to go. Ease up to stay under.", - "budget.insight.onTrack": "{name} is on track — {amount} left.", - "budget.status.onTrack": "On track", - "budget.status.nearing": "Nearing limit", - "budget.status.over": "Over budget", - "budget.detail.history": "Recent periods", - "budget.detail.transactions": "Transactions", - "budget.detail.unavailable": "Budget numbers aren't available right now.", "budget.detail.daysLeft": "{} days left", "budget.detail.daysLeft.one": "{} day left", + "budget.detail.history": "Recent periods", "budget.detail.periodEnded": "Period ended", - "budget.insight.over.short": "You're {amount} over. Trim spending or raise the limit.", + "budget.detail.transactions": "Transactions", + "budget.detail.unavailable": "Budget numbers aren't available right now.", + "budget.insight.nearingLimit": "{name} is {percent}% spent with {days}d to go. Ease up to stay under.", "budget.insight.nearingLimit.short": "{percent}% spent with {days}d to go. Ease up to stay under.", - "budget.insight.overpacing.short": "At this pace, you'll overshoot by {amount}.", + "budget.insight.onTrack": "{name} is on track — {amount} left.", "budget.insight.onTrack.short": "On track — {amount} left.", - "budget.insight.underspending.short": "Plenty of room — {amount} unspent.", "budget.insight.over": "You're {amount} over on {name}. Trim spending or raise the limit.", + "budget.insight.over.short": "You're {amount} over. Trim spending or raise the limit.", "budget.insight.overpacing": "At this pace, {name} will overshoot by {amount}.", + "budget.insight.overpacing.short": "At this pace, you'll overshoot by {amount}.", "budget.insight.underspending": "{name} has plenty of room — {amount} unspent.", + "budget.insight.underspending.short": "Plenty of room — {amount} unspent.", "budget.name": "Budget name", "budget.overview.allHealthy": "Everything's on track. Nice work.", "budget.overview.budgetsTracked": "{count} budgets tracked", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renew automatically", "budget.renewAutomatically.description": "When the period ends, the budget rolls over to the new period. For example, a July budget becomes an August budget.", "budget.scope": "Scope", + "budget.status.nearing": "Nearing limit", + "budget.status.onTrack": "On track", + "budget.status.over": "Over budget", "budgets": "Budgets", "budgets.new": "New budget", "categories": "Categories", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index f87e7652..8953bfd6 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -47,25 +47,22 @@ "budget.categories.description": "Solo los gastos de las categorías seleccionadas cuentan para este presupuesto. No selecciones ninguna para contar todos los gastos.", "budget.delete": "Eliminar presupuesto", "budget.delete.description": "Las transacciones no se verán afectadas. ¡Esta acción es irreversible!", - "budget.insight.nearingLimit": "{name} está al {percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", - "budget.insight.onTrack": "{name} va según lo previsto — quedan {amount}.", - "budget.status.onTrack": "En orden", - "budget.status.nearing": "Cerca del límite", - "budget.status.over": "Límite superado", - "budget.detail.history": "Períodos recientes", - "budget.detail.transactions": "Transacciones", - "budget.detail.unavailable": "Los datos del presupuesto no están disponibles ahora mismo.", "budget.detail.daysLeft": "Quedan {} días", "budget.detail.daysLeft.one": "Queda {} día", + "budget.detail.history": "Períodos recientes", "budget.detail.periodEnded": "Período finalizado", - "budget.insight.over.short": "Te has excedido {amount}. Reduce el gasto o aumenta el límite.", + "budget.detail.transactions": "Transacciones", + "budget.detail.unavailable": "Los datos del presupuesto no están disponibles ahora mismo.", + "budget.insight.nearingLimit": "{name} está al {percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", "budget.insight.nearingLimit.short": "{percent}% gastado y quedan {days}d. Reduce el ritmo para mantenerte por debajo.", - "budget.insight.overpacing.short": "A este ritmo, te excederás en {amount}.", + "budget.insight.onTrack": "{name} va según lo previsto — quedan {amount}.", "budget.insight.onTrack.short": "Según lo previsto — quedan {amount}.", - "budget.insight.underspending.short": "Tienes margen — {amount} sin gastar.", "budget.insight.over": "Has excedido {amount} en {name}. Reduce el gasto o aumenta el límite.", + "budget.insight.over.short": "Te has excedido {amount}. Reduce el gasto o aumenta el límite.", "budget.insight.overpacing": "A este ritmo, {name} se excederá en {amount}.", + "budget.insight.overpacing.short": "A este ritmo, te excederás en {amount}.", "budget.insight.underspending": "{name} tiene margen — {amount} sin gastar.", + "budget.insight.underspending.short": "Tienes margen — {amount} sin gastar.", "budget.name": "Nombre del presupuesto", "budget.overview.allHealthy": "Todo va según lo previsto. Buen trabajo.", "budget.overview.budgetsTracked": "{count} presupuestos registrados", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renovar automáticamente", "budget.renewAutomatically.description": "Cuando termina el período, el presupuesto pasa al nuevo período. Por ejemplo, un presupuesto de julio se convierte en uno de agosto.", "budget.scope": "Alcance", + "budget.status.nearing": "Cerca del límite", + "budget.status.onTrack": "En orden", + "budget.status.over": "Límite superado", "budgets": "Presupuestos", "budgets.new": "Nuevo presupuesto", "categories": "Categorías", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index bd9ef44c..2204f9b6 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -47,25 +47,22 @@ "budget.categories.description": "فقط هزینه‌های دسته‌بندی‌های انتخاب‌شده در این بودجه حساب می‌شوند. برای احتساب همه هزینه‌ها هیچ‌کدام را انتخاب نکنید.", "budget.delete": "حذف بودجه", "budget.delete.description": "تراکنش‌ها تغییری نمی‌کنند. این عمل غیرقابل بازگشت است!", - "budget.insight.nearingLimit": "{name}، {percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", - "budget.insight.onTrack": "{name} در مسیر درست است — {amount} باقی‌مانده.", - "budget.status.onTrack": "در مسیر درست", - "budget.status.nearing": "نزدیک به حد", - "budget.status.over": "بیش از بودجه", - "budget.detail.history": "دوره‌های اخیر", - "budget.detail.transactions": "تراکنش‌ها", - "budget.detail.unavailable": "اعداد بودجه در حال حاضر در دسترس نیستند.", "budget.detail.daysLeft": "{} روز مانده", "budget.detail.daysLeft.one": "{} روز مانده", + "budget.detail.history": "دوره‌های اخیر", "budget.detail.periodEnded": "دوره به پایان رسید", - "budget.insight.over.short": "شما {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", + "budget.detail.transactions": "تراکنش‌ها", + "budget.detail.unavailable": "اعداد بودجه در حال حاضر در دسترس نیستند.", + "budget.insight.nearingLimit": "{name}، {percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", "budget.insight.nearingLimit.short": "{percent}% مصرف شده و {days} روز مانده. کمی صرفه‌جویی کنید تا از حد عبور نکند.", - "budget.insight.overpacing.short": "با این روند، تا {amount} بیش از بودجه خواهید رفت.", + "budget.insight.onTrack": "{name} در مسیر درست است — {amount} باقی‌مانده.", "budget.insight.onTrack.short": "در مسیر درست — {amount} باقی‌مانده.", - "budget.insight.underspending.short": "فضای زیادی دارید — {amount} خرج‌نشده.", "budget.insight.over": "شما در {name} {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", + "budget.insight.over.short": "شما {amount} بیشتر از بودجه خرج کرده‌اید. هزینه‌ها را کاهش دهید یا سقف را افزایش دهید.", "budget.insight.overpacing": "با این روند، {name} تا {amount} بیش از بودجه خواهد شد.", + "budget.insight.overpacing.short": "با این روند، تا {amount} بیش از بودجه خواهید رفت.", "budget.insight.underspending": "{name} فضای زیادی دارد — {amount} خرج‌نشده.", + "budget.insight.underspending.short": "فضای زیادی دارید — {amount} خرج‌نشده.", "budget.name": "نام بودجه", "budget.overview.allHealthy": "همه چیز طبق برنامه است. آفرین.", "budget.overview.budgetsTracked": "{count} بودجه پیگیری شدند", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "تمدید خودکار", "budget.renewAutomatically.description": "با پایان دوره، بودجه به دوره جدید منتقل می‌شود. برای مثال، بودجه ژوئیه به بودجه اوت تبدیل می‌شود.", "budget.scope": "محدوده", + "budget.status.nearing": "نزدیک به حد", + "budget.status.onTrack": "در مسیر درست", + "budget.status.over": "بیش از بودجه", "budgets": "بودجه‌ها", "budgets.new": "بودجه جدید", "categories": "دسته‌بندی‌ها", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index f4d91732..4a941c23 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -47,25 +47,22 @@ "budget.categories.description": "Seules les dépenses des catégories sélectionnées comptent dans ce budget. N'en sélectionnez aucune pour compter toutes les dépenses.", "budget.delete": "Supprimer le budget", "budget.delete.description": "Les transactions ne seront pas affectées. Cette action est irréversible !", - "budget.insight.nearingLimit": "{name} est à {percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", - "budget.insight.onTrack": "{name} est dans les clous — {amount} restants.", - "budget.status.onTrack": "Dans les clous", - "budget.status.nearing": "Limite proche", - "budget.status.over": "Budget dépassé", - "budget.detail.history": "Périodes récentes", - "budget.detail.transactions": "Transactions", - "budget.detail.unavailable": "Les chiffres du budget ne sont pas disponibles pour le moment.", "budget.detail.daysLeft": "{} jours restants", "budget.detail.daysLeft.one": "{} jour restant", + "budget.detail.history": "Périodes récentes", "budget.detail.periodEnded": "Période terminée", - "budget.insight.over.short": "Vous êtes en dépassement de {amount}. Réduisez les dépenses ou augmentez la limite.", + "budget.detail.transactions": "Transactions", + "budget.detail.unavailable": "Les chiffres du budget ne sont pas disponibles pour le moment.", + "budget.insight.nearingLimit": "{name} est à {percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", "budget.insight.nearingLimit.short": "{percent}% dépensé — {days}j restants. Modérez vos dépenses pour rester sous la limite.", - "budget.insight.overpacing.short": "À ce rythme, vous dépasserez la limite de {amount}.", + "budget.insight.onTrack": "{name} est dans les clous — {amount} restants.", "budget.insight.onTrack.short": "Dans les clous — {amount} restants.", - "budget.insight.underspending.short": "Il reste de la marge — {amount} non dépensé.", "budget.insight.over": "Vous êtes en dépassement de {amount} pour {name}. Réduisez les dépenses ou augmentez la limite.", + "budget.insight.over.short": "Vous êtes en dépassement de {amount}. Réduisez les dépenses ou augmentez la limite.", "budget.insight.overpacing": "À ce rythme, {name} dépassera la limite de {amount}.", + "budget.insight.overpacing.short": "À ce rythme, vous dépasserez la limite de {amount}.", "budget.insight.underspending": "{name} a de la marge — {amount} non dépensé.", + "budget.insight.underspending.short": "Il reste de la marge — {amount} non dépensé.", "budget.name": "Nom du budget", "budget.overview.allHealthy": "Tout est sur la bonne voie. Bon travail.", "budget.overview.budgetsTracked": "{count} budgets suivis", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Renouveler automatiquement", "budget.renewAutomatically.description": "À la fin de la période, le budget passe à la période suivante. Par exemple, un budget de juillet devient un budget d'août.", "budget.scope": "Périmètre", + "budget.status.nearing": "Limite proche", + "budget.status.onTrack": "Dans les clous", + "budget.status.over": "Budget dépassé", "budgets": "Budgets", "budgets.new": "Nouveau budget", "categories": "Catégories", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index b2697528..1c210f49 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -47,25 +47,22 @@ "budget.categories.description": "Solo le spese nelle categorie selezionate contano per questo budget. Non selezionarne nessuna per contare tutte le spese.", "budget.delete": "Elimina budget", "budget.delete.description": "Le transazioni non saranno interessate. Questa azione è irreversibile!", - "budget.insight.nearingLimit": "{name} ha speso il {percent}% e mancano {days} giorni. Rallenta per restare entro il limite.", - "budget.insight.onTrack": "{name} è sulla buona strada — {amount} rimanenti.", - "budget.status.onTrack": "Nei limiti", - "budget.status.nearing": "Vicino al limite", - "budget.status.over": "Oltre il limite", - "budget.detail.history": "Periodi recenti", - "budget.detail.transactions": "Transazioni", - "budget.detail.unavailable": "I dati del budget non sono al momento disponibili.", "budget.detail.daysLeft": "{} giorni rimasti", "budget.detail.daysLeft.one": "{} giorno rimasto", + "budget.detail.history": "Periodi recenti", "budget.detail.periodEnded": "Periodo terminato", - "budget.insight.over.short": "Hai superato di {amount}. Riduci le spese o aumenta il limite.", + "budget.detail.transactions": "Transazioni", + "budget.detail.unavailable": "I dati del budget non sono al momento disponibili.", + "budget.insight.nearingLimit": "{name} ha speso il {percent}% e mancano {days} giorni. Rallenta per restare entro il limite.", "budget.insight.nearingLimit.short": "{percent}% speso e mancano {days} giorni. Rallenta per restare entro il limite.", - "budget.insight.overpacing.short": "A questo ritmo, sforerai di {amount}.", + "budget.insight.onTrack": "{name} è sulla buona strada — {amount} rimanenti.", "budget.insight.onTrack.short": "Sulla buona strada — {amount} rimanenti.", - "budget.insight.underspending.short": "C'è molto margine — {amount} non spesi.", "budget.insight.over": "Hai superato di {amount} il budget per {name}. Riduci le spese o aumenta il limite.", + "budget.insight.over.short": "Hai superato di {amount}. Riduci le spese o aumenta il limite.", "budget.insight.overpacing": "A questo ritmo, {name} sforerà di {amount}.", + "budget.insight.overpacing.short": "A questo ritmo, sforerai di {amount}.", "budget.insight.underspending": "{name} ha molto margine — {amount} non spesi.", + "budget.insight.underspending.short": "C'è molto margine — {amount} non spesi.", "budget.name": "Nome del budget", "budget.overview.allHealthy": "Tutto procede bene. Ottimo lavoro.", "budget.overview.budgetsTracked": "{count} budget monitorati", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Rinnova automaticamente", "budget.renewAutomatically.description": "Al termine del periodo, il budget passa al periodo successivo. Ad esempio, un budget di luglio diventa un budget di agosto.", "budget.scope": "Ambito", + "budget.status.nearing": "Vicino al limite", + "budget.status.onTrack": "Nei limiti", + "budget.status.over": "Oltre il limite", "budgets": "Budget", "budgets.new": "Nuovo budget", "categories": "Categorie", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 5137a456..585648f8 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -47,25 +47,22 @@ "budget.categories.description": "Зөвхөн сонгосон ангиллын зарлага энэ төсөвт тооцогдоно. Юу ч сонгоогүй бол бүх зарлага тооцогдоно.", "budget.delete": "Төсвийг устгах", "budget.delete.description": "Гүйлгээнүүдэд нөлөөлөхгүй. Энэ үйлдлийг буцаах боломжгүй!", - "budget.insight.nearingLimit": "{name} нь {percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", - "budget.insight.onTrack": "{name} хэвийн байна — {amount} үлдсэн.", - "budget.status.onTrack": "Хэвийн", - "budget.status.nearing": "Хязгаарт дөхсөн", - "budget.status.over": "Хэтэрсэн", - "budget.detail.history": "Сүүлийн хугацаанууд", - "budget.detail.transactions": "Гүйлгээнүүд", - "budget.detail.unavailable": "Төсвийн тоо одоогоор гарахгүй байна.", "budget.detail.daysLeft": "{} хоног үлдлээ", "budget.detail.daysLeft.one": "{} хоног үлдлээ", + "budget.detail.history": "Сүүлийн хугацаанууд", "budget.detail.periodEnded": "Хугацаа дууссан", - "budget.insight.over.short": "Та төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", + "budget.detail.transactions": "Гүйлгээнүүд", + "budget.detail.unavailable": "Төсвийн тоо одоогоор гарахгүй байна.", + "budget.insight.nearingLimit": "{name} нь {percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", "budget.insight.nearingLimit.short": "{percent}% зарцуулсан, {days} хоног үлдлээ. Төсөвт багтахын тулд зарлагаа багасга.", - "budget.insight.overpacing.short": "Энэ янзаараа төсөв {amount}-р хэтрэх болно.", + "budget.insight.onTrack": "{name} хэвийн байна — {amount} үлдсэн.", "budget.insight.onTrack.short": "Хэвийн байна — {amount} үлдсэн.", - "budget.insight.underspending.short": "Төсөвт {amount} зарцуулагдаагүй үлджээ.", "budget.insight.over": "Та {name} төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", + "budget.insight.over.short": "Та төсвөөс {amount}-р давсан байна. Зарлагаа багасгах эсвэл хязгаарыг нэмнэ үү.", "budget.insight.overpacing": "Энэ янзаараа {name} төсөв {amount}-р хэтрэх болно.", + "budget.insight.overpacing.short": "Энэ янзаараа төсөв {amount}-р хэтрэх болно.", "budget.insight.underspending": "{name} төсөвт {amount} зарцуулагдаагүй үлджээ.", + "budget.insight.underspending.short": "Төсөвт {amount} зарцуулагдаагүй үлджээ.", "budget.name": "Төсвийн нэр", "budget.overview.allHealthy": "Бүх зүйл төсвийг дагуу. Янзтай!", "budget.overview.budgetsTracked": "{count} төсөв хянагдаж байна", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Автоматаар шинэчлэх", "budget.renewAutomatically.description": "Хугацаа дуусахад төсөв дараагийн хугацаа руу шилжинэ. Жишээ нь, 7-р сарын төсөв 8-р сарын төсөв болно.", "budget.scope": "Хүрээ", + "budget.status.nearing": "Хязгаарт дөхсөн", + "budget.status.onTrack": "Хэвийн", + "budget.status.over": "Хэтэрсэн", "budgets": "Төсвүүд", "budgets.new": "Шинэ төсөв", "categories": "Ангиллууд", @@ -946,4 +946,4 @@ "transactions.query.noResult": "Тохирох гүйлгээнүүд олдсонгүй", "transactions.query.noResult.description": "Шүүлтүүрээ өөрчлөөд дахин оролдоно уу", "visitGitHubRepo": "GitHub repo-р зочлох" -} +} \ No newline at end of file diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index e2f5aba4..dd6483c8 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -47,25 +47,22 @@ "budget.categories.description": "Do tego budżetu liczą się tylko wydatki z wybranych kategorii. Nie wybieraj żadnej, aby liczyć wszystkie wydatki.", "budget.delete": "Usuń budżet", "budget.delete.description": "Transakcje nie zostaną naruszone. Ta akcja jest nieodwracalna!", - "budget.insight.nearingLimit": "{name} jest wykorzystany w {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", - "budget.insight.onTrack": "{name} jest na dobrej drodze — pozostało {amount}.", - "budget.status.onTrack": "W normie", - "budget.status.nearing": "Blisko limitu", - "budget.status.over": "Limit przekroczony", - "budget.detail.history": "Ostatnie okresy", - "budget.detail.transactions": "Transakcje", - "budget.detail.unavailable": "Dane budżetu są chwilowo niedostępne.", "budget.detail.daysLeft": "Pozostało {} dni", "budget.detail.daysLeft.one": "Pozostał {} dzień", + "budget.detail.history": "Ostatnie okresy", "budget.detail.periodEnded": "Okres zakończony", - "budget.insight.over.short": "Masz przekroczenie o {amount}. Ogranicz wydatki lub zwiększ limit.", + "budget.detail.transactions": "Transakcje", + "budget.detail.unavailable": "Dane budżetu są chwilowo niedostępne.", + "budget.insight.nearingLimit": "{name} jest wykorzystany w {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", "budget.insight.nearingLimit.short": "Wykorzystano {percent}%, pozostało {days} dni. Zmniejsz wydatki, aby nie przekroczyć.", - "budget.insight.overpacing.short": "W tym tempie przekroczysz limit o {amount}.", + "budget.insight.onTrack": "{name} jest na dobrej drodze — pozostało {amount}.", "budget.insight.onTrack.short": "Na dobrej drodze — pozostało {amount}.", - "budget.insight.underspending.short": "Jest dużo zapasu — pozostało {amount}.", "budget.insight.over": "Masz przekroczenie o {amount} w {name}. Ogranicz wydatki lub zwiększ limit.", + "budget.insight.over.short": "Masz przekroczenie o {amount}. Ogranicz wydatki lub zwiększ limit.", "budget.insight.overpacing": "W tym tempie {name} przekroczy limit o {amount}.", + "budget.insight.overpacing.short": "W tym tempie przekroczysz limit o {amount}.", "budget.insight.underspending": "{name} ma dużo zapasu — pozostało {amount}.", + "budget.insight.underspending.short": "Jest dużo zapasu — pozostało {amount}.", "budget.name": "Nazwa budżetu", "budget.overview.allHealthy": "Wszystko idzie zgodnie z planem. Świetna robota.", "budget.overview.budgetsTracked": "{count} śledzonych budżetów", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Odnawiaj automatycznie", "budget.renewAutomatically.description": "Gdy okres się kończy, budżet przechodzi na nowy okres. Na przykład budżet lipcowy staje się budżetem sierpniowym.", "budget.scope": "Zakres", + "budget.status.nearing": "Blisko limitu", + "budget.status.onTrack": "W normie", + "budget.status.over": "Limit przekroczony", "budgets": "Budżety", "budgets.new": "Nowy budżet", "categories": "Kategorie", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index d7bd6847..9f49d8d9 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -47,25 +47,22 @@ "budget.categories.description": "В этот бюджет входят только расходы выбранных категорий. Не выбирайте ничего, чтобы учитывать все расходы.", "budget.delete": "Удалить бюджет", "budget.delete.description": "Транзакции не будут затронуты. Это действие необратимо!", - "budget.insight.nearingLimit": "{name}: потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", - "budget.insight.onTrack": "{name} в норме — осталось {amount}.", - "budget.status.onTrack": "В норме", - "budget.status.nearing": "Близко к лимиту", - "budget.status.over": "Лимит превышен", - "budget.detail.history": "Последние периоды", - "budget.detail.transactions": "Транзакции", - "budget.detail.unavailable": "Данные бюджета сейчас недоступны.", "budget.detail.daysLeft": "Осталось {} дн.", "budget.detail.daysLeft.one": "Остался {} день", + "budget.detail.history": "Последние периоды", "budget.detail.periodEnded": "Период завершён", - "budget.insight.over.short": "Вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", + "budget.detail.transactions": "Транзакции", + "budget.detail.unavailable": "Данные бюджета сейчас недоступны.", + "budget.insight.nearingLimit": "{name}: потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", "budget.insight.nearingLimit.short": "Потрачено {percent}%, осталось {days} дн. Уменьшите траты, чтобы не превысить лимит.", - "budget.insight.overpacing.short": "С таким темпом вы превысите лимит на {amount}.", + "budget.insight.onTrack": "{name} в норме — осталось {amount}.", "budget.insight.onTrack.short": "В норме — осталось {amount}.", - "budget.insight.underspending.short": "Есть запас — не потрачено {amount}.", "budget.insight.over": "По {name} вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", + "budget.insight.over.short": "Вы превысили лимит на {amount}. Сократите траты или увеличьте лимит.", "budget.insight.overpacing": "С таким темпом {name} превысит лимит на {amount}.", + "budget.insight.overpacing.short": "С таким темпом вы превысите лимит на {amount}.", "budget.insight.underspending": "{name} имеет запас — не потрачено {amount}.", + "budget.insight.underspending.short": "Есть запас — не потрачено {amount}.", "budget.name": "Название бюджета", "budget.overview.allHealthy": "Все в порядке. Отличная работа.", "budget.overview.budgetsTracked": "{count} отслеживаемых бюджетов", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Продлевать автоматически", "budget.renewAutomatically.description": "Когда период заканчивается, бюджет переходит на новый период. Например, бюджет июля становится бюджетом августа.", "budget.scope": "Область", + "budget.status.nearing": "Близко к лимиту", + "budget.status.onTrack": "В норме", + "budget.status.over": "Лимит превышен", "budgets": "Бюджеты", "budgets.new": "Новый бюджет", "categories": "Категории", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index acff5519..e7949759 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -47,25 +47,22 @@ "budget.categories.description": "Bu bütçeye yalnızca seçilen kategorilerdeki harcamalar dahil edilir. Tüm harcamaları saymak için hiçbirini seçmeyin.", "budget.delete": "Bütçeyi sil", "budget.delete.description": "İşlemler etkilenmeyecek. Bu işlem geri alınamaz!", - "budget.insight.nearingLimit": "{name} için {percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", - "budget.insight.onTrack": "{name} iyi gidiyor — {amount} kaldı.", - "budget.status.onTrack": "İyi gidiyor", - "budget.status.nearing": "Limite yakın", - "budget.status.over": "Limit aşıldı", - "budget.detail.history": "Son dönemler", - "budget.detail.transactions": "İşlemler", - "budget.detail.unavailable": "Bütçe rakamları şu anda kullanılamıyor.", "budget.detail.daysLeft": "{} gün kaldı", "budget.detail.daysLeft.one": "{} gün kaldı", + "budget.detail.history": "Son dönemler", "budget.detail.periodEnded": "Dönem sona erdi", - "budget.insight.over.short": "Bütçeyi {amount} aştınız. Harcamaları kısın veya limiti artırın.", + "budget.detail.transactions": "İşlemler", + "budget.detail.unavailable": "Bütçe rakamları şu anda kullanılamıyor.", + "budget.insight.nearingLimit": "{name} için {percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", "budget.insight.nearingLimit.short": "{percent}% harcandı, {days} gün kaldı. Limitin altında kalmak için harcamayı azaltın.", - "budget.insight.overpacing.short": "Bu hızla bütçeyi {amount} kadar aşacaksınız.", + "budget.insight.onTrack": "{name} iyi gidiyor — {amount} kaldı.", "budget.insight.onTrack.short": "İyi gidiyor — {amount} kaldı.", - "budget.insight.underspending.short": "Fazla bütçe kaldı — {amount} harcanmadı.", "budget.insight.over": "{name} bütçesini {amount} aştınız. Harcamaları kısın veya limiti artırın.", + "budget.insight.over.short": "Bütçeyi {amount} aştınız. Harcamaları kısın veya limiti artırın.", "budget.insight.overpacing": "Bu hızla {name} bütçesi {amount} kadar aşılacak.", + "budget.insight.overpacing.short": "Bu hızla bütçeyi {amount} kadar aşacaksınız.", "budget.insight.underspending": "{name} için fazla bütçe kaldı — {amount} harcanmadı.", + "budget.insight.underspending.short": "Fazla bütçe kaldı — {amount} harcanmadı.", "budget.name": "Bütçe adı", "budget.overview.allHealthy": "Her şey yolunda. İyi iş.", "budget.overview.budgetsTracked": "{count} bütçe takip ediliyor", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Otomatik yenile", "budget.renewAutomatically.description": "Dönem sona erdiğinde bütçe yeni döneme geçer. Örneğin, temmuz bütçesi ağustos bütçesine dönüşür.", "budget.scope": "Kapsam", + "budget.status.nearing": "Limite yakın", + "budget.status.onTrack": "İyi gidiyor", + "budget.status.over": "Limit aşıldı", "budgets": "Bütçeler", "budgets.new": "Yeni bütçe", "categories": "Kategori", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 0d897f37..f101ac1c 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -47,25 +47,22 @@ "budget.categories.description": "До цього бюджету входять лише витрати вибраних категорій. Не вибирайте нічого, щоб враховувати всі витрати.", "budget.delete": "Видалити бюджет", "budget.delete.description": "Транзакції не будуть змінені. Ця дія незворотна!", - "budget.insight.nearingLimit": "{name} використано на {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", - "budget.insight.onTrack": "{name} в рамках бюджету — залишилося {amount}.", - "budget.status.onTrack": "У межах ліміту", - "budget.status.nearing": "Близько до ліміту", - "budget.status.over": "Ліміт перевищено", - "budget.detail.history": "Останні періоди", - "budget.detail.transactions": "Транзакції", - "budget.detail.unavailable": "Дані бюджету зараз недоступні.", "budget.detail.daysLeft": "Залишилося {} дн.", "budget.detail.daysLeft.one": "Залишився {} день", + "budget.detail.history": "Останні періоди", "budget.detail.periodEnded": "Період завершено", - "budget.insight.over.short": "Ви перевищили бюджет на {amount}. Зменшіть витрати або підвищіть ліміт.", + "budget.detail.transactions": "Транзакції", + "budget.detail.unavailable": "Дані бюджету зараз недоступні.", + "budget.insight.nearingLimit": "{name} використано на {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", "budget.insight.nearingLimit.short": "Використано {percent}% — залишилося {days} дн. Зменшіть витрати, щоб не перевищити ліміт.", - "budget.insight.overpacing.short": "При такому темпі ви перевищите ліміт на {amount}.", + "budget.insight.onTrack": "{name} в рамках бюджету — залишилося {amount}.", "budget.insight.onTrack.short": "У рамках бюджету — залишилося {amount}.", - "budget.insight.underspending.short": "Є запас — не витрачено {amount}.", "budget.insight.over": "Ви перевищили бюджет {name} на {amount}. Зменшіть витрати або підвищіть ліміт.", + "budget.insight.over.short": "Ви перевищили бюджет на {amount}. Зменшіть витрати або підвищіть ліміт.", "budget.insight.overpacing": "При такому темпі {name} перевищить ліміт на {amount}.", + "budget.insight.overpacing.short": "При такому темпі ви перевищите ліміт на {amount}.", "budget.insight.underspending": "{name} має запас — не витрачено {amount}.", + "budget.insight.underspending.short": "Є запас — не витрачено {amount}.", "budget.name": "Назва бюджету", "budget.overview.allHealthy": "Усе йде за планом. Чудова робота.", "budget.overview.budgetsTracked": "Відстежується {count} бюджетів", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "Поновлювати автоматично", "budget.renewAutomatically.description": "Коли період закінчується, бюджет переходить на новий період. Наприклад, бюджет липня стає бюджетом серпня.", "budget.scope": "Охоплення", + "budget.status.nearing": "Близько до ліміту", + "budget.status.onTrack": "У межах ліміту", + "budget.status.over": "Ліміт перевищено", "budgets": "Бюджети", "budgets.new": "Новий бюджет", "categories": "Категорії", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index 5d8006ec..dc289d26 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -47,25 +47,22 @@ "budget.categories.description": "只有所选分类的支出会计入此预算。若不选择任何分类,则计入所有支出。", "budget.delete": "删除预算", "budget.delete.description": "交易不会受到影响。此操作无法撤销!", - "budget.insight.nearingLimit": "{name} 已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", - "budget.insight.onTrack": "{name} 进度正常 — 剩余 {amount}。", - "budget.status.onTrack": "进度正常", - "budget.status.nearing": "接近上限", - "budget.status.over": "超出预算", - "budget.detail.history": "最近的周期", - "budget.detail.transactions": "交易", - "budget.detail.unavailable": "当前无法获取预算数据。", "budget.detail.daysLeft": "剩余 {} 天", "budget.detail.daysLeft.one": "剩余 {} 天", + "budget.detail.history": "最近的周期", "budget.detail.periodEnded": "周期已结束", - "budget.insight.over.short": "已超出 {amount}。请减少支出或提高上限。", + "budget.detail.transactions": "交易", + "budget.detail.unavailable": "当前无法获取预算数据。", + "budget.insight.nearingLimit": "{name} 已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", "budget.insight.nearingLimit.short": "已使用 {percent}%,还有 {days} 天。放缓支出以免超支。", - "budget.insight.overpacing.short": "照此速度,将超出 {amount}。", + "budget.insight.onTrack": "{name} 进度正常 — 剩余 {amount}。", "budget.insight.onTrack.short": "进度正常 — 剩余 {amount}。", - "budget.insight.underspending.short": "余量充足 — 尚有 {amount} 未使用。", "budget.insight.over": "你在 {name} 已超出 {amount}。请减少支出或提高上限。", + "budget.insight.over.short": "已超出 {amount}。请减少支出或提高上限。", "budget.insight.overpacing": "照此速度,{name} 将超出 {amount}。", + "budget.insight.overpacing.short": "照此速度,将超出 {amount}。", "budget.insight.underspending": "{name} 余量充足 — 尚有 {amount} 未使用。", + "budget.insight.underspending.short": "余量充足 — 尚有 {amount} 未使用。", "budget.name": "预算名称", "budget.overview.allHealthy": "一切正常,做得好。", "budget.overview.budgetsTracked": "已跟踪 {count} 个预算", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "自动续期", "budget.renewAutomatically.description": "周期结束后,预算会进入新的周期。例如,七月的预算会变成八月的预算。", "budget.scope": "范围", + "budget.status.nearing": "接近上限", + "budget.status.onTrack": "进度正常", + "budget.status.over": "超出预算", "budgets": "预算", "budgets.new": "新建预算", "categories": "分类", @@ -946,4 +946,4 @@ "transactions.query.noResult": "没有可显示的交易", "transactions.query.noResult.description": "尝试更新您的过滤条件", "visitGitHubRepo": "前往 GitHub 查看" -} +} \ No newline at end of file diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index c7fa9dfa..0ab913de 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -47,25 +47,22 @@ "budget.categories.description": "只有所選分類的支出會計入此預算。若未選擇任何分類,則計入所有支出。", "budget.delete": "刪除預算", "budget.delete.description": "交易不會受到影響。此操作無法復原!", - "budget.insight.nearingLimit": "{name} 已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", - "budget.insight.onTrack": "{name} 進度正常 — {amount} 剩餘。", - "budget.status.onTrack": "進度正常", - "budget.status.nearing": "接近上限", - "budget.status.over": "超出預算", - "budget.detail.history": "最近的期間", - "budget.detail.transactions": "交易", - "budget.detail.unavailable": "目前無法取得預算數字。", "budget.detail.daysLeft": "剩餘 {} 天", "budget.detail.daysLeft.one": "剩餘 {} 天", + "budget.detail.history": "最近的期間", "budget.detail.periodEnded": "期間已結束", - "budget.insight.over.short": "已超出 {amount}。請減少支出或提高上限。", + "budget.detail.transactions": "交易", + "budget.detail.unavailable": "目前無法取得預算數字。", + "budget.insight.nearingLimit": "{name} 已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", "budget.insight.nearingLimit.short": "已使用 {percent}%,還有 {days} 天。放緩消費以免超支。", - "budget.insight.overpacing.short": "照此速度,將超出 {amount}。", + "budget.insight.onTrack": "{name} 進度正常 — {amount} 剩餘。", "budget.insight.onTrack.short": "進度正常 — {amount} 剩餘。", - "budget.insight.underspending.short": "空間充足 — 尚有 {amount} 未使用。", "budget.insight.over": "你在 {name} 超出 {amount}。請減少支出或提高上限。", + "budget.insight.over.short": "已超出 {amount}。請減少支出或提高上限。", "budget.insight.overpacing": "照此速度,{name} 將超出 {amount}。", + "budget.insight.overpacing.short": "照此速度,將超出 {amount}。", "budget.insight.underspending": "{name} 空間充足 — 尚有 {amount} 未使用。", + "budget.insight.underspending.short": "空間充足 — 尚有 {amount} 未使用。", "budget.name": "預算名稱", "budget.overview.allHealthy": "一切正常,做得好。", "budget.overview.budgetsTracked": "追蹤 {count} 個預算", @@ -87,6 +84,9 @@ "budget.renewAutomatically": "自動續期", "budget.renewAutomatically.description": "期間結束後,預算會進入新的期間。例如,七月的預算會變成八月的預算。", "budget.scope": "範圍", + "budget.status.nearing": "接近上限", + "budget.status.onTrack": "進度正常", + "budget.status.over": "超出預算", "budgets": "預算", "budgets.new": "新增預算", "categories": "分類", diff --git a/pubspec.lock b/pubspec.lock index 1e31cd06..6a1a7b85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1018,10 +1018,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -1202,10 +1202,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1226,10 +1226,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mgrs_dart: dependency: transitive description: @@ -1927,26 +1927,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -2063,10 +2063,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: diff --git a/reformat_l10n_files.sh b/reformat_l10n_files.sh index 5027e70c..e1f826a9 100755 --- a/reformat_l10n_files.sh +++ b/reformat_l10n_files.sh @@ -1 +1 @@ -dart ./scripts/reformat_l10n_files.dart \ No newline at end of file +dart ./scripts/reformat_l10n_files.dart From 03cc6903a8fbd884a73ada130692dfacca792045 Mon Sep 17 00:00:00 2001 From: Oliver Tzeng Date: Sun, 13 Sep 2026 23:41:52 +0800 Subject: [PATCH 05/57] =?UTF-8?q?replaced=20some=20=E6=94=AF=E6=8F=B4=20to?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/l10n/zh_TW.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 29e69866..90e42275 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -579,7 +579,7 @@ "setup.slides.privacy": "完全掌控您的資料", "setup.slides.privacy.description": "您的所有資料僅儲存在本機(或您的私人雲端空間),並提供完整的資料匯出選項。", "setup.transactionTags": "選擇標籤", - "support": "支援", + "support": "支持", "support.contribute": "貢獻程式碼", "support.contribute.description": "如果您是一位開發者,您可以參與 Flow 的開發。貢獻者名單等待您的加入。", "support.description": "Flow 是一款出於熱忱、免費且為所有人開源的軟體。如果您認為 Flow 很有幫助,考慮協助專案成長!您可以透過以下方式支持我們:", @@ -592,7 +592,7 @@ "support.starOnGitHub": "在 GitHub 上給顆星星", "support.starOnGitHub.description": "在 GitHub 上給 Flow 點擊星星有助於讓更多人發現我們", "support.tip.error": "無法開始購買。請再試一次。", - "support.tip.thankYou": "感謝您支援 Flow!💜", + "support.tip.thankYou": "感謝您支持 Flow!💜", "sync.export": "匯出", "sync.export.asCSV": "匯出為 CSV", "sync.export.asCSV.description": "不可用於還原/匯入!適合在 Google 試算表等軟體中開啟", @@ -697,7 +697,7 @@ "tabs.profile.other": "其他", "tabs.profile.preferences": "偏好設定", "tabs.profile.recommend": "推薦 Flow", - "tabs.profile.support": "支援 Flow", + "tabs.profile.support": "支持 Flow", "tabs.profile.withLoveFromTheCreator": "來自 sadespresso 🤍 的作品", "tabs.stats": "統計", "tabs.stats.analytics.budgets": "預算", @@ -946,4 +946,4 @@ "transactions.query.noResult": "沒有可顯示的交易", "transactions.query.noResult.description": "嘗試更新您的過濾條件", "visitGitHubRepo": "前往 GitHub 查看" -} \ No newline at end of file +} From b65664b6390cce10294eedcc85265d58fe33d679 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 13 Sep 2026 23:52:50 +0800 Subject: [PATCH 06/57] navbar: fix new transaction button with accessible navigation PieMenu drops its radial menu when accessible navigation is on and only calls onPressed, which we never passed, so the button did nothing. Open a sheet with the same choices in that mode instead. The theme's pointerSize of 2.0 also doubled as PieMenu's release slop, so a finger drifting a couple of pixels closed the menu. Use touch slop. Also logs pointer events that land on the button, or miss it, to help track down the unresponsive button on some Android devices. --- CHANGELOG.md | 8 + lib/theme/theme.dart | 1 - .../home/navbar/new_transaction_button.dart | 169 +++++- .../sheets/select_flow_button_type_sheet.dart | 47 ++ test/widget/new_transaction_button_test.dart | 571 ++++++++++++++++++ 5 files changed, 776 insertions(+), 20 deletions(-) create mode 100644 lib/widgets/sheets/select_flow_button_type_sheet.dart create mode 100644 test/widget/new_transaction_button_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c684e95..e5fce461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## next + +### Fixes + +* Fixed new transaction button not responding when accessible navigation is + enabled +* Fixed transaction menu closing on small finger movements + ## 0.24.0 ### Features diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 136035c8..47067f13 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -44,7 +44,6 @@ class ThemeFactory { overlayColor: colorScheme.surface.withAlpha(0xe0), pointerColor: kTransparent, angleOffset: 0.0, - pointerSize: 2.0, tooltipTextStyle: flowTextTheme.displaySmall!.copyWith( color: colorScheme.onSurface, ), diff --git a/lib/widgets/home/navbar/new_transaction_button.dart b/lib/widgets/home/navbar/new_transaction_button.dart index 775aa296..e778ced1 100644 --- a/lib/widgets/home/navbar/new_transaction_button.dart +++ b/lib/widgets/home/navbar/new_transaction_button.dart @@ -2,16 +2,22 @@ import "dart:math"; import "package:flow/data/flow_button_type.dart"; import "package:flow/entity/user_preferences.dart"; +import "package:flow/l10n/extensions.dart"; import "package:flow/l10n/named_enum.dart"; import "package:flow/services/integrations/eny.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/theme/navbar_theme.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/directionality.dart"; +import "package:flow/widgets/sheets/select_flow_button_type_sheet.dart"; +import "package:flutter/gestures.dart"; import "package:flutter/material.dart" hide Flow; +import "package:logging/logging.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:pie_menu/pie_menu.dart"; +final Logger _log = Logger("NewTransactionButton"); + class NewTransactionButton extends StatefulWidget { final Function(FlowButtonType type) onActionTap; @@ -36,9 +42,46 @@ class _NewTransactionButtonState extends State ), ); + bool _sheetOpen = false; + bool _menuOpen = false; + bool _pointerDownDelivered = false; + bool? _accessibleNavigation; + + @override + void initState() { + super.initState(); + GestureBinding.instance.pointerRouter.addGlobalRoute(_observePointerDown); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final bool accessibleNavigation = MediaQuery.accessibleNavigationOf( + context, + ); + if (_accessibleNavigation == accessibleNavigation) return; + + _accessibleNavigation = accessibleNavigation; + _log.fine("Button ready: accessibleNavigation=$accessibleNavigation"); + } + + @override + void dispose() { + GestureBinding.instance.pointerRouter.removeGlobalRoute( + _observePointerDown, + ); + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final NavbarTheme navbarTheme = Theme.of(context).extension()!; + final double touchSlop = max( + kTouchSlop, + MediaQuery.gestureSettingsOf(context).touchSlop ?? kTouchSlop, + ); return AnimatedBuilder( animation: Listenable.merge([ @@ -67,7 +110,15 @@ class _NewTransactionButtonState extends State }; return PieMenu( + // [PieMenu] skips the radial menu under accessible navigation, and + // only calls [onPressed]. Without it, the button does nothing. + onPressed: MediaQuery.accessibleNavigationOf(context) + ? () => _showSheet(buttonOrder) + : null, theme: context.pieTheme.copyWith( + // [PieMenu] closes the menu once the pointer moves further than + // `pointerSize / 2`, so keep it at least as big as touch slop. + pointerSize: touchSlop * 2, customAngle: 90.0, customAngleDiff: 48.0, radius: 108.0, @@ -85,7 +136,8 @@ class _NewTransactionButtonState extends State .map( (transactionType) => PieAction( tooltip: Text(transactionType.localizedNameContext(context)), - onSelect: () => widget.onActionTap(transactionType), + semanticsLabel: transactionType.localizedNameContext(context), + onSelect: () => onSelect(transactionType), child: Icon(transactionType.icon, weight: 800.0), buttonTheme: PieButtonTheme( backgroundColor: transactionType.actionBackgroundColor( @@ -96,24 +148,30 @@ class _NewTransactionButtonState extends State ), ) .toList(), - child: Material( - color: navbarTheme.transactionButtonBackgroundColor, - shape: RoundedRectangleBorder(borderRadius: .circular(32.0)), - child: Padding( - padding: const EdgeInsets.all(20.0), - child: AnimatedBuilder( - animation: _bounceAnimation, - builder: (context, child) { - return Transform.rotate( - angle: _bounceAnimation.value, - child: child, - ); - }, - child: Icon( - Symbols.add_rounded, - fill: 0.0, - color: navbarTheme.transactionButtonForegroundColor, - weight: 600.0, + child: Listener( + onPointerDown: _logPointerEvent, + onPointerUp: _logPointerEvent, + onPointerCancel: _logPointerEvent, + child: Material( + color: navbarTheme.transactionButtonBackgroundColor, + shape: RoundedRectangleBorder(borderRadius: .circular(32.0)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: AnimatedBuilder( + animation: _bounceAnimation, + builder: (context, child) { + return Transform.rotate( + angle: _bounceAnimation.value, + child: child, + ); + }, + child: Icon( + Symbols.add_rounded, + semanticLabel: "transaction.new".t(context), + fill: 0.0, + color: navbarTheme.transactionButtonForegroundColor, + weight: 600.0, + ), ), ), ), @@ -124,10 +182,83 @@ class _NewTransactionButtonState extends State } void onToggle(bool toggled) { + _menuOpen = toggled; + _log.fine("Radial menu ${toggled ? "opened" : "closed"}"); + if (toggled) { _animationController.forward(); } else { _animationController.reverse(); } } + + void onSelect(FlowButtonType type) { + _log.fine("Selected transaction action: ${type.name}"); + widget.onActionTap(type); + } + + void _showSheet(List buttonOrder) async { + if (_sheetOpen) return; + + _sheetOpen = true; + _log.fine("Opening accessible transaction picker"); + + try { + final FlowButtonType? type = await showModalBottomSheet( + context: context, + builder: (context) => SelectFlowButtonTypeSheet(types: buttonOrder), + isScrollControlled: true, + ); + + if (!mounted || type == null) return; + + onSelect(type); + } finally { + _sheetOpen = false; + } + } + + void _logPointerEvent(PointerEvent event) { + if (event is PointerDownEvent) { + _pointerDownDelivered = true; + } + + final MediaQueryData media = MediaQuery.of(context); + + _log.fine( + "${event.runtimeType}: pointer=${event.pointer}, " + "kind=${event.kind.name}, buttons=${event.buttons}, " + "local=${event.localPosition}, time=${event.timeStamp.inMicroseconds}, " + "accessibleNavigation=${media.accessibleNavigation}, " + "touchSlop=${media.gestureSettings.touchSlop}, " + "pixelRatio=${media.devicePixelRatio}, " + "padding=${media.viewPadding}, gestures=${media.systemGestureInsets}", + ); + } + + /// Global routes see every pointer down, even the ones an overlay or + /// [IgnorePointer] kept from reaching this button's [Listener]. + void _observePointerDown(PointerEvent event) { + if (event is! PointerDownEvent) return; + + final bool delivered = _pointerDownDelivered; + _pointerDownDelivered = false; + + if (!mounted || delivered || _menuOpen) return; + if (ModalRoute.of(context)?.isCurrent == false) return; + if (!TickerMode.valuesOf(context).enabled) return; + if (event.viewId != View.of(context).viewId) return; + + final RenderObject? box = context.findRenderObject(); + if (box is! RenderBox || !box.attached || !box.hasSize) return; + + final Offset local = box.globalToLocal(event.position); + if (!box.size.contains(local)) return; + + _log.warning( + "Touch reached Flutter inside transaction button bounds but missed " + "the button hit test: pointer=${event.pointer}, local=$local, " + "accessibleNavigation=$_accessibleNavigation", + ); + } } diff --git a/lib/widgets/sheets/select_flow_button_type_sheet.dart b/lib/widgets/sheets/select_flow_button_type_sheet.dart new file mode 100644 index 00000000..79a7b7ae --- /dev/null +++ b/lib/widgets/sheets/select_flow_button_type_sheet.dart @@ -0,0 +1,47 @@ +import "package:flow/data/flow_button_type.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/l10n/named_enum.dart"; +import "package:flow/widgets/general/directional_chevron.dart"; +import "package:flow/widgets/general/modal_overflow_bar.dart"; +import "package:flow/widgets/general/modal_sheet.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +class SelectFlowButtonTypeSheet extends StatelessWidget { + final List types; + + const SelectFlowButtonTypeSheet({super.key, required this.types}); + + @override + Widget build(BuildContext context) { + return ModalSheet.scrollable( + title: Text("transaction.new".t(context)), + trailing: ModalOverflowBar( + alignment: .end, + children: [ + TextButton.icon( + onPressed: () => context.pop(null), + icon: const Icon(Symbols.close_rounded), + label: Text("general.cancel".t(context)), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: types + .map( + (value) => ListTile( + leading: Icon(value.icon), + title: Text(value.localizedNameContext(context)), + trailing: const LeChevron(), + onTap: () => context.pop(value), + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/test/widget/new_transaction_button_test.dart b/test/widget/new_transaction_button_test.dart new file mode 100644 index 00000000..ffa3dc75 --- /dev/null +++ b/test/widget/new_transaction_button_test.dart @@ -0,0 +1,571 @@ +import "dart:async"; + +import "package:flow/data/flow_button_type.dart"; +import "package:flow/entity/user_preferences.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/prefs/eny_preferences.dart"; +import "package:flow/services/integrations/eny.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/color_themes/registry.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/home/navbar.dart"; +import "package:flow/widgets/home/navbar/new_transaction_button.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/gestures.dart"; +import "package:flutter/material.dart"; +import "package:flutter/semantics.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:go_router/go_router.dart"; +import "package:logging/logging.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:pie_menu/pie_menu.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +/// Guards for the central + button on the home navbar. +/// +/// [PieMenu] renders the button but drops its radial menu under accessible +/// navigation, and treats `pointerSize / 2` as the movement allowed before a +/// release closes the menu. Both left the button looking fine while doing +/// nothing, so these tests drive real pointers and semantics actions. +/// +/// [PieMenu] keeps action widgets mounted after the menu closes, so finding an +/// action icon doesn't prove the menu is open. Check `onMenuToggle` instead. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await FlowLocalizations(const Locale("en")).load(); + EnyLocalPreferences.initialize(_MemoryPreferences()); + }); + + List captureLogs() { + final List records = []; + final Level previousLevel = Logger.root.level; + + Logger.root.level = Level.ALL; + final StreamSubscription subscription = Logger.root.onRecord + .where((record) => record.loggerName == "NewTransactionButton") + .listen(records.add); + + addTearDown(() async { + await subscription.cancel(); + Logger.root.level = previousLevel; + }); + + return records; + } + + testWidgets("accessible navigation can select a transaction type", ( + tester, + ) async { + final List selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: true, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + + expect(find.text("Expense"), findsOneWidget); + expect(find.text("Income"), findsOneWidget); + expect(find.text("Transfer"), findsOneWidget); + expect(find.text("Eny"), findsNothing); + + await tester.tap(find.text("Income")); + await tester.pumpAndSettle(); + + expect(selected, [FlowButtonType.income]); + expect(find.byType(BottomSheet), findsNothing); + }); + + testWidgets("diagnostics detect touches excluded from the button hit test", ( + tester, + ) async { + final List records = captureLogs(); + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: (_) {}, + ignoreTransactionButton: true, + ), + ); + await tester.pumpAndSettle(); + + await tester.tapAt(tester.getCenter(find.byType(NewTransactionButton))); + await tester.pumpAndSettle(); + + expect( + records.any( + (record) => record.message.contains("missed the button hit test"), + ), + isTrue, + ); + expect( + records.any((record) => record.message.contains("PointerDownEvent:")), + isFalse, + ); + }); + + testWidgets("diagnostics do not report working taps as missed hits", ( + tester, + ) async { + final List records = captureLogs(); + await tester.pumpWidget( + _testApp(accessibleNavigation: false, onActionTap: (_) {}), + ); + await tester.pumpAndSettle(); + + await tester.tapAt(tester.getCenter(find.byType(NewTransactionButton))); + await tester.pumpAndSettle(); + + expect( + records.any((record) => record.message.contains("PointerDownEvent:")), + isTrue, + ); + expect(records.any((record) => record.level >= Level.WARNING), isFalse); + }); + + testWidgets("screen-reader activation opens the transaction picker", ( + tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + final List selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: true, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + final SemanticsNode node = tester.getSemantics( + find.bySemanticsLabel("New transaction"), + ); + expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); + + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(find.text("Expense"), findsOneWidget); + + await tester.tap(find.text("Expense")); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.expense]); + + semantics.dispose(); + }); + + testWidgets("cancel creates nothing and the picker can reopen", ( + tester, + ) async { + final List selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: true, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text("Cancel")); + await tester.pumpAndSettle(); + + expect(selected, isEmpty); + expect(find.byType(BottomSheet), findsNothing); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text("Transfer")); + await tester.pumpAndSettle(); + + expect(selected, [FlowButtonType.transfer]); + }); + + testWidgets("accessible picker preserves custom order and connected Eny", ( + tester, + ) async { + final UserPreferences preferences = UserPreferencesService().value; + final String? previousOrder = preferences.transactionButtonOrderJoined; + addTearDown(() async { + preferences.transactionButtonOrderJoined = previousOrder; + await EnyService().setApiKey(apiKey: null); + }); + + preferences.transactionButtonOrder = [ + FlowButtonType.expense, + FlowButtonType.eny, + FlowButtonType.transfer, + FlowButtonType.income, + ]; + await EnyService().setApiKey(apiKey: "eny-test"); + + final List selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: true, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + + expect( + tester + .widgetList(find.byType(ListTile)) + .map((tile) => (tile.title! as Text).data), + ["Expense", "Eny", "Transfer", "Income"], + ); + + await tester.tap(find.text("Eny")); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.eny]); + }); + + testWidgets("normal tap still opens radial actions and selects once", ( + tester, + ) async { + final List selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: false, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsNothing); + expect(find.byIcon(FlowButtonType.expense.icon), findsOneWidget); + expect(selected, isEmpty); + + // PieCanvas handles raw pointer events over its painted action widgets. + await tester.tapAt( + tester.getCenter(find.byIcon(FlowButtonType.expense.icon)), + ); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.expense]); + }); + + for (final double drift in [2.0, 8.0, 16.0]) { + testWidgets("a touch drifting $drift pixels keeps the menu open", ( + tester, + ) async { + final List selected = []; + final List menuStates = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: selected.add, + onMenuToggle: menuStates.add, + ), + ); + await tester.pumpAndSettle(); + + final Offset start = tester.getCenter(find.byIcon(Symbols.add_rounded)); + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + expect(menuStates.last, isTrue); + + await gesture.moveTo(start + Offset(drift, 0.0)); + await tester.pump(const Duration(milliseconds: 16)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(selected, isEmpty); + expect(menuStates.last, isTrue); + expect(find.byIcon(FlowButtonType.expense.icon), findsOneWidget); + }); + } + + testWidgets("menu release tolerance respects Android touch slop", ( + tester, + ) async { + final List menuStates = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: (_) {}, + onMenuToggle: menuStates.add, + gestureSettings: const DeviceGestureSettings(touchSlop: 32.0), + ), + ); + await tester.pumpAndSettle(); + + final Offset start = tester.getCenter(find.byIcon(Symbols.add_rounded)); + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + await gesture.moveTo(start + const Offset(24.0, 0.0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(menuStates.last, isTrue); + }); + + testWidgets("a cancelled touch can be dismissed and the button reused", ( + tester, + ) async { + final List selected = []; + final List menuStates = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: selected.add, + onMenuToggle: menuStates.add, + ), + ); + await tester.pumpAndSettle(); + + final Offset start = tester.getCenter(find.byIcon(Symbols.add_rounded)); + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + await gesture.cancel(); + await tester.pumpAndSettle(); + + // PieMenu keeps an open menu after cancellation; the next tap dismisses it. + await tester.tapAt(start); + await tester.pumpAndSettle(); + expect(selected, isEmpty); + expect(menuStates.last, isFalse); + + await tester.tapAt(start); + await tester.pumpAndSettle(); + expect(menuStates.last, isTrue); + + await tester.tapAt( + tester.getCenter(find.byIcon(FlowButtonType.expense.icon)), + ); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.expense]); + }); + + for (final Size size in [ + const Size(320.0, 640.0), + const Size(393.0, 873.0), + ]) { + testWidgets("the full button receives taps in a $size navbar", ( + tester, + ) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final List menuStates = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: false, + onActionTap: (_) {}, + onMenuToggle: menuStates.add, + padding: const EdgeInsets.only(top: 24.0, bottom: 48.0), + ), + ); + await tester.pumpAndSettle(); + + final Rect rect = tester.getRect(find.byType(NewTransactionButton)); + expect(rect.bottom, lessThanOrEqualTo(size.height - 48.0)); + + for (final Offset offset in const [ + Offset.zero, + Offset(-24.0, 0.0), + Offset(24.0, 0.0), + Offset(0.0, -24.0), + Offset(0.0, 24.0), + ]) { + await tester.tapAt(rect.center + offset); + await tester.pumpAndSettle(); + expect(menuStates.last, isTrue); + + await tester.tapAt(rect.center); + await tester.pumpAndSettle(); + expect(menuStates.last, isFalse); + } + }); + } + + testWidgets("drag opens when early movement arrives before a frame", ( + tester, + ) async { + final List selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: false, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + final Offset start = tester.getCenter(find.byIcon(Symbols.add_rounded)); + final TestGesture gesture = await tester.startGesture(start); + await gesture.moveTo(start + const Offset(0.0, -8.0)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pumpAndSettle(); + expect(find.byIcon(FlowButtonType.income.icon), findsOneWidget); + + await gesture.moveTo( + tester.getCenter(find.byIcon(FlowButtonType.income.icon)), + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(selected, [FlowButtonType.income]); + }); + + testWidgets("dragging from the button selects a radial action once", ( + tester, + ) async { + final List selected = []; + await tester.pumpWidget( + _testApp(accessibleNavigation: false, onActionTap: selected.add), + ); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byIcon(Symbols.add_rounded)), + ); + await tester.pumpAndSettle(); + await gesture.moveTo( + tester.getCenter(find.byIcon(FlowButtonType.income.icon)), + ); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(selected, [FlowButtonType.income]); + expect(find.byType(BottomSheet), findsNothing); + }); + + testWidgets("accessible choices remain usable with large text", ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320.0, 640.0)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final List selected = []; + await tester.pumpWidget( + _testApp( + accessibleNavigation: true, + textScaler: const TextScaler.linear(2.5), + onActionTap: selected.add, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.add_rounded)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text("Expense"), + 100.0, + scrollable: find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text("Expense")); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(selected, [FlowButtonType.expense]); + }); +} + +Widget _testApp({ + required bool accessibleNavigation, + required ValueChanged onActionTap, + TextScaler textScaler = .noScaling, + ValueChanged? onMenuToggle, + DeviceGestureSettings gestureSettings = const DeviceGestureSettings(), + EdgeInsets padding = .zero, + bool ignoreTransactionButton = false, +}) { + final ThemeFactory theme = ThemeFactory(flowLights.schemes.first); + + // Mirrors HomePage: the navbar sits over a TabBarView, under the app-wide + // unlock tap, and sheets are popped through GoRouter. + final Widget home = GestureDetector( + onTap: () {}, + child: PieCanvas( + theme: theme.pieTheme, + onMenuToggle: onMenuToggle, + child: DefaultTabController( + length: 4, + child: Stack( + children: [ + const Scaffold( + body: TabBarView( + children: [SizedBox(), SizedBox(), SizedBox(), SizedBox()], + ), + ), + Positioned( + bottom: 16.0, + left: 0.0, + right: 0.0, + child: SafeArea( + child: Frame( + child: Stack( + alignment: Alignment.center, + children: [ + Navbar(onTap: (_) {}), + IgnorePointer( + ignoring: ignoreTransactionButton, + child: NewTransactionButton(onActionTap: onActionTap), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + + return MaterialApp.router( + theme: theme.materialTheme, + localizationsDelegates: const [_TestLocalizationsDelegate()], + routerConfig: GoRouter( + routes: [GoRoute(path: "/", builder: (context, state) => home)], + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + accessibleNavigation: accessibleNavigation, + textScaler: textScaler, + gestureSettings: gestureSettings, + padding: padding, + viewPadding: padding, + ), + child: child!, + ), + ); +} + +/// [FlowLocalizations.delegate] also syncs home widgets over a platform +/// channel, which isn't there in widget tests. +class _TestLocalizationsDelegate + extends LocalizationsDelegate { + const _TestLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => + SynchronousFuture(FlowLocalizations(locale)); + + @override + bool shouldReload(_TestLocalizationsDelegate old) => false; +} + +class _MemoryPreferences extends Fake implements SharedPreferencesWithCache { + final Map _values = {}; + + @override + Object? get(String key) => _values[key]; + + @override + String? getString(String key) => _values[key] as String?; + + @override + List? getStringList(String key) => _values[key] as List?; + + @override + Future setString(String key, String value) async { + _values[key] = value; + } + + @override + Future remove(String key) async { + _values.remove(key); + } +} From bfcb72e00e53e96eb80399e2bcea450fab654c36 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 14 Sep 2026 00:01:12 +0800 Subject: [PATCH 07/57] remove doc --- docs/feasibility-self-hosted-sync.md | 167 --------------------------- 1 file changed, 167 deletions(-) delete mode 100644 docs/feasibility-self-hosted-sync.md diff --git a/docs/feasibility-self-hosted-sync.md b/docs/feasibility-self-hosted-sync.md deleted file mode 100644 index 36632d54..00000000 --- a/docs/feasibility-self-hosted-sync.md +++ /dev/null @@ -1,167 +0,0 @@ -# Feasibility — Self-hosted, encrypted, multi-user sync - -**Status:** Assessment only (no code written) -**Reviewed against:** `analytics-overhaul` branch, app version `0.23.0+347` -**Scope of request:** Self-hosted backend + multi-device sync + conflict handling + end-to-end encryption + multi-user shared accounts with per-account permissions. - ---- - -## 1. Verdict up front - -This is **feasible but it is the single largest feature ever proposed for Flow** — it is closer to "build a second product" than "add a feature." The request bundles five things that are each substantial on their own: - -1. A sync engine (change tracking + merge + conflict resolution) -2. A self-hostable backend server (Flow has **zero** backend code today) -3. End-to-end encryption with multi-device key management -4. A multi-user identity + permissions model -5. The UI for all of the above - -Two of the stated requirements — **true E2E encryption** and **server-enforced per-account permissions** — are in direct technical tension (see §5). They can be reconciled, but only with real cryptographic engineering (envelope encryption + key rotation), not a CRUD backend. - -My honest recommendation (§8): **do not build the full request as one project.** Ship a self-hosted *encrypted backup-sync* first (small, reuses existing code, covers the most common real-world case — your own devices and couples sharing one login), then decide whether true collaborative multi-user sync is worth the ongoing security + ops burden. - ---- - -## 2. What Flow is today (the parts that matter here) - -Grounded in the code, not assumptions: - -| Area | Current state | File | -|---|---|---| -| Database | **ObjectBox** embedded NoSQL, single store, **single-user, fully offline** | `lib/objectbox.dart` | -| Backend | **None.** No auth, no API client, no server, no concept of a "user account" | — (verified: no auth/token/login code in `lib/`) | -| "Sync" today | **Whole-database JSON snapshot** + assets, zipped, uploaded to iCloud | `lib/sync/export/export_v2.dart`, `lib/services/sync/icloud_syncer.dart` | -| Sync abstraction | `Syncer` interface (`put`/`get`/`list`/`delete`/`download`) — **file-level**, only iCloud implements it | `lib/services/sync/syncer.dart` | -| Encryption | **None.** Backups are plaintext JSON in a zip | `lib/sync/export/export_v2.dart` | -| Reactive UI | ObjectBox `query().watch()` streams + singleton services with listeners | `lib/services/transactions.dart:44` | -| Identity | `Profile` and `UserPreferences` are **single global records** (one user assumed) | `lib/entity/profile.dart`, `lib/entity/user_preferences.dart` | - -### The data model is *partly* sync-ready - -Good news first — these help: - -- **Every entity already has a `uuid` (`@Unique`)** — globally unique IDs, not just local autoincrement `id`. Essential for sync, and it's already there. (`Account`, `Category`, `Transaction`, `TransactionTag`, `FileAttachment`, `RecurringTransaction`, `Budget`, `Goal`, `Profile`, `TransactionFilterPreset`, `UserPreferences`.) -- **Relations are denormalized to UUIDs**, not just ObjectBox int links: `Transaction` carries `accountUuid`, `categoryUuid`, `tagsUuids`, `attachmentsUuids` alongside the `ToOne`/`ToMany`. This means records are portable across devices without remapping local IDs — a real head start. (`lib/entity/transaction.dart:147-225`) -- **Transactions already have soft-delete + tombstone fields**: `isDeleted`, `deletedDate`. (`lib/entity/transaction.dart:36-39`) - -### The gaps that block sync - -These are the parts that don't exist yet and have to be built: - -1. **No `updatedDate` / version / revision on any entity.** Only `createdDate` exists. Confirmed across all entities. Without a per-record modification timestamp or logical clock, **there is no basis for conflict resolution** ("which edit wins?"). This is the #1 schema gap. -2. **Tombstones exist only for `Transaction`.** `Account`, `Category`, `TransactionTag`, `Budget`, `Goal`, etc. are **hard-deleted** (`removeAllAsync`, `box.remove`). A hard delete cannot be synced — the other device never learns it happened. Every syncable entity needs a soft-delete path. -3. **No change-log / outbox.** Nothing records "what changed locally since the last sync." Today writes just hit ObjectBox. A sync engine needs an outbox or a query over `updatedDate > lastSyncCursor`. -4. **File attachments are loose binary blobs on disk** (`FileAttachment.filePath` → file under the app data dir). These are *not* in the database and need a **separate content-addressed blob sync channel** (and separate encryption). (`lib/entity/file_attachment.dart`) -5. **Single-user assumptions baked in.** `Profile`, `UserPreferences`, and the unique constraint on `Account.name` all assume one user. Multi-user breaks several of these (e.g. two users both named "Cash"; whose `primaryCurrency`?). - ---- - -## 3. Decomposing the request into workstreams - -| # | Workstream | What it is | Net-new? | -|---|---|---|---| -| A | **Schema + write-path changes** | Add `updatedDate`/clock + tombstones to all entities; add ownership/visibility to `Account`; outbox; ObjectBox migration | Modifies every entity + every write path | -| B | **Client sync engine** | Track local changes, push/pull deltas, merge into ObjectBox, resolve conflicts, sync attachment blobs, retry/offline queue | Net-new | -| C | **Self-hostable backend** | Auth, sync API, per-account ACL, blob store, invite flow, Docker packaging, versioning, migrations | **Net-new from zero** | -| D | **E2E encryption layer** | Per-account keys, device keypairs, envelope encryption, key backup/recovery, rotation-on-revoke | Net-new, security-critical | -| E | **UI** | Server connection/sign-in, account sharing + members + roles, invites, sync status, conflict surfacing, key-recovery flow | Net-new screens | - ---- - -## 4. Complexity ratings (you asked specifically about UI vs integration) - -### Integration / backend complexity: **Very High** - -- **The hard core is the sync engine + E2E, not the UI.** Conflict resolution, referential integrity across devices, blob sync, and key management are the parts that consume the time and are the ones that, if wrong, cause **silent financial-data loss or a privacy breach**. For a finance app, "mostly works" is not acceptable here. -- **There is no backend to extend** — it's greenfield. Auth, API, storage, ops, packaging, and *keeping the server versioned in lockstep with the app* is a permanent maintenance surface. -- ObjectBox is **not** natively a sync database in the open-source build. You either (a) adopt a sync-capable engine alongside/under it, or (b) hand-roll the sync protocol over the existing snapshot model. Either way this is weeks-to-months, not days. - -### UI complexity: **Medium-High** (the *easier* half, and Flow's strength) - -The reactive foundation is a genuine advantage: because the UI already rebuilds off ObjectBox `query().watch()` streams, **a sync engine that writes merged records into the boxes gets live UI updates for free** — no screen rewrites for "data changed remotely." New screens needed: - -- Server connection / sign-in / "connect to your server" -- Account sharing: member list, role picker (read-only / read-edit), pending invites, revoke -- Invite acceptance flow (share code or link) -- Sync status (syncing / offline / conflict / last-synced) -- **Key backup & recovery** (the scary one — lose your key, lose your encrypted data; the UX must make this nearly impossible to get wrong) -- Reconciling single-user UI assumptions (per-account ownership badges, "private" vs "shared" indicators) - -UI is real work but it's bounded, visual, and exactly the kind of thing this codebase does well (1 widget = 1 file, l10n via `.t()`, established theming). - ---- - -## 5. The two tensions to resolve before any code - -### Tension A — E2E encryption vs. server-enforced permissions (technical) - -The request asks for **both**: -- "encrypted in a way that prevents server operators from accessing plaintext" (true E2E), **and** -- per-account permissions enforced server-side (private/shared, read-only/read-edit, invite, revoke). - -These pull in opposite directions. **If the server cannot read the data, it cannot enforce "user B may only see account X"** by inspecting rows — enforcement has to be *cryptographic*: each shared account gets its own symmetric key, and that key is envelope-encrypted to the public key of every authorized member. Sharing = wrap the account key to a new member's key. **Revoking = rotate the account key** and re-wrap to the remaining members (and note: revoke can only stop *future* updates — anything already synced to a removed member's device was decryptable and can't be clawed back). - -This is a solved problem (it's how E2E group messaging works), but it means workstream D is "build a small group-key-management system," not "add a permissions column." This is the part I'd want reviewed by someone with crypto experience, possibly audited. - -A pragmatic middle ground: **encrypted at rest with a server-held-but-per-user key** (server *could* technically read, but the threat model is "your own server / a trusted host") gives you real privacy + clean server-side RBAC at a fraction of the complexity. Whether that satisfies "prevents server operators from accessing plaintext" depends on how literally that requirement is taken — worth confirming with the requester. - -### Tension B — strategy & economics (product) - -Per the current direction (free privacy-first local core; **Eny is the monetization vehicle**, pay-per-use credits; solo dev, donations currently below the Apple developer fee): a **self-hosted** sync server is the textbook feature that quietly sinks solo apps — - -- It generates **support load** (every user's broken Docker deploy, reverse proxy, TLS cert, and "why won't it sync" becomes your problem). -- It is **security-critical and permanently maintained**, versioned in lockstep with the app. -- Self-hosted by definition produces **no recurring revenue**, and the request explicitly rules out paid dependencies — so it can't subsidize its own maintenance. - -This isn't a reason to reject it. It's a reason to (a) scope it down hard, and (b) consider that a **hosted, optional, paid sync** could be the version that's actually sustainable, with self-hosting as a power-user option on the same protocol. - ---- - -## 6. Architectural options - -### Option B (recommended first step) — Self-hosted *encrypted backup-sync* over WebDAV/S3 -Reuse what exists: `SyncModelV2` snapshot + the `Syncer` abstraction. Add (1) client-side encryption of the snapshot before upload, and (2) a `WebDavSyncer implements Syncer` (and/or S3). The requester explicitly named WebDAV as acceptable. - -- **Gets you:** self-hosted ✅, user-owned data ✅, encrypted ✅, no proprietary cloud ✅, multi-device for one user ✅, couples-sharing-one-login ✅ (the most common real case). -- **Does NOT get you:** real-time sync, concurrent multi-user editing, per-account permissions, fine-grained conflict resolution (it's whole-file last-writer-wins). -- **Effort:** Small-to-medium (days-to-weeks). Mostly reuses existing code + the existing `Syncer` seam. **This is the highest value-per-effort path by far.** - -### Option A — Adopt an existing local-first / CRDT sync engine -Rather than hand-roll the protocol, build on an established local-first stack (CRDT or server-authoritative log-based sync). This is the right path **if** true collaborative multi-user sync is the goal. Trade-off: most such engines are SQLite/Postgres-oriented, not ObjectBox-native, so it likely means introducing a second store or migrating the persistence layer — a large architectural change. (ObjectBox also has its own commercial Sync product with a self-hostable server, which is the most DB-native option but is paid and not E2E by default — worth evaluating against the "no paid services" requirement.) - -- **Effort:** High. Multi-month. But far less risky than hand-rolling conflict resolution and crypto from scratch. - -### Option C — Fully custom build (the literal request) -Workstreams A–E, all hand-built: bespoke sync protocol, custom backend, custom E2E group-key system, custom RBAC. - -- **Effort:** Very high. Realistically **6–12+ months of solo work** to something trustworthy with financial data, plus indefinite maintenance. I'd advise against this as a starting point. - ---- - -## 7. Effort summary - -| Path | What you get | Rough effort (solo) | Ongoing burden | -|---|---|---|---| -| **B — encrypted backup-sync** | Self-host + multi-device (single user) + privacy | Days → few weeks | Low | -| **A — local-first engine** | Real multi-user collaborative sync | Multi-month | Medium-High | -| **C — full custom** | Exactly the request, E2E + RBAC | 6–12+ months | High (security + ops) | - ---- - -## 8. Recommendation - -1. **Phase 1 — ship Option B.** Self-hosted encrypted backup-sync via WebDAV, reusing `SyncModelV2` + the `Syncer` seam + client-side encryption. Small lift, no backend to operate, and it satisfies *most* of what real users mean by "sync across my devices / share with my partner." -2. **Decouple the schema prerequisites and do them anyway.** Adding `updatedDate` + tombstones to every entity and a soft-delete path (workstream A) is **valuable regardless** and is the foundation for any future real sync. Do this early; it's not wasted even if Phase 3 never happens. -3. **Phase 2 — measure demand** for true concurrent multi-user collaboration (private + shared accounts, live permissions). If it's genuinely there: -4. **Phase 3 — build collaborative sync on an existing engine (Option A), not from scratch**, and treat E2E + group-key management (workstream D) as a discrete, security-reviewed sub-project. Seriously weigh a **hosted, optional, paid** tier so the feature can fund its own maintenance, with self-hosting as a power-user option on the same protocol. - -### Open questions for the requester (these change the estimate materially) -- Is **literal zero-knowledge** E2E required, or is "encrypted, on *your own* server, host can't casually read it" enough? (This is the single biggest cost driver — see Tension A.) -- Is the real need **multi-device for one person**, or **genuine concurrent multi-user with private accounts**? The first is Phase 1; the second is Phase 3. -- Acceptable to offer an **optional hosted** version (paid) so it's sustainable, with self-hosting as the open option? - ---- - -*Prepared from a read of the current codebase. No application code was modified.* - - From 14c61fb75b8c7e32b1f4b4c4c01d2bd9a3d064c5 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 14 Sep 2026 00:29:48 +0800 Subject: [PATCH 08/57] zh_TW: revise terminology from #758 --- assets/l10n/zh_TW.json | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 90e42275..6c4d1413 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -90,7 +90,7 @@ "budgets": "預算", "budgets.new": "新增預算", "categories": "分類", - "categories.addFromPresets": "從設定檔新增", + "categories.addFromPresets": "從預設新增", "categories.noCategories": "您目前沒有任何分類", "category": "分類", "category.delete": "刪除分類", @@ -175,7 +175,7 @@ "enum.ImportV2Progress@writingRecurringTransactions": "正在寫入週期性交易", "enum.ImportV2Progress@writingTransactionTags": "正在寫入交易標籤", "enum.ImportV2Progress@writingTransactions": "正在寫入交易", - "enum.ImportV2Progress@writingTranscationFilterPresets": "正在寫入交易過濾設定檔", + "enum.ImportV2Progress@writingTranscationFilterPresets": "正在寫入交易過濾預設", "enum.ImportV2Progress@writingUserPreferences": "正在寫入使用者偏好設定", "enum.PDFHeader@account": "帳戶", "enum.PDFHeader@amount": "金額", @@ -324,12 +324,12 @@ "integrations.eny.connected#false": "未連接", "integrations.eny.connected#true": "已連接", "integrations.eny.creditsRemaining": "剩餘點數", - "integrations.eny.dashboard": "Eny 儀錶板", - "integrations.eny.dashboard.description": "您可以從儀錶板連接您的 Eny 帳戶", + "integrations.eny.dashboard": "Eny 儀表板", + "integrations.eny.dashboard.description": "您可以從儀表板連接您的 Eny 帳戶", "integrations.eny.disconnect": "中斷連接 Eny", "integrations.eny.invalidCredentials": "憑證無效", "integrations.eny.invalidCredentials.configure": "設定", - "integrations.eny.invalidCredentials.description": "請嘗試從儀錶板重新連接 Eny", + "integrations.eny.invalidCredentials.description": "請嘗試從儀表板重新連接 Eny", "integrations.eny.multipleImagesNotice": "傳送 {n} 張圖片進行處理?", "integrations.eny.multipleImagesNotice.checkNotice": "即使有部分圖片失敗,仍會消耗點數。因此,在確認前請仔細檢查圖片。您一次最多可傳送 5 張圖片。", "integrations.eny.multipleImagesNotice.description": "將消耗 {n} 點 Eny 點數", @@ -396,7 +396,7 @@ "preferences.reminders.remindDaily.expiryWarning": "如果您連續 7 天未開啟 Flow,提醒將會自動停止", "preferences.reminders.remindDaily.time": "提醒時間", "preferences.reminders.unsupportedPlatform": "此平台不支援排程通知", - "preferences.scan": "掃描檔案", + "preferences.scan": "掃描文件", "preferences.scan.createTransactionsPerItemInScans": "掃描的每個項目建立單獨交易", "preferences.scan.createTransactionsPerItemInScans.description": "對過長的收據可能會很混亂", "preferences.scan.markPendingThreshold": "將交易標示為待處理", @@ -425,7 +425,7 @@ "preferences.theme.enableOledTheme": "使用 OLED 深色主題", "preferences.theme.other": "其他主題", "preferences.theme.themeChangesAppIcon": "應用程式圖示跟隨主題變更", - "preferences.transactionButtonOrder": "按鈕設定", + "preferences.transactionButtonOrder": "按鈕順序", "preferences.transactionButtonOrder.description": "變更首頁新增交易按鈕的順序", "preferences.transactionButtonOrder.guide": "拖曳按鈕以重新排序", "preferences.transactionButtonOrder.widgetDescription": "此順序將會反映在主畫面的交易按鈕小工具上", @@ -529,7 +529,7 @@ "setup.categories.existing": "現有分類", "setup.categories.preset.beauty": "美容", "setup.categories.preset.childCare": "育兒", - "setup.categories.preset.donations": "捐助", + "setup.categories.preset.donations": "捐款", "setup.categories.preset.drinks": "飲品", "setup.categories.preset.eatingOut": "外食", "setup.categories.preset.education": "教育", @@ -583,9 +583,9 @@ "support.contribute": "貢獻程式碼", "support.contribute.description": "如果您是一位開發者,您可以參與 Flow 的開發。貢獻者名單等待您的加入。", "support.description": "Flow 是一款出於熱忱、免費且為所有人開源的軟體。如果您認為 Flow 很有幫助,考慮協助專案成長!您可以透過以下方式支持我們:", - "support.donateDeveloper": "捐助開發者", + "support.donateDeveloper": "贊助開發者", "support.donateDeveloper.action": "請創作者喝杯咖啡", - "support.donateDeveloper.description": "Flow 的所有功能均免費提供,捐助開發者不會解鎖任何額外功能", + "support.donateDeveloper.description": "Flow 的所有功能均免費提供,贊助開發者不會解鎖任何額外功能", "support.leaveAReview": "留下評論", "support.leaveAReview.action": "為 Flow 評分", "support.leaveAReview.description": "您可以在 {appStore} 為 Flow 評分並留下您的評論", @@ -599,7 +599,7 @@ "sync.export.asJSON": "匯出為備份 (JSON)", "sync.export.asJSON.description": "僅包含基本資料,不含圖片或附加檔案。", "sync.export.asPDF": "報表 (PDF)", - "sync.export.asPDF.description": "帳戶報表;適合列印。這並非官方正式檔案,僅供您個人使用。", + "sync.export.asPDF.description": "帳戶報表;適合列印。這並非官方正式文件,僅供您個人使用。", "sync.export.asZIP": "匯出為備份 (ZIP)", "sync.export.asZIP.description": "稍後可完全還原。包含附加檔案", "sync.export.autoBackup": "自動備份", @@ -617,7 +617,7 @@ "sync.export.pdf.generatedAt": "產生時間", "sync.export.pdf.header": "Flow - 個人財務紀錄 (非官方,{range})", "sync.export.pdf.notice[0]": "由 ", - "sync.export.pdf.notice[1]": " 產生。這不是一份具法律效力的檔案。這不是財務報表。這不是收據。這不以任何形式代表現實狀況。此檔案僅供個人參考使用。", + "sync.export.pdf.notice[1]": " 產生。這不是一份具法律效力的文件。這不是財務報表。這不是收據。這不以任何形式代表現實狀況。此文件僅供個人參考使用。", "sync.export.pdf.size": "紙張尺寸", "sync.export.pdf.summary": "按帳戶統計摘要 ({range})", "sync.export.pdf.summary.allAcounts": "所有帳戶", @@ -655,7 +655,7 @@ "sync.import.syncData.parsedEstimate.fileAttachmentsCount": "{count} 個檔案附件", "sync.import.syncData.parsedEstimate.goalCount": "{count} 個目標", "sync.import.syncData.parsedEstimate.transactionCount": "{count} 筆交易", - "sync.import.syncData.parsedEstimate.transactionFilterPresets": "{count} 個交易過濾設定檔", + "sync.import.syncData.parsedEstimate.transactionFilterPresets": "{count} 個交易過濾預設", "sync.import.syncData.parsedEstimate.transactionTagCount": "{count} 個交易標籤", "sync.import.zipWarning": "請確保匯入的 ZIP 檔案是由 Flow App 所產生的!", "tabs.accounts": "帳戶", @@ -894,15 +894,15 @@ "transaction.transfer.to": "轉入帳戶", "transaction.transfer.to.select": "轉入至此", "transaction.transfer.to.title": "轉入至 {account}", - "transactionFilterPreset": "過濾設定檔", - "transactionFilterPreset.default": "設定檔選項", - "transactionFilterPreset.delete": "刪除設定檔", + "transactionFilterPreset": "過濾預設", + "transactionFilterPreset.default": "預設選項", + "transactionFilterPreset.delete": "刪除預設", "transactionFilterPreset.invalid": "無效", - "transactionFilterPreset.invalid.description": "此設定檔中的某些帳戶/分類已遺失。請重新增立或刪除該設定檔", + "transactionFilterPreset.invalid.description": "此預設中的某些帳戶/分類已遺失。請重新建立或刪除該預設", "transactionFilterPreset.makeDefault": "設為預設", - "transactionFilterPreset.saveAsNew": "另存為新設定檔", - "transactionFilterPreset.saveAsNew.guide": "要儲存新的設定檔,請先變更過濾條件後再回到這裡", - "transactionFilterPreset.saveAsNew.name": "設定檔名稱", + "transactionFilterPreset.saveAsNew": "另存為新預設", + "transactionFilterPreset.saveAsNew.guide": "要儲存新的預設,請先變更過濾條件後再回到這裡", + "transactionFilterPreset.saveAsNew.name": "預設名稱", "transactions.all": "所有交易", "transactions.batch.assignAccountForAll": "為全部指派帳戶", "transactions.batch.assignAccountIndividually": "個別指派帳戶", From d953bdb6dbd388f75d5a889def1b7e3abed93f20 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 14 Sep 2026 00:53:47 +0800 Subject: [PATCH 09/57] rc 0.24.1 --- CHANGELOG.md | 9 +++++---- pubspec.yaml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5fce461..fb7633ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,13 @@ # Changelog -## next +## 0.24.1 ### Fixes -* Fixed new transaction button not responding when accessible navigation is - enabled -* Fixed transaction menu closing on small finger movements +* Fixed new transaction button not working when accessibility services (e.g. + TalkBack) are enabled +* Fixed new transaction menu closing on small finger movements +* Fixed Traditional Chinese (Taiwan) translations, [#758](https://github.com/flow-mn/flow/pull/758) by [@olivertzeng](https://github.com/olivertzeng) ## 0.24.0 diff --git a/pubspec.yaml b/pubspec.yaml index b988ed3a..b412ec3f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.24.0+357" +version: "0.24.1+358" environment: sdk: ">=3.10.0 <4.0.0" From c80dedb199419eba2bf21e4f73d9a38ff901b379 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 22:34:05 +0800 Subject: [PATCH 10/57] show approximate primary currency amount --- lib/routes/transaction_page.dart | 32 ++++++--- lib/services/exchange_rates.dart | 34 ++++++---- .../general/approximate_money_text.dart | 65 +++++++++++++++++++ lib/widgets/transaction_list_tile.dart | 14 +++- 4 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 lib/widgets/general/approximate_money_text.dart diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index 766ac2f8..f755ac8b 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -41,6 +41,7 @@ import "package:flow/services/transactions.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/utils.dart"; +import "package:flow/widgets/general/approximate_money_text.dart"; import "package:flow/widgets/general/button.dart"; import "package:flow/widgets/general/directional_chevron.dart"; import "package:flow/widgets/general/flow_icon.dart"; @@ -338,14 +339,29 @@ class _TransactionPageState extends State { Center( child: InkWell( onTap: inputAmount, - child: Center( - child: Text( - Money( - _amount, - _selectedAccount?.currency ?? primaryCurrency, - ).formatMoney(), - style: context.textTheme.displayMedium, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Money( + _amount, + _selectedAccount?.currency ?? primaryCurrency, + ).formatMoney(), + style: context.textTheme.displayMedium, + ), + ApproximateMoneyText( + Money( + _amount, + _selectedAccount?.currency ?? primaryCurrency, + ), + overrideObscure: false, + textAlign: .center, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurface + .withAlpha(0x80), + ), + ), + ], ), ), ), diff --git a/lib/services/exchange_rates.dart b/lib/services/exchange_rates.dart index e0a8236b..bb6209fb 100644 --- a/lib/services/exchange_rates.dart +++ b/lib/services/exchange_rates.dart @@ -66,6 +66,9 @@ class ExchangeRatesService { "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@$dateParam/v1/currencies/$normalizedCurrency.min.json", ), ); + if (response.statusCode != 200) { + throw Exception("Unexpected status code: ${response.statusCode}"); + } jsonResponse = jsonDecode(response.body); } catch (e, stackTrace) { _log.warning( @@ -75,19 +78,24 @@ class ExchangeRatesService { ); } - try { - final response = await http.get( - Uri.parse( - "https://$dateParam.currency-api.pages.dev/v1/currencies/$normalizedCurrency.min.json", - ), - ); - jsonResponse = jsonDecode(response.body); - } catch (e, stackTrace) { - _log.warning( - "Failed to fetch exchange rates from side source", - e, - stackTrace, - ); + if (jsonResponse == null) { + try { + final response = await http.get( + Uri.parse( + "https://$dateParam.currency-api.pages.dev/v1/currencies/$normalizedCurrency.min.json", + ), + ); + if (response.statusCode != 200) { + throw Exception("Unexpected status code: ${response.statusCode}"); + } + jsonResponse = jsonDecode(response.body); + } catch (e, stackTrace) { + _log.warning( + "Failed to fetch exchange rates from side source", + e, + stackTrace, + ); + } } if (jsonResponse == null) { diff --git a/lib/widgets/general/approximate_money_text.dart b/lib/widgets/general/approximate_money_text.dart new file mode 100644 index 00000000..7b8c7f7a --- /dev/null +++ b/lib/widgets/general/approximate_money_text.dart @@ -0,0 +1,65 @@ +import "package:flow/data/money.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/utils/extensions/money.dart"; +import "package:flow/widgets/general/money_text_builder.dart"; +import "package:flow/widgets/general/money_text_raw.dart"; +import "package:flutter/material.dart"; + +/// Shows [money] converted to the primary currency using the latest rates, +/// e.g., "≈ R$25". Renders nothing when conversion isn't possible or needed. +class ApproximateMoneyText extends StatelessWidget { + final Money money; + + final bool displayAbsoluteAmount; + + /// Set this to [true] to make it always unobscured + /// + /// Set this to [false] to make it always obscured + /// + /// Set this to [null] to use the default behavior + final bool? overrideObscure; + + final TextAlign? textAlign; + final TextStyle? style; + + const ApproximateMoneyText( + this.money, { + super.key, + this.displayAbsoluteAmount = false, + this.overrideObscure, + this.textAlign, + this.style, + }); + + @override + Widget build(BuildContext context) { + final String primaryCurrency = UserPreferencesService().primaryCurrency; + + if (money.currency == primaryCurrency) return const SizedBox.shrink(); + + return ValueListenableBuilder( + valueListenable: ExchangeRatesService().exchangeRatesCache, + builder: (context, exchangeRatesCache, child) { + final double? converted = money.tryConvertAmount( + primaryCurrency, + exchangeRatesCache?.get(primaryCurrency), + ); + + if (converted == null) return const SizedBox.shrink(); + + return MoneyTextBuilder( + money: Money(converted, primaryCurrency), + displayAbsoluteAmount: displayAbsoluteAmount, + overrideObscure: overrideObscure, + builder: (context, text, money) => MoneyTextRaw( + text: "≈ $text", + style: style, + textAlign: textAlign, + maxLines: 1, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index 175a37d5..18be1c2f 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -9,6 +9,7 @@ import "package:flow/providers/accounts_provider.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/transaction.dart"; +import "package:flow/widgets/general/approximate_money_text.dart"; import "package:flow/widgets/general/directional_slidable.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/money_text.dart"; @@ -274,6 +275,18 @@ class TransactionListTile extends StatelessWidget { ), ), overrideObscure: overrideObscure, + ) + else + ApproximateMoneyText( + transaction.money, + displayAbsoluteAmount: + transaction.isTransfer && combineTransfers, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurface.withAlpha( + 0x80, + ), + ), + overrideObscure: overrideObscure, ), ], ), @@ -443,4 +456,3 @@ class TransactionListTile extends StatelessWidget { ), ); } - From 9093bd39962e2a53305c4f31e2bb850555e164da Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 22:34:48 +0800 Subject: [PATCH 11/57] option to show absolute dates in list headers --- assets/l10n/ar.json | 1 + assets/l10n/be_BY.json | 1 + assets/l10n/cs_CZ.json | 1 + assets/l10n/de_DE.json | 1 + assets/l10n/en.json | 1 + assets/l10n/es_ES.json | 1 + assets/l10n/fa_IR.json | 1 + assets/l10n/fr_FR.json | 1 + assets/l10n/it_IT.json | 1 + assets/l10n/mn_MN.json | 1 + assets/l10n/pl_PL.json | 1 + assets/l10n/ru_RU.json | 1 + assets/l10n/tr_TR.json | 1 + assets/l10n/uk_UA.json | 1 + assets/l10n/zh_CN.json | 1 + assets/l10n/zh_TW.json | 1 + lib/entity/user_preferences.dart | 3 +++ lib/entity/user_preferences.g.dart | 4 ++++ lib/objectbox/objectbox-model.json | 7 ++++++- lib/objectbox/objectbox.g.dart | 19 +++++++++++++++++-- ...list_item_appearance_preferences_page.dart | 15 +++++++++++++++ lib/services/user_preferences.dart | 10 ++++++++++ lib/widgets/transactions_date_header.dart | 13 ++++++++++++- 23 files changed, 83 insertions(+), 4 deletions(-) diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e1f16610..f586b647 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "يمكنك إخفاء هذا القسم من الإعدادات", "preferences.transactions.geo.enable": "تفعيل", "preferences.transactions.listTile": "مظهر عناصر القائمة", + "preferences.transactions.listTile.absoluteDateHeaders": "عرض التواريخ الدقيقة في عناوين القائمة", "preferences.transactions.listTile.fallbackToCategoryName": "عرض الفئة للمعاملات بدون عنوان", "preferences.transactions.listTile.leading": "الأيقونة الرئيسية", "preferences.transactions.listTile.leading.account": "الحساب", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 12330f6f..fb147863 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Вы можаце схаваць гэты раздзел у наладах", "preferences.transactions.geo.enable": "Уключыць", "preferences.transactions.listTile": "Вонкавы выгляд элемента спісу", + "preferences.transactions.listTile.absoluteDateHeaders": "Паказваць дакладныя даты ў загалоўках", "preferences.transactions.listTile.fallbackToCategoryName": "Паказваць катэгорыю для транзакцый без назвы", "preferences.transactions.listTile.leading": "Галоўны абразок", "preferences.transactions.listTile.leading.account": "Рахунак", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 25fe07cf..deb5b194 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Tuto sekci můžete skrýt v nastavení.", "preferences.transactions.geo.enable": "Povolit", "preferences.transactions.listTile": "Vzhled položky v seznamu", + "preferences.transactions.listTile.absoluteDateHeaders": "Zobrazit přesná data v záhlavích", "preferences.transactions.listTile.fallbackToCategoryName": "Zobrazit název kategorie u transakcí bez názvu", "preferences.transactions.listTile.leading": "Hlavní ikona", "preferences.transactions.listTile.leading.account": "Účet", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 8bd1995d..43f07cf7 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Du kannst diesen Bereich in den Einstellungen ausblenden.", "preferences.transactions.geo.enable": "Aktivieren", "preferences.transactions.listTile": "Aussehen der Listeneinträge", + "preferences.transactions.listTile.absoluteDateHeaders": "Genaue Daten in Listenüberschriften anzeigen", "preferences.transactions.listTile.fallbackToCategoryName": "Kategorie anzeigen bei Buchungen ohne Titel", "preferences.transactions.listTile.leading": "Symbol am Anfang", "preferences.transactions.listTile.leading.account": "Konto", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 7c8ff122..b3d0291b 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "You can hide this section in settings", "preferences.transactions.geo.enable": "Enable", "preferences.transactions.listTile": "List item appearance", + "preferences.transactions.listTile.absoluteDateHeaders": "Show exact dates in list headers", "preferences.transactions.listTile.fallbackToCategoryName": "Show category for untitled transactions", "preferences.transactions.listTile.leading": "Leading icon", "preferences.transactions.listTile.leading.account": "Account", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 8953bfd6..7b351d05 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Puedes ocultar esta sección en los ajustes", "preferences.transactions.geo.enable": "Activar", "preferences.transactions.listTile": "Apariencia del elemento de lista", + "preferences.transactions.listTile.absoluteDateHeaders": "Mostrar fechas exactas en los encabezados", "preferences.transactions.listTile.fallbackToCategoryName": "Mostrar categoría para transacciones sin título", "preferences.transactions.listTile.leading": "Icono principal", "preferences.transactions.listTile.leading.account": "Cuenta", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 2204f9b6..92e76aea 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "می‌توانید این بخش را در تنظیمات مخفی کنید", "preferences.transactions.geo.enable": "فعال کردن", "preferences.transactions.listTile": "نمایش آیتم‌های لیست", + "preferences.transactions.listTile.absoluteDateHeaders": "نمایش تاریخ دقیق در سرتیترها", "preferences.transactions.listTile.fallbackToCategoryName": "برای تراکنش‌های بدون عنوان، دسته‌بندی را نشان بده", "preferences.transactions.listTile.leading": "آیکن ابتدای آیتم", "preferences.transactions.listTile.leading.account": "حساب", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 4a941c23..487a979a 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Vous pouvez masquer cette section dans les paramètres", "preferences.transactions.geo.enable": "Activer", "preferences.transactions.listTile": "Apparence des éléments de la liste", + "preferences.transactions.listTile.absoluteDateHeaders": "Afficher les dates exactes dans les en-têtes", "preferences.transactions.listTile.fallbackToCategoryName": "Afficher la catégorie pour les transactions sans titre", "preferences.transactions.listTile.leading": "Icône principale", "preferences.transactions.listTile.leading.account": "Compte", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 1c210f49..eaed425f 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Puoi nascondere questa sezione nelle impostazioni.", "preferences.transactions.geo.enable": "Abilita", "preferences.transactions.listTile": "Aspetto degli elementi della lista", + "preferences.transactions.listTile.absoluteDateHeaders": "Mostra date esatte nelle intestazioni", "preferences.transactions.listTile.fallbackToCategoryName": "Mostra categoria per transazioni senza titolo", "preferences.transactions.listTile.leading": "Icona principale", "preferences.transactions.listTile.leading.account": "Conto", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 585648f8..a7734084 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Энэ хэсгийг тохиргооноос нуух боломжтой", "preferences.transactions.geo.enable": "Идэвхжүүлэх", "preferences.transactions.listTile": "Жагсаалтад харагдах байдал", + "preferences.transactions.listTile.absoluteDateHeaders": "Гарчигт яг огноог харуулах", "preferences.transactions.listTile.fallbackToCategoryName": "Гарчиггүй бол ангиллыг харуулах", "preferences.transactions.listTile.leading": "Толгой дүрс", "preferences.transactions.listTile.leading.account": "Данс", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index dd6483c8..d1940b6f 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Możesz ukryć tę sekcję w ustawieniach", "preferences.transactions.geo.enable": "Włącz", "preferences.transactions.listTile": "Wygląd na liście", + "preferences.transactions.listTile.absoluteDateHeaders": "Pokazuj dokładne daty w nagłówkach", "preferences.transactions.listTile.fallbackToCategoryName": "Pokaż kategorię dla transakcji bez tytułu", "preferences.transactions.listTile.leading": "Ikona główna", "preferences.transactions.listTile.leading.account": "Konto", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 9f49d8d9..de49e7b8 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Вы можете скрыть этот раздел в настройках", "preferences.transactions.geo.enable": "Включить", "preferences.transactions.listTile": "Внешний вид элемента списка", + "preferences.transactions.listTile.absoluteDateHeaders": "Показывать точные даты в заголовках", "preferences.transactions.listTile.fallbackToCategoryName": "Показывать категорию для транзакций без названия", "preferences.transactions.listTile.leading": "Ведущий значок", "preferences.transactions.listTile.leading.account": "Счет", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index e7949759..4db84d63 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Bu bölümü ayarlardan gizleyebilirsiniz", "preferences.transactions.geo.enable": "Etkinleştirmek", "preferences.transactions.listTile": "Liste öğesi görünümü", + "preferences.transactions.listTile.absoluteDateHeaders": "Başlıklarda tam tarihleri göster", "preferences.transactions.listTile.fallbackToCategoryName": "Başlıksız işlemler için kategoriyi göster", "preferences.transactions.listTile.leading": "Önde gelen simge", "preferences.transactions.listTile.leading.account": "Hesap", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index f101ac1c..29e20476 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "Ви можете приховати цей розділ у налаштуваннях", "preferences.transactions.geo.enable": "Увімкнути", "preferences.transactions.listTile": "Зовнішній вигляд елемента списку", + "preferences.transactions.listTile.absoluteDateHeaders": "Показувати точні дати в заголовках", "preferences.transactions.listTile.fallbackToCategoryName": "Показувати категорію для транзакцій без назви", "preferences.transactions.listTile.leading": "Провідний значок", "preferences.transactions.listTile.leading.account": "Рахунок", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index dc289d26..eda89210 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "您可以在设置中隐藏此区块", "preferences.transactions.geo.enable": "启用", "preferences.transactions.listTile": "列表项目外观", + "preferences.transactions.listTile.absoluteDateHeaders": "在列表标题中显示具体日期", "preferences.transactions.listTile.fallbackToCategoryName": "未命名交易显示分类名称", "preferences.transactions.listTile.leading": "开头图标", "preferences.transactions.listTile.leading.account": "账户", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 6c4d1413..0b920d39 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -445,6 +445,7 @@ "preferences.transactions.geo.disableInstructions": "您可以在設定中隱藏此區塊", "preferences.transactions.geo.enable": "啟用", "preferences.transactions.listTile": "清單項目外觀", + "preferences.transactions.listTile.absoluteDateHeaders": "在列表標題中顯示具體日期", "preferences.transactions.listTile.fallbackToCategoryName": "未命名交易顯示分類名稱", "preferences.transactions.listTile.leading": "開頭圖示", "preferences.transactions.listTile.leading.account": "帳戶", diff --git a/lib/entity/user_preferences.dart b/lib/entity/user_preferences.dart index 5cc60a08..39abfc24 100644 --- a/lib/entity/user_preferences.dart +++ b/lib/entity/user_preferences.dart @@ -76,6 +76,8 @@ class UserPreferences implements EntityBase { bool transactionListTileShowExternalSource; bool transactionListTileRelaxedDensity; + bool transactionListAbsoluteDateHeaders; + bool createTransactionsPerItemInScans; int? scansPendingThresholdInHours; @@ -174,6 +176,7 @@ class UserPreferences implements EntityBase { this.transactionListTileShowAccountForLeading = false, this.transactionListTileShowExternalSource = true, this.transactionListTileRelaxedDensity = false, + this.transactionListAbsoluteDateHeaders = false, this.createTransactionsPerItemInScans = true, this.scansPendingThresholdInHours = 6, this.privacyModeUponLaunch = false, diff --git a/lib/entity/user_preferences.g.dart b/lib/entity/user_preferences.g.dart index 1d5675d5..35a43143 100644 --- a/lib/entity/user_preferences.g.dart +++ b/lib/entity/user_preferences.g.dart @@ -21,6 +21,8 @@ UserPreferences _$UserPreferencesFromJson(Map json) => json['transactionListTileShowExternalSource'] as bool? ?? true, transactionListTileRelaxedDensity: json['transactionListTileRelaxedDensity'] as bool? ?? false, + transactionListAbsoluteDateHeaders: + json['transactionListAbsoluteDateHeaders'] as bool? ?? false, createTransactionsPerItemInScans: json['createTransactionsPerItemInScans'] as bool? ?? true, scansPendingThresholdInHours: @@ -74,6 +76,8 @@ Map _$UserPreferencesToJson( instance.transactionListTileShowExternalSource, 'transactionListTileRelaxedDensity': instance.transactionListTileRelaxedDensity, + 'transactionListAbsoluteDateHeaders': + instance.transactionListAbsoluteDateHeaders, 'createTransactionsPerItemInScans': instance.createTransactionsPerItemInScans, 'scansPendingThresholdInHours': instance.scansPendingThresholdInHours, 'privacyModeUponLaunch': instance.privacyModeUponLaunch, diff --git a/lib/objectbox/objectbox-model.json b/lib/objectbox/objectbox-model.json index 7a03b93a..14a28d37 100644 --- a/lib/objectbox/objectbox-model.json +++ b/lib/objectbox/objectbox-model.json @@ -362,7 +362,7 @@ }, { "id": "10:7829328581176695647", - "lastPropertyId": "30:5353888497210708730", + "lastPropertyId": "31:8739212611241568931", "name": "UserPreferences", "properties": [ { @@ -507,6 +507,11 @@ "id": "30:5353888497210708730", "name": "homePendingTransactionsTimeRangeSerialized", "type": 9 + }, + { + "id": "31:8739212611241568931", + "name": "transactionListAbsoluteDateHeaders", + "type": 1 } ], "relations": [] diff --git a/lib/objectbox/objectbox.g.dart b/lib/objectbox/objectbox.g.dart index af65cad6..2577b99e 100644 --- a/lib/objectbox/objectbox.g.dart +++ b/lib/objectbox/objectbox.g.dart @@ -455,7 +455,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(10, 7829328581176695647), name: 'UserPreferences', - lastPropertyId: const obx_int.IdUid(30, 5353888497210708730), + lastPropertyId: const obx_int.IdUid(31, 8739212611241568931), flags: 0, properties: [ obx_int.ModelProperty( @@ -627,6 +627,12 @@ final _entities = [ type: 9, flags: 0, ), + obx_int.ModelProperty( + id: const obx_int.IdUid(31, 8739212611241568931), + name: 'transactionListAbsoluteDateHeaders', + type: 1, + flags: 0, + ), ], relations: [], backlinks: [], @@ -1657,7 +1663,7 @@ obx_int.ModelDefinition getObjectBoxModel() { : fbb.writeString( object.homePendingTransactionsTimeRangeSerialized!, ); - fbb.startTable(31); + fbb.startTable(32); fbb.addInt64(0, object.id); fbb.addOffset(1, uuidOffset); fbb.addBool(2, object.combineTransfers); @@ -1686,6 +1692,7 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addBool(27, object.privacyModeUponShaking); fbb.addBool(28, object.transactionListTileShowExternalSource); fbb.addOffset(29, homePendingTransactionsTimeRangeSerializedOffset); + fbb.addBool(30, object.transactionListAbsoluteDateHeaders); fbb.finish(fbb.endTable()); return object.id; }, @@ -1720,6 +1727,8 @@ obx_int.ModelDefinition getObjectBoxModel() { .vTableGet(buffer, rootOffset, 60, false); final transactionListTileRelaxedDensityParam = const fb.BoolReader() .vTableGet(buffer, rootOffset, 46, false); + final transactionListAbsoluteDateHeadersParam = const fb.BoolReader() + .vTableGet(buffer, rootOffset, 64, false); final createTransactionsPerItemInScansParam = const fb.BoolReader() .vTableGet(buffer, rootOffset, 52, false); final scansPendingThresholdInHoursParam = const fb.Int64Reader() @@ -1796,6 +1805,8 @@ obx_int.ModelDefinition getObjectBoxModel() { transactionListTileShowExternalSourceParam, transactionListTileRelaxedDensity: transactionListTileRelaxedDensityParam, + transactionListAbsoluteDateHeaders: + transactionListAbsoluteDateHeadersParam, createTransactionsPerItemInScans: createTransactionsPerItemInScansParam, scansPendingThresholdInHours: scansPendingThresholdInHoursParam, @@ -2694,6 +2705,10 @@ class UserPreferences_ { /// See [UserPreferences.homePendingTransactionsTimeRangeSerialized]. static final homePendingTransactionsTimeRangeSerialized = obx.QueryStringProperty(_entities[6].properties[27]); + + /// See [UserPreferences.transactionListAbsoluteDateHeaders]. + static final transactionListAbsoluteDateHeaders = + obx.QueryBooleanProperty(_entities[6].properties[28]); } /// [Budget] entity fields to define ObjectBox queries. diff --git a/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart b/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart index 0f630374..0cdc47e3 100644 --- a/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart +++ b/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart @@ -44,6 +44,8 @@ class _TransactionListItemAppearancePreferencesPageState UserPreferencesService().transactionListTileShowAccountForLeading; final bool transactionListTileRelaxedDensity = UserPreferencesService().transactionListTileRelaxedDensity; + final bool transactionListAbsoluteDateHeaders = + UserPreferencesService().transactionListAbsoluteDateHeaders; return Scaffold( appBar: AppBar( @@ -122,6 +124,19 @@ class _TransactionListItemAppearancePreferencesPageState setState(() {}); }, ), + SwitchListTile( + title: Text( + "preferences.transactions.listTile.absoluteDateHeaders".t( + context, + ), + ), + value: transactionListAbsoluteDateHeaders, + onChanged: (bool newValue) { + UserPreferencesService().transactionListAbsoluteDateHeaders = + newValue; + setState(() {}); + }, + ), const SizedBox(height: 8.0), ListHeader( "preferences.transactions.listTile.leading".t(context), diff --git a/lib/services/user_preferences.dart b/lib/services/user_preferences.dart index 529ae03f..610d3be0 100644 --- a/lib/services/user_preferences.dart +++ b/lib/services/user_preferences.dart @@ -200,6 +200,16 @@ class UserPreferencesService { ObjectBox().box().put(value); } + bool get transactionListAbsoluteDateHeaders => + value.transactionListAbsoluteDateHeaders; + set transactionListAbsoluteDateHeaders( + bool newTransactionListAbsoluteDateHeaders, + ) { + value.transactionListAbsoluteDateHeaders = + newTransactionListAbsoluteDateHeaders; + ObjectBox().box().put(value); + } + bool get transactionListTileShowAccountForLeading => value.transactionListTileShowAccountForLeading; set transactionListTileShowAccountForLeading( diff --git a/lib/widgets/transactions_date_header.dart b/lib/widgets/transactions_date_header.dart index 2a5dbf55..53527c90 100644 --- a/lib/widgets/transactions_date_header.dart +++ b/lib/widgets/transactions_date_header.dart @@ -61,6 +61,7 @@ class _TransactionListDateHeaderState extends State { TransitiveLocalPreferences().sessionPrivacyMode.addListener( _updatePrivacyMode, ); + UserPreferencesService().valueNotifier.addListener(_rerender); obscure = TransitiveLocalPreferences().sessionPrivacyMode.get(); } @@ -70,6 +71,7 @@ class _TransactionListDateHeaderState extends State { TransitiveLocalPreferences().sessionPrivacyMode.removeListener( _updatePrivacyMode, ); + UserPreferencesService().valueNotifier.removeListener(_rerender); super.dispose(); } @@ -174,6 +176,11 @@ class _TransactionListDateHeaderState extends State { setState(() {}); } + void _rerender() { + if (!mounted) return; + setState(() {}); + } + void _handleRangeTextTap() { rangeTitleAlternative = !rangeTitleAlternative; @@ -195,7 +202,11 @@ class _TransactionListDateHeaderState extends State { } String _getRangeTitle() { - return switch ((widget.range, rangeTitleAlternative)) { + final bool absolute = + UserPreferencesService().transactionListAbsoluteDateHeaders != + rangeTitleAlternative; + + return switch ((widget.range, absolute)) { (DayTimeRange dayTimeRange, false) => dayTimeRange.from.toMoment().calendar(omitHours: true), (DayTimeRange dayTimeRange, true) => dayTimeRange.from.toMoment().format( From e5542c2d950fa7305434db89b672f8b63d080973 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 22:42:50 +0800 Subject: [PATCH 12/57] add toggle for approximate primary currency amount --- 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_CN.json | 2 + assets/l10n/zh_TW.json | 2 + lib/prefs/local_preferences.dart | 9 ++++ .../money_formatting_preferences_page.dart | 28 +++++++++++ .../general/approximate_money_text.dart | 48 ++++++++++++------- 19 files changed, 99 insertions(+), 18 deletions(-) diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e1f16610..a28f0d8e 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "لا تقصر الأرقام إذا أمكن", "preferences.moneyFormatting.setICUPattern": "اختر تنسيقًا مخصصًا", "preferences.moneyFormatting.setICUPattern.default": "افتراضي", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "إظهار المبلغ التقريبي", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "عرض المبالغ الأجنبية بعملتك الرئيسية أيضاً", "preferences.moneyFormatting.useCurrencySymbol": "استخدام رمز العملة", "preferences.moneyFormatting.useCurrencySymbol.description": "مثلاً، \"$5\" بدلاً من \"5USD\"", "preferences.numpad": "لوحة الأرقام", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 12330f6f..634cad56 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Не скарачаць лічбы, калі гэта магчыма", "preferences.moneyFormatting.setICUPattern": "Выбраць карыстальніцкі фармат", "preferences.moneyFormatting.setICUPattern.default": "Па змаўчанні", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Паказваць прыблізную суму", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Таксама паказваць замежныя сумы ў асноўнай валюце", "preferences.moneyFormatting.useCurrencySymbol": "Выкарыстоўваць сімвал валюты", "preferences.moneyFormatting.useCurrencySymbol.description": "напр., \"$5\" замест \"5USD\"", "preferences.numpad": "Лічбавая панэль", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 25fe07cf..d2c73325 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Nezkracovat velká čísla (např. 1 000 000 místo 1M).", "preferences.moneyFormatting.setICUPattern": "Nastavit vlastní formát", "preferences.moneyFormatting.setICUPattern.default": "Výchozí", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Zobrazit přibližnou částku", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Zobrazovat cizí částky i v primární měně", "preferences.moneyFormatting.useCurrencySymbol": "Používat symbol měny", "preferences.moneyFormatting.useCurrencySymbol.description": "např. \"5 $\" místo \"5USD\"", "preferences.numpad": "Číselná klávesnice", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 8bd1995d..fd30402b 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Zahlen nicht abkürzen, wenn es möglich ist.", "preferences.moneyFormatting.setICUPattern": "Eigenes Format auswählen", "preferences.moneyFormatting.setICUPattern.default": "Standard", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Ungefähren Betrag anzeigen", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Fremdwährungsbeträge auch in der Hauptwährung anzeigen", "preferences.moneyFormatting.useCurrencySymbol": "Währungssymbol verwenden", "preferences.moneyFormatting.useCurrencySymbol.description": "z.B. „5€“ statt „5 EUR“", "preferences.numpad": "Ziffernblock", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 7c8ff122..14f32162 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Don't abbreviate numbers when possible", "preferences.moneyFormatting.setICUPattern": "Select a custom format", "preferences.moneyFormatting.setICUPattern.default": "Default", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Show approximate amount", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Also show foreign amounts in your primary currency", "preferences.moneyFormatting.useCurrencySymbol": "Use currency symbol", "preferences.moneyFormatting.useCurrencySymbol.description": "e.g., \"$5\" instead of \"5USD\"", "preferences.numpad": "Numpad", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 8953bfd6..3525e45c 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "No abreviar números cuando sea posible", "preferences.moneyFormatting.setICUPattern": "Seleccionar un formato personalizado", "preferences.moneyFormatting.setICUPattern.default": "Predeterminado", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Mostrar importe aproximado", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Mostrar también importes extranjeros en tu moneda principal", "preferences.moneyFormatting.useCurrencySymbol": "Usar símbolo de moneda", "preferences.moneyFormatting.useCurrencySymbol.description": "ej., \"5$\" en lugar de \"5USD\"", "preferences.numpad": "Teclado numérico", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 2204f9b6..8525f51a 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "تا جای ممکن اعداد را خلاصه نکن", "preferences.moneyFormatting.setICUPattern": "انتخاب فرمت سفارشی", "preferences.moneyFormatting.setICUPattern.default": "پیش‌فرض", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "نمایش مبلغ تقریبی", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "مبالغ ارزی را به ارز اصلی هم نشان بده", "preferences.moneyFormatting.useCurrencySymbol": "استفاده از نماد ارز", "preferences.moneyFormatting.useCurrencySymbol.description": "مثلاً «$5» به‌جای «5USD»", "preferences.numpad": "صفحه‌کلید عددی", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 4a941c23..befa0ba0 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Ne pas abréger les nombres lorsque c'est possible", "preferences.moneyFormatting.setICUPattern": "Sélectionner un format personnalisé", "preferences.moneyFormatting.setICUPattern.default": "Par défaut", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Afficher le montant approximatif", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Afficher aussi les montants étrangers dans votre devise principale", "preferences.moneyFormatting.useCurrencySymbol": "Utiliser le symbole de la devise", "preferences.moneyFormatting.useCurrencySymbol.description": "par exemple, \"5 €\" au lieu de \"5 EUR\"", "preferences.numpad": "Clavier numérique", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 1c210f49..d38fb0fd 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Non abbreviare i numeri quando possibile", "preferences.moneyFormatting.setICUPattern": "Seleziona un formato personalizzato", "preferences.moneyFormatting.setICUPattern.default": "Predefinito", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Mostra importo approssimativo", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Mostra anche gli importi esteri nella valuta principale", "preferences.moneyFormatting.useCurrencySymbol": "Usa simbolo valuta", "preferences.moneyFormatting.useCurrencySymbol.description": "Ad esempio, \"€5\" invece di \"5 EUR\"", "preferences.numpad": "Tastierino numerico", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 585648f8..fe4d132b 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Тоог хураахгүй бүтнээр нь харуулах", "preferences.moneyFormatting.setICUPattern": "Өөр хэлбэр сонгох", "preferences.moneyFormatting.setICUPattern.default": "Өгөгдмөл", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Ойролцоо дүнг харуулах", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Гадаад валютын дүнг үндсэн валютаар давхар харуулах", "preferences.moneyFormatting.useCurrencySymbol": "Тэмдэгт ашиглах", "preferences.moneyFormatting.useCurrencySymbol.description": "Жишээлбэл, \"MNT 3,200\" гэхийн оронд \"3,200₮\"", "preferences.numpad": "Тоон товчлуур", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index dd6483c8..7286d15a 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Nie skracaj liczb, jeśli to możliwe", "preferences.moneyFormatting.setICUPattern": "Wybierz niestandardowy format", "preferences.moneyFormatting.setICUPattern.default": "Domyślny", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Pokaż przybliżoną kwotę", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Pokazuj też kwoty obce w walucie głównej", "preferences.moneyFormatting.useCurrencySymbol": "Użyj symbolu waluty", "preferences.moneyFormatting.useCurrencySymbol.description": "np. \"$5\" zamiast \"5USD\"", "preferences.numpad": "Klawiatura numeryczna", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 9f49d8d9..79c941e4 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Не сокращать числа, когда это возможно", "preferences.moneyFormatting.setICUPattern": "Выберите пользовательский формат", "preferences.moneyFormatting.setICUPattern.default": "По умолчанию", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Показывать примерную сумму", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Также показывать иностранные суммы в основной валюте", "preferences.moneyFormatting.useCurrencySymbol": "Использовать символ валюты", "preferences.moneyFormatting.useCurrencySymbol.description": "например, \"$5\" вместо \"5USD\"", "preferences.numpad": "Цифровая клавиатура", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index e7949759..ed6d5384 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Mümkün olduğunca sayıları kısaltmayın", "preferences.moneyFormatting.setICUPattern": "Özel bir biçim seçin", "preferences.moneyFormatting.setICUPattern.default": "Varsayılan", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Yaklaşık tutarı göster", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Yabancı tutarları ana para biriminde de göster", "preferences.moneyFormatting.useCurrencySymbol": "Para birimi simgesini kullan", "preferences.moneyFormatting.useCurrencySymbol.description": "örneğin, \"5$\" yerine \"5 ABD Doları\"", "preferences.numpad": "Sayısal tuş takımı", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index f101ac1c..616dd643 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "Не скорочувати числа, коли це можливо", "preferences.moneyFormatting.setICUPattern": "Виберіть користувацький формат", "preferences.moneyFormatting.setICUPattern.default": "За замовчуванням", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "Показувати приблизну суму", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Також показувати іноземні суми в основній валюті", "preferences.moneyFormatting.useCurrencySymbol": "Використовувати символ валюти", "preferences.moneyFormatting.useCurrencySymbol.description": "наприклад, \"$5\" замість \"5USD\"", "preferences.numpad": "Цифрова клавіатура", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index dc289d26..bc2076c1 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "尽可能不缩写数字", "preferences.moneyFormatting.setICUPattern": "选择自定义格式", "preferences.moneyFormatting.setICUPattern.default": "默认", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "显示近似金额", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "同时以主要货币显示外币金额", "preferences.moneyFormatting.useCurrencySymbol": "使用货币符号", "preferences.moneyFormatting.useCurrencySymbol.description": "例如:使用“$5”取代“5USD”", "preferences.numpad": "数字键盘", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 6c4d1413..5053c254 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -376,6 +376,8 @@ "preferences.moneyFormatting.preferFull.description": "盡可能不縮寫數字", "preferences.moneyFormatting.setICUPattern": "選擇自訂格式", "preferences.moneyFormatting.setICUPattern.default": "預設", + "preferences.moneyFormatting.showApproximatePrimaryAmount": "顯示約略金額", + "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "同時以主要貨幣顯示外幣金額", "preferences.moneyFormatting.useCurrencySymbol": "使用貨幣符號", "preferences.moneyFormatting.useCurrencySymbol.description": "例如:使用「$5」取代「5USD」", "preferences.numpad": "數字鍵盤", diff --git a/lib/prefs/local_preferences.dart b/lib/prefs/local_preferences.dart index f7a25e50..4fd65e13 100644 --- a/lib/prefs/local_preferences.dart +++ b/lib/prefs/local_preferences.dart @@ -55,6 +55,10 @@ class LocalPreferences { late final BoolSettingsEntry preferFullAmounts; late final BoolSettingsEntry useCurrencySymbol; + /// Whether to show foreign currency amounts converted to the primary + /// currency, e.g., "≈ R$25" + late final BoolSettingsEntry showApproximatePrimaryAmount; + /// Whether the user has opened the Insights index at least once. /// /// Drives the one-time "New" badge on the Insights entry in the Profile tab: @@ -146,6 +150,11 @@ class LocalPreferences { preferences: _prefs, initialValue: true, ); + showApproximatePrimaryAmount = BoolSettingsEntry( + key: "showApproximatePrimaryAmount", + preferences: _prefs, + initialValue: true, + ); openedInsightsIndex = BoolSettingsEntry( key: "openedInsightsIndex", diff --git a/lib/routes/preferences/money_formatting_preferences_page.dart b/lib/routes/preferences/money_formatting_preferences_page.dart index 8c758ba5..169eaebb 100644 --- a/lib/routes/preferences/money_formatting_preferences_page.dart +++ b/lib/routes/preferences/money_formatting_preferences_page.dart @@ -23,6 +23,9 @@ class _MoneyFormattingPreferencesPageState Widget build(BuildContext context) { final bool preferFullAmounts = LocalPreferences().preferFullAmounts.get(); final bool useCurrencySymbol = LocalPreferences().useCurrencySymbol.get(); + final bool showApproximatePrimaryAmount = LocalPreferences() + .showApproximatePrimaryAmount + .get(); return Scaffold( appBar: AppBar(title: Text("preferences.moneyFormatting".t(context))), @@ -65,6 +68,19 @@ class _MoneyFormattingPreferencesPageState value: useCurrencySymbol, onChanged: updateUseCurrencySymbol, ), + CheckboxListTile( + title: Text( + "preferences.moneyFormatting.showApproximatePrimaryAmount".t( + context, + ), + ), + subtitle: Text( + "preferences.moneyFormatting.showApproximatePrimaryAmount.description" + .t(context), + ), + value: showApproximatePrimaryAmount, + onChanged: updateShowApproximatePrimaryAmount, + ), ListTile( title: Text( "preferences.moneyFormatting.setICUPattern".t(context), @@ -95,6 +111,18 @@ class _MoneyFormattingPreferencesPageState if (mounted) setState(() {}); } + void updateShowApproximatePrimaryAmount( + bool? newShowApproximatePrimaryAmount, + ) async { + if (newShowApproximatePrimaryAmount == null) return; + + await LocalPreferences().showApproximatePrimaryAmount.set( + newShowApproximatePrimaryAmount, + ); + + if (mounted) setState(() {}); + } + void updateCustomICUCurrencyFormatter() async { final Optional? result = await showModalBottomSheet( context: context, diff --git a/lib/widgets/general/approximate_money_text.dart b/lib/widgets/general/approximate_money_text.dart index 7b8c7f7a..53d0f0cd 100644 --- a/lib/widgets/general/approximate_money_text.dart +++ b/lib/widgets/general/approximate_money_text.dart @@ -1,4 +1,5 @@ import "package:flow/data/money.dart"; +import "package:flow/prefs/local_preferences.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/utils/extensions/money.dart"; @@ -7,7 +8,8 @@ import "package:flow/widgets/general/money_text_raw.dart"; import "package:flutter/material.dart"; /// Shows [money] converted to the primary currency using the latest rates, -/// e.g., "≈ R$25". Renders nothing when conversion isn't possible or needed. +/// e.g., "≈ R$25". Renders nothing when conversion isn't possible or needed, +/// or when [LocalPreferences.showApproximatePrimaryAmount] is off. class ApproximateMoneyText extends StatelessWidget { final Money money; @@ -39,25 +41,35 @@ class ApproximateMoneyText extends StatelessWidget { if (money.currency == primaryCurrency) return const SizedBox.shrink(); return ValueListenableBuilder( - valueListenable: ExchangeRatesService().exchangeRatesCache, - builder: (context, exchangeRatesCache, child) { - final double? converted = money.tryConvertAmount( - primaryCurrency, - exchangeRatesCache?.get(primaryCurrency), - ); + valueListenable: + LocalPreferences().showApproximatePrimaryAmount.valueNotifier, + builder: (context, showApproximatePrimaryAmount, child) { + if (showApproximatePrimaryAmount == false) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: ExchangeRatesService().exchangeRatesCache, + builder: (context, exchangeRatesCache, child) { + final double? converted = money.tryConvertAmount( + primaryCurrency, + exchangeRatesCache?.get(primaryCurrency), + ); - if (converted == null) return const SizedBox.shrink(); + if (converted == null) return const SizedBox.shrink(); - return MoneyTextBuilder( - money: Money(converted, primaryCurrency), - displayAbsoluteAmount: displayAbsoluteAmount, - overrideObscure: overrideObscure, - builder: (context, text, money) => MoneyTextRaw( - text: "≈ $text", - style: style, - textAlign: textAlign, - maxLines: 1, - ), + return MoneyTextBuilder( + money: Money(converted, primaryCurrency), + displayAbsoluteAmount: displayAbsoluteAmount, + overrideObscure: overrideObscure, + builder: (context, text, money) => MoneyTextRaw( + text: "≈ $text", + style: style, + textAlign: textAlign, + maxLines: 1, + ), + ); + }, ); }, ); From 353a981e4f9c635e7f2358762dbbccbd1046fa79 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 22:43:52 +0800 Subject: [PATCH 13/57] reorganize preferences --- assets/l10n/ar.json | 10 +-- assets/l10n/be_BY.json | 10 +-- assets/l10n/cs_CZ.json | 8 +-- assets/l10n/de_DE.json | 10 +-- assets/l10n/en.json | 10 +-- assets/l10n/es_ES.json | 10 +-- assets/l10n/fa_IR.json | 10 +-- assets/l10n/fr_FR.json | 10 +-- assets/l10n/it_IT.json | 10 +-- assets/l10n/mn_MN.json | 6 +- assets/l10n/pl_PL.json | 10 +-- assets/l10n/ru_RU.json | 10 +-- assets/l10n/tr_TR.json | 10 +-- assets/l10n/uk_UA.json | 10 +-- assets/l10n/zh_CN.json | 6 +- assets/l10n/zh_TW.json | 6 +- lib/routes/preferences_page.dart | 107 +++++++++++++++---------------- 17 files changed, 126 insertions(+), 127 deletions(-) diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e1f16610..0918de6b 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "لا تنسَ إضافة معاملاتك اليوم!", "preferences": "التفضيلات", "preferences.appearance": "المظهر", - "preferences.changeVisuals": "تغيير", + "preferences.changeVisuals": "مؤشرات الاتجاه", "preferences.changeVisuals.arrow": "السهم", "preferences.changeVisuals.clickToChange": "انقر على السهم أو اللون للتغيير", "preferences.changeVisuals.color": "اللون", "preferences.changeVisuals.expenseIncrease": "زيادة النفقات", "preferences.changeVisuals.incomeIncrease": "زيادة الدخل", + "preferences.data": "البيانات", "preferences.dateFormat": "تنسيق التاريخ", "preferences.feedback": "مشاكل وملاحظات", "preferences.feedback.debugLogs": "عرض سجلات التصحيح", - "preferences.hapticFeedback": "ردود فعل الأزرار", + "preferences.general": "عام", "preferences.hapticFeedback.description": "صوت/اهتزاز عند الضغط", - "preferences.integrations": "التكاملات", "preferences.language": "اللغة", "preferences.language.choose": "اختر لغة", "preferences.moneyFormatting": "تنسيق الأموال", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "كلاسيكي", "preferences.numpad.layout.modern": "حديث", "preferences.primaryCurrency": "العملة الأساسية", - "preferences.privacy": "الخصوصية", "preferences.privacy.appLock": "قفل التطبيق", "preferences.privacy.appLock.description#Android": "يتطلب التحقق البيومتري لفتح التطبيق", "preferences.privacy.appLock.description#iOS": "يتطلب Face ID أو Touch ID لفتح التطبيق", "preferences.privacy.appLock.lockAfterClosing": "قفل بعد الإغلاق", "preferences.privacy.maskAtShake": "إخفاء الأرقام (*) عند هزّ الجهاز", "preferences.privacy.maskAtStartup": "إخفاء الأرقام (*) عند بدء التشغيل", + "preferences.privacyAndSecurity": "الخصوصية والأمان", "preferences.reminders": "التذكير", "preferences.reminders.remindDaily": "تذكير يومي", "preferences.reminders.remindDaily.description": "تذكير بتتبع المصروفات يوميًا", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "قد يكون فوضويًا مع الإيصالات الطويلة", "preferences.scan.markPendingThreshold": "وضع المعاملات كمعلقة", "preferences.scan.markPendingThreshold.description": "إذا تم إيقاف هذا الخيار، ستظل المعاملات التي يكون تاريخها المُحلل أقدم من 6 ساعات معلمة على أنها معلقة", - "preferences.sync": "المزامنة", + "preferences.sync": "المزامنة والنسخ الاحتياطي", "preferences.sync.autoBackup": "النسخ الاحتياطي التلقائي", "preferences.sync.autoBackup.disabled": "تعطيل", "preferences.sync.autoBackup.interval": "فاصل النسخ الاحتياطي", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 12330f6f..958d3097 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Не забудзьцеся дадаць свае транзакцыі сёння!", "preferences": "Налады", "preferences.appearance": "Вонкавы выгляд", - "preferences.changeVisuals": "Змяніць", + "preferences.changeVisuals": "Індыкатары тэндэнцыі", "preferences.changeVisuals.arrow": "Стрэлка", "preferences.changeVisuals.clickToChange": "Націсніце на стрэлку/колер, каб змяніць", "preferences.changeVisuals.color": "Колер", "preferences.changeVisuals.expenseIncrease": "Рост выбаткаў", "preferences.changeVisuals.incomeIncrease": "Рост даходаў", + "preferences.data": "Даныя", "preferences.dateFormat": "Фармат даты", "preferences.feedback": "Праблемы і зваротная сувязь", "preferences.feedback.debugLogs": "Прагляд журналаў адладкі", - "preferences.hapticFeedback": "Водгук кнопак", + "preferences.general": "Агульныя", "preferences.hapticFeedback.description": "Гукавы/тактыльны водгук пры націсканні", - "preferences.integrations": "Інтэграцыі", "preferences.language": "Мова", "preferences.language.choose": "Выберыце мову", "preferences.moneyFormatting": "Фарматаванне грошай", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Класічная", "preferences.numpad.layout.modern": "Сучасная", "preferences.primaryCurrency": "Асноўная валюта", - "preferences.privacy": "Канфідэнцыяльнасць", "preferences.privacy.appLock": "Блакіроўка праграмы", "preferences.privacy.appLock.description#Android": "Патрабаваць біяметрычную аўтэнтыфікацыю для адкрыцця праграмы", "preferences.privacy.appLock.description#iOS": "Патрабаваць Face ID або Touch ID для адкрыцця праграмы", "preferences.privacy.appLock.lockAfterClosing": "Блакіраваць пасля закрыцця", "preferences.privacy.maskAtShake": "Хаваць лічбы (*) пры ўстрэсванні прылады", "preferences.privacy.maskAtStartup": "Хаваць лічбы (*) пры запуску", + "preferences.privacyAndSecurity": "Канфідэнцыяльнасць і бяспека", "preferences.reminders": "Напамінкі", "preferences.reminders.remindDaily": "Нагадваць штодзень", "preferences.reminders.remindDaily.description": "Нагадваць аб штодзённым адсочванні выдаткаў", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Можа быць хаатычна для доўгіх чэкаў", "preferences.scan.markPendingThreshold": "Адзначаць транзакцыі як чакаючыя", "preferences.scan.markPendingThreshold.description": "Калі выключана, транзакцыі з апрацаванай датай, старэйшай за 6 гадзін, усё роўна будуць пазначацца як чакаючыя", - "preferences.sync": "Сінхранізацыя", + "preferences.sync": "Сінхранізацыя і рэзервовае капіраванне", "preferences.sync.autoBackup": "Аўта-рэзерваванне", "preferences.sync.autoBackup.disabled": "Адключыць", "preferences.sync.autoBackup.interval": "Інтэрвал рэзервавання", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 25fe07cf..307bcd85 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Nezapomeňte dnes přidat své transakce!", "preferences": "Předvolby", "preferences.appearance": "Vzhled", - "preferences.changeVisuals": "Změnit vzhled", + "preferences.changeVisuals": "Indikátory trendu", "preferences.changeVisuals.arrow": "Šipka", "preferences.changeVisuals.clickToChange": "Klikněte na šipku nebo barvu pro změnu", "preferences.changeVisuals.color": "Barva", "preferences.changeVisuals.expenseIncrease": "Nárůst výdajů", "preferences.changeVisuals.incomeIncrease": "Nárůst příjmů", + "preferences.data": "Data", "preferences.dateFormat": "Formát data", "preferences.feedback": "Problémy a zpětná vazba", "preferences.feedback.debugLogs": "Zobrazit protokoly ladění", - "preferences.hapticFeedback": "Haptická odezva", + "preferences.general": "Obecné", "preferences.hapticFeedback.description": "Vibrace/zvuk při interakci s uživatelským rozhraním.", - "preferences.integrations": "Integrace", "preferences.language": "Jazyk", "preferences.language.choose": "Vyberte jazyk", "preferences.moneyFormatting": "Formátování měny", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Klasické", "preferences.numpad.layout.modern": "Moderní", "preferences.primaryCurrency": "Primární měna", - "preferences.privacy": "Soukromí", "preferences.privacy.appLock": "Zámek aplikace", "preferences.privacy.appLock.description#Android": "Vyžadovat biometrické ověření pro otevření aplikace.", "preferences.privacy.appLock.description#iOS": "Vyžadovat Face ID nebo Touch ID pro otevření aplikace.", "preferences.privacy.appLock.lockAfterClosing": "Zamknout po zavření", "preferences.privacy.maskAtShake": "Při zatřesení zařízením maskovat čísla (*)", "preferences.privacy.maskAtStartup": "Maskovat částky (*) při spuštění", + "preferences.privacyAndSecurity": "Soukromí a zabezpečení", "preferences.reminders": "Připomínky", "preferences.reminders.remindDaily": "Připomínat denně", "preferences.reminders.remindDaily.description": "Dostávat denní připomínky k zaznamenání výdajů.", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 8bd1995d..7b226918 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Vergiss nicht, heute deine Buchungen hinzuzufügen!", "preferences": "Einstellungen", "preferences.appearance": "Aussehen", - "preferences.changeVisuals": "Ändern", + "preferences.changeVisuals": "Trendanzeigen", "preferences.changeVisuals.arrow": "Pfeil", "preferences.changeVisuals.clickToChange": "Klicken Sie auf den Pfeil oder die Farbe, um die Darstellung zu ändern.", "preferences.changeVisuals.color": "Farbe", "preferences.changeVisuals.expenseIncrease": "Ausgabenanstieg", "preferences.changeVisuals.incomeIncrease": "Einnahmenanstieg", + "preferences.data": "Daten", "preferences.dateFormat": "Datumsformat", "preferences.feedback": "Probleme und Feedback", "preferences.feedback.debugLogs": "Debug-Protokolle anzeigen", - "preferences.hapticFeedback": "Feedback beim Tippen", + "preferences.general": "Allgemein", "preferences.hapticFeedback.description": "Ton/Vibrations-Feedback beim Klicken", - "preferences.integrations": "Integrationen", "preferences.language": "Sprache", "preferences.language.choose": "Sprache auswählen", "preferences.moneyFormatting": "Geldbeträge formatieren", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Klassisch", "preferences.numpad.layout.modern": "Modern", "preferences.primaryCurrency": "Hauptwährung", - "preferences.privacy": "Datenschutz", "preferences.privacy.appLock": "App sperren", "preferences.privacy.appLock.description#Android": "Die App benötigt Fingerabdruck oder Gesichtserkennung zum Öffnen.", "preferences.privacy.appLock.description#iOS": "Die App benötigt nach Face ID oder Touch ID zum Öffnen.", "preferences.privacy.appLock.lockAfterClosing": "Nach dem Schließen sperren", "preferences.privacy.maskAtShake": "Zahlen beim Schütteln des Geräts mit (*) maskieren", "preferences.privacy.maskAtStartup": "Zahlen (*) beim Start ausblenden", + "preferences.privacyAndSecurity": "Datenschutz & Sicherheit", "preferences.reminders": "Erinnerung", "preferences.reminders.remindDaily": "Täglich erinnern", "preferences.reminders.remindDaily.description": "Täglich daran erinnern, Ausgaben zu erfassen.", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Bei langen Belegen möglicherweise unübersichtlich", "preferences.scan.markPendingThreshold": "Transaktionen als ausstehend markieren", "preferences.scan.markPendingThreshold.description": "Wenn deaktiviert, werden Transaktionen, deren ermitteltes Datum älter als 6 Stunden ist, weiterhin als ausstehend markiert", - "preferences.sync": "Synchronisierung", + "preferences.sync": "Synchronisierung & Backup", "preferences.sync.autoBackup": "Automatische Sicherung", "preferences.sync.autoBackup.disabled": "Deaktivieren", "preferences.sync.autoBackup.interval": "Intervall für Sicherung", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 7c8ff122..cefa7ebe 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Don't forget to add your transactions today!", "preferences": "Preferences", "preferences.appearance": "Appearance", - "preferences.changeVisuals": "Change", + "preferences.changeVisuals": "Trend indicators", "preferences.changeVisuals.arrow": "Arrow", "preferences.changeVisuals.clickToChange": "Click on the arrow/color to change", "preferences.changeVisuals.color": "Color", "preferences.changeVisuals.expenseIncrease": "Expense growth", "preferences.changeVisuals.incomeIncrease": "Income growth", + "preferences.data": "Data", "preferences.dateFormat": "Date format", "preferences.feedback": "Problems and feedback", "preferences.feedback.debugLogs": "View debug logs", - "preferences.hapticFeedback": "Button feedback", + "preferences.general": "General", "preferences.hapticFeedback.description": "Sound/haptic feedback upon click", - "preferences.integrations": "Integrations", "preferences.language": "Language", "preferences.language.choose": "Select a language", "preferences.moneyFormatting": "Money formatting", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Classic", "preferences.numpad.layout.modern": "Modern", "preferences.primaryCurrency": "Primary currency", - "preferences.privacy": "Privacy", "preferences.privacy.appLock": "Lock app", "preferences.privacy.appLock.description#Android": "Require biometric authentication to open the app", "preferences.privacy.appLock.description#iOS": "Require Face ID or Touch ID to open the app", "preferences.privacy.appLock.lockAfterClosing": "Lock after closing", "preferences.privacy.maskAtShake": "Mask numbers (*) when shaking the device", "preferences.privacy.maskAtStartup": "Mask numbers (*) at startup", + "preferences.privacyAndSecurity": "Privacy & security", "preferences.reminders": "Reminder", "preferences.reminders.remindDaily": "Remind daily", "preferences.reminders.remindDaily.description": "Remind to track expenses daily", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Maybe chaotic for long receipts", "preferences.scan.markPendingThreshold": "Mark transactions as pending", "preferences.scan.markPendingThreshold.description": "If off, transactions with parsed date older than 6 hours will still be marked as pending", - "preferences.sync": "Sync", + "preferences.sync": "Sync & backup", "preferences.sync.autoBackup": "Auto-backup", "preferences.sync.autoBackup.disabled": "Disable", "preferences.sync.autoBackup.interval": "Backup interval", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 8953bfd6..b332e242 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "¡No olvides añadir tus transacciones de hoy!", "preferences": "Preferencias", "preferences.appearance": "Apariencia", - "preferences.changeVisuals": "Cambiar", + "preferences.changeVisuals": "Indicadores de tendencia", "preferences.changeVisuals.arrow": "Flecha", "preferences.changeVisuals.clickToChange": "Haz clic en la flecha o en el color para cambiarlo", "preferences.changeVisuals.color": "Color", "preferences.changeVisuals.expenseIncrease": "Crecimiento de los gastos", "preferences.changeVisuals.incomeIncrease": "Crecimiento de los ingresos", + "preferences.data": "Datos", "preferences.dateFormat": "Formato de fecha", "preferences.feedback": "Problemas y comentarios", "preferences.feedback.debugLogs": "Ver registros de depuración", - "preferences.hapticFeedback": "Respuesta táctil de botones", + "preferences.general": "General", "preferences.hapticFeedback.description": "Respuesta sonora/táctil al hacer clic", - "preferences.integrations": "Integraciones", "preferences.language": "Idioma", "preferences.language.choose": "Seleccionar un idioma", "preferences.moneyFormatting": "Formato de moneda", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Clásico", "preferences.numpad.layout.modern": "Moderno", "preferences.primaryCurrency": "Moneda principal", - "preferences.privacy": "Privacidad", "preferences.privacy.appLock": "Bloquear aplicación", "preferences.privacy.appLock.description#Android": "Requerir autenticación biométrica para abrir la aplicación", "preferences.privacy.appLock.description#iOS": "Requerir Face ID o Touch ID para abrir la aplicación", "preferences.privacy.appLock.lockAfterClosing": "Bloquear tras cerrar", "preferences.privacy.maskAtShake": "Enmascarar números (*) al agitar el dispositivo", "preferences.privacy.maskAtStartup": "Ocultar números (*) al inicio", + "preferences.privacyAndSecurity": "Privacidad y seguridad", "preferences.reminders": "Recordatorio", "preferences.reminders.remindDaily": "Recordar diariamente", "preferences.reminders.remindDaily.description": "Recordar registrar gastos diariamente", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Puede resultar caótico con recibos largos", "preferences.scan.markPendingThreshold": "Marcar transacciones como pendientes", "preferences.scan.markPendingThreshold.description": "Si está desactivado, las transacciones cuya fecha analizada tenga más de 6 horas seguirán marcándose como pendientes.", - "preferences.sync": "Sincronización", + "preferences.sync": "Sincronización y copia de seguridad", "preferences.sync.autoBackup": "Copia de seguridad automática", "preferences.sync.autoBackup.disabled": "Desactivar", "preferences.sync.autoBackup.interval": "Intervalo de copia de seguridad", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 2204f9b6..a0333e33 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "فراموش نکن امروز تراکنش‌هایت را اضافه کنی!", "preferences": "تنظیمات", "preferences.appearance": "ظاهر", - "preferences.changeVisuals": "تغییر", + "preferences.changeVisuals": "نشانگرهای روند", "preferences.changeVisuals.arrow": "فلش", "preferences.changeVisuals.clickToChange": "برای تغییر، روی فلش/رنگ کلیک کنید", "preferences.changeVisuals.color": "رنگ", "preferences.changeVisuals.expenseIncrease": "افزایش هزینه", "preferences.changeVisuals.incomeIncrease": "افزایش درآمد", + "preferences.data": "داده‌ها", "preferences.dateFormat": "فرمت تاریخ", "preferences.feedback": "مشکلات و بازخورد", "preferences.feedback.debugLogs": "مشاهده لاگ‌های دیباگ", - "preferences.hapticFeedback": "بازخورد دکمه", + "preferences.general": "عمومی", "preferences.hapticFeedback.description": "بازخورد صوتی/هپتیک هنگام کلیک", - "preferences.integrations": "یکپارچه‌سازی‌ها", "preferences.language": "زبان", "preferences.language.choose": "یک زبان انتخاب کنید", "preferences.moneyFormatting": "فرمت نمایش پول", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "کلاسیک", "preferences.numpad.layout.modern": "مدرن", "preferences.primaryCurrency": "ارز اصلی", - "preferences.privacy": "حریم خصوصی", "preferences.privacy.appLock": "قفل برنامه", "preferences.privacy.appLock.description#Android": "برای باز کردن برنامه، احراز هویت بیومتریک لازم باشد", "preferences.privacy.appLock.description#iOS": "برای باز کردن برنامه، Face ID یا Touch ID لازم باشد", "preferences.privacy.appLock.lockAfterClosing": "قفل پس از بستن", "preferences.privacy.maskAtShake": "مخفی‌سازی اعداد (*) با تکان دادن دستگاه", "preferences.privacy.maskAtStartup": "مخفی‌سازی اعداد (*) هنگام شروع", + "preferences.privacyAndSecurity": "حریم خصوصی و امنیت", "preferences.reminders": "یادآوری", "preferences.reminders.remindDaily": "یادآوری روزانه", "preferences.reminders.remindDaily.description": "روزانه یادآوری کند هزینه‌ها را ثبت کنید", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "برای رسیدهای طولانی ممکن است شلوغ شود", "preferences.scan.markPendingThreshold": "علامت‌گذاری تراکنش‌ها به‌صورت در انتظار", "preferences.scan.markPendingThreshold.description": "اگر خاموش باشد، تراکنش‌هایی که تاریخِ استخراج‌شده‌شان بیش از ۶ ساعت قبل باشد هم در حالت در انتظار می‌مانند", - "preferences.sync": "همگام‌سازی", + "preferences.sync": "همگام‌سازی و پشتیبان‌گیری", "preferences.sync.autoBackup": "پشتیبان‌گیری خودکار", "preferences.sync.autoBackup.disabled": "غیرفعال", "preferences.sync.autoBackup.interval": "فاصله پشتیبان‌گیری", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 4a941c23..20525c55 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "N'oubliez pas d'ajouter vos transactions aujourd'hui !", "preferences": "Préférences", "preferences.appearance": "Apparence", - "preferences.changeVisuals": "Modifier", + "preferences.changeVisuals": "Indicateurs de tendance", "preferences.changeVisuals.arrow": "Flèche", "preferences.changeVisuals.clickToChange": "Cliquez sur la flèche/la couleur pour modifier", "preferences.changeVisuals.color": "Couleur", "preferences.changeVisuals.expenseIncrease": "Augmentation des dépenses", "preferences.changeVisuals.incomeIncrease": "Augmentation des revenus", + "preferences.data": "Données", "preferences.dateFormat": "Format de date", "preferences.feedback": "Problèmes et retours", "preferences.feedback.debugLogs": "Voir les journaux de débogage", - "preferences.hapticFeedback": "Retour tactile", + "preferences.general": "Général", "preferences.hapticFeedback.description": "Retour sonore/tactile lors du clic", - "preferences.integrations": "Intégrations", "preferences.language": "Langue", "preferences.language.choose": "Sélectionner une langue", "preferences.moneyFormatting": "Formatage de l'argent", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Classique", "preferences.numpad.layout.modern": "Moderne", "preferences.primaryCurrency": "Devise principale", - "preferences.privacy": "Confidentialité", "preferences.privacy.appLock": "Verrouiller l'application", "preferences.privacy.appLock.description#Android": "Exiger l'authentification biométrique pour ouvrir l'application", "preferences.privacy.appLock.description#iOS": "Exiger Face ID ou Touch ID pour ouvrir l'application", "preferences.privacy.appLock.lockAfterClosing": "Verrouiller après la fermeture", "preferences.privacy.maskAtShake": "Masquer les numéros (*) en secouant l'appareil", "preferences.privacy.maskAtStartup": "Masquer les nombres (*) au démarrage", + "preferences.privacyAndSecurity": "Confidentialité et sécurité", "preferences.reminders": "Rappel quotidien", "preferences.reminders.remindDaily": "Rappel quotidien", "preferences.reminders.remindDaily.description": "Rappeler de suivre les dépenses quotidiennement", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Peut devenir chaotique pour les tickets de caisse longs", "preferences.scan.markPendingThreshold": "Marquer les transactions comme en attente", "preferences.scan.markPendingThreshold.description": "Si désactivée, les transactions dont la date analysée est antérieure à 6 heures resteront marquées comme « en attente ».", - "preferences.sync": "Synchronisation", + "preferences.sync": "Synchronisation et sauvegarde", "preferences.sync.autoBackup": "Sauvegarde automatique", "preferences.sync.autoBackup.disabled": "Désactiver", "preferences.sync.autoBackup.interval": "Intervalle de sauvegarde", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 1c210f49..1106fe86 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Non dimenticare di aggiungere le tue transazioni oggi!", "preferences": "Preferenze", "preferences.appearance": "Aspetto", - "preferences.changeVisuals": "Modifica", + "preferences.changeVisuals": "Indicatori di tendenza", "preferences.changeVisuals.arrow": "Freccia", "preferences.changeVisuals.clickToChange": "Fai clic sulla freccia o sul colore per modificare", "preferences.changeVisuals.color": "Colore", "preferences.changeVisuals.expenseIncrease": "Crescita delle spese", "preferences.changeVisuals.incomeIncrease": "Crescita delle entrate", + "preferences.data": "Dati", "preferences.dateFormat": "Formato data", "preferences.feedback": "Problemi e feedback", "preferences.feedback.debugLogs": "Visualizza i log di debug", - "preferences.hapticFeedback": "Feedback dei tasti", + "preferences.general": "Generali", "preferences.hapticFeedback.description": "Feedback sonoro/tattile alla pressione", - "preferences.integrations": "Integrazioni", "preferences.language": "Lingua", "preferences.language.choose": "Selezionare una lingua", "preferences.moneyFormatting": "Formato valuta", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Classico", "preferences.numpad.layout.modern": "Moderno", "preferences.primaryCurrency": "Valuta principale", - "preferences.privacy": "Privacy", "preferences.privacy.appLock": "Blocca app", "preferences.privacy.appLock.description#Android": "Richiedi autenticazione biometrica per aprire l'app", "preferences.privacy.appLock.description#iOS": "Richiedi Face ID o Touch ID per aprire l'app", "preferences.privacy.appLock.lockAfterClosing": "Blocca dopo la chiusura", "preferences.privacy.maskAtShake": "Maschera i numeri (*) quando si scuote il dispositivo", "preferences.privacy.maskAtStartup": "Nascondi i numeri (*) all'avvio", + "preferences.privacyAndSecurity": "Privacy e sicurezza", "preferences.reminders": "Promemoria giornaliero", "preferences.reminders.remindDaily": "Ricorda quotidianamente", "preferences.reminders.remindDaily.description": "Ricorda di tenere traccia delle spese quotidianamente", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Potrebbe risultare caotico per scontrini lunghi", "preferences.scan.markPendingThreshold": "Contrassegna le transazioni come in sospeso", "preferences.scan.markPendingThreshold.description": "Se disattivata, le transazioni la cui data interpretata ha più di 6 ore verranno comunque contrassegnate come in sospeso", - "preferences.sync": "Sincronizzazione", + "preferences.sync": "Sincronizzazione e backup", "preferences.sync.autoBackup": "Backup automatico", "preferences.sync.autoBackup.disabled": "Disabilita", "preferences.sync.autoBackup.interval": "Intervallo di backup", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 585648f8..0a412c54 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -363,12 +363,12 @@ "preferences.changeVisuals.color": "Өнгө", "preferences.changeVisuals.expenseIncrease": "Зарлага өсөх", "preferences.changeVisuals.incomeIncrease": "Орлого өсөх", + "preferences.data": "Өгөгдөл", "preferences.dateFormat": "Огнооны хэв", "preferences.feedback": "Асуудал ", "preferences.feedback.debugLogs": "Дибаг лог харах", - "preferences.hapticFeedback": "Товчны хариу", + "preferences.general": "Ерөнхий", "preferences.hapticFeedback.description": "Дарах үед дуу/чичиргээ тоглуулна", - "preferences.integrations": "Холболтууд", "preferences.language": "Хэл", "preferences.language.choose": "Хэл сонгох", "preferences.moneyFormatting": "Мөнгөний хэлбэр", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Хуучны", "preferences.numpad.layout.modern": "Орчин үеийн", "preferences.primaryCurrency": "Үндсэн валют", - "preferences.privacy": "Нууцлал", "preferences.privacy.appLock": "Апп түгжих", "preferences.privacy.appLock.description#Android": "Аппыг нээхийн тулд биометрик түгжээ ашиглаж өөрийгөө баталгаажуулах", "preferences.privacy.appLock.description#iOS": "Аппыг нээхийн тулд Face ID эсвэл Touch ID шаардах", "preferences.privacy.appLock.lockAfterClosing": "Хаах үед түгжих", "preferences.privacy.maskAtShake": "Төхөөрөмж сэгсрэх үед дүнг нууцлах (*)", "preferences.privacy.maskAtStartup": "Апп нээгдэхэд дүнг нууцлах (*)", + "preferences.privacyAndSecurity": "Нууцлал ба аюулгүй байдал", "preferences.reminders": "Сануулга", "preferences.reminders.remindDaily": "Өдөр бүр сануулах", "preferences.reminders.remindDaily.description": "Гүйлгээгээ бүртгэхийг сануулах", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index dd6483c8..a9a3f1c2 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Nie zapomnij dzisiaj dodać transakcji!", "preferences": "Preferencje", "preferences.appearance": "Wygląd", - "preferences.changeVisuals": "Zmień", + "preferences.changeVisuals": "Wskaźniki trendu", "preferences.changeVisuals.arrow": "Strzałka", "preferences.changeVisuals.clickToChange": "Kliknij strzałkę/kolor, aby zmienić", "preferences.changeVisuals.color": "Kolor", "preferences.changeVisuals.expenseIncrease": "Wzrost wydatków", "preferences.changeVisuals.incomeIncrease": "Wzrost przychodów", + "preferences.data": "Dane", "preferences.dateFormat": "Format daty", "preferences.feedback": "Problemy i opinie", "preferences.feedback.debugLogs": "Zobacz logi debugowania", - "preferences.hapticFeedback": "Wibracje przycisków", + "preferences.general": "Ogólne", "preferences.hapticFeedback.description": "Dźwięk/wibracja przy kliknięciu", - "preferences.integrations": "Integracje", "preferences.language": "Język", "preferences.language.choose": "Wybierz język", "preferences.moneyFormatting": "Formatowanie waluty", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Klasyczny", "preferences.numpad.layout.modern": "Nowoczesny", "preferences.primaryCurrency": "Waluta główna", - "preferences.privacy": "Prywatność", "preferences.privacy.appLock": "Zablokuj aplikację", "preferences.privacy.appLock.description#Android": "Wymagaj uwierzytelniania biometrycznego, aby otworzyć aplikację", "preferences.privacy.appLock.description#iOS": "Wymagaj Face ID lub Touch ID, aby otworzyć aplikację", "preferences.privacy.appLock.lockAfterClosing": "Zablokuj po zamknięciu", "preferences.privacy.maskAtShake": "Ukryj kwoty (*) po potrząśnięciu", "preferences.privacy.maskAtStartup": "Ukryj kwoty (*) przy uruchomieniu", + "preferences.privacyAndSecurity": "Prywatność i bezpieczeństwo", "preferences.reminders": "Przypomnienia", "preferences.reminders.remindDaily": "Codzienne przypomnienie", "preferences.reminders.remindDaily.description": "Przypominaj o codziennym zapisywaniu wydatków", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Może być uciążliwe w przypadku długich paragonów", "preferences.scan.markPendingThreshold": "Oznacz jako oczekujące", "preferences.scan.markPendingThreshold.description": "Jeśli wyłączone, transakcje z datą starszą niż 6 godzin nadal będą oznaczane jako oczekujące", - "preferences.sync": "Synchronizacja", + "preferences.sync": "Synchronizacja i kopia zapasowa", "preferences.sync.autoBackup": "Auto kopia zapasowa", "preferences.sync.autoBackup.disabled": "Wyłączone", "preferences.sync.autoBackup.interval": "Interwał kopii zapasowej", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 9f49d8d9..eac427ee 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Не забудьте добавить свои транзакции сегодня!", "preferences": "Настройки", "preferences.appearance": "Внешний вид", - "preferences.changeVisuals": "Изменить", + "preferences.changeVisuals": "Индикаторы тренда", "preferences.changeVisuals.arrow": "Стрелка", "preferences.changeVisuals.clickToChange": "Нажмите на стрелку/цвет, чтобы изменить", "preferences.changeVisuals.color": "Цвет", "preferences.changeVisuals.expenseIncrease": "Рост расходов", "preferences.changeVisuals.incomeIncrease": "Рост дохода", + "preferences.data": "Данные", "preferences.dateFormat": "Формат даты", "preferences.feedback": "Проблемы и отзывы", "preferences.feedback.debugLogs": "Просмотреть отладочные логи", - "preferences.hapticFeedback": "Отклик кнопок", + "preferences.general": "Общие", "preferences.hapticFeedback.description": "Звуковой/тактильный отклик при нажатии", - "preferences.integrations": "Интеграции", "preferences.language": "Язык", "preferences.language.choose": "Выберите язык", "preferences.moneyFormatting": "Форматирование денег", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Классическая", "preferences.numpad.layout.modern": "Современная", "preferences.primaryCurrency": "Основная валюта", - "preferences.privacy": "Конфиденциальность", "preferences.privacy.appLock": "Заблокировать приложение", "preferences.privacy.appLock.description#Android": "Требовать биометрическую аутентификацию для открытия приложения", "preferences.privacy.appLock.description#iOS": "Требовать Face ID или Touch ID для открытия приложения", "preferences.privacy.appLock.lockAfterClosing": "Блокировать после закрытия", "preferences.privacy.maskAtShake": "Маскировать цифры (*) при встряхивании устройства", "preferences.privacy.maskAtStartup": "Маскировать числа (*) при запуске", + "preferences.privacyAndSecurity": "Конфиденциальность и безопасность", "preferences.reminders": "Напоминание", "preferences.reminders.remindDaily": "Напоминать ежедневно", "preferences.reminders.remindDaily.description": "Напоминать ежедневно отслеживать расходы", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Может быть хаотично при длинных чеках", "preferences.scan.markPendingThreshold": "Помечать транзакции как ожидающие", "preferences.scan.markPendingThreshold.description": "Если опция отключена, транзакции с распознанной датой старше 6 часов всё равно будут помечены как ожидающие", - "preferences.sync": "Синхронизация", + "preferences.sync": "Синхронизация и резервное копирование", "preferences.sync.autoBackup": "Авто-резервное копирование", "preferences.sync.autoBackup.disabled": "Отключить", "preferences.sync.autoBackup.interval": "Интервал резервного копирования", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index e7949759..fe88beee 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Bugün işlemlerinizi eklemeyi unutmayın!", "preferences": "Tercihler", "preferences.appearance": "Görünüm", - "preferences.changeVisuals": "Değiştir", + "preferences.changeVisuals": "Trend göstergeleri", "preferences.changeVisuals.arrow": "Ok", "preferences.changeVisuals.clickToChange": "Değiştirmek için oka/renge tıklayın", "preferences.changeVisuals.color": "Renk", "preferences.changeVisuals.expenseIncrease": "Gider artışı", "preferences.changeVisuals.incomeIncrease": "Gelir artışı", + "preferences.data": "Veriler", "preferences.dateFormat": "Tarih formatı", "preferences.feedback": "Sorunlar ve geri bildirim", "preferences.feedback.debugLogs": "Hata ayıklama günlüklerini görüntüle", - "preferences.hapticFeedback": "Düğme geri bildirimi", + "preferences.general": "Genel", "preferences.hapticFeedback.description": "Tıklama üzerine sesli/dokunsal geri bildirim", - "preferences.integrations": "Entegrasyonlar", "preferences.language": "Dil", "preferences.language.choose": "Bir dil seçin", "preferences.moneyFormatting": "Para biçimlendirme", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Klasik", "preferences.numpad.layout.modern": "Modern", "preferences.primaryCurrency": "Birincil para birimi", - "preferences.privacy": "Gizlilik", "preferences.privacy.appLock": "Uygulamayı kilitle", "preferences.privacy.appLock.description#Android": "Uygulamayı açmak için biyometrik kimlik doğrulama gerektirir", "preferences.privacy.appLock.description#iOS": "Uygulamayı açmak için Face ID veya Touch ID gerektirir", "preferences.privacy.appLock.lockAfterClosing": "Kapattıktan sonra kilitle", "preferences.privacy.maskAtShake": "Cihaz sallanınca numaraları (*) ile gizle", "preferences.privacy.maskAtStartup": "Başlangıçta sayıları maskele (*)", + "preferences.privacyAndSecurity": "Gizlilik ve güvenlik", "preferences.reminders": "Hatırlatıcı", "preferences.reminders.remindDaily": "Remind daily", "preferences.reminders.remindDaily.description": "Remind to track expenses daily", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Uzun fişlerde karışıklık yaratabilir", "preferences.scan.markPendingThreshold": "İşlemleri beklemede olarak işaretle", "preferences.scan.markPendingThreshold.description": "Kapalıysa, tarih bilgisi 6 saatten eski olan işlemler yine de beklemede olarak işaretlenir.", - "preferences.sync": "Senkranizasyon", + "preferences.sync": "Senkronizasyon ve yedekleme", "preferences.sync.autoBackup": "Otomatik yedekleme", "preferences.sync.autoBackup.disabled": "Devre dışı bırak", "preferences.sync.autoBackup.interval": "Yedekleme aralığı", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index f101ac1c..be157de6 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -357,18 +357,18 @@ "notifications.reminderText#7": "Не забудьте додати свої транзакції сьогодні!", "preferences": "Налаштування", "preferences.appearance": "Зовнішній вигляд", - "preferences.changeVisuals": "Змінити", + "preferences.changeVisuals": "Індикатори тренду", "preferences.changeVisuals.arrow": "Стрілка", "preferences.changeVisuals.clickToChange": "Натисніть на стрілку/колір, щоб змінити", "preferences.changeVisuals.color": "Колір", "preferences.changeVisuals.expenseIncrease": "Зростання витрат", "preferences.changeVisuals.incomeIncrease": "Зростання доходу", + "preferences.data": "Дані", "preferences.dateFormat": "Формат дати", "preferences.feedback": "Проблеми та відгуки", "preferences.feedback.debugLogs": "Переглянути налагоджувальні журнали", - "preferences.hapticFeedback": "Тактильний відгук", + "preferences.general": "Загальні", "preferences.hapticFeedback.description": "Звуковий/тактильний відгук при натисканні", - "preferences.integrations": "Інтеграції", "preferences.language": "Мова", "preferences.language.choose": "Виберіть мову", "preferences.moneyFormatting": "Форматування грошей", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "Класична", "preferences.numpad.layout.modern": "Сучасна", "preferences.primaryCurrency": "Основна валюта", - "preferences.privacy": "Конфіденційність", "preferences.privacy.appLock": "Заблокувати програму", "preferences.privacy.appLock.description#Android": "Вимагати біометричну автентифікацію для відкриття програми", "preferences.privacy.appLock.description#iOS": "Вимагати Face ID або Touch ID для відкриття програми", "preferences.privacy.appLock.lockAfterClosing": "Блокувати після закриття", "preferences.privacy.maskAtShake": "Маскувати числа (*) при струшуванні пристрою", "preferences.privacy.maskAtStartup": "Маскувати числа (*) при запуску", + "preferences.privacyAndSecurity": "Конфіденційність і безпека", "preferences.reminders": "Нагадування", "preferences.reminders.remindDaily": "Нагадувати щодня", "preferences.reminders.remindDaily.description": "Нагадувати щодня відстежувати витрати", @@ -401,7 +401,7 @@ "preferences.scan.createTransactionsPerItemInScans.description": "Може бути хаотично для довгих чеків", "preferences.scan.markPendingThreshold": "Позначати транзакції як очікувані", "preferences.scan.markPendingThreshold.description": "Якщо вимкнено, транзакції з датою, розпізнаною більше ніж 6 годин тому, все одно будуть позначені як очікувані", - "preferences.sync": "Синхронізація", + "preferences.sync": "Синхронізація та резервне копіювання", "preferences.sync.autoBackup": "Автоматичне резервне копіювання", "preferences.sync.autoBackup.disabled": "Вимкнути", "preferences.sync.autoBackup.interval": "Інтервал резервного копіювання", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index dc289d26..d5225e1c 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -363,12 +363,12 @@ "preferences.changeVisuals.color": "颜色", "preferences.changeVisuals.expenseIncrease": "支出增加", "preferences.changeVisuals.incomeIncrease": "收入增加", + "preferences.data": "数据", "preferences.dateFormat": "日期格式", "preferences.feedback": "问题与反馈", "preferences.feedback.debugLogs": "查看调试日志", - "preferences.hapticFeedback": "按钮反馈", + "preferences.general": "常规", "preferences.hapticFeedback.description": "点击时的声音/触觉震动反馈", - "preferences.integrations": "集成服务", "preferences.language": "语言", "preferences.language.choose": "选择语言", "preferences.moneyFormatting": "金额格式", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "经典", "preferences.numpad.layout.modern": "现代", "preferences.primaryCurrency": "主要币种", - "preferences.privacy": "隐私", "preferences.privacy.appLock": "锁定应用程序", "preferences.privacy.appLock.description#Android": "开启应用程序需要生物识别验证", "preferences.privacy.appLock.description#iOS": "开启应用程序需要 Face ID 或 Touch ID", "preferences.privacy.appLock.lockAfterClosing": "关闭后锁定", "preferences.privacy.maskAtShake": "摇晃设备时遮蔽数字 (*)", "preferences.privacy.maskAtStartup": "启动时遮蔽数字 (*)", + "preferences.privacyAndSecurity": "隐私与安全", "preferences.reminders": "提醒事项", "preferences.reminders.remindDaily": "每日提醒", "preferences.reminders.remindDaily.description": "提醒每天记录支出", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 6c4d1413..618afbbf 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -363,12 +363,12 @@ "preferences.changeVisuals.color": "顏色", "preferences.changeVisuals.expenseIncrease": "支出增加", "preferences.changeVisuals.incomeIncrease": "收入增加", + "preferences.data": "資料", "preferences.dateFormat": "日期格式", "preferences.feedback": "問題與回饋", "preferences.feedback.debugLogs": "檢視偵錯日誌", - "preferences.hapticFeedback": "按鈕回饋", + "preferences.general": "一般", "preferences.hapticFeedback.description": "點擊時的聲音/觸覺震動回饋", - "preferences.integrations": "整合服務", "preferences.language": "語言", "preferences.language.choose": "選擇語言", "preferences.moneyFormatting": "金額格式", @@ -383,13 +383,13 @@ "preferences.numpad.layout.classic": "經典", "preferences.numpad.layout.modern": "現代", "preferences.primaryCurrency": "主要幣別", - "preferences.privacy": "隱私", "preferences.privacy.appLock": "鎖定應用程式", "preferences.privacy.appLock.description#Android": "開啟應用程式需要生物辨識驗證", "preferences.privacy.appLock.description#iOS": "開啟應用程式需要 Face ID 或 Touch ID", "preferences.privacy.appLock.lockAfterClosing": "關閉後鎖定", "preferences.privacy.maskAtShake": "搖晃裝置時遮蔽數字 (*)", "preferences.privacy.maskAtStartup": "啟動時遮蔽數字 (*)", + "preferences.privacyAndSecurity": "隱私與安全", "preferences.reminders": "提醒事項", "preferences.reminders.remindDaily": "每日提醒", "preferences.reminders.remindDaily.description": "提醒每天記錄支出", diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart index 35436126..8392812b 100644 --- a/lib/routes/preferences_page.dart +++ b/lib/routes/preferences_page.dart @@ -84,19 +84,8 @@ class PreferencesPageState extends State { body: SafeArea( child: ListView( children: [ - ListTile( - title: Text("preferences.sync".t(context)), - leading: const Icon(Symbols.sync_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/sync"), - trailing: const LeChevron(), - ), - if (flowDebugMode || NotificationsService.schedulingSupported) - ListTile( - title: Text("preferences.reminders".t(context)), - leading: const Icon(Symbols.notifications_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/reminders"), - trailing: const LeChevron(), - ), + ListHeader("preferences.general".t(context)), + const SizedBox(height: 8.0), ListTile( title: Text("preferences.language".t(context)), leading: const Icon(Symbols.language_rounded), @@ -112,46 +101,41 @@ class PreferencesPageState extends State { subtitle: Text(currentPrimaryCurrency), trailing: const LeChevron(), ), - ListTile( - title: Text("preferences.transfer".t(context)), - leading: const Icon(Symbols.sync_alt_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/transfer"), - subtitle: Text( - "preferences.transfer.description".t(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: const LeChevron(), - ), - ListTile( - title: Text("preferences.trashBin".t(context)), - leading: const Icon(Symbols.delete_rounded), - onTap: () => _pushAndRefreshAfter("/preferences/trashBin"), - trailing: const LeChevron(), - ), ListTile( title: Text("preferences.moneyFormatting".t(context)), leading: const Icon(Symbols.numbers_rounded), onTap: () => _pushAndRefreshAfter("/preferences/moneyFormatting"), trailing: const LeChevron(), ), + if (flowDebugMode || NotificationsService.schedulingSupported) + ListTile( + title: Text("preferences.reminders".t(context)), + leading: const Icon(Symbols.notifications_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/reminders"), + trailing: const LeChevron(), + ), + const Haptics(), const SizedBox(height: 24.0), - ListHeader("preferences.integrations".t(context)), + ListHeader("preferences.transactions".t(context)), const SizedBox(height: 8.0), ListTile( - title: Text("Eny"), - leading: const SizedBox( - width: 24.0, - height: 24.0, - child: AnimatedEnyLogo(), - ), + leading: const Icon(Symbols.automation_rounded), + title: Text("preferences.transactionEntryFlow".t(context)), onTap: () => - _pushAndRefreshAfter("/preferences/integrations/eny"), + _pushAndRefreshAfter("/preferences/transactionEntryFlow"), + trailing: const LeChevron(), + ), + ListTile( + title: Text("preferences.transfer".t(context)), + leading: const Icon(Symbols.sync_alt_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/transfer"), + subtitle: Text( + "preferences.transfer.description".t(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), trailing: const LeChevron(), ), - const SizedBox(height: 24.0), - ListHeader("preferences.transactions".t(context)), - const SizedBox(height: 8.0), ListTile( title: Text("preferences.transactions.pending".t(context)), subtitle: Text( @@ -189,13 +173,6 @@ class PreferencesPageState extends State { ), trailing: const LeChevron(), ), - ListTile( - leading: const Icon(Symbols.automation_rounded), - title: Text("preferences.transactionEntryFlow".t(context)), - onTap: () => - _pushAndRefreshAfter("/preferences/transactionEntryFlow"), - trailing: const LeChevron(), - ), const SizedBox(height: 24.0), ListHeader("preferences.appearance".t(context)), const SizedBox(height: 8.0), @@ -240,23 +217,45 @@ class PreferencesPageState extends State { trailing: const LeChevron(), ), const SizedBox(height: 24.0), - ListHeader("preferences.privacy".t(context)), + ListHeader("preferences.privacyAndSecurity".t(context)), const SizedBox(height: 8.0), const Privacy(), if (_showLockApp) ...[const SizedBox(height: 8.0), const LockApp()], const SizedBox(height: 24.0), - ListHeader("preferences.hapticFeedback".t(context)), - const SizedBox(height: 8.0), - const Haptics(), - const SizedBox(height: 24.0), - ListHeader("preferences.feedback".t(context)), + ListHeader("preferences.data".t(context)), const SizedBox(height: 8.0), + ListTile( + title: Text("preferences.sync".t(context)), + leading: const Icon(Symbols.sync_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/sync"), + trailing: const LeChevron(), + ), + ListTile( + title: Text("Eny"), + leading: const SizedBox( + width: 24.0, + height: 24.0, + child: AnimatedEnyLogo(), + ), + onTap: () => + _pushAndRefreshAfter("/preferences/integrations/eny"), + trailing: const LeChevron(), + ), + ListTile( + title: Text("preferences.trashBin".t(context)), + leading: const Icon(Symbols.delete_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/trashBin"), + trailing: const LeChevron(), + ), ListTile( title: Text("fileAttachment.cleanupHangingFiles".t(context)), leading: const Icon(Symbols.bug_report_rounded), onTap: () => _deleteHangingFiles(), trailing: const LeChevron(), ), + const SizedBox(height: 24.0), + ListHeader("preferences.feedback".t(context)), + const SizedBox(height: 8.0), ListTile( title: Text("preferences.feedback.debugLogs".t(context)), leading: const Icon(Symbols.bug_report_rounded), From 3c261b64e1a5706071807efaef601ac0181737a7 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 22:49:11 +0800 Subject: [PATCH 14/57] add date format preference --- assets/l10n/ar.json | 3 +- assets/l10n/be_BY.json | 3 +- assets/l10n/cs_CZ.json | 3 +- assets/l10n/de_DE.json | 3 +- assets/l10n/en.json | 3 +- assets/l10n/es_ES.json | 3 +- assets/l10n/fa_IR.json | 3 +- assets/l10n/fr_FR.json | 3 +- assets/l10n/it_IT.json | 3 +- assets/l10n/mn_MN.json | 3 +- assets/l10n/pl_PL.json | 3 +- assets/l10n/ru_RU.json | 3 +- assets/l10n/tr_TR.json | 3 +- assets/l10n/uk_UA.json | 3 +- assets/l10n/zh_CN.json | 3 +- assets/l10n/zh_TW.json | 3 +- lib/data/prefs/date_format_preset.dart | 44 ++++++ lib/entity/user_preferences.dart | 5 + lib/entity/user_preferences.g.dart | 2 + lib/l10n/date_format_moment_localization.dart | 126 ++++++++++++++++++ lib/main.dart | 27 +++- lib/objectbox/objectbox-model.json | 7 +- lib/objectbox/objectbox.g.dart | 23 +++- lib/routes.dart | 5 + .../date_format_preferences_page.dart | 94 +++++++++++++ ...list_item_appearance_preferences_page.dart | 15 --- lib/routes/preferences_page.dart | 6 + lib/services/user_preferences.dart | 8 ++ test/unit/data/date_format_preset_test.dart | 72 ++++++++++ 29 files changed, 447 insertions(+), 35 deletions(-) create mode 100644 lib/data/prefs/date_format_preset.dart create mode 100644 lib/l10n/date_format_moment_localization.dart create mode 100644 lib/routes/preferences/date_format_preferences_page.dart create mode 100644 test/unit/data/date_format_preset_test.dart diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index f586b647..f20e1a31 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "زيادة النفقات", "preferences.changeVisuals.incomeIncrease": "زيادة الدخل", "preferences.dateFormat": "تنسيق التاريخ", + "preferences.dateFormat.absoluteDateHeaders": "عرض التواريخ الدقيقة في عناوين القائمة", + "preferences.dateFormat.system": "افتراضي اللغة", "preferences.feedback": "مشاكل وملاحظات", "preferences.feedback.debugLogs": "عرض سجلات التصحيح", "preferences.hapticFeedback": "ردود فعل الأزرار", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "يمكنك إخفاء هذا القسم من الإعدادات", "preferences.transactions.geo.enable": "تفعيل", "preferences.transactions.listTile": "مظهر عناصر القائمة", - "preferences.transactions.listTile.absoluteDateHeaders": "عرض التواريخ الدقيقة في عناوين القائمة", "preferences.transactions.listTile.fallbackToCategoryName": "عرض الفئة للمعاملات بدون عنوان", "preferences.transactions.listTile.leading": "الأيقونة الرئيسية", "preferences.transactions.listTile.leading.account": "الحساب", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index fb147863..038659bf 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Рост выбаткаў", "preferences.changeVisuals.incomeIncrease": "Рост даходаў", "preferences.dateFormat": "Фармат даты", + "preferences.dateFormat.absoluteDateHeaders": "Паказваць дакладныя даты ў загалоўках", + "preferences.dateFormat.system": "Паводле мовы", "preferences.feedback": "Праблемы і зваротная сувязь", "preferences.feedback.debugLogs": "Прагляд журналаў адладкі", "preferences.hapticFeedback": "Водгук кнопак", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Вы можаце схаваць гэты раздзел у наладах", "preferences.transactions.geo.enable": "Уключыць", "preferences.transactions.listTile": "Вонкавы выгляд элемента спісу", - "preferences.transactions.listTile.absoluteDateHeaders": "Паказваць дакладныя даты ў загалоўках", "preferences.transactions.listTile.fallbackToCategoryName": "Паказваць катэгорыю для транзакцый без назвы", "preferences.transactions.listTile.leading": "Галоўны абразок", "preferences.transactions.listTile.leading.account": "Рахунак", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index deb5b194..f86823ee 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Nárůst výdajů", "preferences.changeVisuals.incomeIncrease": "Nárůst příjmů", "preferences.dateFormat": "Formát data", + "preferences.dateFormat.absoluteDateHeaders": "Zobrazit přesná data v záhlavích", + "preferences.dateFormat.system": "Podle jazyka", "preferences.feedback": "Problémy a zpětná vazba", "preferences.feedback.debugLogs": "Zobrazit protokoly ladění", "preferences.hapticFeedback": "Haptická odezva", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Tuto sekci můžete skrýt v nastavení.", "preferences.transactions.geo.enable": "Povolit", "preferences.transactions.listTile": "Vzhled položky v seznamu", - "preferences.transactions.listTile.absoluteDateHeaders": "Zobrazit přesná data v záhlavích", "preferences.transactions.listTile.fallbackToCategoryName": "Zobrazit název kategorie u transakcí bez názvu", "preferences.transactions.listTile.leading": "Hlavní ikona", "preferences.transactions.listTile.leading.account": "Účet", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 43f07cf7..2d010dc9 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Ausgabenanstieg", "preferences.changeVisuals.incomeIncrease": "Einnahmenanstieg", "preferences.dateFormat": "Datumsformat", + "preferences.dateFormat.absoluteDateHeaders": "Genaue Daten in Listenüberschriften anzeigen", + "preferences.dateFormat.system": "Sprachstandard", "preferences.feedback": "Probleme und Feedback", "preferences.feedback.debugLogs": "Debug-Protokolle anzeigen", "preferences.hapticFeedback": "Feedback beim Tippen", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Du kannst diesen Bereich in den Einstellungen ausblenden.", "preferences.transactions.geo.enable": "Aktivieren", "preferences.transactions.listTile": "Aussehen der Listeneinträge", - "preferences.transactions.listTile.absoluteDateHeaders": "Genaue Daten in Listenüberschriften anzeigen", "preferences.transactions.listTile.fallbackToCategoryName": "Kategorie anzeigen bei Buchungen ohne Titel", "preferences.transactions.listTile.leading": "Symbol am Anfang", "preferences.transactions.listTile.leading.account": "Konto", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b3d0291b..042edadc 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Expense growth", "preferences.changeVisuals.incomeIncrease": "Income growth", "preferences.dateFormat": "Date format", + "preferences.dateFormat.absoluteDateHeaders": "Show exact dates in list headers", + "preferences.dateFormat.system": "Language default", "preferences.feedback": "Problems and feedback", "preferences.feedback.debugLogs": "View debug logs", "preferences.hapticFeedback": "Button feedback", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "You can hide this section in settings", "preferences.transactions.geo.enable": "Enable", "preferences.transactions.listTile": "List item appearance", - "preferences.transactions.listTile.absoluteDateHeaders": "Show exact dates in list headers", "preferences.transactions.listTile.fallbackToCategoryName": "Show category for untitled transactions", "preferences.transactions.listTile.leading": "Leading icon", "preferences.transactions.listTile.leading.account": "Account", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 7b351d05..80b6684e 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Crecimiento de los gastos", "preferences.changeVisuals.incomeIncrease": "Crecimiento de los ingresos", "preferences.dateFormat": "Formato de fecha", + "preferences.dateFormat.absoluteDateHeaders": "Mostrar fechas exactas en los encabezados", + "preferences.dateFormat.system": "Según el idioma", "preferences.feedback": "Problemas y comentarios", "preferences.feedback.debugLogs": "Ver registros de depuración", "preferences.hapticFeedback": "Respuesta táctil de botones", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Puedes ocultar esta sección en los ajustes", "preferences.transactions.geo.enable": "Activar", "preferences.transactions.listTile": "Apariencia del elemento de lista", - "preferences.transactions.listTile.absoluteDateHeaders": "Mostrar fechas exactas en los encabezados", "preferences.transactions.listTile.fallbackToCategoryName": "Mostrar categoría para transacciones sin título", "preferences.transactions.listTile.leading": "Icono principal", "preferences.transactions.listTile.leading.account": "Cuenta", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 92e76aea..ca6847da 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "افزایش هزینه", "preferences.changeVisuals.incomeIncrease": "افزایش درآمد", "preferences.dateFormat": "فرمت تاریخ", + "preferences.dateFormat.absoluteDateHeaders": "نمایش تاریخ دقیق در سرتیترها", + "preferences.dateFormat.system": "پیش‌فرض زبان", "preferences.feedback": "مشکلات و بازخورد", "preferences.feedback.debugLogs": "مشاهده لاگ‌های دیباگ", "preferences.hapticFeedback": "بازخورد دکمه", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "می‌توانید این بخش را در تنظیمات مخفی کنید", "preferences.transactions.geo.enable": "فعال کردن", "preferences.transactions.listTile": "نمایش آیتم‌های لیست", - "preferences.transactions.listTile.absoluteDateHeaders": "نمایش تاریخ دقیق در سرتیترها", "preferences.transactions.listTile.fallbackToCategoryName": "برای تراکنش‌های بدون عنوان، دسته‌بندی را نشان بده", "preferences.transactions.listTile.leading": "آیکن ابتدای آیتم", "preferences.transactions.listTile.leading.account": "حساب", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 487a979a..0b7e6047 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Augmentation des dépenses", "preferences.changeVisuals.incomeIncrease": "Augmentation des revenus", "preferences.dateFormat": "Format de date", + "preferences.dateFormat.absoluteDateHeaders": "Afficher les dates exactes dans les en-têtes", + "preferences.dateFormat.system": "Selon la langue", "preferences.feedback": "Problèmes et retours", "preferences.feedback.debugLogs": "Voir les journaux de débogage", "preferences.hapticFeedback": "Retour tactile", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Vous pouvez masquer cette section dans les paramètres", "preferences.transactions.geo.enable": "Activer", "preferences.transactions.listTile": "Apparence des éléments de la liste", - "preferences.transactions.listTile.absoluteDateHeaders": "Afficher les dates exactes dans les en-têtes", "preferences.transactions.listTile.fallbackToCategoryName": "Afficher la catégorie pour les transactions sans titre", "preferences.transactions.listTile.leading": "Icône principale", "preferences.transactions.listTile.leading.account": "Compte", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index eaed425f..e52b6508 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Crescita delle spese", "preferences.changeVisuals.incomeIncrease": "Crescita delle entrate", "preferences.dateFormat": "Formato data", + "preferences.dateFormat.absoluteDateHeaders": "Mostra date esatte nelle intestazioni", + "preferences.dateFormat.system": "Predefinito della lingua", "preferences.feedback": "Problemi e feedback", "preferences.feedback.debugLogs": "Visualizza i log di debug", "preferences.hapticFeedback": "Feedback dei tasti", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Puoi nascondere questa sezione nelle impostazioni.", "preferences.transactions.geo.enable": "Abilita", "preferences.transactions.listTile": "Aspetto degli elementi della lista", - "preferences.transactions.listTile.absoluteDateHeaders": "Mostra date esatte nelle intestazioni", "preferences.transactions.listTile.fallbackToCategoryName": "Mostra categoria per transazioni senza titolo", "preferences.transactions.listTile.leading": "Icona principale", "preferences.transactions.listTile.leading.account": "Conto", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index a7734084..baf34ad9 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Зарлага өсөх", "preferences.changeVisuals.incomeIncrease": "Орлого өсөх", "preferences.dateFormat": "Огнооны хэв", + "preferences.dateFormat.absoluteDateHeaders": "Гарчигт яг огноог харуулах", + "preferences.dateFormat.system": "Хэлний үндсэн", "preferences.feedback": "Асуудал ", "preferences.feedback.debugLogs": "Дибаг лог харах", "preferences.hapticFeedback": "Товчны хариу", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Энэ хэсгийг тохиргооноос нуух боломжтой", "preferences.transactions.geo.enable": "Идэвхжүүлэх", "preferences.transactions.listTile": "Жагсаалтад харагдах байдал", - "preferences.transactions.listTile.absoluteDateHeaders": "Гарчигт яг огноог харуулах", "preferences.transactions.listTile.fallbackToCategoryName": "Гарчиггүй бол ангиллыг харуулах", "preferences.transactions.listTile.leading": "Толгой дүрс", "preferences.transactions.listTile.leading.account": "Данс", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index d1940b6f..cf14a469 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Wzrost wydatków", "preferences.changeVisuals.incomeIncrease": "Wzrost przychodów", "preferences.dateFormat": "Format daty", + "preferences.dateFormat.absoluteDateHeaders": "Pokazuj dokładne daty w nagłówkach", + "preferences.dateFormat.system": "Zgodnie z językiem", "preferences.feedback": "Problemy i opinie", "preferences.feedback.debugLogs": "Zobacz logi debugowania", "preferences.hapticFeedback": "Wibracje przycisków", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Możesz ukryć tę sekcję w ustawieniach", "preferences.transactions.geo.enable": "Włącz", "preferences.transactions.listTile": "Wygląd na liście", - "preferences.transactions.listTile.absoluteDateHeaders": "Pokazuj dokładne daty w nagłówkach", "preferences.transactions.listTile.fallbackToCategoryName": "Pokaż kategorię dla transakcji bez tytułu", "preferences.transactions.listTile.leading": "Ikona główna", "preferences.transactions.listTile.leading.account": "Konto", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index de49e7b8..8a546efc 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Рост расходов", "preferences.changeVisuals.incomeIncrease": "Рост дохода", "preferences.dateFormat": "Формат даты", + "preferences.dateFormat.absoluteDateHeaders": "Показывать точные даты в заголовках", + "preferences.dateFormat.system": "Как в языке", "preferences.feedback": "Проблемы и отзывы", "preferences.feedback.debugLogs": "Просмотреть отладочные логи", "preferences.hapticFeedback": "Отклик кнопок", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Вы можете скрыть этот раздел в настройках", "preferences.transactions.geo.enable": "Включить", "preferences.transactions.listTile": "Внешний вид элемента списка", - "preferences.transactions.listTile.absoluteDateHeaders": "Показывать точные даты в заголовках", "preferences.transactions.listTile.fallbackToCategoryName": "Показывать категорию для транзакций без названия", "preferences.transactions.listTile.leading": "Ведущий значок", "preferences.transactions.listTile.leading.account": "Счет", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index 4db84d63..e43dfc4e 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Gider artışı", "preferences.changeVisuals.incomeIncrease": "Gelir artışı", "preferences.dateFormat": "Tarih formatı", + "preferences.dateFormat.absoluteDateHeaders": "Başlıklarda tam tarihleri göster", + "preferences.dateFormat.system": "Dil varsayılanı", "preferences.feedback": "Sorunlar ve geri bildirim", "preferences.feedback.debugLogs": "Hata ayıklama günlüklerini görüntüle", "preferences.hapticFeedback": "Düğme geri bildirimi", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Bu bölümü ayarlardan gizleyebilirsiniz", "preferences.transactions.geo.enable": "Etkinleştirmek", "preferences.transactions.listTile": "Liste öğesi görünümü", - "preferences.transactions.listTile.absoluteDateHeaders": "Başlıklarda tam tarihleri göster", "preferences.transactions.listTile.fallbackToCategoryName": "Başlıksız işlemler için kategoriyi göster", "preferences.transactions.listTile.leading": "Önde gelen simge", "preferences.transactions.listTile.leading.account": "Hesap", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 29e20476..b4e5eea1 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "Зростання витрат", "preferences.changeVisuals.incomeIncrease": "Зростання доходу", "preferences.dateFormat": "Формат дати", + "preferences.dateFormat.absoluteDateHeaders": "Показувати точні дати в заголовках", + "preferences.dateFormat.system": "Як у мові", "preferences.feedback": "Проблеми та відгуки", "preferences.feedback.debugLogs": "Переглянути налагоджувальні журнали", "preferences.hapticFeedback": "Тактильний відгук", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "Ви можете приховати цей розділ у налаштуваннях", "preferences.transactions.geo.enable": "Увімкнути", "preferences.transactions.listTile": "Зовнішній вигляд елемента списку", - "preferences.transactions.listTile.absoluteDateHeaders": "Показувати точні дати в заголовках", "preferences.transactions.listTile.fallbackToCategoryName": "Показувати категорію для транзакцій без назви", "preferences.transactions.listTile.leading": "Провідний значок", "preferences.transactions.listTile.leading.account": "Рахунок", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index eda89210..a88c5f49 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "支出增加", "preferences.changeVisuals.incomeIncrease": "收入增加", "preferences.dateFormat": "日期格式", + "preferences.dateFormat.absoluteDateHeaders": "在列表标题中显示具体日期", + "preferences.dateFormat.system": "跟随语言", "preferences.feedback": "问题与反馈", "preferences.feedback.debugLogs": "查看调试日志", "preferences.hapticFeedback": "按钮反馈", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "您可以在设置中隐藏此区块", "preferences.transactions.geo.enable": "启用", "preferences.transactions.listTile": "列表项目外观", - "preferences.transactions.listTile.absoluteDateHeaders": "在列表标题中显示具体日期", "preferences.transactions.listTile.fallbackToCategoryName": "未命名交易显示分类名称", "preferences.transactions.listTile.leading": "开头图标", "preferences.transactions.listTile.leading.account": "账户", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 0b920d39..c5ecc26f 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -364,6 +364,8 @@ "preferences.changeVisuals.expenseIncrease": "支出增加", "preferences.changeVisuals.incomeIncrease": "收入增加", "preferences.dateFormat": "日期格式", + "preferences.dateFormat.absoluteDateHeaders": "在列表標題中顯示具體日期", + "preferences.dateFormat.system": "跟隨語言", "preferences.feedback": "問題與回饋", "preferences.feedback.debugLogs": "檢視偵錯日誌", "preferences.hapticFeedback": "按鈕回饋", @@ -445,7 +447,6 @@ "preferences.transactions.geo.disableInstructions": "您可以在設定中隱藏此區塊", "preferences.transactions.geo.enable": "啟用", "preferences.transactions.listTile": "清單項目外觀", - "preferences.transactions.listTile.absoluteDateHeaders": "在列表標題中顯示具體日期", "preferences.transactions.listTile.fallbackToCategoryName": "未命名交易顯示分類名稱", "preferences.transactions.listTile.leading": "開頭圖示", "preferences.transactions.listTile.leading.account": "帳戶", diff --git a/lib/data/prefs/date_format_preset.dart b/lib/data/prefs/date_format_preset.dart new file mode 100644 index 00000000..8eb9e9eb --- /dev/null +++ b/lib/data/prefs/date_format_preset.dart @@ -0,0 +1,44 @@ +import "package:flow/l10n/date_format_moment_localization.dart"; +import "package:moment_dart/moment_dart.dart"; + +enum DateFormatPreset { + system("system", null, null, null), + iso("iso", "YYYY-MM-DD", "YYYY-MM-DD", "MM-DD"), + dayMonthYearSlash("dmySlash", "DD/MM/YYYY", "DD/MM/YYYY", "D/M"), + monthDayYearSlash("mdySlash", "MM/DD/YYYY", "MM/DD/YYYY", "M/D"), + dayMonthYearDot("dmyDot", "DD.MM.YYYY", "DD.MM.YYYY", "D.M"), + dayMonthNameYear("dMonY", "D MMM YYYY", "D MMMM YYYY", "D MMM"); + + final String value; + + /// moment_dart patterns, null means follow the locale + final String? pattern; + final String? longPattern; + final String? dayMonthPattern; + + const DateFormatPreset( + this.value, + this.pattern, + this.longPattern, + this.dayMonthPattern, + ); + + static DateFormatPreset? tryParse(String? value) { + for (final DateFormatPreset preset in values) { + if (preset.value == value) return preset; + } + + return null; + } + + /// Wraps [localization] so it formats dates with this preset + MomentLocalization apply(MomentLocalization localization) { + final MomentLocalization base = localization is DateFormatMomentLocalization + ? localization.inner + : localization; + + if (this == .system) return base; + + return DateFormatMomentLocalization(base, this); + } +} diff --git a/lib/entity/user_preferences.dart b/lib/entity/user_preferences.dart index 39abfc24..92d8ba67 100644 --- a/lib/entity/user_preferences.dart +++ b/lib/entity/user_preferences.dart @@ -1,5 +1,6 @@ import "package:flow/data/flow_button_type.dart"; import "package:flow/data/prefs/change_visuals.dart"; +import "package:flow/data/prefs/date_format_preset.dart"; import "package:flow/data/transactions_filter/pending_time_range.dart"; import "package:flow/entity/_base.dart"; import "package:flow/entity/user_preferences/transaction_entry_flow.dart"; @@ -86,6 +87,9 @@ class UserPreferences implements EntityBase { String? icuCurrencyFormattingPattern; + /// Serialized version of [DateFormatPreset] + String? dateFormatPreset; + String? primaryCurrency; String? primaryAccountUuid; @@ -188,6 +192,7 @@ class UserPreferences implements EntityBase { this.iCloudBackupsToKeep = 10, this.autoBackupIntervalInHours = 72, this.icuCurrencyFormattingPattern, + this.dateFormatPreset, this.primaryCurrency, this.primaryAccountUuid, this.transactionButtonOrderJoined, diff --git a/lib/entity/user_preferences.g.dart b/lib/entity/user_preferences.g.dart index 35a43143..5c66c6f5 100644 --- a/lib/entity/user_preferences.g.dart +++ b/lib/entity/user_preferences.g.dart @@ -42,6 +42,7 @@ UserPreferences _$UserPreferencesFromJson(Map json) => (json['autoBackupIntervalInHours'] as num?)?.toInt() ?? 72, icuCurrencyFormattingPattern: json['icuCurrencyFormattingPattern'] as String?, + dateFormatPreset: json['dateFormatPreset'] as String?, primaryCurrency: json['primaryCurrency'] as String?, primaryAccountUuid: json['primaryAccountUuid'] as String?, transactionButtonOrderJoined: @@ -83,6 +84,7 @@ Map _$UserPreferencesToJson( 'privacyModeUponLaunch': instance.privacyModeUponLaunch, 'privacyModeUponShaking': instance.privacyModeUponShaking, 'icuCurrencyFormattingPattern': instance.icuCurrencyFormattingPattern, + 'dateFormatPreset': instance.dateFormatPreset, 'primaryCurrency': instance.primaryCurrency, 'primaryAccountUuid': instance.primaryAccountUuid, 'autoBackupIntervalInHours': instance.autoBackupIntervalInHours, diff --git a/lib/l10n/date_format_moment_localization.dart b/lib/l10n/date_format_moment_localization.dart new file mode 100644 index 00000000..a70a6efc --- /dev/null +++ b/lib/l10n/date_format_moment_localization.dart @@ -0,0 +1,126 @@ +import "package:flow/data/prefs/date_format_preset.dart"; +import "package:moment_dart/moment_dart.dart"; +// ignore: implementation_imports +import "package:moment_dart/src/localizations/mixins/simple_range.dart"; + +/// Delegates to [inner], except for localized date formats (l, LL, lll...) +class DateFormatMomentLocalization extends MomentLocalization with SimpleRange { + final MomentLocalization inner; + final DateFormatPreset preset; + + DateFormatMomentLocalization(this.inner, this.preset) + : assert(preset.pattern != null && preset.longPattern != null); + + late final Map _innerFormatters = + inner.formatters; + + @override + late final Map formatters = { + ..._innerFormatters, + FormatterToken.l: _short, + FormatterToken.L: _short, + FormatterToken.ll: _short, + FormatterToken.LL: _long, + FormatterToken.lll: (dateTime) => "${_short(dateTime)} ${_time(dateTime)}", + FormatterToken.LLL: (dateTime) => "${_long(dateTime)} ${_time(dateTime)}", + FormatterToken.llll: (dateTime) => + "${reformat(dateTime, "ddd")}, ${_short(dateTime)} ${_time(dateTime)}", + FormatterToken.LLLL: (dateTime) => + "${reformat(dateTime, "dddd")}, ${_long(dateTime)} ${_time(dateTime)}", + }; + + String _short(DateTime dateTime) => reformat(dateTime, preset.pattern!); + String _long(DateTime dateTime) => reformat(dateTime, preset.longPattern!); + String _time(DateTime dateTime) => + _innerFormatters[FormatterToken.LT]?.call(dateTime) ?? + reformat(dateTime, "HH:mm"); + + @override + Map overrideFormatters() => formatters; + + @override + SimpleRangeData get simpleRangeData => (inner as SimpleRange).simpleRangeData; + + @override + String range(TimeRange range, {DateTime? anchor, bool useRelative = true}) { + if (inner is! SimpleRange) { + return inner.range(range, anchor: anchor, useRelative: useRelative); + } + + return super.range(range, anchor: anchor, useRelative: useRelative); + } + + @override + String relative( + Duration duration, { + bool dropPrefixOrSuffix = false, + Abbreviation form = Abbreviation.none, + }) => inner.relative( + duration, + dropPrefixOrSuffix: dropPrefixOrSuffix, + form: form, + ); + + @override + String duration( + Duration duration, { + bool round = true, + bool omitZeros = true, + bool includeWeeks = false, + Abbreviation form = Abbreviation.none, + String? delimiter, + DurationFormat format = DurationFormat.auto, + bool dropPrefixOrSuffix = false, + }) => inner.duration( + duration, + round: round, + omitZeros: omitZeros, + includeWeeks: includeWeeks, + form: form, + delimiter: delimiter, + format: format, + dropPrefixOrSuffix: dropPrefixOrSuffix, + ); + + @override + String calendarTime(Moment moment) => inner.calendarTime(moment); + + @override + String calendar( + Moment moment, { + DateTime? reference, + String? customFormat, + bool omitHours = false, + bool omitHoursIfDistant = true, + }) => inner.calendar( + moment, + reference: reference, + customFormat: customFormat, + omitHours: omitHours, + omitHoursIfDistant: omitHoursIfDistant, + ); + + @override + CalendarLocalizationData? get calendarData => inner.calendarData; + + @override + Map get weekdayName => inner.weekdayName; + + @override + String get languageCode => inner.languageCode; + + @override + String? get countryCode => inner.countryCode; + + @override + String get locale => inner.locale; + + @override + String get endonym => inner.endonym; + + @override + String get languageNameInEnglish => inner.languageNameInEnglish; + + @override + int get weekStart => inner.weekStart; +} diff --git a/lib/main.dart b/lib/main.dart index 927d3d0b..b411fbcd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import "dart:ui"; import "package:flow/constants.dart"; import "package:flow/data/flow_icon.dart"; +import "package:flow/data/prefs/date_format_preset.dart"; import "package:flow/entity/profile.dart"; import "package:flow/graceful_migrations.dart"; import "package:flow/l10n/flow_localizations.dart"; @@ -187,6 +188,7 @@ class FlowState extends State { late final AppLifecycleListener _appLifeCycleListener; Locale _locale = FlowLocalizations.supportedLocales.first; + DateFormatPreset _dateFormatPreset = .system; ThemeMode _themeMode = ThemeMode.system; ThemeFactory _themeFactory = ThemeFactory.fromThemeName(null); @@ -220,6 +222,7 @@ class FlowState extends State { UserPreferencesService().valueNotifier.addListener(_reloadTheme); UserPreferencesService().valueNotifier.addListener(_listenToShakes); UserPreferencesService().valueNotifier.addListener(_syncWidgets); + UserPreferencesService().valueNotifier.addListener(_reloadDateFormat); ExchangeRatesService().exchangeRatesCache.addListener(_syncWidgets); @@ -291,6 +294,7 @@ class FlowState extends State { UserPreferencesService().valueNotifier.removeListener(_reloadTheme); UserPreferencesService().valueNotifier.removeListener(_listenToShakes); UserPreferencesService().valueNotifier.removeListener(_syncWidgets); + UserPreferencesService().valueNotifier.removeListener(_reloadDateFormat); ExchangeRatesService().exchangeRatesCache.removeListener(_syncWidgets); @@ -456,13 +460,34 @@ class FlowState extends State { "Setting moment_dart localization to ${newMomentLocalization.locale}", ); - Moment.setGlobalLocalization(newMomentLocalization); + _dateFormatPreset = UserPreferencesService().dateFormatPreset; + + Moment.setGlobalLocalization( + _dateFormatPreset.apply(newMomentLocalization), + ); Intl.defaultLocale = overriddenLocale.code; setState(() {}); } + void _reloadDateFormat() { + final DateFormatPreset dateFormatPreset = + UserPreferencesService().dateFormatPreset; + + if (_dateFormatPreset == dateFormatPreset) return; + + _dateFormatPreset = dateFormatPreset; + + mainLogger.fine("Setting date format preset to ${dateFormatPreset.value}"); + + Moment.setGlobalLocalization( + dateFormatPreset.apply(Moment.defaultLocalization), + ); + + setState(() {}); + } + void _refreshExchangeRates() { ExchangeRatesService().tryFetchRates( UserPreferencesService().primaryCurrency, diff --git a/lib/objectbox/objectbox-model.json b/lib/objectbox/objectbox-model.json index 14a28d37..45410f2d 100644 --- a/lib/objectbox/objectbox-model.json +++ b/lib/objectbox/objectbox-model.json @@ -362,7 +362,7 @@ }, { "id": "10:7829328581176695647", - "lastPropertyId": "31:8739212611241568931", + "lastPropertyId": "32:5426961238010158288", "name": "UserPreferences", "properties": [ { @@ -512,6 +512,11 @@ "id": "31:8739212611241568931", "name": "transactionListAbsoluteDateHeaders", "type": 1 + }, + { + "id": "32:5426961238010158288", + "name": "dateFormatPreset", + "type": 9 } ], "relations": [] diff --git a/lib/objectbox/objectbox.g.dart b/lib/objectbox/objectbox.g.dart index 2577b99e..dd131ce7 100644 --- a/lib/objectbox/objectbox.g.dart +++ b/lib/objectbox/objectbox.g.dart @@ -455,7 +455,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(10, 7829328581176695647), name: 'UserPreferences', - lastPropertyId: const obx_int.IdUid(31, 8739212611241568931), + lastPropertyId: const obx_int.IdUid(32, 5426961238010158288), flags: 0, properties: [ obx_int.ModelProperty( @@ -633,6 +633,12 @@ final _entities = [ type: 1, flags: 0, ), + obx_int.ModelProperty( + id: const obx_int.IdUid(32, 5426961238010158288), + name: 'dateFormatPreset', + type: 9, + flags: 0, + ), ], relations: [], backlinks: [], @@ -1663,7 +1669,10 @@ obx_int.ModelDefinition getObjectBoxModel() { : fbb.writeString( object.homePendingTransactionsTimeRangeSerialized!, ); - fbb.startTable(32); + final dateFormatPresetOffset = object.dateFormatPreset == null + ? null + : fbb.writeString(object.dateFormatPreset!); + fbb.startTable(33); fbb.addInt64(0, object.id); fbb.addOffset(1, uuidOffset); fbb.addBool(2, object.combineTransfers); @@ -1693,6 +1702,7 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addBool(28, object.transactionListTileShowExternalSource); fbb.addOffset(29, homePendingTransactionsTimeRangeSerializedOffset); fbb.addBool(30, object.transactionListAbsoluteDateHeaders); + fbb.addOffset(31, dateFormatPresetOffset); fbb.finish(fbb.endTable()); return object.id; }, @@ -1767,6 +1777,9 @@ obx_int.ModelDefinition getObjectBoxModel() { final icuCurrencyFormattingPatternParam = const fb.StringReader( asciiOptimization: true, ).vTableGetNullable(buffer, rootOffset, 30); + final dateFormatPresetParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 66); final primaryCurrencyParam = const fb.StringReader( asciiOptimization: true, ).vTableGetNullable(buffer, rootOffset, 34); @@ -1820,6 +1833,7 @@ obx_int.ModelDefinition getObjectBoxModel() { iCloudBackupsToKeep: iCloudBackupsToKeepParam, autoBackupIntervalInHours: autoBackupIntervalInHoursParam, icuCurrencyFormattingPattern: icuCurrencyFormattingPatternParam, + dateFormatPreset: dateFormatPresetParam, primaryCurrency: primaryCurrencyParam, primaryAccountUuid: primaryAccountUuidParam, transactionButtonOrderJoined: transactionButtonOrderJoinedParam, @@ -2709,6 +2723,11 @@ class UserPreferences_ { /// See [UserPreferences.transactionListAbsoluteDateHeaders]. static final transactionListAbsoluteDateHeaders = obx.QueryBooleanProperty(_entities[6].properties[28]); + + /// See [UserPreferences.dateFormatPreset]. + static final dateFormatPreset = obx.QueryStringProperty( + _entities[6].properties[29], + ); } /// [Budget] entity fields to define ObjectBox queries. diff --git a/lib/routes.dart b/lib/routes.dart index 5e90b659..d5f37368 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -33,6 +33,7 @@ import "package:flow/routes/integrate/integrate_eny_page.dart"; import "package:flow/routes/integrations/eny_page.dart"; import "package:flow/routes/preferences/button_order_preferences_page.dart"; import "package:flow/routes/preferences/change_preferences_page.dart"; +import "package:flow/routes/preferences/date_format_preferences_page.dart"; import "package:flow/routes/preferences/integrations/eny_preferences_page.dart"; import "package:flow/routes/preferences/money_formatting_preferences_page.dart"; import "package:flow/routes/preferences/numpad_preferences_page.dart"; @@ -329,6 +330,10 @@ final GoRouter router = GoRouter( path: "moneyFormatting", builder: (context, state) => const MoneyFormattingPreferencesPage(), ), + GoRoute( + path: "dateFormat", + builder: (context, state) => const DateFormatPreferencesPage(), + ), GoRoute( path: "sync", builder: (context, state) => const SyncPreferencesPage(), diff --git a/lib/routes/preferences/date_format_preferences_page.dart b/lib/routes/preferences/date_format_preferences_page.dart new file mode 100644 index 00000000..0c4112d7 --- /dev/null +++ b/lib/routes/preferences/date_format_preferences_page.dart @@ -0,0 +1,94 @@ +import "package:flow/data/prefs/date_format_preset.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/helpers.dart"; +import "package:flutter/material.dart"; +import "package:moment_dart/moment_dart.dart"; + +class DateFormatPreferencesPage extends StatefulWidget { + const DateFormatPreferencesPage({super.key}); + + @override + State createState() => + _DateFormatPreferencesPageState(); +} + +class _DateFormatPreferencesPageState extends State { + @override + Widget build(BuildContext context) { + final DateFormatPreset dateFormatPreset = + UserPreferencesService().dateFormatPreset; + final bool transactionListAbsoluteDateHeaders = + UserPreferencesService().transactionListAbsoluteDateHeaders; + + return Scaffold( + appBar: AppBar(title: Text("preferences.dateFormat".t(context))), + body: SingleChildScrollView( + child: SafeArea( + child: Column( + crossAxisAlignment: .start, + children: [ + const SizedBox(height: 16.0), + Center( + child: Text( + _example(dateFormatPreset).lll, + style: context.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16.0), + RadioGroup( + groupValue: dateFormatPreset, + onChanged: updateDateFormatPreset, + child: Column( + mainAxisSize: MainAxisSize.min, + children: DateFormatPreset.values + .map( + (preset) => RadioListTile( + title: Text( + preset == .system + ? "preferences.dateFormat.system".t(context) + : _example(preset).format("l"), + ), + subtitle: preset == .system + ? Text(_example(preset).format("l")) + : null, + value: preset, + activeColor: context.colorScheme.primary, + ), + ) + .toList(), + ), + ), + const SizedBox(height: 16.0), + SwitchListTile( + title: Text( + "preferences.dateFormat.absoluteDateHeaders".t(context), + ), + value: transactionListAbsoluteDateHeaders, + onChanged: updateTransactionListAbsoluteDateHeaders, + ), + ], + ), + ), + ), + ); + } + + Moment _example(DateFormatPreset preset) => + Moment.now(localization: preset.apply(Moment.defaultLocalization)); + + void updateDateFormatPreset(DateFormatPreset? newDateFormatPreset) { + if (newDateFormatPreset == null) return; + + UserPreferencesService().dateFormatPreset = newDateFormatPreset; + + setState(() {}); + } + + void updateTransactionListAbsoluteDateHeaders(bool newValue) { + UserPreferencesService().transactionListAbsoluteDateHeaders = newValue; + + setState(() {}); + } +} diff --git a/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart b/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart index 0cdc47e3..0f630374 100644 --- a/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart +++ b/lib/routes/preferences/transaction_list_item_appearance_preferences_page.dart @@ -44,8 +44,6 @@ class _TransactionListItemAppearancePreferencesPageState UserPreferencesService().transactionListTileShowAccountForLeading; final bool transactionListTileRelaxedDensity = UserPreferencesService().transactionListTileRelaxedDensity; - final bool transactionListAbsoluteDateHeaders = - UserPreferencesService().transactionListAbsoluteDateHeaders; return Scaffold( appBar: AppBar( @@ -124,19 +122,6 @@ class _TransactionListItemAppearancePreferencesPageState setState(() {}); }, ), - SwitchListTile( - title: Text( - "preferences.transactions.listTile.absoluteDateHeaders".t( - context, - ), - ), - value: transactionListAbsoluteDateHeaders, - onChanged: (bool newValue) { - UserPreferencesService().transactionListAbsoluteDateHeaders = - newValue; - setState(() {}); - }, - ), const SizedBox(height: 8.0), ListHeader( "preferences.transactions.listTile.leading".t(context), diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart index 35436126..6f08d6cc 100644 --- a/lib/routes/preferences_page.dart +++ b/lib/routes/preferences_page.dart @@ -135,6 +135,12 @@ class PreferencesPageState extends State { onTap: () => _pushAndRefreshAfter("/preferences/moneyFormatting"), trailing: const LeChevron(), ), + ListTile( + title: Text("preferences.dateFormat".t(context)), + leading: const Icon(Symbols.calendar_month_rounded), + onTap: () => _pushAndRefreshAfter("/preferences/dateFormat"), + trailing: const LeChevron(), + ), const SizedBox(height: 24.0), ListHeader("preferences.integrations".t(context)), const SizedBox(height: 8.0), diff --git a/lib/services/user_preferences.dart b/lib/services/user_preferences.dart index 610d3be0..ad5ab017 100644 --- a/lib/services/user_preferences.dart +++ b/lib/services/user_preferences.dart @@ -5,6 +5,7 @@ import "package:flow/constants.dart"; import "package:flow/data/flow_button_type.dart"; import "package:flow/data/flow_notification_payload.dart"; import "package:flow/data/prefs/change_visuals.dart"; +import "package:flow/data/prefs/date_format_preset.dart"; import "package:flow/data/transactions_filter/pending_time_range.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/transaction_filter_preset.dart"; @@ -319,6 +320,13 @@ class UserPreferencesService { ObjectBox().box().put(value); } + DateFormatPreset get dateFormatPreset => + DateFormatPreset.tryParse(value.dateFormatPreset) ?? .system; + set dateFormatPreset(DateFormatPreset newDateFormatPreset) { + value.dateFormatPreset = newDateFormatPreset.value; + ObjectBox().box().put(value); + } + List get transactionButtonOrder => value.transactionButtonOrder; diff --git a/test/unit/data/date_format_preset_test.dart b/test/unit/data/date_format_preset_test.dart new file mode 100644 index 00000000..8d435130 --- /dev/null +++ b/test/unit/data/date_format_preset_test.dart @@ -0,0 +1,72 @@ +import "package:flow/data/prefs/date_format_preset.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; + +void main() { + final DateTime date = DateTime(2026, 9, 5, 14, 30); + + String format(DateFormatPreset preset, String payload) => Moment( + date, + localization: preset.apply(MomentLocalizations.enUS()), + ).format(payload); + + test("presets format short dates", () { + expect(format(.system, "l"), "9/5/2026"); + expect(format(.iso, "l"), "2026-09-05"); + expect(format(.dayMonthYearSlash, "l"), "05/09/2026"); + expect(format(.monthDayYearSlash, "L"), "09/05/2026"); + expect(format(.dayMonthYearDot, "ll"), "05.09.2026"); + expect(format(.dayMonthNameYear, "ll"), "5 Sep 2026"); + expect(format(.dayMonthNameYear, "LL"), "5 September 2026"); + }); + + test("presets keep the locale's time format", () { + expect(format(.iso, "lll"), "2026-09-05 2:30 PM"); + expect(format(.dayMonthYearDot, "llll"), "Sat, 05.09.2026 2:30 PM"); + expect(format(.iso, "LT"), "2:30 PM"); + }); + + test("calendar falls back to the preset for distant dates", () { + final Moment moment = Moment( + date, + localization: DateFormatPreset.iso.apply(MomentLocalizations.enUS()), + ); + + expect( + moment.calendar(reference: DateTime(2026, 10, 20), omitHours: true), + "2026-09-05", + ); + expect(moment.calendar(reference: DateTime(2026, 9, 6)), startsWith("Y")); + }); + + test("custom ranges use the preset", () { + final MomentLocalization localization = DateFormatPreset.dayMonthYearSlash + .apply(MomentLocalizations.enUS()); + + expect( + CustomTimeRange( + DateTime(2026, 1, 1), + DateTime(2026, 2, 1), + ).format(localization: localization, anchor: DateTime(2026, 9, 25)), + "01/01/2026 - 01/02/2026", + ); + }); + + test("apply does not stack wrappers", () { + final MomentLocalization wrapped = DateFormatPreset.iso.apply( + MomentLocalizations.enUS(), + ); + + expect(DateFormatPreset.system.apply(wrapped).locale, "en_US"); + expect(DateFormatPreset.system.apply(wrapped), isNot(same(wrapped))); + }); + + test("tryParse maps stored values", () { + expect( + DateFormatPreset.tryParse("dmySlash"), + DateFormatPreset.dayMonthYearSlash, + ); + expect(DateFormatPreset.tryParse("nope"), isNull); + expect(DateFormatPreset.tryParse(null), isNull); + }); +} From 20b774b3ca4dcd0e750d7c910bbe41cfc2ca2410 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 22:49:11 +0800 Subject: [PATCH 15/57] follow date format in budget strip and net worth tooltip --- lib/widgets/budgets/budget_history_strip.dart | 6 +++++- lib/widgets/stats/net_worth/net_worth_chart.dart | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/widgets/budgets/budget_history_strip.dart b/lib/widgets/budgets/budget_history_strip.dart index 61e4d676..f8706fe1 100644 --- a/lib/widgets/budgets/budget_history_strip.dart +++ b/lib/widgets/budgets/budget_history_strip.dart @@ -1,4 +1,5 @@ import "package:flow/data/budget_progress.dart"; +import "package:flow/services/user_preferences.dart"; import "package:flow/theme/theme.dart"; import "package:flutter/material.dart"; import "package:moment_dart/moment_dart.dart"; @@ -143,6 +144,9 @@ class _Bar extends StatelessWidget { String _label() => switch (progress.range) { MonthTimeRange month => month.from.format(payload: "MMM"), YearTimeRange year => year.year.toString(), - _ => progress.range.from.format(payload: "D/M"), + _ => progress.range.from.format( + payload: + UserPreferencesService().dateFormatPreset.dayMonthPattern ?? "D/M", + ), }; } diff --git a/lib/widgets/stats/net_worth/net_worth_chart.dart b/lib/widgets/stats/net_worth/net_worth_chart.dart index d0de9568..c5462d34 100644 --- a/lib/widgets/stats/net_worth/net_worth_chart.dart +++ b/lib/widgets/stats/net_worth/net_worth_chart.dart @@ -44,7 +44,7 @@ class NetWorthChart extends StatelessWidget { DurationUnit.second || DurationUnit.minute || DurationUnit.hour => "MMM D, HH:mm", - DurationUnit.day || DurationUnit.week => "MMM D, YYYY", + DurationUnit.day || DurationUnit.week => "ll", DurationUnit.month => "MMM YYYY", DurationUnit.year => "YYYY", }; From e1e779f884429eb1260151327b250f67faf2f076 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 22:51:21 +0800 Subject: [PATCH 16/57] stats: ranked category list with share bars --- assets/l10n/ar.json | 19 +++ assets/l10n/be_BY.json | 15 +++ assets/l10n/cs_CZ.json | 13 ++ assets/l10n/de_DE.json | 11 ++ assets/l10n/en.json | 11 ++ assets/l10n/es_ES.json | 11 ++ assets/l10n/fa_IR.json | 11 ++ assets/l10n/fr_FR.json | 11 ++ assets/l10n/it_IT.json | 11 ++ assets/l10n/mn_MN.json | 11 ++ assets/l10n/pl_PL.json | 15 +++ assets/l10n/ru_RU.json | 15 +++ assets/l10n/tr_TR.json | 11 ++ assets/l10n/uk_UA.json | 15 +++ assets/l10n/zh_CN.json | 11 ++ assets/l10n/zh_TW.json | 11 ++ lib/data/chart_data.dart | 2 + lib/data/group_sort_mode.dart | 15 +++ lib/prefs/local_preferences.dart | 9 ++ lib/routes/stats/stats_by_group_page.dart | 74 ++++++++-- lib/utils/extensions/chart_data.dart | 49 +++++++ lib/widgets/home/stats/group_list_tile.dart | 126 +++++++++--------- lib/widgets/home/stats/group_list_view.dart | 103 +++++++++++--- lib/widgets/home/stats/group_share_bar.dart | 64 +++++++++ .../home/stats/group_summary_card.dart | 69 ++++++++++ 25 files changed, 614 insertions(+), 99 deletions(-) create mode 100644 lib/data/group_sort_mode.dart create mode 100644 lib/utils/extensions/chart_data.dart create mode 100644 lib/widgets/home/stats/group_share_bar.dart create mode 100644 lib/widgets/home/stats/group_summary_card.dart diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e1f16610..3764a01f 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "مصروف", "enum.FlowButtonType@income": "إيراد", "enum.FlowButtonType@transfer": "تحويل", + "enum.GroupSortMode@alphabetical": "الاسم", + "enum.GroupSortMode@amount": "المبلغ", "enum.ImportCSVProgress@creatingAccounts": "يجري إنشاء الحسابات", "enum.ImportCSVProgress@creatingCategories": "يجري إنشاء الفئات", "enum.ImportCSVProgress@creatingTransactions": "يجري إنشاء المعاملات", @@ -792,6 +794,23 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "اطلع على مراجعة شهرك", "tabs.stats.analytics.wrapped.tileTitle": "ملخص {month} الخاص بك", "tabs.stats.analytics.wrapped.title": "{month}، ملخص", + "tabs.stats.byGroup.accountCount": "{count} حساب", + "tabs.stats.byGroup.accountCount.few": "{count} حسابات", + "tabs.stats.byGroup.accountCount.many": "{count} حسابًا", + "tabs.stats.byGroup.accountCount.one": "{count} حساب", + "tabs.stats.byGroup.accountCount.two": "{count} حسابان", + "tabs.stats.byGroup.accountCount.zero": "{count} حسابات", + "tabs.stats.byGroup.categoryCount": "{count} فئة", + "tabs.stats.byGroup.categoryCount.few": "{count} فئات", + "tabs.stats.byGroup.categoryCount.many": "{count} فئة", + "tabs.stats.byGroup.categoryCount.one": "{count} فئة", + "tabs.stats.byGroup.categoryCount.two": "{count} فئتان", + "tabs.stats.byGroup.categoryCount.zero": "{count} فئات", + "tabs.stats.byGroup.percentOfTotal": "% من الإجمالي", + "tabs.stats.byGroup.totalEarned": "إجمالي الدخل", + "tabs.stats.byGroup.totalSpent": "إجمالي الإنفاق", + "tabs.stats.byGroup.view.chart": "مخطط", + "tabs.stats.byGroup.view.list": "قائمة", "tabs.stats.categories": "الفئات", "tabs.stats.categories.seeAll": "عرض جميع الفئات", "tabs.stats.categories.top": "أعلى المصروفات", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 12330f6f..d076dcab 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Выдаткі", "enum.FlowButtonType@income": "Даходы", "enum.FlowButtonType@transfer": "Перавод", + "enum.GroupSortMode@alphabetical": "Назва", + "enum.GroupSortMode@amount": "Сума", "enum.ImportCSVProgress@creatingAccounts": "Стварэнне рахункаў", "enum.ImportCSVProgress@creatingCategories": "Стварэнне катэгорый", "enum.ImportCSVProgress@creatingTransactions": "Стварэнне транзакцый", @@ -790,6 +792,19 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Паглядзіце агляд вашага месяца", "tabs.stats.analytics.wrapped.tileTitle": "Ваша рэзюмэ за {month}", "tabs.stats.analytics.wrapped.title": "{month}: падсумаванне", + "tabs.stats.byGroup.accountCount": "{count} рахункаў", + "tabs.stats.byGroup.accountCount.few": "{count} рахункі", + "tabs.stats.byGroup.accountCount.many": "{count} рахункаў", + "tabs.stats.byGroup.accountCount.one": "{count} рахунак", + "tabs.stats.byGroup.categoryCount": "{count} катэгорый", + "tabs.stats.byGroup.categoryCount.few": "{count} катэгорыі", + "tabs.stats.byGroup.categoryCount.many": "{count} катэгорый", + "tabs.stats.byGroup.categoryCount.one": "{count} катэгорыя", + "tabs.stats.byGroup.percentOfTotal": "% ад агульнага", + "tabs.stats.byGroup.totalEarned": "Усяго атрымана", + "tabs.stats.byGroup.totalSpent": "Усяго выдаткавана", + "tabs.stats.byGroup.view.chart": "Дыяграма", + "tabs.stats.byGroup.view.list": "Спіс", "tabs.stats.categories": "Катэгорыі", "tabs.stats.categories.seeAll": "Глядзець усе катэгорыі", "tabs.stats.categories.top": "Галоўныя выдаткі", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 25fe07cf..4310eab1 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Výdaj", "enum.FlowButtonType@income": "Příjem", "enum.FlowButtonType@transfer": "Převod", + "enum.GroupSortMode@alphabetical": "Název", + "enum.GroupSortMode@amount": "Částka", "enum.ImportCSVProgress@creatingAccounts": "Vytváření účtů", "enum.ImportCSVProgress@creatingCategories": "Vytváření kategorií", "enum.ImportCSVProgress@creatingTransactions": "Vytváření transakcí", @@ -789,6 +791,17 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Podívejte se na přehled měsíce", "tabs.stats.analytics.wrapped.tileTitle": "Váš {month} v kostce", "tabs.stats.analytics.wrapped.title": "{month}, shrnutí", + "tabs.stats.byGroup.accountCount": "{count} účtů", + "tabs.stats.byGroup.accountCount.few": "{count} účty", + "tabs.stats.byGroup.accountCount.one": "{count} účet", + "tabs.stats.byGroup.categoryCount": "{count} kategorií", + "tabs.stats.byGroup.categoryCount.few": "{count} kategorie", + "tabs.stats.byGroup.categoryCount.one": "{count} kategorie", + "tabs.stats.byGroup.percentOfTotal": "% z celku", + "tabs.stats.byGroup.totalEarned": "Celkem přijato", + "tabs.stats.byGroup.totalSpent": "Celkem utraceno", + "tabs.stats.byGroup.view.chart": "Graf", + "tabs.stats.byGroup.view.list": "Seznam", "tabs.stats.categories": "Kategorie", "tabs.stats.categories.seeAll": "Zobrazit všechny kategorie", "tabs.stats.categories.top": "Největší výdaje", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 8bd1995d..f50565f2 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Ausgabe", "enum.FlowButtonType@income": "Einnahme", "enum.FlowButtonType@transfer": "Umbuchung", + "enum.GroupSortMode@alphabetical": "Name", + "enum.GroupSortMode@amount": "Betrag", "enum.ImportCSVProgress@creatingAccounts": "Konten werden erstellt", "enum.ImportCSVProgress@creatingCategories": "Kategorien werden erstellt", "enum.ImportCSVProgress@creatingTransactions": "Buchungen werden erstellt", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Sehen Sie Ihren Monat im Rückblick.", "tabs.stats.analytics.wrapped.tileTitle": "Ihr {month}-Rückblick", "tabs.stats.analytics.wrapped.title": "{month}, Rückblick", + "tabs.stats.byGroup.accountCount": "{count} Konten", + "tabs.stats.byGroup.accountCount.one": "{count} Konto", + "tabs.stats.byGroup.categoryCount": "{count} Kategorien", + "tabs.stats.byGroup.categoryCount.one": "{count} Kategorie", + "tabs.stats.byGroup.percentOfTotal": "% der Summe", + "tabs.stats.byGroup.totalEarned": "Gesamt eingenommen", + "tabs.stats.byGroup.totalSpent": "Gesamt ausgegeben", + "tabs.stats.byGroup.view.chart": "Diagramm", + "tabs.stats.byGroup.view.list": "Liste", "tabs.stats.categories": "Kategorien", "tabs.stats.categories.seeAll": "Alle Kategorien anzeigen", "tabs.stats.categories.top": "Top-Ausgaben", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 7c8ff122..8b881c91 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Expense", "enum.FlowButtonType@income": "Income", "enum.FlowButtonType@transfer": "Transfer", + "enum.GroupSortMode@alphabetical": "Name", + "enum.GroupSortMode@amount": "Amount", "enum.ImportCSVProgress@creatingAccounts": "Creating accounts", "enum.ImportCSVProgress@creatingCategories": "Creating categories", "enum.ImportCSVProgress@creatingTransactions": "Creating transactions", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "See your month in review", "tabs.stats.analytics.wrapped.tileTitle": "Your {month}, wrapped", "tabs.stats.analytics.wrapped.title": "{month}, wrapped", + "tabs.stats.byGroup.accountCount": "{count} accounts", + "tabs.stats.byGroup.accountCount.one": "{count} account", + "tabs.stats.byGroup.categoryCount": "{count} categories", + "tabs.stats.byGroup.categoryCount.one": "{count} category", + "tabs.stats.byGroup.percentOfTotal": "% of total", + "tabs.stats.byGroup.totalEarned": "Total earned", + "tabs.stats.byGroup.totalSpent": "Total spent", + "tabs.stats.byGroup.view.chart": "Chart", + "tabs.stats.byGroup.view.list": "List", "tabs.stats.categories": "Categories", "tabs.stats.categories.seeAll": "See all categories", "tabs.stats.categories.top": "Top spending", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 8953bfd6..ff0bcab8 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Gasto", "enum.FlowButtonType@income": "Ingreso", "enum.FlowButtonType@transfer": "Transferencia", + "enum.GroupSortMode@alphabetical": "Nombre", + "enum.GroupSortMode@amount": "Importe", "enum.ImportCSVProgress@creatingAccounts": "Creando cuentas", "enum.ImportCSVProgress@creatingCategories": "Creando categorías", "enum.ImportCSVProgress@creatingTransactions": "Creando transacciones", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Ver tu mes en resumen", "tabs.stats.analytics.wrapped.tileTitle": "Tu {month}, en resumen", "tabs.stats.analytics.wrapped.title": "{month}, resumen", + "tabs.stats.byGroup.accountCount": "{count} cuentas", + "tabs.stats.byGroup.accountCount.one": "{count} cuenta", + "tabs.stats.byGroup.categoryCount": "{count} categorías", + "tabs.stats.byGroup.categoryCount.one": "{count} categoría", + "tabs.stats.byGroup.percentOfTotal": "% del total", + "tabs.stats.byGroup.totalEarned": "Total ingresado", + "tabs.stats.byGroup.totalSpent": "Total gastado", + "tabs.stats.byGroup.view.chart": "Gráfico", + "tabs.stats.byGroup.view.list": "Lista", "tabs.stats.categories": "Categorías", "tabs.stats.categories.seeAll": "Ver todas las categorías", "tabs.stats.categories.top": "Gastos principales", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 2204f9b6..655ae486 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "هزینه", "enum.FlowButtonType@income": "درآمد", "enum.FlowButtonType@transfer": "انتقال", + "enum.GroupSortMode@alphabetical": "نام", + "enum.GroupSortMode@amount": "مبلغ", "enum.ImportCSVProgress@creatingAccounts": "در حال ساخت حساب‌ها", "enum.ImportCSVProgress@creatingCategories": "در حال ساخت دسته‌بندی‌ها", "enum.ImportCSVProgress@creatingTransactions": "در حال ساخت تراکنش‌ها", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "مرور ماه خود را ببینید", "tabs.stats.analytics.wrapped.tileTitle": "خلاصهٔ {month} شما", "tabs.stats.analytics.wrapped.title": "{month}، خلاصه", + "tabs.stats.byGroup.accountCount": "{count} حساب", + "tabs.stats.byGroup.accountCount.one": "{count} حساب", + "tabs.stats.byGroup.categoryCount": "{count} دسته‌بندی", + "tabs.stats.byGroup.categoryCount.one": "{count} دسته‌بندی", + "tabs.stats.byGroup.percentOfTotal": "٪ از کل", + "tabs.stats.byGroup.totalEarned": "کل درآمد", + "tabs.stats.byGroup.totalSpent": "کل هزینه", + "tabs.stats.byGroup.view.chart": "نمودار", + "tabs.stats.byGroup.view.list": "فهرست", "tabs.stats.categories": "دسته‌بندی‌ها", "tabs.stats.categories.seeAll": "مشاهده همه دسته‌بندی‌ها", "tabs.stats.categories.top": "بیشترین هزینه", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 4a941c23..cf72e796 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Dépense", "enum.FlowButtonType@income": "Revenu", "enum.FlowButtonType@transfer": "Transfert", + "enum.GroupSortMode@alphabetical": "Nom", + "enum.GroupSortMode@amount": "Montant", "enum.ImportCSVProgress@creatingAccounts": "Création des comptes", "enum.ImportCSVProgress@creatingCategories": "Création des catégories", "enum.ImportCSVProgress@creatingTransactions": "Création des transactions", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Voir le résumé du mois", "tabs.stats.analytics.wrapped.tileTitle": "Votre récapitulatif de {month}", "tabs.stats.analytics.wrapped.title": "Récapitulatif de {month}", + "tabs.stats.byGroup.accountCount": "{count} comptes", + "tabs.stats.byGroup.accountCount.one": "{count} compte", + "tabs.stats.byGroup.categoryCount": "{count} catégories", + "tabs.stats.byGroup.categoryCount.one": "{count} catégorie", + "tabs.stats.byGroup.percentOfTotal": "% du total", + "tabs.stats.byGroup.totalEarned": "Total gagné", + "tabs.stats.byGroup.totalSpent": "Total dépensé", + "tabs.stats.byGroup.view.chart": "Graphique", + "tabs.stats.byGroup.view.list": "Liste", "tabs.stats.categories": "Catégories", "tabs.stats.categories.seeAll": "Voir toutes les catégories", "tabs.stats.categories.top": "Dépenses principales", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 1c210f49..bb4d9456 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Spesa", "enum.FlowButtonType@income": "Entrata", "enum.FlowButtonType@transfer": "Trasferimento", + "enum.GroupSortMode@alphabetical": "Nome", + "enum.GroupSortMode@amount": "Importo", "enum.ImportCSVProgress@creatingAccounts": "Creazione dei conti", "enum.ImportCSVProgress@creatingCategories": "Creazione delle categorie", "enum.ImportCSVProgress@creatingTransactions": "Creazione delle transazioni", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Guarda il riepilogo del mese", "tabs.stats.analytics.wrapped.tileTitle": "Il tuo {month}, in sintesi", "tabs.stats.analytics.wrapped.title": "{month}, riepilogo", + "tabs.stats.byGroup.accountCount": "{count} conti", + "tabs.stats.byGroup.accountCount.one": "{count} conto", + "tabs.stats.byGroup.categoryCount": "{count} categorie", + "tabs.stats.byGroup.categoryCount.one": "{count} categoria", + "tabs.stats.byGroup.percentOfTotal": "% del totale", + "tabs.stats.byGroup.totalEarned": "Totale entrate", + "tabs.stats.byGroup.totalSpent": "Totale spese", + "tabs.stats.byGroup.view.chart": "Grafico", + "tabs.stats.byGroup.view.list": "Elenco", "tabs.stats.categories": "Categorie", "tabs.stats.categories.seeAll": "Vedi tutte le categorie", "tabs.stats.categories.top": "Spese principali", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 585648f8..be099108 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Зарлага", "enum.FlowButtonType@income": "Орлого", "enum.FlowButtonType@transfer": "Шилжүүлэг", + "enum.GroupSortMode@alphabetical": "Нэр", + "enum.GroupSortMode@amount": "Дүн", "enum.ImportCSVProgress@creatingAccounts": "Дансуудыг үүсгэж байна", "enum.ImportCSVProgress@creatingCategories": "Ангиллуудыг үүсгэж байна", "enum.ImportCSVProgress@creatingTransactions": "Гүйлгээнүүдийг үүсгэж байна", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Сарынхаа тоймыг үзэх", "tabs.stats.analytics.wrapped.tileTitle": "Таны {month}-ын тойм", "tabs.stats.analytics.wrapped.title": "{month}-ын тойм", + "tabs.stats.byGroup.accountCount": "{count} данс", + "tabs.stats.byGroup.accountCount.one": "{count} данс", + "tabs.stats.byGroup.categoryCount": "{count} ангилал", + "tabs.stats.byGroup.categoryCount.one": "{count} ангилал", + "tabs.stats.byGroup.percentOfTotal": "Эзлэх хувь", + "tabs.stats.byGroup.totalEarned": "Нийт орлого", + "tabs.stats.byGroup.totalSpent": "Нийт зарлага", + "tabs.stats.byGroup.view.chart": "График", + "tabs.stats.byGroup.view.list": "Жагсаалт", "tabs.stats.categories": "Ангилал", "tabs.stats.categories.seeAll": "Бүх ангиллыг харах", "tabs.stats.categories.top": "Хамгийн зарлагатай", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index dd6483c8..1e87ef4e 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Wydatek", "enum.FlowButtonType@income": "Przychód", "enum.FlowButtonType@transfer": "Przelew", + "enum.GroupSortMode@alphabetical": "Nazwa", + "enum.GroupSortMode@amount": "Kwota", "enum.ImportCSVProgress@creatingAccounts": "Tworzenie kont", "enum.ImportCSVProgress@creatingCategories": "Tworzenie kategorii", "enum.ImportCSVProgress@creatingTransactions": "Tworzenie transakcji", @@ -790,6 +792,19 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Zobacz podsumowanie miesiąca", "tabs.stats.analytics.wrapped.tileTitle": "Twoje podsumowanie za {month}", "tabs.stats.analytics.wrapped.title": "{month} — podsumowanie", + "tabs.stats.byGroup.accountCount": "{count} kont", + "tabs.stats.byGroup.accountCount.few": "{count} konta", + "tabs.stats.byGroup.accountCount.many": "{count} kont", + "tabs.stats.byGroup.accountCount.one": "{count} konto", + "tabs.stats.byGroup.categoryCount": "{count} kategorii", + "tabs.stats.byGroup.categoryCount.few": "{count} kategorie", + "tabs.stats.byGroup.categoryCount.many": "{count} kategorii", + "tabs.stats.byGroup.categoryCount.one": "{count} kategoria", + "tabs.stats.byGroup.percentOfTotal": "% całości", + "tabs.stats.byGroup.totalEarned": "Łącznie zarobione", + "tabs.stats.byGroup.totalSpent": "Łącznie wydane", + "tabs.stats.byGroup.view.chart": "Wykres", + "tabs.stats.byGroup.view.list": "Lista", "tabs.stats.categories": "Kategorie", "tabs.stats.categories.seeAll": "Pokaż wszystkie kategorie", "tabs.stats.categories.top": "Największe wydatki", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 9f49d8d9..f011c718 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Расход", "enum.FlowButtonType@income": "Доход", "enum.FlowButtonType@transfer": "Перевод", + "enum.GroupSortMode@alphabetical": "Название", + "enum.GroupSortMode@amount": "Сумма", "enum.ImportCSVProgress@creatingAccounts": "Создание счетов", "enum.ImportCSVProgress@creatingCategories": "Создание категорий", "enum.ImportCSVProgress@creatingTransactions": "Создание транзакций", @@ -790,6 +792,19 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Посмотреть обзор месяца", "tabs.stats.analytics.wrapped.tileTitle": "Ваш {month}: итоги", "tabs.stats.analytics.wrapped.title": "{month}: итоги", + "tabs.stats.byGroup.accountCount": "{count} счетов", + "tabs.stats.byGroup.accountCount.few": "{count} счёта", + "tabs.stats.byGroup.accountCount.many": "{count} счетов", + "tabs.stats.byGroup.accountCount.one": "{count} счёт", + "tabs.stats.byGroup.categoryCount": "{count} категорий", + "tabs.stats.byGroup.categoryCount.few": "{count} категории", + "tabs.stats.byGroup.categoryCount.many": "{count} категорий", + "tabs.stats.byGroup.categoryCount.one": "{count} категория", + "tabs.stats.byGroup.percentOfTotal": "% от общего", + "tabs.stats.byGroup.totalEarned": "Всего получено", + "tabs.stats.byGroup.totalSpent": "Всего потрачено", + "tabs.stats.byGroup.view.chart": "Диаграмма", + "tabs.stats.byGroup.view.list": "Список", "tabs.stats.categories": "Категории", "tabs.stats.categories.seeAll": "Посмотреть все категории", "tabs.stats.categories.top": "Наибольшие расходы", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index e7949759..0ab4396c 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Gider", "enum.FlowButtonType@income": "Gelir", "enum.FlowButtonType@transfer": "Aktarmak", + "enum.GroupSortMode@alphabetical": "Ad", + "enum.GroupSortMode@amount": "Miktar", "enum.ImportCSVProgress@creatingAccounts": "Hesaplar oluşturuluyor", "enum.ImportCSVProgress@creatingCategories": "Kategoriler oluşturuluyor", "enum.ImportCSVProgress@creatingTransactions": "İşlemler oluşturuluyor", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Ayınızın özetini görün", "tabs.stats.analytics.wrapped.tileTitle": "{month} özetiniz", "tabs.stats.analytics.wrapped.title": "{month} özeti", + "tabs.stats.byGroup.accountCount": "{count} hesap", + "tabs.stats.byGroup.accountCount.one": "{count} hesap", + "tabs.stats.byGroup.categoryCount": "{count} kategori", + "tabs.stats.byGroup.categoryCount.one": "{count} kategori", + "tabs.stats.byGroup.percentOfTotal": "Toplam payı", + "tabs.stats.byGroup.totalEarned": "Toplam gelir", + "tabs.stats.byGroup.totalSpent": "Toplam harcama", + "tabs.stats.byGroup.view.chart": "Grafik", + "tabs.stats.byGroup.view.list": "Liste", "tabs.stats.categories": "Kategoriler", "tabs.stats.categories.seeAll": "Tüm kategorileri gör", "tabs.stats.categories.top": "En fazla harcama yapılan", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index f101ac1c..354e4c25 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "Витрата", "enum.FlowButtonType@income": "Дохід", "enum.FlowButtonType@transfer": "Переказ", + "enum.GroupSortMode@alphabetical": "Назва", + "enum.GroupSortMode@amount": "Сума", "enum.ImportCSVProgress@creatingAccounts": "Створення рахунків", "enum.ImportCSVProgress@creatingCategories": "Створення категорій", "enum.ImportCSVProgress@creatingTransactions": "Створення транзакцій", @@ -790,6 +792,19 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "Перегляньте підсумки місяця", "tabs.stats.analytics.wrapped.tileTitle": "Ваш підсумок за {month}", "tabs.stats.analytics.wrapped.title": "{month}: підсумок", + "tabs.stats.byGroup.accountCount": "{count} рахунків", + "tabs.stats.byGroup.accountCount.few": "{count} рахунки", + "tabs.stats.byGroup.accountCount.many": "{count} рахунків", + "tabs.stats.byGroup.accountCount.one": "{count} рахунок", + "tabs.stats.byGroup.categoryCount": "{count} категорій", + "tabs.stats.byGroup.categoryCount.few": "{count} категорії", + "tabs.stats.byGroup.categoryCount.many": "{count} категорій", + "tabs.stats.byGroup.categoryCount.one": "{count} категорія", + "tabs.stats.byGroup.percentOfTotal": "% від загального", + "tabs.stats.byGroup.totalEarned": "Усього отримано", + "tabs.stats.byGroup.totalSpent": "Усього витрачено", + "tabs.stats.byGroup.view.chart": "Діаграма", + "tabs.stats.byGroup.view.list": "Список", "tabs.stats.categories": "Категорії", "tabs.stats.categories.seeAll": "Переглянути всі категорії", "tabs.stats.categories.top": "Найбільші витрати", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index dc289d26..a1300ef7 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "支出", "enum.FlowButtonType@income": "收入", "enum.FlowButtonType@transfer": "转账", + "enum.GroupSortMode@alphabetical": "名称", + "enum.GroupSortMode@amount": "金额", "enum.ImportCSVProgress@creatingAccounts": "正在创建账户", "enum.ImportCSVProgress@creatingCategories": "正在创建分类", "enum.ImportCSVProgress@creatingTransactions": "正在创建交易", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "查看你的月度回顾", "tabs.stats.analytics.wrapped.tileTitle": "你的 {month} 回顾", "tabs.stats.analytics.wrapped.title": "{month} 回顾", + "tabs.stats.byGroup.accountCount": "{count} 个账户", + "tabs.stats.byGroup.accountCount.one": "{count} 个账户", + "tabs.stats.byGroup.categoryCount": "{count} 个分类", + "tabs.stats.byGroup.categoryCount.one": "{count} 个分类", + "tabs.stats.byGroup.percentOfTotal": "占比", + "tabs.stats.byGroup.totalEarned": "总收入", + "tabs.stats.byGroup.totalSpent": "总支出", + "tabs.stats.byGroup.view.chart": "图表", + "tabs.stats.byGroup.view.list": "列表", "tabs.stats.categories": "分类", "tabs.stats.categories.seeAll": "查看所有分类", "tabs.stats.categories.top": "最高支出", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 6c4d1413..70994eab 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -143,6 +143,8 @@ "enum.FlowButtonType@expense": "支出", "enum.FlowButtonType@income": "收入", "enum.FlowButtonType@transfer": "轉帳", + "enum.GroupSortMode@alphabetical": "名稱", + "enum.GroupSortMode@amount": "金額", "enum.ImportCSVProgress@creatingAccounts": "正在建立帳戶", "enum.ImportCSVProgress@creatingCategories": "正在建立分類", "enum.ImportCSVProgress@creatingTransactions": "正在建立交易", @@ -788,6 +790,15 @@ "tabs.stats.analytics.wrapped.tileTeaserEmpty": "查看你的月度回顧", "tabs.stats.analytics.wrapped.tileTitle": "你的 {month} 回顧", "tabs.stats.analytics.wrapped.title": "{month} 回顧", + "tabs.stats.byGroup.accountCount": "{count} 個帳戶", + "tabs.stats.byGroup.accountCount.one": "{count} 個帳戶", + "tabs.stats.byGroup.categoryCount": "{count} 個分類", + "tabs.stats.byGroup.categoryCount.one": "{count} 個分類", + "tabs.stats.byGroup.percentOfTotal": "佔比", + "tabs.stats.byGroup.totalEarned": "總收入", + "tabs.stats.byGroup.totalSpent": "總支出", + "tabs.stats.byGroup.view.chart": "圖表", + "tabs.stats.byGroup.view.list": "列表", "tabs.stats.categories": "分類", "tabs.stats.categories.seeAll": "查看所有分類", "tabs.stats.categories.top": "最高支出", diff --git a/lib/data/chart_data.dart b/lib/data/chart_data.dart index d77aeb75..e8dc1f47 100644 --- a/lib/data/chart_data.dart +++ b/lib/data/chart_data.dart @@ -6,6 +6,7 @@ class ChartData implements Comparable> { final Money money; final String currency; final T? associatedData; + final int transactionCount; double get displayTotal => money.amount.abs(); @@ -14,6 +15,7 @@ class ChartData implements Comparable> { required this.money, required this.currency, required this.associatedData, + this.transactionCount = 0, }); @override diff --git a/lib/data/group_sort_mode.dart b/lib/data/group_sort_mode.dart new file mode 100644 index 00000000..6eaede69 --- /dev/null +++ b/lib/data/group_sort_mode.dart @@ -0,0 +1,15 @@ +import "package:flow/l10n/named_enum.dart"; + +enum GroupSortMode with LocalizedEnum { + amount, + alphabetical; + + GroupSortMode get next => + GroupSortMode.values[(index + 1) % GroupSortMode.values.length]; + + @override + String get localizationEnumName => "GroupSortMode"; + + @override + String get localizationEnumValue => name; +} diff --git a/lib/prefs/local_preferences.dart b/lib/prefs/local_preferences.dart index f7a25e50..bd8d4f6f 100644 --- a/lib/prefs/local_preferences.dart +++ b/lib/prefs/local_preferences.dart @@ -61,6 +61,9 @@ class LocalPreferences { /// it flips to true on the first tap, after which the badge never shows again. late final BoolSettingsEntry openedInsightsIndex; + /// Whether the by-category/account stats page opens in chart mode + late final BoolSettingsEntry statsByGroupUseChart; + /// Number of notifications issued by the app /// /// Used to prevent id collisions @@ -153,6 +156,12 @@ class LocalPreferences { initialValue: false, ); + statsByGroupUseChart = BoolSettingsEntry( + key: "statsByGroupUseChart", + preferences: _prefs, + initialValue: false, + ); + lastRequestedAppStoreReview = DateTimeSettingsEntry( key: "lastRequestedAppStoreReview", preferences: _prefs, diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart index 821fb142..8618ed6c 100644 --- a/lib/routes/stats/stats_by_group_page.dart +++ b/lib/routes/stats/stats_by_group_page.dart @@ -1,6 +1,9 @@ +import "dart:async"; + import "package:flow/data/chart_data.dart"; import "package:flow/data/exchange_rates.dart"; import "package:flow/data/flow_analytics.dart"; +import "package:flow/data/group_sort_mode.dart"; import "package:flow/data/money.dart"; import "package:flow/data/multi_currency_flow.dart"; import "package:flow/entity/transaction.dart"; @@ -42,7 +45,9 @@ class StatsByGroupPageState extends State FlowAnalytics? analytics; bool busy = false; - bool useListView = false; + bool useChart = LocalPreferences().statsByGroupUseChart.get(); + + GroupSortMode sortMode = .amount; @override void initState() { @@ -62,12 +67,29 @@ class StatsByGroupPageState extends State widget.byCategory ? "categories".t(context) : "accounts".t(context), ), actions: [ - IconButton( - onPressed: () => setState(() => useListView = !useListView), - icon: Icon( - useListView - ? Symbols.pie_chart_rounded - : Symbols.list_alt_rounded, + Padding( + padding: const EdgeInsetsDirectional.only(end: 8.0), + child: SegmentedButton( + selected: {useChart}, + onSelectionChanged: (selection) => + updateUseChart(selection.first), + showSelectedIcon: false, + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + segments: [ + ButtonSegment( + value: false, + icon: const Icon(Symbols.view_list_rounded), + label: Text("tabs.stats.byGroup.view.list".t(context)), + ), + ButtonSegment( + value: true, + icon: const Icon(Symbols.donut_large_rounded), + label: Text("tabs.stats.byGroup.view.chart".t(context)), + ), + ], ), ), ], @@ -128,27 +150,35 @@ class StatsByGroupPageState extends State child: TabBarView( controller: _tabController, children: [ - useListView - ? GroupListView( + useChart + ? PieGraphView( data: expenses, changeMode: changeMode, range: range, ) - : PieGraphView( + : GroupListView( data: expenses, changeMode: changeMode, range: range, + type: TransactionType.expense, + byCategory: widget.byCategory, + sortMode: sortMode, + onSortModeChanged: updateSortMode, ), - useListView - ? GroupListView( + useChart + ? PieGraphView( data: incomes, changeMode: changeMode, range: range, ) - : PieGraphView( + : GroupListView( data: incomes, changeMode: changeMode, range: range, + type: TransactionType.income, + byCategory: widget.byCategory, + sortMode: sortMode, + onSortModeChanged: updateSortMode, ), ], ), @@ -161,6 +191,20 @@ class StatsByGroupPageState extends State ); } + void updateUseChart(bool newValue) { + setState(() { + useChart = newValue; + }); + + unawaited(LocalPreferences().statsByGroupUseChart.set(newValue)); + } + + void updateSortMode(GroupSortMode newMode) { + setState(() { + sortMode = newMode; + }); + } + void updateRange(TimeRange newRange) { setState(() { range = newRange; @@ -212,6 +256,7 @@ class StatsByGroupPageState extends State final String primaryCurrency = UserPreferencesService().primaryCurrency; final Map cache = {}; + final Map counts = {}; final List>> filtered = raw.entries .where((entry) { @@ -219,9 +264,11 @@ class StatsByGroupPageState extends State if (type == TransactionType.expense) { cache[entry.key] = mergedFlow.totalExpense; + counts[entry.key] = entry.value.expenseCount; return mergedFlow.totalExpense.amount < 0.0; } else { cache[entry.key] = mergedFlow.totalIncome; + counts[entry.key] = entry.value.incomeCount; return mergedFlow.totalIncome.amount > 0.0; } }) @@ -241,6 +288,7 @@ class StatsByGroupPageState extends State money: cache[entry.key]!, currency: primaryCurrency, associatedData: entry.value.associatedData, + transactionCount: counts[entry.key] ?? 0, ), ), ), diff --git a/lib/utils/extensions/chart_data.dart b/lib/utils/extensions/chart_data.dart new file mode 100644 index 00000000..29b42bdf --- /dev/null +++ b/lib/utils/extensions/chart_data.dart @@ -0,0 +1,49 @@ +import "package:flow/data/chart_data.dart"; +import "package:flow/data/flow_icon.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/theme/flow_color_scheme.dart"; +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +extension ChartDataGroupHelpers on ChartData { + String resolveName(BuildContext context) => switch (associatedData) { + Category category => category.name, + Account account => account.name, + _ => "category.none".t(context), + }; + + FlowIconData get icon => switch (associatedData) { + Category category => category.icon, + Account account => account.icon, + _ => FlowIconData.icon(Symbols.category_rounded), + }; + + FlowColorScheme? get colorScheme => switch (associatedData) { + Category category => category.colorScheme, + Account account => account.colorScheme, + _ => null, + }; +} + +extension ChartDataMapHelpers on Map { + double get displayTotal => + values.fold(0.0, (total, chartData) => total + chartData.displayTotal); + + /// Uses each group's own color, falling back to [ThemeAccessor.chartAccents] + /// in map order, so the list and the chart agree. + Map resolveColors(BuildContext context) { + final List palette = context.chartAccents; + int fallbackIndex = 0; + + return map( + (key, chartData) => MapEntry( + key, + chartData.colorScheme?.primary ?? + palette[fallbackIndex++ % palette.length], + ), + ); + } +} diff --git a/lib/widgets/home/stats/group_list_tile.dart b/lib/widgets/home/stats/group_list_tile.dart index b9962498..44186cec 100644 --- a/lib/widgets/home/stats/group_list_tile.dart +++ b/lib/widgets/home/stats/group_list_tile.dart @@ -1,96 +1,94 @@ import "package:flow/data/chart_data.dart"; -import "package:flow/data/flow_icon.dart"; -import "package:flow/entity/account.dart"; -import "package:flow/entity/category.dart"; -import "package:flow/l10n/extensions.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/chart_data.dart"; import "package:flow/widgets/general/flow_icon.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flutter/material.dart"; -import "package:material_symbols_icons_flow/symbols.dart"; class GroupListTile extends StatelessWidget { final ChartData chartData; final double percent; + + /// Bar length, relative to the largest group (0.0 to 1.0) + final double barFactor; + final Color color; + final VoidCallback? onTap; const GroupListTile({ super.key, required this.chartData, required this.percent, + required this.barFactor, + required this.color, this.onTap, }); @override Widget build(BuildContext context) { - final String name = _resolveName(context, chartData.associatedData); - final FlowIconData iconData = _resolveIcon(chartData.associatedData); - final FlowColorScheme? colorScheme = _resolveColorScheme( - chartData.associatedData, - ); + final FlowColorScheme? colorScheme = chartData.colorScheme; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - child: Row( - spacing: 12.0, - children: [ - FlowIcon( - iconData, - plated: true, - color: colorScheme?.primary, - plateColor: colorScheme?.secondary, - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: .start, + return MergeSemantics( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + spacing: 12.0, + children: [ + FlowIcon( + chartData.icon, + plated: true, + color: colorScheme?.primary, + plateColor: colorScheme?.secondary, + ), + Expanded( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + chartData.resolveName(context), + style: context.textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6.0), + ClipRRect( + borderRadius: .all(Radius.circular(3.0)), + child: LinearProgressIndicator( + value: barFactor.clamp(0.01, 1.0), + minHeight: 5.0, + backgroundColor: context.colorScheme.onSurface + .withAlpha(0x1a), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ), + Column( + mainAxisSize: .min, + crossAxisAlignment: .end, children: [ + MoneyText( + chartData.money, + displayAbsoluteAmount: true, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), Text( - name, - style: context.textTheme.bodyLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, + percent < 0.1 ? "<0.1%" : "${percent.toStringAsFixed(1)}%", + style: context.textTheme.bodySmall?.semi(context), ), - Text("${percent.toStringAsFixed(1)}%"), ], ), - ), - MoneyText( - chartData.money, - displayAbsoluteAmount: true, - style: context.textTheme.bodyLarge?.copyWith( - color: chartData.money.isNegative - ? context.flowColors.expense - : context.flowColors.income, - fontWeight: FontWeight.bold, - ), - ), - ], + ], + ), ), ), ); } - - static String _resolveName(BuildContext context, Object? entity) => - switch (entity) { - Category category => category.name, - Account account => account.name, - _ => "category.none".t(context), - }; - - static FlowIconData _resolveIcon(Object? entity) => switch (entity) { - Category category => category.icon, - Account account => account.icon, - _ => FlowIconData.icon(Symbols.category_rounded), - }; - - static FlowColorScheme? _resolveColorScheme(Object? entity) => - switch (entity) { - Category category => category.colorScheme, - Account account => account.colorScheme, - _ => null, - }; } diff --git a/lib/widgets/home/stats/group_list_view.dart b/lib/widgets/home/stats/group_list_view.dart index 2664356a..b1316d44 100644 --- a/lib/widgets/home/stats/group_list_view.dart +++ b/lib/widgets/home/stats/group_list_view.dart @@ -1,22 +1,39 @@ import "package:flow/data/chart_data.dart"; -import "package:flow/data/money.dart"; +import "package:flow/data/group_sort_mode.dart"; import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/l10n/named_enum.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/chart_data.dart"; import "package:flow/widgets/home/stats/group_list_tile.dart"; +import "package:flow/widgets/home/stats/group_summary_card.dart"; import "package:flow/widgets/home/stats/no_data.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class GroupListView extends StatelessWidget { + /// Expected to be sorted by amount, descending final Map data; final TimeRange range; + final TransactionType type; + final bool byCategory; final void Function() changeMode; + final GroupSortMode sortMode; + final ValueChanged onSortModeChanged; + const GroupListView({ super.key, required this.data, required this.range, + required this.type, + required this.byCategory, required this.changeMode, + required this.sortMode, + required this.onSortModeChanged, }); @override @@ -25,28 +42,70 @@ class GroupListView extends StatelessWidget { return NoData(selectTimeRange: changeMode); } - final Money totalAmount = data.values.fold( - Money(0, data.values.first.money.currency), - (previousValue, element) => previousValue + element.money, - ); + final Map colors = data.resolveColors(context); + final double total = data.displayTotal; + final double maxAmount = data.values.first.displayTotal; + + final List sorted = switch (sortMode) { + .amount => data.values.toList(), + .alphabetical => + data.values.toList()..sort( + (a, b) => a + .resolveName(context) + .toLowerCase() + .compareTo(b.resolveName(context).toLowerCase()), + ), + }; + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: GroupSummaryCard( + data: data, + colors: colors, + type: type, + byCategory: byCategory, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: .spaceBetween, + children: [ + ActionChip( + avatar: const Icon(Symbols.swap_vert_rounded), + label: Text(sortMode.localizedNameContext(context)), + onPressed: () => onSortModeChanged(sortMode.next), + ), + Text( + "tabs.stats.byGroup.percentOfTotal".t(context), + style: context.textTheme.bodySmall?.semi(context), + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.only(bottom: 96.0), + sliver: SliverList.builder( + itemCount: sorted.length, + itemBuilder: (context, index) { + final ChartData chartData = sorted[index]; - return ListView.builder( - padding: const EdgeInsets.only(bottom: 96.0, top: 8.0), - itemCount: data.length, - itemBuilder: (context, index) { - final entry = data.entries.elementAt(index); - final chartData = entry.value; - - final double percent = totalAmount.amount != 0 - ? (chartData.displayTotal / totalAmount.amount.abs()) * 100 - : 0; - - return GroupListTile( - chartData: chartData, - percent: percent, - onTap: () => _onTap(context, chartData.associatedData), - ); - }, + return GroupListTile( + chartData: chartData, + percent: total > 0 ? chartData.displayTotal / total * 100 : 0, + barFactor: maxAmount > 0 + ? chartData.displayTotal / maxAmount + : 0, + color: colors[chartData.key]!, + onTap: () => _onTap(context, chartData.associatedData), + ); + }, + ), + ), + ], ); } diff --git a/lib/widgets/home/stats/group_share_bar.dart b/lib/widgets/home/stats/group_share_bar.dart new file mode 100644 index 00000000..09fc3e79 --- /dev/null +++ b/lib/widgets/home/stats/group_share_bar.dart @@ -0,0 +1,64 @@ +import "package:flow/data/chart_data.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/chart_data.dart"; +import "package:flutter/material.dart"; + +/// Stacked 100% bar of each group's share of the total. +class GroupShareBar extends StatelessWidget { + /// Shares below this are merged into a single "other" segment + static const double otherThreshold = 0.02; + + final Map data; + final Map colors; + + final double height; + + const GroupShareBar({ + super.key, + required this.data, + required this.colors, + this.height = 10.0, + }); + + @override + Widget build(BuildContext context) { + final double total = data.displayTotal; + + if (total <= 0) return SizedBox(height: height); + + final List<(double, Color)> segments = []; + double other = 0.0; + + for (final MapEntry(:key, :value) in data.entries) { + if (value.displayTotal / total < otherThreshold) { + other += value.displayTotal; + } else { + segments.add((value.displayTotal, colors[key]!)); + } + } + + if (other > 0) { + segments.add((other, context.flowColors.semi.withAlpha(0x80))); + } + + return ExcludeSemantics( + child: ClipRRect( + borderRadius: .all(Radius.circular(height / 2)), + child: SizedBox( + height: height, + child: Row( + spacing: 2.0, + children: segments + .map( + (segment) => Expanded( + flex: (segment.$1 / total * 1000).round().clamp(1, 1000), + child: ColoredBox(color: segment.$2), + ), + ) + .toList(), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/home/stats/group_summary_card.dart b/lib/widgets/home/stats/group_summary_card.dart new file mode 100644 index 00000000..b24d82b0 --- /dev/null +++ b/lib/widgets/home/stats/group_summary_card.dart @@ -0,0 +1,69 @@ +import "package:flow/data/chart_data.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/chart_data.dart"; +import "package:flow/widgets/general/money_text.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/home/stats/group_share_bar.dart"; +import "package:flutter/material.dart"; + +class GroupSummaryCard extends StatelessWidget { + final Map data; + final Map colors; + final TransactionType type; + final bool byCategory; + + const GroupSummaryCard({ + super.key, + required this.data, + required this.colors, + required this.type, + required this.byCategory, + }); + + @override + Widget build(BuildContext context) { + final String currency = data.values.first.money.currency; + final int transactionCount = data.values.fold( + 0, + (count, chartData) => count + chartData.transactionCount, + ); + + final String groupCount = + (byCategory + ? "tabs.stats.byGroup.categoryCount" + : "tabs.stats.byGroup.accountCount") + .t(context, data.length); + + return Surface( + margin: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0), + builder: (context) => Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + (type == TransactionType.income + ? "tabs.stats.byGroup.totalEarned" + : "tabs.stats.byGroup.totalSpent") + .t(context), + style: context.textTheme.labelMedium, + ), + MoneyText( + Money(data.displayTotal, currency), + style: context.textTheme.headlineSmall, + ), + Text( + "$groupCount · ${"transactions.count".t(context, transactionCount)}", + style: context.textTheme.bodySmall, + ), + const SizedBox(height: 12.0), + GroupShareBar(data: data, colors: colors), + ], + ), + ), + ); + } +} From 28f80d56070c54b92f8bd3c106243738c3c51d73 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 22:51:21 +0800 Subject: [PATCH 17/57] stats: pie uses category colors, shows total, re-tap opens category --- lib/widgets/home/stats/group_pie_chart.dart | 96 ++++++++++----------- 1 file changed, 44 insertions(+), 52 deletions(-) diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index 80396c67..3c5e726a 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -8,12 +8,11 @@ import "package:flow/data/money.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/category.dart"; import "package:flow/l10n/extensions.dart"; -import "package:flow/main.dart"; -import "package:flow/theme/primary_colors.dart"; import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/chart_data.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/home/stats/pie_percent_badge.dart"; -import "package:flutter/material.dart" hide Flow; +import "package:flutter/material.dart"; class GroupPieChart extends StatefulWidget { final EdgeInsets chartPadding; @@ -57,6 +56,9 @@ class _GroupPieChartState extends State> { String? selectedKey; + /// Selection before the current touch, so only a re-tap opens a slice + String? _keyAtTouchStart; + @override void initState() { super.initState(); @@ -77,17 +79,11 @@ class _GroupPieChartState extends State> { ? null : data[selectedKey!]; - final Money? selectedSectionTotal = selectedSection?.money; + final Map colors = data.resolveColors(context); return Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 16.0), - Text( - "tabs.stats.chart.total".t(context), - style: context.textTheme.labelMedium, - ), - MoneyText(totalAmount, style: context.textTheme.headlineMedium), Padding( padding: widget.chartPadding, child: ConstrainedBox( @@ -111,33 +107,7 @@ class _GroupPieChartState extends State> { children: [ PieChart( PieChartData( - pieTouchData: PieTouchData( - touchCallback: (event, response) { - if (!event.isInterestedForInteractions || - response == null || - response.touchedSection == null) { - return; - } - - final int index = - response.touchedSection!.touchedSectionIndex; - - if (index > -1) { - final String newSelectedKey = data.entries - .elementAt(index) - .key; - - // if (!usingMouse && - // newSelectedKey == selectedKey) { - // widget.onReselect?.call(newSelectedKey); - // } - - setState(() { - selectedKey = newSelectedKey; - }); - } - }, - ), + pieTouchData: PieTouchData(touchCallback: _onTouch), sectionsSpace: 1.0, centerSpaceRadius: centerHoleDiameter / 2, startDegreeOffset: -90.0, @@ -146,7 +116,7 @@ class _GroupPieChartState extends State> { (e) => sectionData( data[e.$2.key]!, selected: e.$2.key == selectedKey, - index: e.$1, + color: colors[e.$2.key]!, radius: radius, ), ) @@ -165,15 +135,17 @@ class _GroupPieChartState extends State> { mainAxisSize: MainAxisSize.min, children: [ Text( - resolveName( - selectedSection?.associatedData, - ), + selectedSection == null + ? "tabs.stats.chart.total".t(context) + : resolveName( + selectedSection.associatedData, + ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), MoneyText( - selectedSectionTotal, + selectedSection?.money ?? totalAmount, displayAbsoluteAmount: true, textAlign: TextAlign.center, style: context.textTheme.headlineSmall, @@ -195,20 +167,40 @@ class _GroupPieChartState extends State> { ); } + void _onTouch(FlTouchEvent event, PieTouchResponse? response) { + final int index = response?.touchedSection?.touchedSectionIndex ?? -1; + final String? key = index > -1 && index < data.length + ? data.keys.elementAt(index) + : null; + + // One tap fires both pan down and tap down, so check reselect on tap up + if (event is FlPanDownEvent) { + _keyAtTouchStart = selectedKey; + } + + if (event is FlTapUpEvent) { + if (key != null && key == _keyAtTouchStart) { + widget.onReselect?.call(key); + } + return; + } + + if (!event.isInterestedForInteractions || key == null) return; + + setState(() { + selectedKey = key; + }); + } + PieChartSectionData sectionData( ChartData data, { required double radius, + required Color color, bool selected = false, - int index = 0, }) { - final bool usingDarkTheme = Flow.of(context).useDarkTheme; - - final Color color = (usingDarkTheme - ? accentColors - : primaryColors)[index % primaryColors.length]; - final Color backgroundColor = (usingDarkTheme - ? primaryColors - : accentColors)[index % primaryColors.length]; + final Color backgroundColor = + data.colorScheme?.secondary ?? + Color.alphaBlend(color.withAlpha(0x40), context.colorScheme.surface); return PieChartSectionData( color: color, @@ -221,7 +213,7 @@ class _GroupPieChartState extends State> { data.associatedData, color: color, backgroundColor: backgroundColor, - percent: data.displayTotal / totalAmount.amount, + percent: data.displayTotal / totalAmount.amount.abs(), ) : null, badgePositionPercentageOffset: 0.8, From 627ac4b14c336ff98e899dc750fa91aab57c8d3a Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 25 Sep 2026 23:12:52 +0800 Subject: [PATCH 18/57] insights: local insight engine --- lib/data/insights/insight.dart | 306 ++++++++++ lib/data/insights/insight_adapter.dart | 104 ++++ lib/data/insights/insight_engine.dart | 708 ++++++++++++++++++++++ lib/data/insights/insight_math.dart | 92 +++ lib/data/insights/insight_request.dart | 138 +++++ lib/data/insights/insight_thresholds.dart | 98 +++ lib/data/insights/recurring_series.dart | 424 +++++++++++++ lib/services/insights.dart | 78 +++ 8 files changed, 1948 insertions(+) create mode 100644 lib/data/insights/insight.dart create mode 100644 lib/data/insights/insight_adapter.dart create mode 100644 lib/data/insights/insight_engine.dart create mode 100644 lib/data/insights/insight_math.dart create mode 100644 lib/data/insights/insight_request.dart create mode 100644 lib/data/insights/insight_thresholds.dart create mode 100644 lib/data/insights/recurring_series.dart create mode 100644 lib/services/insights.dart diff --git a/lib/data/insights/insight.dart b/lib/data/insights/insight.dart new file mode 100644 index 00000000..c8f5e337 --- /dev/null +++ b/lib/data/insights/insight.dart @@ -0,0 +1,306 @@ +import "package:flow/data/insights/recurring_series.dart"; + +export "package:flow/data/insights/recurring_series.dart"; + +/// One per "don't show this kind" switch. +enum InsightType { + monthPace, + categorySpike, + categoryDrop, + newCategory, + recurringCharge, + priceChange, +} + +enum InsightDirection { above, below } + +/// A month's value in an evidence chart. +class InsightMonthValue { + /// First day of the month. + final DateTime month; + + final double amount; + + /// False for months skipped as logging gaps (too few entries). + final bool counted; + + const InsightMonthValue({ + required this.month, + required this.amount, + required this.counted, + }); +} + +/// The single transaction that explains most of a change. +class InsightAttribution { + final String transactionUuid; + final String? title; + final String? categoryUuid; + final DateTime date; + + /// Absolute amount, in the primary currency. + final double amount; + + const InsightAttribution({ + required this.transactionUuid, + required this.title, + required this.categoryUuid, + required this.date, + required this.amount, + }); +} + +/// How much one category moved a month comparison. +class InsightCategoryDelta { + /// Null for uncategorized. + final String? categoryUuid; + final double current; + final double usual; + + const InsightCategoryDelta({ + required this.categoryUuid, + required this.current, + required this.usual, + }); + + double get delta => current - usual; +} + +/// All amounts are absolute, in the primary currency. +sealed class Insight { + /// Money at stake relative to the usual month, used for ranking. + final double score; + + const Insight({required this.score}); + + InsightType get type; + + /// What this insight is about, within its [type]. + String get subject; + + /// Stable identity for cooldowns and dismissals. + String get key => "${type.name}:$subject"; + + /// Money at stake. Never negative. + double get stake; + + /// The transactions behind the insight, most relevant first. + List get transactionUuids; +} + +/// The month as a whole is running above or below the usual. For an +/// in-progress month, both sides are measured through [throughDay]. +final class MonthPaceInsight extends Insight { + final InsightDirection direction; + final double current; + final double usual; + + /// Null for a complete month. + final int? throughDay; + + /// Baseline months, oldest first, measured the same way as [current]. + final List history; + + /// Only for [InsightDirection.above]. + final InsightAttribution? attribution; + + /// Categories that moved in [direction], biggest first. + final List drivers; + + @override + final List transactionUuids; + + const MonthPaceInsight({ + required super.score, + required this.direction, + required this.current, + required this.usual, + required this.throughDay, + required this.history, + required this.attribution, + required this.drivers, + required this.transactionUuids, + }); + + @override + InsightType get type => .monthPace; + + @override + String get subject => direction.name; + + @override + double get stake => (current - usual).abs(); + + /// e.g. 1.27 for "27% above". + double get ratio => current / usual; + + /// [current] without [attribution], if any. + double? get currentWithoutAttribution => + attribution == null ? null : current - attribution!.amount; + + double? get ratioWithoutAttribution => + attribution == null ? null : currentWithoutAttribution! / usual; + + /// The first driver when it explains at least half of the change. + InsightCategoryDelta? get dominantDriver { + final InsightCategoryDelta? first = drivers.firstOrNull; + if (first == null) return null; + + return first.delta.abs() * 2 >= stake ? first : null; + } +} + +/// A category is well above its usual month. [current] may be a month in +/// progress; [usual] is always a complete month. +final class CategorySpikeInsight extends Insight { + final String categoryUuid; + final double current; + final double usual; + final int entryCount; + + /// Complete baseline months, oldest first. + final List history; + + final InsightAttribution? attribution; + + @override + final List transactionUuids; + + const CategorySpikeInsight({ + required super.score, + required this.categoryUuid, + required this.current, + required this.usual, + required this.entryCount, + required this.history, + required this.attribution, + required this.transactionUuids, + }); + + @override + InsightType get type => .categorySpike; + + @override + String get subject => categoryUuid; + + @override + double get stake => current - usual; + + double get ratio => current / usual; +} + +/// A regular category is well below its usual. For an in-progress month, +/// both sides are measured through [throughDay]. +final class CategoryDropInsight extends Insight { + final String categoryUuid; + final double current; + final double usual; + final int? throughDay; + final List history; + + @override + final List transactionUuids; + + const CategoryDropInsight({ + required super.score, + required this.categoryUuid, + required this.current, + required this.usual, + required this.throughDay, + required this.history, + required this.transactionUuids, + }); + + @override + InsightType get type => .categoryDrop; + + @override + String get subject => categoryUuid; + + @override + double get stake => usual - current; + + double get ratio => current / usual; +} + +/// Spend in a category that had none in the baseline months. +final class NewCategoryInsight extends Insight { + final String categoryUuid; + final double current; + final int entryCount; + + @override + final List transactionUuids; + + const NewCategoryInsight({ + required super.score, + required this.categoryUuid, + required this.current, + required this.entryCount, + required this.transactionUuids, + }); + + @override + InsightType get type => .newCategory; + + @override + String get subject => categoryUuid; + + @override + double get stake => current; +} + +/// A charge that repeats but isn't tracked as recurring in Flow yet. +final class RecurringChargeInsight extends Insight { + final RecurringSeries series; + + const RecurringChargeInsight({required super.score, required this.series}); + + @override + InsightType get type => .recurringCharge; + + @override + String get subject => series.key; + + @override + double get stake => series.annualCost; + + @override + List get transactionUuids => series.occurrences.reversed + .map((occurrence) => occurrence.transactionUuid) + .toList(); +} + +/// The latest charge of a recurring series changed price. +final class PriceChangeInsight extends Insight { + final RecurringSeries series; + final double previousAmount; + final double currentAmount; + + const PriceChangeInsight({ + required super.score, + required this.series, + required this.previousAmount, + required this.currentAmount, + }); + + @override + InsightType get type => .priceChange; + + @override + String get subject => series.key; + + /// Yearly difference. + @override + double get stake => + (currentAmount - previousAmount).abs() * series.cadence.perYear; + + double get ratio => currentAmount / previousAmount; + + DateTime get changedOn => series.occurrences.last.date; + + @override + List get transactionUuids => series.occurrences.reversed + .map((occurrence) => occurrence.transactionUuid) + .toList(); +} diff --git a/lib/data/insights/insight_adapter.dart b/lib/data/insights/insight_adapter.dart new file mode 100644 index 00000000..4c4caffc --- /dev/null +++ b/lib/data/insights/insight_adapter.dart @@ -0,0 +1,104 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/insights/insight_request.dart"; +import "package:flow/data/money.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/utils/extensions/money.dart"; + +/// Maps Flow transactions to [InsightTransaction]s in [primaryCurrency]. +/// +/// Deleted, pending and transfer transactions are left out. Ones that can't +/// be converted are skipped and flagged with `hasMissingRates`. +({List transactions, bool hasMissingRates}) +insightTransactionsOf( + Iterable transactions, { + required String primaryCurrency, + required ExchangeRates? rates, +}) { + final List result = []; + bool hasMissingRates = false; + + for (final Transaction transaction in transactions) { + if (transaction.isDeleted == true || + transaction.isPending == true || + transaction.isTransfer) { + continue; + } + + final double? amount = _convert( + transaction.amount, + transaction.currency, + primaryCurrency, + rates, + ); + + if (amount == null) { + hasMissingRates = true; + continue; + } + + result.add( + InsightTransaction( + uuid: transaction.uuid, + date: transaction.transactionDate.toLocal(), + amount: amount, + title: transaction.title, + categoryUuid: transaction.categoryUuid, + accountUuid: transaction.accountUuid, + recurringUuid: transaction.extensions.recurring?.uuid, + ), + ); + } + + return (transactions: result, hasMissingRates: hasMissingRates); +} + +/// Active, non-transfer recurring transactions as templates. Ones that can't +/// be read or converted are skipped. +List insightTemplatesOf( + Iterable recurringTransactions, { + required String primaryCurrency, + required ExchangeRates? rates, +}) { + final List result = []; + + for (final RecurringTransaction recurring in recurringTransactions) { + if (recurring.disabled || recurring.transferToAccountUuid != null) { + continue; + } + + final Transaction template; + + try { + template = recurring.template; + } catch (_) { + continue; + } + + final double? amount = _convert( + template.amount, + template.currency, + primaryCurrency, + rates, + ); + + if (amount == null) continue; + + result.add(InsightRecurringTemplate(title: template.title, amount: amount)); + } + + return result; +} + +double? _convert( + double amount, + String currency, + String primaryCurrency, + ExchangeRates? rates, +) { + try { + return Money(amount, currency).tryConvertAmount(primaryCurrency, rates); + } catch (_) { + return null; + } +} diff --git a/lib/data/insights/insight_engine.dart b/lib/data/insights/insight_engine.dart new file mode 100644 index 00000000..48e651d9 --- /dev/null +++ b/lib/data/insights/insight_engine.dart @@ -0,0 +1,708 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight.dart"; +import "package:flow/data/insights/insight_math.dart"; +import "package:flow/data/insights/insight_request.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; + +export "package:flow/data/insights/insight.dart"; +export "package:flow/data/insights/insight_request.dart"; + +/// Entry point for `compute()`. +InsightReport computeInsights(InsightRequest request) => + InsightEngine(request).run(); + +class _Expense { + final InsightTransaction source; + final double amount; + + /// Month index this expense is compared in. Differs from the calendar + /// month only for monthly charges paid a few days early or late. + int bucket; + + /// Set for members of a monthly series. + RecurringSeries? monthlySeries; + + /// A yearly charge that happened before, so not unusual. + bool isYearlyRepeat = false; + + _Expense(this.source) + : amount = source.amount.abs(), + bucket = monthIndexOf(source.date); + + String get uuid => source.uuid; + String? get categoryUuid => source.categoryUuid; +} + +/// Local, deterministic spending insights for one month. +/// +/// Baselines are medians over the last [InsightThresholds.baselineMonths] +/// complete months, so one big purchase doesn't move what counts as usual. A +/// month in progress is compared with the same point of earlier months. +class InsightEngine { + final InsightRequest request; + + InsightEngine(this.request); + + late final int _month = monthIndexOf(request.month); + late final bool _inProgress = _month == monthIndexOf(request.now); + late final int? _throughDay = _inProgress ? request.now.day : null; + + /// Nothing at or after this is considered. + late final DateTime _cutoff = _inProgress + ? DateTime(request.now.year, request.now.month, request.now.day + 1) + : monthStartOf(_month + 1); + + late final List _baseline = [ + for (int i = InsightThresholds.baselineMonths; i >= 1; i--) _month - i, + ]; + + final Map> _buckets = {}; + List _series = const []; + + /// Monthly series already charged for the analyzed month. + final Set _includedSeries = {}; + + /// Monthly series expected later in the analyzed month. + final Set _deferredSeries = {}; + + List _active = const []; + double _usualTotal = 0.0; + double _floor = 0.0; + + InsightReport run() { + if (_month > monthIndexOf(request.now)) { + return _report(.futureMonth, const []); + } + + _prepare(); + + _active = _baseline + .where( + (month) => + _full(month).length >= InsightThresholds.minExpensesPerMonth, + ) + .toList(); + + if (_active.length < InsightThresholds.minActiveMonths) { + return _report(.notEnoughHistory, const []); + } + + _usualTotal = median(_active.map((month) => _sum(_full(month)))); + + if (_usualTotal <= 0.0) { + return _report(.notEnoughHistory, const []); + } + + final double medianExpense = median( + _active.expand(_full).map((expense) => expense.amount), + ); + + _floor = math.max( + _usualTotal * InsightThresholds.materialityShare, + medianExpense * InsightThresholds.materialityMedianMultiple, + ); + + final bool tooEarly = + _inProgress && _throughDay! < InsightThresholds.minDayOfMonth; + + final List candidates = [ + if (!tooEarly) ...[ + ?_monthPace(), + ..._categorySpikes(), + ..._categoryDrops(), + ..._newCategories(), + ], + ..._recurringCharges(), + ..._priceChanges(), + ]; + + return _report( + tooEarly ? .tooEarly : .ready, + _rank( + candidates + .where((insight) => !request.hiddenTypes.contains(insight.type)) + .toList(), + ), + ); + } + + InsightReport _report(InsightReportStatus status, List insights) => + InsightReport( + month: monthStartOf(_month), + status: status, + insights: insights, + usualMonthTotal: _usualTotal > 0.0 ? _usualTotal : null, + throughDay: _throughDay, + hasMissingRates: request.hasMissingRates, + ); + + void _prepare() { + final List valid = + request.transactions + .where( + (transaction) => + !transaction.isTransfer && + !transaction.isPending && + transaction.amount.isFinite && + transaction.amount != 0.0 && + transaction.date.isBefore(_cutoff), + ) + .toList() + ..sort((a, b) { + final int byDate = a.date.compareTo(b.date); + return byDate != 0 ? byDate : a.uuid.compareTo(b.uuid); + }); + + final Set refunded = _matchRefunds(valid); + + final List<_Expense> expenses = valid + .where( + (transaction) => + transaction.amount < 0.0 && !refunded.contains(transaction.uuid), + ) + .map(_Expense.new) + .toList(); + + _series = detectRecurringSeries( + expenses.map((expense) => expense.source).toList(), + templates: request.recurringTemplates, + ); + + final Map byUuid = { + for (final _Expense expense in expenses) expense.uuid: expense, + }; + + for (final RecurringSeries series in _series) { + for (int i = 0; i < series.occurrences.length; i++) { + final RecurringOccurrence occurrence = series.occurrences[i]; + final _Expense expense = byUuid[occurrence.transactionUuid]!; + + switch (series.cadence) { + case .monthly: + expense.monthlySeries = series; + expense.bucket = series.periodOf(occurrence); + case .yearly: + expense.isYearlyRepeat = i > 0; + case .weekly: + break; + } + } + } + + for (final _Expense expense in expenses) { + (_buckets[expense.bucket] ??= []).add(expense); + } + + if (_inProgress) _splitSeriesByTiming(); + } + + /// Pairs a positive transaction with an earlier expense of the same amount + /// and title (or category), so a returned purchase isn't counted as spend. + Set _matchRefunds(List valid) { + final Map> expensesByKey = {}; + + for (final InsightTransaction transaction in valid) { + if (transaction.amount >= 0.0) continue; + + final String? key = _refundKey(transaction); + if (key != null) (expensesByKey[key] ??= []).add(transaction); + } + + final Set matched = {}; + + for (final InsightTransaction refund in valid) { + if (refund.amount <= 0.0) continue; + + final String? key = _refundKey(refund); + final List? candidates = expensesByKey[key]; + if (candidates == null) continue; + + for (final InsightTransaction expense in candidates.reversed) { + if (matched.contains(expense.uuid)) continue; + if (expense.date.isAfter(refund.date)) continue; + if (dayDifference(expense.date, refund.date) > + InsightThresholds.refundWindowDays) { + break; + } + + final double difference = (expense.amount.abs() - refund.amount).abs(); + if (difference <= + refund.amount * InsightThresholds.refundAmountTolerance) { + matched + ..add(expense.uuid) + ..add(refund.uuid); + break; + } + } + } + + return matched; + } + + String? _refundKey(InsightTransaction transaction) { + final String? title = normalizeTitle(transaction.title); + if (title != null) return "title:$title"; + + final String? category = transaction.categoryUuid; + return category == null ? null : "category:$category"; + } + + /// For a month in progress, a monthly charge that already happened is + /// compared in full against earlier months, and one still to come is left + /// out of both sides. Rent paid on the 20th instead of the 28th is then + /// not "spending more". + void _splitSeriesByTiming() { + for (final RecurringSeries series in _series) { + if (series.cadence != .monthly) continue; + + final List periods = series.occurrences + .map(series.periodOf) + .toList(); + + if (periods.contains(_month)) { + _includedSeries.add(series.key); + } else if (periods.last >= _month - 2 && + chargeDayOf(_month, series.anchorDay).day > _throughDay!) { + _deferredSeries.add(series.key); + } + } + } + + List<_Expense> _full(int month) => _buckets[month] ?? const []; + + /// [_full], cut to the same day of month as the analyzed month. + Iterable<_Expense> _comparable(int month) => + _full(month).where((expense) => _counts(expense, month)); + + bool _counts(_Expense expense, int month) { + if (!_inProgress || month == _month) return true; + + final RecurringSeries? series = expense.monthlySeries; + + if (series != null) { + if (_includedSeries.contains(series.key)) return true; + if (_deferredSeries.contains(series.key)) return false; + } + + return _dayInBucket(expense) <= math.min(_throughDay!, daysInMonth(month)); + } + + int _dayInBucket(_Expense expense) { + final int calendarMonth = monthIndexOf(expense.source.date); + + if (calendarMonth < expense.bucket) return 1; + if (calendarMonth > expense.bucket) return 31; + return expense.source.date.day; + } + + double _sum(Iterable<_Expense> expenses) => + expenses.fold(0.0, (total, expense) => total + expense.amount); + + Iterable<_Expense> _inCategory(Iterable<_Expense> expenses, String? uuid) => + expenses.where((expense) => expense.categoryUuid == uuid); + + List<_Expense> _biggestFirst(Iterable<_Expense> expenses) => + expenses.toList()..sort((a, b) { + final int byAmount = b.amount.compareTo(a.amount); + return byAmount != 0 ? byAmount : a.uuid.compareTo(b.uuid); + }); + + List _evidence(Iterable<_Expense> expenses) => _biggestFirst(expenses) + .take(InsightThresholds.maxEvidenceTransactions) + .map((expense) => expense.uuid) + .toList(); + + /// The biggest expense, when it covers most of [delta]. + InsightAttribution? _attribute(Iterable<_Expense> expenses, double delta) { + final _Expense? biggest = _biggestFirst(expenses).firstOrNull; + + if (biggest == null || + biggest.amount < delta * InsightThresholds.attributionShare) { + return null; + } + + return InsightAttribution( + transactionUuid: biggest.uuid, + title: biggest.source.title, + categoryUuid: biggest.categoryUuid, + date: biggest.source.date, + amount: biggest.amount, + ); + } + + /// A yearly charge that was paid last year too shouldn't make a month + /// look unusual. + bool _survivesYearlyRepeats( + Iterable<_Expense> current, + double usual, + double ratio, + ) { + final double withoutRepeats = _sum( + current.where((expense) => !expense.isYearlyRepeat), + ); + + return withoutRepeats >= usual * ratio && withoutRepeats - usual >= _floor; + } + + List _history(double Function(int month) value) => [ + for (final int month in _baseline) + InsightMonthValue( + month: monthStartOf(month), + amount: value(month), + counted: _active.contains(month), + ), + ]; + + Set _categories(Iterable months, {bool comparable = false}) => { + for (final int month in months) + for (final _Expense expense + in comparable ? _comparable(month) : _full(month)) + expense.categoryUuid, + }; + + double _score(double stake, {double weight = 1.0}) => + stake / _usualTotal * weight; + + MonthPaceInsight? _monthPace() { + final List<_Expense> current = _comparable(_month).toList(); + final double currentTotal = _sum(current); + final double usual = median( + _active.map((month) => _sum(_comparable(month))), + ); + + if (usual <= 0.0) return null; + + final double ratio = currentTotal / usual; + final double delta = currentTotal - usual; + + final InsightDirection? direction = + ratio >= InsightThresholds.monthAboveRatio + ? .above + : ratio <= InsightThresholds.monthBelowRatio + ? .below + : null; + + if (direction == null || delta.abs() < _floor) return null; + + if (direction == .above && + !_survivesYearlyRepeats( + current, + usual, + InsightThresholds.monthAboveRatio, + )) { + return null; + } + + return MonthPaceInsight( + score: _score(delta.abs()), + direction: direction, + current: currentTotal, + usual: usual, + throughDay: _throughDay, + history: _history((month) => _sum(_comparable(month))), + attribution: direction == .above ? _attribute(current, delta) : null, + drivers: _drivers(direction), + transactionUuids: _evidence(current), + ); + } + + List _drivers(InsightDirection direction) { + final List drivers = []; + + for (final String? category in _categories([ + _month, + ..._active, + ], comparable: true)) { + final double current = _sum(_inCategory(_comparable(_month), category)); + final double usual = median( + _active.map((month) => _sum(_inCategory(_comparable(month), category))), + ); + + final InsightCategoryDelta driver = InsightCategoryDelta( + categoryUuid: category, + current: current, + usual: usual, + ); + + if (direction == .above ? driver.delta > 0.0 : driver.delta < 0.0) { + drivers.add(driver); + } + } + + drivers.sort((a, b) { + final int byDelta = b.delta.abs().compareTo(a.delta.abs()); + return byDelta != 0 + ? byDelta + : (a.categoryUuid ?? "").compareTo(b.categoryUuid ?? ""); + }); + + return drivers.take(InsightThresholds.maxDrivers).toList(); + } + + List _categorySpikes() { + final List spikes = []; + + for (final String? category in _categories([_month])) { + if (category == null) continue; + + final List<_Expense> current = _inCategory( + _full(_month), + category, + ).toList(); + + if (current.length < InsightThresholds.categorySpikeMinEntries) continue; + + final List present = _active + .map((month) => _sum(_inCategory(_full(month), category))) + .where((total) => total > 0.0) + .toList(); + + if (present.length < InsightThresholds.categoryMinMonthsPresent) { + continue; + } + + // Months without this category don't drag its usual down. + final double usual = median(present); + final double currentTotal = _sum(current); + final double delta = currentTotal - usual; + + if (currentTotal < usual * InsightThresholds.categorySpikeRatio || + delta < _floor || + !_survivesYearlyRepeats( + current, + usual, + InsightThresholds.categorySpikeRatio, + )) { + continue; + } + + spikes.add( + CategorySpikeInsight( + score: _score(delta), + categoryUuid: category, + current: currentTotal, + usual: usual, + entryCount: current.length, + history: _history( + (month) => _sum(_inCategory(_full(month), category)), + ), + attribution: _attribute(current, delta), + transactionUuids: _evidence(current), + ), + ); + } + + return spikes; + } + + List _categoryDrops() { + if (_inProgress && _throughDay! < InsightThresholds.categoryDropMinDay) { + return const []; + } + + final List drops = []; + + for (final String? category in _categories(_active)) { + if (category == null) continue; + + final List fullTotals = _active + .map((month) => _sum(_inCategory(_full(month), category))) + .toList(); + + final int present = fullTotals.where((total) => total > 0.0).length; + + // Only categories that show up nearly every month can "drop". + if (present < + math.max( + InsightThresholds.categoryMinMonthsPresent, + _active.length - 1, + )) { + continue; + } + + if (median(fullTotals) < + _usualTotal * InsightThresholds.categoryDropMinShare) { + continue; + } + + final List<_Expense> current = _inCategory( + _comparable(_month), + category, + ).toList(); + final double currentTotal = _sum(current); + final double usual = median( + _active.map((month) => _sum(_inCategory(_comparable(month), category))), + ); + + if (usual <= 0.0 || + currentTotal > usual * InsightThresholds.categoryDropRatio || + usual - currentTotal < _floor) { + continue; + } + + drops.add( + CategoryDropInsight( + score: _score(usual - currentTotal), + categoryUuid: category, + current: currentTotal, + usual: usual, + throughDay: _throughDay, + history: _history( + (month) => _sum(_inCategory(_comparable(month), category)), + ), + transactionUuids: _evidence(current), + ), + ); + } + + return drops; + } + + List _newCategories() { + final Set seen = _categories(_baseline); + final List result = []; + + for (final String? category in _categories([_month])) { + if (category == null || seen.contains(category)) continue; + + final List<_Expense> current = _inCategory( + _full(_month), + category, + ).toList(); + final double total = _sum( + current.where((expense) => !expense.isYearlyRepeat), + ); + + if (total < _floor) continue; + + result.add( + NewCategoryInsight( + score: _score(total), + categoryUuid: category, + current: _sum(current), + entryCount: current.length, + transactionUuids: _evidence(current), + ), + ); + } + + return result; + } + + bool _chargedThisMonth(RecurringSeries series) => + series.periodOf(series.occurrences.last) == _month; + + /// Needs two charges at the old price, so a one-off discount isn't a change. + bool _changedPriceThisMonth(RecurringSeries series) => + series.isFixedPrice && + series.priceChangeIndex == series.occurrences.length - 1 && + series.priceChangeIndex! >= 2 && + _chargedThisMonth(series); + + List _recurringCharges() => [ + for (final RecurringSeries series in _series) + // A price change says more this month; the suggestion can wait. + if (series.isFixedPrice && + !series.isTracked && + _chargedThisMonth(series) && + !_changedPriceThisMonth(series) && + series.annualCost >= _floor) + RecurringChargeInsight( + score: _score( + series.annualCost, + weight: InsightThresholds.actionWeight, + ), + series: series, + ), + ]; + + List _priceChanges() => [ + for (final RecurringSeries series in _series) + if (_changedPriceThisMonth(series)) + PriceChangeInsight( + score: _score( + (series.typicalAmount - series.previousAmount!).abs() * + series.cadence.perYear, + ), + series: series, + previousAmount: series.previousAmount!, + currentAmount: series.typicalAmount, + ), + ]; + + bool _isCoolingDown(Insight insight) { + if (!_inProgress) return false; + + final DateTime monthStart = monthStartOf(_month); + + // Cooldown only across months; within a month the card stays stable. + return request.sightings.any( + (sighting) => + sighting.key == insight.key && + sighting.shownAt.isBefore(monthStart) && + dayDifference(sighting.shownAt, request.now) < + InsightThresholds.cooldownDays && + insight.stake < sighting.stake * InsightThresholds.cooldownGrowth, + ); + } + + List _rank(List candidates) { + final List sorted = candidates.toList() + ..sort((a, b) { + final int byScore = b.score.compareTo(a.score); + if (byScore != 0) return byScore; + + final int byType = a.type.index.compareTo(b.type.index); + return byType != 0 ? byType : a.key.compareTo(b.key); + }); + + // A cooling-down month insight still covers what it explains. + final MonthPaceInsight? pace = sorted + .whereType() + .firstOrNull; + + final List result = []; + int spikes = 0; + int suggestions = 0; + + for (final Insight insight in sorted) { + if (result.length >= request.limit) break; + if (_isCoolingDown(insight)) continue; + if (pace != null && _explains(pace, insight)) continue; + + switch (insight) { + case CategorySpikeInsight(): + if (++spikes > InsightThresholds.maxCategorySpikes) continue; + case RecurringChargeInsight(): + if (++suggestions > InsightThresholds.maxRecurringSuggestions) { + continue; + } + default: + break; + } + + result.add(insight); + } + + return result; + } + + /// Whether [insight] mostly repeats what [pace] already says. + bool _explains(MonthPaceInsight pace, Insight insight) { + final bool sameDirection = switch (insight) { + CategorySpikeInsight() || + NewCategoryInsight() => pace.direction == .above, + CategoryDropInsight() => pace.direction == .below, + _ => false, + }; + + if (!sameDirection) return false; + + final String? attributed = pace.attribution?.transactionUuid; + + return insight.stake >= pace.stake * InsightThresholds.explainedShare || + (attributed != null && + insight.transactionUuids.firstOrNull == attributed); + } +} diff --git a/lib/data/insights/insight_math.dart b/lib/data/insights/insight_math.dart new file mode 100644 index 00000000..d1d3e177 --- /dev/null +++ b/lib/data/insights/insight_math.dart @@ -0,0 +1,92 @@ +import "dart:math" as math; + +/// Months since year 0, so month arithmetic never touches [Duration]s. +int monthIndexOf(DateTime date) => date.year * 12 + date.month - 1; + +DateTime monthStartOf(int monthIndex) => + DateTime(monthIndex ~/ 12, monthIndex % 12 + 1); + +int daysInMonth(int monthIndex) => + DateTime.utc(monthIndex ~/ 12, monthIndex % 12 + 2, 0).day; + +/// Calendar days from [from] to [to], ignoring time of day and DST. +int dayDifference(DateTime from, DateTime to) => DateTime.utc( + to.year, + to.month, + to.day, +).difference(DateTime.utc(from.year, from.month, from.day)).inDays; + +/// The charge day for [anchorDay] in a month, clamped for short months. +DateTime chargeDayOf(int monthIndex, int anchorDay) => DateTime( + monthIndex ~/ 12, + monthIndex % 12 + 1, + math.min(anchorDay, daysInMonth(monthIndex)), +); + +/// The month whose charge on [anchorDay] is nearest to [date], and the signed +/// distance in days (positive when [date] is late). +({int slot, int offset}) nearestSlot(DateTime date, int anchorDay) { + final int index = monthIndexOf(date); + + ({int slot, int offset})? best; + + for (int slot = index - 1; slot <= index + 1; slot++) { + final int offset = dayDifference(chargeDayOf(slot, anchorDay), date); + if (best == null || offset.abs() < best.offset.abs()) { + best = (slot: slot, offset: offset); + } + } + + return best!; +} + +/// Returns 0.0 for an empty list. +double median(Iterable values) { + final List sorted = values.toList()..sort(); + + if (sorted.isEmpty) return 0.0; + + final int middle = sorted.length ~/ 2; + + if (sorted.length.isOdd) return sorted[middle]; + + return (sorted[middle - 1] + sorted[middle]) / 2; +} + +double mean(Iterable values) { + if (values.isEmpty) return 0.0; + + return values.fold(0.0, (a, b) => a + b) / values.length; +} + +/// Standard deviation over mean. 0.0 for fewer than two values or a zero mean. +double coefficientOfVariation(List values) { + if (values.length < 2) return 0.0; + + final double average = mean(values); + if (average == 0.0) return 0.0; + + final double variance = mean( + values.map((value) => (value - average) * (value - average)), + ); + + return math.sqrt(variance) / average.abs(); +} + +final RegExp _separators = RegExp(r"[^\p{L}\p{N}]+", unicode: true); +final RegExp _digit = RegExp(r"\d"); + +/// Lowercased title without punctuation and without tokens that contain +/// digits (reference numbers, dates), so "NETFLIX #1234" and "Netflix" group +/// together. Null when nothing is left. +String? normalizeTitle(String? title) { + if (title == null) return null; + + final String normalized = title + .toLowerCase() + .split(_separators) + .where((token) => token.isNotEmpty && !token.contains(_digit)) + .join(" "); + + return normalized.isEmpty ? null : normalized; +} diff --git a/lib/data/insights/insight_request.dart b/lib/data/insights/insight_request.dart new file mode 100644 index 00000000..8b52ebbd --- /dev/null +++ b/lib/data/insights/insight_request.dart @@ -0,0 +1,138 @@ +import "package:flow/data/insights/insight.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; + +/// A transaction as the insight engine sees it. Plain data, safe to send to +/// another isolate. +class InsightTransaction { + final String uuid; + + /// Local time. + final DateTime date; + + /// In the primary currency. Negative for expenses. + final double amount; + + final String? title; + final String? categoryUuid; + final String? accountUuid; + + final bool isTransfer; + final bool isPending; + + /// Uuid of the Flow recurring transaction that created this, if any. + final String? recurringUuid; + + const InsightTransaction({ + required this.uuid, + required this.date, + required this.amount, + this.title, + this.categoryUuid, + this.accountUuid, + this.isTransfer = false, + this.isPending = false, + this.recurringUuid, + }); +} + +/// A recurring transaction the user already set up in Flow. +class InsightRecurringTemplate { + final String? title; + + /// Absolute amount, in the primary currency. + final double amount; + + const InsightRecurringTemplate({required this.title, required this.amount}); +} + +/// An insight the UI has shown before, used for the cooldown. +class InsightSighting { + /// [Insight.key] + final String key; + final DateTime shownAt; + + /// [Insight.stake] at the time it was shown. + final double stake; + + const InsightSighting({ + required this.key, + required this.shownAt, + required this.stake, + }); +} + +class InsightRequest { + /// Everything available up to [now]. Pass at least 7 months; recurring + /// detection benefits from more (yearly charges need two years). + final List transactions; + + /// Any moment within the month to analyze. + final DateTime month; + + final DateTime now; + + final List recurringTemplates; + + /// Types the user chose not to see. + final Set hiddenTypes; + + final List sightings; + + final int limit; + + /// Passed through to [InsightReport.hasMissingRates]. + final bool hasMissingRates; + + const InsightRequest({ + required this.transactions, + required this.month, + required this.now, + this.recurringTemplates = const [], + this.hiddenTypes = const {}, + this.sightings = const [], + this.limit = InsightThresholds.maxInsights, + this.hasMissingRates = false, + }); +} + +enum InsightReportStatus { + ready, + + /// Before [InsightThresholds.minDayOfMonth]; only recurring charge + /// insights can appear. + tooEarly, + + /// Fewer than [InsightThresholds.minActiveMonths] usable baseline months. + notEnoughHistory, + + futureMonth, +} + +class InsightReport { + /// First day of the analyzed month. + final DateTime month; + + final InsightReportStatus status; + + /// Ranked by money at stake, at most [InsightRequest.limit]. + final List insights; + + /// Median spend of a complete baseline month. Null without enough history. + final double? usualMonthTotal; + + /// Day of month the comparisons run through. Null for a complete month. + final int? throughDay; + + final bool hasMissingRates; + + const InsightReport({ + required this.month, + required this.status, + required this.insights, + this.usualMonthTotal, + this.throughDay, + this.hasMissingRates = false, + }); + + bool get isInProgress => throughDay != null; +} diff --git a/lib/data/insights/insight_thresholds.dart b/lib/data/insights/insight_thresholds.dart new file mode 100644 index 00000000..8739a993 --- /dev/null +++ b/lib/data/insights/insight_thresholds.dart @@ -0,0 +1,98 @@ +/// Every tuning knob of the insight engine, in one place. +abstract final class InsightThresholds { + /// Complete months before the analyzed one that make up the baseline. + static const int baselineMonths = 6; + + /// A baseline month with fewer expenses is treated as a logging gap. + static const int minExpensesPerMonth = 10; + + /// Active baseline months required before the engine says anything. + static const int minActiveMonths = 3; + + /// Month comparisons stay quiet before this day of an in-progress month. + static const int minDayOfMonth = 7; + + static const double monthAboveRatio = 1.25; + static const double monthBelowRatio = 0.80; + + /// Materiality floor: max(share × usual month, multiple × median expense). + static const double materialityShare = 0.05; + static const double materialityMedianMultiple = 3.0; + + /// A single transaction covering this share of a change gets named. + static const double attributionShare = 0.5; + + static const double categorySpikeRatio = 1.5; + static const int categorySpikeMinEntries = 3; + static const int maxCategorySpikes = 2; + + /// Months a category must appear in to have a usual amount. + static const int categoryMinMonthsPresent = 3; + + static const double categoryDropRatio = 0.6; + + /// Categories smaller than this share of the usual month can't "drop". + static const double categoryDropMinShare = 0.05; + + /// Early in a month everything looks low. + static const int categoryDropMinDay = 20; + + /// A category change covering this share of the month change is folded + /// into the month insight. + static const double explainedShare = 0.8; + + /// Weight for insights that come with an action. + static const double actionWeight = 1.5; + + static const int maxInsights = 3; + static const int maxRecurringSuggestions = 1; + + static const int cooldownDays = 28; + + /// A cooled-down insight comes back early if it grew by this factor. + static const double cooldownGrowth = 1.5; + + static const int recurringMinOccurrences = 3; + static const int yearlyMinOccurrences = 2; + + /// Allowed distance from the usual charge day (weekends, bank delays). + static const int monthlyDayJitter = 4; + + /// Longer series tolerate the odd charge paid this far off. + static const int monthlyMaxShift = 10; + + /// Longest step between charges, in months (skipped months). + static const int monthlyMaxStep = 3; + + static const int weeklyDayJitter = 1; + static const int yearlyMinGap = 360; + static const int yearlyMaxGap = 370; + + /// Coefficient of variation allowed for a fixed-price charge. + static const double recurringMaxVariation = 0.10; + + /// Looser variation for variable bills (utilities), only used to line up + /// month boundaries, never suggested. + static const double variableChargeMaxVariation = 0.5; + + /// Amounts further apart than this ratio are split into separate series. + static const double amountClusterRatio = 1.25; + + static const double priceChangeMinRatio = 0.05; + static const double priceChangeMinAmount = 1.0; + + /// The step must also beat this many times the charges' own wobble. + static const double priceChangeNoiseMultiple = 3.0; + + /// Bigger jumps are more likely a different charge than a price change. + static const double priceChangeMaxRatio = 0.5; + + /// A Flow recurring template matches a series within this amount ratio. + static const double templateAmountTolerance = 0.25; + + static const double refundAmountTolerance = 0.01; + static const int refundWindowDays = 60; + + static const int maxDrivers = 3; + static const int maxEvidenceTransactions = 5; +} diff --git a/lib/data/insights/recurring_series.dart b/lib/data/insights/recurring_series.dart new file mode 100644 index 00000000..9e185d9c --- /dev/null +++ b/lib/data/insights/recurring_series.dart @@ -0,0 +1,424 @@ +import "dart:collection"; +import "dart:math" as math; + +import "package:flow/data/insights/insight_math.dart"; +import "package:flow/data/insights/insight_request.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; + +enum RecurringCadence { + weekly(52), + monthly(12), + yearly(1); + + final int perYear; + + const RecurringCadence(this.perYear); +} + +class RecurringOccurrence { + final String transactionUuid; + final DateTime date; + + /// Absolute amount, in the primary currency. + final double amount; + + const RecurringOccurrence({ + required this.transactionUuid, + required this.date, + required this.amount, + }); +} + +/// A charge that repeats on a steady cadence. +class RecurringSeries { + /// `recurring:` for Flow recurring transactions, `title:` + /// otherwise (with a `:<n>` suffix when one title holds several amounts). + final String key; + + /// Title of the latest occurrence. + final String? title; + + final RecurringCadence cadence; + + /// Monthly: day of month (clamped in short months). Weekly: weekday of the + /// latest charge. Yearly: day of month of the latest charge. + final int anchorDay; + + /// Oldest first. + final List<RecurringOccurrence> occurrences; + + /// False for variable bills, like utilities. + final bool isFixedPrice; + + /// Index in [occurrences] where the current price starts, if it changed. + final int? priceChangeIndex; + + final String? categoryUuid; + final String? accountUuid; + + /// Already set up as recurring in Flow. + final bool isTracked; + + const RecurringSeries({ + required this.key, + required this.title, + required this.cadence, + required this.anchorDay, + required this.occurrences, + required this.isFixedPrice, + required this.priceChangeIndex, + required this.categoryUuid, + required this.accountUuid, + required this.isTracked, + }); + + /// Median amount at the current price. + double get typicalAmount => median( + occurrences.skip(priceChangeIndex ?? 0).map((item) => item.amount), + ); + + /// Median amount before the price change. + double? get previousAmount => priceChangeIndex == null + ? null + : median(occurrences.take(priceChangeIndex!).map((item) => item.amount)); + + double get annualCost => typicalAmount * cadence.perYear; + + /// Month index of the charge [occurrence] belongs to. Monthly charges paid + /// a few days early or late still land in their own month. + int periodOf(RecurringOccurrence occurrence) => switch (cadence) { + .monthly => nearestSlot(occurrence.date, anchorDay).slot, + _ => monthIndexOf(occurrence.date), + }; +} + +/// Finds repeating charges among [expenses] (negative amounts). +/// +/// Charges are grouped by their Flow recurring transaction, or else by +/// [normalizeTitle]. Untitled charges are never grouped. +List<RecurringSeries> detectRecurringSeries( + List<InsightTransaction> expenses, { + List<InsightRecurringTemplate> templates = const [], +}) { + final SplayTreeMap<String, List<InsightTransaction>> groups = SplayTreeMap(); + + for (final InsightTransaction expense in expenses) { + final String? title = normalizeTitle(expense.title); + + final String? key = expense.recurringUuid != null + ? "recurring:${expense.recurringUuid}" + : title != null + ? "title:$title" + : null; + + if (key == null) continue; + + (groups[key] ??= []).add(expense); + } + + final List<RecurringSeries> result = []; + + for (final MapEntry<String, List<InsightTransaction>> group + in groups.entries) { + final List<InsightTransaction> items = group.value + ..sort(_compareTransactions); + + final RecurringSeries? whole = _detect(group.key, items, templates); + if (whole != null) { + result.add(whole); + continue; + } + + final List<List<InsightTransaction>> clusters = _amountClusters(items); + if (clusters.length < 2) continue; + + for (int i = 0; i < clusters.length; i++) { + final RecurringSeries? series = _detect( + "${group.key}:$i", + clusters[i]..sort(_compareTransactions), + templates, + ); + if (series != null) result.add(series); + } + } + + return result; +} + +int _compareTransactions(InsightTransaction a, InsightTransaction b) { + final int byDate = a.date.compareTo(b.date); + return byDate != 0 ? byDate : a.uuid.compareTo(b.uuid); +} + +/// Splits a title that covers several prices, like two "Apple" plans. +List<List<InsightTransaction>> _amountClusters(List<InsightTransaction> items) { + final List<InsightTransaction> byAmount = [...items] + ..sort((a, b) => b.amount.compareTo(a.amount)); + + final List<List<InsightTransaction>> clusters = []; + + for (final InsightTransaction item in byAmount) { + final List<InsightTransaction>? last = clusters.lastOrNull; + + if (last != null && + item.amount.abs() <= + last.last.amount.abs() * InsightThresholds.amountClusterRatio) { + last.add(item); + } else { + clusters.add([item]); + } + } + + return clusters; +} + +RecurringSeries? _detect( + String key, + List<InsightTransaction> items, + List<InsightRecurringTemplate> templates, +) { + final ({RecurringCadence cadence, int anchorDay})? rhythm = + _monthly(items) ?? _weekly(items) ?? _yearly(items); + + if (rhythm == null) return null; + + final List<double> amounts = items.map((item) => item.amount.abs()).toList(); + + final ({bool isFixedPrice, int? priceChangeIndex})? price = _price( + amounts, + rhythm.cadence, + ); + + if (price == null) return null; + + final List<RecurringOccurrence> occurrences = items + .map( + (item) => RecurringOccurrence( + transactionUuid: item.uuid, + date: item.date, + amount: item.amount.abs(), + ), + ) + .toList(); + + final double typicalAmount = median( + amounts.skip(price.priceChangeIndex ?? 0), + ); + + return RecurringSeries( + key: key, + title: items.last.title, + cadence: rhythm.cadence, + anchorDay: rhythm.anchorDay, + occurrences: occurrences, + isFixedPrice: price.isFixedPrice, + priceChangeIndex: price.priceChangeIndex, + categoryUuid: _mostCommon(items.map((item) => item.categoryUuid)), + accountUuid: _mostCommon(items.map((item) => item.accountUuid)), + isTracked: + key.startsWith("recurring:") || + _matchesTemplate(items.last.title, [ + typicalAmount, + amounts.last, + ], templates), + ); +} + +/// Same calendar day each month, give or take a few days, with at most a +/// couple of skipped months. Short months clamp the day (31st -> Feb 28th). +({RecurringCadence cadence, int anchorDay})? _monthly( + List<InsightTransaction> items, +) { + if (items.length < InsightThresholds.recurringMinOccurrences) return null; + + ({int anchorDay, int outliers, int total, int worst})? best; + + for (int anchorDay = 1; anchorDay <= 31; anchorDay++) { + int outliers = 0; + int total = 0; + int worst = 0; + + for (final InsightTransaction item in items) { + final int offset = nearestSlot(item.date, anchorDay).offset.abs(); + if (offset > InsightThresholds.monthlyDayJitter) outliers++; + total += offset; + worst = math.max(worst, offset); + } + + if (best == null || + outliers < best.outliers || + (outliers == best.outliers && total < best.total)) { + best = ( + anchorDay: anchorDay, + outliers: outliers, + total: total, + worst: worst, + ); + } + } + + // A long series may have the odd charge paid well early or late. + final int allowedOutliers = math.min( + math.max(1, items.length ~/ 5), + items.length - InsightThresholds.recurringMinOccurrences, + ); + + if (best!.outliers > allowedOutliers || + best.worst > InsightThresholds.monthlyMaxShift) { + return null; + } + + final List<int> slots = items + .map((item) => nearestSlot(item.date, best!.anchorDay).slot) + .toList(); + + int consecutive = 0; + + for (int i = 1; i < slots.length; i++) { + final int step = slots[i] - slots[i - 1]; + + if (step < 1 || step > InsightThresholds.monthlyMaxStep) return null; + if (step == 1) consecutive++; + } + + // Mostly month after month, so every-other-month charges don't qualify. + if (consecutive * 2 < slots.length - 1) return null; + + return (cadence: .monthly, anchorDay: best.anchorDay); +} + +({RecurringCadence cadence, int anchorDay})? _weekly( + List<InsightTransaction> items, +) { + if (items.length < InsightThresholds.recurringMinOccurrences) return null; + + int consecutive = 0; + + for (int i = 1; i < items.length; i++) { + final int gap = dayDifference(items[i - 1].date, items[i].date); + final int weeks = (gap / 7).round(); + + if (weeks < 1 || weeks > 2) return null; + if ((gap - weeks * 7).abs() > InsightThresholds.weeklyDayJitter) { + return null; + } + if (weeks == 1) consecutive++; + } + + if (consecutive * 2 < items.length - 1) return null; + + return (cadence: .weekly, anchorDay: items.last.date.weekday); +} + +({RecurringCadence cadence, int anchorDay})? _yearly( + List<InsightTransaction> items, +) { + if (items.length < InsightThresholds.yearlyMinOccurrences) return null; + + for (int i = 1; i < items.length; i++) { + final int gap = dayDifference(items[i - 1].date, items[i].date); + + if (gap < InsightThresholds.yearlyMinGap || + gap > InsightThresholds.yearlyMaxGap) { + return null; + } + } + + return (cadence: .yearly, anchorDay: items.last.date.day); +} + +/// A fixed price, a fixed price with one change, or (monthly only) a +/// variable bill. Null when amounts are all over the place. +({bool isFixedPrice, int? priceChangeIndex})? _price( + List<double> amounts, + RecurringCadence cadence, +) { + // Two yearly charges can't tell a price change from two different things. + if (amounts.length >= InsightThresholds.recurringMinOccurrences) { + for (int split = amounts.length - 1; split >= 1; split--) { + if (_isPriceChange(amounts.sublist(0, split), amounts.sublist(split))) { + return (isFixedPrice: true, priceChangeIndex: split); + } + } + } + + final double variation = coefficientOfVariation(amounts); + + if (variation <= InsightThresholds.recurringMaxVariation) { + return (isFixedPrice: true, priceChangeIndex: null); + } + + if (cadence == .monthly && + variation <= InsightThresholds.variableChargeMaxVariation) { + return (isFixedPrice: false, priceChangeIndex: null); + } + + return null; +} + +/// Both sides steady, and the step clearly bigger than their wobble (a +/// charge converted from another currency moves a little every month). +bool _isPriceChange(List<double> before, List<double> after) { + final double noise = math.max( + coefficientOfVariation(before), + coefficientOfVariation(after), + ); + + if (noise > InsightThresholds.recurringMaxVariation) return false; + + final double previous = median(before); + final double change = (median(after) - previous).abs(); + + if (previous <= 0.0) return false; + + final double ratio = change / previous; + + return change >= InsightThresholds.priceChangeMinAmount && + ratio >= + math.max( + InsightThresholds.priceChangeMinRatio, + noise * InsightThresholds.priceChangeNoiseMultiple, + ) && + ratio <= InsightThresholds.priceChangeMaxRatio; +} + +/// Title matches and either amount is close to the template's. +bool _matchesTemplate( + String? seriesTitle, + List<double> amounts, + List<InsightRecurringTemplate> templates, +) { + final String? title = normalizeTitle(seriesTitle); + if (title == null) return false; + + return templates.any((template) { + if (normalizeTitle(template.title) != title) return false; + + final double amount = template.amount.abs(); + final double tolerance = amount * InsightThresholds.templateAmountTolerance; + + return amounts.any((value) => (value - amount).abs() <= tolerance); + }); +} + +/// Ties go to the most recent value. +String? _mostCommon(Iterable<String?> values) { + final List<String?> list = values.toList(); + final Map<String?, int> counts = {}; + + for (final String? value in list) { + counts[value] = (counts[value] ?? 0) + 1; + } + + String? result; + int bestCount = 0; + + for (final String? value in list.reversed) { + if (counts[value]! > bestCount) { + bestCount = counts[value]!; + result = value; + } + } + + return result; +} diff --git a/lib/services/insights.dart b/lib/services/insights.dart new file mode 100644 index 00000000..650ded55 --- /dev/null +++ b/lib/services/insights.dart @@ -0,0 +1,78 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/insights/insight_adapter.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/transaction_filter.dart"; +import "package:flow/data/transactions_filter/time_range.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flow/services/transactions.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flutter/foundation.dart"; +import "package:moment_dart/moment_dart.dart"; + +class InsightsService { + static InsightsService? _instance; + + factory InsightsService() => _instance ??= InsightsService._internal(); + + InsightsService._internal(); + + /// Two years, so yearly charges can be seen repeating. + static const int historyMonths = 26; + + /// Insights for the month containing [month], computed off the main + /// isolate. + Future<InsightReport> computeFor( + DateTime month, { + Set<InsightType> hiddenTypes = const {}, + List<InsightSighting> sightings = const [], + }) async { + final String primaryCurrency = UserPreferencesService().primaryCurrency; + final ExchangeRates? rates = ExchangeRatesService() + .getPrimaryCurrencyRates(); + + final List<Transaction> transactions = await TransactionsService().findMany( + TransactionFilter( + range: TransactionFilterTimeRange.fromTimeRange( + CustomTimeRange( + DateTime(month.year, month.month - historyMonths), + DateTime(month.year, month.month + 1), + ), + ), + ), + ); + + final Query<RecurringTransaction> recurringQuery = + RecurringTransactionsService().activeRecurringsQb().build(); + final List<RecurringTransaction> recurringTransactions = recurringQuery + .find(); + recurringQuery.close(); + + final ({List<InsightTransaction> transactions, bool hasMissingRates}) + converted = insightTransactionsOf( + transactions, + primaryCurrency: primaryCurrency, + rates: rates, + ); + + return compute( + computeInsights, + InsightRequest( + transactions: converted.transactions, + month: month, + now: DateTime.now(), + recurringTemplates: insightTemplatesOf( + recurringTransactions, + primaryCurrency: primaryCurrency, + rates: rates, + ), + hiddenTypes: hiddenTypes, + sightings: sightings, + hasMissingRates: converted.hasMissingRates, + ), + ); + } +} From 947a8c244265f12dedd53cc2b017ccf43f4a2030 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:12:52 +0800 Subject: [PATCH 19/57] insights: engine tests --- test/unit/insights/insight_adapter_test.dart | 141 ++ test/unit/insights/insight_engine_test.dart | 1338 +++++++++++++++++ test/unit/insights/insight_math_test.dart | 144 ++ .../insights/insight_properties_test.dart | 163 ++ test/unit/insights/insight_test_data.dart | 186 +++ test/unit/insights/recurring_series_test.dart | 430 ++++++ 6 files changed, 2402 insertions(+) create mode 100644 test/unit/insights/insight_adapter_test.dart create mode 100644 test/unit/insights/insight_engine_test.dart create mode 100644 test/unit/insights/insight_math_test.dart create mode 100644 test/unit/insights/insight_properties_test.dart create mode 100644 test/unit/insights/insight_test_data.dart create mode 100644 test/unit/insights/recurring_series_test.dart diff --git a/test/unit/insights/insight_adapter_test.dart b/test/unit/insights/insight_adapter_test.dart new file mode 100644 index 00000000..7b62673d --- /dev/null +++ b/test/unit/insights/insight_adapter_test.dart @@ -0,0 +1,141 @@ +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/insights/insight_adapter.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/entity/transaction/extensions/default/recurring.dart"; +import "package:flow/entity/transaction/extensions/default/transfer.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + final ExchangeRates rates = ExchangeRates( + date: DateTime(2026, 9, 25), + baseCurrency: "USD", + rates: const {"usd": 1, "eur": 0.5, "mnt": 3500}, + ); + + int counter = 0; + + Transaction transaction( + double amount, { + String currency = "USD", + String? title, + bool? isPending, + bool? isDeleted, + }) => Transaction( + amount: amount, + currency: currency, + title: title, + isPending: isPending, + uuid: "tx-${counter++}", + transactionDate: DateTime.utc(2026, 9, 10, 12), + )..isDeleted = isDeleted; + + ({List<InsightTransaction> transactions, bool hasMissingRates}) adapt( + List<Transaction> transactions, { + ExchangeRates? withRates, + }) => insightTransactionsOf( + transactions, + primaryCurrency: "USD", + rates: withRates, + ); + + test("converts other currencies to the primary one", () { + final result = adapt([ + transaction(-10.0, currency: "EUR"), + transaction(-7000.0, currency: "MNT"), + transaction(-3.0), + ], withRates: rates); + + expect(result.hasMissingRates, isFalse); + expect(result.transactions.map((item) => item.amount), [-20.0, -2.0, -3.0]); + }); + + test("skips and flags what can't be converted", () { + final withoutRates = adapt([ + transaction(-10.0, currency: "EUR"), + transaction(-3.0), + ]); + final unknownRate = adapt([ + transaction(-10.0, currency: "JPY"), + ], withRates: rates); + + expect(withoutRates.transactions.single.amount, -3.0); + expect(withoutRates.hasMissingRates, isTrue); + expect(unknownRate.transactions, isEmpty); + expect(unknownRate.hasMissingRates, isTrue); + }); + + test("leaves out deleted, pending and transfers", () { + final Transaction transfer = transaction(-50.0) + ..addExtensions([ + Transfer( + uuid: "transfer", + fromAccountUuid: "a", + toAccountUuid: "b", + relatedTransactionUuid: "other", + ), + ]); + + final result = adapt([ + transaction(-1.0, isDeleted: true), + transaction(-2.0, isPending: true), + transfer, + transaction(-4.0, isDeleted: false, isPending: false), + ], withRates: rates); + + expect(result.transactions.single.amount, -4.0); + expect(result.hasMissingRates, isFalse); + }); + + test("carries title, local date and the recurring link", () { + final Transaction generated = transaction(-11.99, title: "Spotify") + ..addExtensions([ + Recurring( + uuid: "recurring-1", + initialTransactionDate: DateTime(2026, 1, 3), + ), + ]); + + final InsightTransaction item = adapt([generated]).transactions.single; + + expect(item.uuid, generated.uuid); + expect(item.title, "Spotify"); + expect(item.recurringUuid, "recurring-1"); + expect(item.date.isUtc, isFalse); + expect(item.date, DateTime.utc(2026, 9, 10, 12).toLocal()); + }); + + test("recurring templates: active, non-transfer, converted", () { + RecurringTransaction recurring( + Transaction template, { + bool disabled = false, + String? transferTo, + }) => RecurringTransaction( + rules: const [], + range: "", + jsonTransactionTemplate: "", + disabled: disabled, + transferToAccountUuid: transferTo, + )..template = template; + + final List<InsightRecurringTemplate> templates = insightTemplatesOf( + [ + recurring(transaction(-15.0, currency: "EUR", title: "Netflix")), + recurring(transaction(-9.0, title: "Old"), disabled: true), + recurring(transaction(-100.0, title: "Savings"), transferTo: "b"), + recurring(transaction(-5.0, currency: "JPY", title: "Unknown")), + RecurringTransaction( + rules: const [], + range: "", + jsonTransactionTemplate: "not json", + ), + ], + primaryCurrency: "USD", + rates: rates, + ); + + expect(templates.single.title, "Netflix"); + expect(templates.single.amount, -30.0); + }); +} diff --git a/test/unit/insights/insight_engine_test.dart b/test/unit/insights/insight_engine_test.dart new file mode 100644 index 00000000..a9d7c084 --- /dev/null +++ b/test/unit/insights/insight_engine_test.dart @@ -0,0 +1,1338 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight_engine.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "insight_test_data.dart"; + +/// Closed-month scenarios analyze August 2026 as seen from late September. +final DateTime august = DateTime(2026, 8); +final DateTime september = DateTime(2026, 9); +final DateTime lateSeptember = DateTime(2026, 9, 25, 18); + +/// Feb..Jul 2026, the baseline of August. +const List<int> augustBaseline = [2, 3, 4, 5, 6, 7]; + +/// Mar..Aug 2026, the baseline of September. +const List<int> septemberBaseline = [3, 4, 5, 6, 7, 8]; + +List<T> only<T extends Insight>(InsightReport report) => + report.insights.whereType<T>().toList(); + +void main() { + group("personas", () { + test("a steady spender only hears about an untracked subscription", () { + final math.Random random = math.Random(7); + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 8; month++) { + ledger.steadyMonth(2026, month, random); + } + ledger.steadyMonth(2026, 9, random, throughDay: 25); + + for (final DateTime month in [august, september]) { + final InsightReport report = ledger.analyze(month, now: lateSeptember); + + expect(report.status, InsightReportStatus.ready); + expect(report.insights, hasLength(1)); + + final RecurringChargeInsight insight = + report.insights.single as RecurringChargeInsight; + expect(insight.series.title, "Spotify"); + expect(insight.series.anchorDay, 3); + expect(insight.series.cadence, RecurringCadence.monthly); + } + }); + + test("a traveler's flight is named, and the month is fine without it", () { + final math.Random random = math.Random(3); + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 8; month++) { + ledger.steadyMonth(2026, month, random); + } + ledger.steadyMonth(2026, 9, random, throughDay: 25); + final InsightTransaction flight = ledger.spend( + DateTime(2026, 9, 12, 8), + 1500.0, + title: "Flight to Tokyo", + category: "travel", + ); + + final InsightReport report = ledger.analyze( + september, + now: lateSeptember, + ); + + final MonthPaceInsight pace = report.insights.first as MonthPaceInsight; + + expect(pace.direction, InsightDirection.above); + expect(pace.throughDay, 25); + expect(pace.ratio, greaterThan(1.7)); + expect(pace.attribution?.transactionUuid, flight.uuid); + expect(pace.attribution?.title, "Flight to Tokyo"); + expect(pace.ratioWithoutAttribution, lessThan(1.1)); + expect(pace.dominantDriver?.categoryUuid, "travel"); + expect(pace.transactionUuids.first, flight.uuid); + expect(pace.history, hasLength(6)); + expect(pace.history.first.month, DateTime(2026, 3)); + + // "First travel spend" would only repeat the same flight. + expect(only<NewCategoryInsight>(report), isEmpty); + }); + + test("a freelancer's irregular income changes nothing", () { + final math.Random random = math.Random(11); + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 9; month++) { + ledger.steadyMonth(2026, month, random, throughDay: 25); + for (int i = 0; i < random.nextInt(4); i++) { + ledger.earn( + DateTime(2026, month, 1 + random.nextInt(25)), + 500.0 + random.nextDouble() * 6000, + title: "Client ${random.nextInt(5)}", + category: "freelance", + ); + } + } + + final InsightReport report = ledger.analyze( + september, + now: lateSeptember, + ); + + expect(report.status, InsightReportStatus.ready); + expect( + report.insights.where((insight) => insight is! RecurringChargeInsight), + isEmpty, + ); + }); + + test("a new user with two months hears nothing", () { + final math.Random random = math.Random(5); + final TestLedger ledger = TestLedger(); + + ledger.steadyMonth(2026, 7, random); + ledger.steadyMonth(2026, 8, random); + ledger.steadyMonth(2026, 9, random, throughDay: 25); + ledger.spend(DateTime(2026, 9, 12), 5000.0, title: "Laptop"); + + final InsightReport report = ledger.analyze( + september, + now: lateSeptember, + ); + + expect(report.status, InsightReportStatus.notEnoughHistory); + expect(report.insights, isEmpty); + expect(report.usualMonthTotal, isNull); + }); + + test("three good months are enough", () { + final TestLedger ledger = TestLedger(); + + for (final int month in [5, 6, 7]) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 20), 2000.0); + + expect( + ledger.analyze(august, now: lateSeptember).status, + InsightReportStatus.ready, + ); + }); + + test("sparse months don't count toward history", () { + final TestLedger ledger = TestLedger(); + + ledger.flatMonth(2026, 5); + ledger.flatMonth(2026, 6); + ledger.flatMonth(2026, 7, count: 9, each: 111.0); + ledger.flatMonth(2026, 8); + + expect( + ledger.analyze(august, now: lateSeptember).status, + InsightReportStatus.notEnoughHistory, + ); + }); + + test("a subscription paid abroad, converted at drifting rates, " + "isn't a price change", () { + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 8; month++) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + final double rate = 1.08 + 0.03 * math.sin(month.toDouble()); + ledger.spend( + DateTime(2026, month, 15), + 12.99 * rate, + title: "Netflix", + category: "subscriptions", + ); + } + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + + expect(only<PriceChangeInsight>(report), isEmpty); + expect( + only<RecurringChargeInsight>(report).single.series.isFixedPrice, + isTrue, + ); + }); + }); + + group("month pace thresholds", () { + /// 20 × 50 a month: usual 1000, floor max(50, 150) = 150. + TestLedger smallFloor() { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 20, each: 50.0); + } + return ledger; + } + + /// 10 × 100 a month: usual 1000, floor max(50, 300) = 300. + TestLedger bigFloor() { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + return ledger; + } + + test("just under 1.25× stays quiet", () { + final TestLedger ledger = smallFloor() + ..flatMonth(2026, 8, count: 20, each: 50.0) + ..spend(DateTime(2026, 8, 20), 249.0); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("1.25× fires", () { + final TestLedger ledger = smallFloor() + ..flatMonth(2026, 8, count: 20, each: 50.0) + ..spend(DateTime(2026, 8, 20), 250.0); + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + final MonthPaceInsight pace = only<MonthPaceInsight>(report).single; + + expect(report.usualMonthTotal, 1000.0); + expect(pace.direction, InsightDirection.above); + expect(pace.ratio, 1.25); + expect(pace.stake, 250.0); + expect(pace.score, 0.25); + expect(pace.throughDay, isNull); + }); + + test("0.80× fires as below, 0.801× doesn't", () { + final TestLedger below = smallFloor() + ..flatMonth(2026, 8, count: 16, each: 50.0); + final TestLedger almost = smallFloor() + ..flatMonth(2026, 8, count: 16, each: 50.0) + ..spend(DateTime(2026, 8, 30), 1.0); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + below.analyze(august, now: lateSeptember), + ).single; + + expect(pace.direction, InsightDirection.below); + expect(pace.attribution, isNull); + expect(pace.drivers.single.categoryUuid, "misc"); + expect(pace.drivers.single.delta, -200.0); + expect(almost.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("a ratio over 1.25 still needs the absolute floor", () { + final TestLedger under = bigFloor() + ..flatMonth(2026, 8) + ..spend(DateTime(2026, 8, 20), 299.0); + final TestLedger over = bigFloor() + ..flatMonth(2026, 8) + ..spend(DateTime(2026, 8, 20), 300.0); + + expect(under.analyze(august, now: lateSeptember).insights, isEmpty); + expect( + only<MonthPaceInsight>(over.analyze(august, now: lateSeptember)), + hasLength(1), + ); + }); + + test("nothing fires before the 7th", () { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 9, throughDay: 6); + ledger.spend(DateTime(2026, 9, 2), 2000.0, title: "Sofa"); + + final InsightReport early = ledger.analyze( + september, + now: DateTime(2026, 9, 6, 23, 59), + ); + final InsightReport onTime = ledger.analyze( + september, + now: DateTime(2026, 9, 7, 0, 1), + ); + + expect(early.status, InsightReportStatus.tooEarly); + expect(early.insights, isEmpty); + expect(onTime.status, InsightReportStatus.ready); + expect(only<MonthPaceInsight>(onTime).single.throughDay, 7); + }); + + test("an in-progress month is compared with the same day before", () { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month); + } + // Days 1, 4, 7, 10, 13 so far: exactly the usual pace. + ledger.flatMonth(2026, 9, throughDay: 15); + + final InsightReport report = ledger.analyze( + september, + now: DateTime(2026, 9, 15, 12), + ); + + expect(report.insights, isEmpty); + expect(report.throughDay, 15); + expect(report.isInProgress, isTrue); + }); + + test("a spike explained by one purchase says what it'd be without it", () { + final TestLedger ledger = bigFloor()..flatMonth(2026, 8); + final InsightTransaction tv = ledger.spend( + DateTime(2026, 8, 9), + 900.0, + title: "TV", + ); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(pace.attribution?.transactionUuid, tv.uuid); + expect(pace.attribution?.amount, 900.0); + expect(pace.currentWithoutAttribution, 1000.0); + expect(pace.ratioWithoutAttribution, 1.0); + }); + + test("many small increases aren't pinned on one purchase", () { + final TestLedger ledger = bigFloor() + ..flatMonth(2026, 8, count: 18, each: 100.0); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(pace.attribution, isNull); + expect(pace.ratioWithoutAttribution, isNull); + }); + }); + + group("categories", () { + /// misc 30 × 30 plus dining [diningEach] × [diningCount] each month. + TestLedger withDining({int diningCount = 4, double diningEach = 50.0}) { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + ledger.flatMonth( + 2026, + month, + count: diningCount, + each: diningEach, + category: "dining", + ); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + return ledger; + } + + test("1.5× the usual fires, just under doesn't", () { + final TestLedger spiking = withDining() + ..flatMonth(2026, 8, count: 6, each: 50.0, category: "dining"); + final TestLedger almost = withDining() + ..flatMonth(2026, 8, count: 5, each: 50.0, category: "dining") + ..spend(DateTime(2026, 8, 30), 49.0, category: "dining"); + + final CategorySpikeInsight spike = only<CategorySpikeInsight>( + spiking.analyze(august, now: lateSeptember), + ).single; + + expect(spike.categoryUuid, "dining"); + expect(spike.ratio, 1.5); + expect(spike.usual, 200.0); + expect(spike.entryCount, 6); + expect(spike.history.map((value) => value.amount), everyElement(200.0)); + expect(almost.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("the absolute floor applies to categories too", () { + // Usual month 1000, median expense 30: floor 90. + final TestLedger under = withDining(diningCount: 2) + ..flatMonth(2026, 8, count: 2, each: 60.0, category: "dining") + ..spend(DateTime(2026, 8, 30), 69.0, category: "dining"); + final TestLedger over = withDining(diningCount: 2) + ..flatMonth(2026, 8, count: 2, each: 60.0, category: "dining") + ..spend(DateTime(2026, 8, 30), 70.0, category: "dining"); + + expect( + only<CategorySpikeInsight>(under.analyze(august, now: lateSeptember)), + isEmpty, + ); + expect( + only<CategorySpikeInsight>(over.analyze(august, now: lateSeptember)), + hasLength(1), + ); + }); + + test("fewer than three entries can't spike", () { + final TestLedger ledger = withDining() + ..flatMonth(2026, 8, count: 2, each: 250.0, category: "dining"); + + expect( + only<CategorySpikeInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + + test("a category seen in only two months has no usual", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + } + ledger.flatMonth(2026, 3, count: 3, each: 20.0, category: "gifts"); + ledger.flatMonth(2026, 6, count: 3, each: 20.0, category: "gifts"); + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 4, each: 60.0, category: "gifts"); + + expect( + only<CategorySpikeInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + + test("months without the category don't drag its usual down", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + } + for (final int month in [3, 5, 7]) { + ledger.flatMonth(2026, month, count: 3, each: 60.0, category: "hobby"); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 3, each: 60.0, category: "hobby"); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("uncategorized spend never spikes", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + for (int i = 0; i < 3; i++) { + ledger.spend(DateTime(2026, month, 3 + i), 20.0, category: null); + } + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + for (int i = 0; i < 3; i++) { + ledger.spend(DateTime(2026, 8, 3 + i), 60.0, category: null); + } + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + + expect(only<CategorySpikeInsight>(report), isEmpty); + expect(only<NewCategoryInsight>(report), isEmpty); + }); + + test("at most two category spikes, ties broken by key", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + for (final String category in ["c", "b", "a"]) { + ledger.flatMonth( + 2026, + month, + count: 3, + each: 20.0, + category: category, + ); + } + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + for (final String category in ["c", "b", "a"]) { + ledger.flatMonth(2026, 8, count: 3, each: 70.0, category: category); + } + + final InsightReport report = ledger.analyze( + august, + now: lateSeptember, + limit: 10, + ); + + expect(report.insights.map((insight) => insight.key), [ + "monthPace:above", + "categorySpike:a", + "categorySpike:b", + ]); + }); + + group("drops", () { + /// misc 30 × 20 and groceries 4 × 100: usual 1000, floor 60. + TestLedger withGroceries() { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 20.0); + ledger.flatMonth( + 2026, + month, + count: 4, + each: 100.0, + category: "groceries", + ); + } + return ledger; + } + + test("0.6× of a regular category fires", () { + final TestLedger ledger = withGroceries() + ..flatMonth(2026, 8, count: 40, each: 20.0) + ..flatMonth(2026, 8, count: 2, each: 100.0, category: "groceries") + ..spend(DateTime(2026, 8, 28), 40.0, category: "groceries"); + + final CategoryDropInsight drop = only<CategoryDropInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(drop.categoryUuid, "groceries"); + expect(drop.ratio, 0.6); + expect(drop.stake, 160.0); + }); + + test("just above 0.6× doesn't", () { + final TestLedger ledger = withGroceries() + ..flatMonth(2026, 8, count: 40, each: 20.0) + ..flatMonth(2026, 8, count: 2, each: 100.0, category: "groceries") + ..spend(DateTime(2026, 8, 28), 41.0, category: "groceries"); + + expect( + only<CategoryDropInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + + test("waits until the 20th in a month in progress", () { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 20.0); + ledger.flatMonth( + 2026, + month, + count: 4, + each: 100.0, + category: "groceries", + ); + } + // Twice the usual misc so the month as a whole isn't low. + ledger.flatMonth(2026, 9, count: 60, each: 20.0, throughDay: 20); + + final InsightReport early = ledger.analyze( + september, + now: DateTime(2026, 9, 19, 20), + ); + final InsightReport late = ledger.analyze( + september, + now: DateTime(2026, 9, 20, 20), + ); + + expect(only<CategoryDropInsight>(early), isEmpty); + + final CategoryDropInsight drop = only<CategoryDropInsight>(late).single; + expect(drop.current, 0.0); + expect(drop.throughDay, 20); + // Days 1, 8 and 16 of earlier months, not the full month. + expect(drop.usual, 300.0); + }); + + test("a small category can't drop", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + ledger.flatMonth(2026, month, count: 2, each: 20.0, category: "tea"); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + + expect( + only<CategoryDropInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + }); + + test("spend in a brand-new category above the floor", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 3, each: 40.0, category: "pets"); + + final NewCategoryInsight insight = only<NewCategoryInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(insight.categoryUuid, "pets"); + expect(insight.current, 120.0); + expect(insight.entryCount, 3); + }); + + test("a brand-new category under the floor stays quiet", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 2, each: 40.0, category: "pets"); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + }); + + group("dates", () { + test("comparisons cap at the end of shorter months", () { + final TestLedger ledger = TestLedger(); + for (int month = 9; month <= 14; month++) { + ledger.flatMonth(2025, month); + } + ledger.flatMonth(2026, 3); + ledger.spend(DateTime(2026, 3, 20), 1000.0, title: "Bike"); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(DateTime(2026, 3), now: DateTime(2026, 3, 31, 22)), + ).single; + + expect(pace.history.map((value) => value.month), [ + DateTime(2025, 9), + DateTime(2025, 10), + DateTime(2025, 11), + DateTime(2025, 12), + DateTime(2026, 1), + DateTime(2026, 2), + ]); + // Through the 31st: every month counts in full, February included. + expect(pace.history.map((value) => value.amount), everyElement(1000.0)); + expect(pace.throughDay, 31); + }); + + test("mid-month, earlier months count up to the same day", () { + final TestLedger ledger = TestLedger(); + for (int month = 9; month <= 14; month++) { + ledger.flatMonth(2025, month); + } + ledger.flatMonth(2026, 3, throughDay: 15); + ledger.spend(DateTime(2026, 3, 14), 1000.0, title: "Bike"); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(DateTime(2026, 3), now: DateTime(2026, 3, 15, 9)), + ).single; + + expect(pace.usual, 500.0); + expect(pace.current, 1500.0); + }); + + test("an expense late on the last evening belongs to that month", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 31, 23, 59, 59), 900.0); + + expect( + only<MonthPaceInsight>( + ledger.analyze(august, now: lateSeptember), + ).single.current, + 1900.0, + ); + }); + + test("rent paid a day early doesn't make months look odd", () { + final TestLedger ledger = TestLedger(); + for (int month = 7; month <= 15; month++) { + ledger.flatMonth(2025, month); + } + for (int month = 7; month <= 15; month++) { + // February's rent goes out on January 31st. + final DateTime date = month == 14 + ? DateTime(2026, 1, 31) + : DateTime(2025, month, 1); + ledger.spend(date, 1200.0, title: "Rent", category: "housing"); + } + + final DateTime now = DateTime(2026, 4, 10); + + for (final DateTime month in [DateTime(2026, 1), DateTime(2026, 2)]) { + expect( + only<MonthPaceInsight>(ledger.analyze(month, now: now)), + isEmpty, + reason: "$month", + ); + } + }); + + test("rent paid earlier than usual this month isn't overspending", () { + TestLedger build({required String title}) { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month); + ledger.spend( + DateTime(2026, month, 28), + 1200.0, + title: "Rent", + category: "housing", + ); + } + ledger.flatMonth(2026, 9, throughDay: 25); + ledger.spend(DateTime(2026, 9, 20), 1200.0, title: title); + return ledger; + } + + expect( + only<MonthPaceInsight>( + build(title: "Rent").analyze(september, now: lateSeptember), + ), + isEmpty, + ); + + // The same amount as a one-off is real overspending. + expect( + only<MonthPaceInsight>( + build(title: "Sofa").analyze(september, now: lateSeptember), + ).single.direction, + InsightDirection.above, + ); + }); + + test("rent that isn't due yet doesn't make the month look low", () { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month, count: 6, each: 50.0); + ledger.flatMonth(2026, month, count: 6, each: 50.0, category: "food"); + ledger.spend( + DateTime(2026, month, 28), + 1200.0, + title: "Rent", + category: "housing", + ); + } + ledger.flatMonth(2026, 9, count: 6, each: 50.0, throughDay: 25); + ledger.flatMonth( + 2026, + 9, + count: 6, + each: 50.0, + category: "food", + throughDay: 25, + ); + + expect(ledger.analyze(september, now: lateSeptember).insights, isEmpty); + }); + + test("a yearly charge seen last year isn't unusual", () { + TestLedger build({required bool paidLastYear}) { + final TestLedger ledger = TestLedger(); + for (int month = 1; month <= 20; month++) { + ledger.flatMonth(2025, month); + } + if (paidLastYear) { + ledger.spend( + DateTime(2025, 8, 14), + 1500.0, + title: "Car insurance", + category: "insurance", + ); + } + ledger.spend( + DateTime(2026, 8, 14), + 1500.0, + title: "Car insurance", + category: "insurance", + ); + return ledger; + } + + final InsightReport repeated = build( + paidLastYear: true, + ).analyze(august, now: lateSeptember); + + expect(only<MonthPaceInsight>(repeated), isEmpty); + expect(only<NewCategoryInsight>(repeated), isEmpty); + expect( + only<RecurringChargeInsight>(repeated).single.series.cadence, + RecurringCadence.yearly, + ); + expect( + only<MonthPaceInsight>( + build(paidLastYear: false).analyze(august, now: lateSeptember), + ), + hasLength(1), + ); + }); + }); + + group("robustness", () { + test("runs through compute(), on another isolate", () async { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 10), 1000.0, title: "Sofa"); + + final InsightReport report = await compute( + computeInsights, + InsightRequest( + transactions: ledger.transactions, + month: august, + now: lateSeptember, + hiddenTypes: const {InsightType.priceChange}, + ), + ); + + expect(report.insights.single, isA<MonthPaceInsight>()); + }); + + test("no data at all", () { + final InsightReport report = TestLedger().analyze( + september, + now: lateSeptember, + ); + + expect(report.status, InsightReportStatus.notEnoughHistory); + expect(report.insights, isEmpty); + expect(report.month, september); + }); + + test("only income", () { + final TestLedger ledger = TestLedger(); + for (int month = 1; month <= 9; month++) { + for (int i = 0; i < 12; i++) { + ledger.earn(DateTime(2026, month, 1 + i), 1000.0); + } + } + + expect( + ledger.analyze(september, now: lateSeptember).status, + InsightReportStatus.notEnoughHistory, + ); + }); + + test("a future month says so", () { + expect( + TestLedger().analyze(DateTime(2026, 10), now: lateSeptember).status, + InsightReportStatus.futureMonth, + ); + }); + + test("transfers, pending and zero amounts are ignored", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 5), 5000.0, isTransfer: true); + ledger.spend(DateTime(2026, 8, 6), 5000.0, isPending: true); + ledger.spend(DateTime(2026, 8, 7), 0.0); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("a refunded purchase isn't spend", () { + TestLedger build({double? refund}) { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 3), 600.0, title: "Jacket"); + if (refund != null) { + ledger.earn( + DateTime(2026, 8, 12), + refund, + title: "JACKET", + category: "misc", + ); + } + return ledger; + } + + expect( + build(refund: 600.0).analyze(august, now: lateSeptember).insights, + isEmpty, + ); + expect( + only<MonthPaceInsight>( + build().analyze(august, now: lateSeptember), + ).single.current, + 1600.0, + ); + expect( + only<MonthPaceInsight>( + build(refund: 300.0).analyze(august, now: lateSeptember), + ), + hasLength(1), + ); + }); + + test("a refund before the purchase doesn't cancel it", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.earn(DateTime(2026, 8, 1), 600.0, title: "Jacket"); + ledger.spend(DateTime(2026, 8, 3), 600.0, title: "Jacket"); + + expect( + only<MonthPaceInsight>(ledger.analyze(august, now: lateSeptember)), + hasLength(1), + ); + }); + + test("a huge outlier in the baseline doesn't make a normal month low", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.spend(DateTime(2026, 4, 10), 50000.0, title: "Car"); + ledger.flatMonth(2026, 8); + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + + expect(report.usualMonthTotal, 1000.0); + expect(report.insights, isEmpty); + }); + + test("a huge outlier this month is named, with finite numbers", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 10), 1e9, title: "Yacht"); + + final MonthPaceInsight pace = + ledger.analyze(august, now: lateSeptember).insights.first + as MonthPaceInsight; + + expect(pace.attribution?.title, "Yacht"); + expect(pace.ratioWithoutAttribution, 1.0); + expect(numbersOf(pace).every((number) => number.isFinite), isTrue); + }); + + test("non-finite amounts are skipped", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 10), double.infinity); + ledger.spend(DateTime(2026, 8, 11), double.nan); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + }); + + group("recurring charges", () { + /// 30 × 30 a month (floor 90) plus [title] on the [day]th. + TestLedger withCharge( + String title, + List<double> amounts, { + int day = 15, + String? recurringUuid, + }) { + final TestLedger ledger = TestLedger(); + for (int i = 0; i < amounts.length; i++) { + final int month = 9 - amounts.length + i; + ledger.flatMonth(2026, month, count: 30, each: 30.0); + ledger.spend( + DateTime(2026, month, day), + amounts[i], + title: title, + category: "subscriptions", + recurringUuid: recurringUuid, + ); + } + return ledger; + } + + test("an untracked monthly charge is suggested once charged", () { + final TestLedger ledger = withCharge("Netflix", List.filled(6, 15.49)); + + final RecurringChargeInsight insight = only<RecurringChargeInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(insight.key, "recurringCharge:title:netflix"); + expect(insight.stake, moreOrLessEquals(15.49 * 12)); + expect(insight.series.occurrences, hasLength(6)); + expect(insight.series.categoryUuid, "subscriptions"); + expect(insight.transactionUuids, hasLength(6)); + }); + + test("not before this month's charge", () { + final TestLedger ledger = withCharge("Netflix", List.filled(6, 15.49)); + ledger.flatMonth(2026, 9, count: 30, each: 30.0, throughDay: 10); + + expect( + only<RecurringChargeInsight>( + ledger.analyze(september, now: DateTime(2026, 9, 10)), + ), + isEmpty, + ); + }); + + test("already tracked charges aren't suggested", () { + final TestLedger generated = withCharge( + "Netflix", + List.filled(6, 15.49), + recurringUuid: "r-netflix", + ); + final TestLedger manual = withCharge("Netflix", List.filled(6, 15.49)); + + expect(generated.analyze(august, now: lateSeptember).insights, isEmpty); + expect( + manual + .analyze( + august, + now: lateSeptember, + templates: const [ + InsightRecurringTemplate(title: "Netflix", amount: 15.49), + ], + ) + .insights, + isEmpty, + ); + }); + + test("cheap charges under the floor aren't suggested", () { + final TestLedger ledger = withCharge("App", List.filled(6, 2.99)); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("a variable bill isn't suggested", () { + final TestLedger ledger = withCharge("Electricity", [ + 82.0, + 140.0, + 95.0, + 61.0, + 120.0, + 88.0, + ]); + + expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + }); + + test("only the biggest suggestion per month", () { + final TestLedger ledger = withCharge("Netflix", List.filled(6, 15.49)); + for (int month = 3; month <= 8; month++) { + ledger.spend(DateTime(2026, month, 3), 11.99, title: "Spotify"); + } + + final List<RecurringChargeInsight> suggestions = + only<RecurringChargeInsight>( + ledger.analyze(august, now: lateSeptember, limit: 10), + ); + + expect(suggestions.single.series.title, "Netflix"); + }); + + test("a price change shows in its month, then the suggestion returns", () { + final TestLedger ledger = withCharge("Netflix", [ + 15.49, + 15.49, + 15.49, + 15.49, + 15.49, + 17.99, + ]); + + final InsightReport changed = ledger.analyze(august, now: lateSeptember); + final PriceChangeInsight change = only<PriceChangeInsight>( + changed, + ).single; + + expect(change.previousAmount, 15.49); + expect(change.currentAmount, 17.99); + expect(change.stake, moreOrLessEquals(2.5 * 12)); + expect(change.changedOn, DateTime(2026, 8, 15)); + expect(only<RecurringChargeInsight>(changed), isEmpty); + + ledger.flatMonth(2026, 9, count: 30, each: 30.0, throughDay: 25); + ledger.spend(DateTime(2026, 9, 15), 17.99, title: "Netflix"); + + final InsightReport next = ledger.analyze(september, now: lateSeptember); + expect(only<PriceChangeInsight>(next), isEmpty); + expect(only<RecurringChargeInsight>(next), hasLength(1)); + }); + + test("tracked series still report price changes", () { + final TestLedger ledger = withCharge("Netflix", [ + 15.49, + 15.49, + 15.49, + 17.99, + ], recurringUuid: "r-netflix"); + + expect( + only<PriceChangeInsight>(ledger.analyze(august, now: lateSeptember)), + hasLength(1), + ); + }); + + test("tiny price moves aren't changes", () { + // 6% but only 0.60, and 2% of 100. + for (final List<double> amounts in [ + [10.0, 10.0, 10.0, 10.6], + [100.0, 100.0, 100.0, 102.0], + ]) { + expect( + only<PriceChangeInsight>( + withCharge("Gym", amounts).analyze(august, now: lateSeptember), + ), + isEmpty, + reason: "$amounts", + ); + } + }); + + test("a charge at the old price once isn't enough history", () { + final TestLedger ledger = withCharge("Gym", [30.0, 30.0, 35.0]); + + expect( + only<PriceChangeInsight>(ledger.analyze(august, now: lateSeptember)), + isEmpty, + ); + }); + + test("a yearly subscription is suggested in its month", () { + final TestLedger ledger = TestLedger(); + for (int month = 1; month <= 20; month++) { + ledger.flatMonth(2025, month, count: 30, each: 30.0); + } + ledger.spend(DateTime(2025, 8, 20), 139.0, title: "Prime"); + ledger.spend(DateTime(2026, 8, 20), 139.0, title: "Prime"); + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + final RecurringChargeInsight insight = + report.insights.single as RecurringChargeInsight; + + expect(insight.series.cadence, RecurringCadence.yearly); + expect(insight.stake, 139.0); + }); + + test("suggestions still show before the 7th", () { + final TestLedger ledger = withCharge( + "Netflix", + List.filled(6, 15.49), + day: 3, + ); + ledger.flatMonth(2026, 9, count: 30, each: 30.0, throughDay: 4); + ledger.spend(DateTime(2026, 9, 3), 15.49, title: "Netflix"); + + final InsightReport report = ledger.analyze( + september, + now: DateTime(2026, 9, 4), + ); + + expect(report.status, InsightReportStatus.tooEarly); + expect(only<RecurringChargeInsight>(report), hasLength(1)); + }); + }); + + group("ranking", () { + /// Dining makes up the whole month's jump. + TestLedger diningExplainsMonth() { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + ledger.flatMonth(2026, month, count: 4, each: 25.0, category: "dining"); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 4, each: 25.0, category: "dining"); + ledger.flatMonth(2026, 8, count: 5, each: 80.0, category: "dining"); + return ledger; + } + + test("a category spike that explains the month folds into it", () { + final InsightReport report = diningExplainsMonth().analyze( + august, + now: lateSeptember, + ); + + final MonthPaceInsight pace = report.insights.single as MonthPaceInsight; + expect(pace.dominantDriver?.categoryUuid, "dining"); + expect(pace.dominantDriver?.delta, 400.0); + }); + + test("hiding a type lets the next one through", () { + final InsightReport report = diningExplainsMonth().analyze( + august, + now: lateSeptember, + hiddenTypes: {InsightType.monthPace}, + ); + + expect(report.insights.single, isA<CategorySpikeInsight>()); + }); + + test("never more than the limit, highest stake first", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month, count: 30, each: 30.0); + for (final String category in ["a", "b", "c", "d"]) { + ledger.flatMonth( + 2026, + month, + count: 3, + each: 20.0, + category: category, + ); + } + ledger.spend(DateTime(2026, month, 3), 30.0, title: "Gym"); + } + ledger.flatMonth(2026, 8, count: 30, each: 30.0); + ledger.flatMonth(2026, 8, count: 3, each: 90.0, category: "a"); + ledger.flatMonth(2026, 8, count: 3, each: 70.0, category: "b"); + ledger.flatMonth(2026, 8, count: 3, each: 60.0, category: "c"); + ledger.flatMonth(2026, 8, count: 3, each: 20.0, category: "d"); + ledger.flatMonth(2026, 8, count: 3, each: 50.0, category: "pets"); + ledger.spend(DateTime(2026, 8, 3), 30.0, title: "Gym"); + + final InsightReport report = ledger.analyze(august, now: lateSeptember); + final List<double> scores = report.insights + .map((insight) => insight.score) + .toList(); + + expect(report.insights, hasLength(3)); + expect( + scores, + orderedEquals([...scores]..sort((a, b) => b.compareTo(a))), + ); + expect(report.insights.first, isA<MonthPaceInsight>()); + + final InsightReport all = ledger.analyze( + august, + now: lateSeptember, + limit: 10, + ); + expect(all.insights.length, greaterThan(3)); + expect(all.insights.take(3).map((insight) => insight.key), [ + for (final Insight insight in report.insights) insight.key, + ]); + }); + + test("the same input in any order gives the same output", () { + final math.Random random = math.Random(42); + final TestLedger ledger = TestLedger(); + for (int month = 1; month <= 8; month++) { + ledger.steadyMonth(2026, month, random); + } + ledger.steadyMonth(2026, 9, random, throughDay: 25); + ledger.spend(DateTime(2026, 9, 12), 1500.0, title: "Flight"); + ledger.flatMonth(2026, 9, count: 4, each: 90.0, category: "dining"); + + String fingerprint(List<InsightTransaction> transactions) => + computeInsights( + InsightRequest( + transactions: transactions, + month: september, + now: lateSeptember, + limit: 10, + ), + ).insights + .map((insight) => "${insight.key}@${insight.score}") + .join(","); + + final String expected = fingerprint(ledger.transactions); + + for (int seed = 0; seed < 5; seed++) { + expect( + fingerprint([...ledger.transactions]..shuffle(math.Random(seed))), + expected, + ); + } + }); + + group("cooldown", () { + TestLedger overspending() { + final TestLedger ledger = TestLedger(); + for (final int month in septemberBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 9, throughDay: 20); + ledger.spend(DateTime(2026, 9, 10), 1000.0, title: "Sofa"); + return ledger; + } + + final DateTime now = DateTime(2026, 9, 20); + + List<Insight> seen(DateTime shownAt, double stake) => overspending() + .analyze( + september, + now: now, + sightings: [ + InsightSighting( + key: "monthPace:above", + shownAt: shownAt, + stake: stake, + ), + ], + ) + .insights; + + test("shown last month, recently: suppressed", () { + expect(seen(DateTime(2026, 8, 28), 900.0), isEmpty); + }); + + test("unless it grew by half", () { + expect(seen(DateTime(2026, 8, 28), 600.0), hasLength(1)); + }); + + test("shown this month: stays, so the card is stable", () { + expect(seen(DateTime(2026, 9, 12), 900.0), hasLength(1)); + }); + + test("shown over 28 days ago: back", () { + expect(seen(DateTime(2026, 8, 22), 900.0), hasLength(1)); + }); + + test("closed months ignore it", () { + final TestLedger ledger = TestLedger(); + for (final int month in augustBaseline) { + ledger.flatMonth(2026, month); + } + ledger.flatMonth(2026, 8); + ledger.spend(DateTime(2026, 8, 10), 1000.0); + + expect( + ledger + .analyze( + august, + now: DateTime(2026, 9, 3), + sightings: [ + InsightSighting( + key: "monthPace:above", + shownAt: DateTime(2026, 7, 30), + stake: 1000.0, + ), + ], + ) + .insights, + hasLength(1), + ); + }); + }); + }); +} diff --git a/test/unit/insights/insight_math_test.dart b/test/unit/insights/insight_math_test.dart new file mode 100644 index 00000000..8e811905 --- /dev/null +++ b/test/unit/insights/insight_math_test.dart @@ -0,0 +1,144 @@ +import "package:flow/data/insights/insight_math.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("median", () { + test("odd, even and empty", () { + expect(median([3.0, 1.0, 2.0]), 2.0); + expect(median([4.0, 1.0, 3.0, 2.0]), 2.5); + expect(median(<double>[]), 0.0); + }); + + test("one huge value doesn't move it", () { + expect(median([100.0, 110.0, 90.0, 105.0, 50000.0]), 105.0); + }); + }); + + group("coefficientOfVariation", () { + test("constant values have none", () { + expect(coefficientOfVariation([9.99, 9.99, 9.99]), 0.0); + }); + + test("fewer than two values, or a zero mean, read as 0", () { + expect(coefficientOfVariation([5.0]), 0.0); + expect(coefficientOfVariation([0.0, 0.0]), 0.0); + }); + + test("measures spread relative to the mean", () { + expect(coefficientOfVariation([90.0, 110.0]), moreOrLessEquals(0.1)); + }); + }); + + group("months", () { + test("index round-trips across year boundaries", () { + final int december = monthIndexOf(DateTime(2025, 12, 31, 23, 59)); + + expect(monthStartOf(december), DateTime(2025, 12)); + expect(monthStartOf(december + 1), DateTime(2026, 1)); + expect(monthStartOf(december - 12), DateTime(2024, 12)); + }); + + test("days in month know about leap years", () { + expect(daysInMonth(monthIndexOf(DateTime(2026, 2))), 28); + expect(daysInMonth(monthIndexOf(DateTime(2024, 2))), 29); + expect(daysInMonth(monthIndexOf(DateTime(2100, 2))), 28); + expect(daysInMonth(monthIndexOf(DateTime(2000, 2))), 29); + expect(daysInMonth(monthIndexOf(DateTime(2026, 4))), 30); + expect(daysInMonth(monthIndexOf(DateTime(2026, 12))), 31); + }); + + test("charge day clamps to short months", () { + expect(chargeDayOf(monthIndexOf(DateTime(2026, 2)), 31).day, 28); + expect(chargeDayOf(monthIndexOf(DateTime(2024, 2)), 31).day, 29); + expect(chargeDayOf(monthIndexOf(DateTime(2026, 4)), 31).day, 30); + expect(chargeDayOf(monthIndexOf(DateTime(2026, 5)), 31).day, 31); + }); + }); + + group("dayDifference", () { + test("ignores time of day", () { + expect( + dayDifference(DateTime(2026, 3, 1, 23, 59), DateTime(2026, 3, 2)), + 1, + ); + expect( + dayDifference(DateTime(2026, 3, 2), DateTime(2026, 3, 1, 23, 59)), + -1, + ); + }); + + test("isn't fooled by daylight saving transitions", () { + // US and EU spring-forward and fall-back weekends of 2026. + expect(dayDifference(DateTime(2026, 3, 7), DateTime(2026, 3, 9)), 2); + expect(dayDifference(DateTime(2026, 3, 28), DateTime(2026, 3, 30)), 2); + expect(dayDifference(DateTime(2026, 10, 24), DateTime(2026, 10, 26)), 2); + expect(dayDifference(DateTime(2026, 10, 31), DateTime(2026, 11, 2)), 2); + expect(dayDifference(DateTime(2026, 1, 1), DateTime(2027, 1, 1)), 365); + }); + + test("treats UTC and local dates by their calendar fields", () { + expect( + dayDifference(DateTime.utc(2026, 3, 29), DateTime(2026, 3, 30)), + 1, + ); + }); + }); + + group("nearestSlot", () { + test( + "a charge on the 28th of February belongs to February for the 31st", + () { + final ({int slot, int offset}) result = nearestSlot( + DateTime(2026, 2, 28), + 31, + ); + + expect(monthStartOf(result.slot), DateTime(2026, 2)); + expect(result.offset, 0); + }, + ); + + test("a charge paid a day early belongs to the next month", () { + final ({int slot, int offset}) result = nearestSlot( + DateTime(2026, 1, 31), + 1, + ); + + expect(monthStartOf(result.slot), DateTime(2026, 2)); + expect(result.offset, -1); + }); + + test("a charge slipping into the next month stays with its own", () { + final ({int slot, int offset}) result = nearestSlot( + DateTime(2026, 3, 2), + 31, + ); + + expect(monthStartOf(result.slot), DateTime(2026, 2)); + expect(result.offset, 2); + }); + }); + + group("normalizeTitle", () { + test("lowercases, trims and drops punctuation", () { + expect(normalizeTitle(" Spotify "), "spotify"); + expect(normalizeTitle("SPOTIFY*Premium"), "spotify premium"); + }); + + test("drops tokens with digits, like references and dates", () { + expect(normalizeTitle("NETFLIX #1234"), "netflix"); + expect(normalizeTitle("Rent 2026-09"), "rent"); + }); + + test("keeps non-Latin letters", () { + expect(normalizeTitle("Түрээс"), "түрээс"); + }); + + test("is null when nothing is left", () { + expect(normalizeTitle(null), isNull); + expect(normalizeTitle(""), isNull); + expect(normalizeTitle("12345"), isNull); + expect(normalizeTitle(" -- "), isNull); + }); + }); +} diff --git a/test/unit/insights/insight_properties_test.dart b/test/unit/insights/insight_properties_test.dart new file mode 100644 index 00000000..7d0b1e7f --- /dev/null +++ b/test/unit/insights/insight_properties_test.dart @@ -0,0 +1,163 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight_engine.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "insight_test_data.dart"; + +/// Seeded random ledgers: the engine must stay sane whatever it's fed. +void main() { + const List<String?> titles = [ + null, + "Coffee", + "Grocer", + "Spotify", + "Rent", + "Uber", + "Amazon", + "Gym", + ]; + const List<String?> categories = [null, "food", "fun", "bills", "travel"]; + + TestLedger randomLedger(math.Random random, DateTime now) { + final TestLedger ledger = TestLedger(); + final int months = random.nextInt(16); + + for (int back = months; back >= 0; back--) { + final int count = random.nextInt(60); + + for (int i = 0; i < count; i++) { + final DateTime date = DateTime( + now.year, + now.month - back, + 1 + random.nextInt(31), + random.nextInt(24), + random.nextInt(60), + ); + if (date.isAfter(now)) continue; + + final double roll = random.nextDouble(); + final double amount = switch (roll) { + < 0.01 => random.nextDouble() * 1e7, + < 0.03 => 0.0, + _ => math.exp(random.nextDouble() * 7), + }; + + if (random.nextDouble() < 0.1) { + ledger.earn( + date, + amount, + title: titles[random.nextInt(titles.length)], + category: categories[random.nextInt(categories.length)], + ); + } else { + ledger.spend( + date, + amount, + title: titles[random.nextInt(titles.length)], + category: categories[random.nextInt(categories.length)], + isTransfer: random.nextDouble() < 0.03, + isPending: random.nextDouble() < 0.03, + ); + } + } + } + + return ledger; + } + + test("random ledgers: finite numbers, at most 3, ranked, unique", () { + int produced = 0; + + for (int seed = 0; seed < 300; seed++) { + final math.Random random = math.Random(seed); + final DateTime now = DateTime( + 2024 + random.nextInt(3), + 1 + random.nextInt(12), + 1 + random.nextInt(28), + random.nextInt(24), + ); + final TestLedger ledger = randomLedger(random, now); + final DateTime month = DateTime(now.year, now.month - random.nextInt(3)); + + final InsightReport report = ledger.analyze(month, now: now); + + expect(report.insights.length, lessThanOrEqualTo(3), reason: "$seed"); + produced += report.insights.length; + + final Set<String> keys = {}; + double previous = double.infinity; + + for (final Insight insight in report.insights) { + expect( + numbersOf(insight).every((number) => number.isFinite), + isTrue, + reason: "seed $seed: ${insight.key}", + ); + expect(insight.stake, greaterThanOrEqualTo(0.0), reason: "$seed"); + expect(insight.score, lessThanOrEqualTo(previous), reason: "$seed"); + expect(keys.add(insight.key), isTrue, reason: "$seed"); + previous = insight.score; + } + + final double? usual = report.usualMonthTotal; + if (usual != null) expect(usual.isFinite && usual > 0, isTrue); + } + + // Otherwise the checks above prove nothing. + expect(produced, greaterThan(50)); + }); + + test("identical months never trigger a comparison", () { + for (int seed = 0; seed < 100; seed++) { + final math.Random random = math.Random(seed); + final int count = 10 + random.nextInt(40); + final List<({int day, double amount, String? category})> pattern = [ + for (int i = 0; i < count; i++) + ( + day: 1 + random.nextInt(28), + amount: math.exp(random.nextDouble() * 6), + category: categories[random.nextInt(categories.length)], + ), + ]; + + final DateTime now = DateTime(2026, 9, 1 + random.nextInt(28), 23); + final TestLedger ledger = TestLedger(); + + for (int month = 1; month <= 9; month++) { + for (final entry in pattern) { + if (month == 9 && entry.day > now.day) continue; + + ledger.spend( + DateTime(2026, month, entry.day, 12), + entry.amount, + category: entry.category, + ); + } + } + + for (final DateTime month in [DateTime(2026, 8), DateTime(2026, 9)]) { + expect( + ledger.analyze(month, now: now).insights, + isEmpty, + reason: "seed $seed, $month", + ); + } + } + }); + + test("a limit of zero returns nothing", () { + final TestLedger ledger = TestLedger(); + for (int month = 2; month <= 8; month++) { + ledger.flatMonth(2026, month); + } + ledger.spend(DateTime(2026, 8, 3), 5000.0); + + expect( + ledger + .analyze(DateTime(2026, 8), now: DateTime(2026, 9, 2), limit: 0) + .insights, + isEmpty, + ); + }); +} diff --git a/test/unit/insights/insight_test_data.dart b/test/unit/insights/insight_test_data.dart new file mode 100644 index 00000000..4df62816 --- /dev/null +++ b/test/unit/insights/insight_test_data.dart @@ -0,0 +1,186 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight_engine.dart"; + +/// Builds [InsightTransaction]s with predictable, sortable uuids. +class TestLedger { + final List<InsightTransaction> transactions = []; + + int _counter = 0; + + String _nextUuid() => "t${(_counter++).toString().padLeft(6, "0")}"; + + InsightTransaction spend( + DateTime date, + double amount, { + String? title, + String? category = "misc", + String? account = "checking", + String? recurringUuid, + bool isTransfer = false, + bool isPending = false, + }) => _add( + InsightTransaction( + uuid: _nextUuid(), + date: date, + amount: -amount.abs(), + title: title, + categoryUuid: category, + accountUuid: account, + recurringUuid: recurringUuid, + isTransfer: isTransfer, + isPending: isPending, + ), + ); + + InsightTransaction earn( + DateTime date, + double amount, { + String? title, + String? category = "salary", + }) => _add( + InsightTransaction( + uuid: _nextUuid(), + date: date, + amount: amount.abs(), + title: title, + categoryUuid: category, + accountUuid: "checking", + ), + ); + + InsightTransaction _add(InsightTransaction transaction) { + transactions.add(transaction); + return transaction; + } + + /// [count] untitled expenses of [each], spread over the month. + void flatMonth( + int year, + int month, { + int count = 10, + double each = 100.0, + String category = "misc", + int throughDay = 31, + }) { + final int days = DateTime(year, month + 1, 0).day; + + for (int i = 0; i < count; i++) { + final int day = 1 + (i * days) ~/ count; + if (day > throughDay) continue; + + spend(DateTime(year, month, day, 12), each, category: category); + } + } + + /// A realistic month: tracked rent, Spotify, groceries, dining, transport + /// and coffee, with seeded noise. + void steadyMonth( + int year, + int month, + math.Random random, { + int throughDay = 31, + }) { + final int days = DateTime(year, month + 1, 0).day; + + void add( + int day, + double amount, + String title, + String category, { + String? recurringUuid, + }) { + if (day > throughDay) return; + + spend( + DateTime(year, month, math.min(day, days), 12), + amount, + title: title, + category: category, + recurringUuid: recurringUuid, + ); + } + + add(1, 1200.0, "Rent", "housing", recurringUuid: "rent"); + add(3, 11.99, "Spotify", "subscriptions"); + + for (int i = 0; i < 4; i++) { + add(2 + i * 7, 70.0 + random.nextDouble() * 20, "Grocer", "groceries"); + } + + const List<String> diners = ["Noodle bar", "Pizzeria", "Taqueria", "Cafe"]; + for (int i = 0; i < 5; i++) { + add( + 4 + i * 6, + 25.0 + random.nextDouble() * 15, + diners[random.nextInt(diners.length)], + "dining", + ); + } + + for (int i = 0; i < 6; i++) { + add(2 + i * 5, 12.0 + random.nextDouble() * 6, "Metro", "transport"); + } + + for (int i = 0; i < 10; i++) { + add(1 + i * 3, 4.5, "Coffee", "coffee"); + } + } + + InsightReport analyze( + DateTime month, { + required DateTime now, + List<InsightRecurringTemplate> templates = const [], + Set<InsightType> hiddenTypes = const {}, + List<InsightSighting> sightings = const [], + int limit = 3, + }) => computeInsights( + InsightRequest( + transactions: transactions, + month: month, + now: now, + recurringTemplates: templates, + hiddenTypes: hiddenTypes, + sightings: sightings, + limit: limit, + ), + ); +} + +/// Every number an insight carries, for NaN/infinity checks. +List<double> numbersOf(Insight insight) => [ + insight.score, + insight.stake, + ...switch (insight) { + MonthPaceInsight() => [ + insight.current, + insight.usual, + insight.ratio, + ?insight.ratioWithoutAttribution, + ...insight.history.map((value) => value.amount), + ...insight.drivers.expand((d) => [d.current, d.usual]), + ], + CategorySpikeInsight() => [ + insight.current, + insight.usual, + insight.ratio, + ...insight.history.map((value) => value.amount), + ], + CategoryDropInsight() => [ + insight.current, + insight.usual, + insight.ratio, + ...insight.history.map((value) => value.amount), + ], + NewCategoryInsight() => [insight.current], + RecurringChargeInsight() => [ + insight.series.typicalAmount, + insight.series.annualCost, + ], + PriceChangeInsight() => [ + insight.previousAmount, + insight.currentAmount, + insight.ratio, + ], + }, +]; diff --git a/test/unit/insights/recurring_series_test.dart b/test/unit/insights/recurring_series_test.dart new file mode 100644 index 00000000..3096056d --- /dev/null +++ b/test/unit/insights/recurring_series_test.dart @@ -0,0 +1,430 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/insights/insight_math.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "insight_test_data.dart"; + +void main() { + List<RecurringSeries> detect( + TestLedger ledger, { + List<InsightRecurringTemplate> templates = const [], + }) => detectRecurringSeries(ledger.transactions, templates: templates); + + /// Charges [title] on each of [dates]. + TestLedger charges( + List<DateTime> dates, { + String title = "Spotify", + double amount = 11.99, + List<double>? amounts, + }) { + final TestLedger ledger = TestLedger(); + + for (int i = 0; i < dates.length; i++) { + ledger.spend(dates[i], amounts?[i] ?? amount, title: title); + } + + return ledger; + } + + List<DateTime> monthly(int day, {int from = 1, int count = 6}) => [ + for (int i = 0; i < count; i++) DateTime(2026, from + i, day, 9), + ]; + + group("monthly", () { + test("exact monthly charges", () { + final List<RecurringSeries> found = detect(charges(monthly(3))); + + expect(found, hasLength(1)); + expect(found.single.cadence, RecurringCadence.monthly); + expect(found.single.anchorDay, 3); + expect(found.single.isFixedPrice, isTrue); + expect(found.single.priceChangeIndex, isNull); + expect(found.single.typicalAmount, 11.99); + expect(found.single.annualCost, moreOrLessEquals(143.88)); + expect(found.single.key, "title:spotify"); + }); + + test("up to 3 days of jitter, like weekends", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2026, 1, 3), + DateTime(2026, 2, 5), + DateTime(2026, 3, 2), + DateTime(2026, 4, 6), + DateTime(2026, 5, 3), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + }); + + test("wraps around month starts (30th, 1st, 2nd, 31st)", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2025, 12, 30), + DateTime(2026, 2, 1), + DateTime(2026, 3, 2), + DateTime(2026, 3, 31), + DateTime(2026, 5, 1), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + }); + + test("too much jitter isn't monthly", () { + expect( + detect( + charges([ + DateTime(2026, 1, 3), + DateTime(2026, 2, 11), + DateTime(2026, 3, 1), + DateTime(2026, 4, 12), + ]), + ), + isEmpty, + ); + }); + + test("month ends: Jan 31 -> Feb 28 -> Mar 31 -> Apr 30", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2026, 1, 31), + DateTime(2026, 2, 28), + DateTime(2026, 3, 31), + DateTime(2026, 4, 30), + DateTime(2026, 5, 31), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + expect(found.single.anchorDay, 31); + }); + + test("leap years: Jan 31 -> Feb 29 -> Mar 31", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2024, 1, 31), + DateTime(2024, 2, 29), + DateTime(2024, 3, 31), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + }); + + test("one charge paid ten days early in a long series is tolerated", () { + final List<RecurringSeries> found = detect( + charges([...monthly(28, count: 5), DateTime(2026, 6, 18)]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + expect(found.single.anchorDay, 28); + expect( + monthStartOf(found.single.periodOf(found.single.occurrences.last)), + DateTime(2026, 6), + ); + }); + + test("but not in a short one", () { + expect( + detect(charges([...monthly(28, count: 2), DateTime(2026, 3, 18)])), + isEmpty, + ); + }); + + test("a skipped month is fine", () { + final List<RecurringSeries> found = detect( + charges([ + DateTime(2026, 1, 3), + DateTime(2026, 2, 3), + DateTime(2026, 4, 3), + DateTime(2026, 5, 3), + DateTime(2026, 6, 3), + ]), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + }); + + test("every other month isn't monthly", () { + expect( + detect( + charges([for (int i = 0; i < 5; i++) DateTime(2026, 1 + i * 2, 3)]), + ), + isEmpty, + ); + }); + + test("every 28 days isn't monthly", () { + expect( + detect( + charges([ + for (int i = 0; i < 8; i++) + DateTime(2026, 1, 1).add(Duration(days: 28 * i)), + ]), + ), + isEmpty, + ); + }); + + test("two charges needed at least three times", () { + expect(detect(charges(monthly(3, count: 2))), isEmpty); + }); + + test("a price increase is one series with a change at the end", () { + final List<RecurringSeries> found = detect( + charges( + monthly(15), + amounts: [15.49, 15.49, 15.49, 15.49, 15.49, 17.99], + ), + ); + + expect(found.single.isFixedPrice, isTrue); + expect(found.single.priceChangeIndex, 5); + expect(found.single.previousAmount, 15.49); + expect(found.single.typicalAmount, 17.99); + }); + + test("an older price change settles on the new price", () { + final List<RecurringSeries> found = detect( + charges( + monthly(15), + amounts: [15.49, 15.49, 15.49, 17.99, 17.99, 17.99], + ), + ); + + expect(found.single.priceChangeIndex, 3); + expect(found.single.typicalAmount, 17.99); + }); + + test("small currency wobble is still one fixed price", () { + final List<RecurringSeries> found = detect( + charges( + monthly(15), + amounts: [14.02, 14.31, 13.87, 14.12, 14.40, 13.95], + ), + ); + + expect(found.single.isFixedPrice, isTrue); + expect(found.single.priceChangeIndex, isNull); + }); + + test("a variable bill is monthly but not fixed price", () { + final List<RecurringSeries> found = detect( + charges( + monthly(20), + title: "Electricity", + amounts: [82.0, 140.0, 95.0, 61.0, 120.0, 88.0], + ), + ); + + expect(found.single.cadence, RecurringCadence.monthly); + expect(found.single.isFixedPrice, isFalse); + }); + + test("wildly different amounts aren't a series", () { + expect( + detect( + charges( + monthly(20), + title: "Amazon", + amounts: [5.0, 300.0, 18.0, 90.0, 2.0, 700.0], + ), + ), + isEmpty, + ); + }); + + test("a double charge in one month breaks the pattern", () { + expect( + detect( + charges([ + DateTime(2026, 1, 3), + DateTime(2026, 2, 3), + DateTime(2026, 2, 4), + DateTime(2026, 3, 3), + ]), + ), + isEmpty, + ); + }); + }); + + group("weekly and yearly", () { + test("weekly isn't mislabeled as monthly", () { + final List<RecurringSeries> found = detect( + charges([ + for (int i = 0; i < 8; i++) DateTime(2026, 3, 2 + i * 7, 18), + ], title: "Yoga class"), + ); + + expect(found.single.cadence, RecurringCadence.weekly); + expect(found.single.annualCost, moreOrLessEquals(11.99 * 52)); + }); + + test("weekly across daylight saving still reads 7-day gaps", () { + final List<RecurringSeries> found = detect( + charges([ + for (int i = 0; i < 4; i++) DateTime(2026, 3, 22 + i * 7, 0, 30), + ], title: "Yoga class"), + ); + + expect(found.single.cadence, RecurringCadence.weekly); + }); + + test("every two weeks is neither weekly nor monthly", () { + expect( + detect( + charges([for (int i = 0; i < 8; i++) DateTime(2026, 1, 5 + i * 14)]), + ), + isEmpty, + ); + }); + + test("yearly isn't mislabeled as monthly", () { + final List<RecurringSeries> found = detect( + charges( + [DateTime(2024, 8, 14), DateTime(2025, 8, 14), DateTime(2026, 8, 13)], + title: "Prime", + amount: 139.0, + ), + ); + + expect(found.single.cadence, RecurringCadence.yearly); + expect(found.single.annualCost, 139.0); + }); + + test("two yearly charges are enough when the amount holds", () { + final List<RecurringSeries> found = detect( + charges( + [DateTime(2025, 8, 14), DateTime(2026, 8, 14)], + title: "Prime", + amount: 139.0, + ), + ); + + expect(found.single.cadence, RecurringCadence.yearly); + }); + + test("two yearly charges with different amounts aren't a series", () { + expect( + detect( + charges( + [DateTime(2025, 8, 14), DateTime(2026, 8, 14)], + title: "Dentist", + amounts: [120.0, 480.0], + ), + ), + isEmpty, + ); + }); + }); + + group("grouping", () { + test("two merchants with the same amount stay apart", () { + final TestLedger ledger = TestLedger(); + + for (final DateTime date in monthly(5)) { + ledger.spend(date, 9.99, title: "Netflix"); + ledger.spend(date, 9.99, title: "Hulu"); + } + + final List<RecurringSeries> found = detect(ledger); + + expect(found.map((series) => series.key), [ + "title:hulu", + "title:netflix", + ]); + }); + + test("one title with two plans splits into two series", () { + final TestLedger ledger = TestLedger(); + + for (int i = 0; i < 5; i++) { + ledger.spend(DateTime(2026, 1 + i, 5), 2.99, title: "Apple"); + ledger.spend(DateTime(2026, 1 + i, 12), 10.99, title: "Apple"); + } + + final List<RecurringSeries> found = detect(ledger); + + expect(found, hasLength(2)); + expect( + found.map((series) => series.typicalAmount), + containsAll([2.99, 10.99]), + ); + expect(found.map((series) => series.anchorDay), containsAll([5, 12])); + }); + + test("titles group after normalization", () { + final TestLedger ledger = charges(monthly(3, count: 2)); + ledger.spend(DateTime(2026, 3, 3), 11.99, title: "SPOTIFY #88213"); + + expect(detect(ledger).single.occurrences, hasLength(3)); + }); + + test("untitled charges are never grouped", () { + final TestLedger ledger = TestLedger(); + for (final DateTime date in monthly(3)) { + ledger.spend(date, 11.99); + } + + expect(detect(ledger), isEmpty); + }); + + test("Flow recurring transactions are tracked", () { + final TestLedger ledger = TestLedger(); + for (final DateTime date in monthly(1)) { + ledger.spend(date, 1200.0, title: "Rent", recurringUuid: "r-1"); + } + + final RecurringSeries series = detect(ledger).single; + + expect(series.key, "recurring:r-1"); + expect(series.isTracked, isTrue); + }); + + test("a matching recurring template marks the series as tracked", () { + final List<RecurringSeries> found = detect( + charges(monthly(3)), + templates: const [ + InsightRecurringTemplate(title: "spotify", amount: -11.99), + ], + ); + + expect(found.single.isTracked, isTrue); + }); + + test("a template with another title or amount doesn't match", () { + final List<RecurringSeries> found = detect( + charges(monthly(3)), + templates: const [ + InsightRecurringTemplate(title: "Spotify", amount: 49.99), + InsightRecurringTemplate(title: "Netflix", amount: 11.99), + ], + ); + + expect(found.single.isTracked, isFalse); + }); + + test("the most common category and account win", () { + final TestLedger ledger = TestLedger(); + final List<DateTime> dates = monthly(3); + + for (int i = 0; i < dates.length; i++) { + ledger.spend( + dates[i], + 11.99, + title: "Spotify", + category: i == 0 ? "fun" : "subscriptions", + account: i < 4 ? "card" : "checking", + ); + } + + final RecurringSeries series = detect(ledger).single; + + expect(series.categoryUuid, "subscriptions"); + expect(series.accountUuid, "card"); + }); + }); +} From c37d13aaeb2a645ff730cfa7663220572e4d09e2 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:22:15 +0800 Subject: [PATCH 20/57] fetch exchange rates for the actual primary currency --- lib/main.dart | 24 ++++++++++++------- .../general/approximate_money_text.dart | 4 +++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index b411fbcd..a3e938cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -125,9 +125,6 @@ void main() async { }), ); - startupLog.fine("Initializing exchange rates service"); - ExchangeRatesService().init(); - CurrencyRegistryService(); if (Platform.isIOS) { @@ -146,6 +143,10 @@ void main() async { startupLog.severe("Failed to initialize UserPreferencesService", e); } + // Depends on [UserPreferencesService] for the primary currency + startupLog.fine("Initializing exchange rates service"); + ExchangeRatesService().init(); + try { startupLog.fine("Initializing SyncService"); SyncService(); @@ -189,6 +190,7 @@ class FlowState extends State<Flow> { Locale _locale = FlowLocalizations.supportedLocales.first; DateFormatPreset _dateFormatPreset = .system; + String? _primaryCurrency; ThemeMode _themeMode = ThemeMode.system; ThemeFactory _themeFactory = ThemeFactory.fromThemeName(null); @@ -227,7 +229,7 @@ class FlowState extends State<Flow> { ExchangeRatesService().exchangeRatesCache.addListener(_syncWidgets); LocalPreferences().localeOverride.addListener(_reloadLocale); - LocalPreferences().primaryCurrency.addListener(_refreshExchangeRates); + UserPreferencesService().valueNotifier.addListener(_refreshExchangeRates); _tempLock = LocalPreferences().requireLocalAuth.get(); @@ -290,7 +292,9 @@ class FlowState extends State<Flow> { @override void dispose() { LocalPreferences().localeOverride.removeListener(_reloadLocale); - LocalPreferences().primaryCurrency.removeListener(_refreshExchangeRates); + UserPreferencesService().valueNotifier.removeListener( + _refreshExchangeRates, + ); UserPreferencesService().valueNotifier.removeListener(_reloadTheme); UserPreferencesService().valueNotifier.removeListener(_listenToShakes); UserPreferencesService().valueNotifier.removeListener(_syncWidgets); @@ -489,9 +493,13 @@ class FlowState extends State<Flow> { } void _refreshExchangeRates() { - ExchangeRatesService().tryFetchRates( - UserPreferencesService().primaryCurrency, - ); + final String primaryCurrency = UserPreferencesService().primaryCurrency; + + if (_primaryCurrency == primaryCurrency) return; + + _primaryCurrency = primaryCurrency; + + ExchangeRatesService().tryFetchRates(primaryCurrency); } void _syncWidgets() { diff --git a/lib/widgets/general/approximate_money_text.dart b/lib/widgets/general/approximate_money_text.dart index 53d0f0cd..940af439 100644 --- a/lib/widgets/general/approximate_money_text.dart +++ b/lib/widgets/general/approximate_money_text.dart @@ -38,7 +38,9 @@ class ApproximateMoneyText extends StatelessWidget { Widget build(BuildContext context) { final String primaryCurrency = UserPreferencesService().primaryCurrency; - if (money.currency == primaryCurrency) return const SizedBox.shrink(); + if (money.currency == primaryCurrency || money.amount == 0) { + return const SizedBox.shrink(); + } return ValueListenableBuilder( valueListenable: From cf8d9c90fb1065411bdeb851f35044361688defa Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:22:15 +0800 Subject: [PATCH 21/57] stats: fix share bar, align row bars, compact view toggle --- lib/routes/stats/stats_by_group_page.dart | 9 ++-- lib/widgets/home/stats/group_list_tile.dart | 47 +++++++++++---------- lib/widgets/home/stats/group_share_bar.dart | 1 + 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart index 8618ed6c..01ff4298 100644 --- a/lib/routes/stats/stats_by_group_page.dart +++ b/lib/routes/stats/stats_by_group_page.dart @@ -13,6 +13,7 @@ import "package:flow/objectbox/actions.dart"; import "package:flow/prefs/local_preferences.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; import "package:flow/utils/time_and_range.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/home/stats/group_list_view.dart"; @@ -74,20 +75,22 @@ class StatsByGroupPageState extends State<StatsByGroupPage> onSelectionChanged: (selection) => updateUseChart(selection.first), showSelectedIcon: false, - style: const ButtonStyle( + style: SegmentedButton.styleFrom( visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap, + selectedBackgroundColor: context.colorScheme.primary, + selectedForegroundColor: context.colorScheme.onPrimary, ), segments: [ ButtonSegment<bool>( value: false, icon: const Icon(Symbols.view_list_rounded), - label: Text("tabs.stats.byGroup.view.list".t(context)), + tooltip: "tabs.stats.byGroup.view.list".t(context), ), ButtonSegment<bool>( value: true, icon: const Icon(Symbols.donut_large_rounded), - label: Text("tabs.stats.byGroup.view.chart".t(context)), + tooltip: "tabs.stats.byGroup.view.chart".t(context), ), ], ), diff --git a/lib/widgets/home/stats/group_list_tile.dart b/lib/widgets/home/stats/group_list_tile.dart index 44186cec..bfce4c34 100644 --- a/lib/widgets/home/stats/group_list_tile.dart +++ b/lib/widgets/home/stats/group_list_tile.dart @@ -48,11 +48,31 @@ class GroupListTile extends StatelessWidget { mainAxisSize: .min, crossAxisAlignment: .start, children: [ - Text( - chartData.resolveName(context), - style: context.textTheme.bodyLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, + Row( + spacing: 8.0, + children: [ + Expanded( + child: Text( + chartData.resolveName(context), + style: context.textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + percent < 0.1 + ? "<0.1%" + : "${percent.toStringAsFixed(1)}%", + style: context.textTheme.bodySmall?.semi(context), + ), + MoneyText( + chartData.money, + displayAbsoluteAmount: true, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], ), const SizedBox(height: 6.0), ClipRRect( @@ -68,23 +88,6 @@ class GroupListTile extends StatelessWidget { ], ), ), - Column( - mainAxisSize: .min, - crossAxisAlignment: .end, - children: [ - MoneyText( - chartData.money, - displayAbsoluteAmount: true, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - percent < 0.1 ? "<0.1%" : "${percent.toStringAsFixed(1)}%", - style: context.textTheme.bodySmall?.semi(context), - ), - ], - ), ], ), ), diff --git a/lib/widgets/home/stats/group_share_bar.dart b/lib/widgets/home/stats/group_share_bar.dart index 09fc3e79..b54eb02a 100644 --- a/lib/widgets/home/stats/group_share_bar.dart +++ b/lib/widgets/home/stats/group_share_bar.dart @@ -47,6 +47,7 @@ class GroupShareBar extends StatelessWidget { child: SizedBox( height: height, child: Row( + crossAxisAlignment: .stretch, spacing: 2.0, children: segments .map( From 4af2d3a1f1efb775bbeb420f2b65c7e431a23d6d Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:26:28 +0800 Subject: [PATCH 22/57] preferences: better icon for deleting unused files --- lib/routes/preferences_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart index e856f28b..e4ec6de8 100644 --- a/lib/routes/preferences_page.dart +++ b/lib/routes/preferences_page.dart @@ -255,7 +255,7 @@ class PreferencesPageState extends State<PreferencesPage> { ), ListTile( title: Text("fileAttachment.cleanupHangingFiles".t(context)), - leading: const Icon(Symbols.bug_report_rounded), + leading: const Icon(Symbols.folder_delete_rounded), onTap: () => _deleteHangingFiles(), trailing: const LeChevron(), ), From f7f168391017a58d333c289a53d833ac90c358ce Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:37:29 +0800 Subject: [PATCH 23/57] stats: open categories as list, drop sort option --- assets/l10n/ar.json | 3 -- assets/l10n/be_BY.json | 3 -- assets/l10n/cs_CZ.json | 3 -- assets/l10n/de_DE.json | 3 -- assets/l10n/en.json | 3 -- assets/l10n/es_ES.json | 3 -- assets/l10n/fa_IR.json | 3 -- assets/l10n/fr_FR.json | 3 -- assets/l10n/it_IT.json | 3 -- assets/l10n/mn_MN.json | 3 -- assets/l10n/pl_PL.json | 3 -- assets/l10n/ru_RU.json | 3 -- assets/l10n/tr_TR.json | 3 -- assets/l10n/uk_UA.json | 3 -- assets/l10n/zh_CN.json | 3 -- assets/l10n/zh_TW.json | 3 -- lib/data/group_sort_mode.dart | 15 -------- lib/prefs/local_preferences.dart | 9 ----- lib/routes/stats/stats_by_group_page.dart | 17 +-------- lib/widgets/home/stats/group_list_view.dart | 40 +-------------------- 20 files changed, 2 insertions(+), 127 deletions(-) delete mode 100644 lib/data/group_sort_mode.dart diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index ac2418c4..af4490cc 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "مصروف", "enum.FlowButtonType@income": "إيراد", "enum.FlowButtonType@transfer": "تحويل", - "enum.GroupSortMode@alphabetical": "الاسم", - "enum.GroupSortMode@amount": "المبلغ", "enum.ImportCSVProgress@creatingAccounts": "يجري إنشاء الحسابات", "enum.ImportCSVProgress@creatingCategories": "يجري إنشاء الفئات", "enum.ImportCSVProgress@creatingTransactions": "يجري إنشاء المعاملات", @@ -810,7 +808,6 @@ "tabs.stats.byGroup.categoryCount.one": "{count} فئة", "tabs.stats.byGroup.categoryCount.two": "{count} فئتان", "tabs.stats.byGroup.categoryCount.zero": "{count} فئات", - "tabs.stats.byGroup.percentOfTotal": "% من الإجمالي", "tabs.stats.byGroup.totalEarned": "إجمالي الدخل", "tabs.stats.byGroup.totalSpent": "إجمالي الإنفاق", "tabs.stats.byGroup.view.chart": "مخطط", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 72863a89..740294d7 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Выдаткі", "enum.FlowButtonType@income": "Даходы", "enum.FlowButtonType@transfer": "Перавод", - "enum.GroupSortMode@alphabetical": "Назва", - "enum.GroupSortMode@amount": "Сума", "enum.ImportCSVProgress@creatingAccounts": "Стварэнне рахункаў", "enum.ImportCSVProgress@creatingCategories": "Стварэнне катэгорый", "enum.ImportCSVProgress@creatingTransactions": "Стварэнне транзакцый", @@ -804,7 +802,6 @@ "tabs.stats.byGroup.categoryCount.few": "{count} катэгорыі", "tabs.stats.byGroup.categoryCount.many": "{count} катэгорый", "tabs.stats.byGroup.categoryCount.one": "{count} катэгорыя", - "tabs.stats.byGroup.percentOfTotal": "% ад агульнага", "tabs.stats.byGroup.totalEarned": "Усяго атрымана", "tabs.stats.byGroup.totalSpent": "Усяго выдаткавана", "tabs.stats.byGroup.view.chart": "Дыяграма", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index d17261af..6d39706e 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Výdaj", "enum.FlowButtonType@income": "Příjem", "enum.FlowButtonType@transfer": "Převod", - "enum.GroupSortMode@alphabetical": "Název", - "enum.GroupSortMode@amount": "Částka", "enum.ImportCSVProgress@creatingAccounts": "Vytváření účtů", "enum.ImportCSVProgress@creatingCategories": "Vytváření kategorií", "enum.ImportCSVProgress@creatingTransactions": "Vytváření transakcí", @@ -801,7 +799,6 @@ "tabs.stats.byGroup.categoryCount": "{count} kategorií", "tabs.stats.byGroup.categoryCount.few": "{count} kategorie", "tabs.stats.byGroup.categoryCount.one": "{count} kategorie", - "tabs.stats.byGroup.percentOfTotal": "% z celku", "tabs.stats.byGroup.totalEarned": "Celkem přijato", "tabs.stats.byGroup.totalSpent": "Celkem utraceno", "tabs.stats.byGroup.view.chart": "Graf", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index f511eba6..7bf1eb0a 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Ausgabe", "enum.FlowButtonType@income": "Einnahme", "enum.FlowButtonType@transfer": "Umbuchung", - "enum.GroupSortMode@alphabetical": "Name", - "enum.GroupSortMode@amount": "Betrag", "enum.ImportCSVProgress@creatingAccounts": "Konten werden erstellt", "enum.ImportCSVProgress@creatingCategories": "Kategorien werden erstellt", "enum.ImportCSVProgress@creatingTransactions": "Buchungen werden erstellt", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} Konto", "tabs.stats.byGroup.categoryCount": "{count} Kategorien", "tabs.stats.byGroup.categoryCount.one": "{count} Kategorie", - "tabs.stats.byGroup.percentOfTotal": "% der Summe", "tabs.stats.byGroup.totalEarned": "Gesamt eingenommen", "tabs.stats.byGroup.totalSpent": "Gesamt ausgegeben", "tabs.stats.byGroup.view.chart": "Diagramm", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b35b71c4..9aae1514 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Expense", "enum.FlowButtonType@income": "Income", "enum.FlowButtonType@transfer": "Transfer", - "enum.GroupSortMode@alphabetical": "Name", - "enum.GroupSortMode@amount": "Amount", "enum.ImportCSVProgress@creatingAccounts": "Creating accounts", "enum.ImportCSVProgress@creatingCategories": "Creating categories", "enum.ImportCSVProgress@creatingTransactions": "Creating transactions", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} account", "tabs.stats.byGroup.categoryCount": "{count} categories", "tabs.stats.byGroup.categoryCount.one": "{count} category", - "tabs.stats.byGroup.percentOfTotal": "% of total", "tabs.stats.byGroup.totalEarned": "Total earned", "tabs.stats.byGroup.totalSpent": "Total spent", "tabs.stats.byGroup.view.chart": "Chart", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 53ff9c39..cddd8ac4 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Gasto", "enum.FlowButtonType@income": "Ingreso", "enum.FlowButtonType@transfer": "Transferencia", - "enum.GroupSortMode@alphabetical": "Nombre", - "enum.GroupSortMode@amount": "Importe", "enum.ImportCSVProgress@creatingAccounts": "Creando cuentas", "enum.ImportCSVProgress@creatingCategories": "Creando categorías", "enum.ImportCSVProgress@creatingTransactions": "Creando transacciones", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} cuenta", "tabs.stats.byGroup.categoryCount": "{count} categorías", "tabs.stats.byGroup.categoryCount.one": "{count} categoría", - "tabs.stats.byGroup.percentOfTotal": "% del total", "tabs.stats.byGroup.totalEarned": "Total ingresado", "tabs.stats.byGroup.totalSpent": "Total gastado", "tabs.stats.byGroup.view.chart": "Gráfico", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 6b05c44b..ae88c4b0 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "هزینه", "enum.FlowButtonType@income": "درآمد", "enum.FlowButtonType@transfer": "انتقال", - "enum.GroupSortMode@alphabetical": "نام", - "enum.GroupSortMode@amount": "مبلغ", "enum.ImportCSVProgress@creatingAccounts": "در حال ساخت حساب‌ها", "enum.ImportCSVProgress@creatingCategories": "در حال ساخت دسته‌بندی‌ها", "enum.ImportCSVProgress@creatingTransactions": "در حال ساخت تراکنش‌ها", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} حساب", "tabs.stats.byGroup.categoryCount": "{count} دسته‌بندی", "tabs.stats.byGroup.categoryCount.one": "{count} دسته‌بندی", - "tabs.stats.byGroup.percentOfTotal": "٪ از کل", "tabs.stats.byGroup.totalEarned": "کل درآمد", "tabs.stats.byGroup.totalSpent": "کل هزینه", "tabs.stats.byGroup.view.chart": "نمودار", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 7998aded..2d76d149 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Dépense", "enum.FlowButtonType@income": "Revenu", "enum.FlowButtonType@transfer": "Transfert", - "enum.GroupSortMode@alphabetical": "Nom", - "enum.GroupSortMode@amount": "Montant", "enum.ImportCSVProgress@creatingAccounts": "Création des comptes", "enum.ImportCSVProgress@creatingCategories": "Création des catégories", "enum.ImportCSVProgress@creatingTransactions": "Création des transactions", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} compte", "tabs.stats.byGroup.categoryCount": "{count} catégories", "tabs.stats.byGroup.categoryCount.one": "{count} catégorie", - "tabs.stats.byGroup.percentOfTotal": "% du total", "tabs.stats.byGroup.totalEarned": "Total gagné", "tabs.stats.byGroup.totalSpent": "Total dépensé", "tabs.stats.byGroup.view.chart": "Graphique", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 56567819..33ec719c 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Spesa", "enum.FlowButtonType@income": "Entrata", "enum.FlowButtonType@transfer": "Trasferimento", - "enum.GroupSortMode@alphabetical": "Nome", - "enum.GroupSortMode@amount": "Importo", "enum.ImportCSVProgress@creatingAccounts": "Creazione dei conti", "enum.ImportCSVProgress@creatingCategories": "Creazione delle categorie", "enum.ImportCSVProgress@creatingTransactions": "Creazione delle transazioni", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} conto", "tabs.stats.byGroup.categoryCount": "{count} categorie", "tabs.stats.byGroup.categoryCount.one": "{count} categoria", - "tabs.stats.byGroup.percentOfTotal": "% del totale", "tabs.stats.byGroup.totalEarned": "Totale entrate", "tabs.stats.byGroup.totalSpent": "Totale spese", "tabs.stats.byGroup.view.chart": "Grafico", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index caaa2a12..61d7ca3d 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Зарлага", "enum.FlowButtonType@income": "Орлого", "enum.FlowButtonType@transfer": "Шилжүүлэг", - "enum.GroupSortMode@alphabetical": "Нэр", - "enum.GroupSortMode@amount": "Дүн", "enum.ImportCSVProgress@creatingAccounts": "Дансуудыг үүсгэж байна", "enum.ImportCSVProgress@creatingCategories": "Ангиллуудыг үүсгэж байна", "enum.ImportCSVProgress@creatingTransactions": "Гүйлгээнүүдийг үүсгэж байна", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} данс", "tabs.stats.byGroup.categoryCount": "{count} ангилал", "tabs.stats.byGroup.categoryCount.one": "{count} ангилал", - "tabs.stats.byGroup.percentOfTotal": "Эзлэх хувь", "tabs.stats.byGroup.totalEarned": "Нийт орлого", "tabs.stats.byGroup.totalSpent": "Нийт зарлага", "tabs.stats.byGroup.view.chart": "График", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index 38a3c48d..9ac4229b 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Wydatek", "enum.FlowButtonType@income": "Przychód", "enum.FlowButtonType@transfer": "Przelew", - "enum.GroupSortMode@alphabetical": "Nazwa", - "enum.GroupSortMode@amount": "Kwota", "enum.ImportCSVProgress@creatingAccounts": "Tworzenie kont", "enum.ImportCSVProgress@creatingCategories": "Tworzenie kategorii", "enum.ImportCSVProgress@creatingTransactions": "Tworzenie transakcji", @@ -804,7 +802,6 @@ "tabs.stats.byGroup.categoryCount.few": "{count} kategorie", "tabs.stats.byGroup.categoryCount.many": "{count} kategorii", "tabs.stats.byGroup.categoryCount.one": "{count} kategoria", - "tabs.stats.byGroup.percentOfTotal": "% całości", "tabs.stats.byGroup.totalEarned": "Łącznie zarobione", "tabs.stats.byGroup.totalSpent": "Łącznie wydane", "tabs.stats.byGroup.view.chart": "Wykres", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 8989a120..d7f84242 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Расход", "enum.FlowButtonType@income": "Доход", "enum.FlowButtonType@transfer": "Перевод", - "enum.GroupSortMode@alphabetical": "Название", - "enum.GroupSortMode@amount": "Сумма", "enum.ImportCSVProgress@creatingAccounts": "Создание счетов", "enum.ImportCSVProgress@creatingCategories": "Создание категорий", "enum.ImportCSVProgress@creatingTransactions": "Создание транзакций", @@ -804,7 +802,6 @@ "tabs.stats.byGroup.categoryCount.few": "{count} категории", "tabs.stats.byGroup.categoryCount.many": "{count} категорий", "tabs.stats.byGroup.categoryCount.one": "{count} категория", - "tabs.stats.byGroup.percentOfTotal": "% от общего", "tabs.stats.byGroup.totalEarned": "Всего получено", "tabs.stats.byGroup.totalSpent": "Всего потрачено", "tabs.stats.byGroup.view.chart": "Диаграмма", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index e7ab84c4..c529f7a6 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Gider", "enum.FlowButtonType@income": "Gelir", "enum.FlowButtonType@transfer": "Aktarmak", - "enum.GroupSortMode@alphabetical": "Ad", - "enum.GroupSortMode@amount": "Miktar", "enum.ImportCSVProgress@creatingAccounts": "Hesaplar oluşturuluyor", "enum.ImportCSVProgress@creatingCategories": "Kategoriler oluşturuluyor", "enum.ImportCSVProgress@creatingTransactions": "İşlemler oluşturuluyor", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} hesap", "tabs.stats.byGroup.categoryCount": "{count} kategori", "tabs.stats.byGroup.categoryCount.one": "{count} kategori", - "tabs.stats.byGroup.percentOfTotal": "Toplam payı", "tabs.stats.byGroup.totalEarned": "Toplam gelir", "tabs.stats.byGroup.totalSpent": "Toplam harcama", "tabs.stats.byGroup.view.chart": "Grafik", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 5b7b2d30..0e2ed04f 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "Витрата", "enum.FlowButtonType@income": "Дохід", "enum.FlowButtonType@transfer": "Переказ", - "enum.GroupSortMode@alphabetical": "Назва", - "enum.GroupSortMode@amount": "Сума", "enum.ImportCSVProgress@creatingAccounts": "Створення рахунків", "enum.ImportCSVProgress@creatingCategories": "Створення категорій", "enum.ImportCSVProgress@creatingTransactions": "Створення транзакцій", @@ -804,7 +802,6 @@ "tabs.stats.byGroup.categoryCount.few": "{count} категорії", "tabs.stats.byGroup.categoryCount.many": "{count} категорій", "tabs.stats.byGroup.categoryCount.one": "{count} категорія", - "tabs.stats.byGroup.percentOfTotal": "% від загального", "tabs.stats.byGroup.totalEarned": "Усього отримано", "tabs.stats.byGroup.totalSpent": "Усього витрачено", "tabs.stats.byGroup.view.chart": "Діаграма", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index 5143461d..b9c54948 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "支出", "enum.FlowButtonType@income": "收入", "enum.FlowButtonType@transfer": "转账", - "enum.GroupSortMode@alphabetical": "名称", - "enum.GroupSortMode@amount": "金额", "enum.ImportCSVProgress@creatingAccounts": "正在创建账户", "enum.ImportCSVProgress@creatingCategories": "正在创建分类", "enum.ImportCSVProgress@creatingTransactions": "正在创建交易", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} 个账户", "tabs.stats.byGroup.categoryCount": "{count} 个分类", "tabs.stats.byGroup.categoryCount.one": "{count} 个分类", - "tabs.stats.byGroup.percentOfTotal": "占比", "tabs.stats.byGroup.totalEarned": "总收入", "tabs.stats.byGroup.totalSpent": "总支出", "tabs.stats.byGroup.view.chart": "图表", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 4ce4861e..afe3e595 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -143,8 +143,6 @@ "enum.FlowButtonType@expense": "支出", "enum.FlowButtonType@income": "收入", "enum.FlowButtonType@transfer": "轉帳", - "enum.GroupSortMode@alphabetical": "名稱", - "enum.GroupSortMode@amount": "金額", "enum.ImportCSVProgress@creatingAccounts": "正在建立帳戶", "enum.ImportCSVProgress@creatingCategories": "正在建立分類", "enum.ImportCSVProgress@creatingTransactions": "正在建立交易", @@ -798,7 +796,6 @@ "tabs.stats.byGroup.accountCount.one": "{count} 個帳戶", "tabs.stats.byGroup.categoryCount": "{count} 個分類", "tabs.stats.byGroup.categoryCount.one": "{count} 個分類", - "tabs.stats.byGroup.percentOfTotal": "佔比", "tabs.stats.byGroup.totalEarned": "總收入", "tabs.stats.byGroup.totalSpent": "總支出", "tabs.stats.byGroup.view.chart": "圖表", diff --git a/lib/data/group_sort_mode.dart b/lib/data/group_sort_mode.dart deleted file mode 100644 index 6eaede69..00000000 --- a/lib/data/group_sort_mode.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:flow/l10n/named_enum.dart"; - -enum GroupSortMode with LocalizedEnum { - amount, - alphabetical; - - GroupSortMode get next => - GroupSortMode.values[(index + 1) % GroupSortMode.values.length]; - - @override - String get localizationEnumName => "GroupSortMode"; - - @override - String get localizationEnumValue => name; -} diff --git a/lib/prefs/local_preferences.dart b/lib/prefs/local_preferences.dart index 1854039e..4fd65e13 100644 --- a/lib/prefs/local_preferences.dart +++ b/lib/prefs/local_preferences.dart @@ -65,9 +65,6 @@ class LocalPreferences { /// it flips to true on the first tap, after which the badge never shows again. late final BoolSettingsEntry openedInsightsIndex; - /// Whether the by-category/account stats page opens in chart mode - late final BoolSettingsEntry statsByGroupUseChart; - /// Number of notifications issued by the app /// /// Used to prevent id collisions @@ -165,12 +162,6 @@ class LocalPreferences { initialValue: false, ); - statsByGroupUseChart = BoolSettingsEntry( - key: "statsByGroupUseChart", - preferences: _prefs, - initialValue: false, - ); - lastRequestedAppStoreReview = DateTimeSettingsEntry( key: "lastRequestedAppStoreReview", preferences: _prefs, diff --git a/lib/routes/stats/stats_by_group_page.dart b/lib/routes/stats/stats_by_group_page.dart index 01ff4298..0a336259 100644 --- a/lib/routes/stats/stats_by_group_page.dart +++ b/lib/routes/stats/stats_by_group_page.dart @@ -3,7 +3,6 @@ import "dart:async"; import "package:flow/data/chart_data.dart"; import "package:flow/data/exchange_rates.dart"; import "package:flow/data/flow_analytics.dart"; -import "package:flow/data/group_sort_mode.dart"; import "package:flow/data/money.dart"; import "package:flow/data/multi_currency_flow.dart"; import "package:flow/entity/transaction.dart"; @@ -46,9 +45,7 @@ class StatsByGroupPageState extends State<StatsByGroupPage> FlowAnalytics? analytics; bool busy = false; - bool useChart = LocalPreferences().statsByGroupUseChart.get(); - - GroupSortMode sortMode = .amount; + bool useChart = false; @override void initState() { @@ -165,8 +162,6 @@ class StatsByGroupPageState extends State<StatsByGroupPage> range: range, type: TransactionType.expense, byCategory: widget.byCategory, - sortMode: sortMode, - onSortModeChanged: updateSortMode, ), useChart ? PieGraphView( @@ -180,8 +175,6 @@ class StatsByGroupPageState extends State<StatsByGroupPage> range: range, type: TransactionType.income, byCategory: widget.byCategory, - sortMode: sortMode, - onSortModeChanged: updateSortMode, ), ], ), @@ -198,14 +191,6 @@ class StatsByGroupPageState extends State<StatsByGroupPage> setState(() { useChart = newValue; }); - - unawaited(LocalPreferences().statsByGroupUseChart.set(newValue)); - } - - void updateSortMode(GroupSortMode newMode) { - setState(() { - sortMode = newMode; - }); } void updateRange(TimeRange newRange) { diff --git a/lib/widgets/home/stats/group_list_view.dart b/lib/widgets/home/stats/group_list_view.dart index b1316d44..8f0c4c86 100644 --- a/lib/widgets/home/stats/group_list_view.dart +++ b/lib/widgets/home/stats/group_list_view.dart @@ -1,17 +1,12 @@ import "package:flow/data/chart_data.dart"; -import "package:flow/data/group_sort_mode.dart"; import "package:flow/entity/category.dart"; import "package:flow/entity/transaction.dart"; -import "package:flow/l10n/extensions.dart"; -import "package:flow/l10n/named_enum.dart"; -import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/chart_data.dart"; import "package:flow/widgets/home/stats/group_list_tile.dart"; import "package:flow/widgets/home/stats/group_summary_card.dart"; import "package:flow/widgets/home/stats/no_data.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; class GroupListView extends StatelessWidget { @@ -22,9 +17,6 @@ class GroupListView extends StatelessWidget { final bool byCategory; final void Function() changeMode; - final GroupSortMode sortMode; - final ValueChanged<GroupSortMode> onSortModeChanged; - const GroupListView({ super.key, required this.data, @@ -32,8 +24,6 @@ class GroupListView extends StatelessWidget { required this.type, required this.byCategory, required this.changeMode, - required this.sortMode, - required this.onSortModeChanged, }); @override @@ -46,16 +36,7 @@ class GroupListView extends StatelessWidget { final double total = data.displayTotal; final double maxAmount = data.values.first.displayTotal; - final List<ChartData> sorted = switch (sortMode) { - .amount => data.values.toList(), - .alphabetical => - data.values.toList()..sort( - (a, b) => a - .resolveName(context) - .toLowerCase() - .compareTo(b.resolveName(context).toLowerCase()), - ), - }; + final List<ChartData> sorted = data.values.toList(); return CustomScrollView( slivers: [ @@ -67,25 +48,6 @@ class GroupListView extends StatelessWidget { byCategory: byCategory, ), ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - sliver: SliverToBoxAdapter( - child: Row( - mainAxisAlignment: .spaceBetween, - children: [ - ActionChip( - avatar: const Icon(Symbols.swap_vert_rounded), - label: Text(sortMode.localizedNameContext(context)), - onPressed: () => onSortModeChanged(sortMode.next), - ), - Text( - "tabs.stats.byGroup.percentOfTotal".t(context), - style: context.textTheme.bodySmall?.semi(context), - ), - ], - ), - ), - ), SliverPadding( padding: const EdgeInsets.only(bottom: 96.0), sliver: SliverList.builder( From 82b1f83767e29ddf34274bcdd7a75cadbeb2f691 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:40:03 +0800 Subject: [PATCH 24/57] update changelog, fix #760 --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7633ad..9ce24503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.25.0 + +### Features + +* Transactions in a foreign currency now show an approximate amount in your + primary currency (e.g., "≈ R$25"). Can be turned off in Money formatting, + closes [#760](https://github.com/flow-mn/flow/issues/760) +* Added date format preference, and an option to show exact dates in list + headers, [#761](https://github.com/flow-mn/flow/issues/761) +* Categories/accounts stats now open as a ranked list with share bars, + [#761](https://github.com/flow-mn/flow/issues/761) + +### Changes + +* Reorganized preferences +* Pie chart now uses category colors, shows the total, and opens the category + when you tap a selected slice again + +### Fixes + +* Fixed exchange rates being fetched for the wrong currency at startup, and + not refreshing after changing the primary currency + ## 0.24.1 ### Fixes From 8611d5acb65bee54c96dac6686de21033c932237 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:43:52 +0800 Subject: [PATCH 25/57] insights: hidden kinds and sightings in local prefs --- lib/prefs/insights_preferences.dart | 99 +++++++++++++++++++ lib/prefs/local_preferences.dart | 3 + .../insights/insights_preferences_test.dart | 69 +++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 lib/prefs/insights_preferences.dart create mode 100644 test/unit/insights/insights_preferences_test.dart diff --git a/lib/prefs/insights_preferences.dart b/lib/prefs/insights_preferences.dart new file mode 100644 index 00000000..7d6aceae --- /dev/null +++ b/lib/prefs/insights_preferences.dart @@ -0,0 +1,99 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; +import "package:local_settings/local_settings.dart"; +import "package:logging/logging.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +final Logger _log = Logger("InsightsLocalPreferences"); + +class InsightsLocalPreferences { + final SharedPreferencesWithCache _prefs; + + static InsightsLocalPreferences? _instance; + + factory InsightsLocalPreferences() { + if (_instance == null) { + throw Exception( + "You must initialize InsightsLocalPreferences by calling initialize().", + ); + } + + return _instance!; + } + + /// [InsightType.name]s the user chose not to see. + late final StringListSettingsEntry hiddenTypes; + + /// Insights shown for the current month, latest per key. Used for the + /// engine's cooldown. + late final JsonListSettingsEntry<InsightSighting> sightings; + + InsightsLocalPreferences._internal(this._prefs) { + SettingsEntry.defaultPrefix = "flow.insights."; + + hiddenTypes = StringListSettingsEntry( + key: "hiddenTypes", + preferences: _prefs, + removeDuplicates: true, + ); + + sightings = JsonListSettingsEntry<InsightSighting>( + key: "sightings", + preferences: _prefs, + fromJson: (json) => InsightSighting( + key: json["key"] as String, + shownAt: DateTime.parse(json["shownAt"] as String), + stake: (json["stake"] as num).toDouble(), + ), + toJson: (sighting) => { + "key": sighting.key, + "shownAt": sighting.shownAt.toIso8601String(), + "stake": sighting.stake, + }, + ); + } + + Set<InsightType> get hiddenTypeSet { + final Map<String, InsightType> byName = InsightType.values.asNameMap(); + + return (hiddenTypes.get() ?? const []) + .map((name) => byName[name]) + .nonNulls + .toSet(); + } + + Future<void> setHidden(InsightType type, bool hidden) => hidden + ? hiddenTypes.addItem(type.name) + : hiddenTypes.removeItem(type.name); + + List<InsightSighting> get sightingList { + try { + return sightings.get() ?? const []; + } catch (e, stackTrace) { + _log.warning("Failed to read insight sightings", e, stackTrace); + return const []; + } + } + + /// Replaces older sightings of the same keys, and forgets the ones too old + /// to matter. + Future<void> recordSightings(List<Insight> insights, DateTime now) async { + final Set<String> keys = insights.map((insight) => insight.key).toSet(); + final DateTime cutoff = now.subtract( + const Duration(days: InsightThresholds.cooldownDays * 2), + ); + + await sightings.set([ + ...sightingList.where( + (sighting) => + !keys.contains(sighting.key) && sighting.shownAt.isAfter(cutoff), + ), + for (final Insight insight in insights) + InsightSighting(key: insight.key, shownAt: now, stake: insight.stake), + ]); + } + + static InsightsLocalPreferences initialize( + SharedPreferencesWithCache instance, + ) => _instance ??= InsightsLocalPreferences._internal(instance); +} diff --git a/lib/prefs/local_preferences.dart b/lib/prefs/local_preferences.dart index f7a25e50..55cddc5a 100644 --- a/lib/prefs/local_preferences.dart +++ b/lib/prefs/local_preferences.dart @@ -6,6 +6,7 @@ import "package:flow/logging.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/objectbox.g.dart"; import "package:flow/prefs/eny_preferences.dart"; +import "package:flow/prefs/insights_preferences.dart"; import "package:flow/prefs/pending_transactions.dart"; import "package:flow/prefs/transitive.dart"; import "package:intl/intl.dart"; @@ -69,6 +70,7 @@ class LocalPreferences { late final PendingTransactionsLocalPreferences pendingTransactions; late final TransitiveLocalPreferences transitive; late final EnyLocalPreferences eny; + late final InsightsLocalPreferences insights; LocalPreferences._internal(this._prefs) { SettingsEntry.defaultPrefix = "flow."; @@ -170,6 +172,7 @@ class LocalPreferences { ); transitive = TransitiveLocalPreferences.initialize(_prefs); eny = EnyLocalPreferences.initialize(_prefs); + insights = InsightsLocalPreferences.initialize(_prefs); } @Deprecated("Use UserPreferencesService().primaryCurrency instead") diff --git a/test/unit/insights/insights_preferences_test.dart b/test/unit/insights/insights_preferences_test.dart new file mode 100644 index 00000000..8cbc3d7e --- /dev/null +++ b/test/unit/insights/insights_preferences_test.dart @@ -0,0 +1,69 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/prefs/insights_preferences.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +void main() { + final InsightsLocalPreferences preferences = + InsightsLocalPreferences.initialize(_MemoryPreferences()); + + setUp(() async { + await preferences.hiddenTypes.set([]); + await preferences.sightings.set([]); + }); + + test("hidden types round trip and ignore unknown names", () async { + await preferences.setHidden(.monthPace, true); + await preferences.setHidden(.priceChange, true); + await preferences.hiddenTypes.addItem("somethingRemoved"); + + expect(preferences.hiddenTypeSet, { + InsightType.monthPace, + InsightType.priceChange, + }); + + await preferences.setHidden(.monthPace, false); + expect(preferences.hiddenTypeSet, {InsightType.priceChange}); + }); + + test("sightings keep the latest per key and forget old ones", () async { + const NewCategoryInsight pets = NewCategoryInsight( + score: 0.1, + categoryUuid: "pets", + current: 140.0, + entryCount: 2, + transactionUuids: [], + ); + const NewCategoryInsight garden = NewCategoryInsight( + score: 0.1, + categoryUuid: "garden", + current: 90.0, + entryCount: 1, + transactionUuids: [], + ); + + await preferences.recordSightings([pets, garden], DateTime(2026, 6, 1)); + await preferences.recordSightings([pets], DateTime(2026, 8, 20)); + + final List<InsightSighting> sightings = preferences.sightingList; + expect(sightings.map((sighting) => sighting.key), [pets.key]); + expect(sightings.single.shownAt, DateTime(2026, 8, 20)); + expect(sightings.single.stake, 140.0); + }); +} + +class _MemoryPreferences extends Fake implements SharedPreferencesWithCache { + final Map<String, Object> _values = {}; + + @override + bool containsKey(String key) => _values.containsKey(key); + + @override + List<String>? getStringList(String key) => + (_values[key] as List<String>?)?.toList(); + + @override + Future<void> setStringList(String key, List<String> value) async { + _values[key] = value.toList(); + } +} From 3c4f230785a348c47de504e745ac34a4c6d7d00b Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:43:52 +0800 Subject: [PATCH 26/57] transactions: range link, suggested recurrence on edit --- lib/routes.dart | 12 ++++++++++-- lib/routes/transaction_page.dart | 16 +++++++++++++--- lib/routes/transactions_page.dart | 14 ++++++++++++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/lib/routes.dart b/lib/routes.dart index 5e90b659..8c75ee99 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -79,6 +79,7 @@ import "package:flow/sync/import/import_v2.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; final GlobalKey<NavigatorState> globalNavigatorKey = GlobalKey<NavigatorState>(); @@ -148,14 +149,21 @@ final GoRouter router = GoRouter( pageBuilder: (context, state) => MaterialPage( child: TransactionPage.edit( transactionId: int.tryParse(state.pathParameters["id"]!) ?? -1, + initialRecurrence: Recurrence.tryParse( + state.uri.queryParameters["recurrence"] ?? "", + ), ), fullscreenDialog: true, ), ), GoRoute( path: "/transactions", - builder: (context, state) => - TransactionsPage.all(title: "transactions.all".t(context)), + builder: (context, state) => TransactionsPage.all( + title: "transactions.all".t(context), + initialRange: TimeRange.tryParse( + state.uri.queryParameters["range"] ?? "", + ), + ), ), GoRoute( path: "/transactions/pending", diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index 766ac2f8..e29a27e8 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -77,11 +77,19 @@ class TransactionPage extends StatefulWidget { final TransactionProgrammableObject? params; + /// Suggested recurrence for a transaction that isn't recurring yet. + final Recurrence? initialRecurrence; + bool get isNewTransaction => transactionId == 0; - const TransactionPage.create({super.key, this.params}) : transactionId = 0; - const TransactionPage.edit({super.key, required this.transactionId}) - : params = null; + const TransactionPage.create({super.key, this.params}) + : transactionId = 0, + initialRecurrence = null; + const TransactionPage.edit({ + super.key, + required this.transactionId, + this.initialRecurrence, + }) : params = null; @override State<TransactionPage> createState() => _TransactionPageState(); @@ -240,6 +248,8 @@ class _TransactionPageState extends State<TransactionPage> { _currentlyEditing.extensions.recurring?.uuid, ); _recurrence = _recurringTransaction?.recurrence; + } else { + _recurrence = widget.initialRecurrence; } } } diff --git a/lib/routes/transactions_page.dart b/lib/routes/transactions_page.dart index 4190f446..62ad5862 100644 --- a/lib/routes/transactions_page.dart +++ b/lib/routes/transactions_page.dart @@ -66,7 +66,12 @@ class TransactionsPage extends StatefulWidget { return TransactionsPage(queryFn: queryBuilder, key: key, title: title); } - factory TransactionsPage.all({Key? key, String? title, Widget? header}) { + factory TransactionsPage.all({ + Key? key, + String? title, + Widget? header, + TimeRange? initialRange, + }) { QueryBuilder<Transaction> queryBuilder(TimeRange range) => TransactionFilter( sortBy: TransactionSortField.transactionDate, @@ -74,7 +79,12 @@ class TransactionsPage extends StatefulWidget { range: TransactionFilterTimeRange.fromTimeRange(range), ).queryBuilder(); - return TransactionsPage(queryFn: queryBuilder, key: key, title: title); + return TransactionsPage( + queryFn: queryBuilder, + key: key, + title: title, + initialRange: initialRange, + ); } factory TransactionsPage.pending({ From 3804e79126a2a928b1569f02c8593b90b029d60a Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 25 Sep 2026 23:43:52 +0800 Subject: [PATCH 27/57] insights: worth knowing card on stats --- assets/l10n/ar.json | 71 ++++ assets/l10n/be_BY.json | 67 ++++ assets/l10n/cs_CZ.json | 67 ++++ assets/l10n/de_DE.json | 65 +++ assets/l10n/en.json | 65 +++ assets/l10n/es_ES.json | 65 +++ assets/l10n/fa_IR.json | 65 +++ assets/l10n/fr_FR.json | 65 +++ assets/l10n/it_IT.json | 65 +++ assets/l10n/mn_MN.json | 65 +++ assets/l10n/pl_PL.json | 67 ++++ assets/l10n/ru_RU.json | 67 ++++ assets/l10n/tr_TR.json | 65 +++ assets/l10n/uk_UA.json | 67 ++++ assets/l10n/zh_CN.json | 65 +++ assets/l10n/zh_TW.json | 65 +++ .../home/stats/bento/analytics_bento.dart | 9 +- .../home/stats/bento/worth_knowing_card.dart | 177 +++++++++ lib/widgets/insights/insight_actions.dart | 73 ++++ lib/widgets/insights/insight_driver_tile.dart | 65 +++ .../insights/insight_evidence_sheet.dart | 368 +++++++++++++++++ lib/widgets/insights/insight_formatter.dart | 39 ++ .../insights/insight_history_chart.dart | 150 +++++++ lib/widgets/insights/insight_icon_badge.dart | 25 ++ lib/widgets/insights/insight_kinds_sheet.dart | 41 ++ lib/widgets/insights/insight_summary.dart | 270 +++++++++++++ lib/widgets/insights/worth_knowing_row.dart | 86 ++++ lib/widgets/insights/worth_knowing_view.dart | 187 +++++++++ lib/widgets/stats/wrapped/mini_bars.dart | 17 +- .../unit/insights/insight_formatter_test.dart | 41 ++ test/widget/insight_history_chart_test.dart | 62 +++ test/widget/worth_knowing_view_test.dart | 376 ++++++++++++++++++ 32 files changed, 3036 insertions(+), 6 deletions(-) create mode 100644 lib/widgets/home/stats/bento/worth_knowing_card.dart create mode 100644 lib/widgets/insights/insight_actions.dart create mode 100644 lib/widgets/insights/insight_driver_tile.dart create mode 100644 lib/widgets/insights/insight_evidence_sheet.dart create mode 100644 lib/widgets/insights/insight_formatter.dart create mode 100644 lib/widgets/insights/insight_history_chart.dart create mode 100644 lib/widgets/insights/insight_icon_badge.dart create mode 100644 lib/widgets/insights/insight_kinds_sheet.dart create mode 100644 lib/widgets/insights/insight_summary.dart create mode 100644 lib/widgets/insights/worth_knowing_row.dart create mode 100644 lib/widgets/insights/worth_knowing_view.dart create mode 100644 test/unit/insights/insight_formatter_test.dart create mode 100644 test/widget/insight_history_chart_test.dart create mode 100644 test/widget/worth_knowing_view_test.dart diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index e1f16610..a976fc5b 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -816,6 +816,77 @@ "tabs.stats.trends.median.description": "الوسيط لمبلغ المصروفات في النطاق الزمني المحدد", "tabs.stats.trends.topSpendingTitles": "المعاملات المتكررة", "tabs.stats.trends.topSpendingTitles.description": "أكثر عناوين المعاملات استخدامًا", + "tabs.stats.worthKnowing": "يستحق المعرفة", + "tabs.stats.worthKnowing.biggestEntries": "أكبر القيود", + "tabs.stats.worthKnowing.categoryDrop": "{category}: انخفاض بنسبة {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: انخفاض بنسبة {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} المعتاد.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: كان {value} المعتاد.", + "tabs.stats.worthKnowing.charges": "الدفعات", + "tabs.stats.worthKnowing.chartBefore": "سابقًا {amount}", + "tabs.stats.worthKnowing.chartCharges": "كل دفعة", + "tabs.stats.worthKnowing.chartMonthly": "الإنفاق كل شهر", + "tabs.stats.worthKnowing.chartThroughDay": "الإنفاق حتى اليوم {dayNumber} من كل شهر", + "tabs.stats.worthKnowing.chartUsual": "المعتاد {amount}", + "tabs.stats.worthKnowing.entries": "{count} قيود", + "tabs.stats.worthKnowing.entries.many": "{count} قيدًا", + "tabs.stats.worthKnowing.entries.one": "قيد واحد", + "tabs.stats.worthKnowing.entries.other": "{count} قيد", + "tabs.stats.worthKnowing.entries.two": "قيدان", + "tabs.stats.worthKnowing.hidden": "تم الإخفاء. يمكنك إعادة تفعيله من «يستحق المعرفة».", + "tabs.stats.worthKnowing.hide.categoryDrop": "عدم إظهار انخفاضات الفئات", + "tabs.stats.worthKnowing.hide.categorySpike": "عدم إظهار ارتفاعات الفئات", + "tabs.stats.worthKnowing.hide.monthPace": "عدم إظهار مقارنات الشهر", + "tabs.stats.worthKnowing.hide.newCategory": "عدم إظهار الفئات الجديدة", + "tabs.stats.worthKnowing.hide.priceChange": "عدم إظهار تغيّرات الأسعار", + "tabs.stats.worthKnowing.hide.recurringCharge": "عدم إظهار اقتراحات الاشتراكات", + "tabs.stats.worthKnowing.kinds": "الإظهار في «يستحق المعرفة»", + "tabs.stats.worthKnowing.lessThanUsual": "أقل من المعتاد بـ {amount}", + "tabs.stats.worthKnowing.monthAbove": "{month}: أعلى من المعتاد بنسبة {value} حتى اليوم {dayNumber}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: أعلى من المعتاد بنسبة {value}.", + "tabs.stats.worthKnowing.monthBelow": "{month}: أقل من المعتاد بنسبة {value} حتى اليوم {dayNumber}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: أقل من المعتاد بنسبة {value}.", + "tabs.stats.worthKnowing.moreThanUsual": "أكثر من المعتاد بـ {amount}", + "tabs.stats.worthKnowing.mostlyCategory": "غالبًا {category}، {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "غالبًا {title} ({amount}). بدونه، ضمن المعتاد.", + "tabs.stats.worthKnowing.mostlyOver": "غالبًا {title} ({amount}). بدونه، لا يزال أعلى بنسبة {percent}.", + "tabs.stats.worthKnowing.mostlyUnder": "غالبًا {title} ({amount}). بدونه، أقل بنسبة {percent}.", + "tabs.stats.worthKnowing.newCategory": "{value}: أول إنفاق منذ {months} أشهر.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: لا شيء غير معتاد حتى الآن.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: لا شيء غير معتاد.", + "tabs.stats.worthKnowing.priceChange": "{title}: من {previous} إلى {value}.", + "tabs.stats.worthKnowing.priceDown": "أقل بـ {amount} سنويًا", + "tabs.stats.worthKnowing.priceUp": "أكثر بـ {amount} سنويًا", + "tabs.stats.worthKnowing.recurringMonthly": "{value} يبدو دفعة شهرية.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} حوالي اليوم {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} يبدو دفعة أسبوعية.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} كل {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} يبدو دفعة سنوية.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} حوالي {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "يظهر عندما تنخفض فئة تستخدمها معظم الأشهر إلى أقل من {percent} من وسيطها.", + "tabs.stats.worthKnowing.rule.categorySpike": "يظهر عندما تبلغ فئة {ratio} وسيطها في الأشهر {months} السابقة، مع {count} قيود أو أكثر.", + "tabs.stats.worthKnowing.rule.footer": "التحويلات والقيود المعلّقة غير محسوبة. تم الحساب على هذا الجهاز.", + "tabs.stats.worthKnowing.rule.monthPace": "الإنفاق حتى {date} مقارنةً بوسيط الأشهر {months} السابقة حتى اليوم نفسه.", + "tabs.stats.worthKnowing.rule.monthPacePast": "إنفاق {month} مقارنةً بوسيط الأشهر {months} السابقة.", + "tabs.stats.worthKnowing.rule.newCategory": "يظهر عندما يكون لفئة إنفاق لأول مرة منذ {months} أشهر.", + "tabs.stats.worthKnowing.rule.priceChange": "يظهر عندما تتغير دفعة متكررة بنسبة {percent} أو أكثر.", + "tabs.stats.worthKnowing.rule.recurringCharge": "دفعات بالعنوان نفسه ومبلغ ثابت تتكرر بانتظام.", + "tabs.stats.worthKnowing.seeTransactions": "عرض هذه المعاملات", + "tabs.stats.worthKnowing.times": "{count} مرات حتى الآن", + "tabs.stats.worthKnowing.times.many": "{count} مرة حتى الآن", + "tabs.stats.worthKnowing.times.one": "مرة واحدة حتى الآن", + "tabs.stats.worthKnowing.times.other": "{count} مرة حتى الآن", + "tabs.stats.worthKnowing.times.two": "مرتان حتى الآن", + "tabs.stats.worthKnowing.track": "تتبّع كمعاملة متكررة", + "tabs.stats.worthKnowing.type.categoryDrop": "انخفاضات الفئات", + "tabs.stats.worthKnowing.type.categorySpike": "ارتفاعات الفئات", + "tabs.stats.worthKnowing.type.monthPace": "مقارنات الشهر", + "tabs.stats.worthKnowing.type.newCategory": "الفئات الجديدة", + "tabs.stats.worthKnowing.type.priceChange": "تغيّرات الأسعار", + "tabs.stats.worthKnowing.type.recurringCharge": "اقتراحات الاشتراكات", + "tabs.stats.worthKnowing.versusTypical": "{current} مقابل {usual} المعتاد", + "tabs.stats.worthKnowing.whatMovedIt": "ما الذي غيّره", + "tabs.stats.worthKnowing.why": "لماذا ترى هذا", "transaction": "معاملة", "transaction.actions": "الإجراءات", "transaction.attachments": "المرفقات", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 12330f6f..012d2fec 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -814,6 +814,73 @@ "tabs.stats.trends.median.description": "Медыяна сумы выдаткаў у выбраным дыяпазоне часу", "tabs.stats.trends.topSpendingTitles": "Частыя транзакцыі", "tabs.stats.trends.topSpendingTitles.description": "Назвы транзакцый, што сустракаюцца часцей за ўсё", + "tabs.stats.worthKnowing": "Варта ведаць", + "tabs.stats.worthKnowing.biggestEntries": "Найбуйнейшыя запісы", + "tabs.stats.worthKnowing.categoryDrop": "{category}: менш на {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: было менш на {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} ад звычайнага.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: было {value} ад звычайнага.", + "tabs.stats.worthKnowing.charges": "Спісанні", + "tabs.stats.worthKnowing.chartBefore": "раней {amount}", + "tabs.stats.worthKnowing.chartCharges": "Кожнае спісанне", + "tabs.stats.worthKnowing.chartMonthly": "Выдаткі па месяцах", + "tabs.stats.worthKnowing.chartThroughDay": "Выдаткі да {dayNumber}-га чысла кожнага месяца", + "tabs.stats.worthKnowing.chartUsual": "звычайна {amount}", + "tabs.stats.worthKnowing.entries": "{count} запісаў", + "tabs.stats.worthKnowing.entries.few": "{count} запісы", + "tabs.stats.worthKnowing.entries.one": "{count} запіс", + "tabs.stats.worthKnowing.hidden": "Схавана. Можна вярнуць у картцы «Варта ведаць».", + "tabs.stats.worthKnowing.hide.categoryDrop": "Не паказваць зніжэнні па катэгорыях", + "tabs.stats.worthKnowing.hide.categorySpike": "Не паказваць усплёскі па катэгорыях", + "tabs.stats.worthKnowing.hide.monthPace": "Не паказваць параўнанні месяца", + "tabs.stats.worthKnowing.hide.newCategory": "Не паказваць новыя катэгорыі", + "tabs.stats.worthKnowing.hide.priceChange": "Не паказваць змены цэн", + "tabs.stats.worthKnowing.hide.recurringCharge": "Не паказваць падказкі пра падпіскі", + "tabs.stats.worthKnowing.kinds": "Паказваць у «Варта ведаць»", + "tabs.stats.worthKnowing.lessThanUsual": "на {amount} менш, чым звычайна", + "tabs.stats.worthKnowing.monthAbove": "{month}: на {value} вышэй за звычайнае да {dayNumber}-га чысла.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: на {value} вышэй за звычайнае.", + "tabs.stats.worthKnowing.monthBelow": "{month}: на {value} ніжэй за звычайнае да {dayNumber}-га чысла.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: на {value} ніжэй за звычайнае.", + "tabs.stats.worthKnowing.moreThanUsual": "на {amount} больш, чым звычайна", + "tabs.stats.worthKnowing.mostlyCategory": "Пераважна {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Пераважна {title} ({amount}). Без гэтага — як звычайна.", + "tabs.stats.worthKnowing.mostlyOver": "Пераважна {title} ({amount}). Без гэтага ўсё роўна на {percent} вышэй.", + "tabs.stats.worthKnowing.mostlyUnder": "Пераважна {title} ({amount}). Без гэтага на {percent} ніжэй.", + "tabs.stats.worthKnowing.newCategory": "{value}: першыя выдаткі за {months} мес.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: пакуль нічога незвычайнага.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: нічога незвычайнага.", + "tabs.stats.worthKnowing.priceChange": "{title}: было {previous}, стала {value}.", + "tabs.stats.worthKnowing.priceDown": "на {amount} менш за год", + "tabs.stats.worthKnowing.priceUp": "на {amount} больш за год", + "tabs.stats.worthKnowing.recurringMonthly": "{value} падобна на штомесячны плацёж.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} каля {dayNumber}-га чысла · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} падобна на штотыднёвы плацёж.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} падобна на штогадовы плацёж.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} каля {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "З'яўляецца, калі катэгорыя, якой вы карыстаецеся амаль штомесяц, падае ніжэй за {percent} ад сваёй медыяны.", + "tabs.stats.worthKnowing.rule.categorySpike": "З'яўляецца, калі катэгорыя дасягае {ratio} ад сваёй медыяны за {months} папярэдніх мес. і мае не менш за {count} запісы.", + "tabs.stats.worthKnowing.rule.footer": "Пераводы і чаканыя запісы не ўлічваюцца. Падлічана на гэтай прыладзе.", + "tabs.stats.worthKnowing.rule.monthPace": "Выдаткі да {date} у параўнанні з медыянай {months} папярэдніх мес. на той жа дзень.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Выдаткі за перыяд «{month}» у параўнанні з медыянай {months} папярэдніх мес.", + "tabs.stats.worthKnowing.rule.newCategory": "З'яўляецца, калі ў катэгорыі ўпершыню за {months} мес. ёсць выдаткі.", + "tabs.stats.worthKnowing.rule.priceChange": "З'яўляецца, калі рэгулярны плацёж мяняецца на {percent} ці больш.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Плацяжы з аднолькавай назвай і сталай сумай, якія рэгулярна паўтараюцца.", + "tabs.stats.worthKnowing.seeTransactions": "Паказаць гэтыя транзакцыі", + "tabs.stats.worthKnowing.times": "ужо {count} разоў", + "tabs.stats.worthKnowing.times.few": "ужо {count} разы", + "tabs.stats.worthKnowing.times.one": "ужо {count} раз", + "tabs.stats.worthKnowing.track": "Зрабіць перыядычнай", + "tabs.stats.worthKnowing.type.categoryDrop": "Зніжэнні па катэгорыях", + "tabs.stats.worthKnowing.type.categorySpike": "Усплёскі па катэгорыях", + "tabs.stats.worthKnowing.type.monthPace": "Параўнанні месяца", + "tabs.stats.worthKnowing.type.newCategory": "Новыя катэгорыі", + "tabs.stats.worthKnowing.type.priceChange": "Змены цэн", + "tabs.stats.worthKnowing.type.recurringCharge": "Падказкі пра падпіскі", + "tabs.stats.worthKnowing.versusTypical": "{current} супраць звычайных {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Што паўплывала", + "tabs.stats.worthKnowing.why": "Чаму вы гэта бачыце", "transaction": "Транзакцыя", "transaction.actions": "Дзеянні", "transaction.attachments": "Прымацаваныя файлы", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 25fe07cf..6400a4ca 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -813,6 +813,73 @@ "tabs.stats.trends.median.description": "Střední hodnota výdajů ve vybraném časovém období.", "tabs.stats.trends.topSpendingTitles": "Nejčastější transakce", "tabs.stats.trends.topSpendingTitles.description": "Nejčastěji používané názvy transakcí.", + "tabs.stats.worthKnowing": "Dobré vědět", + "tabs.stats.worthKnowing.biggestEntries": "Největší záznamy", + "tabs.stats.worthKnowing.categoryDrop": "{category}: pokles o {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: pokles o {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} obvyklé částky.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: {value} obvyklé částky.", + "tabs.stats.worthKnowing.charges": "Platby", + "tabs.stats.worthKnowing.chartBefore": "dříve {amount}", + "tabs.stats.worthKnowing.chartCharges": "Každá platba", + "tabs.stats.worthKnowing.chartMonthly": "Výdaje v každém měsíci", + "tabs.stats.worthKnowing.chartThroughDay": "Výdaje do {dayNumber}. dne každého měsíce", + "tabs.stats.worthKnowing.chartUsual": "obvykle {amount}", + "tabs.stats.worthKnowing.entries": "{count} záznamů", + "tabs.stats.worthKnowing.entries.few": "{count} záznamy", + "tabs.stats.worthKnowing.entries.one": "{count} záznam", + "tabs.stats.worthKnowing.hidden": "Skryto. Znovu zapnete v kartě Dobré vědět.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Nezobrazovat poklesy kategorií", + "tabs.stats.worthKnowing.hide.categorySpike": "Nezobrazovat výkyvy kategorií", + "tabs.stats.worthKnowing.hide.monthPace": "Nezobrazovat srovnání měsíce", + "tabs.stats.worthKnowing.hide.newCategory": "Nezobrazovat nové kategorie", + "tabs.stats.worthKnowing.hide.priceChange": "Nezobrazovat změny cen", + "tabs.stats.worthKnowing.hide.recurringCharge": "Nezobrazovat návrhy předplatného", + "tabs.stats.worthKnowing.kinds": "Zobrazovat v Dobré vědět", + "tabs.stats.worthKnowing.lessThanUsual": "o {amount} méně než obvykle", + "tabs.stats.worthKnowing.monthAbove": "{month}: o {value} nad obvyklou úrovní k {dayNumber}. dni.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: o {value} nad obvyklou úrovní.", + "tabs.stats.worthKnowing.monthBelow": "{month}: o {value} pod obvyklou úrovní k {dayNumber}. dni.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: o {value} pod obvyklou úrovní.", + "tabs.stats.worthKnowing.moreThanUsual": "o {amount} více než obvykle", + "tabs.stats.worthKnowing.mostlyCategory": "Hlavně {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Hlavně {title} ({amount}). Bez toho zhruba obvykle.", + "tabs.stats.worthKnowing.mostlyOver": "Hlavně {title} ({amount}). Bez toho stále o {percent} výš.", + "tabs.stats.worthKnowing.mostlyUnder": "Hlavně {title} ({amount}). Bez toho o {percent} níž.", + "tabs.stats.worthKnowing.newCategory": "{value}: první výdaje za {months} měsíců.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: zatím nic neobvyklého.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: nic neobvyklého.", + "tabs.stats.worthKnowing.priceChange": "{title}: dříve {previous}, nyní {value}.", + "tabs.stats.worthKnowing.priceDown": "o {amount} méně ročně", + "tabs.stats.worthKnowing.priceUp": "o {amount} více ročně", + "tabs.stats.worthKnowing.recurringMonthly": "{value} vypadá jako měsíční platba.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} kolem {dayNumber}. dne · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} vypadá jako týdenní platba.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} vypadá jako roční platba.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} kolem {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Zobrazí se, když kategorie používaná téměř každý měsíc klesne pod {percent} svého mediánu.", + "tabs.stats.worthKnowing.rule.categorySpike": "Zobrazí se, když kategorie dosáhne {ratio} svého mediánu za {months} předchozích měsíců a má alespoň {count} záznamy.", + "tabs.stats.worthKnowing.rule.footer": "Převody a čekající záznamy se nezapočítávají. Spočítáno na tomto zařízení.", + "tabs.stats.worthKnowing.rule.monthPace": "Výdaje do {date} ve srovnání s mediánem {months} předchozích měsíců ke stejnému dni.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Výdaje za {month} ve srovnání s mediánem {months} předchozích měsíců.", + "tabs.stats.worthKnowing.rule.newCategory": "Zobrazí se, když má kategorie výdaje poprvé za {months} měsíců.", + "tabs.stats.worthKnowing.rule.priceChange": "Zobrazí se, když se pravidelná platba změní o {percent} nebo více.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Platby se stejným názvem a stálou částkou, které se pravidelně opakují.", + "tabs.stats.worthKnowing.seeTransactions": "Zobrazit tyto transakce", + "tabs.stats.worthKnowing.times": "zatím {count}krát", + "tabs.stats.worthKnowing.times.few": "zatím {count}krát", + "tabs.stats.worthKnowing.times.one": "zatím {count}krát", + "tabs.stats.worthKnowing.track": "Sledovat jako opakující se", + "tabs.stats.worthKnowing.type.categoryDrop": "Poklesy kategorií", + "tabs.stats.worthKnowing.type.categorySpike": "Výkyvy kategorií", + "tabs.stats.worthKnowing.type.monthPace": "Srovnání měsíce", + "tabs.stats.worthKnowing.type.newCategory": "Nové kategorie", + "tabs.stats.worthKnowing.type.priceChange": "Změny cen", + "tabs.stats.worthKnowing.type.recurringCharge": "Návrhy předplatného", + "tabs.stats.worthKnowing.versusTypical": "{current} vs obvyklé {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Co to ovlivnilo", + "tabs.stats.worthKnowing.why": "Proč to vidíte", "transaction": "Transakce", "transaction.actions": "Akce", "transaction.attachments": "Přílohy", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 8bd1995d..f7ea1af9 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "Median des Ausgabenbetrags im ausgewählten Zeitraum", "tabs.stats.trends.topSpendingTitles": "Häufige Buchungen", "tabs.stats.trends.topSpendingTitles.description": "Am häufigsten verwendete Buchungstitel", + "tabs.stats.worthKnowing": "Gut zu wissen", + "tabs.stats.worthKnowing.biggestEntries": "Größte Buchungen", + "tabs.stats.worthKnowing.categoryDrop": "{category} ist um {value} gesunken.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} lag {value} niedriger.", + "tabs.stats.worthKnowing.categorySpike": "{category} liegt beim {value} des Üblichen.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} lag beim {value} des Üblichen.", + "tabs.stats.worthKnowing.charges": "Abbuchungen", + "tabs.stats.worthKnowing.chartBefore": "vorher {amount}", + "tabs.stats.worthKnowing.chartCharges": "Jede Abbuchung", + "tabs.stats.worthKnowing.chartMonthly": "Ausgaben pro Monat", + "tabs.stats.worthKnowing.chartThroughDay": "Ausgaben bis zum {day} jedes Monats", + "tabs.stats.worthKnowing.chartUsual": "üblich {amount}", + "tabs.stats.worthKnowing.entries": "{count} Buchungen", + "tabs.stats.worthKnowing.entries.one": "{count} Buchung", + "tabs.stats.worthKnowing.hidden": "Ausgeblendet. Unter „Gut zu wissen“ wieder einschalten.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Kategorie-Rückgänge ausblenden", + "tabs.stats.worthKnowing.hide.categorySpike": "Kategorie-Spitzen ausblenden", + "tabs.stats.worthKnowing.hide.monthPace": "Monatsvergleiche ausblenden", + "tabs.stats.worthKnowing.hide.newCategory": "Neue Kategorien ausblenden", + "tabs.stats.worthKnowing.hide.priceChange": "Preisänderungen ausblenden", + "tabs.stats.worthKnowing.hide.recurringCharge": "Abo-Vorschläge ausblenden", + "tabs.stats.worthKnowing.kinds": "In „Gut zu wissen“ zeigen", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} weniger als üblich", + "tabs.stats.worthKnowing.monthAbove": "{month} liegt bis zum {day} {value} über dem Üblichen.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} lag {value} über dem Üblichen.", + "tabs.stats.worthKnowing.monthBelow": "{month} liegt bis zum {day} {value} unter dem Üblichen.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} lag {value} unter dem Üblichen.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} mehr als üblich", + "tabs.stats.worthKnowing.mostlyCategory": "Vor allem {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Vor allem {title} ({amount}). Ohne das etwa wie üblich.", + "tabs.stats.worthKnowing.mostlyOver": "Vor allem {title} ({amount}). Ohne das immer noch {percent} darüber.", + "tabs.stats.worthKnowing.mostlyUnder": "Vor allem {title} ({amount}). Ohne das {percent} darunter.", + "tabs.stats.worthKnowing.newCategory": "Erste Ausgaben für {value} seit {months} Monaten.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: bisher nichts Ungewöhnliches.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: nichts Ungewöhnliches.", + "tabs.stats.worthKnowing.priceChange": "{title} kostet jetzt {value} statt {previous}.", + "tabs.stats.worthKnowing.priceDown": "{amount} weniger pro Jahr", + "tabs.stats.worthKnowing.priceUp": "{amount} mehr pro Jahr", + "tabs.stats.worthKnowing.recurringMonthly": "{value} sieht nach einer monatlichen Zahlung aus.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} um den {day} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} sieht nach einer wöchentlichen Zahlung aus.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} jeden {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} sieht nach einer jährlichen Zahlung aus.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} um den {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Erscheint, wenn eine fast jeden Monat genutzte Kategorie unter {percent} ihres Medians fällt.", + "tabs.stats.worthKnowing.rule.categorySpike": "Erscheint, wenn eine Kategorie das {ratio} ihres Medians der letzten {months} Monate erreicht, bei mindestens {count} Buchungen.", + "tabs.stats.worthKnowing.rule.footer": "Überweisungen und ausstehende Buchungen zählen nicht mit. Auf diesem Gerät berechnet.", + "tabs.stats.worthKnowing.rule.monthPace": "Ausgaben bis {date}, verglichen mit dem Median der letzten {months} Monate bis zum selben Tag.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Ausgaben im {month}, verglichen mit dem Median der letzten {months} Monate.", + "tabs.stats.worthKnowing.rule.newCategory": "Erscheint, wenn eine Kategorie zum ersten Mal seit {months} Monaten Ausgaben hat.", + "tabs.stats.worthKnowing.rule.priceChange": "Erscheint, wenn sich eine wiederkehrende Zahlung um {percent} oder mehr ändert.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Zahlungen mit gleichem Titel und festem Betrag, die regelmäßig wiederkehren.", + "tabs.stats.worthKnowing.seeTransactions": "Diese Buchungen ansehen", + "tabs.stats.worthKnowing.times": "bisher {count}-mal", + "tabs.stats.worthKnowing.times.one": "bisher {count}-mal", + "tabs.stats.worthKnowing.track": "Als wiederkehrend erfassen", + "tabs.stats.worthKnowing.type.categoryDrop": "Kategorie-Rückgänge", + "tabs.stats.worthKnowing.type.categorySpike": "Kategorie-Spitzen", + "tabs.stats.worthKnowing.type.monthPace": "Monatsvergleiche", + "tabs.stats.worthKnowing.type.newCategory": "Neue Kategorien", + "tabs.stats.worthKnowing.type.priceChange": "Preisänderungen", + "tabs.stats.worthKnowing.type.recurringCharge": "Abo-Vorschläge", + "tabs.stats.worthKnowing.versusTypical": "{current} statt üblich {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Was es bewegt hat", + "tabs.stats.worthKnowing.why": "Warum Sie das sehen", "transaction": "Buchung", "transaction.actions": "Aktionen", "transaction.attachments": "Dateianhänge", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 7c8ff122..6a8c9c9b 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "Median of the expense amount in the selected time range", "tabs.stats.trends.topSpendingTitles": "Frequent transactions", "tabs.stats.trends.topSpendingTitles.description": "Most frequently used transaction titles", + "tabs.stats.worthKnowing": "Worth knowing", + "tabs.stats.worthKnowing.biggestEntries": "Biggest entries", + "tabs.stats.worthKnowing.categoryDrop": "{category} is down {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} was down {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category} is {value} your usual.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} was {value} your usual.", + "tabs.stats.worthKnowing.charges": "Charges", + "tabs.stats.worthKnowing.chartBefore": "before {amount}", + "tabs.stats.worthKnowing.chartCharges": "Each charge", + "tabs.stats.worthKnowing.chartMonthly": "Spent each month", + "tabs.stats.worthKnowing.chartThroughDay": "Spent by the {day} of each month", + "tabs.stats.worthKnowing.chartUsual": "usual {amount}", + "tabs.stats.worthKnowing.entries": "{count} entries", + "tabs.stats.worthKnowing.entries.one": "{count} entry", + "tabs.stats.worthKnowing.hidden": "Hidden. Turn it back on from Worth knowing.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Don't show category drops", + "tabs.stats.worthKnowing.hide.categorySpike": "Don't show category spikes", + "tabs.stats.worthKnowing.hide.monthPace": "Don't show month comparisons", + "tabs.stats.worthKnowing.hide.newCategory": "Don't show new categories", + "tabs.stats.worthKnowing.hide.priceChange": "Don't show price changes", + "tabs.stats.worthKnowing.hide.recurringCharge": "Don't show subscription suggestions", + "tabs.stats.worthKnowing.kinds": "Show in Worth knowing", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} less than usual", + "tabs.stats.worthKnowing.monthAbove": "{month} is {value} above your usual by the {day}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} was {value} above your usual.", + "tabs.stats.worthKnowing.monthBelow": "{month} is {value} below your usual by the {day}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} was {value} below your usual.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} more than usual", + "tabs.stats.worthKnowing.mostlyCategory": "Mostly {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Mostly {title} ({amount}). Without it, about usual.", + "tabs.stats.worthKnowing.mostlyOver": "Mostly {title} ({amount}). Without it, still {percent} over.", + "tabs.stats.worthKnowing.mostlyUnder": "Mostly {title} ({amount}). Without it, {percent} under.", + "tabs.stats.worthKnowing.newCategory": "First {value} spending in {months} months.", + "tabs.stats.worthKnowing.nothingUnusual": "Nothing unusual in {month} so far.", + "tabs.stats.worthKnowing.nothingUnusualPast": "Nothing unusual in {month}.", + "tabs.stats.worthKnowing.priceChange": "{title} went from {previous} to {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} less a year", + "tabs.stats.worthKnowing.priceUp": "{amount} more a year", + "tabs.stats.worthKnowing.recurringMonthly": "{value} looks like a monthly charge.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} around the {day} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} looks like a weekly charge.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} every {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} looks like a yearly charge.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} around {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Shown when a category you use most months falls under {percent} of its median.", + "tabs.stats.worthKnowing.rule.categorySpike": "Shown when a category reaches {ratio} its median of the previous {months} months, with {count} or more entries.", + "tabs.stats.worthKnowing.rule.footer": "Transfers and pending entries are left out. Worked out on this device.", + "tabs.stats.worthKnowing.rule.monthPace": "Spending through {date}, compared with the median of the previous {months} months through the same day.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Spending in {month}, compared with the median of the previous {months} months.", + "tabs.stats.worthKnowing.rule.newCategory": "Shown when a category has spending for the first time in {months} months.", + "tabs.stats.worthKnowing.rule.priceChange": "Shown when a repeating charge changes by {percent} or more.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Charges with the same title and a steady amount, repeating on a regular schedule.", + "tabs.stats.worthKnowing.seeTransactions": "See these transactions", + "tabs.stats.worthKnowing.times": "{count} times so far", + "tabs.stats.worthKnowing.times.one": "{count} time so far", + "tabs.stats.worthKnowing.track": "Track as recurring", + "tabs.stats.worthKnowing.type.categoryDrop": "Category drops", + "tabs.stats.worthKnowing.type.categorySpike": "Category spikes", + "tabs.stats.worthKnowing.type.monthPace": "Month comparisons", + "tabs.stats.worthKnowing.type.newCategory": "New categories", + "tabs.stats.worthKnowing.type.priceChange": "Price changes", + "tabs.stats.worthKnowing.type.recurringCharge": "Subscription suggestions", + "tabs.stats.worthKnowing.versusTypical": "{current} vs {usual} typical", + "tabs.stats.worthKnowing.whatMovedIt": "What moved it", + "tabs.stats.worthKnowing.why": "Why you're seeing this", "transaction": "Transaction", "transaction.actions": "Actions", "transaction.attachments": "File attachments", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 8953bfd6..117aadf7 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "Mediana del monto de gasto en el rango de tiempo seleccionado", "tabs.stats.trends.topSpendingTitles": "Transacciones frecuentes", "tabs.stats.trends.topSpendingTitles.description": "Títulos de transacciones más utilizados frecuentemente", + "tabs.stats.worthKnowing": "Conviene saber", + "tabs.stats.worthKnowing.biggestEntries": "Mayores movimientos", + "tabs.stats.worthKnowing.categoryDrop": "{category} ha bajado un {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} bajó un {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category} está en {value} lo habitual.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} estuvo en {value} lo habitual.", + "tabs.stats.worthKnowing.charges": "Cargos", + "tabs.stats.worthKnowing.chartBefore": "antes {amount}", + "tabs.stats.worthKnowing.chartCharges": "Cada cargo", + "tabs.stats.worthKnowing.chartMonthly": "Gasto de cada mes", + "tabs.stats.worthKnowing.chartThroughDay": "Gasto hasta el día {dayNumber} de cada mes", + "tabs.stats.worthKnowing.chartUsual": "habitual {amount}", + "tabs.stats.worthKnowing.entries": "{count} movimientos", + "tabs.stats.worthKnowing.entries.one": "{count} movimiento", + "tabs.stats.worthKnowing.hidden": "Oculto. Puedes reactivarlo desde Conviene saber.", + "tabs.stats.worthKnowing.hide.categoryDrop": "No mostrar bajadas de categoría", + "tabs.stats.worthKnowing.hide.categorySpike": "No mostrar picos de categoría", + "tabs.stats.worthKnowing.hide.monthPace": "No mostrar comparaciones del mes", + "tabs.stats.worthKnowing.hide.newCategory": "No mostrar categorías nuevas", + "tabs.stats.worthKnowing.hide.priceChange": "No mostrar cambios de precio", + "tabs.stats.worthKnowing.hide.recurringCharge": "No mostrar sugerencias de suscripción", + "tabs.stats.worthKnowing.kinds": "Mostrar en Conviene saber", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} menos de lo habitual", + "tabs.stats.worthKnowing.monthAbove": "{month} va un {value} por encima de lo habitual al día {dayNumber}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} estuvo un {value} por encima de lo habitual.", + "tabs.stats.worthKnowing.monthBelow": "{month} va un {value} por debajo de lo habitual al día {dayNumber}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} estuvo un {value} por debajo de lo habitual.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} más de lo habitual", + "tabs.stats.worthKnowing.mostlyCategory": "Sobre todo {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Sobre todo {title} ({amount}). Sin eso, lo habitual.", + "tabs.stats.worthKnowing.mostlyOver": "Sobre todo {title} ({amount}). Sin eso, aún un {percent} por encima.", + "tabs.stats.worthKnowing.mostlyUnder": "Sobre todo {title} ({amount}). Sin eso, un {percent} por debajo.", + "tabs.stats.worthKnowing.newCategory": "Primer gasto en {value} en {months} meses.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: nada fuera de lo normal por ahora.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: nada fuera de lo normal.", + "tabs.stats.worthKnowing.priceChange": "{title} pasó de {previous} a {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} menos al año", + "tabs.stats.worthKnowing.priceUp": "{amount} más al año", + "tabs.stats.worthKnowing.recurringMonthly": "{value} parece un cargo mensual.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} hacia el día {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} parece un cargo semanal.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} cada {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} parece un cargo anual.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} hacia el {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Aparece cuando una categoría que usas casi todos los meses baja del {percent} de su mediana.", + "tabs.stats.worthKnowing.rule.categorySpike": "Aparece cuando una categoría llega a {ratio} su mediana de los {months} meses anteriores, con {count} o más movimientos.", + "tabs.stats.worthKnowing.rule.footer": "No se cuentan transferencias ni movimientos pendientes. Calculado en este dispositivo.", + "tabs.stats.worthKnowing.rule.monthPace": "Gasto hasta el {date}, comparado con la mediana de los {months} meses anteriores hasta el mismo día.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Gasto de {month}, comparado con la mediana de los {months} meses anteriores.", + "tabs.stats.worthKnowing.rule.newCategory": "Aparece cuando una categoría tiene gastos por primera vez en {months} meses.", + "tabs.stats.worthKnowing.rule.priceChange": "Aparece cuando un cargo periódico cambia un {percent} o más.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Cargos con el mismo título e importe estable que se repiten con regularidad.", + "tabs.stats.worthKnowing.seeTransactions": "Ver estas transacciones", + "tabs.stats.worthKnowing.times": "{count} veces hasta ahora", + "tabs.stats.worthKnowing.times.one": "{count} vez hasta ahora", + "tabs.stats.worthKnowing.track": "Registrar como recurrente", + "tabs.stats.worthKnowing.type.categoryDrop": "Bajadas de categoría", + "tabs.stats.worthKnowing.type.categorySpike": "Picos de categoría", + "tabs.stats.worthKnowing.type.monthPace": "Comparaciones del mes", + "tabs.stats.worthKnowing.type.newCategory": "Categorías nuevas", + "tabs.stats.worthKnowing.type.priceChange": "Cambios de precio", + "tabs.stats.worthKnowing.type.recurringCharge": "Sugerencias de suscripción", + "tabs.stats.worthKnowing.versusTypical": "{current} frente a {usual} habitual", + "tabs.stats.worthKnowing.whatMovedIt": "Qué lo movió", + "tabs.stats.worthKnowing.why": "Por qué ves esto", "transaction": "Transacción", "transaction.actions": "Acciones", "transaction.attachments": "Archivos adjuntos", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 2204f9b6..66c238f9 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "میانه مبلغ هزینه در بازه زمانی انتخاب‌شده", "tabs.stats.trends.topSpendingTitles": "تراکنش‌های پرتکرار", "tabs.stats.trends.topSpendingTitles.description": "عنوان‌های پرتکرارِ تراکنش‌ها", + "tabs.stats.worthKnowing": "خوب است بدانید", + "tabs.stats.worthKnowing.biggestEntries": "بزرگ‌ترین تراکنش‌ها", + "tabs.stats.worthKnowing.categoryDrop": "{category}: {value} کاهش.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: {value} کاهش داشت.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} حالت معمول.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: {value} حالت معمول بود.", + "tabs.stats.worthKnowing.charges": "پرداخت‌ها", + "tabs.stats.worthKnowing.chartBefore": "قبلاً {amount}", + "tabs.stats.worthKnowing.chartCharges": "هر پرداخت", + "tabs.stats.worthKnowing.chartMonthly": "هزینهٔ هر ماه", + "tabs.stats.worthKnowing.chartThroughDay": "هزینه تا روز {dayNumber} هر ماه", + "tabs.stats.worthKnowing.chartUsual": "معمول {amount}", + "tabs.stats.worthKnowing.entries": "{count} تراکنش", + "tabs.stats.worthKnowing.entries.one": "{count} تراکنش", + "tabs.stats.worthKnowing.hidden": "پنهان شد. از «خوب است بدانید» دوباره روشنش کنید.", + "tabs.stats.worthKnowing.hide.categoryDrop": "کاهش دسته‌ها را نشان نده", + "tabs.stats.worthKnowing.hide.categorySpike": "افزایش ناگهانی دسته‌ها را نشان نده", + "tabs.stats.worthKnowing.hide.monthPace": "مقایسه‌های ماه را نشان نده", + "tabs.stats.worthKnowing.hide.newCategory": "دسته‌های جدید را نشان نده", + "tabs.stats.worthKnowing.hide.priceChange": "تغییر قیمت‌ها را نشان نده", + "tabs.stats.worthKnowing.hide.recurringCharge": "پیشنهاد اشتراک‌ها را نشان نده", + "tabs.stats.worthKnowing.kinds": "نمایش در «خوب است بدانید»", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} کمتر از معمول", + "tabs.stats.worthKnowing.monthAbove": "{month}: تا روز {dayNumber}، {value} بالاتر از معمول.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: {value} بالاتر از معمول بود.", + "tabs.stats.worthKnowing.monthBelow": "{month}: تا روز {dayNumber}، {value} پایین‌تر از معمول.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: {value} پایین‌تر از معمول بود.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} بیشتر از معمول", + "tabs.stats.worthKnowing.mostlyCategory": "بیشتر {category}، {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "بیشتر {title} ({amount}). بدون آن، در حد معمول.", + "tabs.stats.worthKnowing.mostlyOver": "بیشتر {title} ({amount}). بدون آن هم {percent} بالاتر.", + "tabs.stats.worthKnowing.mostlyUnder": "بیشتر {title} ({amount}). بدون آن، {percent} پایین‌تر.", + "tabs.stats.worthKnowing.newCategory": "{value}: اولین هزینه در {months} ماه اخیر.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: تا اینجا چیز غیرعادی‌ای نیست.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: چیز غیرعادی‌ای نبود.", + "tabs.stats.worthKnowing.priceChange": "{title}: از {previous} به {value}.", + "tabs.stats.worthKnowing.priceDown": "سالانه {amount} کمتر", + "tabs.stats.worthKnowing.priceUp": "سالانه {amount} بیشتر", + "tabs.stats.worthKnowing.recurringMonthly": "{value} شبیه یک پرداخت ماهانه است.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} حدود روز {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} شبیه یک پرداخت هفتگی است.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} هر {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} شبیه یک پرداخت سالانه است.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} حدود {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "وقتی دسته‌ای که تقریباً هر ماه استفاده می‌کنید به زیر {percent} میانه‌اش برسد نمایش داده می‌شود.", + "tabs.stats.worthKnowing.rule.categorySpike": "وقتی یک دسته به {ratio} میانهٔ {months} ماه قبل برسد و دست‌کم {count} تراکنش داشته باشد نمایش داده می‌شود.", + "tabs.stats.worthKnowing.rule.footer": "انتقال‌ها و تراکنش‌های در انتظار حساب نمی‌شوند. روی همین دستگاه محاسبه شد.", + "tabs.stats.worthKnowing.rule.monthPace": "هزینه تا {date}، در مقایسه با میانهٔ {months} ماه قبل تا همان روز.", + "tabs.stats.worthKnowing.rule.monthPacePast": "هزینهٔ {month}، در مقایسه با میانهٔ {months} ماه قبل.", + "tabs.stats.worthKnowing.rule.newCategory": "وقتی دسته‌ای برای اولین بار در {months} ماه هزینه داشته باشد نمایش داده می‌شود.", + "tabs.stats.worthKnowing.rule.priceChange": "وقتی یک پرداخت تکراری {percent} یا بیشتر تغییر کند نمایش داده می‌شود.", + "tabs.stats.worthKnowing.rule.recurringCharge": "پرداخت‌هایی با عنوان یکسان و مبلغ ثابت که مرتب تکرار می‌شوند.", + "tabs.stats.worthKnowing.seeTransactions": "دیدن این تراکنش‌ها", + "tabs.stats.worthKnowing.times": "تا حالا {count} بار", + "tabs.stats.worthKnowing.times.one": "تا حالا {count} بار", + "tabs.stats.worthKnowing.track": "ثبت به‌عنوان تکرارشونده", + "tabs.stats.worthKnowing.type.categoryDrop": "کاهش دسته‌ها", + "tabs.stats.worthKnowing.type.categorySpike": "افزایش ناگهانی دسته‌ها", + "tabs.stats.worthKnowing.type.monthPace": "مقایسه‌های ماه", + "tabs.stats.worthKnowing.type.newCategory": "دسته‌های جدید", + "tabs.stats.worthKnowing.type.priceChange": "تغییر قیمت‌ها", + "tabs.stats.worthKnowing.type.recurringCharge": "پیشنهاد اشتراک‌ها", + "tabs.stats.worthKnowing.versusTypical": "{current} در برابر معمولِ {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "چه چیزی آن را تغییر داد", + "tabs.stats.worthKnowing.why": "چرا این را می‌بینید", "transaction": "تراکنش", "transaction.actions": "اقدامات", "transaction.attachments": "پیوست‌های فایل", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 4a941c23..1e53deac 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "Médiane du montant des dépenses dans la période sélectionnée", "tabs.stats.trends.topSpendingTitles": "Transactions fréquentes", "tabs.stats.trends.topSpendingTitles.description": "Titres de transaction les plus fréquemment utilisés", + "tabs.stats.worthKnowing": "Bon à savoir", + "tabs.stats.worthKnowing.biggestEntries": "Plus grosses dépenses", + "tabs.stats.worthKnowing.categoryDrop": "{category} est en baisse de {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} était en baisse de {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category} est à {value} l'habitude.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} était à {value} l'habitude.", + "tabs.stats.worthKnowing.charges": "Prélèvements", + "tabs.stats.worthKnowing.chartBefore": "avant {amount}", + "tabs.stats.worthKnowing.chartCharges": "Chaque prélèvement", + "tabs.stats.worthKnowing.chartMonthly": "Dépenses de chaque mois", + "tabs.stats.worthKnowing.chartThroughDay": "Dépenses jusqu'au {dayNumber} de chaque mois", + "tabs.stats.worthKnowing.chartUsual": "habituel {amount}", + "tabs.stats.worthKnowing.entries": "{count} opérations", + "tabs.stats.worthKnowing.entries.one": "{count} opération", + "tabs.stats.worthKnowing.hidden": "Masqué. Réactivez-le depuis Bon à savoir.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Masquer les baisses de catégorie", + "tabs.stats.worthKnowing.hide.categorySpike": "Masquer les pics de catégorie", + "tabs.stats.worthKnowing.hide.monthPace": "Masquer les comparaisons du mois", + "tabs.stats.worthKnowing.hide.newCategory": "Masquer les nouvelles catégories", + "tabs.stats.worthKnowing.hide.priceChange": "Masquer les changements de prix", + "tabs.stats.worthKnowing.hide.recurringCharge": "Masquer les suggestions d'abonnement", + "tabs.stats.worthKnowing.kinds": "Afficher dans Bon à savoir", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} de moins que d'habitude", + "tabs.stats.worthKnowing.monthAbove": "{month} : {value} au-dessus de l'habitude au {dayNumber}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} : {value} au-dessus de l'habitude.", + "tabs.stats.worthKnowing.monthBelow": "{month} : {value} en dessous de l'habitude au {dayNumber}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} : {value} en dessous de l'habitude.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} de plus que d'habitude", + "tabs.stats.worthKnowing.mostlyCategory": "Surtout {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Surtout {title} ({amount}). Sans cela, comme d'habitude.", + "tabs.stats.worthKnowing.mostlyOver": "Surtout {title} ({amount}). Sans cela, encore {percent} au-dessus.", + "tabs.stats.worthKnowing.mostlyUnder": "Surtout {title} ({amount}). Sans cela, {percent} en dessous.", + "tabs.stats.worthKnowing.newCategory": "Premières dépenses en {value} depuis {months} mois.", + "tabs.stats.worthKnowing.nothingUnusual": "{month} : rien d'inhabituel pour l'instant.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month} : rien d'inhabituel.", + "tabs.stats.worthKnowing.priceChange": "{title} est passé de {previous} à {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} de moins par an", + "tabs.stats.worthKnowing.priceUp": "{amount} de plus par an", + "tabs.stats.worthKnowing.recurringMonthly": "{value} ressemble à un prélèvement mensuel.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} vers le {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} ressemble à un prélèvement hebdomadaire.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} chaque {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} ressemble à un prélèvement annuel.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} vers le {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Affiché quand une catégorie utilisée presque chaque mois passe sous {percent} de sa médiane.", + "tabs.stats.worthKnowing.rule.categorySpike": "Affiché quand une catégorie atteint {ratio} sa médiane des {months} mois précédents, avec au moins {count} opérations.", + "tabs.stats.worthKnowing.rule.footer": "Les virements et opérations en attente ne sont pas comptés. Calculé sur cet appareil.", + "tabs.stats.worthKnowing.rule.monthPace": "Dépenses jusqu'au {date}, comparées à la médiane des {months} mois précédents au même jour.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Dépenses de {month}, comparées à la médiane des {months} mois précédents.", + "tabs.stats.worthKnowing.rule.newCategory": "Affiché quand une catégorie a des dépenses pour la première fois depuis {months} mois.", + "tabs.stats.worthKnowing.rule.priceChange": "Affiché quand un prélèvement régulier change de {percent} ou plus.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Prélèvements au même titre et au montant stable, qui reviennent régulièrement.", + "tabs.stats.worthKnowing.seeTransactions": "Voir ces transactions", + "tabs.stats.worthKnowing.times": "{count} fois jusqu'ici", + "tabs.stats.worthKnowing.times.one": "{count} fois jusqu'ici", + "tabs.stats.worthKnowing.track": "Suivre comme récurrent", + "tabs.stats.worthKnowing.type.categoryDrop": "Baisses de catégorie", + "tabs.stats.worthKnowing.type.categorySpike": "Pics de catégorie", + "tabs.stats.worthKnowing.type.monthPace": "Comparaisons du mois", + "tabs.stats.worthKnowing.type.newCategory": "Nouvelles catégories", + "tabs.stats.worthKnowing.type.priceChange": "Changements de prix", + "tabs.stats.worthKnowing.type.recurringCharge": "Suggestions d'abonnement", + "tabs.stats.worthKnowing.versusTypical": "{current} contre {usual} habituellement", + "tabs.stats.worthKnowing.whatMovedIt": "Ce qui l'explique", + "tabs.stats.worthKnowing.why": "Pourquoi vous voyez ceci", "transaction": "Transaction", "transaction.actions": "Actions", "transaction.attachments": "Pièces jointes", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 1c210f49..0570065f 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "Mediana dell'importo della spesa nell'intervallo di tempo selezionato", "tabs.stats.trends.topSpendingTitles": "Transazioni frequenti", "tabs.stats.trends.topSpendingTitles.description": "Titoli di transazione più frequentemente utilizzati", + "tabs.stats.worthKnowing": "Da sapere", + "tabs.stats.worthKnowing.biggestEntries": "Movimenti più grandi", + "tabs.stats.worthKnowing.categoryDrop": "{category} è calata del {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} era calata del {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category} è a {value} il solito.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} era a {value} il solito.", + "tabs.stats.worthKnowing.charges": "Addebiti", + "tabs.stats.worthKnowing.chartBefore": "prima {amount}", + "tabs.stats.worthKnowing.chartCharges": "Ogni addebito", + "tabs.stats.worthKnowing.chartMonthly": "Spesa di ogni mese", + "tabs.stats.worthKnowing.chartThroughDay": "Spesa fino al {dayNumber} di ogni mese", + "tabs.stats.worthKnowing.chartUsual": "solito {amount}", + "tabs.stats.worthKnowing.entries": "{count} movimenti", + "tabs.stats.worthKnowing.entries.one": "{count} movimento", + "tabs.stats.worthKnowing.hidden": "Nascosto. Puoi riattivarlo da Da sapere.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Non mostrare cali di categoria", + "tabs.stats.worthKnowing.hide.categorySpike": "Non mostrare picchi di categoria", + "tabs.stats.worthKnowing.hide.monthPace": "Non mostrare confronti del mese", + "tabs.stats.worthKnowing.hide.newCategory": "Non mostrare nuove categorie", + "tabs.stats.worthKnowing.hide.priceChange": "Non mostrare variazioni di prezzo", + "tabs.stats.worthKnowing.hide.recurringCharge": "Non mostrare suggerimenti di abbonamento", + "tabs.stats.worthKnowing.kinds": "Mostra in Da sapere", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} meno del solito", + "tabs.stats.worthKnowing.monthAbove": "{month} è {value} sopra il solito al giorno {dayNumber}.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} è stato {value} sopra il solito.", + "tabs.stats.worthKnowing.monthBelow": "{month} è {value} sotto il solito al giorno {dayNumber}.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} è stato {value} sotto il solito.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} più del solito", + "tabs.stats.worthKnowing.mostlyCategory": "Soprattutto {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Soprattutto {title} ({amount}). Senza, come al solito.", + "tabs.stats.worthKnowing.mostlyOver": "Soprattutto {title} ({amount}). Senza, ancora {percent} sopra.", + "tabs.stats.worthKnowing.mostlyUnder": "Soprattutto {title} ({amount}). Senza, {percent} sotto.", + "tabs.stats.worthKnowing.newCategory": "Prima spesa in {value} da {months} mesi.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: niente di insolito finora.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: niente di insolito.", + "tabs.stats.worthKnowing.priceChange": "{title} è passato da {previous} a {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} in meno all'anno", + "tabs.stats.worthKnowing.priceUp": "{amount} in più all'anno", + "tabs.stats.worthKnowing.recurringMonthly": "{value} sembra un addebito mensile.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} verso il {dayNumber} · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} sembra un addebito settimanale.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount} ogni {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} sembra un addebito annuale.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} verso il {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Appare quando una categoria usata quasi ogni mese scende sotto il {percent} della sua mediana.", + "tabs.stats.worthKnowing.rule.categorySpike": "Appare quando una categoria arriva a {ratio} la sua mediana dei {months} mesi precedenti, con almeno {count} movimenti.", + "tabs.stats.worthKnowing.rule.footer": "Trasferimenti e movimenti in sospeso sono esclusi. Calcolato su questo dispositivo.", + "tabs.stats.worthKnowing.rule.monthPace": "Spesa fino al {date}, confrontata con la mediana dei {months} mesi precedenti allo stesso giorno.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Spesa di {month}, confrontata con la mediana dei {months} mesi precedenti.", + "tabs.stats.worthKnowing.rule.newCategory": "Appare quando una categoria ha spese per la prima volta da {months} mesi.", + "tabs.stats.worthKnowing.rule.priceChange": "Appare quando un addebito ricorrente cambia del {percent} o più.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Addebiti con lo stesso titolo e importo stabile, che si ripetono regolarmente.", + "tabs.stats.worthKnowing.seeTransactions": "Vedi queste transazioni", + "tabs.stats.worthKnowing.times": "{count} volte finora", + "tabs.stats.worthKnowing.times.one": "{count} volta finora", + "tabs.stats.worthKnowing.track": "Segui come ricorrente", + "tabs.stats.worthKnowing.type.categoryDrop": "Cali di categoria", + "tabs.stats.worthKnowing.type.categorySpike": "Picchi di categoria", + "tabs.stats.worthKnowing.type.monthPace": "Confronti del mese", + "tabs.stats.worthKnowing.type.newCategory": "Nuove categorie", + "tabs.stats.worthKnowing.type.priceChange": "Variazioni di prezzo", + "tabs.stats.worthKnowing.type.recurringCharge": "Suggerimenti di abbonamento", + "tabs.stats.worthKnowing.versusTypical": "{current} contro {usual} di solito", + "tabs.stats.worthKnowing.whatMovedIt": "Cosa l'ha mosso", + "tabs.stats.worthKnowing.why": "Perché lo vedi", "transaction": "Transazione", "transaction.actions": "Azioni", "transaction.attachments": "File allegati", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 585648f8..63d661f6 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "Сонгосон хугацаан дахь зарлагын гүйлгээнүүдийг голч", "tabs.stats.trends.topSpendingTitles": "Түгээмэл зарлагууд", "tabs.stats.trends.topSpendingTitles.description": "Хамгийн түгээмэл хийгддэг зарлагууд", + "tabs.stats.worthKnowing": "Мэдэхэд илүүдэхгүй", + "tabs.stats.worthKnowing.biggestEntries": "Хамгийн том гүйлгээнүүд", + "tabs.stats.worthKnowing.categoryDrop": "{category} {value}-иар буурсан.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} {value}-иар буурсан байсан.", + "tabs.stats.worthKnowing.categorySpike": "{category} ердийнхөөсөө {value} байна.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} ердийнхөөсөө {value} байсан.", + "tabs.stats.worthKnowing.charges": "Төлбөрүүд", + "tabs.stats.worthKnowing.chartBefore": "өмнө нь {amount}", + "tabs.stats.worthKnowing.chartCharges": "Төлбөр бүр", + "tabs.stats.worthKnowing.chartMonthly": "Сар бүрийн зарлага", + "tabs.stats.worthKnowing.chartThroughDay": "Сар бүрийн {day} өдөр хүртэлх зарлага", + "tabs.stats.worthKnowing.chartUsual": "ердийн {amount}", + "tabs.stats.worthKnowing.entries": "{count} гүйлгээ", + "tabs.stats.worthKnowing.entries.one": "{count} гүйлгээ", + "tabs.stats.worthKnowing.hidden": "Нуулаа. «Мэдэхэд илүүдэхгүй» картаас дахин асааж болно.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Ангиллын бууралтыг харуулахгүй", + "tabs.stats.worthKnowing.hide.categorySpike": "Ангиллын өсөлтийг харуулахгүй", + "tabs.stats.worthKnowing.hide.monthPace": "Сарын харьцуулалтыг харуулахгүй", + "tabs.stats.worthKnowing.hide.newCategory": "Шинэ ангиллыг харуулахгүй", + "tabs.stats.worthKnowing.hide.priceChange": "Үнийн өөрчлөлтийг харуулахгүй", + "tabs.stats.worthKnowing.hide.recurringCharge": "Захиалгын саналыг харуулахгүй", + "tabs.stats.worthKnowing.kinds": "«Мэдэхэд илүүдэхгүй»-д харуулах", + "tabs.stats.worthKnowing.lessThanUsual": "ердийнхөөс {amount} бага", + "tabs.stats.worthKnowing.monthAbove": "{month}: {day} өдрийн байдлаар ердийнхөөс {value} өндөр.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: ердийнхөөс {value} өндөр байсан.", + "tabs.stats.worthKnowing.monthBelow": "{month}: {day} өдрийн байдлаар ердийнхөөс {value} бага.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: ердийнхөөс {value} бага байсан.", + "tabs.stats.worthKnowing.moreThanUsual": "ердийнхөөс {amount} их", + "tabs.stats.worthKnowing.mostlyCategory": "Голчлон {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Голчлон {title} ({amount}). Үүнгүйгээр ердийн хэмжээнд.", + "tabs.stats.worthKnowing.mostlyOver": "Голчлон {title} ({amount}). Үүнгүйгээр ч {percent} өндөр.", + "tabs.stats.worthKnowing.mostlyUnder": "Голчлон {title} ({amount}). Үүнгүйгээр {percent} бага.", + "tabs.stats.worthKnowing.newCategory": "{value}: {months} сард анх удаа зарлага гарлаа.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: одоогоор онцгой зүйлгүй.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: онцгой зүйлгүй.", + "tabs.stats.worthKnowing.priceChange": "{title}: {previous} байсан бол одоо {value}.", + "tabs.stats.worthKnowing.priceDown": "жилд {amount} бага", + "tabs.stats.worthKnowing.priceUp": "жилд {amount} их", + "tabs.stats.worthKnowing.recurringMonthly": "{value} сар бүрийн төлбөр бололтой.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount}, {day} өдрийн орчим · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} долоо хоног бүрийн төлбөр бололтой.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} бүр · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} жил бүрийн төлбөр бололтой.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount}, {date}-ны орчим · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Бараг сар бүр хэрэглэдэг ангилал медианынхаа {percent}-иас доош буувал харагдана.", + "tabs.stats.worthKnowing.rule.categorySpike": "Ангилал өмнөх {months} сарын медианынхаа {ratio} хүрч, дор хаяж {count} гүйлгээтэй бол харагдана.", + "tabs.stats.worthKnowing.rule.footer": "Шилжүүлэг болон хүлээгдэж буй гүйлгээг тооцохгүй. Энэ төхөөрөмж дээр тооцоолов.", + "tabs.stats.worthKnowing.rule.monthPace": "{date} хүртэлх зарлагыг өмнөх {months} сарын мөн өдөр хүртэлх медиантай харьцуулав.", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month}-ын зарлагыг өмнөх {months} сарын медиантай харьцуулав.", + "tabs.stats.worthKnowing.rule.newCategory": "Ангилалд {months} сард анх удаа зарлага гарвал харагдана.", + "tabs.stats.worthKnowing.rule.priceChange": "Давтагддаг төлбөр {percent} буюу түүнээс их өөрчлөгдвөл харагдана.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Ижил нэртэй, тогтмол дүнтэй, тогтмол давтагддаг төлбөрүүд.", + "tabs.stats.worthKnowing.seeTransactions": "Эдгээр гүйлгээг харах", + "tabs.stats.worthKnowing.times": "одоогоор {count} удаа", + "tabs.stats.worthKnowing.times.one": "одоогоор {count} удаа", + "tabs.stats.worthKnowing.track": "Давтамжит болгох", + "tabs.stats.worthKnowing.type.categoryDrop": "Ангиллын бууралт", + "tabs.stats.worthKnowing.type.categorySpike": "Ангиллын өсөлт", + "tabs.stats.worthKnowing.type.monthPace": "Сарын харьцуулалт", + "tabs.stats.worthKnowing.type.newCategory": "Шинэ ангилал", + "tabs.stats.worthKnowing.type.priceChange": "Үнийн өөрчлөлт", + "tabs.stats.worthKnowing.type.recurringCharge": "Захиалгын санал", + "tabs.stats.worthKnowing.versusTypical": "{current} vs ердийн {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Юу нөлөөлсөн бэ", + "tabs.stats.worthKnowing.why": "Яагаад үүнийг харж байна вэ", "transaction": "Гүйлгээ", "transaction.actions": "Үйлдлүүд", "transaction.attachments": "Файл хасвралтууд", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index dd6483c8..f2262fd3 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -814,6 +814,73 @@ "tabs.stats.trends.median.description": "Mediana kwoty wydatku w wybranym okresie czasu", "tabs.stats.trends.topSpendingTitles": "Częste transakcje", "tabs.stats.trends.topSpendingTitles.description": "Najczęściej powtarzające się tytuły transakcji", + "tabs.stats.worthKnowing": "Warto wiedzieć", + "tabs.stats.worthKnowing.biggestEntries": "Największe wpisy", + "tabs.stats.worthKnowing.categoryDrop": "{category}: spadek o {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: spadek o {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} zwykłej kwoty.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: {value} zwykłej kwoty.", + "tabs.stats.worthKnowing.charges": "Obciążenia", + "tabs.stats.worthKnowing.chartBefore": "wcześniej {amount}", + "tabs.stats.worthKnowing.chartCharges": "Każde obciążenie", + "tabs.stats.worthKnowing.chartMonthly": "Wydatki w każdym miesiącu", + "tabs.stats.worthKnowing.chartThroughDay": "Wydatki do {dayNumber}. dnia każdego miesiąca", + "tabs.stats.worthKnowing.chartUsual": "zwykle {amount}", + "tabs.stats.worthKnowing.entries": "{count} wpisów", + "tabs.stats.worthKnowing.entries.few": "{count} wpisy", + "tabs.stats.worthKnowing.entries.one": "{count} wpis", + "tabs.stats.worthKnowing.hidden": "Ukryto. Możesz to włączyć w karcie Warto wiedzieć.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Nie pokazuj spadków kategorii", + "tabs.stats.worthKnowing.hide.categorySpike": "Nie pokazuj skoków kategorii", + "tabs.stats.worthKnowing.hide.monthPace": "Nie pokazuj porównań miesiąca", + "tabs.stats.worthKnowing.hide.newCategory": "Nie pokazuj nowych kategorii", + "tabs.stats.worthKnowing.hide.priceChange": "Nie pokazuj zmian cen", + "tabs.stats.worthKnowing.hide.recurringCharge": "Nie pokazuj sugestii subskrypcji", + "tabs.stats.worthKnowing.kinds": "Pokazuj w Warto wiedzieć", + "tabs.stats.worthKnowing.lessThanUsual": "{amount} mniej niż zwykle", + "tabs.stats.worthKnowing.monthAbove": "{month}: {value} powyżej normy do {dayNumber}. dnia.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: {value} powyżej normy.", + "tabs.stats.worthKnowing.monthBelow": "{month}: {value} poniżej normy do {dayNumber}. dnia.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: {value} poniżej normy.", + "tabs.stats.worthKnowing.moreThanUsual": "{amount} więcej niż zwykle", + "tabs.stats.worthKnowing.mostlyCategory": "Głównie {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Głównie {title} ({amount}). Bez tego w normie.", + "tabs.stats.worthKnowing.mostlyOver": "Głównie {title} ({amount}). Bez tego nadal {percent} powyżej.", + "tabs.stats.worthKnowing.mostlyUnder": "Głównie {title} ({amount}). Bez tego {percent} poniżej.", + "tabs.stats.worthKnowing.newCategory": "Pierwsze wydatki na {value} od {months} miesięcy.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: na razie nic nietypowego.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: nic nietypowego.", + "tabs.stats.worthKnowing.priceChange": "{title}: zmiana z {previous} na {value}.", + "tabs.stats.worthKnowing.priceDown": "{amount} mniej rocznie", + "tabs.stats.worthKnowing.priceUp": "{amount} więcej rocznie", + "tabs.stats.worthKnowing.recurringMonthly": "{value} wygląda na opłatę miesięczną.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} około {dayNumber}. dnia · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} wygląda na opłatę tygodniową.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} wygląda na opłatę roczną.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} około {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Pojawia się, gdy kategoria używana niemal co miesiąc spada poniżej {percent} swojej mediany.", + "tabs.stats.worthKnowing.rule.categorySpike": "Pojawia się, gdy kategoria osiąga {ratio} swojej mediany z {months} poprzednich miesięcy przy co najmniej {count} wpisach.", + "tabs.stats.worthKnowing.rule.footer": "Przelewy i oczekujące wpisy są pomijane. Obliczone na tym urządzeniu.", + "tabs.stats.worthKnowing.rule.monthPace": "Wydatki do {date} w porównaniu z medianą {months} poprzednich miesięcy do tego samego dnia.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Wydatki w miesiącu {month} w porównaniu z medianą {months} poprzednich miesięcy.", + "tabs.stats.worthKnowing.rule.newCategory": "Pojawia się, gdy kategoria ma wydatki pierwszy raz od {months} miesięcy.", + "tabs.stats.worthKnowing.rule.priceChange": "Pojawia się, gdy cykliczna opłata zmienia się o {percent} lub więcej.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Opłaty o tym samym tytule i stałej kwocie, powtarzające się regularnie.", + "tabs.stats.worthKnowing.seeTransactions": "Zobacz te transakcje", + "tabs.stats.worthKnowing.times": "{count} razy dotąd", + "tabs.stats.worthKnowing.times.few": "{count} razy dotąd", + "tabs.stats.worthKnowing.times.one": "{count} raz dotąd", + "tabs.stats.worthKnowing.track": "Śledź jako cykliczną", + "tabs.stats.worthKnowing.type.categoryDrop": "Spadki kategorii", + "tabs.stats.worthKnowing.type.categorySpike": "Skoki kategorii", + "tabs.stats.worthKnowing.type.monthPace": "Porównania miesiąca", + "tabs.stats.worthKnowing.type.newCategory": "Nowe kategorie", + "tabs.stats.worthKnowing.type.priceChange": "Zmiany cen", + "tabs.stats.worthKnowing.type.recurringCharge": "Sugestie subskrypcji", + "tabs.stats.worthKnowing.versusTypical": "{current} vs zwykle {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Co na to wpłynęło", + "tabs.stats.worthKnowing.why": "Dlaczego to widzisz", "transaction": "Transakcja", "transaction.actions": "Akcje", "transaction.attachments": "Załączniki plików", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 9f49d8d9..2edef80c 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -814,6 +814,73 @@ "tabs.stats.trends.median.description": "Медиана суммы расходов за выбранный временной диапазон", "tabs.stats.trends.topSpendingTitles": "Частые транзакции", "tabs.stats.trends.topSpendingTitles.description": "Наиболее часто используемые названия транзакций", + "tabs.stats.worthKnowing": "Стоит знать", + "tabs.stats.worthKnowing.biggestEntries": "Крупнейшие записи", + "tabs.stats.worthKnowing.categoryDrop": "{category}: меньше на {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: было меньше на {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} от обычного.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: было {value} от обычного.", + "tabs.stats.worthKnowing.charges": "Списания", + "tabs.stats.worthKnowing.chartBefore": "раньше {amount}", + "tabs.stats.worthKnowing.chartCharges": "Каждое списание", + "tabs.stats.worthKnowing.chartMonthly": "Расходы по месяцам", + "tabs.stats.worthKnowing.chartThroughDay": "Расходы к {dayNumber}-му числу каждого месяца", + "tabs.stats.worthKnowing.chartUsual": "обычно {amount}", + "tabs.stats.worthKnowing.entries": "{count} записей", + "tabs.stats.worthKnowing.entries.few": "{count} записи", + "tabs.stats.worthKnowing.entries.one": "{count} запись", + "tabs.stats.worthKnowing.hidden": "Скрыто. Можно вернуть в карточке «Стоит знать».", + "tabs.stats.worthKnowing.hide.categoryDrop": "Не показывать снижения по категориям", + "tabs.stats.worthKnowing.hide.categorySpike": "Не показывать всплески по категориям", + "tabs.stats.worthKnowing.hide.monthPace": "Не показывать сравнения месяца", + "tabs.stats.worthKnowing.hide.newCategory": "Не показывать новые категории", + "tabs.stats.worthKnowing.hide.priceChange": "Не показывать изменения цен", + "tabs.stats.worthKnowing.hide.recurringCharge": "Не показывать подсказки о подписках", + "tabs.stats.worthKnowing.kinds": "Показывать в «Стоит знать»", + "tabs.stats.worthKnowing.lessThanUsual": "на {amount} меньше обычного", + "tabs.stats.worthKnowing.monthAbove": "{month}: на {value} выше обычного к {dayNumber}-му числу.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: на {value} выше обычного.", + "tabs.stats.worthKnowing.monthBelow": "{month}: на {value} ниже обычного к {dayNumber}-му числу.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: на {value} ниже обычного.", + "tabs.stats.worthKnowing.moreThanUsual": "на {amount} больше обычного", + "tabs.stats.worthKnowing.mostlyCategory": "В основном {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "В основном {title} ({amount}). Без этого — как обычно.", + "tabs.stats.worthKnowing.mostlyOver": "В основном {title} ({amount}). Без этого всё равно на {percent} выше.", + "tabs.stats.worthKnowing.mostlyUnder": "В основном {title} ({amount}). Без этого на {percent} ниже.", + "tabs.stats.worthKnowing.newCategory": "{value}: первые расходы за {months} мес.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: пока ничего необычного.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: ничего необычного.", + "tabs.stats.worthKnowing.priceChange": "{title}: было {previous}, стало {value}.", + "tabs.stats.worthKnowing.priceDown": "на {amount} меньше в год", + "tabs.stats.worthKnowing.priceUp": "на {amount} больше в год", + "tabs.stats.worthKnowing.recurringMonthly": "{value} похоже на ежемесячный платёж.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} около {dayNumber}-го числа · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} похоже на еженедельный платёж.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} похоже на ежегодный платёж.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} около {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Показывается, когда категория, которую вы используете почти каждый месяц, опускается ниже {percent} от своей медианы.", + "tabs.stats.worthKnowing.rule.categorySpike": "Показывается, когда категория достигает {ratio} от своей медианы за {months} прошлых мес. и в ней не меньше {count} записей.", + "tabs.stats.worthKnowing.rule.footer": "Переводы и ожидающие записи не учитываются. Посчитано на этом устройстве.", + "tabs.stats.worthKnowing.rule.monthPace": "Расходы по {date} в сравнении с медианой {months} прошлых мес. на тот же день.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Расходы за период «{month}» в сравнении с медианой {months} прошлых мес.", + "tabs.stats.worthKnowing.rule.newCategory": "Показывается, когда в категории впервые за {months} мес. появляются расходы.", + "tabs.stats.worthKnowing.rule.priceChange": "Показывается, когда регулярный платёж меняется на {percent} или больше.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Платежи с одинаковым названием и стабильной суммой, которые повторяются регулярно.", + "tabs.stats.worthKnowing.seeTransactions": "Показать эти транзакции", + "tabs.stats.worthKnowing.times": "{count} раз уже", + "tabs.stats.worthKnowing.times.few": "{count} раза уже", + "tabs.stats.worthKnowing.times.one": "{count} раз уже", + "tabs.stats.worthKnowing.track": "Сделать повторяющейся", + "tabs.stats.worthKnowing.type.categoryDrop": "Снижения по категориям", + "tabs.stats.worthKnowing.type.categorySpike": "Всплески по категориям", + "tabs.stats.worthKnowing.type.monthPace": "Сравнения месяца", + "tabs.stats.worthKnowing.type.newCategory": "Новые категории", + "tabs.stats.worthKnowing.type.priceChange": "Изменения цен", + "tabs.stats.worthKnowing.type.recurringCharge": "Подсказки о подписках", + "tabs.stats.worthKnowing.versusTypical": "{current} против обычных {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Что повлияло", + "tabs.stats.worthKnowing.why": "Почему вы это видите", "transaction": "Транзакция", "transaction.actions": "Действия", "transaction.attachments": "Файловые вложения", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index e7949759..eae8dd32 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "Seçilen zaman aralığındaki gider miktarının medyanı", "tabs.stats.trends.topSpendingTitles": "Sık yapılan işlemler", "tabs.stats.trends.topSpendingTitles.description": "En sık kullanılan işlem başlıkları", + "tabs.stats.worthKnowing": "Bilmekte fayda var", + "tabs.stats.worthKnowing.biggestEntries": "En büyük kayıtlar", + "tabs.stats.worthKnowing.categoryDrop": "{category} {value} düştü.", + "tabs.stats.worthKnowing.categoryDropPast": "{category} {value} düşüktü.", + "tabs.stats.worthKnowing.categorySpike": "{category} olağanın {value} katında.", + "tabs.stats.worthKnowing.categorySpikePast": "{category} olağanın {value} katındaydı.", + "tabs.stats.worthKnowing.charges": "Ödemeler", + "tabs.stats.worthKnowing.chartBefore": "önce {amount}", + "tabs.stats.worthKnowing.chartCharges": "Her ödeme", + "tabs.stats.worthKnowing.chartMonthly": "Aylık harcama", + "tabs.stats.worthKnowing.chartThroughDay": "Her ayın {dayNumber}. gününe kadar harcama", + "tabs.stats.worthKnowing.chartUsual": "olağan {amount}", + "tabs.stats.worthKnowing.entries": "{count} kayıt", + "tabs.stats.worthKnowing.entries.one": "{count} kayıt", + "tabs.stats.worthKnowing.hidden": "Gizlendi. Bilmekte fayda var kartından yeniden açabilirsiniz.", + "tabs.stats.worthKnowing.hide.categoryDrop": "Kategori düşüşlerini gösterme", + "tabs.stats.worthKnowing.hide.categorySpike": "Kategori artışlarını gösterme", + "tabs.stats.worthKnowing.hide.monthPace": "Ay karşılaştırmalarını gösterme", + "tabs.stats.worthKnowing.hide.newCategory": "Yeni kategorileri gösterme", + "tabs.stats.worthKnowing.hide.priceChange": "Fiyat değişikliklerini gösterme", + "tabs.stats.worthKnowing.hide.recurringCharge": "Abonelik önerilerini gösterme", + "tabs.stats.worthKnowing.kinds": "Bilmekte fayda var'da göster", + "tabs.stats.worthKnowing.lessThanUsual": "olağandan {amount} az", + "tabs.stats.worthKnowing.monthAbove": "{month}, ayın {dayNumber}. günü itibarıyla olağanın {value} üzerinde.", + "tabs.stats.worthKnowing.monthAbovePast": "{month} olağanın {value} üzerindeydi.", + "tabs.stats.worthKnowing.monthBelow": "{month}, ayın {dayNumber}. günü itibarıyla olağanın {value} altında.", + "tabs.stats.worthKnowing.monthBelowPast": "{month} olağanın {value} altındaydı.", + "tabs.stats.worthKnowing.moreThanUsual": "olağandan {amount} fazla", + "tabs.stats.worthKnowing.mostlyCategory": "Çoğunlukla {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Çoğunlukla {title} ({amount}). O olmadan olağan seviyede.", + "tabs.stats.worthKnowing.mostlyOver": "Çoğunlukla {title} ({amount}). O olmadan yine {percent} üzerinde.", + "tabs.stats.worthKnowing.mostlyUnder": "Çoğunlukla {title} ({amount}). O olmadan {percent} altında.", + "tabs.stats.worthKnowing.newCategory": "{months} aydır ilk kez {value} harcaması.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: şimdilik olağan dışı bir şey yok.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: olağan dışı bir şey yok.", + "tabs.stats.worthKnowing.priceChange": "{title} {previous} tutarından {value} tutarına çıktı.", + "tabs.stats.worthKnowing.priceDown": "yılda {amount} daha az", + "tabs.stats.worthKnowing.priceUp": "yılda {amount} daha fazla", + "tabs.stats.worthKnowing.recurringMonthly": "{value} aylık bir ödeme gibi görünüyor.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount}, ayın {dayNumber}. günü civarı · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} haftalık bir ödeme gibi görünüyor.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, her {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} yıllık bir ödeme gibi görünüyor.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount}, {date} civarı · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Neredeyse her ay kullandığınız bir kategori medyanının {percent} altına düştüğünde gösterilir.", + "tabs.stats.worthKnowing.rule.categorySpike": "Bir kategori önceki {months} ayın medyanının {ratio} katına ulaştığında ve en az {count} kayıt olduğunda gösterilir.", + "tabs.stats.worthKnowing.rule.footer": "Transferler ve bekleyen kayıtlar hariçtir. Bu cihazda hesaplandı.", + "tabs.stats.worthKnowing.rule.monthPace": "{date} tarihine kadarki harcama, önceki {months} ayın aynı güne kadarki medyanıyla karşılaştırıldı.", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month} harcaması, önceki {months} ayın medyanıyla karşılaştırıldı.", + "tabs.stats.worthKnowing.rule.newCategory": "Bir kategoride {months} aydır ilk kez harcama olduğunda gösterilir.", + "tabs.stats.worthKnowing.rule.priceChange": "Düzenli bir ödeme {percent} veya daha fazla değiştiğinde gösterilir.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Aynı başlıklı, sabit tutarlı ve düzenli tekrarlanan ödemeler.", + "tabs.stats.worthKnowing.seeTransactions": "Bu işlemleri gör", + "tabs.stats.worthKnowing.times": "şimdiye kadar {count} kez", + "tabs.stats.worthKnowing.times.one": "şimdiye kadar {count} kez", + "tabs.stats.worthKnowing.track": "Yinelenen olarak takip et", + "tabs.stats.worthKnowing.type.categoryDrop": "Kategori düşüşleri", + "tabs.stats.worthKnowing.type.categorySpike": "Kategori artışları", + "tabs.stats.worthKnowing.type.monthPace": "Ay karşılaştırmaları", + "tabs.stats.worthKnowing.type.newCategory": "Yeni kategoriler", + "tabs.stats.worthKnowing.type.priceChange": "Fiyat değişiklikleri", + "tabs.stats.worthKnowing.type.recurringCharge": "Abonelik önerileri", + "tabs.stats.worthKnowing.versusTypical": "{current} · tipik {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Etkileyenler", + "tabs.stats.worthKnowing.why": "Bunu neden görüyorsunuz", "transaction": "İşlem", "transaction.actions": "Eylemler", "transaction.attachments": "Dosya ekleri", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index f101ac1c..29ec958b 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -814,6 +814,73 @@ "tabs.stats.trends.median.description": "Медіана суми витрат за вибраний часовий діапазон", "tabs.stats.trends.topSpendingTitles": "Часті транзакції", "tabs.stats.trends.topSpendingTitles.description": "Найбільш часто використовувані назви транзакцій", + "tabs.stats.worthKnowing": "Варто знати", + "tabs.stats.worthKnowing.biggestEntries": "Найбільші записи", + "tabs.stats.worthKnowing.categoryDrop": "{category}: менше на {value}.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: було менше на {value}.", + "tabs.stats.worthKnowing.categorySpike": "{category}: {value} від звичайного.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: було {value} від звичайного.", + "tabs.stats.worthKnowing.charges": "Списання", + "tabs.stats.worthKnowing.chartBefore": "раніше {amount}", + "tabs.stats.worthKnowing.chartCharges": "Кожне списання", + "tabs.stats.worthKnowing.chartMonthly": "Витрати за місяцями", + "tabs.stats.worthKnowing.chartThroughDay": "Витрати до {dayNumber}-го числа кожного місяця", + "tabs.stats.worthKnowing.chartUsual": "зазвичай {amount}", + "tabs.stats.worthKnowing.entries": "{count} записів", + "tabs.stats.worthKnowing.entries.few": "{count} записи", + "tabs.stats.worthKnowing.entries.one": "{count} запис", + "tabs.stats.worthKnowing.hidden": "Приховано. Можна повернути в картці «Варто знати».", + "tabs.stats.worthKnowing.hide.categoryDrop": "Не показувати зниження за категоріями", + "tabs.stats.worthKnowing.hide.categorySpike": "Не показувати сплески за категоріями", + "tabs.stats.worthKnowing.hide.monthPace": "Не показувати порівняння місяця", + "tabs.stats.worthKnowing.hide.newCategory": "Не показувати нові категорії", + "tabs.stats.worthKnowing.hide.priceChange": "Не показувати зміни цін", + "tabs.stats.worthKnowing.hide.recurringCharge": "Не показувати підказки про підписки", + "tabs.stats.worthKnowing.kinds": "Показувати у «Варто знати»", + "tabs.stats.worthKnowing.lessThanUsual": "на {amount} менше, ніж зазвичай", + "tabs.stats.worthKnowing.monthAbove": "{month}: на {value} вище звичайного до {dayNumber}-го числа.", + "tabs.stats.worthKnowing.monthAbovePast": "{month}: на {value} вище звичайного.", + "tabs.stats.worthKnowing.monthBelow": "{month}: на {value} нижче звичайного до {dayNumber}-го числа.", + "tabs.stats.worthKnowing.monthBelowPast": "{month}: на {value} нижче звичайного.", + "tabs.stats.worthKnowing.moreThanUsual": "на {amount} більше, ніж зазвичай", + "tabs.stats.worthKnowing.mostlyCategory": "Здебільшого {category}, {difference}.", + "tabs.stats.worthKnowing.mostlyEven": "Здебільшого {title} ({amount}). Без цього — як зазвичай.", + "tabs.stats.worthKnowing.mostlyOver": "Здебільшого {title} ({amount}). Без цього все одно на {percent} вище.", + "tabs.stats.worthKnowing.mostlyUnder": "Здебільшого {title} ({amount}). Без цього на {percent} нижче.", + "tabs.stats.worthKnowing.newCategory": "{value}: перші витрати за {months} міс.", + "tabs.stats.worthKnowing.nothingUnusual": "{month}: поки нічого незвичного.", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}: нічого незвичного.", + "tabs.stats.worthKnowing.priceChange": "{title}: було {previous}, стало {value}.", + "tabs.stats.worthKnowing.priceDown": "на {amount} менше на рік", + "tabs.stats.worthKnowing.priceUp": "на {amount} більше на рік", + "tabs.stats.worthKnowing.recurringMonthly": "{value} схоже на щомісячний платіж.", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount} близько {dayNumber}-го числа · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} схоже на щотижневий платіж.", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} схоже на щорічний платіж.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount} близько {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "З'являється, коли категорія, якою ви користуєтеся майже щомісяця, падає нижче {percent} від своєї медіани.", + "tabs.stats.worthKnowing.rule.categorySpike": "З'являється, коли категорія досягає {ratio} від своєї медіани за {months} попередніх міс. і має щонайменше {count} записи.", + "tabs.stats.worthKnowing.rule.footer": "Перекази та очікувані записи не враховуються. Пораховано на цьому пристрої.", + "tabs.stats.worthKnowing.rule.monthPace": "Витрати до {date} порівняно з медіаною {months} попередніх міс. на той самий день.", + "tabs.stats.worthKnowing.rule.monthPacePast": "Витрати за період «{month}» порівняно з медіаною {months} попередніх міс.", + "tabs.stats.worthKnowing.rule.newCategory": "З'являється, коли в категорії вперше за {months} міс. є витрати.", + "tabs.stats.worthKnowing.rule.priceChange": "З'являється, коли регулярний платіж змінюється на {percent} або більше.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Платежі з однаковою назвою та сталою сумою, що регулярно повторюються.", + "tabs.stats.worthKnowing.seeTransactions": "Показати ці транзакції", + "tabs.stats.worthKnowing.times": "уже {count} разів", + "tabs.stats.worthKnowing.times.few": "уже {count} рази", + "tabs.stats.worthKnowing.times.one": "уже {count} раз", + "tabs.stats.worthKnowing.track": "Зробити повторюваною", + "tabs.stats.worthKnowing.type.categoryDrop": "Зниження за категоріями", + "tabs.stats.worthKnowing.type.categorySpike": "Сплески за категоріями", + "tabs.stats.worthKnowing.type.monthPace": "Порівняння місяця", + "tabs.stats.worthKnowing.type.newCategory": "Нові категорії", + "tabs.stats.worthKnowing.type.priceChange": "Зміни цін", + "tabs.stats.worthKnowing.type.recurringCharge": "Підказки про підписки", + "tabs.stats.worthKnowing.versusTypical": "{current} проти звичних {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "Що вплинуло", + "tabs.stats.worthKnowing.why": "Чому ви це бачите", "transaction": "Транзакція", "transaction.actions": "Дії", "transaction.attachments": "Файлові вкладення", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index dc289d26..2a38e078 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "所选时间范围内的支出金额中位数", "tabs.stats.trends.topSpendingTitles": "频繁交易", "tabs.stats.trends.topSpendingTitles.description": "最常使用的交易标题", + "tabs.stats.worthKnowing": "值得了解", + "tabs.stats.worthKnowing.biggestEntries": "最大的几笔", + "tabs.stats.worthKnowing.categoryDrop": "{category} 下降了 {value}。", + "tabs.stats.worthKnowing.categoryDropPast": "{category} 下降了 {value}。", + "tabs.stats.worthKnowing.categorySpike": "{category} 是平时的 {value}。", + "tabs.stats.worthKnowing.categorySpikePast": "{category} 曾是平时的 {value}。", + "tabs.stats.worthKnowing.charges": "扣款", + "tabs.stats.worthKnowing.chartBefore": "之前 {amount}", + "tabs.stats.worthKnowing.chartCharges": "每笔扣款", + "tabs.stats.worthKnowing.chartMonthly": "每月支出", + "tabs.stats.worthKnowing.chartThroughDay": "每月截至 {dayNumber} 日的支出", + "tabs.stats.worthKnowing.chartUsual": "平时 {amount}", + "tabs.stats.worthKnowing.entries": "{count} 笔", + "tabs.stats.worthKnowing.entries.one": "{count} 笔", + "tabs.stats.worthKnowing.hidden": "已隐藏。可在「值得了解」中重新开启。", + "tabs.stats.worthKnowing.hide.categoryDrop": "不显示分类下降", + "tabs.stats.worthKnowing.hide.categorySpike": "不显示分类激增", + "tabs.stats.worthKnowing.hide.monthPace": "不显示月度对比", + "tabs.stats.worthKnowing.hide.newCategory": "不显示新分类", + "tabs.stats.worthKnowing.hide.priceChange": "不显示价格变动", + "tabs.stats.worthKnowing.hide.recurringCharge": "不显示订阅建议", + "tabs.stats.worthKnowing.kinds": "在「值得了解」中显示", + "tabs.stats.worthKnowing.lessThanUsual": "比平时少 {amount}", + "tabs.stats.worthKnowing.monthAbove": "{month}截至 {dayNumber} 日比平时高 {value}。", + "tabs.stats.worthKnowing.monthAbovePast": "{month}比平时高 {value}。", + "tabs.stats.worthKnowing.monthBelow": "{month}截至 {dayNumber} 日比平时低 {value}。", + "tabs.stats.worthKnowing.monthBelowPast": "{month}比平时低 {value}。", + "tabs.stats.worthKnowing.moreThanUsual": "比平时多 {amount}", + "tabs.stats.worthKnowing.mostlyCategory": "主要是 {category},{difference}。", + "tabs.stats.worthKnowing.mostlyEven": "主要是 {title}({amount})。除去它,与平时相当。", + "tabs.stats.worthKnowing.mostlyOver": "主要是 {title}({amount})。除去它,仍高出 {percent}。", + "tabs.stats.worthKnowing.mostlyUnder": "主要是 {title}({amount})。除去它,低 {percent}。", + "tabs.stats.worthKnowing.newCategory": "{months} 个月来首次在 {value} 上支出。", + "tabs.stats.worthKnowing.nothingUnusual": "{month}目前没有异常。", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}没有异常。", + "tabs.stats.worthKnowing.priceChange": "{title} 从 {previous} 变为 {value}。", + "tabs.stats.worthKnowing.priceDown": "每年少 {amount}", + "tabs.stats.worthKnowing.priceUp": "每年多 {amount}", + "tabs.stats.worthKnowing.recurringMonthly": "{value} 看起来是每月扣款。", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount},约每月 {dayNumber} 日 · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} 看起来是每周扣款。", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount},每{weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} 看起来是每年扣款。", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount},约 {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "当几乎每月都用的分类低于其中位数的 {percent} 时显示。", + "tabs.stats.worthKnowing.rule.categorySpike": "当某分类达到前 {months} 个月中位数的 {ratio},且至少有 {count} 笔时显示。", + "tabs.stats.worthKnowing.rule.footer": "不含转账和待处理记录。在本设备上计算。", + "tabs.stats.worthKnowing.rule.monthPace": "截至 {date} 的支出,与前 {months} 个月同一天的中位数对比。", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month}的支出,与前 {months} 个月的中位数对比。", + "tabs.stats.worthKnowing.rule.newCategory": "当某分类 {months} 个月来首次有支出时显示。", + "tabs.stats.worthKnowing.rule.priceChange": "当定期扣款变动 {percent} 或以上时显示。", + "tabs.stats.worthKnowing.rule.recurringCharge": "标题相同、金额稳定、按固定周期重复的扣款。", + "tabs.stats.worthKnowing.seeTransactions": "查看这些交易", + "tabs.stats.worthKnowing.times": "已 {count} 次", + "tabs.stats.worthKnowing.times.one": "已 {count} 次", + "tabs.stats.worthKnowing.track": "设为周期性交易", + "tabs.stats.worthKnowing.type.categoryDrop": "分类下降", + "tabs.stats.worthKnowing.type.categorySpike": "分类激增", + "tabs.stats.worthKnowing.type.monthPace": "月度对比", + "tabs.stats.worthKnowing.type.newCategory": "新分类", + "tabs.stats.worthKnowing.type.priceChange": "价格变动", + "tabs.stats.worthKnowing.type.recurringCharge": "订阅建议", + "tabs.stats.worthKnowing.versusTypical": "{current},平时 {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "主要原因", + "tabs.stats.worthKnowing.why": "为什么会看到这个", "transaction": "交易", "transaction.actions": "动作", "transaction.attachments": "文件附件", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 6c4d1413..c3574bf0 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -812,6 +812,71 @@ "tabs.stats.trends.median.description": "所選時間範圍內的支出金額中位數", "tabs.stats.trends.topSpendingTitles": "頻繁交易", "tabs.stats.trends.topSpendingTitles.description": "最常使用的交易標題", + "tabs.stats.worthKnowing": "值得了解", + "tabs.stats.worthKnowing.biggestEntries": "最大的幾筆", + "tabs.stats.worthKnowing.categoryDrop": "{category} 下降了 {value}。", + "tabs.stats.worthKnowing.categoryDropPast": "{category} 下降了 {value}。", + "tabs.stats.worthKnowing.categorySpike": "{category} 是平時的 {value}。", + "tabs.stats.worthKnowing.categorySpikePast": "{category} 曾是平時的 {value}。", + "tabs.stats.worthKnowing.charges": "扣款", + "tabs.stats.worthKnowing.chartBefore": "之前 {amount}", + "tabs.stats.worthKnowing.chartCharges": "每筆扣款", + "tabs.stats.worthKnowing.chartMonthly": "每月支出", + "tabs.stats.worthKnowing.chartThroughDay": "每月截至 {dayNumber} 日的支出", + "tabs.stats.worthKnowing.chartUsual": "平時 {amount}", + "tabs.stats.worthKnowing.entries": "{count} 筆", + "tabs.stats.worthKnowing.entries.one": "{count} 筆", + "tabs.stats.worthKnowing.hidden": "已隱藏。可在「值得了解」中重新開啟。", + "tabs.stats.worthKnowing.hide.categoryDrop": "不顯示分類下降", + "tabs.stats.worthKnowing.hide.categorySpike": "不顯示分類激增", + "tabs.stats.worthKnowing.hide.monthPace": "不顯示月度比較", + "tabs.stats.worthKnowing.hide.newCategory": "不顯示新分類", + "tabs.stats.worthKnowing.hide.priceChange": "不顯示價格變動", + "tabs.stats.worthKnowing.hide.recurringCharge": "不顯示訂閱建議", + "tabs.stats.worthKnowing.kinds": "在「值得了解」中顯示", + "tabs.stats.worthKnowing.lessThanUsual": "比平時少 {amount}", + "tabs.stats.worthKnowing.monthAbove": "{month}截至 {dayNumber} 日比平時高 {value}。", + "tabs.stats.worthKnowing.monthAbovePast": "{month}比平時高 {value}。", + "tabs.stats.worthKnowing.monthBelow": "{month}截至 {dayNumber} 日比平時低 {value}。", + "tabs.stats.worthKnowing.monthBelowPast": "{month}比平時低 {value}。", + "tabs.stats.worthKnowing.moreThanUsual": "比平時多 {amount}", + "tabs.stats.worthKnowing.mostlyCategory": "主要是 {category},{difference}。", + "tabs.stats.worthKnowing.mostlyEven": "主要是 {title}({amount})。除去它,與平時相當。", + "tabs.stats.worthKnowing.mostlyOver": "主要是 {title}({amount})。除去它,仍高出 {percent}。", + "tabs.stats.worthKnowing.mostlyUnder": "主要是 {title}({amount})。除去它,低 {percent}。", + "tabs.stats.worthKnowing.newCategory": "{months} 個月來首次在 {value} 上支出。", + "tabs.stats.worthKnowing.nothingUnusual": "{month}目前沒有異常。", + "tabs.stats.worthKnowing.nothingUnusualPast": "{month}沒有異常。", + "tabs.stats.worthKnowing.priceChange": "{title} 從 {previous} 變為 {value}。", + "tabs.stats.worthKnowing.priceDown": "每年少 {amount}", + "tabs.stats.worthKnowing.priceUp": "每年多 {amount}", + "tabs.stats.worthKnowing.recurringMonthly": "{value} 看起來是每月扣款。", + "tabs.stats.worthKnowing.recurringMonthlyDetail": "{amount},約每月 {dayNumber} 日 · {times}", + "tabs.stats.worthKnowing.recurringWeekly": "{value} 看起來是每週扣款。", + "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount},每{weekday} · {times}", + "tabs.stats.worthKnowing.recurringYearly": "{value} 看起來是每年扣款。", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount},約 {date} · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "當幾乎每月都用的分類低於其中位數的 {percent} 時顯示。", + "tabs.stats.worthKnowing.rule.categorySpike": "當某分類達到前 {months} 個月中位數的 {ratio},且至少有 {count} 筆時顯示。", + "tabs.stats.worthKnowing.rule.footer": "不含轉帳和待處理紀錄。在本裝置上計算。", + "tabs.stats.worthKnowing.rule.monthPace": "截至 {date} 的支出,與前 {months} 個月同一天的中位數比較。", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month}的支出,與前 {months} 個月的中位數比較。", + "tabs.stats.worthKnowing.rule.newCategory": "當某分類 {months} 個月來首次有支出時顯示。", + "tabs.stats.worthKnowing.rule.priceChange": "當定期扣款變動 {percent} 或以上時顯示。", + "tabs.stats.worthKnowing.rule.recurringCharge": "標題相同、金額穩定、按固定週期重複的扣款。", + "tabs.stats.worthKnowing.seeTransactions": "查看這些交易", + "tabs.stats.worthKnowing.times": "已 {count} 次", + "tabs.stats.worthKnowing.times.one": "已 {count} 次", + "tabs.stats.worthKnowing.track": "設為週期性交易", + "tabs.stats.worthKnowing.type.categoryDrop": "分類下降", + "tabs.stats.worthKnowing.type.categorySpike": "分類激增", + "tabs.stats.worthKnowing.type.monthPace": "月度比較", + "tabs.stats.worthKnowing.type.newCategory": "新分類", + "tabs.stats.worthKnowing.type.priceChange": "價格變動", + "tabs.stats.worthKnowing.type.recurringCharge": "訂閱建議", + "tabs.stats.worthKnowing.versusTypical": "{current},平時 {usual}", + "tabs.stats.worthKnowing.whatMovedIt": "主要原因", + "tabs.stats.worthKnowing.why": "為什麼會看到這個", "transaction": "交易", "transaction.actions": "動作", "transaction.attachments": "檔案附件", diff --git a/lib/widgets/home/stats/bento/analytics_bento.dart b/lib/widgets/home/stats/bento/analytics_bento.dart index 531c8694..066c7f4e 100644 --- a/lib/widgets/home/stats/bento/analytics_bento.dart +++ b/lib/widgets/home/stats/bento/analytics_bento.dart @@ -9,15 +9,16 @@ import "package:flow/widgets/home/stats/bento/net_worth_tile.dart"; import "package:flow/widgets/home/stats/bento/pace_tile.dart"; import "package:flow/widgets/home/stats/bento/recurring_tile.dart"; import "package:flow/widgets/home/stats/bento/top_categories_tile.dart"; +import "package:flow/widgets/home/stats/bento/worth_knowing_card.dart"; import "package:flow/widgets/home/stats/bento/wrapped_tile.dart"; import "package:flutter/material.dart"; import "package:moment_dart/moment_dart.dart"; /// The Stats bento dashboard, split into two sections. /// -/// The top section is **range-bound**: [CashFlowTile], [PaceTile], and -/// [TopCategoriesTile] follow the [range] picked by the Stats tab's time-range -/// selector, so they belong directly beneath it. +/// The top section is **range-bound**: [WorthKnowingCard], [CashFlowTile], +/// [PaceTile], and [TopCategoriesTile] follow the [range] picked by the Stats +/// tab's time-range selector, so they belong directly beneath it. /// /// Below an "Insights" header sits the **timeless** section: net worth, /// wrapped, budgets, the spending calendar, recurring, and the spending map @@ -39,6 +40,8 @@ class AnalyticsBento extends StatelessWidget { child: Column( crossAxisAlignment: .start, children: [ + // Collapses to nothing outside month ranges, spacing included. + WorthKnowingCard(range: range), // Range-bound — these respond to the selected time range. Cash // flow and pace share a row to stay compact; each takes half. Row( diff --git a/lib/widgets/home/stats/bento/worth_knowing_card.dart b/lib/widgets/home/stats/bento/worth_knowing_card.dart new file mode 100644 index 00000000..6fc54cff --- /dev/null +++ b/lib/widgets/home/stats/bento/worth_knowing_card.dart @@ -0,0 +1,177 @@ +import "dart:async"; + +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/prefs/insights_preferences.dart"; +import "package:flow/prefs/local_preferences.dart"; +import "package:flow/services/insights.dart"; +import "package:flow/utils/primary_currency_dependent_state.dart"; +import "package:flow/widgets/insights/insight_actions.dart"; +import "package:flow/widgets/insights/insight_evidence_sheet.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flow/widgets/insights/insight_kinds_sheet.dart"; +import "package:flow/widgets/insights/worth_knowing_view.dart"; +import "package:flutter/material.dart"; +import "package:logging/logging.dart"; +import "package:moment_dart/moment_dart.dart"; + +final Logger _log = Logger("WorthKnowingCard"); + +/// A few computed observations about the selected month, above the Stats +/// tiles. Hidden for ranges other than a month. +class WorthKnowingCard extends StatefulWidget { + final TimeRange range; + + const WorthKnowingCard({super.key, required this.range}); + + @override + State<WorthKnowingCard> createState() => _WorthKnowingCardState(); +} + +class _WorthKnowingCardState extends State<WorthKnowingCard> + with PrimaryCurrencyDependentState<WorthKnowingCard> { + bool busy = true; + InsightReport? report; + + /// Drops results of fetches that a newer one replaced. + int _generation = 0; + + DateTime? get month => switch (widget.range) { + MonthTimeRange range => range.from, + _ => null, + }; + + InsightFormatter get format => InsightFormatter( + currency: primaryCurrency, + obscure: TransitiveLocalPreferences().sessionPrivacyMode.get(), + useCurrencySymbol: LocalPreferences().useCurrencySymbol.get(), + preferFullAmounts: LocalPreferences().preferFullAmounts.get(), + ); + + late final Listenable _formatting = Listenable.merge([ + TransitiveLocalPreferences().sessionPrivacyMode.valueNotifier, + LocalPreferences().useCurrencySymbol.valueNotifier, + LocalPreferences().preferFullAmounts.valueNotifier, + InsightsLocalPreferences().hiddenTypes.valueNotifier, + ]); + + @override + void initState() { + super.initState(); + InsightsLocalPreferences().hiddenTypes.addListener(_onHiddenTypesChanged); + } + + @override + void dispose() { + InsightsLocalPreferences().hiddenTypes.removeListener( + _onHiddenTypesChanged, + ); + super.dispose(); + } + + @override + void didUpdateWidget(WorthKnowingCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.range != oldWidget.range) fetch(); + } + + @override + Widget build(BuildContext context) { + final DateTime? month = this.month; + + return AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: month == null || (!busy && report == null) + ? const SizedBox(width: double.infinity) + : ListenableBuilder( + listenable: _formatting, + builder: (context, _) => WorthKnowingView( + report: report, + stale: + report != null && + (report!.month.year != month.year || + report!.month.month != month.month), + format: format, + hiddenTypeCount: + InsightsLocalPreferences().hiddenTypeSet.length, + onOpen: _open, + onTrack: (insight) => + InsightActions.trackAsRecurring(context, insight.series), + onManageHidden: _manageHidden, + ), + ), + ); + } + + void _onHiddenTypesChanged() => fetch(); + + void _open(Insight insight) { + final InsightReport? report = this.report; + if (report == null) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => InsightEvidenceSheet( + insight: insight, + report: report, + format: format, + ), + ); + } + + void _manageHidden() => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => const InsightKindsSheet(), + ); + + @override + Future<void> fetch() async { + final int generation = ++_generation; + final DateTime? month = this.month; + + if (month == null) { + report = null; + busy = false; + if (mounted) setState(() {}); + return; + } + + busy = true; + + try { + final InsightReport next = await InsightsService().computeFor( + month, + hiddenTypes: InsightsLocalPreferences().hiddenTypeSet, + sightings: InsightsLocalPreferences().sightingList, + ); + if (generation != _generation) return; + + report = next; + + if (next.isInProgress && next.insights.isNotEmpty) { + unawaited( + InsightsLocalPreferences() + .recordSightings(next.insights, DateTime.now()) + .catchError( + (error, stackTrace) => _log.warning( + "Failed to record insight sightings", + error, + stackTrace, + ), + ), + ); + } + } catch (e, stackTrace) { + _log.warning("Failed to compute insights", e, stackTrace); + if (generation == _generation) report = null; + } finally { + if (generation == _generation) { + busy = false; + if (mounted) setState(() {}); + } + } + } +} diff --git a/lib/widgets/insights/insight_actions.dart b/lib/widgets/insights/insight_actions.dart new file mode 100644 index 00000000..a0074b32 --- /dev/null +++ b/lib/widgets/insights/insight_actions.dart @@ -0,0 +1,73 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/providers/categories_provider.dart"; +import "package:flow/services/transactions.dart"; +import "package:flutter/widgets.dart"; +import "package:go_router/go_router.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; + +/// Where Worth knowing rows lead to. +abstract final class InsightActions { + /// Opens the latest charge with its recurrence filled in, so saving it + /// starts tracking the series from there. + static Future<void> trackAsRecurring( + BuildContext context, + RecurringSeries series, + ) async { + final Transaction? latest = TransactionsService().findByIdentifierSync( + series.occurrences.last.transactionUuid, + ); + if (latest == null) return; + + final DateTime date = latest.transactionDate; + + final Recurrence recurrence = Recurrence.fromIndefinitely( + rules: [ + switch (series.cadence) { + .weekly => RecurrenceRule.weekly(date.weekday), + .monthly => RecurrenceRule.monthly(date.day), + .yearly => RecurrenceRule.yearly(date.month, date.day), + }, + ], + start: date, + ); + + await context.push( + "/transaction/${latest.id}?recurrence=${Uri.encodeQueryComponent(recurrence.serialize())}", + ); + } + + /// A list of the transactions behind [insight], or null if there's no + /// better list than the evidence itself. + static String? transactionsPath( + BuildContext context, + Insight insight, + InsightReport report, + ) { + final String range = Uri.encodeQueryComponent( + MonthTimeRange.fromDateTime(report.month).encodeShort(), + ); + + final String? categoryUuid = switch (insight) { + CategorySpikeInsight spike => spike.categoryUuid, + CategoryDropInsight drop => drop.categoryUuid, + NewCategoryInsight newCategory => newCategory.categoryUuid, + _ => null, + }; + + if (categoryUuid != null) { + final Category? category = CategoriesProvider.of( + context, + ).get(categoryUuid); + + return category == null ? null : "/category/${category.id}?range=$range"; + } + + return switch (insight) { + MonthPaceInsight() => "/transactions?range=$range", + _ => null, + }; + } +} diff --git a/lib/widgets/insights/insight_driver_tile.dart b/lib/widgets/insights/insight_driver_tile.dart new file mode 100644 index 00000000..a1cbebf4 --- /dev/null +++ b/lib/widgets/insights/insight_driver_tile.dart @@ -0,0 +1,65 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/providers/categories_provider.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/flow_icon.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// A category that moved a month comparison, opening that category's month. +class InsightDriverTile extends StatelessWidget { + final InsightCategoryDelta driver; + + /// First day of the analyzed month. + final DateTime month; + + final InsightFormatter format; + + const InsightDriverTile({ + super.key, + required this.driver, + required this.month, + required this.format, + }); + + @override + Widget build(BuildContext context) { + final Category? category = driver.categoryUuid == null + ? null + : CategoriesProvider.of(context).get(driver.categoryUuid); + final String range = Uri.encodeQueryComponent( + MonthTimeRange.fromDateTime(month).encodeShort(), + ); + + return ListTile( + leading: FlowIcon( + category?.icon ?? FlowIconData.icon(Symbols.category_rounded), + plated: true, + colorScheme: category?.colorScheme, + ), + title: Text( + category?.name ?? "tabs.stats.analytics.uncategorized".t(context), + ), + subtitle: Text( + (driver.delta >= 0.0 + ? "tabs.stats.worthKnowing.moreThanUsual" + : "tabs.stats.worthKnowing.lessThanUsual") + .t(context, {"amount": format.money(driver.delta)}), + ), + trailing: Text( + format.money(driver.current), + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + onTap: category == null + ? null + : () => context.push("/category/${category.id}?range=$range"), + ); + } +} diff --git a/lib/widgets/insights/insight_evidence_sheet.dart b/lib/widgets/insights/insight_evidence_sheet.dart new file mode 100644 index 00000000..5daf7bbc --- /dev/null +++ b/lib/widgets/insights/insight_evidence_sheet.dart @@ -0,0 +1,368 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; +import "package:flow/data/transaction_filter.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/prefs/insights_preferences.dart"; +import "package:flow/services/transactions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions.dart"; +import "package:flow/widgets/general/frame.dart"; +import "package:flow/widgets/general/modal_overflow_bar.dart"; +import "package:flow/widgets/general/modal_sheet.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/insights/insight_actions.dart"; +import "package:flow/widgets/insights/insight_driver_tile.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flow/widgets/insights/insight_history_chart.dart"; +import "package:flow/widgets/insights/insight_summary.dart"; +import "package:flow/widgets/stats/emphasized_text.dart"; +import "package:flow/widgets/transaction_list_tile.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// The math behind one Worth knowing row: its history against the usual, +/// what moved it, and the rule that fired. +class InsightEvidenceSheet extends StatefulWidget { + final Insight insight; + final InsightReport report; + final InsightFormatter format; + + const InsightEvidenceSheet({ + super.key, + required this.insight, + required this.report, + required this.format, + }); + + @override + State<InsightEvidenceSheet> createState() => _InsightEvidenceSheetState(); +} + +class _InsightEvidenceSheetState extends State<InsightEvidenceSheet> { + static const String _prefix = "tabs.stats.worthKnowing"; + static const int _maxChargeBars = 8; + + late final List<Transaction> transactions; + + Insight get insight => widget.insight; + InsightFormatter get format => widget.format; + + @override + void initState() { + super.initState(); + + final List<String> uuids = insight.transactionUuids + .take(InsightThresholds.maxEvidenceTransactions) + .toList(); + final Map<String, Transaction> found = { + for (final Transaction transaction in TransactionsService().findManySync( + TransactionFilter(uuids: uuids), + )) + transaction.uuid: transaction, + }; + + transactions = uuids.map((uuid) => found[uuid]).nonNulls.toList(); + } + + @override + Widget build(BuildContext context) { + final InsightSummary summary = InsightSummary.of( + context, + insight: insight, + report: widget.report, + format: format, + ); + final String? transactionsPath = InsightActions.transactionsPath( + context, + insight, + widget.report, + ); + final List<InsightChartBar>? bars = _bars(); + final List<InsightCategoryDelta> drivers = switch (insight) { + MonthPaceInsight pace => pace.drivers, + _ => const [], + }; + + return ModalSheet.scrollable( + trailing: ModalOverflowBar( + alignment: .end, + children: [ + TextButton.icon( + onPressed: _hide, + icon: const Icon(Symbols.visibility_off_rounded), + label: Text("$_prefix.hide.${insight.type.name}".t(context)), + ), + if (insight case RecurringChargeInsight recurring) + TextButton.icon( + onPressed: () => _track(recurring.series), + icon: const Icon(Symbols.add_rounded), + label: Text("$_prefix.track".t(context)), + ) + else if (transactionsPath != null) + TextButton.icon( + onPressed: () => context.push(transactionsPath), + icon: const Icon(Symbols.list_rounded), + label: Text("$_prefix.seeTransactions".t(context)), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: .start, + children: [ + Frame( + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + Icon( + Symbols.help_rounded, + color: context.colorScheme.primary, + size: 18.0, + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + "$_prefix.why".t(context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.flowColors.semi, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8.0), + DefaultTextStyle.merge( + style: context.textTheme.titleSmall?.copyWith(height: 1.35), + child: EmphasizedText( + template: summary.template, + value: summary.value, + valueStyle: TextStyle( + color: summary.valueColor(context), + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 4.0), + Text( + summary.detail, + style: context.textTheme.labelMedium?.semi(context), + ), + if (bars != null) ...[ + const SizedBox(height: 16.0), + Surface( + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB( + 14.0, + 14.0, + 14.0, + 10.0, + ), + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + _chartCaption(), + style: context.textTheme.labelMedium?.semi( + context, + ), + ), + const SizedBox(height: 20.0), + InsightHistoryChart( + bars: bars, + reference: _reference(), + referenceLabel: _referenceLabel(), + formatAmount: format.money, + ), + ], + ), + ), + ), + ], + ], + ), + ), + if (drivers.isNotEmpty) ...[ + const SizedBox(height: 16.0), + Frame( + child: Text( + "$_prefix.whatMovedIt".t(context), + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + for (final InsightCategoryDelta driver in drivers) + InsightDriverTile( + driver: driver, + month: widget.report.month, + format: format, + ), + ], + if (transactions.isNotEmpty) ...[ + const SizedBox(height: 16.0), + Frame( + child: Text( + _transactionsTitle(drivers.isNotEmpty), + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + for (final Transaction transaction in transactions) + TransactionListTile( + transaction: transaction, + recoverFromTrashFn: null, + moveToTrashFn: null, + combineTransfers: false, + groupRange: .month, + ), + ], + const SizedBox(height: 16.0), + Frame( + child: Text( + [_rule(), "$_prefix.rule.footer".t(context)].join(" "), + style: context.textTheme.labelMedium?.semi(context), + ), + ), + const SizedBox(height: 16.0), + ], + ), + ), + ); + } + + List<InsightChartBar> _monthBars( + List<InsightMonthValue> history, + double current, + ) => [ + for (final InsightMonthValue value in history) + ( + label: value.month.toMoment().format("MMM"), + amount: value.amount, + muted: !value.counted, + ), + ( + label: widget.report.month.toMoment().format("MMM"), + amount: current, + muted: false, + ), + ]; + + List<InsightChartBar> _chargeBars(RecurringSeries series) => [ + for (final RecurringOccurrence occurrence + in series.occurrences.reversed.take(_maxChargeBars).toList().reversed) + ( + label: occurrence.date.toMoment().format("MMM D"), + amount: occurrence.amount, + muted: false, + ), + ]; + + List<InsightChartBar>? _bars() => switch (insight) { + MonthPaceInsight pace => _monthBars(pace.history, pace.current), + CategorySpikeInsight spike => _monthBars(spike.history, spike.current), + CategoryDropInsight drop => _monthBars(drop.history, drop.current), + NewCategoryInsight() => null, + RecurringChargeInsight recurring => _chargeBars(recurring.series), + PriceChangeInsight priceChange => _chargeBars(priceChange.series), + }; + + double _reference() => switch (insight) { + MonthPaceInsight pace => pace.usual, + CategorySpikeInsight spike => spike.usual, + CategoryDropInsight drop => drop.usual, + NewCategoryInsight() => 0.0, + RecurringChargeInsight recurring => recurring.series.typicalAmount, + PriceChangeInsight priceChange => priceChange.previousAmount, + }; + + String _referenceLabel() => + "$_prefix.${switch (insight) { + PriceChangeInsight() => "chartBefore", + _ => "chartUsual", + }}" + .t(context, {"amount": format.money(_reference())}); + + int? get _throughDay => switch (insight) { + MonthPaceInsight pace => pace.throughDay, + CategoryDropInsight drop => drop.throughDay, + _ => null, + }; + + String _chartCaption() { + if (insight is RecurringChargeInsight || insight is PriceChangeInsight) { + return "$_prefix.chartCharges".t(context); + } + + return switch (_throughDay) { + int day => "$_prefix.chartThroughDay".t( + context, + InsightSummary.dayValues(day), + ), + null => "$_prefix.chartMonthly".t(context), + }; + } + + String _transactionsTitle(bool hasDrivers) => + "$_prefix.${switch (insight) { + RecurringChargeInsight() || PriceChangeInsight() => "charges", + _ when hasDrivers => "biggestEntries", + _ => "whatMovedIt", + }}" + .t(context); + + String _rule() { + final int months = InsightThresholds.baselineMonths; + + return switch (insight) { + MonthPaceInsight(throughDay: int day) => + "$_prefix.rule.monthPace".t(context, { + "date": DateTime( + widget.report.month.year, + widget.report.month.month, + day, + ).toMoment().format("MMM D"), + "months": months, + }), + MonthPaceInsight() => "$_prefix.rule.monthPacePast".t(context, { + "month": widget.report.month.toMoment().format("MMMM"), + "months": months, + }), + CategorySpikeInsight() => "$_prefix.rule.categorySpike".t(context, { + "ratio": format.ratio(InsightThresholds.categorySpikeRatio), + "count": InsightThresholds.categorySpikeMinEntries, + "months": months, + }), + CategoryDropInsight() => "$_prefix.rule.categoryDrop".t(context, { + "percent": format.percent(InsightThresholds.categoryDropRatio), + }), + NewCategoryInsight() => "$_prefix.rule.newCategory".t(context, { + "months": months, + }), + RecurringChargeInsight() => "$_prefix.rule.recurringCharge".t(context), + PriceChangeInsight() => "$_prefix.rule.priceChange".t(context, { + "percent": format.percent(InsightThresholds.priceChangeMinRatio), + }), + }; + } + + Future<void> _hide() async { + await InsightsLocalPreferences().setHidden(insight.type, true); + if (!mounted) return; + + context.showToast(text: "$_prefix.hidden".t(context)); + context.pop(); + } + + void _track(RecurringSeries series) { + context.pop(); + InsightActions.trackAsRecurring(context, series); + } +} diff --git a/lib/widgets/insights/insight_formatter.dart b/lib/widgets/insights/insight_formatter.dart new file mode 100644 index 00000000..35d6e585 --- /dev/null +++ b/lib/widgets/insights/insight_formatter.dart @@ -0,0 +1,39 @@ +import "package:flow/data/money.dart"; +import "package:flow/utils/extensions.dart"; + +/// Formats insight figures for use inside sentences. Honors privacy mode and +/// the money formatting preferences. +class InsightFormatter { + final String currency; + final bool obscure; + final bool useCurrencySymbol; + final bool preferFullAmounts; + + const InsightFormatter({ + required this.currency, + this.obscure = false, + this.useCurrencySymbol = true, + this.preferFullAmounts = false, + }); + + /// Cents only matter for small amounts, like a subscription. + String money(double amount) { + final double value = amount.abs(); + final Money money = Money(value, currency); + + final String text = !preferFullAmounts && value >= 10000.0 + ? money.formatMoney(compact: true, useCurrencySymbol: useCurrencySymbol) + : money.formatMoney( + useCurrencySymbol: useCurrencySymbol, + decimalDigits: value >= 100.0 ? 0 : null, + ); + + return obscure ? text.digitsObscured : text; + } + + /// e.g. "27%" for 0.27 or -0.27. + String percent(double fraction) => fraction.abs().percentInt; + + /// e.g. "1.9×". + String ratio(double ratio) => "${ratio.toStringAsFixed(1)}×"; +} diff --git a/lib/widgets/insights/insight_history_chart.dart b/lib/widgets/insights/insight_history_chart.dart new file mode 100644 index 00000000..f9f54a89 --- /dev/null +++ b/lib/widgets/insights/insight_history_chart.dart @@ -0,0 +1,150 @@ +import "dart:math" as math; + +import "package:fl_chart/fl_chart.dart"; +import "package:flow/theme/theme.dart"; +import "package:flutter/material.dart"; + +/// One bar in an [InsightHistoryChart]. +typedef InsightChartBar = ({String label, double amount, bool muted}); + +/// Bars for past values with the latest one highlighted, and a dashed line +/// at the [reference] value (usually the median). +class InsightHistoryChart extends StatelessWidget { + /// Oldest first. The last one is highlighted. + final List<InsightChartBar> bars; + + final double reference; + final String referenceLabel; + + /// Formats amounts for the tooltip. + final String Function(double amount) formatAmount; + + final double height; + + const InsightHistoryChart({ + super.key, + required this.bars, + required this.reference, + required this.referenceLabel, + required this.formatAmount, + this.height = 120.0, + }); + + @override + Widget build(BuildContext context) { + final double maxAmount = bars.fold( + reference, + (max, bar) => math.max(max, bar.amount), + ); + final Color base = context.colorScheme.onSurface.withAlpha(0x33); + final Color muted = context.colorScheme.onSurface.withAlpha(0x14); + + return SizedBox( + height: height, + child: LayoutBuilder( + builder: (context, constraints) { + final double barWidth = + (constraints.maxWidth / math.max(bars.length, 1) * 0.6).clamp( + 4.0, + 28.0, + ); + + return BarChart( + BarChartData( + minY: 0.0, + maxY: maxAmount <= 0.0 ? 1.0 : maxAmount * 1.2, + alignment: .spaceAround, + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => context.colorScheme.onSurface, + getTooltipItem: (group, groupIndex, rod, rodIndex) => + BarTooltipItem( + formatAmount(rod.toY), + TextStyle( + color: context.colorScheme.surface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + titlesData: FlTitlesData( + leftTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + topTitles: const AxisTitles(), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 20.0, + getTitlesWidget: (value, meta) { + final int index = value.toInt(); + if (index < 0 || index >= bars.length) { + return const SizedBox.shrink(); + } + + final bool last = index == bars.length - 1; + + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + bars[index].label, + maxLines: 1, + style: context.textTheme.labelSmall?.copyWith( + color: last ? null : context.flowColors.semi, + fontWeight: last ? FontWeight.w600 : null, + ), + ), + ); + }, + ), + ), + ), + extraLinesData: ExtraLinesData( + horizontalLines: [ + HorizontalLine( + y: reference, + color: context.colorScheme.onSurface.withAlpha(0x8c), + strokeWidth: 1.5, + dashArray: [4, 4], + label: HorizontalLineLabel( + show: true, + alignment: Alignment.topLeft, + padding: const EdgeInsets.only(bottom: 2.0), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + labelResolver: (_) => referenceLabel, + ), + ), + ], + ), + barGroups: [ + for (final (int index, InsightChartBar bar) in bars.indexed) + BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: bar.amount, + width: barWidth, + color: index == bars.length - 1 + ? context.colorScheme.primary + : bar.muted + ? muted + : base, + borderRadius: const .vertical( + top: Radius.circular(5.0), + ), + ), + ], + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/insights/insight_icon_badge.dart b/lib/widgets/insights/insight_icon_badge.dart new file mode 100644 index 00000000..7889091f --- /dev/null +++ b/lib/widgets/insights/insight_icon_badge.dart @@ -0,0 +1,25 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/flow_icon.dart"; +import "package:flutter/material.dart"; + +/// A small tinted square holding an insight's icon. +class InsightIconBadge extends StatelessWidget { + final FlowIconData icon; + + const InsightIconBadge({super.key, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + width: 32.0, + height: 32.0, + alignment: Alignment.center, + decoration: BoxDecoration( + color: context.colorScheme.primary.withAlpha(0x29), + borderRadius: .all(Radius.circular(10.0)), + ), + child: FlowIcon(icon, size: 18.0), + ); + } +} diff --git a/lib/widgets/insights/insight_kinds_sheet.dart b/lib/widgets/insights/insight_kinds_sheet.dart new file mode 100644 index 00000000..479dc7d6 --- /dev/null +++ b/lib/widgets/insights/insight_kinds_sheet.dart @@ -0,0 +1,41 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/prefs/insights_preferences.dart"; +import "package:flow/widgets/general/modal_sheet.dart"; +import "package:flutter/material.dart"; + +/// Turns kinds of insights back on after "Don't show". +class InsightKindsSheet extends StatelessWidget { + const InsightKindsSheet({super.key}); + + @override + Widget build(BuildContext context) { + final InsightsLocalPreferences preferences = InsightsLocalPreferences(); + + return ModalSheet.scrollable( + title: Text("tabs.stats.worthKnowing.kinds".t(context)), + child: SingleChildScrollView( + child: ValueListenableBuilder( + valueListenable: preferences.hiddenTypes.valueNotifier, + builder: (context, _, _) { + final Set<InsightType> hidden = preferences.hiddenTypeSet; + + return Column( + mainAxisSize: .min, + children: [ + for (final InsightType type in InsightType.values) + SwitchListTile( + title: Text( + "tabs.stats.worthKnowing.type.${type.name}".t(context), + ), + value: !hidden.contains(type), + onChanged: (show) => preferences.setHidden(type, !show), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/insights/insight_summary.dart b/lib/widgets/insights/insight_summary.dart new file mode 100644 index 00000000..7206c9e1 --- /dev/null +++ b/lib/widgets/insights/insight_summary.dart @@ -0,0 +1,270 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/insights/insight_thresholds.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/providers/categories_provider.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// What a Worth knowing row says about an [Insight]: one sentence with an +/// emphasized figure, and one line of context. +class InsightSummary { + /// Holds a `{value}` token for [value], see `EmphasizedText`. + final String template; + final String value; + final String detail; + + final FlowIconData icon; + + /// Whether spending went up. Null for insights that aren't a change. + final bool? increase; + + /// Past values, oldest first, ending with the current one. + final List<double>? spark; + + const InsightSummary({ + required this.template, + required this.value, + required this.detail, + required this.icon, + this.increase, + this.spark, + }); + + /// A day of month as `{day}` ("25th") and `{dayNumber}` ("25"), for + /// languages without ordinal formatting. + static Map<String, String> dayValues(int day) => { + "day": DateTime(2000, 1, day).toMoment().format("Do"), + "dayNumber": day.toString(), + }; + + /// The sentence without emphasis. + String get sentence => template.replaceAll("{value}", value); + + /// Follows the user's change colors. + Color valueColor(BuildContext context) { + final bool? increase = this.increase; + if (increase == null) return context.colorScheme.primary; + + final bool redWhenUp = + UserPreferencesService().changeVisuals.expenseIncreaseRed; + + return increase == redWhenUp + ? context.flowColors.expense + : context.flowColors.income; + } + + factory InsightSummary.of( + BuildContext context, { + required Insight insight, + required InsightReport report, + required InsightFormatter format, + }) => _InsightSummaryBuilder( + context, + report: report, + format: format, + ).build(insight); +} + +class _InsightSummaryBuilder { + static const String _prefix = "tabs.stats.worthKnowing"; + static const int _maxSparkCharges = 7; + + final BuildContext context; + final InsightReport report; + final InsightFormatter format; + + const _InsightSummaryBuilder( + this.context, { + required this.report, + required this.format, + }); + + bool get _past => !report.isInProgress; + + String get _month => report.month.toMoment().format("MMMM"); + + String _t(String key, [dynamic replace]) => + "$_prefix.$key".t(context, replace); + + Category? _category(String? uuid) => + uuid == null ? null : CategoriesProvider.of(context).get(uuid); + + String _categoryName(String? uuid) => + _category(uuid)?.name ?? "tabs.stats.analytics.uncategorized".t(context); + + FlowIconData _categoryIcon(String? uuid) => + _category(uuid)?.icon ?? FlowIconData.icon(Symbols.category_rounded); + + String _untitled(String? title) => switch (title) { + String title when title.trim().isNotEmpty => title, + _ => "tabs.stats.analytics.untitled".t(context), + }; + + String _versus(double current, double usual) => _t("versusTypical", { + "current": format.money(current), + "usual": format.money(usual), + }); + + List<double> _spark(List<InsightMonthValue> history, double current) => [ + ...history.map((month) => month.amount), + current, + ]; + + InsightSummary build(Insight insight) => switch (insight) { + MonthPaceInsight pace => _monthPace(pace), + CategorySpikeInsight spike => _categorySpike(spike), + CategoryDropInsight drop => _categoryDrop(drop), + NewCategoryInsight newCategory => _newCategory(newCategory), + RecurringChargeInsight recurring => _recurringCharge(recurring), + PriceChangeInsight priceChange => _priceChange(priceChange), + }; + + InsightSummary _monthPace(MonthPaceInsight pace) { + final bool above = pace.direction == .above; + final bool upArrow = + above == UserPreferencesService().changeVisuals.expenseIncreaseUpArrow; + + final String key = switch ((above, _past)) { + (true, false) => "monthAbove", + (false, false) => "monthBelow", + (true, true) => "monthAbovePast", + (false, true) => "monthBelowPast", + }; + + return InsightSummary( + template: _t(key, { + "month": _month, + if (pace.throughDay case int day) ...InsightSummary.dayValues(day), + }), + value: format.percent(pace.ratio - 1.0), + detail: _monthPaceDetail(pace), + icon: FlowIconData.icon( + upArrow ? Symbols.trending_up_rounded : Symbols.trending_down_rounded, + ), + increase: above, + spark: _spark(pace.history, pace.current), + ); + } + + String _monthPaceDetail(MonthPaceInsight pace) { + final InsightAttribution? attribution = pace.attribution; + + if (attribution != null) { + final double ratio = pace.ratioWithoutAttribution!; + final Map<String, String> values = { + "title": switch (attribution.title) { + String title when title.trim().isNotEmpty => title, + _ => _categoryName(attribution.categoryUuid), + }, + "amount": format.money(attribution.amount), + "percent": format.percent(ratio - 1.0), + }; + + return switch (ratio) { + < 0.995 => _t("mostlyUnder", values), + > 1.005 => _t("mostlyOver", values), + _ => _t("mostlyEven", values), + }; + } + + if (pace.dominantDriver case InsightCategoryDelta driver) { + return _t("mostlyCategory", { + "category": _categoryName(driver.categoryUuid), + "difference": _difference(driver.delta), + }); + } + + return _versus(pace.current, pace.usual); + } + + String _difference(double delta) => _t( + delta >= 0.0 ? "moreThanUsual" : "lessThanUsual", + {"amount": format.money(delta)}, + ); + + InsightSummary _categorySpike(CategorySpikeInsight spike) => InsightSummary( + template: _t(_past ? "categorySpikePast" : "categorySpike", { + "category": _categoryName(spike.categoryUuid), + }), + value: format.ratio(spike.ratio), + detail: [ + _versus(spike.current, spike.usual), + _t("entries", spike.entryCount), + ].join(" · "), + icon: _categoryIcon(spike.categoryUuid), + increase: true, + spark: _spark(spike.history, spike.current), + ); + + InsightSummary _categoryDrop(CategoryDropInsight drop) => InsightSummary( + template: _t(_past ? "categoryDropPast" : "categoryDrop", { + "category": _categoryName(drop.categoryUuid), + }), + value: format.percent(1.0 - drop.ratio), + detail: _versus(drop.current, drop.usual), + icon: _categoryIcon(drop.categoryUuid), + increase: false, + spark: _spark(drop.history, drop.current), + ); + + InsightSummary _newCategory(NewCategoryInsight insight) => InsightSummary( + template: _t("newCategory", {"months": InsightThresholds.baselineMonths}), + value: _categoryName(insight.categoryUuid), + detail: [ + format.money(insight.current), + _t("entries", insight.entryCount), + ].join(" · "), + icon: _categoryIcon(insight.categoryUuid), + ); + + InsightSummary _recurringCharge(RecurringChargeInsight insight) { + final RecurringSeries series = insight.series; + + final Map<String, String> values = { + "amount": format.money(series.typicalAmount), + "times": _t("times", series.occurrences.length), + ...InsightSummary.dayValues(series.anchorDay), + "weekday": DateTime(2024, 1, series.anchorDay).toMoment().format("dddd"), + "date": series.occurrences.last.date.toMoment().format("MMMM Do"), + }; + + final String cadence = switch (series.cadence) { + .weekly => "Weekly", + .monthly => "Monthly", + .yearly => "Yearly", + }; + + return InsightSummary( + template: _t("recurring$cadence"), + value: _untitled(series.title), + detail: _t("recurring${cadence}Detail", values), + icon: FlowIconData.icon(Symbols.autorenew_rounded), + ); + } + + InsightSummary _priceChange(PriceChangeInsight insight) => InsightSummary( + template: _t("priceChange", { + "title": _untitled(insight.series.title), + "previous": format.money(insight.previousAmount), + }), + value: format.money(insight.currentAmount), + detail: _t( + insight.currentAmount >= insight.previousAmount ? "priceUp" : "priceDown", + {"amount": format.money(insight.stake)}, + ), + icon: FlowIconData.icon(Symbols.price_change_rounded), + increase: insight.currentAmount >= insight.previousAmount, + spark: insight.series.occurrences.reversed + .take(_maxSparkCharges) + .map((occurrence) => occurrence.amount) + .toList() + .reversed + .toList(), + ); +} diff --git a/lib/widgets/insights/worth_knowing_row.dart b/lib/widgets/insights/worth_knowing_row.dart new file mode 100644 index 00000000..9a71abf5 --- /dev/null +++ b/lib/widgets/insights/worth_knowing_row.dart @@ -0,0 +1,86 @@ +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/insights/insight_icon_badge.dart"; +import "package:flow/widgets/insights/insight_summary.dart"; +import "package:flow/widgets/stats/emphasized_text.dart"; +import "package:flow/widgets/stats/wrapped/mini_bars.dart"; +import "package:flutter/material.dart"; + +/// One insight in the Worth knowing card: a sentence, a line of context, and +/// a tiny history when there is one. +class WorthKnowingRow extends StatelessWidget { + final InsightSummary summary; + final VoidCallback onTap; + + /// Shown under the text, e.g. "Track as recurring". + final Widget? action; + + const WorthKnowingRow({ + super.key, + required this.summary, + required this.onTap, + this.action, + }); + + @override + Widget build(BuildContext context) { + final List<double>? spark = summary.spark; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + crossAxisAlignment: .start, + children: [ + InsightIconBadge(icon: summary.icon), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + DefaultTextStyle.merge( + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + height: 1.35, + ), + child: EmphasizedText( + template: summary.template, + value: summary.value, + valueStyle: TextStyle( + color: summary.valueColor(context), + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 2.0), + Text( + summary.detail, + style: context.textTheme.labelMedium?.semi(context), + ), + if (action != null) ...[const SizedBox(height: 8.0), action!], + ], + ), + ), + if (spark != null && spark.length > 1) ...[ + const SizedBox(width: 12.0), + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: SizedBox( + width: 44.0, + child: ExcludeSemantics( + child: MiniBars( + values: spark, + highlightColor: context.colorScheme.primary, + height: 28.0, + spacing: 2.0, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/insights/worth_knowing_view.dart b/lib/widgets/insights/worth_knowing_view.dart new file mode 100644 index 00000000..62400e5a --- /dev/null +++ b/lib/widgets/insights/worth_knowing_view.dart @@ -0,0 +1,187 @@ +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/theme/theme.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flow/widgets/insights/insight_summary.dart"; +import "package:flow/widgets/insights/worth_knowing_row.dart"; +import "package:flow/widgets/stats/missing_rates_notice.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; + +/// The Worth knowing card for a computed [report]. Renders nothing when the +/// engine has too little history to say anything. +class WorthKnowingView extends StatelessWidget { + /// Null while the first report is on its way. + final InsightReport? report; + + /// Whether [report] is for another month than the one being loaded. + final bool stale; + + final InsightFormatter format; + + final int hiddenTypeCount; + + final ValueChanged<Insight> onOpen; + final ValueChanged<RecurringChargeInsight> onTrack; + final VoidCallback onManageHidden; + + const WorthKnowingView({ + super.key, + required this.report, + required this.format, + required this.onOpen, + required this.onTrack, + required this.onManageHidden, + this.stale = false, + this.hiddenTypeCount = 0, + }); + + static bool isVisible(InsightReport? report) => switch (report?.status) { + null || InsightReportStatus.ready || InsightReportStatus.tooEarly => true, + _ => false, + }; + + @override + Widget build(BuildContext context) { + final InsightReport? report = this.report; + + if (!isVisible(report)) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Surface( + clipBehavior: .antiAlias, + builder: (context) => AnimatedOpacity( + opacity: stale ? 0.5 : 1.0, + duration: const Duration(milliseconds: 150), + child: Column( + crossAxisAlignment: .start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + 16.0, + hiddenTypeCount > 0 ? 4.0 : 16.0, + hiddenTypeCount > 0 ? 4.0 : 16.0, + hiddenTypeCount > 0 ? 0.0 : 4.0, + ), + child: Row( + children: [ + Icon( + Symbols.lightbulb_rounded, + color: context.colorScheme.primary, + size: 18.0, + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + "tabs.stats.worthKnowing".t(context).toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.flowColors.semi, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ), + if (hiddenTypeCount > 0) + IconButton( + onPressed: onManageHidden, + tooltip: "tabs.stats.worthKnowing.kinds".t(context), + visualDensity: VisualDensity.compact, + iconSize: 18.0, + color: context.flowColors.semi, + icon: const Icon(Symbols.visibility_off_rounded), + ), + ], + ), + ), + if (report == null) + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 20.0), + child: Container( + width: 180.0, + height: 12.0, + decoration: BoxDecoration( + color: context.colorScheme.onSurface.withAlpha(0x14), + borderRadius: .all(Radius.circular(6.0)), + ), + ), + ) + else if (report.insights.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), + child: Text( + (report.isInProgress + ? "tabs.stats.worthKnowing.nothingUnusual" + : "tabs.stats.worthKnowing.nothingUnusualPast") + .t(context, { + "month": report.month.toMoment().format("MMMM"), + }), + style: context.textTheme.bodyMedium?.semi(context), + ), + ) + else + for (final (int index, Insight insight) + in report.insights.indexed) ...[ + if (index > 0) + Divider( + height: 1.0, + thickness: 1.0, + indent: 16.0, + endIndent: 16.0, + color: context.colorScheme.onSurface.withAlpha(0x17), + ), + WorthKnowingRow( + summary: InsightSummary.of( + context, + insight: insight, + report: report, + format: format, + ), + onTap: () => onOpen(insight), + action: insight is RecurringChargeInsight + ? ActionChip( + avatar: Icon( + Symbols.add_rounded, + color: context.colorScheme.primary, + ), + label: Text( + "tabs.stats.worthKnowing.track".t(context), + ), + labelStyle: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + side: BorderSide( + color: context.colorScheme.primary.withAlpha( + 0x80, + ), + ), + shape: const StadiumBorder(), + visualDensity: VisualDensity.compact, + onPressed: () => onTrack(insight), + ) + : null, + ), + ], + if (report?.hasMissingRates == true) + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesAmounts".t( + context, + ), + ), + ), + if (report != null && report.insights.isNotEmpty) + const SizedBox(height: 4.0), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/stats/wrapped/mini_bars.dart b/lib/widgets/stats/wrapped/mini_bars.dart index bf1c4dd2..4e6ef53b 100644 --- a/lib/widgets/stats/wrapped/mini_bars.dart +++ b/lib/widgets/stats/wrapped/mini_bars.dart @@ -7,7 +7,18 @@ class MiniBars extends StatelessWidget { final List<double> values; final Color highlightColor; - const MiniBars({super.key, required this.values, required this.highlightColor}); + final double height; + + /// Gap between two bars. + final double spacing; + + const MiniBars({ + super.key, + required this.values, + required this.highlightColor, + this.height = 44.0, + this.spacing = 8.0, + }); @override Widget build(BuildContext context) { @@ -17,7 +28,7 @@ class MiniBars extends StatelessWidget { final Color base = context.colorScheme.onSurface.withAlpha(0x33); return SizedBox( - height: 44.0, + height: height, child: Row( crossAxisAlignment: .end, children: values.asMap().entries.map((entry) { @@ -26,7 +37,7 @@ class MiniBars extends StatelessWidget { return Expanded( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), + padding: EdgeInsets.symmetric(horizontal: spacing / 2), child: Align( alignment: Alignment.bottomCenter, child: FractionallySizedBox( diff --git a/test/unit/insights/insight_formatter_test.dart b/test/unit/insights/insight_formatter_test.dart new file mode 100644 index 00000000..6f019b9c --- /dev/null +++ b/test/unit/insights/insight_formatter_test.dart @@ -0,0 +1,41 @@ +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + const InsightFormatter format = InsightFormatter(currency: "USD"); + + test("small amounts keep their cents", () { + expect(format.money(11.99), "\$11.99"); + }); + + test("amounts over 100 drop the cents", () { + expect(format.money(1905.4), "\$1,905"); + }); + + test("big amounts are abbreviated unless full amounts are preferred", () { + expect(format.money(12400.0), "\$12.4K"); + expect( + const InsightFormatter( + currency: "USD", + preferFullAmounts: true, + ).money(12400.0), + "\$12,400", + ); + }); + + test("amounts are always positive", () { + expect(format.money(-226.0), "\$226"); + }); + + test("privacy mode hides the digits", () { + expect( + const InsightFormatter(currency: "USD", obscure: true).money(486.0), + "\$***", + ); + }); + + test("percents and ratios", () { + expect(format.percent(-0.274), "27%"); + expect(format.ratio(1.87), "1.9×"); + }); +} diff --git a/test/widget/insight_history_chart_test.dart b/test/widget/insight_history_chart_test.dart new file mode 100644 index 00000000..adcea4f5 --- /dev/null +++ b/test/widget/insight_history_chart_test.dart @@ -0,0 +1,62 @@ +import "package:fl_chart/fl_chart.dart"; +import "package:flow/theme/flow_custom_colors.dart"; +import "package:flow/widgets/insights/insight_history_chart.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + Future<void> pump(WidgetTester tester, List<InsightChartBar> bars) => + tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: const [ + FlowCustomColors( + income: Color(0xFF32CC70), + expense: Color(0xFFC42525), + semi: Color(0xFF888888), + ), + ], + ), + home: Scaffold( + body: SizedBox( + width: 320.0, + child: InsightHistoryChart( + bars: bars, + reference: 100.0, + referenceLabel: "usual \$100", + formatAmount: (amount) => "\$${amount.round()}", + ), + ), + ), + ), + ); + + testWidgets("labels every bar and highlights the last one", (tester) async { + await pump(tester, [ + (label: "Jul", amount: 90.0, muted: false), + (label: "Aug", amount: 10.0, muted: true), + (label: "Sep", amount: 180.0, muted: false), + ]); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text("Jul"), findsOneWidget); + expect(find.text("Sep"), findsOneWidget); + + final BarChart chart = tester.widget(find.byType(BarChart)); + final List<Color> colors = chart.data.barGroups + .map((group) => group.barRods.single.color!) + .toList(); + + expect(colors.last, isNot(colors.first)); + expect(colors[1], isNot(colors.first)); + expect(chart.data.maxY, greaterThan(180.0)); + }); + + testWidgets("a single empty bar still renders", (tester) async { + await pump(tester, [(label: "Sep", amount: 0.0, muted: false)]); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widget/worth_knowing_view_test.dart b/test/widget/worth_knowing_view_test.dart new file mode 100644 index 00000000..d6a67e46 --- /dev/null +++ b/test/widget/worth_knowing_view_test.dart @@ -0,0 +1,376 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/providers/categories_provider.dart"; +import "package:flow/theme/flow_custom_colors.dart"; +import "package:flow/widgets/general/surface.dart"; +import "package:flow/widgets/insights/insight_formatter.dart"; +import "package:flow/widgets/insights/worth_knowing_view.dart"; +import "package:flutter/foundation.dart" hide Category; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_symbols_icons_flow/symbols.dart"; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await FlowLocalizations(const Locale("en")).load(); + }); + + const Color expense = Color(0xFFC42525); + const Color income = Color(0xFF32CC70); + + const InsightFormatter usd = InsightFormatter(currency: "USD"); + + final Category dining = Category.preset( + name: "Dining out", + iconCode: FlowIconData.icon(Symbols.restaurant_rounded).toString(), + uuid: "dining", + ); + + final DateTime september = DateTime(2026, 9); + + InsightReport report( + List<Insight> insights, { + InsightReportStatus status = .ready, + int? throughDay = 25, + }) => InsightReport( + month: september, + status: status, + insights: insights, + usualMonthTotal: 1000.0, + throughDay: throughDay, + ); + + MonthPaceInsight pace({ + InsightDirection direction = .above, + double current = 1270.0, + InsightAttribution? attribution, + List<InsightCategoryDelta> drivers = const [], + int? throughDay = 25, + }) => MonthPaceInsight( + score: 0.27, + direction: direction, + current: current, + usual: 1000.0, + throughDay: throughDay, + history: [ + for (int month = 3; month <= 8; month++) + InsightMonthValue( + month: DateTime(2026, month), + amount: 1000.0, + counted: true, + ), + ], + attribution: attribution, + drivers: drivers, + transactionUuids: const ["a"], + ); + + final RecurringChargeInsight spotify = RecurringChargeInsight( + score: 0.2, + series: RecurringSeries( + key: "title:spotify", + title: "Spotify", + cadence: .monthly, + anchorDay: 3, + occurrences: [ + for (int month = 6; month <= 9; month++) + RecurringOccurrence( + transactionUuid: "s$month", + date: DateTime(2026, month, 3), + amount: 11.99, + ), + ], + isFixedPrice: true, + priceChangeIndex: null, + categoryUuid: null, + accountUuid: null, + isTracked: false, + ), + ); + + Future<void> pump( + WidgetTester tester, + InsightReport? report, { + InsightFormatter format = usd, + int hiddenTypeCount = 0, + ValueChanged<Insight>? onOpen, + ValueChanged<RecurringChargeInsight>? onTrack, + VoidCallback? onManageHidden, + }) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [_TestLocalizationsDelegate()], + theme: ThemeData( + extensions: const [ + FlowCustomColors( + income: income, + expense: expense, + semi: Color(0xFF888888), + ), + ], + ), + home: CategoriesProvider( + [dining], + child: Scaffold( + body: SingleChildScrollView( + child: WorthKnowingView( + report: report, + format: format, + hiddenTypeCount: hiddenTypeCount, + onOpen: onOpen ?? (_) {}, + onTrack: onTrack ?? (_) {}, + onManageHidden: onManageHidden ?? () {}, + ), + ), + ), + ), + ), + ); + } + + Finder text(String value) => find.text(value, findRichText: true); + + group("visibility", () { + for (final InsightReportStatus status in [ + InsightReportStatus.notEnoughHistory, + InsightReportStatus.futureMonth, + ]) { + testWidgets("${status.name} renders nothing", (tester) async { + await pump(tester, report(const [], status: status)); + + expect(find.byType(Surface), findsNothing); + }); + } + + testWidgets("loading keeps the header, without rows", (tester) async { + await pump(tester, null); + + expect(find.text("WORTH KNOWING"), findsOneWidget); + expect(find.byType(InkWell), findsNothing); + }); + + testWidgets("hidden kinds can be managed from the header", (tester) async { + int taps = 0; + await pump( + tester, + report(const []), + hiddenTypeCount: 2, + onManageHidden: () => taps++, + ); + + await tester.tap(find.byIcon(Symbols.visibility_off_rounded)); + expect(taps, 1); + }); + + testWidgets("the restore button only shows when something is hidden", ( + tester, + ) async { + await pump(tester, report(const [])); + + expect(find.byIcon(Symbols.visibility_off_rounded), findsNothing); + }); + }); + + group("empty state", () { + testWidgets("an in-progress month says so far", (tester) async { + await pump(tester, report(const [])); + + expect(text("Nothing unusual in September so far."), findsOneWidget); + }); + + testWidgets("a past month doesn't", (tester) async { + await pump(tester, report(const [], throughDay: null)); + + expect(text("Nothing unusual in September."), findsOneWidget); + }); + + testWidgets("too early still shows the calm line", (tester) async { + await pump(tester, report(const [], status: .tooEarly)); + + expect(text("Nothing unusual in September so far."), findsOneWidget); + }); + }); + + group("sentences", () { + testWidgets("a month above names the purchase behind it", (tester) async { + await pump( + tester, + report([ + pace( + attribution: InsightAttribution( + transactionUuid: "a", + title: "Flight to Tokyo", + categoryUuid: null, + date: DateTime(2026, 9, 12), + amount: 330.0, + ), + ), + ]), + ); + + expect( + text("September is 27% above your usual by the 25th."), + findsOneWidget, + ); + expect( + text("Mostly Flight to Tokyo (\$330). Without it, 6% under."), + findsOneWidget, + ); + }); + + testWidgets("a closed month below falls back to a category", ( + tester, + ) async { + await pump( + tester, + report([ + pace( + direction: .below, + current: 700.0, + throughDay: null, + drivers: const [ + InsightCategoryDelta( + categoryUuid: "dining", + current: 50.0, + usual: 300.0, + ), + ], + ), + ], throughDay: null), + ); + + expect(text("September was 30% below your usual."), findsOneWidget); + expect(text("Mostly Dining out, \$250 less than usual."), findsOneWidget); + }); + + testWidgets("a category spike counts its entries", (tester) async { + await pump( + tester, + report([ + CategorySpikeInsight( + score: 0.2, + categoryUuid: "dining", + current: 486.0, + usual: 260.0, + entryCount: 11, + history: const [], + attribution: null, + transactionUuids: const [], + ), + const NewCategoryInsight( + score: 0.1, + categoryUuid: "gone", + current: 140.0, + entryCount: 1, + transactionUuids: [], + ), + ]), + ); + + expect(text("Dining out is 1.9× your usual."), findsOneWidget); + expect(text("\$486 vs \$260 typical · 11 entries"), findsOneWidget); + expect(text("First Uncategorized spending in 6 months."), findsOneWidget); + expect(text("\$140 · 1 entry"), findsOneWidget); + }); + + testWidgets("subscriptions keep their cents", (tester) async { + await pump(tester, report([spotify])); + + expect(text("Spotify looks like a monthly charge."), findsOneWidget); + expect(text("\$11.99 around the 3rd · 4 times so far"), findsOneWidget); + }); + + testWidgets("privacy mode obscures amounts", (tester) async { + await pump( + tester, + report([spotify]), + format: const InsightFormatter(currency: "USD", obscure: true), + ); + + expect(text("\$**.** around the 3rd · 4 times so far"), findsOneWidget); + }); + }); + + group("interaction", () { + testWidgets("tapping a row opens it", (tester) async { + final List<Insight> opened = []; + final MonthPaceInsight insight = pace(); + await pump(tester, report([insight]), onOpen: opened.add); + + await tester.tap(text("September is 27% above your usual by the 25th.")); + expect(opened, [insight]); + }); + + testWidgets("track as recurring doesn't open the row", (tester) async { + final List<Insight> opened = []; + final List<RecurringChargeInsight> tracked = []; + await pump( + tester, + report([spotify]), + onOpen: opened.add, + onTrack: tracked.add, + ); + + await tester.tap(find.text("Track as recurring")); + expect(tracked, [spotify]); + expect(opened, isEmpty); + }); + + testWidgets("rows are capped by the engine, not the view", (tester) async { + await pump(tester, report([pace(), spotify])); + + expect(find.byType(Divider), findsOneWidget); + }); + }); + + group("change colors", () { + Color? valueColor(WidgetTester tester, String value) { + final RichText rich = tester + .widgetList<RichText>(find.byType(RichText)) + .firstWhere((widget) => widget.text.toPlainText().contains(value)); + TextStyle? found; + rich.text.visitChildren((span) { + if (span is TextSpan && span.text == value) found = span.style; + return found == null; + }); + return found?.color; + } + + testWidgets("spending more uses the expense color by default", ( + tester, + ) async { + await pump(tester, report([pace()])); + + expect(valueColor(tester, "27%"), expense); + }); + + testWidgets("spending less uses the income color by default", ( + tester, + ) async { + await pump(tester, report([pace(direction: .below, current: 700.0)])); + + expect(valueColor(tester, "30%"), income); + }); + }); +} + +/// [FlowLocalizations.delegate] also syncs home widgets over a platform +/// channel, which isn't there in widget tests. +class _TestLocalizationsDelegate + extends LocalizationsDelegate<FlowLocalizations> { + const _TestLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future<FlowLocalizations> load(Locale locale) => + SynchronousFuture(FlowLocalizations(locale)); + + @override + bool shouldReload(_TestLocalizationsDelegate old) => false; +} From ba83b3b1ed0ff9011336a1470dbd4f2d5452e83f Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:01:54 +0800 Subject: [PATCH 28/57] insights: only suggest recurring for the current month --- lib/data/insights/insight_engine.dart | 3 +- test/unit/insights/insight_engine_test.dart | 137 +++++++++++++++++--- test/unit/insights/insight_test_data.dart | 56 ++++++++ 3 files changed, 175 insertions(+), 21 deletions(-) diff --git a/lib/data/insights/insight_engine.dart b/lib/data/insights/insight_engine.dart index 48e651d9..4016871e 100644 --- a/lib/data/insights/insight_engine.dart +++ b/lib/data/insights/insight_engine.dart @@ -113,7 +113,8 @@ class InsightEngine { ..._categoryDrops(), ..._newCategories(), ], - ..._recurringCharges(), + // Suggesting to track a charge only helps while the month is current. + if (_inProgress) ..._recurringCharges(), ..._priceChanges(), ]; diff --git a/test/unit/insights/insight_engine_test.dart b/test/unit/insights/insight_engine_test.dart index a9d7c084..98dfd803 100644 --- a/test/unit/insights/insight_engine_test.dart +++ b/test/unit/insights/insight_engine_test.dart @@ -11,6 +11,9 @@ final DateTime august = DateTime(2026, 8); final DateTime september = DateTime(2026, 9); final DateTime lateSeptember = DateTime(2026, 9, 25, 18); +/// For suggestions, which only show while their month is in progress. +final DateTime lateAugust = DateTime(2026, 8, 25, 18); + /// Feb..Jul 2026, the baseline of August. const List<int> augustBaseline = [2, 3, 4, 5, 6, 7]; @@ -31,18 +34,22 @@ void main() { } ledger.steadyMonth(2026, 9, random, throughDay: 25); - for (final DateTime month in [august, september]) { - final InsightReport report = ledger.analyze(month, now: lateSeptember); + final InsightReport closed = ledger.analyze(august, now: lateSeptember); + expect(closed.status, InsightReportStatus.ready); + expect(closed.insights, isEmpty); - expect(report.status, InsightReportStatus.ready); - expect(report.insights, hasLength(1)); + final InsightReport report = ledger.analyze( + september, + now: lateSeptember, + ); + expect(report.status, InsightReportStatus.ready); + expect(report.insights, hasLength(1)); - final RecurringChargeInsight insight = - report.insights.single as RecurringChargeInsight; - expect(insight.series.title, "Spotify"); - expect(insight.series.anchorDay, 3); - expect(insight.series.cadence, RecurringCadence.monthly); - } + final RecurringChargeInsight insight = + report.insights.single as RecurringChargeInsight; + expect(insight.series.title, "Spotify"); + expect(insight.series.anchorDay, 3); + expect(insight.series.cadence, RecurringCadence.monthly); }); test("a traveler's flight is named, and the month is fine without it", () { @@ -173,7 +180,7 @@ void main() { ); } - final InsightReport report = ledger.analyze(august, now: lateSeptember); + final InsightReport report = ledger.analyze(august, now: lateAugust); expect(only<PriceChangeInsight>(report), isEmpty); expect( @@ -764,7 +771,7 @@ void main() { final InsightReport repeated = build( paidLastYear: true, - ).analyze(august, now: lateSeptember); + ).analyze(august, now: lateAugust); expect(only<MonthPaceInsight>(repeated), isEmpty); expect(only<NewCategoryInsight>(repeated), isEmpty); @@ -774,7 +781,7 @@ void main() { ); expect( only<MonthPaceInsight>( - build(paidLastYear: false).analyze(august, now: lateSeptember), + build(paidLastYear: false).analyze(august, now: lateAugust), ), hasLength(1), ); @@ -971,7 +978,7 @@ void main() { final TestLedger ledger = withCharge("Netflix", List.filled(6, 15.49)); final RecurringChargeInsight insight = only<RecurringChargeInsight>( - ledger.analyze(august, now: lateSeptember), + ledger.analyze(august, now: lateAugust), ).single; expect(insight.key, "recurringCharge:title:netflix"); @@ -1001,12 +1008,12 @@ void main() { ); final TestLedger manual = withCharge("Netflix", List.filled(6, 15.49)); - expect(generated.analyze(august, now: lateSeptember).insights, isEmpty); + expect(generated.analyze(august, now: lateAugust).insights, isEmpty); expect( manual .analyze( august, - now: lateSeptember, + now: lateAugust, templates: const [ InsightRecurringTemplate(title: "Netflix", amount: 15.49), ], @@ -1019,7 +1026,7 @@ void main() { test("cheap charges under the floor aren't suggested", () { final TestLedger ledger = withCharge("App", List.filled(6, 2.99)); - expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + expect(ledger.analyze(august, now: lateAugust).insights, isEmpty); }); test("a variable bill isn't suggested", () { @@ -1032,7 +1039,7 @@ void main() { 88.0, ]); - expect(ledger.analyze(august, now: lateSeptember).insights, isEmpty); + expect(ledger.analyze(august, now: lateAugust).insights, isEmpty); }); test("only the biggest suggestion per month", () { @@ -1043,7 +1050,7 @@ void main() { final List<RecurringChargeInsight> suggestions = only<RecurringChargeInsight>( - ledger.analyze(august, now: lateSeptember, limit: 10), + ledger.analyze(august, now: lateAugust, limit: 10), ); expect(suggestions.single.series.title, "Netflix"); @@ -1125,7 +1132,7 @@ void main() { ledger.spend(DateTime(2025, 8, 20), 139.0, title: "Prime"); ledger.spend(DateTime(2026, 8, 20), 139.0, title: "Prime"); - final InsightReport report = ledger.analyze(august, now: lateSeptember); + final InsightReport report = ledger.analyze(august, now: lateAugust); final RecurringChargeInsight insight = report.insights.single as RecurringChargeInsight; @@ -1335,4 +1342,94 @@ void main() { }); }); }); + + group("demo-like ledger", () { + /// Jul 2024 through [now], rent paid at a different hour each month + /// unless [rentHour] is given. Rent goes from 1,590 to 1,690 in + /// [raisedIn], if any. + TestLedger demo(DateTime now, {int? rentHour, DateTime? raisedIn}) { + final math.Random random = math.Random(11); + final TestLedger ledger = TestLedger(); + const List<int> hours = [7, 12, 19, 21, 23]; + + for (int i = 0; ; i++) { + final DateTime month = DateTime(2024, 7 + i); + if (month.isAfter(now)) break; + + final bool current = month.year == now.year && month.month == now.month; + + ledger.demoMonth( + month.year, + month.month, + random, + rentHour: rentHour ?? hours[i % hours.length], + rent: raisedIn == null || !month.isBefore(raisedIn) ? 1690.0 : 1590.0, + throughDay: current ? now.day : 31, + ); + } + + return ledger; + } + + test("suggestions only show while their month is in progress", () { + final TestLedger ledger = demo(lateSeptember); + + for (int month = 10; month <= 20; month++) { + expect( + only<RecurringChargeInsight>( + ledger.analyze(DateTime(2025, month), now: lateSeptember), + ), + isEmpty, + reason: "${DateTime(2025, month)}", + ); + } + + expect( + only<RecurringChargeInsight>( + ledger.analyze(september, now: lateSeptember), + ).single.series.title, + "Rent", + ); + }); + + test("this month's charge is in its series, whatever time it was paid", () { + for (final int hour in [0, 7, 19, 23]) { + for (final DateTime now in [ + lateSeptember, + DateTime(2026, 9, 1, 23, 59, 59), + ]) { + final TestLedger ledger = demo(now, rentHour: hour); + final InsightTransaction rent = ledger.transactions.lastWhere( + (transaction) => transaction.title == "Rent", + ); + + final RecurringChargeInsight insight = only<RecurringChargeInsight>( + ledger.analyze(september, now: now), + ).single; + + expect(rent.date, DateTime(2026, 9, 1, hour, 59), reason: "$now"); + expect(insight.series.occurrences, hasLength(27), reason: "$now"); + expect( + insight.series.occurrences.last.transactionUuid, + rent.uuid, + reason: "$hour:59, $now", + ); + expect(insight.transactionUuids.first, rent.uuid); + } + } + }); + + test("a closed month's series ends with that month", () { + final TestLedger ledger = demo(lateSeptember, raisedIn: august); + + final PriceChangeInsight change = only<PriceChangeInsight>( + ledger.analyze(august, now: lateSeptember), + ).single; + + expect(change.previousAmount, 1590.0); + expect(change.currentAmount, 1690.0); + expect(change.series.occurrences, hasLength(26)); + expect(change.changedOn.month, 8); + }); + }); } diff --git a/test/unit/insights/insight_test_data.dart b/test/unit/insights/insight_test_data.dart index 4df62816..d02dc48f 100644 --- a/test/unit/insights/insight_test_data.dart +++ b/test/unit/insights/insight_test_data.dart @@ -127,6 +127,62 @@ class TestLedger { } } + /// A month like the app's demo data: untracked rent of 1,690 on the 1st + /// at [rentHour]:59, subscriptions, bills and daily spend, with seeded + /// noise. + void demoMonth( + int year, + int month, + math.Random random, { + int rentHour = 21, + double rent = 1690.0, + int throughDay = 31, + }) { + final int days = DateTime(year, month + 1, 0).day; + + void add(DateTime date, double amount, String title, String category) { + if (date.day > throughDay) return; + + spend(date, amount, title: title, category: category); + } + + DateTime at(int day) => + DateTime(year, month, day, 7 + random.nextInt(15), random.nextInt(60)); + + add(DateTime(year, month, 1, rentHour, 59), rent, "Rent", "rent"); + add(at(1), 39.0, "Gym membership", "fitness"); + add(at(2), 2.99, "iCloud+", "subscriptions"); + add(at(5), 17.99, "Netflix", "subscriptions"); + add(at(6), 59.0, "Internet", "utils"); + add(at(9), 11.99, "Spotify", "subscriptions"); + add(at(12), 20.0, "ChatGPT Plus", "subscriptions"); + add(at(18), 42.0 + random.nextDouble() * 10, "Phone bill", "utils"); + add(at(20), 45.0 + random.nextDouble() * 30, "Electricity", "utils"); + add(at(22), 22.0 + random.nextDouble() * 16, "Water", "utils"); + + const List<String> diners = ["Lunch", "Dinner", "Pizza", "Sushi", "Ramen"]; + + for (int day = 1; day <= days; day++) { + if (random.nextDouble() < 0.55) { + add(at(day), 3.25 + random.nextDouble() * 3.5, "Latte", "drinks"); + } + if (random.nextDouble() < 0.25) { + add(at(day), 16.0 + random.nextDouble() * 80, "Groceries", "food"); + } + if (random.nextDouble() < 0.3) { + add( + at(day), + 10.0 + random.nextDouble() * 46, + diners[random.nextInt(diners.length)], + "dining", + ); + } + if (random.nextDouble() < 0.4) { + add(at(day), 2.5 + random.nextDouble() * 13.5, "Subway", "transport"); + } + } + } + InsightReport analyze( DateTime month, { required DateTime now, From 7955fa42ac5385eda39127a0a39c4c8682064000 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:02:06 +0800 Subject: [PATCH 29/57] insights: never pin an unusual month on a regular charge --- lib/data/insights/insight_engine.dart | 11 +++++-- test/unit/insights/insight_engine_test.dart | 35 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/data/insights/insight_engine.dart b/lib/data/insights/insight_engine.dart index 4016871e..e75e5bb6 100644 --- a/lib/data/insights/insight_engine.dart +++ b/lib/data/insights/insight_engine.dart @@ -32,6 +32,11 @@ class _Expense { String get uuid => source.uuid; String? get categoryUuid => source.categoryUuid; + + /// A fixed monthly charge or a yearly one seen before. Usual by + /// definition, so it never explains an unusual month. + bool get isRoutine => + (monthlySeries?.isFixedPrice ?? false) || isYearlyRepeat; } /// Local, deterministic spending insights for one month. @@ -314,9 +319,11 @@ class InsightEngine { .map((expense) => expense.uuid) .toList(); - /// The biggest expense, when it covers most of [delta]. + /// The biggest unusual expense, when it covers most of [delta]. InsightAttribution? _attribute(Iterable<_Expense> expenses, double delta) { - final _Expense? biggest = _biggestFirst(expenses).firstOrNull; + final _Expense? biggest = _biggestFirst( + expenses.where((expense) => !expense.isRoutine), + ).firstOrNull; if (biggest == null || biggest.amount < delta * InsightThresholds.attributionShare) { diff --git a/test/unit/insights/insight_engine_test.dart b/test/unit/insights/insight_engine_test.dart index 98dfd803..ec636880 100644 --- a/test/unit/insights/insight_engine_test.dart +++ b/test/unit/insights/insight_engine_test.dart @@ -1371,6 +1371,14 @@ void main() { return ledger; } + void trip(TestLedger ledger, int month) { + ledger.spend(DateTime(2026, month, 10, 8), 320.0, title: "Flight"); + for (int day = 10; day < 14; day++) { + ledger.spend(DateTime(2026, month, day, 22), 210.0, title: "Hotel"); + } + ledger.spend(DateTime(2026, month, 14, 18), 320.0, title: "Flight"); + } + test("suggestions only show while their month is in progress", () { final TestLedger ledger = demo(lateSeptember); @@ -1431,5 +1439,32 @@ void main() { expect(change.series.occurrences, hasLength(26)); expect(change.changedOn.month, 8); }); + + test("rent never explains an unusual month", () { + for (final DateTime month in [DateTime(2026, 7), september]) { + final TestLedger ledger = demo(lateSeptember); + trip(ledger, month.month); + + final MonthPaceInsight pace = only<MonthPaceInsight>( + ledger.analyze(month, now: lateSeptember), + ).single; + + expect(pace.direction, InsightDirection.above, reason: "$month"); + expect(pace.attribution, isNull, reason: "$month"); + expect(pace.dominantDriver?.categoryUuid, isNot("rent")); + } + }); + + test("a big one-off purchase still explains it", () { + final TestLedger ledger = demo(lateSeptember); + ledger.spend(DateTime(2026, 7, 12, 20), 1500.0, title: "Laptop"); + + expect( + only<MonthPaceInsight>( + ledger.analyze(DateTime(2026, 7), now: lateSeptember), + ).single.attribution?.title, + "Laptop", + ); + }); }); } From 40e198128e36124a7d7f9187421ac1833f18c7d5 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:10:35 +0800 Subject: [PATCH 30/57] insights: compare a price change with the price right before it --- lib/data/insights/insight_engine.dart | 2 +- lib/data/insights/insight_thresholds.dart | 3 + lib/data/insights/recurring_series.dart | 94 +++++++++++++++---- test/unit/insights/insight_engine_test.dart | 33 +++++++ test/unit/insights/recurring_series_test.dart | 53 +++++++++++ 5 files changed, 166 insertions(+), 19 deletions(-) diff --git a/lib/data/insights/insight_engine.dart b/lib/data/insights/insight_engine.dart index e75e5bb6..5f6dc352 100644 --- a/lib/data/insights/insight_engine.dart +++ b/lib/data/insights/insight_engine.dart @@ -605,7 +605,7 @@ class InsightEngine { bool _changedPriceThisMonth(RecurringSeries series) => series.isFixedPrice && series.priceChangeIndex == series.occurrences.length - 1 && - series.priceChangeIndex! >= 2 && + series.priceChangeIndex! - (series.previousPriceIndex ?? 0) >= 2 && _chargedThisMonth(series); List<RecurringChargeInsight> _recurringCharges() => [ diff --git a/lib/data/insights/insight_thresholds.dart b/lib/data/insights/insight_thresholds.dart index 8739a993..872699b9 100644 --- a/lib/data/insights/insight_thresholds.dart +++ b/lib/data/insights/insight_thresholds.dart @@ -84,6 +84,9 @@ abstract final class InsightThresholds { /// The step must also beat this many times the charges' own wobble. static const double priceChangeNoiseMultiple = 3.0; + /// Charges wobbling more than this are bills without a price to change. + static const double priceChangeMaxNoise = 0.05; + /// Bigger jumps are more likely a different charge than a price change. static const double priceChangeMaxRatio = 0.5; diff --git a/lib/data/insights/recurring_series.dart b/lib/data/insights/recurring_series.dart index 9e185d9c..828703bc 100644 --- a/lib/data/insights/recurring_series.dart +++ b/lib/data/insights/recurring_series.dart @@ -53,6 +53,10 @@ class RecurringSeries { /// Index in [occurrences] where the current price starts, if it changed. final int? priceChangeIndex; + /// Index in [occurrences] where the price before [priceChangeIndex] + /// starts, if that changed too. + final int? previousPriceIndex; + final String? categoryUuid; final String? accountUuid; @@ -67,6 +71,7 @@ class RecurringSeries { required this.occurrences, required this.isFixedPrice, required this.priceChangeIndex, + this.previousPriceIndex, required this.categoryUuid, required this.accountUuid, required this.isTracked, @@ -80,7 +85,11 @@ class RecurringSeries { /// Median amount before the price change. double? get previousAmount => priceChangeIndex == null ? null - : median(occurrences.take(priceChangeIndex!).map((item) => item.amount)); + : median( + occurrences + .sublist(previousPriceIndex ?? 0, priceChangeIndex) + .map((item) => item.amount), + ); double get annualCost => typicalAmount * cadence.perYear; @@ -184,10 +193,8 @@ RecurringSeries? _detect( final List<double> amounts = items.map((item) => item.amount.abs()).toList(); - final ({bool isFixedPrice, int? priceChangeIndex})? price = _price( - amounts, - rhythm.cadence, - ); + final ({bool isFixedPrice, int? priceChangeIndex, int? previousPriceIndex})? + price = _price(amounts, rhythm.cadence); if (price == null) return null; @@ -213,6 +220,7 @@ RecurringSeries? _detect( occurrences: occurrences, isFixedPrice: price.isFixedPrice, priceChangeIndex: price.priceChangeIndex, + previousPriceIndex: price.previousPriceIndex, categoryUuid: _mostCommon(items.map((item) => item.categoryUuid)), accountUuid: _mostCommon(items.map((item) => item.accountUuid)), isTracked: @@ -327,44 +335,94 @@ RecurringSeries? _detect( return (cadence: .yearly, anchorDay: items.last.date.day); } -/// A fixed price, a fixed price with one change, or (monthly only) a -/// variable bill. Null when amounts are all over the place. -({bool isFixedPrice, int? priceChangeIndex})? _price( +/// A fixed price, a fixed price that changed, or (monthly only) a variable +/// bill. Null when amounts are all over the place. +({bool isFixedPrice, int? priceChangeIndex, int? previousPriceIndex})? _price( List<double> amounts, RecurringCadence cadence, ) { // Two yearly charges can't tell a price change from two different things. if (amounts.length >= InsightThresholds.recurringMinOccurrences) { - for (int split = amounts.length - 1; split >= 1; split--) { - if (_isPriceChange(amounts.sublist(0, split), amounts.sublist(split))) { - return (isFixedPrice: true, priceChangeIndex: split); - } + final List<int?> changes = _latestPriceChanges(amounts); + final int? change = changes.last; + + if (change != null) { + return ( + isFixedPrice: true, + priceChangeIndex: change, + previousPriceIndex: changes[change], + ); } } final double variation = coefficientOfVariation(amounts); if (variation <= InsightThresholds.recurringMaxVariation) { - return (isFixedPrice: true, priceChangeIndex: null); + return ( + isFixedPrice: true, + priceChangeIndex: null, + previousPriceIndex: null, + ); } if (cadence == .monthly && variation <= InsightThresholds.variableChargeMaxVariation) { - return (isFixedPrice: false, priceChangeIndex: null); + return ( + isFixedPrice: false, + priceChangeIndex: null, + previousPriceIndex: null, + ); } return null; } +/// Where the latest price change starts in the first `n` [amounts], for +/// every `n`. Each change is measured against the price right before it, +/// so a yearly raise still shows after an earlier one. +List<int?> _latestPriceChanges(List<double> amounts) { + final List<int?> changes = List.filled(amounts.length + 1, null); + + for (int end = 3; end <= amounts.length; end++) { + final double wobble = _wobble(amounts.sublist(0, end)); + + for (int split = end - 1; split >= 2; split--) { + final int start = changes[split] ?? 0; + + // A price needs two charges, so a one-off discount isn't one. + if (split - start < 2) continue; + + if (_isPriceChange( + amounts.sublist(start, split), + amounts.sublist(split, end), + wobble, + )) { + changes[end] = split; + break; + } + } + } + + return changes; +} + +/// Typical move from one charge to the next. Unlike the spread, a price +/// change barely moves it. +double _wobble(List<double> amounts) => median([ + for (int i = 1; i < amounts.length; i++) + if (amounts[i - 1] > 0.0) + (amounts[i] - amounts[i - 1]).abs() / amounts[i - 1], +]); + /// Both sides steady, and the step clearly bigger than their wobble (a /// charge converted from another currency moves a little every month). -bool _isPriceChange(List<double> before, List<double> after) { +bool _isPriceChange(List<double> before, List<double> after, double wobble) { final double noise = math.max( - coefficientOfVariation(before), - coefficientOfVariation(after), + wobble, + math.max(coefficientOfVariation(before), coefficientOfVariation(after)), ); - if (noise > InsightThresholds.recurringMaxVariation) return false; + if (noise > InsightThresholds.priceChangeMaxNoise) return false; final double previous = median(before); final double change = (median(after) - previous).abs(); diff --git a/test/unit/insights/insight_engine_test.dart b/test/unit/insights/insight_engine_test.dart index ec636880..9cb558e3 100644 --- a/test/unit/insights/insight_engine_test.dart +++ b/test/unit/insights/insight_engine_test.dart @@ -1440,6 +1440,39 @@ void main() { expect(change.changedOn.month, 8); }); + test("a yearly rent raise shows, even after an earlier one", () { + final TestLedger ledger = TestLedger(); + final math.Random random = math.Random(5); + + for (int i = 0; i < 27; i++) { + final DateTime month = DateTime(2024, 7 + i); + ledger.demoMonth( + month.year, + month.month, + random, + rent: i < 3 + ? 1480.0 + : i < 15 + ? 1590.0 + : 1690.0, + throughDay: i == 26 ? 25 : 31, + ); + } + + final PriceChangeInsight change = only<PriceChangeInsight>( + ledger.analyze(DateTime(2025, 10), now: lateSeptember), + ).single; + expect(change.previousAmount, 1590.0); + expect(change.currentAmount, 1690.0); + + expect( + only<RecurringChargeInsight>( + ledger.analyze(september, now: lateSeptember), + ).single.series.typicalAmount, + 1690.0, + ); + }); + test("rent never explains an unusual month", () { for (final DateTime month in [DateTime(2026, 7), september]) { final TestLedger ledger = demo(lateSeptember); diff --git a/test/unit/insights/recurring_series_test.dart b/test/unit/insights/recurring_series_test.dart index 3096056d..58b9b012 100644 --- a/test/unit/insights/recurring_series_test.dart +++ b/test/unit/insights/recurring_series_test.dart @@ -198,6 +198,59 @@ void main() { expect(found.single.typicalAmount, 17.99); }); + test("a second price change compares with the price right before", () { + final List<RecurringSeries> found = detect( + charges( + monthly(1, count: 25), + title: "Rent", + amounts: [ + ...List.filled(12, 1480.0), + ...List.filled(12, 1590.0), + 1690.0, + ], + ), + ); + + expect(found.single.priceChangeIndex, 24); + expect(found.single.previousAmount, 1590.0); + expect(found.single.typicalAmount, 1690.0); + }); + + test("a bill that wobbles has no price to change", () { + final List<RecurringSeries> found = detect( + charges( + monthly(22, count: 27), + title: "Water", + amounts: [ + ...[30.63, 34.27, 33.31, 28.06, 36.19, 27.73, 31.47, 31.39], + ...[35.95, 37.63, 31.4, 29.23, 36.51, 29.13, 26.52, 28.64], + ...[29.96, 27.48, 27.32, 32.55, 32.04, 33.84, 32.46, 32.07], + ...[33.06, 32.38, 22.12], + ], + ), + ); + + expect(found.single.priceChangeIndex, isNull); + }); + + test("after two price changes, the latest price is typical", () { + final List<RecurringSeries> found = detect( + charges( + monthly(1, count: 26), + title: "Rent", + amounts: [ + ...List.filled(12, 1480.0), + ...List.filled(12, 1590.0), + 1690.0, + 1690.0, + ], + ), + ); + + expect(found.single.priceChangeIndex, 24); + expect(found.single.typicalAmount, 1690.0); + }); + test("small currency wobble is still one fixed price", () { final List<RecurringSeries> found = detect( charges( From 4e61ab0208ea129c42079d4274849c97b28ffca4 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:14:42 +0800 Subject: [PATCH 31/57] demo data: step by calendar day across daylight saving --- lib/data/setup/demo_data.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/data/setup/demo_data.dart b/lib/data/setup/demo_data.dart index 0e0d6cc1..4039b0af 100644 --- a/lib/data/setup/demo_data.dart +++ b/lib/data/setup/demo_data.dart @@ -99,7 +99,7 @@ class DemoDataGenerator { DateTime cursor = _start; while (cursor.isBefore(end)) { _runDay(cursor); - cursor = cursor.add(const Duration(days: 1)); + cursor = DateTime(cursor.year, cursor.month, cursor.day + 1); } return _txns; @@ -683,7 +683,7 @@ class DemoDataGenerator { final int nights = 2 + rng.nextInt(4); for (int i = 0; i < nights; i++) { - final DateTime night = day.add(Duration(days: i)); + final DateTime night = DateTime(day.year, day.month, day.day + i); if (!_within(night)) break; _spend( @@ -708,7 +708,7 @@ class DemoDataGenerator { } // Return flight - final DateTime back = day.add(Duration(days: nights)); + final DateTime back = DateTime(day.year, day.month, day.day + nights); if (_within(back)) { _spend( amount: _money(160, 520), From 648621c1d55c9356b74755c76c41cba36eb03873 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:14:42 +0800 Subject: [PATCH 32/57] insights: check the engine against demo data --- test/insights_demo_data_test.dart | 235 ++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 test/insights_demo_data_test.dart diff --git a/test/insights_demo_data_test.dart b/test/insights_demo_data_test.dart new file mode 100644 index 00000000..bf301ce2 --- /dev/null +++ b/test/insights_demo_data_test.dart @@ -0,0 +1,235 @@ +import "dart:io"; +import "dart:math"; + +import "package:flow/data/insights/insight_adapter.dart"; +import "package:flow/data/insights/insight_engine.dart"; +import "package:flow/data/setup/default_accounts.dart"; +import "package:flow/data/setup/default_categories.dart"; +import "package:flow/data/setup/demo_data.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/services/insights.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path/path.dart" as path; + +import "objectbox_erase.dart"; + +/// Runs the insight engine over the demo data, month by month, the way the +/// Stats tab would. Worth running under a few `TZ`s. +void main() { + late ObjectBox obx; + late List<Category> categories; + late List<Account> accounts; + + final String directory = path.join( + Directory.current.path, + ".objectbox_test_insights", + ); + + final DateTime now = DateTime(2026, 9, 25, 15, 30); + + const List<String> monthlyBills = [ + "Rent", + "Gym membership", + "iCloud+", + "Netflix", + "Internet", + "Spotify", + "ChatGPT Plus", + ]; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + await FlowLocalizations(const Locale("en")).load(); + + obx = await ObjectBox.initialize( + customDirectory: directory, + subdirectory: "insights", + ); + + categories = await obx.box<Category>().putAndGetManyAsync( + getCategoryPresets().map((category) => category..id = 0).toList(), + ); + accounts = await obx.box<Account>().putAndGetManyAsync([ + ...getAccountPresets("USD").map((account) => account..id = 0), + Account.preset( + name: "Credit Card", + currency: "USD", + iconCode: "credit_card", + uuid: "credit-card", + type: AccountType.creditLineValue, + creditLimit: 5000, + excludeFromTotalBalance: true, + )..id = 0, + ]); + }); + + tearDownAll(() async { + await testCleanupObject( + instance: obx, + directory: path.join(directory, "insights"), + ); + }); + + List<Transaction> generate(int seed) { + final [main, cash, savings, creditCard] = accounts; + + return DemoDataGenerator( + main: main, + cash: cash, + savings: savings, + creditCard: creditCard, + categories: categories, + tags: const {}, + random: Random(seed), + now: now, + ).generate(); + } + + /// The last 12 months, each with the history the Stats tab would load. + Map<DateTime, InsightReport> reports(List<Transaction> transactions) { + final List<InsightTransaction> all = insightTransactionsOf( + transactions, + primaryCurrency: "USD", + rates: null, + ).transactions; + + return { + for (int back = 0; back < 12; back++) + DateTime(now.year, now.month - back): computeInsights( + InsightRequest( + transactions: all.where((transaction) { + final DateTime month = DateTime(now.year, now.month - back); + final DateTime from = DateTime( + month.year, + month.month - InsightsService.historyMonths, + ); + + return !transaction.date.isBefore(from) && + transaction.date.isBefore( + DateTime(month.year, month.month + 1), + ); + }).toList(), + month: DateTime(now.year, now.month - back), + now: now, + limit: 9, + ), + ), + }; + } + + test("every monthly bill is charged once a month", () { + final List<Transaction> transactions = generate(1); + + for (int back = 1; back < 36; back++) { + final DateTime month = DateTime(now.year, now.month - back); + + for (final String title in monthlyBills) { + expect( + transactions.where( + (transaction) => + transaction.title == title && + transaction.transactionDate.year == month.year && + transaction.transactionDate.month == month.month, + ), + hasLength(1), + reason: "$title in $month", + ); + } + } + }); + + for (int seed = 1; seed <= 3; seed++) { + group("seed $seed", () { + late String? rentCategory; + late Map<DateTime, InsightReport> byMonth; + + setUpAll(() { + final List<Transaction> transactions = generate(seed); + + rentCategory = transactions + .firstWhere((transaction) => transaction.title == "Rent") + .categoryUuid; + byMonth = reports(transactions); + }); + + test("tracking is only suggested for this month", () { + for (final MapEntry(key: month, value: report) in byMonth.entries) { + final List<RecurringChargeInsight> suggestions = report.insights + .whereType<RecurringChargeInsight>() + .toList(); + + if (month.month != now.month) { + expect(suggestions, isEmpty, reason: "$month"); + continue; + } + + final RecurringSeries rent = suggestions.single.series; + expect(rent.title, "Rent"); + expect(rent.typicalAmount, 1690.0); + expect(rent.occurrences.last.date.month, now.month); + expect(rent.occurrences.last.date.day, 1); + } + }); + + test("regular bills never explain a month", () { + for (final MapEntry(key: month, value: report) in byMonth.entries) { + for (final Insight insight in report.insights) { + final InsightAttribution? attribution = switch (insight) { + MonthPaceInsight pace => pace.attribution, + CategorySpikeInsight spike => spike.attribution, + _ => null, + }; + + expect( + monthlyBills, + isNot(contains(attribution?.title)), + reason: "$month: ${insight.key}", + ); + + if (insight case MonthPaceInsight pace) { + expect( + pace.dominantDriver?.categoryUuid, + isNot(rentCategory), + reason: "$month", + ); + } + } + } + }); + + test("the rent raise shows in its month only", () { + for (final MapEntry(key: month, value: report) in byMonth.entries) { + final List<PriceChangeInsight> changes = report.insights + .whereType<PriceChangeInsight>() + .toList(); + + if (month != DateTime(2025, 10)) { + expect(changes, isEmpty, reason: "$month"); + continue; + } + + expect(changes.single.series.title, "Rent"); + expect(changes.single.previousAmount, 1590.0); + expect(changes.single.currentAmount, 1690.0); + } + }); + + test("every insight is shown once", () { + for (final MapEntry(key: month, value: report) in byMonth.entries) { + expect(report.status, InsightReportStatus.ready, reason: "$month"); + expect( + report.insights.map((insight) => insight.key).toSet(), + hasLength(report.insights.length), + reason: "$month", + ); + } + }); + }); + } +} From 48c52adb969a27fbcf4b1776a0ad4fdc977b9e3e Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:15:24 +0800 Subject: [PATCH 33/57] insights: don't open the previous month's rows while loading --- lib/widgets/insights/worth_knowing_view.dart | 241 ++++++++++--------- test/widget/worth_knowing_view_test.dart | 24 ++ 2 files changed, 147 insertions(+), 118 deletions(-) diff --git a/lib/widgets/insights/worth_knowing_view.dart b/lib/widgets/insights/worth_knowing_view.dart index 62400e5a..9c084f7b 100644 --- a/lib/widgets/insights/worth_knowing_view.dart +++ b/lib/widgets/insights/worth_knowing_view.dart @@ -53,132 +53,137 @@ class WorthKnowingView extends StatelessWidget { padding: const EdgeInsets.only(bottom: 12.0), child: Surface( clipBehavior: .antiAlias, - builder: (context) => AnimatedOpacity( - opacity: stale ? 0.5 : 1.0, - duration: const Duration(milliseconds: 150), - child: Column( - crossAxisAlignment: .start, - children: [ - Padding( - padding: EdgeInsets.fromLTRB( - 16.0, - hiddenTypeCount > 0 ? 4.0 : 16.0, - hiddenTypeCount > 0 ? 4.0 : 16.0, - hiddenTypeCount > 0 ? 0.0 : 4.0, - ), - child: Row( - children: [ - Icon( - Symbols.lightbulb_rounded, - color: context.colorScheme.primary, - size: 18.0, - ), - const SizedBox(width: 8.0), - Expanded( - child: Text( - "tabs.stats.worthKnowing".t(context).toUpperCase(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: context.flowColors.semi, - letterSpacing: 0.6, - fontWeight: FontWeight.w600, - ), - ), - ), - if (hiddenTypeCount > 0) - IconButton( - onPressed: onManageHidden, - tooltip: "tabs.stats.worthKnowing.kinds".t(context), - visualDensity: VisualDensity.compact, - iconSize: 18.0, - color: context.flowColors.semi, - icon: const Icon(Symbols.visibility_off_rounded), - ), - ], - ), - ), - if (report == null) + // Rows of the previous month would open that month's evidence. + builder: (context) => IgnorePointer( + ignoring: stale, + child: AnimatedOpacity( + opacity: stale ? 0.5 : 1.0, + duration: const Duration(milliseconds: 150), + child: Column( + crossAxisAlignment: .start, + children: [ Padding( - padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 20.0), - child: Container( - width: 180.0, - height: 12.0, - decoration: BoxDecoration( - color: context.colorScheme.onSurface.withAlpha(0x14), - borderRadius: .all(Radius.circular(6.0)), - ), + padding: EdgeInsets.fromLTRB( + 16.0, + hiddenTypeCount > 0 ? 4.0 : 16.0, + hiddenTypeCount > 0 ? 4.0 : 16.0, + hiddenTypeCount > 0 ? 0.0 : 4.0, ), - ) - else if (report.insights.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), - child: Text( - (report.isInProgress - ? "tabs.stats.worthKnowing.nothingUnusual" - : "tabs.stats.worthKnowing.nothingUnusualPast") - .t(context, { - "month": report.month.toMoment().format("MMMM"), - }), - style: context.textTheme.bodyMedium?.semi(context), + child: Row( + children: [ + Icon( + Symbols.lightbulb_rounded, + color: context.colorScheme.primary, + size: 18.0, + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + "tabs.stats.worthKnowing".t(context).toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.flowColors.semi, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ), + if (hiddenTypeCount > 0) + IconButton( + onPressed: onManageHidden, + tooltip: "tabs.stats.worthKnowing.kinds".t(context), + visualDensity: VisualDensity.compact, + iconSize: 18.0, + color: context.flowColors.semi, + icon: const Icon(Symbols.visibility_off_rounded), + ), + ], ), - ) - else - for (final (int index, Insight insight) - in report.insights.indexed) ...[ - if (index > 0) - Divider( - height: 1.0, - thickness: 1.0, - indent: 16.0, - endIndent: 16.0, - color: context.colorScheme.onSurface.withAlpha(0x17), + ), + if (report == null) + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 20.0), + child: Container( + width: 180.0, + height: 12.0, + decoration: BoxDecoration( + color: context.colorScheme.onSurface.withAlpha(0x14), + borderRadius: .all(Radius.circular(6.0)), + ), ), - WorthKnowingRow( - summary: InsightSummary.of( - context, - insight: insight, - report: report, - format: format, + ) + else if (report.insights.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), + child: Text( + (report.isInProgress + ? "tabs.stats.worthKnowing.nothingUnusual" + : "tabs.stats.worthKnowing.nothingUnusualPast") + .t(context, { + "month": report.month.toMoment().format("MMMM"), + }), + style: context.textTheme.bodyMedium?.semi(context), ), - onTap: () => onOpen(insight), - action: insight is RecurringChargeInsight - ? ActionChip( - avatar: Icon( - Symbols.add_rounded, - color: context.colorScheme.primary, - ), - label: Text( - "tabs.stats.worthKnowing.track".t(context), - ), - labelStyle: context.textTheme.labelMedium?.copyWith( - color: context.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - side: BorderSide( - color: context.colorScheme.primary.withAlpha( - 0x80, + ) + else + for (final (int index, Insight insight) + in report.insights.indexed) ...[ + if (index > 0) + Divider( + height: 1.0, + thickness: 1.0, + indent: 16.0, + endIndent: 16.0, + color: context.colorScheme.onSurface.withAlpha(0x17), + ), + WorthKnowingRow( + summary: InsightSummary.of( + context, + insight: insight, + report: report, + format: format, + ), + onTap: () => onOpen(insight), + action: insight is RecurringChargeInsight + ? ActionChip( + avatar: Icon( + Symbols.add_rounded, + color: context.colorScheme.primary, ), - ), - shape: const StadiumBorder(), - visualDensity: VisualDensity.compact, - onPressed: () => onTrack(insight), - ) - : null, - ), - ], - if (report?.hasMissingRates == true) - Padding( - padding: const EdgeInsets.only(bottom: 12.0), - child: MissingRatesNotice( - message: "tabs.stats.analytics.missingRatesAmounts".t( - context, + label: Text( + "tabs.stats.worthKnowing.track".t(context), + ), + labelStyle: context.textTheme.labelMedium + ?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + side: BorderSide( + color: context.colorScheme.primary.withAlpha( + 0x80, + ), + ), + shape: const StadiumBorder(), + visualDensity: VisualDensity.compact, + onPressed: () => onTrack(insight), + ) + : null, + ), + ], + if (report?.hasMissingRates == true) + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: MissingRatesNotice( + message: "tabs.stats.analytics.missingRatesAmounts".t( + context, + ), ), ), - ), - if (report != null && report.insights.isNotEmpty) - const SizedBox(height: 4.0), - ], + if (report != null && report.insights.isNotEmpty) + const SizedBox(height: 4.0), + ], + ), ), ), ), diff --git a/test/widget/worth_knowing_view_test.dart b/test/widget/worth_knowing_view_test.dart index d6a67e46..8e5bdc4c 100644 --- a/test/widget/worth_knowing_view_test.dart +++ b/test/widget/worth_knowing_view_test.dart @@ -97,6 +97,7 @@ void main() { InsightReport? report, { InsightFormatter format = usd, int hiddenTypeCount = 0, + bool stale = false, ValueChanged<Insight>? onOpen, ValueChanged<RecurringChargeInsight>? onTrack, VoidCallback? onManageHidden, @@ -121,6 +122,7 @@ void main() { report: report, format: format, hiddenTypeCount: hiddenTypeCount, + stale: stale, onOpen: onOpen ?? (_) {}, onTrack: onTrack ?? (_) {}, onManageHidden: onManageHidden ?? () {}, @@ -320,6 +322,28 @@ void main() { expect(opened, isEmpty); }); + testWidgets("a previous month's rows can't be opened while loading", ( + tester, + ) async { + final List<Insight> opened = []; + final List<RecurringChargeInsight> tracked = []; + await pump( + tester, + report([spotify]), + stale: true, + onOpen: opened.add, + onTrack: tracked.add, + ); + + await tester.tap( + text("Spotify looks like a monthly charge."), + warnIfMissed: false, + ); + await tester.tap(find.text("Track as recurring"), warnIfMissed: false); + expect(opened, isEmpty); + expect(tracked, isEmpty); + }); + testWidgets("rows are capped by the engine, not the view", (tester) async { await pump(tester, report([pace(), spotify])); From c4fd23c055c2c52116bf5939cc663e20fa1ed411 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:34:09 +0800 Subject: [PATCH 34/57] insights: fix slow recurring detection on ios --- lib/data/insights/insight_math.dart | 41 +++++--- test/unit/insights/insight_math_test.dart | 25 +++++ .../insights/insight_performance_test.dart | 98 +++++++++++++++++++ 3 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 test/unit/insights/insight_performance_test.dart diff --git a/lib/data/insights/insight_math.dart b/lib/data/insights/insight_math.dart index d1d3e177..3c1e0470 100644 --- a/lib/data/insights/insight_math.dart +++ b/lib/data/insights/insight_math.dart @@ -6,8 +6,15 @@ int monthIndexOf(DateTime date) => date.year * 12 + date.month - 1; DateTime monthStartOf(int monthIndex) => DateTime(monthIndex ~/ 12, monthIndex % 12 + 1); -int daysInMonth(int monthIndex) => - DateTime.utc(monthIndex ~/ 12, monthIndex % 12 + 2, 0).day; +int daysInMonth(int monthIndex) { + final int year = monthIndex ~/ 12; + + return switch (monthIndex % 12 + 1) { + 2 => year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) ? 29 : 28, + 4 || 6 || 9 || 11 => 30, + _ => 31, + }; +} /// Calendar days from [from] to [to], ignoring time of day and DST. int dayDifference(DateTime from, DateTime to) => DateTime.utc( @@ -25,19 +32,29 @@ DateTime chargeDayOf(int monthIndex, int anchorDay) => DateTime( /// The month whose charge on [anchorDay] is nearest to [date], and the signed /// distance in days (positive when [date] is late). +/// +/// Plain day arithmetic: this runs for every charge and candidate day, and +/// local [DateTime]s are slow on iOS. ({int slot, int offset}) nearestSlot(DateTime date, int anchorDay) { final int index = monthIndexOf(date); - ({int slot, int offset})? best; - - for (int slot = index - 1; slot <= index + 1; slot++) { - final int offset = dayDifference(chargeDayOf(slot, anchorDay), date); - if (best == null || offset.abs() < best.offset.abs()) { - best = (slot: slot, offset: offset); - } - } - - return best!; + int chargeDay(int slot) => math.min(anchorDay, daysInMonth(slot)); + + final List<({int slot, int offset})> candidates = [ + ( + slot: index - 1, + offset: date.day + daysInMonth(index - 1) - chargeDay(index - 1), + ), + (slot: index, offset: date.day - chargeDay(index)), + ( + slot: index + 1, + offset: date.day - daysInMonth(index) - chargeDay(index + 1), + ), + ]; + + return candidates.reduce( + (best, next) => next.offset.abs() < best.offset.abs() ? next : best, + ); } /// Returns 0.0 for an empty list. diff --git a/test/unit/insights/insight_math_test.dart b/test/unit/insights/insight_math_test.dart index 8e811905..70a29a18 100644 --- a/test/unit/insights/insight_math_test.dart +++ b/test/unit/insights/insight_math_test.dart @@ -117,6 +117,31 @@ void main() { expect(monthStartOf(result.slot), DateTime(2026, 2)); expect(result.offset, 2); }); + + test("matches the nearest calendar charge day, every day and anchor", () { + for ( + DateTime date = DateTime(2023, 12, 1, 12); + date.isBefore(DateTime(2025, 3, 1)); + date = DateTime(date.year, date.month, date.day + 1, 12) + ) { + for (int anchorDay = 1; anchorDay <= 31; anchorDay++) { + final int index = monthIndexOf(date); + final List<int> offsets = [ + for (int slot = index - 1; slot <= index + 1; slot++) + dayDifference(chargeDayOf(slot, anchorDay), date), + ]; + int best = 0; + for (int i = 1; i < offsets.length; i++) { + if (offsets[i].abs() < offsets[best].abs()) best = i; + } + + expect(nearestSlot(date, anchorDay), ( + slot: index - 1 + best, + offset: offsets[best], + ), reason: "$date, anchor $anchorDay"); + } + } + }); }); group("normalizeTitle", () { diff --git a/test/unit/insights/insight_performance_test.dart b/test/unit/insights/insight_performance_test.dart new file mode 100644 index 00000000..10f33db7 --- /dev/null +++ b/test/unit/insights/insight_performance_test.dart @@ -0,0 +1,98 @@ +import "dart:math" as math; + +import "package:flow/data/insights/insight_engine.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "insight_test_data.dart"; + +void main() { + final DateTime now = DateTime(2026, 9, 25, 15, 30); + + /// USD to EUR, so nothing is a round number. + const double rate = 0.8537; + + const List<String> places = [ + "Corner bakery", + "Green grocer", + "Noodle house", + "Book nook", + "Hardware store", + "Pharmacy", + "Taxi", + "Cinema", + "Florist", + "Pet shop", + "Deli", + "Bike repair", + "Laundromat", + "Tea room", + "Bistro", + "Kiosk", + "Butcher", + "Stationery", + "Car wash", + "Market stall", + ]; + + /// Three years of converted daily spend over many titles, each with its + /// own price range, so most of it goes through recurring detection. + TestLedger heavyLedger() { + final math.Random random = math.Random(7); + final TestLedger ledger = TestLedger(); + + for ( + DateTime day = DateTime(2023, 9, 1); + day.isBefore(now); + day = DateTime(day.year, day.month, day.day + 1) + ) { + DateTime at() => DateTime( + day.year, + day.month, + day.day, + 7 + random.nextInt(15), + random.nextInt(60), + ); + + if (day.day == 1) { + ledger.spend(at(), 1690.0 * rate, title: "Rent", category: "rent"); + } + if (day.day == 9) { + ledger.spend(at(), 11.99 * rate, title: "Spotify", category: "subs"); + } + + for (int i = 0; i < 8; i++) { + final int place = random.nextInt(places.length); + + ledger.spend( + at(), + (5.0 + place * 4) * (0.5 + random.nextDouble() * 1.5) * rate, + title: places[place], + category: "category ${place % 6}", + ); + } + } + + return ledger; + } + + // Took over a second while recurring detection built local DateTimes, + // each a time zone lookup (and ten seconds on an iOS simulator). + test("a heavy ledger computes quickly", () { + final TestLedger ledger = heavyLedger(); + + // Warm up, so JIT compilation isn't measured. + ledger.analyze(DateTime(2026, 8), now: now); + + final Stopwatch stopwatch = Stopwatch()..start(); + final InsightReport complete = ledger.analyze(DateTime(2026, 8), now: now); + final InsightReport inProgress = ledger.analyze( + DateTime(2026, 9), + now: now, + ); + stopwatch.stop(); + + expect(complete.status, InsightReportStatus.ready); + expect(inProgress.status, InsightReportStatus.ready); + expect(stopwatch.elapsedMilliseconds, lessThan(500)); + }); +} From 5dcdbfd3a7a61b3ebea31c059bbf745ce74828eb Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:35:25 +0800 Subject: [PATCH 35/57] update changelog, fix #761 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce24503..36329bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ headers, [#761](https://github.com/flow-mn/flow/issues/761) * Categories/accounts stats now open as a ranked list with share bars, [#761](https://github.com/flow-mn/flow/issues/761) +* Added "Worth knowing" to the stats tab: on-device observations like spending + spikes, unusual categories, and charges that look recurring, closes + [#761](https://github.com/flow-mn/flow/issues/761) ### Changes From 4c45016eb944d8dc12b292ebc5f3f6655669c72c Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:37:35 +0800 Subject: [PATCH 36/57] 0.25.0+359 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index b412ec3f..39d05c1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.24.1+358" +version: "0.25.0+359" environment: sdk: ">=3.10.0 <4.0.0" From d5dfe47c51083338d44eedd2858637b2f4020cab Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:41:30 +0800 Subject: [PATCH 37/57] mn: polish insights translations --- assets/l10n/mn_MN.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 2b9faedd..bc23bae0 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -826,10 +826,10 @@ "tabs.stats.trends.topSpendingTitles.description": "Хамгийн түгээмэл хийгддэг зарлагууд", "tabs.stats.worthKnowing": "Мэдэхэд илүүдэхгүй", "tabs.stats.worthKnowing.biggestEntries": "Хамгийн том гүйлгээнүүд", - "tabs.stats.worthKnowing.categoryDrop": "{category} {value}-иар буурсан.", - "tabs.stats.worthKnowing.categoryDropPast": "{category} {value}-иар буурсан байсан.", - "tabs.stats.worthKnowing.categorySpike": "{category} ердийнхөөсөө {value} байна.", - "tabs.stats.worthKnowing.categorySpikePast": "{category} ердийнхөөсөө {value} байсан.", + "tabs.stats.worthKnowing.categoryDrop": "{category}: ердийнхөөс {value} бага.", + "tabs.stats.worthKnowing.categoryDropPast": "{category}: ердийнхөөс {value} бага байсан.", + "tabs.stats.worthKnowing.categorySpike": "{category}: ердийнхөөс {value} их.", + "tabs.stats.worthKnowing.categorySpikePast": "{category}: ердийнхөөс {value} их байсан.", "tabs.stats.worthKnowing.charges": "Төлбөрүүд", "tabs.stats.worthKnowing.chartBefore": "өмнө нь {amount}", "tabs.stats.worthKnowing.chartCharges": "Төлбөр бүр", @@ -844,7 +844,7 @@ "tabs.stats.worthKnowing.hide.monthPace": "Сарын харьцуулалтыг харуулахгүй", "tabs.stats.worthKnowing.hide.newCategory": "Шинэ ангиллыг харуулахгүй", "tabs.stats.worthKnowing.hide.priceChange": "Үнийн өөрчлөлтийг харуулахгүй", - "tabs.stats.worthKnowing.hide.recurringCharge": "Захиалгын саналыг харуулахгүй", + "tabs.stats.worthKnowing.hide.recurringCharge": "Давтагдах төлбөрийн саналыг харуулахгүй", "tabs.stats.worthKnowing.kinds": "«Мэдэхэд илүүдэхгүй»-д харуулах", "tabs.stats.worthKnowing.lessThanUsual": "ердийнхөөс {amount} бага", "tabs.stats.worthKnowing.monthAbove": "{month}: {day} өдрийн байдлаар ердийнхөөс {value} өндөр.", @@ -867,26 +867,26 @@ "tabs.stats.worthKnowing.recurringWeekly": "{value} долоо хоног бүрийн төлбөр бололтой.", "tabs.stats.worthKnowing.recurringWeeklyDetail": "{amount}, {weekday} бүр · {times}", "tabs.stats.worthKnowing.recurringYearly": "{value} жил бүрийн төлбөр бололтой.", - "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount}, {date}-ны орчим · {times}", - "tabs.stats.worthKnowing.rule.categoryDrop": "Бараг сар бүр хэрэглэдэг ангилал медианынхаа {percent}-иас доош буувал харагдана.", - "tabs.stats.worthKnowing.rule.categorySpike": "Ангилал өмнөх {months} сарын медианынхаа {ratio} хүрч, дор хаяж {count} гүйлгээтэй бол харагдана.", + "tabs.stats.worthKnowing.recurringYearlyDetail": "{amount}, {date} орчим · {times}", + "tabs.stats.worthKnowing.rule.categoryDrop": "Бараг сар бүр хэрэглэдэг ангиллын зарлага ердийн (медиан) дүнгийн {percent}-иас доош буувал харагдана.", + "tabs.stats.worthKnowing.rule.categorySpike": "Ангиллын зарлага өмнөх {months} сарын ердийн (медиан) дүнгийн {ratio} хүрч, дор хаяж {count} гүйлгээтэй бол харагдана.", "tabs.stats.worthKnowing.rule.footer": "Шилжүүлэг болон хүлээгдэж буй гүйлгээг тооцохгүй. Энэ төхөөрөмж дээр тооцоолов.", - "tabs.stats.worthKnowing.rule.monthPace": "{date} хүртэлх зарлагыг өмнөх {months} сарын мөн өдөр хүртэлх медиантай харьцуулав.", - "tabs.stats.worthKnowing.rule.monthPacePast": "{month}-ын зарлагыг өмнөх {months} сарын медиантай харьцуулав.", + "tabs.stats.worthKnowing.rule.monthPace": "{date} хүртэлх зарлагыг өмнөх {months} сарын мөн өдөр хүртэлх ердийн (медиан) дүнтэй харьцуулав.", + "tabs.stats.worthKnowing.rule.monthPacePast": "{month}: зарлагыг өмнөх {months} сарын ердийн (медиан) дүнтэй харьцуулав.", "tabs.stats.worthKnowing.rule.newCategory": "Ангилалд {months} сард анх удаа зарлага гарвал харагдана.", "tabs.stats.worthKnowing.rule.priceChange": "Давтагддаг төлбөр {percent} буюу түүнээс их өөрчлөгдвөл харагдана.", "tabs.stats.worthKnowing.rule.recurringCharge": "Ижил нэртэй, тогтмол дүнтэй, тогтмол давтагддаг төлбөрүүд.", "tabs.stats.worthKnowing.seeTransactions": "Эдгээр гүйлгээг харах", "tabs.stats.worthKnowing.times": "одоогоор {count} удаа", "tabs.stats.worthKnowing.times.one": "одоогоор {count} удаа", - "tabs.stats.worthKnowing.track": "Давтамжит болгох", + "tabs.stats.worthKnowing.track": "Давтагдах гүйлгээ болгох", "tabs.stats.worthKnowing.type.categoryDrop": "Ангиллын бууралт", "tabs.stats.worthKnowing.type.categorySpike": "Ангиллын өсөлт", "tabs.stats.worthKnowing.type.monthPace": "Сарын харьцуулалт", "tabs.stats.worthKnowing.type.newCategory": "Шинэ ангилал", "tabs.stats.worthKnowing.type.priceChange": "Үнийн өөрчлөлт", - "tabs.stats.worthKnowing.type.recurringCharge": "Захиалгын санал", - "tabs.stats.worthKnowing.versusTypical": "{current} vs ердийн {usual}", + "tabs.stats.worthKnowing.type.recurringCharge": "Давтагдах төлбөрийн санал", + "tabs.stats.worthKnowing.versusTypical": "{current} (ердийн {usual})", "tabs.stats.worthKnowing.whatMovedIt": "Юу нөлөөлсөн бэ", "tabs.stats.worthKnowing.why": "Яагаад үүнийг харж байна вэ", "transaction": "Гүйлгээ", From 4365801ddfdb677087c874a1978be8026f39fc32 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:41:55 +0800 Subject: [PATCH 38/57] =?UTF-8?q?mn:=20don't=20translate=20subscriptions?= =?UTF-8?q?=20as=20=D0=B1=D2=AF=D1=80=D1=82=D0=B3=D1=8D=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/l10n/mn_MN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index bc23bae0..2ed8e856 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -692,7 +692,7 @@ "tabs.profile.analytics.cashFlow": "Мөнгөний урсгал (Sankey)", "tabs.profile.analytics.map": "Зарцуулалтын газрын зураг", "tabs.profile.analytics.netWorth": "Цаг хугацааны явцад нийт хөрөнгө", - "tabs.profile.analytics.recurring": "Бүртгэл ба давтагдах", + "tabs.profile.analytics.recurring": "Давтагдах төлбөрүүд", "tabs.profile.analytics.wrapped": "Сарын тойм", "tabs.profile.backup": "Нөөцлөх", "tabs.profile.community": "Холбоо", From 969c9cadf064688fc97ae13e74fcbf621e1fbebc Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:42:35 +0800 Subject: [PATCH 39/57] =?UTF-8?q?mn:=20use=20=D0=B7=D0=B0=D1=85=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=B3=D0=B0=20for=20subscriptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/l10n/mn_MN.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 2ed8e856..fe0ffd12 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -692,7 +692,7 @@ "tabs.profile.analytics.cashFlow": "Мөнгөний урсгал (Sankey)", "tabs.profile.analytics.map": "Зарцуулалтын газрын зураг", "tabs.profile.analytics.netWorth": "Цаг хугацааны явцад нийт хөрөнгө", - "tabs.profile.analytics.recurring": "Давтагдах төлбөрүүд", + "tabs.profile.analytics.recurring": "Захиалга ба давтагдах", "tabs.profile.analytics.wrapped": "Сарын тойм", "tabs.profile.backup": "Нөөцлөх", "tabs.profile.community": "Холбоо", @@ -844,7 +844,7 @@ "tabs.stats.worthKnowing.hide.monthPace": "Сарын харьцуулалтыг харуулахгүй", "tabs.stats.worthKnowing.hide.newCategory": "Шинэ ангиллыг харуулахгүй", "tabs.stats.worthKnowing.hide.priceChange": "Үнийн өөрчлөлтийг харуулахгүй", - "tabs.stats.worthKnowing.hide.recurringCharge": "Давтагдах төлбөрийн саналыг харуулахгүй", + "tabs.stats.worthKnowing.hide.recurringCharge": "Захиалгын саналыг харуулахгүй", "tabs.stats.worthKnowing.kinds": "«Мэдэхэд илүүдэхгүй»-д харуулах", "tabs.stats.worthKnowing.lessThanUsual": "ердийнхөөс {amount} бага", "tabs.stats.worthKnowing.monthAbove": "{month}: {day} өдрийн байдлаар ердийнхөөс {value} өндөр.", @@ -885,7 +885,7 @@ "tabs.stats.worthKnowing.type.monthPace": "Сарын харьцуулалт", "tabs.stats.worthKnowing.type.newCategory": "Шинэ ангилал", "tabs.stats.worthKnowing.type.priceChange": "Үнийн өөрчлөлт", - "tabs.stats.worthKnowing.type.recurringCharge": "Давтагдах төлбөрийн санал", + "tabs.stats.worthKnowing.type.recurringCharge": "Захиалгын санал", "tabs.stats.worthKnowing.versusTypical": "{current} (ердийн {usual})", "tabs.stats.worthKnowing.whatMovedIt": "Юу нөлөөлсөн бэ", "tabs.stats.worthKnowing.why": "Яагаад үүнийг харж байна вэ", From 9f4fbde54d3fc4af1729c6f59afed869999ecbb1 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sat, 26 Sep 2026 00:43:23 +0800 Subject: [PATCH 40/57] =?UTF-8?q?mn:=20recurring=20&=20subscriptions=20as?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=B2=D1=82=D0=B0=D0=B3=D0=B4=D0=B0=D1=85=20?= =?UTF-8?q?=D1=82=D3=A9=D0=BB=D0=B1=D3=A9=D1=80=D2=AF=D2=AF=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/l10n/mn_MN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index fe0ffd12..30a193d6 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -692,7 +692,7 @@ "tabs.profile.analytics.cashFlow": "Мөнгөний урсгал (Sankey)", "tabs.profile.analytics.map": "Зарцуулалтын газрын зураг", "tabs.profile.analytics.netWorth": "Цаг хугацааны явцад нийт хөрөнгө", - "tabs.profile.analytics.recurring": "Захиалга ба давтагдах", + "tabs.profile.analytics.recurring": "Давтагдах төлбөрүүд", "tabs.profile.analytics.wrapped": "Сарын тойм", "tabs.profile.backup": "Нөөцлөх", "tabs.profile.community": "Холбоо", From f34c435137e2403d3c9a0923b48e311314e0e4cc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 06:27:48 +0000 Subject: [PATCH 41/57] Use updated icon forks; make Simple Icons migration version-independent Bump material_symbols_icons_flow to ^4.2960.0 and simple_icons_flow to ^16.23.0 (both synced with upstream). Simple Icons reassigns code points every release (3370 of them moved between 16.20.0 and 16.23.0). migrateSimpleIconsToSlug mapped legacy 14.6.1 code points to 16.20.0 ones and then resolved the slug against the *bundled* font, so bumping the package would have migrated stored brand icons to the wrong brands. Regenerate the legacy table as 14.6.1 code point -> slug (limited to slugs present in the bundled build) and look slugs up directly, so future package bumps can't break the migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FdMdLgvKZMHugJXw9JPFcz --- CHANGELOG.md | 2 + lib/data/legacy_simple_icons_codepoints.dart | 6324 +++++++++--------- lib/graceful_migrations.dart | 20 +- pubspec.yaml | 4 +- 4 files changed, 3184 insertions(+), 3166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36329bd0..da215bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ * Reorganized preferences * Pie chart now uses category colors, shows the total, and opens the category when you tap a selected slice again +* Updated icons: Material Symbols 4.2960.0 and Simple Icons (brand icons) + 16.23.0 ### Fixes diff --git a/lib/data/legacy_simple_icons_codepoints.dart b/lib/data/legacy_simple_icons_codepoints.dart index 569b4255..11496835 100644 --- a/lib/data/legacy_simple_icons_codepoints.dart +++ b/lib/data/legacy_simple_icons_codepoints.dart @@ -1,3155 +1,3181 @@ // GENERATED — do not edit by hand. // // simple_icons assigns code points sequentially by icon slug, so they -// shift between releases. Flow shipped simple_icons 14.6.1; the fork -// simple_icons_flow is built from 16.20.0. This table remaps a legacy -// (14.6.1) code point to its 16.20.0 equivalent, matched by slug, so -// brand icons saved before the migration keep pointing at the same -// glyph. Slugs removed upstream have no entry and fall back unchanged. +// shift between releases. Flow shipped simple_icons 14.6.1 and stored brand +// icons by code point. This table maps a legacy (14.6.1) code point to its +// slug, which is stable across releases, so the migration to +// [SimpleIconFlowIcon] doesn't depend on the bundled simple_icons_flow +// version. Slugs removed upstream (absent from the bundled build) have no +// entry and are left unchanged. library; -const Map<int, int> legacySimpleIconsCodepoints = { - 0xea17: 0xea18, - 0xea18: 0xea19, - 0xea19: 0xea1a, - 0xea1a: 0xea1b, - 0xea1b: 0xea1c, - 0xea1c: 0xea1d, - 0xea1d: 0xea1e, - 0xea1e: 0xea1f, - 0xea1f: 0xea20, - 0xea20: 0xea21, - 0xea21: 0xea22, - 0xea22: 0xea24, - 0xea23: 0xea26, - 0xea24: 0xea27, - 0xea25: 0xea28, - 0xea26: 0xea29, - 0xea27: 0xea2a, - 0xea28: 0xea2c, - 0xea29: 0xea2e, - 0xea2a: 0xea2f, - 0xea2b: 0xea30, - 0xea2c: 0xea31, - 0xea2d: 0xea32, - 0xea2e: 0xea33, - 0xea2f: 0xea34, - 0xea30: 0xea35, - 0xea31: 0xea36, - 0xea32: 0xea37, - 0xea33: 0xea38, - 0xea34: 0xea39, - 0xea36: 0xea3a, - 0xea37: 0xea3b, - 0xea3a: 0xea3c, - 0xea3b: 0xea3d, - 0xea40: 0xea3e, - 0xea41: 0xea3f, - 0xea42: 0xea40, - 0xea43: 0xea41, - 0xea44: 0xea42, - 0xea45: 0xea43, - 0xea46: 0xea44, - 0xea47: 0xea45, - 0xea48: 0xea46, - 0xea49: 0xea47, - 0xea4a: 0xea48, - 0xea4b: 0xea49, - 0xea4c: 0xea4a, - 0xea4d: 0xea4b, - 0xea4e: 0xea4c, - 0xea4f: 0xea4d, - 0xea50: 0xea4e, - 0xea51: 0xea4f, - 0xea52: 0xea50, - 0xea53: 0xea51, - 0xea54: 0xea52, - 0xea55: 0xea53, - 0xea56: 0xea54, - 0xea57: 0xea55, - 0xea58: 0xea56, - 0xea59: 0xea57, - 0xea5a: 0xea58, - 0xea5b: 0xea59, - 0xea5c: 0xea5a, - 0xea5d: 0xea5b, - 0xea5e: 0xea5c, - 0xea5f: 0xea5d, - 0xea60: 0xea5e, - 0xea61: 0xea5f, - 0xea62: 0xea60, - 0xea64: 0xea61, - 0xea65: 0xea62, - 0xea66: 0xea63, - 0xea67: 0xea64, - 0xea68: 0xea65, - 0xea69: 0xea66, - 0xea6a: 0xea67, - 0xea6b: 0xea68, - 0xea6c: 0xea6a, - 0xea6d: 0xea6b, - 0xea6f: 0xea6c, - 0xea70: 0xea6d, - 0xea71: 0xea6e, - 0xea72: 0xea6f, - 0xea73: 0xea70, - 0xea76: 0xea71, - 0xea92: 0xea72, - 0xea93: 0xea73, - 0xea94: 0xea74, - 0xea95: 0xea75, - 0xea96: 0xea76, - 0xea97: 0xea77, - 0xea98: 0xea78, - 0xea99: 0xea79, - 0xea9a: 0xea7a, - 0xea9b: 0xea7b, - 0xea9c: 0xea7c, - 0xea9d: 0xea7d, - 0xea9e: 0xea7e, - 0xea9f: 0xea7f, - 0xeaa0: 0xea80, - 0xeaa1: 0xea82, - 0xeaa2: 0xea83, - 0xeaa3: 0xea85, - 0xeaa4: 0xea86, - 0xeaa5: 0xea87, - 0xeaa6: 0xea88, - 0xeaa7: 0xea89, - 0xeaa8: 0xea8a, - 0xeaa9: 0xea8b, - 0xeaaa: 0xea8c, - 0xeaab: 0xea8e, - 0xeaac: 0xea8f, - 0xeaad: 0xea90, - 0xeaae: 0xea91, - 0xeaaf: 0xea92, - 0xeab1: 0xea93, - 0xeab2: 0xea94, - 0xeab3: 0xea95, - 0xeab4: 0xea97, - 0xeab5: 0xea98, - 0xeab6: 0xea99, - 0xeab7: 0xea9a, - 0xeab8: 0xea9b, - 0xeab9: 0xea9d, - 0xeaba: 0xea9e, - 0xeabb: 0xea9f, - 0xeabc: 0xeaa0, - 0xeabd: 0xeaa1, - 0xeabe: 0xeaa2, - 0xeabf: 0xeaa3, - 0xeac0: 0xeaa4, - 0xeac1: 0xeaa5, - 0xeac2: 0xeaa6, - 0xeac3: 0xeaa7, - 0xeac4: 0xeaa8, - 0xeac5: 0xeaa9, - 0xeac6: 0xeaaa, - 0xeac7: 0xeaab, - 0xeac8: 0xeaac, - 0xeac9: 0xeaad, - 0xeaca: 0xeaae, - 0xeacb: 0xeab0, - 0xeacc: 0xeab1, - 0xeacd: 0xeab2, - 0xeace: 0xeab3, - 0xeacf: 0xeab4, - 0xead0: 0xeab5, - 0xead1: 0xeab6, - 0xead2: 0xeab7, - 0xead3: 0xeab8, - 0xead4: 0xeab9, - 0xead5: 0xeaba, - 0xead6: 0xeabb, - 0xead7: 0xeabc, - 0xead8: 0xeabd, - 0xead9: 0xeabe, - 0xeada: 0xeac0, - 0xeadb: 0xeac1, - 0xeadc: 0xeac2, - 0xeadd: 0xeac3, - 0xeade: 0xeac4, - 0xeadf: 0xeac5, - 0xeae0: 0xeac6, - 0xeae1: 0xeac7, - 0xeae2: 0xeac9, - 0xeae3: 0xeaca, - 0xeae4: 0xeacb, - 0xeae5: 0xeacc, - 0xeae6: 0xeacd, - 0xeae7: 0xeace, - 0xeae8: 0xeacf, - 0xeae9: 0xead0, - 0xeaea: 0xead1, - 0xeaeb: 0xead2, - 0xeaec: 0xead3, - 0xeaed: 0xead4, - 0xeaee: 0xead5, - 0xeaef: 0xead6, - 0xeaf0: 0xead7, - 0xeaf1: 0xead8, - 0xeaf2: 0xead9, - 0xeaf3: 0xeada, - 0xeaf4: 0xeadb, - 0xeaf5: 0xeadc, - 0xeaf6: 0xeadd, - 0xeaf7: 0xeade, - 0xeaf8: 0xeadf, - 0xeaf9: 0xeae0, - 0xeafa: 0xeae1, - 0xeafb: 0xeae2, - 0xeafc: 0xeae3, - 0xeafd: 0xeae4, - 0xeafe: 0xeae5, - 0xeaff: 0xeae6, - 0xeb00: 0xeae7, - 0xeb01: 0xeae8, - 0xeb02: 0xeae9, - 0xeb03: 0xeaea, - 0xeb04: 0xeaeb, - 0xeb05: 0xeaec, - 0xeb06: 0xeaed, - 0xeb07: 0xeaee, - 0xeb08: 0xeaef, - 0xeb09: 0xeaf0, - 0xeb0a: 0xeaf1, - 0xeb0b: 0xeaf2, - 0xeb0c: 0xeaf3, - 0xeb0d: 0xeaf4, - 0xeb0e: 0xeaf5, - 0xeb0f: 0xeaf6, - 0xeb10: 0xeaf7, - 0xeb11: 0xeaf8, - 0xeb12: 0xeaf9, - 0xeb13: 0xeafa, - 0xeb14: 0xeafb, - 0xeb15: 0xeafc, - 0xeb16: 0xeafe, - 0xeb17: 0xeaff, - 0xeb18: 0xeb00, - 0xeb1a: 0xeb01, - 0xeb1b: 0xeb02, - 0xeb1c: 0xeb03, - 0xeb1d: 0xeb04, - 0xeb1e: 0xeb05, - 0xeb1f: 0xeb06, - 0xeb20: 0xeb07, - 0xeb21: 0xeb08, - 0xeb22: 0xeb09, - 0xeb23: 0xeb0a, - 0xeb24: 0xeb0b, - 0xeb25: 0xeb0d, - 0xeb26: 0xeb0e, - 0xeb27: 0xeb0f, - 0xeb28: 0xeb11, - 0xeb29: 0xeb12, - 0xeb30: 0xeb13, - 0xeb31: 0xeb14, - 0xeb32: 0xeb16, - 0xeb33: 0xeb18, - 0xeb34: 0xeb19, - 0xeb35: 0xeb1a, - 0xeb36: 0xeb1b, - 0xeb37: 0xeb1c, - 0xeb38: 0xeb1d, - 0xeb39: 0xeb1e, - 0xeb3a: 0xeb1f, - 0xeb3b: 0xeb21, - 0xeb3c: 0xeb22, - 0xeb3d: 0xeb23, - 0xeb3e: 0xeb24, - 0xeb3f: 0xeb25, - 0xeb40: 0xeb26, - 0xeb41: 0xeb27, - 0xeb42: 0xeb28, - 0xeb43: 0xeb29, - 0xeb44: 0xeb2a, - 0xeb45: 0xeb2b, - 0xeb46: 0xeb2c, - 0xeb47: 0xeb2e, - 0xeb48: 0xeb2f, - 0xeb49: 0xeb30, - 0xeb4a: 0xeb31, - 0xeb4b: 0xeb32, - 0xeb4c: 0xeb33, - 0xeb4d: 0xeb34, - 0xeb4e: 0xeb35, - 0xeb4f: 0xeb36, - 0xeb50: 0xeb37, - 0xeb51: 0xeb38, - 0xeb52: 0xeb39, - 0xeb53: 0xeb3a, - 0xeb54: 0xeb3b, - 0xeb55: 0xeb3c, - 0xeb56: 0xeb3d, - 0xeb57: 0xeb3e, - 0xeb58: 0xeb3f, - 0xeb59: 0xeb40, - 0xeb5a: 0xeb41, - 0xeb5b: 0xeb42, - 0xeb5c: 0xeb43, - 0xeb5d: 0xeb45, - 0xeb5e: 0xeb46, - 0xeb5f: 0xeb47, - 0xeb60: 0xeb48, - 0xeb61: 0xeb49, - 0xeb62: 0xeb4a, - 0xeb63: 0xeb4b, - 0xeb64: 0xeb4c, - 0xeb65: 0xeb4d, - 0xeb66: 0xeb4e, - 0xeb67: 0xeb4f, - 0xeb68: 0xeb50, - 0xeb69: 0xeb52, - 0xeb6a: 0xeb53, - 0xeb6b: 0xeb54, - 0xeb6c: 0xeb55, - 0xeb6d: 0xeb56, - 0xeb6e: 0xeb57, - 0xeb6f: 0xeb58, - 0xeb70: 0xeb59, - 0xeb71: 0xeb5a, - 0xeb72: 0xeb5b, - 0xeb73: 0xeb5c, - 0xeb74: 0xeb5e, - 0xeb75: 0xeb5f, - 0xeb76: 0xeb60, - 0xeb77: 0xeb62, - 0xeb78: 0xeb63, - 0xeb79: 0xeb64, - 0xeb7a: 0xeb65, - 0xeb7b: 0xeb66, - 0xeb7c: 0xeb68, - 0xeb7d: 0xeb69, - 0xeb7e: 0xeb6a, - 0xeb7f: 0xeb6b, - 0xeb80: 0xeb6c, - 0xeb81: 0xeb6d, - 0xeb82: 0xeb6e, - 0xeb83: 0xeb6f, - 0xeb84: 0xeb70, - 0xeb85: 0xeb71, - 0xeb86: 0xeb72, - 0xeb87: 0xeb73, - 0xeb88: 0xeb74, - 0xeb89: 0xeb75, - 0xeb8a: 0xeb76, - 0xeb8b: 0xeb78, - 0xeb8c: 0xeb79, - 0xeb8d: 0xeb7a, - 0xeb8e: 0xeb7c, - 0xeb8f: 0xeb7d, - 0xeb90: 0xeb7e, - 0xeb91: 0xeb7f, - 0xeb92: 0xeb80, - 0xeb93: 0xeb81, - 0xeb94: 0xeb82, - 0xeb95: 0xeb83, - 0xeb96: 0xeb84, - 0xeb97: 0xeb85, - 0xeb98: 0xeb86, - 0xeb99: 0xeb87, - 0xeb9a: 0xeb88, - 0xeb9b: 0xeb89, - 0xeb9c: 0xeb8a, - 0xeb9d: 0xeb8b, - 0xeb9e: 0xeb8e, - 0xeb9f: 0xeb8f, - 0xeba0: 0xeb90, - 0xeba1: 0xeb91, - 0xeba2: 0xeb92, - 0xeba3: 0xeb93, - 0xeba4: 0xeb94, - 0xeba5: 0xeb95, - 0xeba6: 0xeb96, - 0xeba7: 0xeb97, - 0xeba8: 0xeb98, - 0xeba9: 0xeb99, - 0xebaa: 0xeb9a, - 0xebab: 0xeb9b, - 0xebac: 0xeb9c, - 0xebad: 0xeb9d, - 0xebae: 0xeb9e, - 0xebaf: 0xeb9f, - 0xebb0: 0xeba0, - 0xebb2: 0xeba1, - 0xebb3: 0xeba2, - 0xebb4: 0xeba3, - 0xebb5: 0xeba4, - 0xebb6: 0xeba5, - 0xebb7: 0xeba6, - 0xebb8: 0xeba7, - 0xebb9: 0xeba9, - 0xebba: 0xebaa, - 0xebbb: 0xebab, - 0xebbc: 0xebac, - 0xebbd: 0xebad, - 0xebbe: 0xebae, - 0xebbf: 0xebaf, - 0xebc0: 0xebb0, - 0xebc1: 0xebb1, - 0xebc2: 0xebb2, - 0xebc3: 0xebb3, - 0xebc4: 0xebb4, - 0xebc5: 0xebb5, - 0xebc6: 0xebb6, - 0xebc7: 0xebb8, - 0xebc8: 0xebb9, - 0xebc9: 0xebba, - 0xebca: 0xebbb, - 0xebcb: 0xebbc, - 0xebcc: 0xebbd, - 0xebcd: 0xebbe, - 0xebce: 0xebbf, - 0xebcf: 0xebc0, - 0xebd0: 0xebc1, - 0xebd1: 0xebc2, - 0xebd2: 0xebc3, - 0xebd3: 0xebc4, - 0xebd5: 0xebc5, - 0xebd6: 0xebc6, - 0xebd7: 0xebc7, - 0xebd8: 0xebc8, - 0xebd9: 0xebc9, - 0xebda: 0xebcb, - 0xebdb: 0xebcc, - 0xebdc: 0xebcd, - 0xebdd: 0xebce, - 0xebde: 0xebcf, - 0xebdf: 0xebd0, - 0xebe0: 0xebd1, - 0xebe1: 0xebd2, - 0xebe2: 0xebd3, - 0xebe3: 0xebd4, - 0xebe4: 0xebd5, - 0xebe5: 0xebd6, - 0xebe6: 0xebd7, - 0xebe7: 0xebd8, - 0xebe8: 0xebd9, - 0xebe9: 0xebda, - 0xebea: 0xebdb, - 0xebeb: 0xebdc, - 0xebec: 0xebdd, - 0xebed: 0xebde, - 0xebee: 0xebdf, - 0xebef: 0xebe0, - 0xebf0: 0xebe1, - 0xebf1: 0xebe2, - 0xebf2: 0xebe4, - 0xebf3: 0xebe5, - 0xebf4: 0xebe6, - 0xebf5: 0xebe7, - 0xebf6: 0xebe8, - 0xebf7: 0xebe9, - 0xebf8: 0xebea, - 0xebf9: 0xebeb, - 0xebfa: 0xebec, - 0xebfb: 0xebed, - 0xebfc: 0xebee, - 0xebfd: 0xebef, - 0xebfe: 0xebf0, - 0xebff: 0xebf1, - 0xec00: 0xebf2, - 0xec01: 0xebf3, - 0xec02: 0xebf4, - 0xec03: 0xebf5, - 0xec04: 0xebf7, - 0xec05: 0xebf8, - 0xec06: 0xebf9, - 0xec07: 0xebfa, - 0xec09: 0xebfb, - 0xec0a: 0xebfc, - 0xec0b: 0xebfd, - 0xec0c: 0xebfe, - 0xec0d: 0xebff, - 0xec0e: 0xec01, - 0xec0f: 0xec02, - 0xec10: 0xec03, - 0xec11: 0xec04, - 0xec12: 0xec05, - 0xec13: 0xec06, - 0xec14: 0xec07, - 0xec15: 0xec08, - 0xec16: 0xec09, - 0xec17: 0xec0a, - 0xec18: 0xec0b, - 0xec19: 0xec0c, - 0xec1a: 0xec0d, - 0xec1b: 0xec0e, - 0xec1c: 0xec0f, - 0xec1d: 0xec10, - 0xec1e: 0xec11, - 0xec1f: 0xec13, - 0xec20: 0xec14, - 0xec21: 0xec15, - 0xec22: 0xec16, - 0xec23: 0xec17, - 0xec24: 0xec19, - 0xec25: 0xec1a, - 0xec26: 0xec1b, - 0xec27: 0xec1c, - 0xec28: 0xec1d, - 0xec29: 0xec1e, - 0xec2a: 0xec1f, - 0xec2b: 0xec20, - 0xec2c: 0xec21, - 0xec2d: 0xec22, - 0xec2e: 0xec23, - 0xec2f: 0xec24, - 0xec30: 0xec25, - 0xec31: 0xec26, - 0xec32: 0xec27, - 0xec33: 0xec28, - 0xec34: 0xec29, - 0xec35: 0xec2a, - 0xec36: 0xec2b, - 0xec37: 0xec2d, - 0xec38: 0xec2e, - 0xec39: 0xec2f, - 0xec3a: 0xec30, - 0xec3b: 0xec31, - 0xec3c: 0xec32, - 0xec3d: 0xec33, - 0xec3e: 0xec34, - 0xec3f: 0xec35, - 0xec40: 0xec36, - 0xec41: 0xec37, - 0xec42: 0xec38, - 0xec43: 0xec39, - 0xec44: 0xec3a, - 0xec45: 0xec3b, - 0xec46: 0xec3c, - 0xec47: 0xec3d, - 0xec48: 0xec3e, - 0xec49: 0xec3f, - 0xec4b: 0xec40, - 0xec4c: 0xec41, - 0xec4d: 0xec42, - 0xec4e: 0xec43, - 0xec50: 0xec44, - 0xec51: 0xec45, - 0xec52: 0xec47, - 0xec53: 0xec48, - 0xec54: 0xec49, - 0xec55: 0xec4a, - 0xec56: 0xec4b, - 0xec57: 0xec4c, - 0xec58: 0xec4d, - 0xec59: 0xec4e, - 0xec5a: 0xec4f, - 0xec5b: 0xec50, - 0xec5c: 0xec51, - 0xec5d: 0xec52, - 0xec60: 0xec53, - 0xec61: 0xec54, - 0xec62: 0xec55, - 0xec63: 0xec56, - 0xec64: 0xec57, - 0xec65: 0xec58, - 0xec66: 0xec59, - 0xec67: 0xec5a, - 0xec68: 0xec5b, - 0xec69: 0xec5c, - 0xec6a: 0xec5d, - 0xec6b: 0xec5e, - 0xec6c: 0xec5f, - 0xec6d: 0xec60, - 0xec6e: 0xec61, - 0xec6f: 0xec62, - 0xec70: 0xec63, - 0xec71: 0xec64, - 0xec72: 0xec65, - 0xec73: 0xec66, - 0xec74: 0xec67, - 0xec75: 0xec68, - 0xec76: 0xec69, - 0xec77: 0xec6a, - 0xec78: 0xec6b, - 0xec79: 0xec6d, - 0xec7a: 0xec6e, - 0xec7b: 0xec6f, - 0xec7c: 0xec70, - 0xec7d: 0xec71, - 0xec7e: 0xec72, - 0xec7f: 0xec74, - 0xec80: 0xec75, - 0xec81: 0xec77, - 0xec82: 0xec78, - 0xec83: 0xec79, - 0xec84: 0xec7b, - 0xec85: 0xec7c, - 0xec86: 0xec7d, - 0xec87: 0xec7e, - 0xec88: 0xec7f, - 0xec89: 0xec80, - 0xec8a: 0xec81, - 0xec8b: 0xec82, - 0xec8c: 0xec83, - 0xec8d: 0xec84, - 0xec8e: 0xec85, - 0xec8f: 0xec86, - 0xec90: 0xec87, - 0xec91: 0xec88, - 0xec92: 0xec89, - 0xec93: 0xec8a, - 0xec94: 0xec8b, - 0xec95: 0xec8c, - 0xec96: 0xec8d, - 0xec97: 0xec8e, - 0xec98: 0xec8f, - 0xec99: 0xec90, - 0xec9a: 0xec91, - 0xec9b: 0xec92, - 0xec9c: 0xec93, - 0xec9d: 0xec94, - 0xec9e: 0xec95, - 0xec9f: 0xec96, - 0xeca0: 0xec97, - 0xeca1: 0xec98, - 0xeca2: 0xec99, - 0xeca3: 0xec9a, - 0xeca4: 0xec9b, - 0xeca5: 0xec9c, - 0xeca6: 0xec9d, - 0xeca7: 0xec9e, - 0xeca8: 0xec9f, - 0xecaa: 0xeca0, - 0xecab: 0xeca1, - 0xecac: 0xeca2, - 0xecad: 0xeca3, - 0xecae: 0xeca4, - 0xecaf: 0xeca6, - 0xecb0: 0xeca7, - 0xecb1: 0xeca8, - 0xecb2: 0xeca9, - 0xecb3: 0xecaa, - 0xecb4: 0xecab, - 0xecb5: 0xecac, - 0xecb6: 0xecad, - 0xecb7: 0xecae, - 0xecb8: 0xecaf, - 0xecb9: 0xecb0, - 0xecba: 0xecb1, - 0xecbb: 0xecb2, - 0xecbc: 0xecb3, - 0xecbd: 0xecb4, - 0xecbe: 0xecb5, - 0xecbf: 0xecb6, - 0xecc0: 0xecb7, - 0xecc1: 0xecb8, - 0xecc2: 0xecb9, - 0xecc3: 0xecbb, - 0xecc4: 0xecbc, - 0xecc5: 0xecbd, - 0xecc6: 0xecbe, - 0xecc7: 0xecbf, - 0xecc8: 0xecc0, - 0xecc9: 0xecc1, - 0xecca: 0xecc2, - 0xeccb: 0xecc3, - 0xeccc: 0xecc4, - 0xeccd: 0xecc5, - 0xecce: 0xecc6, - 0xeccf: 0xecc7, - 0xecd0: 0xecc8, - 0xecd1: 0xecc9, - 0xecd2: 0xecca, - 0xecd3: 0xeccb, - 0xecd4: 0xeccc, - 0xecd5: 0xeccd, - 0xecd7: 0xecce, - 0xecd8: 0xeccf, - 0xecd9: 0xecd0, - 0xecda: 0xecd1, - 0xecdb: 0xecd2, - 0xecdc: 0xecd3, - 0xecdd: 0xecd4, - 0xecde: 0xecd5, - 0xecdf: 0xecd6, - 0xece0: 0xecd7, - 0xece1: 0xecd9, - 0xece2: 0xecdc, - 0xece3: 0xecdd, - 0xece4: 0xecde, - 0xece5: 0xecdf, - 0xece6: 0xece0, - 0xece7: 0xece1, - 0xece8: 0xece3, - 0xece9: 0xece4, - 0xecea: 0xece5, - 0xeceb: 0xece6, - 0xecec: 0xece7, - 0xeced: 0xece8, - 0xecee: 0xece9, - 0xecef: 0xecea, - 0xecf0: 0xeceb, - 0xecf1: 0xecec, - 0xecf2: 0xeced, - 0xecf3: 0xecef, - 0xecf4: 0xecf0, - 0xecf5: 0xecf1, - 0xecf6: 0xecf2, - 0xecf7: 0xecf4, - 0xecf8: 0xecf5, - 0xecf9: 0xecf6, - 0xecfa: 0xecf7, - 0xecfb: 0xecf8, - 0xecfc: 0xecfa, - 0xecfd: 0xecfc, - 0xecfe: 0xecfd, - 0xecff: 0xecfe, - 0xed00: 0xecff, - 0xed01: 0xed00, - 0xed02: 0xed01, - 0xed03: 0xed02, - 0xed04: 0xed03, - 0xed09: 0xed0a, - 0xed0a: 0xed0b, - 0xed0b: 0xed0c, - 0xed0c: 0xed0d, - 0xed0d: 0xed0e, - 0xed0e: 0xed10, - 0xed0f: 0xed12, - 0xed10: 0xed13, - 0xed11: 0xed14, - 0xed12: 0xed15, - 0xed13: 0xed16, - 0xed14: 0xed18, - 0xed15: 0xed19, - 0xed16: 0xed1a, - 0xed17: 0xed1d, - 0xed18: 0xed1e, - 0xed19: 0xed1f, - 0xed1a: 0xed20, - 0xed1b: 0xed21, - 0xed1c: 0xed22, - 0xed1d: 0xed23, - 0xed1e: 0xed24, - 0xed1f: 0xed25, - 0xed20: 0xed26, - 0xed21: 0xed27, - 0xed22: 0xed28, - 0xed23: 0xed29, - 0xed24: 0xed2a, - 0xed25: 0xed2b, - 0xed26: 0xed2c, - 0xed27: 0xed2d, - 0xed28: 0xed2e, - 0xed29: 0xed2f, - 0xed2a: 0xed30, - 0xed2b: 0xed31, - 0xed2c: 0xed32, - 0xed2d: 0xed33, - 0xed2e: 0xed34, - 0xed2f: 0xed35, - 0xed30: 0xed36, - 0xed31: 0xed37, - 0xed32: 0xed38, - 0xed33: 0xed39, - 0xed34: 0xed3a, - 0xed35: 0xed3b, - 0xed36: 0xed3c, - 0xed37: 0xed3d, - 0xed38: 0xed3e, - 0xed39: 0xed3f, - 0xed3a: 0xed40, - 0xed3b: 0xed41, - 0xed3c: 0xed42, - 0xed3d: 0xed43, - 0xed3e: 0xed44, - 0xed3f: 0xed45, - 0xed40: 0xed46, - 0xed41: 0xed47, - 0xed42: 0xed48, - 0xed43: 0xed49, - 0xed44: 0xed4a, - 0xed45: 0xed4b, - 0xed46: 0xed4c, - 0xed47: 0xed4d, - 0xed48: 0xed4e, - 0xed49: 0xed4f, - 0xed4a: 0xed50, - 0xed4b: 0xed51, - 0xed4c: 0xed52, - 0xed4d: 0xed54, - 0xed4e: 0xed55, - 0xed4f: 0xed56, - 0xed50: 0xed57, - 0xed51: 0xed58, - 0xed52: 0xed59, - 0xed53: 0xed5a, - 0xed54: 0xed5b, - 0xed55: 0xed5c, - 0xed56: 0xed5d, - 0xed57: 0xed5e, - 0xed58: 0xed5f, - 0xed59: 0xed60, - 0xed5a: 0xed61, - 0xed5b: 0xed62, - 0xed5c: 0xed63, - 0xed5d: 0xed64, - 0xed5e: 0xed65, - 0xed5f: 0xed66, - 0xed60: 0xed67, - 0xed61: 0xed68, - 0xed62: 0xed6a, - 0xed63: 0xed6b, - 0xed64: 0xed6c, - 0xed65: 0xed6d, - 0xed66: 0xed6e, - 0xed67: 0xed6f, - 0xed68: 0xed70, - 0xed69: 0xed71, - 0xed6b: 0xed72, - 0xed6d: 0xed73, - 0xed6e: 0xed76, - 0xed6f: 0xed77, - 0xed70: 0xed78, - 0xed71: 0xed79, - 0xed72: 0xed7a, - 0xed73: 0xed7b, - 0xed74: 0xed7c, - 0xed75: 0xed7d, - 0xed76: 0xed7e, - 0xed77: 0xed7f, - 0xed78: 0xed80, - 0xed79: 0xed81, - 0xed7a: 0xed82, - 0xed7b: 0xed83, - 0xed7c: 0xed84, - 0xed7d: 0xed85, - 0xed7e: 0xed86, - 0xed7f: 0xed87, - 0xed80: 0xed88, - 0xed81: 0xed89, - 0xed82: 0xed8a, - 0xed83: 0xed8b, - 0xed84: 0xed8c, - 0xed85: 0xed8d, - 0xed86: 0xed8e, - 0xed87: 0xed8f, - 0xed88: 0xed90, - 0xed8a: 0xed91, - 0xed8b: 0xed93, - 0xed8c: 0xed94, - 0xed8d: 0xed95, - 0xed8e: 0xed96, - 0xed8f: 0xed97, - 0xed90: 0xed98, - 0xed91: 0xed99, - 0xed92: 0xed9a, - 0xed93: 0xed9b, - 0xed94: 0xed9c, - 0xed95: 0xed9d, - 0xed96: 0xed9e, - 0xed97: 0xed9f, - 0xed99: 0xeda0, - 0xed9a: 0xeda1, - 0xed9b: 0xeda2, - 0xed9c: 0xeda3, - 0xed9d: 0xeda4, - 0xed9e: 0xeda5, - 0xed9f: 0xeda6, - 0xeda0: 0xeda7, - 0xeda1: 0xeda8, - 0xeda2: 0xedaa, - 0xeda3: 0xedab, - 0xeda4: 0xedac, - 0xeda5: 0xedad, - 0xeda6: 0xedae, - 0xeda7: 0xedaf, - 0xeda8: 0xedb0, - 0xeda9: 0xedb1, - 0xedaa: 0xedb2, - 0xedab: 0xedb3, - 0xedac: 0xedb4, - 0xedad: 0xedb5, - 0xedae: 0xedb6, - 0xedaf: 0xedb7, - 0xedb0: 0xedb8, - 0xedb1: 0xedb9, - 0xedb2: 0xedba, - 0xedb3: 0xedbb, - 0xedb4: 0xedbc, - 0xedb5: 0xedbd, - 0xedb6: 0xedbe, - 0xedb7: 0xedbf, - 0xedb8: 0xedc0, - 0xedb9: 0xedc1, - 0xedba: 0xedc2, - 0xedbb: 0xedc3, - 0xedbc: 0xedc4, - 0xedbd: 0xedc5, - 0xedbe: 0xedc6, - 0xedbf: 0xedc7, - 0xedc0: 0xedc8, - 0xedc1: 0xedc9, - 0xedc2: 0xedca, - 0xedc3: 0xedcb, - 0xedc4: 0xedcc, - 0xedc5: 0xedcd, - 0xedc6: 0xedce, - 0xedc7: 0xedcf, - 0xedc8: 0xedd1, - 0xedc9: 0xedd2, - 0xedca: 0xedd3, - 0xedcb: 0xedd4, - 0xedcc: 0xedd5, - 0xedcd: 0xedd6, - 0xedce: 0xedd7, - 0xedcf: 0xedd8, - 0xedd0: 0xedd9, - 0xedd1: 0xedda, - 0xedd2: 0xeddc, - 0xedd3: 0xeddd, - 0xedd4: 0xedde, - 0xedd5: 0xeddf, - 0xedd6: 0xede0, - 0xedd7: 0xede1, - 0xedd8: 0xede2, - 0xedd9: 0xede3, - 0xedda: 0xede4, - 0xeddb: 0xede5, - 0xeddc: 0xede6, - 0xeddd: 0xede7, - 0xedde: 0xede8, - 0xede0: 0xede9, - 0xede1: 0xedea, - 0xede2: 0xeded, - 0xede3: 0xedee, - 0xede4: 0xedef, - 0xede5: 0xedf0, - 0xede6: 0xedf1, - 0xede7: 0xedf3, - 0xede8: 0xedf4, - 0xede9: 0xedf5, - 0xedea: 0xedf6, - 0xedeb: 0xedf7, - 0xedec: 0xedf9, - 0xeded: 0xedfa, - 0xedee: 0xedfb, - 0xedef: 0xedfc, - 0xedf0: 0xedfd, - 0xedf1: 0xedfe, - 0xedf2: 0xedff, - 0xedf3: 0xee00, - 0xedf4: 0xee02, - 0xedf5: 0xee03, - 0xedf6: 0xee04, - 0xedf7: 0xee05, - 0xedf8: 0xee07, - 0xedf9: 0xee08, - 0xedfa: 0xee09, - 0xedfb: 0xee0a, - 0xedfc: 0xee0b, - 0xedfd: 0xee0c, - 0xedfe: 0xee0d, - 0xedff: 0xee0e, - 0xee00: 0xee0f, - 0xee01: 0xee10, - 0xee02: 0xee11, - 0xee03: 0xee12, - 0xee04: 0xee13, - 0xee05: 0xee14, - 0xee06: 0xee15, - 0xee07: 0xee16, - 0xee08: 0xee17, - 0xee09: 0xee18, - 0xee0a: 0xee19, - 0xee0b: 0xee1a, - 0xee0c: 0xee1b, - 0xee0d: 0xee1c, - 0xee0e: 0xee1d, - 0xee0f: 0xee1f, - 0xee10: 0xee23, - 0xee11: 0xee24, - 0xee12: 0xee25, - 0xee13: 0xee26, - 0xee14: 0xee27, - 0xee15: 0xee28, - 0xee16: 0xee29, - 0xee17: 0xee2a, - 0xee18: 0xee2b, - 0xee19: 0xee2c, - 0xee1a: 0xee2d, - 0xee1b: 0xee2e, - 0xee1c: 0xee2f, - 0xee1d: 0xee30, - 0xee1e: 0xee31, - 0xee1f: 0xee32, - 0xee20: 0xee33, - 0xee21: 0xee34, - 0xee22: 0xee35, - 0xee23: 0xee36, - 0xee24: 0xee37, - 0xee25: 0xee38, - 0xee26: 0xee3a, - 0xee27: 0xee3c, - 0xee28: 0xee3d, - 0xee29: 0xee3e, - 0xee2a: 0xee3f, - 0xee2b: 0xee40, - 0xee2c: 0xee41, - 0xee2d: 0xee42, - 0xee2e: 0xee43, - 0xee2f: 0xee44, - 0xee30: 0xee45, - 0xee31: 0xee46, - 0xee32: 0xee47, - 0xee33: 0xee48, - 0xee34: 0xee49, - 0xee35: 0xee4a, - 0xee36: 0xee4b, - 0xee37: 0xee4e, - 0xee38: 0xee4f, - 0xee39: 0xee50, - 0xee3a: 0xee51, - 0xee3b: 0xee52, - 0xee3c: 0xee53, - 0xee3d: 0xee54, - 0xee3e: 0xee55, - 0xee3f: 0xee57, - 0xee40: 0xee58, - 0xee41: 0xee59, - 0xee42: 0xee5a, - 0xee43: 0xee5b, - 0xee44: 0xee5c, - 0xee45: 0xee5d, - 0xee46: 0xee5e, - 0xee47: 0xee5f, - 0xee48: 0xee60, - 0xee49: 0xee61, - 0xee4a: 0xee62, - 0xee4b: 0xee63, - 0xee4c: 0xee64, - 0xee4d: 0xee66, - 0xee4e: 0xee68, - 0xee4f: 0xee69, - 0xee50: 0xee6a, - 0xee51: 0xee6b, - 0xee52: 0xee6c, - 0xee53: 0xee6d, - 0xee54: 0xee6e, - 0xee55: 0xee6f, - 0xee56: 0xee70, - 0xee57: 0xee71, - 0xee58: 0xee72, - 0xee59: 0xee73, - 0xee5a: 0xee74, - 0xee5b: 0xee75, - 0xee5c: 0xee76, - 0xee5d: 0xee77, - 0xee5e: 0xee78, - 0xee5f: 0xee79, - 0xee60: 0xee7a, - 0xee61: 0xee7b, - 0xee62: 0xee7c, - 0xee63: 0xee7d, - 0xee64: 0xee7e, - 0xee66: 0xee7f, - 0xee67: 0xee80, - 0xee68: 0xee81, - 0xee69: 0xee82, - 0xee6a: 0xee83, - 0xee6b: 0xee84, - 0xee6c: 0xee85, - 0xee6d: 0xee86, - 0xee6e: 0xee87, - 0xee6f: 0xee88, - 0xee70: 0xee89, - 0xee71: 0xee8a, - 0xee72: 0xee8b, - 0xee73: 0xee8c, - 0xee74: 0xee8d, - 0xee75: 0xee8f, - 0xee76: 0xee90, - 0xee77: 0xee91, - 0xee78: 0xee92, - 0xee79: 0xee93, - 0xee7a: 0xee94, - 0xee7b: 0xee95, - 0xee7c: 0xee96, - 0xee7d: 0xee97, - 0xee7e: 0xee98, - 0xee80: 0xee99, - 0xee81: 0xee9a, - 0xee82: 0xee9b, - 0xee83: 0xee9c, - 0xee84: 0xee9d, - 0xee85: 0xee9e, - 0xee86: 0xee9f, - 0xee88: 0xeea0, - 0xee89: 0xeea1, - 0xee8a: 0xeea2, - 0xee8b: 0xeea3, - 0xee8c: 0xeea5, - 0xee8d: 0xeea6, - 0xee8e: 0xeea7, - 0xee8f: 0xeea8, - 0xee90: 0xeea9, - 0xee91: 0xeeaa, - 0xee92: 0xeeab, - 0xee93: 0xeeac, - 0xee94: 0xeead, - 0xee95: 0xeeae, - 0xee96: 0xeeaf, - 0xee97: 0xeeb0, - 0xee98: 0xeeb1, - 0xee99: 0xeeb2, - 0xee9a: 0xeeb3, - 0xee9b: 0xeeb4, - 0xee9c: 0xeeb5, - 0xee9d: 0xeeb7, - 0xee9e: 0xeeb8, - 0xee9f: 0xeeb9, - 0xeea0: 0xeebb, - 0xeea1: 0xeebd, - 0xeea2: 0xeebe, - 0xeea3: 0xeebf, - 0xeea4: 0xeec0, - 0xeea5: 0xeec1, - 0xeea6: 0xeec2, - 0xeea7: 0xeec3, - 0xeea8: 0xeec4, - 0xeea9: 0xeec5, - 0xeeaa: 0xeec7, - 0xeeab: 0xeec8, - 0xeeac: 0xeec9, - 0xeead: 0xeeca, - 0xeeae: 0xeecb, - 0xeeaf: 0xeecc, - 0xeeb0: 0xeecd, - 0xeeb1: 0xeece, - 0xeeb2: 0xeed0, - 0xeeb3: 0xeed1, - 0xeeb4: 0xeed2, - 0xeeb5: 0xeed3, - 0xeeb6: 0xeed4, - 0xeeb8: 0xeed5, - 0xeeb9: 0xeed7, - 0xeeba: 0xeed9, - 0xeebb: 0xeeda, - 0xeebc: 0xeedb, - 0xeebd: 0xeedc, - 0xeebe: 0xeedd, - 0xeebf: 0xeede, - 0xeec0: 0xeedf, - 0xeec1: 0xeee0, - 0xeec2: 0xeee1, - 0xeec3: 0xeee2, - 0xeec4: 0xeee3, - 0xeec5: 0xeee4, - 0xeec6: 0xeee5, - 0xeec7: 0xeee6, - 0xeec8: 0xeee8, - 0xeec9: 0xeee9, - 0xeeca: 0xeeea, - 0xeecb: 0xeeeb, - 0xeecc: 0xeeec, - 0xeecd: 0xeeed, - 0xeece: 0xeeee, - 0xeecf: 0xeeef, - 0xeed0: 0xeef0, - 0xeed1: 0xeef2, - 0xeed2: 0xeef3, - 0xeed3: 0xeef4, - 0xeed4: 0xeef5, - 0xeed5: 0xeef6, - 0xeed6: 0xeef7, - 0xeed7: 0xeef8, - 0xeed8: 0xeef9, - 0xeed9: 0xeefb, - 0xeeda: 0xeefc, - 0xeedb: 0xeefd, - 0xeedc: 0xeefe, - 0xeedd: 0xeeff, - 0xeede: 0xef00, - 0xeedf: 0xef02, - 0xeee0: 0xef04, - 0xeee1: 0xef06, - 0xeee2: 0xef07, - 0xeee3: 0xef08, - 0xeee4: 0xef09, - 0xeee5: 0xef0a, - 0xeee6: 0xef0b, - 0xeee7: 0xef0c, - 0xeee8: 0xef0d, - 0xeee9: 0xef0f, - 0xeeea: 0xef11, - 0xeeeb: 0xef12, - 0xeeec: 0xef13, - 0xeeed: 0xef14, - 0xeeee: 0xef15, - 0xeeef: 0xef16, - 0xeef0: 0xef17, - 0xeef1: 0xef18, - 0xeef2: 0xef19, - 0xeef4: 0xef1c, - 0xeef5: 0xef1d, - 0xeef6: 0xef1e, - 0xeef7: 0xef1f, - 0xeef8: 0xef20, - 0xeef9: 0xef21, - 0xeefa: 0xef22, - 0xeefb: 0xef23, - 0xeefc: 0xef24, - 0xeefd: 0xef25, - 0xeefe: 0xef26, - 0xeeff: 0xef27, - 0xef00: 0xef28, - 0xef01: 0xef29, - 0xef02: 0xef2a, - 0xef03: 0xef2b, - 0xef04: 0xef2c, - 0xef05: 0xef2d, - 0xef06: 0xef2e, - 0xef07: 0xef2f, - 0xef08: 0xef30, - 0xef09: 0xef31, - 0xef0a: 0xef32, - 0xef0b: 0xef33, - 0xef0c: 0xef34, - 0xef0d: 0xef35, - 0xef0e: 0xef36, - 0xef0f: 0xef37, - 0xef10: 0xef38, - 0xef11: 0xef39, - 0xef12: 0xef3a, - 0xef13: 0xef3b, - 0xef14: 0xef3c, - 0xef15: 0xef3d, - 0xef16: 0xef3e, - 0xef17: 0xef3f, - 0xef18: 0xef40, - 0xef19: 0xef41, - 0xef1a: 0xef42, - 0xef1b: 0xef43, - 0xef1c: 0xef44, - 0xef1d: 0xef45, - 0xef1e: 0xef46, - 0xef1f: 0xef47, - 0xef20: 0xef48, - 0xef21: 0xef49, - 0xef22: 0xef4a, - 0xef23: 0xef4b, - 0xef24: 0xef4c, - 0xef25: 0xef4d, - 0xef26: 0xef4e, - 0xef27: 0xef4f, - 0xef28: 0xef50, - 0xef29: 0xef51, - 0xef2a: 0xef52, - 0xef2b: 0xef53, - 0xef2c: 0xef54, - 0xef2d: 0xef55, - 0xef2e: 0xef56, - 0xef2f: 0xef57, - 0xef30: 0xef58, - 0xef31: 0xef59, - 0xef32: 0xef5a, - 0xef33: 0xef5b, - 0xef34: 0xef5c, - 0xef35: 0xef5d, - 0xef36: 0xef5e, - 0xef37: 0xef5f, - 0xef38: 0xef60, - 0xef39: 0xef61, - 0xef3a: 0xef62, - 0xef3b: 0xef63, - 0xef3c: 0xef64, - 0xef3d: 0xef65, - 0xef3e: 0xef66, - 0xef3f: 0xef67, - 0xef40: 0xef68, - 0xef41: 0xef69, - 0xef42: 0xef6a, - 0xef43: 0xef6b, - 0xef44: 0xef6c, - 0xef45: 0xef6d, - 0xef46: 0xef6e, - 0xef47: 0xef6f, - 0xef48: 0xef72, - 0xef49: 0xef73, - 0xef4a: 0xef74, - 0xef4b: 0xef75, - 0xef4c: 0xef76, - 0xef4d: 0xef77, - 0xef4e: 0xef79, - 0xef4f: 0xef7a, - 0xef50: 0xef7b, - 0xef51: 0xef7c, - 0xef52: 0xef7d, - 0xef53: 0xef7e, - 0xef54: 0xef80, - 0xef55: 0xef81, - 0xef56: 0xef82, - 0xef57: 0xef83, - 0xef58: 0xef85, - 0xef59: 0xef86, - 0xef5a: 0xef87, - 0xef5c: 0xef88, - 0xef5d: 0xef89, - 0xef5e: 0xef8b, - 0xef5f: 0xef8c, - 0xef60: 0xef8d, - 0xef61: 0xef8e, - 0xef62: 0xef90, - 0xef63: 0xef91, - 0xef64: 0xef92, - 0xef65: 0xef93, - 0xef66: 0xef94, - 0xef67: 0xef95, - 0xef68: 0xef96, - 0xef69: 0xef97, - 0xef6a: 0xef98, - 0xef6b: 0xef99, - 0xef6c: 0xef9a, - 0xef6d: 0xef9b, - 0xef6e: 0xef9c, - 0xef6f: 0xef9d, - 0xef70: 0xef9e, - 0xef71: 0xef9f, - 0xef72: 0xefa0, - 0xef73: 0xefa1, - 0xef74: 0xefa2, - 0xef75: 0xefa3, - 0xef77: 0xefa5, - 0xef78: 0xefa6, - 0xef79: 0xefa7, - 0xef7a: 0xefa8, - 0xef7b: 0xefa9, - 0xef7c: 0xefaa, - 0xef7d: 0xefab, - 0xef7e: 0xefac, - 0xef7f: 0xefad, - 0xef80: 0xefae, - 0xef81: 0xefb0, - 0xef82: 0xefb1, - 0xef83: 0xefb2, - 0xef84: 0xefb3, - 0xef85: 0xefb4, - 0xef86: 0xefb5, - 0xef87: 0xefb6, - 0xef88: 0xefb7, - 0xef89: 0xefb8, - 0xef8c: 0xefb9, - 0xef8d: 0xefba, - 0xef8e: 0xefbb, - 0xef8f: 0xefbc, - 0xef90: 0xefbd, - 0xef91: 0xefbe, - 0xef92: 0xefbf, - 0xef93: 0xefc1, - 0xef94: 0xefc2, - 0xef95: 0xefc3, - 0xef96: 0xefc4, - 0xef97: 0xefc5, - 0xef98: 0xefc6, - 0xef99: 0xefc7, - 0xef9a: 0xefc8, - 0xef9b: 0xefc9, - 0xef9c: 0xefca, - 0xef9d: 0xefcb, - 0xef9e: 0xefcc, - 0xef9f: 0xefcd, - 0xefa0: 0xefce, - 0xefa1: 0xefcf, - 0xefa2: 0xefd0, - 0xefa3: 0xefd1, - 0xefa4: 0xefd2, - 0xefa5: 0xefd3, - 0xefa6: 0xefd4, - 0xefa7: 0xefd5, - 0xefa8: 0xefd6, - 0xefa9: 0xefd7, - 0xefaa: 0xefd8, - 0xefab: 0xefd9, - 0xefac: 0xefda, - 0xefad: 0xefdb, - 0xefae: 0xefdc, - 0xefaf: 0xefdd, - 0xefb0: 0xefde, - 0xefb1: 0xefdf, - 0xefb2: 0xefe0, - 0xefb3: 0xefe1, - 0xefb4: 0xefe2, - 0xefb5: 0xefe3, - 0xefb6: 0xefe4, - 0xefb7: 0xefe5, - 0xefb8: 0xefe6, - 0xefb9: 0xefe7, - 0xefba: 0xefe8, - 0xefbb: 0xefea, - 0xefbc: 0xefeb, - 0xefbd: 0xefec, - 0xefbe: 0xefed, - 0xefbf: 0xefee, - 0xefc0: 0xefef, - 0xefc1: 0xeff0, - 0xefc2: 0xeff1, - 0xefc3: 0xeff2, - 0xefc4: 0xeff3, - 0xefc5: 0xeff4, - 0xefc6: 0xeff5, - 0xefc7: 0xeff7, - 0xefc8: 0xeff9, - 0xefc9: 0xeffa, - 0xefca: 0xeffb, - 0xefcb: 0xeffc, - 0xefcc: 0xeffd, - 0xefcd: 0xeffe, - 0xefce: 0xefff, - 0xefcf: 0xf001, - 0xefd0: 0xf002, - 0xefd1: 0xf003, - 0xefd2: 0xf004, - 0xefd3: 0xf005, - 0xefd4: 0xf006, - 0xefd5: 0xf007, - 0xefd6: 0xf008, - 0xefd7: 0xf00a, - 0xefd8: 0xf00c, - 0xefd9: 0xf00d, - 0xefda: 0xf00e, - 0xefdb: 0xf00f, - 0xefdc: 0xf010, - 0xefdd: 0xf011, - 0xefde: 0xf012, - 0xefdf: 0xf013, - 0xefe0: 0xf014, - 0xefe1: 0xf015, - 0xefe2: 0xf016, - 0xefe3: 0xf017, - 0xefe4: 0xf018, - 0xefe5: 0xf019, - 0xefe6: 0xf01a, - 0xefe7: 0xf01b, - 0xefe8: 0xf01c, - 0xefe9: 0xf01d, - 0xefea: 0xf01e, - 0xefeb: 0xf01f, - 0xefec: 0xf021, - 0xefed: 0xf022, - 0xefee: 0xf023, - 0xefef: 0xf024, - 0xeff0: 0xf025, - 0xeff1: 0xf026, - 0xeff2: 0xf027, - 0xeff3: 0xf028, - 0xeff4: 0xf029, - 0xeff5: 0xf02a, - 0xeff6: 0xf02b, - 0xeff7: 0xf02c, - 0xeff8: 0xf02d, - 0xeff9: 0xf02e, - 0xeffa: 0xf02f, - 0xeffb: 0xf031, - 0xeffc: 0xf032, - 0xeffd: 0xf033, - 0xeffe: 0xf034, - 0xefff: 0xf035, - 0xf000: 0xf036, - 0xf001: 0xf037, - 0xf002: 0xf039, - 0xf003: 0xf03a, - 0xf004: 0xf03c, - 0xf005: 0xf03d, - 0xf006: 0xf03e, - 0xf007: 0xf03f, - 0xf008: 0xf040, - 0xf009: 0xf042, - 0xf00a: 0xf043, - 0xf00b: 0xf044, - 0xf00c: 0xf045, - 0xf00d: 0xf046, - 0xf00e: 0xf047, - 0xf00f: 0xf048, - 0xf010: 0xf049, - 0xf011: 0xf04a, - 0xf012: 0xf04c, - 0xf013: 0xf04d, - 0xf014: 0xf04e, - 0xf016: 0xf04f, - 0xf017: 0xf051, - 0xf018: 0xf052, - 0xf019: 0xf053, - 0xf01a: 0xf054, - 0xf01b: 0xf055, - 0xf01c: 0xf056, - 0xf01d: 0xf057, - 0xf01e: 0xf058, - 0xf01f: 0xf059, - 0xf020: 0xf05a, - 0xf021: 0xf05b, - 0xf022: 0xf05c, - 0xf023: 0xf05d, - 0xf024: 0xf05e, - 0xf025: 0xf05f, - 0xf026: 0xf060, - 0xf027: 0xf061, - 0xf028: 0xf062, - 0xf029: 0xf063, - 0xf02a: 0xf064, - 0xf02b: 0xf065, - 0xf02c: 0xf066, - 0xf02d: 0xf067, - 0xf02e: 0xf068, - 0xf02f: 0xf069, - 0xf030: 0xf06a, - 0xf031: 0xf06b, - 0xf032: 0xf06c, - 0xf033: 0xf06d, - 0xf034: 0xf06e, - 0xf035: 0xf06f, - 0xf036: 0xf070, - 0xf037: 0xf071, - 0xf038: 0xf072, - 0xf039: 0xf073, - 0xf03a: 0xf074, - 0xf03b: 0xf075, - 0xf03c: 0xf076, - 0xf03d: 0xf077, - 0xf03e: 0xf078, - 0xf03f: 0xf079, - 0xf040: 0xf07a, - 0xf041: 0xf07b, - 0xf042: 0xf07c, - 0xf043: 0xf07d, - 0xf044: 0xf07e, - 0xf045: 0xf07f, - 0xf046: 0xf080, - 0xf047: 0xf081, - 0xf048: 0xf082, - 0xf049: 0xf083, - 0xf04a: 0xf084, - 0xf04b: 0xf085, - 0xf04c: 0xf086, - 0xf04d: 0xf087, - 0xf04e: 0xf088, - 0xf04f: 0xf089, - 0xf050: 0xf08a, - 0xf051: 0xf08b, - 0xf052: 0xf08c, - 0xf053: 0xf08d, - 0xf054: 0xf08e, - 0xf055: 0xf08f, - 0xf056: 0xf090, - 0xf057: 0xf091, - 0xf058: 0xf092, - 0xf059: 0xf094, - 0xf05a: 0xf095, - 0xf05b: 0xf096, - 0xf05c: 0xf097, - 0xf05d: 0xf098, - 0xf05e: 0xf099, - 0xf05f: 0xf09a, - 0xf060: 0xf09b, - 0xf061: 0xf09c, - 0xf062: 0xf09d, - 0xf063: 0xf09f, - 0xf064: 0xf0a0, - 0xf065: 0xf0a1, - 0xf066: 0xf0a2, - 0xf067: 0xf0a3, - 0xf068: 0xf0a4, - 0xf069: 0xf0a5, - 0xf06a: 0xf0a7, - 0xf06b: 0xf0a8, - 0xf06c: 0xf0a9, - 0xf06d: 0xf0aa, - 0xf06e: 0xf0ab, - 0xf06f: 0xf0af, - 0xf072: 0xf0b0, - 0xf073: 0xf0b1, - 0xf074: 0xf0b2, - 0xf075: 0xf0b4, - 0xf076: 0xf0b5, - 0xf077: 0xf0b6, - 0xf078: 0xf0b7, - 0xf079: 0xf0b9, - 0xf07a: 0xf0ba, - 0xf07b: 0xf0bb, - 0xf07c: 0xf0bc, - 0xf07d: 0xf0bd, - 0xf07e: 0xf0be, - 0xf07f: 0xf0c1, - 0xf080: 0xf0c2, - 0xf081: 0xf0c3, - 0xf082: 0xf0c4, - 0xf083: 0xf0c5, - 0xf084: 0xf0c6, - 0xf085: 0xf0c7, - 0xf086: 0xf0c8, - 0xf087: 0xf0ca, - 0xf088: 0xf0cb, - 0xf089: 0xf0cc, - 0xf08a: 0xf0cd, - 0xf08b: 0xf0ce, - 0xf08c: 0xf0cf, - 0xf08d: 0xf0d0, - 0xf08e: 0xf0d2, - 0xf08f: 0xf0d3, - 0xf090: 0xf0d4, - 0xf091: 0xf0d5, - 0xf092: 0xf0d6, - 0xf093: 0xf0d7, - 0xf094: 0xf0d8, - 0xf096: 0xf0da, - 0xf097: 0xf0db, - 0xf098: 0xf0dc, - 0xf099: 0xf0dd, - 0xf09a: 0xf0de, - 0xf09b: 0xf0df, - 0xf09c: 0xf0e0, - 0xf09d: 0xf0e1, - 0xf09e: 0xf0e2, - 0xf09f: 0xf0e3, - 0xf0a0: 0xf0e4, - 0xf0a1: 0xf0e5, - 0xf0a2: 0xf0e6, - 0xf0a3: 0xf0e9, - 0xf0a4: 0xf0ea, - 0xf0a5: 0xf0eb, - 0xf0a6: 0xf0ec, - 0xf0a7: 0xf0ed, - 0xf0a8: 0xf0ee, - 0xf0a9: 0xf0ef, - 0xf0aa: 0xf0f0, - 0xf0ab: 0xf0f1, - 0xf0ac: 0xf0f2, - 0xf0ad: 0xf0f3, - 0xf0ae: 0xf0f4, - 0xf0af: 0xf0f5, - 0xf0b0: 0xf0f6, - 0xf0b1: 0xf0f7, - 0xf0b2: 0xf0f8, - 0xf0b3: 0xf0f9, - 0xf0b4: 0xf0fa, - 0xf0b5: 0xf0fb, - 0xf0b6: 0xf0fc, - 0xf0b7: 0xf0fd, - 0xf0b8: 0xf0fe, - 0xf0b9: 0xf0ff, - 0xf0ba: 0xf100, - 0xf0bb: 0xf101, - 0xf0bc: 0xf102, - 0xf0bd: 0xf103, - 0xf0be: 0xf104, - 0xf0bf: 0xf105, - 0xf0c0: 0xf106, - 0xf0c1: 0xf107, - 0xf0c2: 0xf108, - 0xf0c3: 0xf109, - 0xf0c4: 0xf10a, - 0xf0c5: 0xf10c, - 0xf0c6: 0xf10d, - 0xf0c7: 0xf10e, - 0xf0c8: 0xf10f, - 0xf0c9: 0xf110, - 0xf0ca: 0xf111, - 0xf0cb: 0xf112, - 0xf0cc: 0xf113, - 0xf0cd: 0xf114, - 0xf0ce: 0xf115, - 0xf0cf: 0xf116, - 0xf0d0: 0xf117, - 0xf0d1: 0xf118, - 0xf0d2: 0xf119, - 0xf0d3: 0xf11a, - 0xf0d4: 0xf11b, - 0xf0d5: 0xf11c, - 0xf0d6: 0xf11d, - 0xf0d7: 0xf11e, - 0xf0d9: 0xf11f, - 0xf0da: 0xf120, - 0xf0db: 0xf121, - 0xf0dc: 0xf122, - 0xf0dd: 0xf123, - 0xf0de: 0xf124, - 0xf0df: 0xf125, - 0xf0e0: 0xf126, - 0xf0e1: 0xf128, - 0xf0e2: 0xf129, - 0xf0e3: 0xf12a, - 0xf0e4: 0xf12b, - 0xf0e5: 0xf12c, - 0xf0e6: 0xf12d, - 0xf0e7: 0xf12e, - 0xf0e8: 0xf130, - 0xf0e9: 0xf131, - 0xf0ea: 0xf132, - 0xf0eb: 0xf133, - 0xf0ec: 0xf134, - 0xf0ed: 0xf135, - 0xf0ee: 0xf136, - 0xf0ef: 0xf137, - 0xf0f0: 0xf138, - 0xf0f1: 0xf13a, - 0xf0f2: 0xf13b, - 0xf0f3: 0xf13c, - 0xf0f4: 0xf13d, - 0xf0f5: 0xf13e, - 0xf0f7: 0xf13f, - 0xf0f8: 0xf140, - 0xf0f9: 0xf142, - 0xf0fa: 0xf143, - 0xf0fb: 0xf144, - 0xf0fc: 0xf145, - 0xf0fd: 0xf146, - 0xf0fe: 0xf147, - 0xf0ff: 0xf149, - 0xf100: 0xf14a, - 0xf101: 0xf14b, - 0xf102: 0xf14c, - 0xf103: 0xf14d, - 0xf104: 0xf14e, - 0xf105: 0xf14f, - 0xf106: 0xf150, - 0xf107: 0xf151, - 0xf108: 0xf152, - 0xf109: 0xf153, - 0xf10a: 0xf156, - 0xf10b: 0xf157, - 0xf10c: 0xf158, - 0xf10d: 0xf159, - 0xf10e: 0xf15a, - 0xf10f: 0xf15b, - 0xf110: 0xf15c, - 0xf111: 0xf15d, - 0xf112: 0xf15e, - 0xf113: 0xf15f, - 0xf114: 0xf160, - 0xf115: 0xf161, - 0xf116: 0xf162, - 0xf117: 0xf163, - 0xf118: 0xf164, - 0xf119: 0xf165, - 0xf11a: 0xf166, - 0xf11b: 0xf167, - 0xf11c: 0xf168, - 0xf11d: 0xf169, - 0xf11e: 0xf16a, - 0xf11f: 0xf16c, - 0xf120: 0xf16d, - 0xf121: 0xf16e, - 0xf122: 0xf16f, - 0xf123: 0xf170, - 0xf124: 0xf171, - 0xf125: 0xf172, - 0xf126: 0xf173, - 0xf127: 0xf174, - 0xf128: 0xf175, - 0xf129: 0xf176, - 0xf12a: 0xf177, - 0xf12b: 0xf178, - 0xf12c: 0xf179, - 0xf12d: 0xf17a, - 0xf12f: 0xf17b, - 0xf130: 0xf17c, - 0xf131: 0xf17d, - 0xf132: 0xf17e, - 0xf133: 0xf17f, - 0xf134: 0xf180, - 0xf136: 0xf181, - 0xf137: 0xf182, - 0xf138: 0xf183, - 0xf139: 0xf184, - 0xf13a: 0xf185, - 0xf13b: 0xf187, - 0xf13c: 0xf188, - 0xf13d: 0xf189, - 0xf13e: 0xf18a, - 0xf13f: 0xf18b, - 0xf140: 0xf18c, - 0xf141: 0xf18d, - 0xf142: 0xf18e, - 0xf143: 0xf18f, - 0xf144: 0xf190, - 0xf145: 0xf191, - 0xf146: 0xf192, - 0xf147: 0xf193, - 0xf148: 0xf194, - 0xf149: 0xf195, - 0xf14a: 0xf196, - 0xf14b: 0xf197, - 0xf14c: 0xf198, - 0xf14d: 0xf199, - 0xf14e: 0xf19a, - 0xf14f: 0xf19b, - 0xf150: 0xf19d, - 0xf151: 0xf19e, - 0xf152: 0xf19f, - 0xf153: 0xf1a0, - 0xf154: 0xf1a2, - 0xf155: 0xf1a4, - 0xf156: 0xf1a5, - 0xf157: 0xf1a6, - 0xf158: 0xf1a7, - 0xf159: 0xf1a8, - 0xf15a: 0xf1a9, - 0xf15b: 0xf1aa, - 0xf15c: 0xf1ab, - 0xf15d: 0xf1ac, - 0xf15e: 0xf1ad, - 0xf15f: 0xf1af, - 0xf160: 0xf1b0, - 0xf161: 0xf1b1, - 0xf162: 0xf1b2, - 0xf163: 0xf1b3, - 0xf164: 0xf1b4, - 0xf165: 0xf1b5, - 0xf166: 0xf1b6, - 0xf167: 0xf1b7, - 0xf168: 0xf1ba, - 0xf169: 0xf1bb, - 0xf16a: 0xf1bd, - 0xf16b: 0xf1be, - 0xf16c: 0xf1bf, - 0xf16d: 0xf1c0, - 0xf16e: 0xf1c1, - 0xf16f: 0xf1c2, - 0xf172: 0xf1c3, - 0xf173: 0xf1c4, - 0xf174: 0xf1c5, - 0xf175: 0xf1c6, - 0xf176: 0xf1c7, - 0xf177: 0xf1c8, - 0xf178: 0xf1c9, - 0xf179: 0xf1cb, - 0xf17a: 0xf1cc, - 0xf17b: 0xf1cd, - 0xf17c: 0xf1ce, - 0xf17d: 0xf1cf, - 0xf17e: 0xf1d1, - 0xf17f: 0xf1d2, - 0xf180: 0xf1d4, - 0xf181: 0xf1d5, - 0xf182: 0xf1d8, - 0xf183: 0xf1d9, - 0xf184: 0xf1da, - 0xf185: 0xf1db, - 0xf186: 0xf1dc, - 0xf187: 0xf1dd, - 0xf188: 0xf1de, - 0xf189: 0xf1df, - 0xf18a: 0xf1e0, - 0xf18b: 0xf1e3, - 0xf18c: 0xf1e4, - 0xf18d: 0xf1e5, - 0xf18e: 0xf1e6, - 0xf18f: 0xf1e7, - 0xf190: 0xf1e8, - 0xf191: 0xf1e9, - 0xf192: 0xf1ea, - 0xf193: 0xf1eb, - 0xf194: 0xf1ec, - 0xf195: 0xf1ed, - 0xf196: 0xf1ee, - 0xf197: 0xf1ef, - 0xf198: 0xf1f0, - 0xf199: 0xf1f1, - 0xf19a: 0xf1f2, - 0xf19b: 0xf1f3, - 0xf19c: 0xf1f4, - 0xf19d: 0xf1f5, - 0xf19e: 0xf1f6, - 0xf19f: 0xf1f7, - 0xf1a0: 0xf1f8, - 0xf1a1: 0xf1f9, - 0xf1a2: 0xf1fa, - 0xf1a3: 0xf1fb, - 0xf1a4: 0xf1fc, - 0xf1a5: 0xf1fd, - 0xf1a6: 0xf1fe, - 0xf1a7: 0xf1ff, - 0xf1a8: 0xf200, - 0xf1a9: 0xf201, - 0xf1aa: 0xf202, - 0xf1ab: 0xf203, - 0xf1ac: 0xf204, - 0xf1ad: 0xf205, - 0xf1ae: 0xf206, - 0xf1af: 0xf207, - 0xf1b0: 0xf208, - 0xf1b1: 0xf209, - 0xf1b2: 0xf20a, - 0xf1b3: 0xf20b, - 0xf1b4: 0xf20d, - 0xf1b5: 0xf20e, - 0xf1b6: 0xf20f, - 0xf1b7: 0xf210, - 0xf1b8: 0xf211, - 0xf1b9: 0xf212, - 0xf1ba: 0xf213, - 0xf1bb: 0xf214, - 0xf1bc: 0xf215, - 0xf1bd: 0xf216, - 0xf1be: 0xf218, - 0xf1bf: 0xf21a, - 0xf1c0: 0xf21b, - 0xf1c1: 0xf21c, - 0xf1c2: 0xf21d, - 0xf1c3: 0xf21e, - 0xf1c4: 0xf21f, - 0xf1c5: 0xf220, - 0xf1c6: 0xf221, - 0xf1c7: 0xf222, - 0xf1c8: 0xf223, - 0xf1c9: 0xf224, - 0xf1ca: 0xf225, - 0xf1cb: 0xf226, - 0xf1cd: 0xf227, - 0xf1ce: 0xf228, - 0xf1cf: 0xf22a, - 0xf1d0: 0xf22d, - 0xf1d1: 0xf22e, - 0xf1d2: 0xf22f, - 0xf1d3: 0xf230, - 0xf1d4: 0xf231, - 0xf1d5: 0xf232, - 0xf1d6: 0xf233, - 0xf1d7: 0xf234, - 0xf1d8: 0xf235, - 0xf1d9: 0xf236, - 0xf1da: 0xf237, - 0xf1db: 0xf238, - 0xf1dc: 0xf23a, - 0xf1dd: 0xf23b, - 0xf1de: 0xf23c, - 0xf1df: 0xf23d, - 0xf1e0: 0xf23e, - 0xf1e1: 0xf23f, - 0xf1e2: 0xf240, - 0xf1e3: 0xf241, - 0xf1e4: 0xf242, - 0xf1e5: 0xf243, - 0xf1e6: 0xf244, - 0xf1e7: 0xf245, - 0xf1e8: 0xf246, - 0xf1e9: 0xf247, - 0xf1ea: 0xf248, - 0xf1eb: 0xf249, - 0xf1ec: 0xf24a, - 0xf1ed: 0xf24b, - 0xf1ee: 0xf24c, - 0xf1ef: 0xf24d, - 0xf1f0: 0xf24e, - 0xf1f1: 0xf24f, - 0xf1f2: 0xf251, - 0xf1f3: 0xf253, - 0xf1f4: 0xf254, - 0xf1f5: 0xf255, - 0xf1f6: 0xf256, - 0xf1f7: 0xf258, - 0xf1f8: 0xf259, - 0xf1f9: 0xf25a, - 0xf1fa: 0xf25b, - 0xf1fb: 0xf25d, - 0xf1fc: 0xf25e, - 0xf1fd: 0xf25f, - 0xf1fe: 0xf260, - 0xf1ff: 0xf261, - 0xf200: 0xf262, - 0xf201: 0xf263, - 0xf202: 0xf264, - 0xf203: 0xf265, - 0xf204: 0xf266, - 0xf205: 0xf268, - 0xf206: 0xf269, - 0xf207: 0xf26a, - 0xf208: 0xf26b, - 0xf209: 0xf26c, - 0xf20a: 0xf26d, - 0xf20b: 0xf26e, - 0xf20c: 0xf26f, - 0xf20d: 0xf270, - 0xf20e: 0xf272, - 0xf20f: 0xf273, - 0xf210: 0xf274, - 0xf211: 0xf275, - 0xf212: 0xf276, - 0xf213: 0xf277, - 0xf214: 0xf278, - 0xf215: 0xf279, - 0xf216: 0xf27a, - 0xf217: 0xf27c, - 0xf218: 0xf27e, - 0xf219: 0xf27f, - 0xf21a: 0xf280, - 0xf21b: 0xf281, - 0xf21c: 0xf282, - 0xf21d: 0xf283, - 0xf21e: 0xf284, - 0xf21f: 0xf285, - 0xf220: 0xf286, - 0xf221: 0xf288, - 0xf222: 0xf289, - 0xf223: 0xf28a, - 0xf224: 0xf28c, - 0xf225: 0xf28d, - 0xf226: 0xf28e, - 0xf227: 0xf28f, - 0xf228: 0xf290, - 0xf229: 0xf292, - 0xf22a: 0xf293, - 0xf22b: 0xf294, - 0xf22c: 0xf295, - 0xf22d: 0xf296, - 0xf22e: 0xf297, - 0xf22f: 0xf298, - 0xf230: 0xf299, - 0xf231: 0xf29a, - 0xf232: 0xf29b, - 0xf233: 0xf29c, - 0xf234: 0xf29d, - 0xf235: 0xf29e, - 0xf236: 0xf29f, - 0xf237: 0xf2a0, - 0xf238: 0xf2a1, - 0xf239: 0xf2a2, - 0xf23a: 0xf2a3, - 0xf23b: 0xf2a4, - 0xf23c: 0xf2a5, - 0xf23d: 0xf2a6, - 0xf23e: 0xf2a7, - 0xf23f: 0xf2a8, - 0xf240: 0xf2a9, - 0xf241: 0xf2aa, - 0xf242: 0xf2ab, - 0xf243: 0xf2ac, - 0xf244: 0xf2ad, - 0xf245: 0xf2ae, - 0xf246: 0xf2af, - 0xf247: 0xf2b0, - 0xf248: 0xf2b1, - 0xf249: 0xf2b2, - 0xf24a: 0xf2b3, - 0xf24b: 0xf2b4, - 0xf24c: 0xf2b5, - 0xf24d: 0xf2b6, - 0xf24e: 0xf2b7, - 0xf24f: 0xf2b8, - 0xf250: 0xf2b9, - 0xf251: 0xf2ba, - 0xf252: 0xf2bb, - 0xf253: 0xf2bc, - 0xf254: 0xf2bd, - 0xf255: 0xf2be, - 0xf256: 0xf2bf, - 0xf257: 0xf2c0, - 0xf258: 0xf2c1, - 0xf259: 0xf2c2, - 0xf25a: 0xf2c3, - 0xf25b: 0xf2c4, - 0xf25c: 0xf2c6, - 0xf25d: 0xf2c7, - 0xf25e: 0xf2c8, - 0xf25f: 0xf2ca, - 0xf260: 0xf2cb, - 0xf261: 0xf2cc, - 0xf262: 0xf2cd, - 0xf263: 0xf2ce, - 0xf264: 0xf2cf, - 0xf265: 0xf2d0, - 0xf266: 0xf2d1, - 0xf267: 0xf2d2, - 0xf268: 0xf2d3, - 0xf269: 0xf2d5, - 0xf26a: 0xf2d6, - 0xf26b: 0xf2d7, - 0xf26c: 0xf2d8, - 0xf26d: 0xf2d9, - 0xf26e: 0xf2da, - 0xf26f: 0xf2db, - 0xf270: 0xf2dc, - 0xf271: 0xf2dd, - 0xf272: 0xf2de, - 0xf273: 0xf2df, - 0xf274: 0xf2e0, - 0xf275: 0xf2e1, - 0xf276: 0xf2e2, - 0xf277: 0xf2e3, - 0xf278: 0xf2e4, - 0xf279: 0xf2e5, - 0xf27a: 0xf2e6, - 0xf27b: 0xf2e7, - 0xf27c: 0xf2e8, - 0xf27d: 0xf2e9, - 0xf27e: 0xf2ea, - 0xf27f: 0xf2eb, - 0xf280: 0xf2ec, - 0xf282: 0xf2ed, - 0xf283: 0xf2ee, - 0xf285: 0xf2ef, - 0xf286: 0xf2f0, - 0xf287: 0xf2f1, - 0xf288: 0xf2f2, - 0xf289: 0xf2f3, - 0xf28a: 0xf2f4, - 0xf28b: 0xf2f5, - 0xf28d: 0xf2f6, - 0xf28e: 0xf2f7, - 0xf28f: 0xf2f8, - 0xf290: 0xf2f9, - 0xf291: 0xf2fa, - 0xf292: 0xf2fb, - 0xf293: 0xf2fc, - 0xf294: 0xf2fe, - 0xf295: 0xf2ff, - 0xf296: 0xf300, - 0xf297: 0xf301, - 0xf298: 0xf303, - 0xf299: 0xf304, - 0xf29a: 0xf305, - 0xf29b: 0xf306, - 0xf29c: 0xf307, - 0xf29d: 0xf308, - 0xf29e: 0xf30a, - 0xf29f: 0xf30b, - 0xf2a0: 0xf30c, - 0xf2a1: 0xf30d, - 0xf2a2: 0xf30e, - 0xf2a3: 0xf30f, - 0xf2a4: 0xf310, - 0xf2a5: 0xf313, - 0xf2a6: 0xf314, - 0xf2a7: 0xf315, - 0xf2a8: 0xf316, - 0xf2a9: 0xf317, - 0xf2aa: 0xf318, - 0xf2ab: 0xf319, - 0xf2ac: 0xf31a, - 0xf2af: 0xf31b, - 0xf2b0: 0xf31c, - 0xf2b1: 0xf31d, - 0xf2b2: 0xf31e, - 0xf2b3: 0xf31f, - 0xf2b4: 0xf320, - 0xf2b5: 0xf321, - 0xf2b6: 0xf322, - 0xf2b7: 0xf323, - 0xf2b8: 0xf324, - 0xf2b9: 0xf325, - 0xf2ba: 0xf326, - 0xf2bb: 0xf328, - 0xf2bc: 0xf329, - 0xf2bd: 0xf32a, - 0xf2be: 0xf32b, - 0xf2bf: 0xf32c, - 0xf2c0: 0xf32d, - 0xf2c1: 0xf32f, - 0xf2c2: 0xf330, - 0xf2c3: 0xf331, - 0xf2c4: 0xf332, - 0xf2c5: 0xf333, - 0xf2c6: 0xf334, - 0xf2c7: 0xf335, - 0xf2c8: 0xf336, - 0xf2c9: 0xf337, - 0xf2ca: 0xf338, - 0xf2cb: 0xf339, - 0xf2cc: 0xf33a, - 0xf2cd: 0xf33b, - 0xf2ce: 0xf33c, - 0xf2cf: 0xf33d, - 0xf2d0: 0xf33e, - 0xf2d1: 0xf33f, - 0xf2d2: 0xf340, - 0xf2d3: 0xf341, - 0xf2d4: 0xf342, - 0xf2d5: 0xf343, - 0xf2d6: 0xf344, - 0xf2d7: 0xf345, - 0xf2d8: 0xf346, - 0xf2d9: 0xf347, - 0xf2da: 0xf348, - 0xf2db: 0xf349, - 0xf2dc: 0xf34a, - 0xf2dd: 0xf34b, - 0xf2de: 0xf34c, - 0xf2df: 0xf34d, - 0xf2e0: 0xf34e, - 0xf2e1: 0xf34f, - 0xf2e2: 0xf350, - 0xf2e3: 0xf351, - 0xf2e4: 0xf352, - 0xf2e5: 0xf353, - 0xf2e6: 0xf354, - 0xf2e7: 0xf355, - 0xf2e8: 0xf356, - 0xf2e9: 0xf357, - 0xf2ea: 0xf358, - 0xf2eb: 0xf359, - 0xf2ec: 0xf35a, - 0xf2ed: 0xf35c, - 0xf2ee: 0xf35d, - 0xf2ef: 0xf35e, - 0xf2f0: 0xf35f, - 0xf2f1: 0xf360, - 0xf2f2: 0xf361, - 0xf2f3: 0xf362, - 0xf2f4: 0xf364, - 0xf2f5: 0xf365, - 0xf2f6: 0xf367, - 0xf2f7: 0xf368, - 0xf2f8: 0xf36a, - 0xf2f9: 0xf36b, - 0xf2fa: 0xf36c, - 0xf2fb: 0xf36d, - 0xf2fc: 0xf36e, - 0xf2fd: 0xf36f, - 0xf2fe: 0xf370, - 0xf2ff: 0xf371, - 0xf300: 0xf372, - 0xf301: 0xf373, - 0xf302: 0xf374, - 0xf303: 0xf375, - 0xf304: 0xf376, - 0xf305: 0xf377, - 0xf307: 0xf378, - 0xf308: 0xf379, - 0xf309: 0xf37a, - 0xf30a: 0xf37c, - 0xf30b: 0xf37d, - 0xf30c: 0xf37e, - 0xf30d: 0xf37f, - 0xf30e: 0xf380, - 0xf30f: 0xf381, - 0xf310: 0xf382, - 0xf311: 0xf383, - 0xf312: 0xf384, - 0xf313: 0xf385, - 0xf314: 0xf387, - 0xf315: 0xf388, - 0xf316: 0xf389, - 0xf317: 0xf38b, - 0xf318: 0xf38d, - 0xf319: 0xf38e, - 0xf31a: 0xf38f, - 0xf31b: 0xf390, - 0xf31c: 0xf391, - 0xf31d: 0xf392, - 0xf31e: 0xf394, - 0xf31f: 0xf395, - 0xf320: 0xf396, - 0xf321: 0xf397, - 0xf322: 0xf398, - 0xf323: 0xf399, - 0xf324: 0xf39a, - 0xf325: 0xf39b, - 0xf326: 0xf39c, - 0xf327: 0xf39d, - 0xf328: 0xf39e, - 0xf329: 0xf39f, - 0xf32a: 0xf3a0, - 0xf32b: 0xf3a1, - 0xf32c: 0xf3a2, - 0xf32d: 0xf3a3, - 0xf32e: 0xf3a4, - 0xf32f: 0xf3a5, - 0xf330: 0xf3a6, - 0xf331: 0xf3a7, - 0xf332: 0xf3a8, - 0xf333: 0xf3a9, - 0xf334: 0xf3aa, - 0xf335: 0xf3ab, - 0xf336: 0xf3ac, - 0xf337: 0xf3ad, - 0xf338: 0xf3ae, - 0xf339: 0xf3af, - 0xf33a: 0xf3b0, - 0xf33b: 0xf3b1, - 0xf33c: 0xf3b2, - 0xf33d: 0xf3b3, - 0xf33e: 0xf3b4, - 0xf33f: 0xf3b5, - 0xf340: 0xf3b6, - 0xf341: 0xf3b7, - 0xf342: 0xf3b8, - 0xf343: 0xf3b9, - 0xf344: 0xf3ba, - 0xf345: 0xf3bb, - 0xf346: 0xf3bc, - 0xf347: 0xf3bf, - 0xf348: 0xf3c0, - 0xf349: 0xf3c1, - 0xf34a: 0xf3c2, - 0xf34b: 0xf3c3, - 0xf34c: 0xf3c4, - 0xf34d: 0xf3c5, - 0xf34e: 0xf3c6, - 0xf34f: 0xf3c7, - 0xf350: 0xf3c8, - 0xf351: 0xf3c9, - 0xf352: 0xf3ca, - 0xf353: 0xf3cb, - 0xf354: 0xf3cc, - 0xf355: 0xf3cd, - 0xf356: 0xf3ce, - 0xf357: 0xf3cf, - 0xf358: 0xf3d0, - 0xf359: 0xf3d1, - 0xf35a: 0xf3d2, - 0xf35b: 0xf3d3, - 0xf35c: 0xf3d5, - 0xf35d: 0xf3d6, - 0xf35e: 0xf3d7, - 0xf35f: 0xf3d8, - 0xf360: 0xf3da, - 0xf361: 0xf3db, - 0xf362: 0xf3dc, - 0xf363: 0xf3dd, - 0xf364: 0xf3de, - 0xf365: 0xf3df, - 0xf366: 0xf3e0, - 0xf367: 0xf3e1, - 0xf368: 0xf3e2, - 0xf369: 0xf3e3, - 0xf36a: 0xf3e4, - 0xf36b: 0xf3e5, - 0xf36c: 0xf3e6, - 0xf36d: 0xf3e7, - 0xf36e: 0xf3e8, - 0xf36f: 0xf3e9, - 0xf370: 0xf3ea, - 0xf371: 0xf3eb, - 0xf372: 0xf3ec, - 0xf373: 0xf3ed, - 0xf374: 0xf3ee, - 0xf375: 0xf3ef, - 0xf376: 0xf3f0, - 0xf377: 0xf3f1, - 0xf378: 0xf3f2, - 0xf379: 0xf3f3, - 0xf37a: 0xf3f4, - 0xf37b: 0xf3f5, - 0xf37c: 0xf3f6, - 0xf37d: 0xf3f7, - 0xf37e: 0xf3f8, - 0xf37f: 0xf3f9, - 0xf380: 0xf3fc, - 0xf381: 0xf3fd, - 0xf382: 0xf3fe, - 0xf383: 0xf3ff, - 0xf384: 0xf401, - 0xf385: 0xf402, - 0xf386: 0xf403, - 0xf387: 0xf404, - 0xf388: 0xf405, - 0xf389: 0xf406, - 0xf38a: 0xf407, - 0xf38b: 0xf408, - 0xf38c: 0xf409, - 0xf38d: 0xf40a, - 0xf38e: 0xf40b, - 0xf38f: 0xf40c, - 0xf390: 0xf40d, - 0xf391: 0xf40e, - 0xf392: 0xf40f, - 0xf393: 0xf410, - 0xf394: 0xf411, - 0xf395: 0xf412, - 0xf396: 0xf413, - 0xf397: 0xf414, - 0xf398: 0xf415, - 0xf399: 0xf416, - 0xf39a: 0xf417, - 0xf39b: 0xf418, - 0xf39c: 0xf419, - 0xf39d: 0xf41a, - 0xf39e: 0xf41b, - 0xf39f: 0xf41c, - 0xf3a0: 0xf41d, - 0xf3a1: 0xf41e, - 0xf3a2: 0xf41f, - 0xf3a3: 0xf421, - 0xf3a4: 0xf422, - 0xf3a5: 0xf423, - 0xf3a6: 0xf424, - 0xf3a7: 0xf425, - 0xf3a8: 0xf426, - 0xf3a9: 0xf427, - 0xf3aa: 0xf429, - 0xf3ab: 0xf42a, - 0xf3ac: 0xf42b, - 0xf3ae: 0xf42c, - 0xf3af: 0xf42d, - 0xf3b0: 0xf42e, - 0xf3b1: 0xf42f, - 0xf3b2: 0xf430, - 0xf3b3: 0xf431, - 0xf3b5: 0xf432, - 0xf3b6: 0xf433, - 0xf3b7: 0xf434, - 0xf3b8: 0xf435, - 0xf3b9: 0xf436, - 0xf3ba: 0xf437, - 0xf3bb: 0xf438, - 0xf3bc: 0xf439, - 0xf3bd: 0xf43a, - 0xf3be: 0xf43b, - 0xf3bf: 0xf43c, - 0xf3c0: 0xf43d, - 0xf3c1: 0xf43e, - 0xf3c2: 0xf43f, - 0xf3c3: 0xf441, - 0xf3c4: 0xf442, - 0xf3c5: 0xf443, - 0xf3c6: 0xf444, - 0xf3c7: 0xf445, - 0xf3c8: 0xf446, - 0xf3c9: 0xf447, - 0xf3ca: 0xf448, - 0xf3cb: 0xf449, - 0xf3cc: 0xf44a, - 0xf3cd: 0xf44b, - 0xf3cf: 0xf44c, - 0xf3d0: 0xf44d, - 0xf3d1: 0xf44e, - 0xf3d2: 0xf44f, - 0xf3d3: 0xf450, - 0xf3d4: 0xf452, - 0xf3d5: 0xf453, - 0xf3d6: 0xf454, - 0xf3d7: 0xf455, - 0xf3d8: 0xf456, - 0xf3d9: 0xf457, - 0xf3da: 0xf458, - 0xf3db: 0xf459, - 0xf3dc: 0xf45a, - 0xf3dd: 0xf45b, - 0xf3de: 0xf45c, - 0xf3df: 0xf45d, - 0xf3e0: 0xf45e, - 0xf3e1: 0xf45f, - 0xf3e2: 0xf460, - 0xf3e3: 0xf461, - 0xf3e4: 0xf462, - 0xf3e5: 0xf463, - 0xf3e7: 0xf464, - 0xf3e8: 0xf465, - 0xf3e9: 0xf466, - 0xf3ea: 0xf467, - 0xf3eb: 0xf468, - 0xf3ec: 0xf46a, - 0xf3ed: 0xf46b, - 0xf3ee: 0xf46c, - 0xf3ef: 0xf46d, - 0xf3f0: 0xf46e, - 0xf3f1: 0xf470, - 0xf3f2: 0xf471, - 0xf3f3: 0xf472, - 0xf3f4: 0xf473, - 0xf3f5: 0xf474, - 0xf3f6: 0xf475, - 0xf3f7: 0xf476, - 0xf3f8: 0xf477, - 0xf3f9: 0xf478, - 0xf3fa: 0xf479, - 0xf3fb: 0xf47a, - 0xf3fc: 0xf47b, - 0xf3fd: 0xf47c, - 0xf3fe: 0xf47d, - 0xf3ff: 0xf47e, - 0xf400: 0xf47f, - 0xf401: 0xf480, - 0xf402: 0xf481, - 0xf403: 0xf482, - 0xf405: 0xf483, - 0xf406: 0xf484, - 0xf407: 0xf485, - 0xf408: 0xf486, - 0xf409: 0xf487, - 0xf40a: 0xf488, - 0xf40b: 0xf489, - 0xf40c: 0xf48a, - 0xf40d: 0xf48b, - 0xf40e: 0xf48c, - 0xf40f: 0xf48e, - 0xf410: 0xf48f, - 0xf411: 0xf490, - 0xf412: 0xf491, - 0xf413: 0xf492, - 0xf414: 0xf493, - 0xf415: 0xf494, - 0xf416: 0xf495, - 0xf417: 0xf496, - 0xf418: 0xf497, - 0xf419: 0xf499, - 0xf41a: 0xf49a, - 0xf41b: 0xf49b, - 0xf41c: 0xf49c, - 0xf41d: 0xf49d, - 0xf41e: 0xf49e, - 0xf41f: 0xf49f, - 0xf421: 0xf4a0, - 0xf422: 0xf4a1, - 0xf423: 0xf4a2, - 0xf424: 0xf4a3, - 0xf425: 0xf4a4, - 0xf426: 0xf4a5, - 0xf427: 0xf4a6, - 0xf428: 0xf4a7, - 0xf429: 0xf4a8, - 0xf42a: 0xf4aa, - 0xf42b: 0xf4ab, - 0xf42c: 0xf4ac, - 0xf42d: 0xf4ad, - 0xf42e: 0xf4ae, - 0xf42f: 0xf4af, - 0xf430: 0xf4b0, - 0xf431: 0xf4b1, - 0xf432: 0xf4b2, - 0xf433: 0xf4b3, - 0xf434: 0xf4b4, - 0xf435: 0xf4b5, - 0xf436: 0xf4b6, - 0xf437: 0xf4b7, - 0xf438: 0xf4b8, - 0xf439: 0xf4b9, - 0xf43a: 0xf4ba, - 0xf43b: 0xf4bb, - 0xf43c: 0xf4bc, - 0xf43d: 0xf4bd, - 0xf43e: 0xf4be, - 0xf43f: 0xf4bf, - 0xf440: 0xf4c1, - 0xf444: 0xf4c5, - 0xf445: 0xf4c6, - 0xf446: 0xf4c7, - 0xf447: 0xf4c8, - 0xf448: 0xf4c9, - 0xf449: 0xf4ca, - 0xf44a: 0xf4cb, - 0xf44b: 0xf4cc, - 0xf44c: 0xf4cd, - 0xf44d: 0xf4ce, - 0xf44e: 0xf4cf, - 0xf44f: 0xf4d0, - 0xf450: 0xf4d1, - 0xf451: 0xf4d2, - 0xf452: 0xf4d3, - 0xf453: 0xf4d4, - 0xf454: 0xf4d5, - 0xf455: 0xf4d6, - 0xf456: 0xf4d7, - 0xf457: 0xf4d8, - 0xf458: 0xf4d9, - 0xf459: 0xf4da, - 0xf45a: 0xf4db, - 0xf45b: 0xf4dc, - 0xf45c: 0xf4dd, - 0xf45d: 0xf4de, - 0xf45e: 0xf4df, - 0xf45f: 0xf4e0, - 0xf460: 0xf4e1, - 0xf461: 0xf4e2, - 0xf462: 0xf4e4, - 0xf463: 0xf4e5, - 0xf464: 0xf4e6, - 0xf465: 0xf4e7, - 0xf466: 0xf4e8, - 0xf467: 0xf4e9, - 0xf468: 0xf4ea, - 0xf469: 0xf4eb, - 0xf46a: 0xf4ec, - 0xf46b: 0xf4ed, - 0xf46c: 0xf4ee, - 0xf46d: 0xf4ef, - 0xf46e: 0xf4f0, - 0xf46f: 0xf4f1, - 0xf470: 0xf4f2, - 0xf471: 0xf4f3, - 0xf472: 0xf4f4, - 0xf473: 0xf4f5, - 0xf474: 0xf4f6, - 0xf475: 0xf4f7, - 0xf476: 0xf4f8, - 0xf477: 0xf4f9, - 0xf478: 0xf4fa, - 0xf479: 0xf4fb, - 0xf47a: 0xf4fc, - 0xf47b: 0xf4fd, - 0xf47c: 0xf4fe, - 0xf47d: 0xf4ff, - 0xf47e: 0xf500, - 0xf47f: 0xf501, - 0xf480: 0xf502, - 0xf481: 0xf503, - 0xf482: 0xf504, - 0xf483: 0xf505, - 0xf484: 0xf506, - 0xf485: 0xf507, - 0xf486: 0xf508, - 0xf487: 0xf509, - 0xf488: 0xf50a, - 0xf489: 0xf50b, - 0xf48a: 0xf50c, - 0xf48b: 0xf50d, - 0xf48c: 0xf50e, - 0xf48d: 0xf50f, - 0xf48e: 0xf510, - 0xf48f: 0xf511, - 0xf490: 0xf512, - 0xf491: 0xf513, - 0xf492: 0xf514, - 0xf493: 0xf515, - 0xf494: 0xf516, - 0xf495: 0xf517, - 0xf496: 0xf518, - 0xf497: 0xf519, - 0xf498: 0xf51a, - 0xf499: 0xf51b, - 0xf49a: 0xf51c, - 0xf49b: 0xf51d, - 0xf49c: 0xf51e, - 0xf49d: 0xf51f, - 0xf49e: 0xf520, - 0xf49f: 0xf521, - 0xf4a0: 0xf522, - 0xf4a1: 0xf523, - 0xf4a2: 0xf524, - 0xf4a3: 0xf525, - 0xf4a4: 0xf526, - 0xf4a5: 0xf527, - 0xf4a6: 0xf528, - 0xf4a7: 0xf529, - 0xf4a8: 0xf52a, - 0xf4a9: 0xf52b, - 0xf4aa: 0xf52c, - 0xf4ab: 0xf52d, - 0xf4ac: 0xf52e, - 0xf4ad: 0xf52f, - 0xf4ae: 0xf530, - 0xf4af: 0xf531, - 0xf4b0: 0xf532, - 0xf4b1: 0xf533, - 0xf4b2: 0xf534, - 0xf4b3: 0xf535, - 0xf4b4: 0xf536, - 0xf4b5: 0xf537, - 0xf4b6: 0xf538, - 0xf4b7: 0xf53a, - 0xf4b8: 0xf53b, - 0xf4b9: 0xf53c, - 0xf4ba: 0xf53d, - 0xf4bb: 0xf53e, - 0xf4bc: 0xf540, - 0xf4bd: 0xf541, - 0xf4be: 0xf542, - 0xf4bf: 0xf543, - 0xf4c0: 0xf544, - 0xf4c1: 0xf545, - 0xf4c2: 0xf546, - 0xf4c3: 0xf547, - 0xf4c4: 0xf548, - 0xf4c5: 0xf549, - 0xf4c6: 0xf54a, - 0xf4c7: 0xf54b, - 0xf4c8: 0xf54c, - 0xf4c9: 0xf54d, - 0xf4ca: 0xf54e, - 0xf4cb: 0xf54f, - 0xf4cc: 0xf550, - 0xf4cd: 0xf551, - 0xf4ce: 0xf552, - 0xf4cf: 0xf554, - 0xf4d0: 0xf555, - 0xf4d1: 0xf556, - 0xf4d2: 0xf557, - 0xf4d3: 0xf558, - 0xf4d4: 0xf559, - 0xf4d5: 0xf55a, - 0xf4d6: 0xf55b, - 0xf4d7: 0xf55c, - 0xf4d8: 0xf55d, - 0xf4d9: 0xf55e, - 0xf4da: 0xf55f, - 0xf4db: 0xf560, - 0xf4dc: 0xf561, - 0xf4dd: 0xf562, - 0xf4de: 0xf563, - 0xf4df: 0xf564, - 0xf4e0: 0xf565, - 0xf4e1: 0xf566, - 0xf4e2: 0xf567, - 0xf4e3: 0xf568, - 0xf4e4: 0xf569, - 0xf4e5: 0xf56a, - 0xf4e6: 0xf56b, - 0xf4e7: 0xf56c, - 0xf4e8: 0xf56e, - 0xf4e9: 0xf56f, - 0xf4ea: 0xf570, - 0xf4eb: 0xf571, - 0xf4ec: 0xf573, - 0xf4ed: 0xf574, - 0xf4ee: 0xf575, - 0xf4ef: 0xf576, - 0xf4f0: 0xf577, - 0xf4f1: 0xf578, - 0xf4f2: 0xf579, - 0xf4f3: 0xf57a, - 0xf4f4: 0xf57b, - 0xf4f5: 0xf57c, - 0xf4f6: 0xf57d, - 0xf4f7: 0xf57e, - 0xf4f8: 0xf57f, - 0xf4f9: 0xf580, - 0xf4fa: 0xf581, - 0xf4fb: 0xf582, - 0xf4fc: 0xf583, - 0xf4fd: 0xf584, - 0xf4fe: 0xf585, - 0xf4ff: 0xf586, - 0xf500: 0xf588, - 0xf501: 0xf589, - 0xf502: 0xf58a, - 0xf503: 0xf58b, - 0xf504: 0xf58c, - 0xf505: 0xf58d, - 0xf506: 0xf58e, - 0xf507: 0xf58f, - 0xf508: 0xf590, - 0xf509: 0xf591, - 0xf50a: 0xf592, - 0xf50b: 0xf593, - 0xf50c: 0xf594, - 0xf50d: 0xf595, - 0xf50e: 0xf597, - 0xf50f: 0xf598, - 0xf510: 0xf599, - 0xf511: 0xf59a, - 0xf512: 0xf59b, - 0xf513: 0xf59c, - 0xf514: 0xf59d, - 0xf515: 0xf59e, - 0xf516: 0xf59f, - 0xf517: 0xf5a0, - 0xf518: 0xf5a1, - 0xf519: 0xf5a2, - 0xf51a: 0xf5a3, - 0xf51b: 0xf5a4, - 0xf51c: 0xf5a6, - 0xf51d: 0xf5a7, - 0xf51e: 0xf5a8, - 0xf51f: 0xf5aa, - 0xf520: 0xf5ab, - 0xf521: 0xf5ac, - 0xf522: 0xf5ae, - 0xf523: 0xf5af, - 0xf524: 0xf5b0, - 0xf525: 0xf5b1, - 0xf526: 0xf5b2, - 0xf527: 0xf5b3, - 0xf528: 0xf5b4, - 0xf529: 0xf5b5, - 0xf52a: 0xf5b6, - 0xf52b: 0xf5b7, - 0xf52c: 0xf5b8, - 0xf52d: 0xf5ba, - 0xf52e: 0xf5bb, - 0xf52f: 0xf5bc, - 0xf530: 0xf5be, - 0xf531: 0xf5bf, - 0xf532: 0xf5c0, - 0xf533: 0xf5c1, - 0xf534: 0xf5c2, - 0xf535: 0xf5c3, - 0xf536: 0xf5c4, - 0xf537: 0xf5c5, - 0xf538: 0xf5c6, - 0xf539: 0xf5c7, - 0xf53a: 0xf5c8, - 0xf53b: 0xf5c9, - 0xf53c: 0xf5ca, - 0xf53d: 0xf5cb, - 0xf53e: 0xf5cc, - 0xf53f: 0xf5cd, - 0xf540: 0xf5ce, - 0xf541: 0xf5cf, - 0xf542: 0xf5d0, - 0xf543: 0xf5d1, - 0xf544: 0xf5d3, - 0xf545: 0xf5d4, - 0xf546: 0xf5d5, - 0xf547: 0xf5d6, - 0xf548: 0xf5d7, - 0xf549: 0xf5d8, - 0xf54a: 0xf5d9, - 0xf54b: 0xf5da, - 0xf54c: 0xf5db, - 0xf54d: 0xf5dc, - 0xf54e: 0xf5dd, - 0xf54f: 0xf5de, - 0xf550: 0xf5df, - 0xf551: 0xf5e0, - 0xf552: 0xf5e1, - 0xf553: 0xf5e2, - 0xf554: 0xf5e3, - 0xf555: 0xf5e4, - 0xf556: 0xf5e5, - 0xf557: 0xf5e6, - 0xf558: 0xf5e7, - 0xf559: 0xf5e8, - 0xf55a: 0xf5e9, - 0xf55b: 0xf5ea, - 0xf55c: 0xf5eb, - 0xf55d: 0xf5ec, - 0xf55e: 0xf5ed, - 0xf55f: 0xf5ee, - 0xf560: 0xf5ef, - 0xf561: 0xf5f0, - 0xf562: 0xf5f1, - 0xf563: 0xf5f2, - 0xf564: 0xf5f3, - 0xf565: 0xf5f4, - 0xf566: 0xf5f6, - 0xf567: 0xf5f8, - 0xf568: 0xf5f9, - 0xf569: 0xf5fa, - 0xf56a: 0xf5fb, - 0xf56b: 0xf5fc, - 0xf56c: 0xf5fd, - 0xf56d: 0xf5fe, - 0xf56e: 0xf5ff, - 0xf56f: 0xf600, - 0xf570: 0xf601, - 0xf571: 0xf603, - 0xf572: 0xf604, - 0xf573: 0xf605, - 0xf574: 0xf606, - 0xf575: 0xf607, - 0xf576: 0xf608, - 0xf577: 0xf609, - 0xf578: 0xf60a, - 0xf579: 0xf60b, - 0xf57a: 0xf60c, - 0xf57b: 0xf60d, - 0xf57c: 0xf60e, - 0xf57d: 0xf60f, - 0xf57f: 0xf610, - 0xf580: 0xf611, - 0xf581: 0xf612, - 0xf582: 0xf613, - 0xf583: 0xf614, - 0xf584: 0xf615, - 0xf585: 0xf617, - 0xf586: 0xf618, - 0xf588: 0xf61a, - 0xf589: 0xf61b, - 0xf58a: 0xf61c, - 0xf58b: 0xf61e, - 0xf58c: 0xf61f, - 0xf58d: 0xf620, - 0xf58e: 0xf621, - 0xf58f: 0xf622, - 0xf590: 0xf623, - 0xf591: 0xf624, - 0xf592: 0xf625, - 0xf593: 0xf626, - 0xf594: 0xf627, - 0xf595: 0xf628, - 0xf596: 0xf629, - 0xf597: 0xf62a, - 0xf598: 0xf62b, - 0xf599: 0xf62c, - 0xf59a: 0xf62d, - 0xf59b: 0xf62e, - 0xf59c: 0xf62f, - 0xf59d: 0xf630, - 0xf59e: 0xf631, - 0xf59f: 0xf632, - 0xf5a0: 0xf633, - 0xf5a1: 0xf634, - 0xf5a2: 0xf635, - 0xf5a3: 0xf637, - 0xf5a4: 0xf638, - 0xf5a5: 0xf639, - 0xf5a6: 0xf63a, - 0xf5a7: 0xf63b, - 0xf5a8: 0xf63c, - 0xf5a9: 0xf63d, - 0xf5aa: 0xf63e, - 0xf5ab: 0xf63f, - 0xf5ac: 0xf640, - 0xf5ad: 0xf641, - 0xf5ae: 0xf642, - 0xf5af: 0xf643, - 0xf5b0: 0xf644, - 0xf5b1: 0xf645, - 0xf5b2: 0xf646, - 0xf5b3: 0xf647, - 0xf5b4: 0xf648, - 0xf5b5: 0xf649, - 0xf5b6: 0xf64b, - 0xf5b7: 0xf64c, - 0xf5b8: 0xf64d, - 0xf5b9: 0xf64e, - 0xf5ba: 0xf64f, - 0xf5bb: 0xf650, - 0xf5bc: 0xf651, - 0xf5bd: 0xf652, - 0xf5be: 0xf653, - 0xf5bf: 0xf655, - 0xf5c0: 0xf656, - 0xf5c1: 0xf657, - 0xf5c2: 0xf658, - 0xf5c3: 0xf659, - 0xf5c4: 0xf65a, - 0xf5c5: 0xf65b, - 0xf5c6: 0xf65c, - 0xf5c7: 0xf65d, - 0xf5c8: 0xf65e, - 0xf5c9: 0xf65f, - 0xf5ca: 0xf660, - 0xf5cb: 0xf662, - 0xf5cc: 0xf663, - 0xf5cd: 0xf664, - 0xf5ce: 0xf665, - 0xf5cf: 0xf666, - 0xf5d0: 0xf667, - 0xf5d1: 0xf668, - 0xf5d2: 0xf669, - 0xf5d3: 0xf66a, - 0xf5d4: 0xf66b, - 0xf5d5: 0xf66c, - 0xf5d6: 0xf66d, - 0xf5d7: 0xf66e, - 0xf5d8: 0xf66f, - 0xf5d9: 0xf670, - 0xf5da: 0xf671, - 0xf5db: 0xf672, - 0xf5dc: 0xf673, - 0xf5dd: 0xf674, - 0xf5de: 0xf675, - 0xf5df: 0xf676, - 0xf5e0: 0xf677, - 0xf5e1: 0xf678, - 0xf5e2: 0xf679, - 0xf5e3: 0xf67a, - 0xf5e4: 0xf67b, - 0xf5e5: 0xf67c, - 0xf5e6: 0xf67d, - 0xf5e7: 0xf67e, - 0xf5e8: 0xf67f, - 0xf5e9: 0xf680, - 0xf5ea: 0xf682, - 0xf5eb: 0xf683, - 0xf5ec: 0xf684, - 0xf5ed: 0xf686, - 0xf5ee: 0xf687, - 0xf5ef: 0xf688, - 0xf5f0: 0xf689, - 0xf5f1: 0xf68a, - 0xf5f2: 0xf68b, - 0xf5f3: 0xf68d, - 0xf5f4: 0xf68e, - 0xf5f5: 0xf68f, - 0xf5f6: 0xf690, - 0xf5f7: 0xf691, - 0xf5f8: 0xf692, - 0xf5f9: 0xf693, - 0xf5fa: 0xf694, - 0xf5fb: 0xf695, - 0xf5fc: 0xf696, - 0xf5fd: 0xf697, - 0xf5fe: 0xf698, - 0xf5ff: 0xf69a, - 0xf600: 0xf69b, - 0xf601: 0xf69d, - 0xf602: 0xf69e, - 0xf603: 0xf69f, - 0xf604: 0xf6a0, - 0xf605: 0xf6a1, - 0xf606: 0xf6a2, - 0xf607: 0xf6a3, - 0xf608: 0xf6a4, - 0xf609: 0xf6a5, - 0xf60a: 0xf6a6, - 0xf60b: 0xf6a7, - 0xf60c: 0xf6a8, - 0xf60d: 0xf6a9, - 0xf60e: 0xf6aa, - 0xf60f: 0xf6ac, - 0xf610: 0xf6ad, - 0xf611: 0xf6ae, - 0xf612: 0xf6af, - 0xf613: 0xf6b0, - 0xf614: 0xf6b1, - 0xf615: 0xf6b2, - 0xf616: 0xf6b3, - 0xf617: 0xf6b4, - 0xf618: 0xf6b5, - 0xf619: 0xf6b6, - 0xf61a: 0xf6b7, - 0xf61c: 0xf6b8, - 0xf61d: 0xf6b9, - 0xf61f: 0xf6ba, - 0xf620: 0xf6bb, - 0xf621: 0xf6bc, - 0xf622: 0xf6bd, - 0xf623: 0xf6be, - 0xf624: 0xf6bf, - 0xf625: 0xf6c0, - 0xf626: 0xf6c1, - 0xf627: 0xf6c2, - 0xf628: 0xf6c3, - 0xf629: 0xf6c4, - 0xf62a: 0xf6c6, - 0xf62b: 0xf6c7, - 0xf62c: 0xf6c8, - 0xf62d: 0xf6c9, - 0xf62e: 0xf6ca, - 0xf62f: 0xf6cb, - 0xf630: 0xf6cc, - 0xf631: 0xf6cd, - 0xf632: 0xf6ce, - 0xf633: 0xf6cf, - 0xf634: 0xf6d0, - 0xf635: 0xf6d1, - 0xf636: 0xf6d2, - 0xf637: 0xf6d3, - 0xf638: 0xf6d4, - 0xf639: 0xf6d5, - 0xf63a: 0xf6d6, - 0xf63b: 0xf6d7, - 0xf63c: 0xf6d8, - 0xf63d: 0xf6d9, - 0xf63e: 0xf6da, - 0xf63f: 0xf6db, - 0xf640: 0xf6dc, - 0xf641: 0xf6dd, - 0xf642: 0xf6de, - 0xf644: 0xf6e0, - 0xf645: 0xf6e1, - 0xf646: 0xf6e2, - 0xf647: 0xf6e3, - 0xf648: 0xf6e5, - 0xf649: 0xf6e6, - 0xf64a: 0xf6e7, - 0xf64b: 0xf6e8, - 0xf64c: 0xf6e9, - 0xf64d: 0xf6ea, - 0xf64e: 0xf6eb, - 0xf64f: 0xf6ec, - 0xf650: 0xf6ed, - 0xf651: 0xf6ee, - 0xf652: 0xf6f0, - 0xf653: 0xf6f1, - 0xf654: 0xf6f2, - 0xf655: 0xf6f4, - 0xf656: 0xf6f5, - 0xf657: 0xf6f6, - 0xf658: 0xf6f7, - 0xf659: 0xf6f8, - 0xf65a: 0xf6f9, - 0xf65b: 0xf6fa, - 0xf65c: 0xf6fb, - 0xf65d: 0xf6fc, - 0xf65e: 0xf6fd, - 0xf65f: 0xf6fe, - 0xf660: 0xf6ff, - 0xf661: 0xf700, - 0xf662: 0xf701, - 0xf663: 0xf702, - 0xf664: 0xf703, - 0xf665: 0xf704, - 0xf666: 0xf705, - 0xf667: 0xf706, - 0xf668: 0xf707, - 0xf669: 0xf708, - 0xf66a: 0xf709, - 0xf66b: 0xf70a, - 0xf66c: 0xf70b, - 0xf66d: 0xf70c, - 0xf66e: 0xf70d, - 0xf66f: 0xf70e, - 0xf670: 0xf70f, - 0xf671: 0xf710, - 0xf672: 0xf711, - 0xf673: 0xf712, - 0xf674: 0xf713, - 0xf675: 0xf714, - 0xf676: 0xf715, - 0xf677: 0xf716, - 0xf678: 0xf717, - 0xf679: 0xf718, - 0xf67a: 0xf719, - 0xf67b: 0xf71a, - 0xf67c: 0xf71b, - 0xf67d: 0xf71c, - 0xf67e: 0xf71d, - 0xf67f: 0xf71e, - 0xf680: 0xf71f, - 0xf681: 0xf720, - 0xf682: 0xf721, - 0xf683: 0xf722, - 0xf684: 0xf724, - 0xf685: 0xf726, - 0xf686: 0xf727, - 0xf687: 0xf728, - 0xf688: 0xf729, - 0xf689: 0xf72a, - 0xf68a: 0xf72b, - 0xf68b: 0xf72c, - 0xf68c: 0xf72d, - 0xf68d: 0xf72e, - 0xf68e: 0xf730, - 0xf68f: 0xf731, - 0xf690: 0xf732, - 0xf691: 0xf733, - 0xf692: 0xf734, - 0xf693: 0xf735, - 0xf694: 0xf736, - 0xf695: 0xf737, - 0xf696: 0xf738, - 0xf697: 0xf739, - 0xf698: 0xf73a, - 0xf699: 0xf73b, - 0xf69a: 0xf73c, - 0xf69b: 0xf73d, - 0xf69c: 0xf73e, - 0xf69d: 0xf73f, - 0xf69e: 0xf740, - 0xf69f: 0xf741, - 0xf6a0: 0xf742, - 0xf6a1: 0xf743, - 0xf6a2: 0xf744, - 0xf6a3: 0xf745, - 0xf6a4: 0xf746, - 0xf6a5: 0xf747, - 0xf6a6: 0xf748, - 0xf6a7: 0xf749, - 0xf6a8: 0xf74a, - 0xf6a9: 0xf74b, - 0xf6aa: 0xf74c, - 0xf6ab: 0xf74e, - 0xf6ac: 0xf74f, - 0xf6ad: 0xf750, - 0xf6ae: 0xf751, - 0xf6af: 0xf752, - 0xf6b0: 0xf753, - 0xf6b1: 0xf754, - 0xf6b2: 0xf755, - 0xf6b3: 0xf756, - 0xf6b4: 0xf757, - 0xf6b5: 0xf758, - 0xf6b6: 0xf759, - 0xf6b7: 0xf75a, - 0xf6b8: 0xf75b, - 0xf6b9: 0xf75c, - 0xf6ba: 0xf75d, - 0xf6bb: 0xf75e, - 0xf6bc: 0xf75f, - 0xf6bd: 0xf760, - 0xf6be: 0xf761, - 0xf6bf: 0xf763, - 0xf6c0: 0xf764, - 0xf6c1: 0xf765, - 0xf6c2: 0xf766, - 0xf6c3: 0xf767, - 0xf6c4: 0xf768, - 0xf6c5: 0xf769, +const Map<int, String> legacySimpleIconsCodepointToSlug = { + 0xea01: "dotenv", + 0xea02: "dotnet", + 0xea03: "e", + 0xea04: "n1dot1dot1dot1", + 0xea05: "n1and1", + 0xea06: "n1001tracklists", + 0xea07: "n1panel", + 0xea08: "n1password", + 0xea09: "n2fas", + 0xea0a: "n2k", + 0xea0b: "n30secondsofcode", + 0xea0c: "n365datascience", + 0xea0d: "n3m", + 0xea0e: "n42", + 0xea0f: "n4chan", + 0xea10: "n4d", + 0xea11: "n500px", + 0xea12: "n7zip", + 0xea13: "n99designs", + 0xea14: "n9gag", + 0xea15: "aframe", + 0xea17: "abbott", + 0xea18: "abbvie", + 0xea19: "aboutdotme", + 0xea1a: "abstracticon", + 0xea1b: "abusedotch", + 0xea1c: "academia", + 0xea1d: "accenture", + 0xea1e: "accusoft", + 0xea1f: "accuweather", + 0xea20: "acer", + 0xea21: "acm", + 0xea22: "actigraph", + 0xea23: "activision", + 0xea24: "activitypub", + 0xea25: "actix", + 0xea26: "actualbudget", + 0xea27: "acura", + 0xea28: "adafruit", + 0xea29: "adblock", + 0xea2a: "adblockplus", + 0xea2b: "addydotio", + 0xea2c: "adguard", + 0xea2d: "adidas", + 0xea2e: "adminer", + 0xea2f: "adonisjs", + 0xea30: "adp", + 0xea31: "adroll", + 0xea32: "adventofcode", + 0xea33: "adyen", + 0xea34: "aegisauthenticator", + 0xea36: "aeroflot", + 0xea37: "aeromexico", + 0xea3a: "afdian", + 0xea3b: "affine", + 0xea40: "afterpay", + 0xea41: "aftership", + 0xea42: "agora", + 0xea43: "aidungeon", + 0xea44: "aib", + 0xea45: "aiohttp", + 0xea46: "aiqfome", + 0xea47: "aircanada", + 0xea48: "airchina", + 0xea49: "airfrance", + 0xea4a: "airindia", + 0xea4b: "airserbia", + 0xea4c: "airtransat", + 0xea4d: "airasia", + 0xea4e: "airbnb", + 0xea4f: "airbrake", + 0xea50: "airbus", + 0xea51: "airbyte", + 0xea52: "aircall", + 0xea53: "airplayaudio", + 0xea54: "airplayvideo", + 0xea55: "airtable", + 0xea56: "airtel", + 0xea57: "ajv", + 0xea58: "akamai", + 0xea59: "akasaair", + 0xea5a: "akaunting", + 0xea5b: "akiflow", + 0xea5c: "alacritty", + 0xea5d: "alamy", + 0xea5e: "albertheijn", + 0xea5f: "alby", + 0xea60: "alchemy", + 0xea61: "aldinord", + 0xea62: "aldisud", + 0xea64: "alfred", + 0xea65: "algolia", + 0xea66: "algorand", + 0xea67: "alibabacloud", + 0xea68: "alibabadotcom", + 0xea69: "alienware", + 0xea6a: "aliexpress", + 0xea6b: "alipay", + 0xea6c: "allegro", + 0xea6d: "alliedmodders", + 0xea6f: "alltrails", + 0xea70: "almalinux", + 0xea71: "alpinelinux", + 0xea72: "alpinedotjs", + 0xea73: "alternativeto", + 0xea76: "alwaysdata", + 0xea92: "amd", + 0xea93: "ameba", + 0xea94: "americanairlines", + 0xea95: "americanexpress", + 0xea96: "amg", + 0xea97: "amp", + 0xea98: "amul", + 0xea99: "ana", + 0xea9a: "anaconda", + 0xea9b: "analogue", + 0xea9c: "andela", + 0xea9d: "android", + 0xea9e: "androidauto", + 0xea9f: "androidstudio", + 0xeaa0: "angular", + 0xeaa1: "anilist", + 0xeaa2: "animalplanet", + 0xeaa3: "ankermake", + 0xeaa4: "anki", + 0xeaa5: "ansible", + 0xeaa6: "answer", + 0xeaa7: "ansys", + 0xeaa8: "antdesign", + 0xeaa9: "anta", + 0xeaaa: "antena3", + 0xeaab: "anthropic", + 0xeaac: "antv", + 0xeaad: "anycubic", + 0xeaae: "anydesk", + 0xeaaf: "anytype", + 0xeab1: "apache", + 0xeab2: "apacheairflow", + 0xeab3: "apacheant", + 0xeab4: "apachecassandra", + 0xeab5: "apachecloudstack", + 0xeab6: "apachecordova", + 0xeab7: "apachecouchdb", + 0xeab8: "apachedolphinscheduler", + 0xeab9: "apachedruid", + 0xeaba: "apacheecharts", + 0xeabb: "apacheflink", + 0xeabc: "apachefreemarker", + 0xeabd: "apachegroovy", + 0xeabe: "apacheguacamole", + 0xeabf: "apachehadoop", + 0xeac0: "apachehbase", + 0xeac1: "apachehive", + 0xeac2: "apachejmeter", + 0xeac3: "apachekafka", + 0xeac4: "apachekylin", + 0xeac5: "apachelucene", + 0xeac6: "apachemaven", + 0xeac7: "apachenetbeanside", + 0xeac8: "apachenifi", + 0xeac9: "apacheopenoffice", + 0xeaca: "apacheparquet", + 0xeacb: "apachepulsar", + 0xeacc: "apacherocketmq", + 0xeacd: "apachesolr", + 0xeace: "apachespark", + 0xeacf: "apachestorm", + 0xead0: "apachesuperset", + 0xead1: "apachetomcat", + 0xead2: "aparat", + 0xead3: "apifox", + 0xead4: "apmterminals", + 0xead5: "apollographql", + 0xead6: "apostrophe", + 0xead7: "appstore", + 0xead8: "appgallery", + 0xead9: "appian", + 0xeada: "appium", + 0xeadb: "apple", + 0xeadc: "applearcade", + 0xeadd: "applemusic", + 0xeade: "applenews", + 0xeadf: "applepay", + 0xeae0: "applepodcasts", + 0xeae1: "appletv", + 0xeae2: "appsignal", + 0xeae3: "appsmith", + 0xeae4: "appveyor", + 0xeae5: "appwrite", + 0xeae6: "aqua", + 0xeae7: "aral", + 0xeae8: "arangodb", + 0xeae9: "arc", + 0xeaea: "arcgis", + 0xeaeb: "archlinux", + 0xeaec: "archicad", + 0xeaed: "archiveofourown", + 0xeaee: "ardour", + 0xeaef: "arduino", + 0xeaf0: "argo", + 0xeaf1: "argos", + 0xeaf2: "ariakit", + 0xeaf3: "arkecosystem", + 0xeaf4: "arlo", + 0xeaf5: "arm", + 0xeaf6: "armkeil", + 0xeaf7: "arstechnica", + 0xeaf8: "artifacthub", + 0xeaf9: "artixlinux", + 0xeafa: "artstation", + 0xeafb: "arxiv", + 0xeafc: "asahilinux", + 0xeafd: "asana", + 0xeafe: "asciidoctor", + 0xeaff: "asciinema", + 0xeb00: "asda", + 0xeb01: "aseprite", + 0xeb02: "assemblyscript", + 0xeb03: "asterisk", + 0xeb04: "astonmartin", + 0xeb05: "astra", + 0xeb06: "astral", + 0xeb07: "astro", + 0xeb08: "asus", + 0xeb09: "atandt", + 0xeb0a: "atari", + 0xeb0b: "atlasos", + 0xeb0c: "atlassian", + 0xeb0d: "auchan", + 0xeb0e: "audacity", + 0xeb0f: "audi", + 0xeb10: "audible", + 0xeb11: "audiotechnica", + 0xeb12: "audiobookshelf", + 0xeb13: "audioboom", + 0xeb14: "audiomack", + 0xeb15: "aurelia", + 0xeb16: "auth0", + 0xeb17: "authelia", + 0xeb18: "authentik", + 0xeb1a: "autocad", + 0xeb1b: "autocannon", + 0xeb1c: "autodesk", + 0xeb1d: "autodeskmaya", + 0xeb1e: "autodeskrevit", + 0xeb1f: "autohotkey", + 0xeb20: "autoit", + 0xeb21: "automattic", + 0xeb22: "autoprefixer", + 0xeb23: "autozone", + 0xeb24: "avajs", + 0xeb25: "avast", + 0xeb26: "avianca", + 0xeb27: "avira", + 0xeb28: "awesomelists", + 0xeb29: "awesomewm", + 0xeb30: "awwwards", + 0xeb31: "axios", + 0xeb32: "bandrautomation", + 0xeb33: "babel", + 0xeb34: "babelio", + 0xeb35: "babylondotjs", + 0xeb36: "backblaze", + 0xeb37: "backbone", + 0xeb38: "backbonedotjs", + 0xeb39: "backendless", + 0xeb3a: "backstage", + 0xeb3b: "badoo", + 0xeb3c: "baidu", + 0xeb3d: "bakalari", + 0xeb3e: "bamboo", + 0xeb3f: "bambulab", + 0xeb40: "bandcamp", + 0xeb41: "bandlab", + 0xeb42: "bandsintown", + 0xeb43: "bankofamerica", + 0xeb44: "barclays", + 0xeb45: "baremetrics", + 0xeb46: "barmenia", + 0xeb47: "basecamp", + 0xeb48: "baserow", + 0xeb49: "basicattentiontoken", + 0xeb4a: "bastyon", + 0xeb4b: "bat", + 0xeb4c: "bata", + 0xeb4d: "battledotnet", + 0xeb4e: "bazel", + 0xeb4f: "beatport", + 0xeb50: "beats", + 0xeb51: "beatsbydre", + 0xeb52: "beatstars", + 0xeb53: "beekeeperstudio", + 0xeb54: "behance", + 0xeb55: "beijingsubway", + 0xeb56: "bem", + 0xeb57: "bentley", + 0xeb58: "bento", + 0xeb59: "bentobox", + 0xeb5a: "bentoml", + 0xeb5b: "bereal", + 0xeb5c: "betfair", + 0xeb5d: "betterstack", + 0xeb5e: "betterdiscord", + 0xeb5f: "bevy", + 0xeb60: "bigcartel", + 0xeb61: "bigbasket", + 0xeb62: "bigbluebutton", + 0xeb63: "bigcommerce", + 0xeb64: "bilibili", + 0xeb65: "billboard", + 0xeb66: "bim", + 0xeb67: "binance", + 0xeb68: "biolink", + 0xeb69: "biome", + 0xeb6a: "bisecthosting", + 0xeb6b: "bit", + 0xeb6c: "bitbucket", + 0xeb6d: "bitcoin", + 0xeb6e: "bitcoincash", + 0xeb6f: "bitcoinsv", + 0xeb70: "bitcomet", + 0xeb71: "bitdefender", + 0xeb72: "bitly", + 0xeb73: "bitrise", + 0xeb74: "bittorrent", + 0xeb75: "bitwarden", + 0xeb76: "bitwig", + 0xeb77: "blackberry", + 0xeb78: "blackmagicdesign", + 0xeb79: "blazemeter", + 0xeb7a: "blazor", + 0xeb7b: "blender", + 0xeb7c: "blockbench", + 0xeb7d: "blockchaindotcom", + 0xeb7e: "blogger", + 0xeb7f: "bloglovin", + 0xeb80: "blueprint", + 0xeb81: "bluesky", + 0xeb82: "bluesound", + 0xeb83: "bluetooth", + 0xeb84: "bmcsoftware", + 0xeb85: "bmw", + 0xeb86: "bnbchain", + 0xeb87: "boardgamegeek", + 0xeb88: "boat", + 0xeb89: "boehringeringelheim", + 0xeb8a: "boeing", + 0xeb8b: "bombardier", + 0xeb8c: "bookalope", + 0xeb8d: "bookbub", + 0xeb8e: "bookmeter", + 0xeb8f: "bookmyshow", + 0xeb90: "bookstack", + 0xeb91: "boost", + 0xeb92: "boosty", + 0xeb93: "boots", + 0xeb94: "bootstrap", + 0xeb95: "borgbackup", + 0xeb96: "bosch", + 0xeb97: "bose", + 0xeb98: "botblecms", + 0xeb99: "boulanger", + 0xeb9a: "bower", + 0xeb9b: "box", + 0xeb9c: "boxysvg", + 0xeb9d: "braintree", + 0xeb9e: "brandfolder", + 0xeb9f: "brave", + 0xeba0: "breaker", + 0xeba1: "brenntag", + 0xeba2: "brevo", + 0xeba3: "brex", + 0xeba4: "bricks", + 0xeba5: "britishairways", + 0xeba6: "broadcom", + 0xeba7: "bruno", + 0xeba8: "bsd", + 0xeba9: "bspwm", + 0xebaa: "bt", + 0xebab: "buddy", + 0xebac: "budibase", + 0xebad: "buefy", + 0xebae: "buffer", + 0xebaf: "bugatti", + 0xebb0: "bugcrowd", + 0xebb2: "buhl", + 0xebb3: "buildkite", + 0xebb4: "builtbybit", + 0xebb5: "bukalapak", + 0xebb6: "bulma", + 0xebb7: "bun", + 0xebb8: "bungie", + 0xebb9: "bunq", + 0xebba: "burgerking", + 0xebbb: "burpsuite", + 0xebbc: "burton", + 0xebbd: "buymeacoffee", + 0xebbe: "buysellads", + 0xebbf: "buzzfeed", + 0xebc0: "bvg", + 0xebc1: "byjus", + 0xebc2: "bytedance", + 0xebc3: "c", + 0xebc4: "cplusplus", + 0xebc5: "cplusplusbuilder", + 0xebc6: "cachet", + 0xebc7: "caddy", + 0xebc8: "cadillac", + 0xebc9: "cafepress", + 0xebca: "cairographics", + 0xebcb: "cairometro", + 0xebcc: "caixabank", + 0xebcd: "cakephp", + 0xebce: "caldotcom", + 0xebcf: "calendly", + 0xebd0: "calibreweb", + 0xebd1: "campaignmonitor", + 0xebd2: "camunda", + 0xebd3: "canonical", + 0xebd5: "canvas", + 0xebd6: "capacitor", + 0xebd7: "caprover", + 0xebd8: "carthrottle", + 0xebd9: "cardano", + 0xebda: "carlsberggroup", + 0xebdb: "carrd", + 0xebdc: "carrefour", + 0xebdd: "carto", + 0xebde: "cashapp", + 0xebdf: "castbox", + 0xebe0: "castorama", + 0xebe1: "castro", + 0xebe2: "caterpillar", + 0xebe3: "cbc", + 0xebe4: "cbs", + 0xebe5: "ccc", + 0xebe6: "ccleaner", + 0xebe7: "cdprojekt", + 0xebe8: "ce", + 0xebe9: "celery", + 0xebea: "celestron", + 0xebeb: "centos", + 0xebec: "ceph", + 0xebed: "cesium", + 0xebee: "chai", + 0xebef: "chainguard", + 0xebf0: "chainlink", + 0xebf1: "chakraui", + 0xebf2: "channel4", + 0xebf3: "charles", + 0xebf4: "chartdotjs", + 0xebf5: "chartmogul", + 0xebf6: "chase", + 0xebf7: "chatbot", + 0xebf8: "chatwoot", + 0xebf9: "checkio", + 0xebfa: "checkmarx", + 0xebfb: "checkmk", + 0xebfc: "chedraui", + 0xebfd: "cheerio", + 0xebfe: "chef", + 0xebff: "chemex", + 0xec00: "chessdotcom", + 0xec01: "chevrolet", + 0xec02: "chianetwork", + 0xec03: "chinaeasternairlines", + 0xec04: "chinasouthernairlines", + 0xec05: "chocolatey", + 0xec06: "chromatic", + 0xec07: "chromewebstore", + 0xec09: "chrysler", + 0xec0a: "chupachups", + 0xec0b: "cilium", + 0xec0c: "cinema4d", + 0xec0d: "cinnamon", + 0xec0e: "circle", + 0xec0f: "circleci", + 0xec10: "circuitverse", + 0xec11: "cirrusci", + 0xec12: "cisco", + 0xec13: "citrix", + 0xec14: "citroen", + 0xec15: "civicrm", + 0xec16: "civo", + 0xec17: "clarifai", + 0xec18: "claris", + 0xec19: "clarivate", + 0xec1a: "claude", + 0xec1b: "clerk", + 0xec1c: "clevercloud", + 0xec1d: "clickhouse", + 0xec1e: "clickup", + 0xec1f: "clion", + 0xec20: "clockify", + 0xec21: "clojure", + 0xec22: "cloud66", + 0xec23: "cloudfoundry", + 0xec24: "cloudbees", + 0xec25: "cloudcannon", + 0xec26: "cloudera", + 0xec27: "cloudflare", + 0xec28: "cloudflarepages", + 0xec29: "cloudflareworkers", + 0xec2a: "cloudinary", + 0xec2b: "cloudron", + 0xec2c: "cloudsmith", + 0xec2d: "cloudways", + 0xec2e: "clubforce", + 0xec2f: "clubhouse", + 0xec30: "clyp", + 0xec31: "cmake", + 0xec32: "cncf", + 0xec33: "cnes", + 0xec34: "cnet", + 0xec35: "cnn", + 0xec36: "coop", + 0xec37: "cocacola", + 0xec38: "cockpit", + 0xec39: "cockroachlabs", + 0xec3a: "cocoapods", + 0xec3b: "cocos", + 0xec3c: "coda", + 0xec3d: "codacy", + 0xec3e: "codeclimate", + 0xec3f: "codeblocks", + 0xec40: "codeberg", + 0xec41: "codecademy", + 0xec42: "codeceptjs", + 0xec43: "codechef", + 0xec44: "codecov", + 0xec45: "codecrafters", + 0xec46: "codefactor", + 0xec47: "codeforces", + 0xec48: "codefresh", + 0xec49: "codeigniter", + 0xec4b: "codemagic", + 0xec4c: "codementor", + 0xec4d: "codemirror", + 0xec4e: "codenewbie", + 0xec50: "codeproject", + 0xec51: "coder", + 0xec52: "codersrank", + 0xec53: "coderwall", + 0xec54: "codesandbox", + 0xec55: "codeship", + 0xec56: "codesignal", + 0xec57: "codestream", + 0xec58: "codewars", + 0xec59: "codingninjas", + 0xec5a: "codingame", + 0xec5b: "codio", + 0xec5c: "coffeescript", + 0xec5d: "coggle", + 0xec60: "coinbase", + 0xec61: "coinmarketcap", + 0xec62: "collaboraonline", + 0xec63: "comicfury", + 0xec64: "comma", + 0xec65: "commerzbank", + 0xec66: "commitlint", + 0xec67: "commodore", + 0xec68: "commonlisp", + 0xec69: "commonworkflowlanguage", + 0xec6a: "compilerexplorer", + 0xec6b: "composer", + 0xec6c: "comptia", + 0xec6d: "comsol", + 0xec6e: "conan", + 0xec6f: "concourse", + 0xec70: "condaforge", + 0xec71: "conekta", + 0xec72: "confluence", + 0xec73: "construct3", + 0xec74: "consul", + 0xec75: "contabo", + 0xec76: "contactlesspayment", + 0xec77: "containerd", + 0xec78: "contao", + 0xec79: "contentful", + 0xec7a: "contentstack", + 0xec7b: "continente", + 0xec7c: "contributorcovenant", + 0xec7d: "conventionalcommits", + 0xec7e: "convertio", + 0xec7f: "cookiecutter", + 0xec80: "coolermaster", + 0xec81: "copaairlines", + 0xec82: "coppel", + 0xec83: "cora", + 0xec84: "coreldraw", + 0xec85: "coronaengine", + 0xec86: "coronarenderer", + 0xec87: "corsair", + 0xec88: "couchbase", + 0xec89: "counterstrike", + 0xec8a: "countingworkspro", + 0xec8b: "coursera", + 0xec8c: "coveralls", + 0xec8d: "coze", + 0xec8e: "cpanel", + 0xec8f: "craftcms", + 0xec90: "craftsman", + 0xec91: "cratedb", + 0xec92: "crayon", + 0xec93: "creality", + 0xec94: "createreactapp", + 0xec95: "creativecommons", + 0xec96: "creativetechnology", + 0xec97: "credly", + 0xec98: "crehana", + 0xec99: "crewunited", + 0xec9a: "crewai", + 0xec9b: "criticalrole", + 0xec9c: "crowdin", + 0xec9d: "crowdsource", + 0xec9e: "crunchbase", + 0xec9f: "crunchyroll", + 0xeca0: "cryengine", + 0xeca1: "cryptomator", + 0xeca2: "cryptpad", + 0xeca3: "crystal", + 0xeca4: "csdn", + 0xeca5: "css", + 0xeca6: "cssdesignawards", + 0xeca7: "cssmodules", + 0xeca8: "csswizardry", + 0xecaa: "cts", + 0xecab: "cucumber", + 0xecac: "cultura", + 0xecad: "curl", + 0xecae: "curseforge", + 0xecaf: "customink", + 0xecb0: "cyberdefenders", + 0xecb1: "cycling74", + 0xecb2: "cypress", + 0xecb3: "cytoscapedotjs", + 0xecb4: "d", + 0xecb5: "dedge", + 0xecb6: "dwavesystems", + 0xecb7: "d3", + 0xecb8: "dacia", + 0xecb9: "daf", + 0xecba: "dailydotdev", + 0xecbb: "dailymotion", + 0xecbc: "daisyui", + 0xecbd: "dapr", + 0xecbe: "darkreader", + 0xecbf: "dart", + 0xecc0: "darty", + 0xecc1: "daserste", + 0xecc2: "dash", + 0xecc3: "dashlane", + 0xecc4: "dask", + 0xecc5: "dassaultsystemes", + 0xecc6: "datadotai", + 0xecc7: "databricks", + 0xecc8: "datacamp", + 0xecc9: "datadog", + 0xecca: "datagrip", + 0xeccb: "dataiku", + 0xeccc: "datastax", + 0xeccd: "datefns", + 0xecce: "datev", + 0xeccf: "datocms", + 0xecd0: "datto", + 0xecd1: "davinciresolve", + 0xecd2: "dazhongdianping", + 0xecd3: "dazn", + 0xecd4: "dbeaver", + 0xecd5: "dblp", + 0xecd7: "dcentertainment", + 0xecd8: "delonghi", + 0xecd9: "debian", + 0xecda: "debridlink", + 0xecdb: "decapcms", + 0xecdc: "decentraland", + 0xecdd: "deepcool", + 0xecde: "deepgram", + 0xecdf: "deepin", + 0xece0: "deepl", + 0xece1: "deepnote", + 0xece2: "deliveroo", + 0xece3: "dell", + 0xece4: "delphi", + 0xece5: "delta", + 0xece6: "deluge", + 0xece7: "deno", + 0xece8: "denon", + 0xece9: "dependabot", + 0xecea: "depositphotos", + 0xeceb: "derspiegel", + 0xecec: "deutschebahn", + 0xeced: "deutschebank", + 0xecee: "deutschepost", + 0xecef: "deutschetelekom", + 0xecf0: "deutschewelle", + 0xecf1: "devdotto", + 0xecf2: "devbox", + 0xecf3: "devexpress", + 0xecf4: "deviantart", + 0xecf5: "devpost", + 0xecf6: "devrant", + 0xecf7: "dgraph", + 0xecf8: "dhl", + 0xecf9: "diagramsdotnet", + 0xecfa: "dialogflow", + 0xecfb: "diaspora", + 0xecfc: "dictionarydotcom", + 0xecfd: "digg", + 0xecfe: "digikeyelectronics", + 0xecff: "digitalocean", + 0xed00: "dinersclub", + 0xed01: "dior", + 0xed02: "directus", + 0xed03: "discogs", + 0xed04: "discord", + 0xed05: "discourse", + 0xed06: "discover", + 0xed07: "disqus", + 0xed08: "disroot", + 0xed09: "distrokid", + 0xed0a: "django", + 0xed0b: "dji", + 0xed0c: "dlib", + 0xed0d: "dlna", + 0xed0e: "dm", + 0xed0f: "docker", + 0xed10: "docsdotrs", + 0xed11: "docsify", + 0xed12: "doctrine", + 0xed13: "docusaurus", + 0xed14: "dogecoin", + 0xed15: "doi", + 0xed16: "dolby", + 0xed17: "doordash", + 0xed18: "dota2", + 0xed19: "douban", + 0xed1a: "doubanread", + 0xed1b: "dovecot", + 0xed1c: "dovetail", + 0xed1d: "downdetector", + 0xed1e: "doxygen", + 0xed1f: "dpd", + 0xed20: "dragonframe", + 0xed21: "draugiemdotlv", + 0xed22: "dreamstime", + 0xed23: "dribbble", + 0xed24: "drizzle", + 0xed25: "drone", + 0xed26: "drooble", + 0xed27: "dropbox", + 0xed28: "drupal", + 0xed29: "dsautomobiles", + 0xed2a: "dts", + 0xed2b: "dtube", + 0xed2c: "ducati", + 0xed2d: "duckdb", + 0xed2e: "duckduckgo", + 0xed2f: "dungeonsanddragons", + 0xed30: "dunked", + 0xed31: "dunzo", + 0xed32: "duolingo", + 0xed33: "duplicati", + 0xed34: "dvc", + 0xed35: "dwm", + 0xed36: "dynatrace", + 0xed37: "edotleclerc", + 0xed38: "e3", + 0xed39: "ea", + 0xed3a: "eac", + 0xed3b: "eagle", + 0xed3c: "easyeda", + 0xed3d: "easyjet", + 0xed3e: "ebay", + 0xed3f: "ebox", + 0xed40: "eclipseadoptium", + 0xed41: "eclipseche", + 0xed42: "eclipseide", + 0xed43: "eclipsejetty", + 0xed44: "eclipsemosquitto", + 0xed45: "eclipsevertdotx", + 0xed46: "ecosia", + 0xed47: "ecovacs", + 0xed48: "edeka", + 0xed49: "edgeimpulse", + 0xed4a: "editorconfig", + 0xed4b: "educative", + 0xed4c: "edx", + 0xed4d: "egghead", + 0xed4e: "egnyte", + 0xed4f: "eight", + 0xed50: "eightsleep", + 0xed51: "ejs", + 0xed52: "elastic", + 0xed53: "elasticcloud", + 0xed54: "elasticstack", + 0xed55: "elasticsearch", + 0xed56: "elavon", + 0xed57: "electron", + 0xed58: "electronfiddle", + 0xed59: "electronbuilder", + 0xed5a: "elegoo", + 0xed5b: "element", + 0xed5c: "elementary", + 0xed5d: "elementor", + 0xed5e: "elevenlabs", + 0xed5f: "eleventy", + 0xed60: "elgato", + 0xed61: "elixir", + 0xed62: "elm", + 0xed63: "elsevier", + 0xed64: "embarcadero", + 0xed65: "embark", + 0xed66: "emberdotjs", + 0xed67: "emby", + 0xed68: "emirates", + 0xed69: "emlakjet", + 0xed6b: "endeavouros", + 0xed6d: "enpass", + 0xed6e: "enterprisedb", + 0xed6f: "envato", + 0xed70: "envoyproxy", + 0xed71: "epel", + 0xed72: "epicgames", + 0xed73: "epson", + 0xed74: "equinixmetal", + 0xed75: "eraser", + 0xed76: "ericsson", + 0xed77: "erlang", + 0xed78: "erpnext", + 0xed79: "esbuild", + 0xed7a: "esea", + 0xed7b: "eslgaming", + 0xed7c: "eslint", + 0xed7d: "esotericsoftware", + 0xed7e: "esphome", + 0xed7f: "espressif", + 0xed80: "esri", + 0xed81: "etcd", + 0xed82: "ethereum", + 0xed83: "ethers", + 0xed84: "ethiopianairlines", + 0xed85: "etihadairways", + 0xed86: "etsy", + 0xed87: "europeanunion", + 0xed88: "eventstore", + 0xed8a: "evernote", + 0xed8b: "excalidraw", + 0xed8c: "exercism", + 0xed8d: "exordo", + 0xed8e: "exoscale", + 0xed8f: "expedia", + 0xed90: "expensify", + 0xed91: "expertsexchange", + 0xed92: "expo", + 0xed93: "express", + 0xed94: "expressdotcom", + 0xed95: "expressvpn", + 0xed96: "eyeem", + 0xed97: "fdroid", + 0xed99: "fsharp", + 0xed9a: "f1", + 0xed9b: "f5", + 0xed9c: "facebook", + 0xed9d: "facebookgaming", + 0xed9e: "facebooklive", + 0xed9f: "faceit", + 0xeda0: "facepunch", + 0xeda1: "fairphone", + 0xeda2: "falco", + 0xeda3: "falcon", + 0xeda4: "fampay", + 0xeda5: "fandango", + 0xeda6: "fandom", + 0xeda7: "fanfou", + 0xeda8: "fantom", + 0xeda9: "farcaster", + 0xedaa: "fareharbor", + 0xedab: "farfetch", + 0xedac: "fastapi", + 0xedad: "fastify", + 0xedae: "fastlane", + 0xedaf: "fastly", + 0xedb0: "fathom", + 0xedb1: "fauna", + 0xedb2: "favro", + 0xedb3: "fcc", + 0xedb4: "fedex", + 0xedb5: "fedora", + 0xedb6: "feedly", + 0xedb7: "ferrari", + 0xedb8: "ferrarinv", + 0xedb9: "ferretdb", + 0xedba: "ffmpeg", + 0xedbb: "fi", + 0xedbc: "fiat", + 0xedbd: "fidoalliance", + 0xedbe: "fifa", + 0xedbf: "fig", + 0xedc0: "figma", + 0xedc1: "figshare", + 0xedc2: "fila", + 0xedc3: "filament", + 0xedc4: "filedotio", + 0xedc5: "filen", + 0xedc6: "files", + 0xedc7: "filezilla", + 0xedc8: "fineco", + 0xedc9: "fing", + 0xedca: "firebase", + 0xedcb: "firefish", + 0xedcc: "fireflyiii", + 0xedcd: "firefox", + 0xedce: "firefoxbrowser", + 0xedcf: "fireship", + 0xedd0: "firewalla", + 0xedd1: "first", + 0xedd2: "fishshell", + 0xedd3: "fitbit", + 0xedd4: "fivem", + 0xedd5: "fiverr", + 0xedd6: "fizz", + 0xedd7: "flashforge", + 0xedd8: "flask", + 0xedd9: "flat", + 0xedda: "flathub", + 0xeddb: "flatpak", + 0xeddc: "flickr", + 0xeddd: "flightaware", + 0xedde: "flipboard", + 0xede0: "floatplane", + 0xede1: "flood", + 0xede2: "fluentbit", + 0xede3: "fluentd", + 0xede4: "fluke", + 0xede5: "flutter", + 0xede6: "flux", + 0xede7: "flydotio", + 0xede8: "flyway", + 0xede9: "fmod", + 0xedea: "fnac", + 0xedeb: "folium", + 0xedec: "fonoma", + 0xeded: "fontawesome", + 0xedee: "fontbase", + 0xedef: "fontforge", + 0xedf0: "foobar2000", + 0xedf1: "foodpanda", + 0xedf2: "ford", + 0xedf3: "forgejo", + 0xedf4: "formik", + 0xedf5: "formspree", + 0xedf6: "formstack", + 0xedf7: "fortinet", + 0xedf8: "fortran", + 0xedf9: "fossa", + 0xedfa: "fossilscm", + 0xedfb: "foundryvirtualtabletop", + 0xedfc: "foursquare", + 0xedfd: "fox", + 0xedfe: "foxtel", + 0xedff: "fozzy", + 0xee00: "framer", + 0xee01: "framework", + 0xee02: "framework7", + 0xee03: "franprix", + 0xee04: "frappe", + 0xee05: "fraunhofergesellschaft", + 0xee06: "freebsd", + 0xee07: "freecad", + 0xee08: "freecodecamp", + 0xee09: "freedesktopdotorg", + 0xee0a: "freelancer", + 0xee0b: "freelancermap", + 0xee0c: "freenas", + 0xee0d: "freenet", + 0xee0e: "freepik", + 0xee0f: "fresh", + 0xee10: "frontendmentor", + 0xee11: "frontify", + 0xee12: "fubo", + 0xee13: "fueler", + 0xee14: "fugacloud", + 0xee15: "fujifilm", + 0xee16: "fujitsu", + 0xee17: "furaffinity", + 0xee18: "furrynetwork", + 0xee19: "fusionauth", + 0xee1a: "futurelearn", + 0xee1b: "fyle", + 0xee1c: "g2", + 0xee1d: "g2a", + 0xee1e: "g2g", + 0xee1f: "galaxus", + 0xee20: "gamedeveloper", + 0xee21: "gamejolt", + 0xee22: "gamescience", + 0xee23: "gamebanana", + 0xee24: "gameloft", + 0xee25: "gamemaker", + 0xee26: "garmin", + 0xee27: "gatling", + 0xee28: "gatsby", + 0xee29: "gcore", + 0xee2a: "gdal", + 0xee2b: "geeksforgeeks", + 0xee2c: "generalelectric", + 0xee2d: "generalmotors", + 0xee2e: "genius", + 0xee2f: "gentoo", + 0xee30: "geocaching", + 0xee31: "geode", + 0xee32: "geopandas", + 0xee33: "gerrit", + 0xee34: "getx", + 0xee35: "ghost", + 0xee36: "ghostery", + 0xee37: "gimp", + 0xee38: "gin", + 0xee39: "giphy", + 0xee3a: "git", + 0xee3b: "gitextensions", + 0xee3c: "gitforwindows", + 0xee3d: "gitlfs", + 0xee3e: "gitbook", + 0xee3f: "gitconnected", + 0xee40: "gitea", + 0xee41: "gitee", + 0xee42: "github", + 0xee43: "githubactions", + 0xee44: "githubcopilot", + 0xee45: "githubpages", + 0xee46: "githubsponsors", + 0xee47: "gitignoredotio", + 0xee48: "gitkraken", + 0xee49: "gitlab", + 0xee4a: "gitpod", + 0xee4b: "gitter", + 0xee4c: "gldotinet", + 0xee4d: "glassdoor", + 0xee4e: "glide", + 0xee4f: "glitch", + 0xee50: "globus", + 0xee51: "glovo", + 0xee52: "gltf", + 0xee53: "gmail", + 0xee54: "gmx", + 0xee55: "gnome", + 0xee56: "gnometerminal", + 0xee57: "gnu", + 0xee58: "gnubash", + 0xee59: "gnuemacs", + 0xee5a: "gnuicecat", + 0xee5b: "gnuprivacyguard", + 0xee5c: "gnusocial", + 0xee5d: "go", + 0xee5e: "gocd", + 0xee5f: "godaddy", + 0xee60: "godotengine", + 0xee61: "gofundme", + 0xee62: "gogdotcom", + 0xee63: "gojek", + 0xee64: "goland", + 0xee66: "goldmansachs", + 0xee67: "goodreads", + 0xee68: "google", + 0xee69: "googleadmob", + 0xee6a: "googleads", + 0xee6b: "googleadsense", + 0xee6c: "googleanalytics", + 0xee6d: "googleappsscript", + 0xee6e: "googleassistant", + 0xee6f: "googleauthenticator", + 0xee70: "googlebigquery", + 0xee71: "googlebigtable", + 0xee72: "googlecalendar", + 0xee73: "googlecampaignmanager360", + 0xee74: "googlecardboard", + 0xee75: "googlechat", + 0xee76: "googlechrome", + 0xee77: "googlechronicle", + 0xee78: "googleclassroom", + 0xee79: "googlecloud", + 0xee7a: "googlecloudcomposer", + 0xee7b: "googlecloudspanner", + 0xee7c: "googlecloudstorage", + 0xee7d: "googlecolab", + 0xee7e: "googlecontaineroptimizedos", + 0xee80: "googledataflow", + 0xee81: "googledataproc", + 0xee82: "googledisplayandvideo360", + 0xee83: "googledocs", + 0xee84: "googledrive", + 0xee85: "googleearth", + 0xee86: "googleearthengine", + 0xee88: "googlefonts", + 0xee89: "googleforms", + 0xee8a: "googlegemini", + 0xee8b: "googlehome", + 0xee8c: "googlekeep", + 0xee8d: "googlelens", + 0xee8e: "googlemaps", + 0xee8f: "googlemarketingplatform", + 0xee90: "googlemeet", + 0xee91: "googlemessages", + 0xee92: "googlenearby", + 0xee93: "googlenews", + 0xee94: "googlepay", + 0xee95: "googlephotos", + 0xee96: "googleplay", + 0xee97: "googlepubsub", + 0xee98: "googlescholar", + 0xee99: "googlesearchconsole", + 0xee9a: "googlesheets", + 0xee9b: "googleslides", + 0xee9c: "googlestreetview", + 0xee9d: "googletagmanager", + 0xee9e: "googletasks", + 0xee9f: "googletranslate", + 0xeea0: "gotomeeting", + 0xeea1: "grab", + 0xeea2: "gradio", + 0xeea3: "gradle", + 0xeea4: "gradleplaypublisher", + 0xeea5: "grafana", + 0xeea6: "grammarly", + 0xeea7: "grandfrais", + 0xeea8: "grapheneos", + 0xeea9: "graphite", + 0xeeaa: "graphql", + 0xeeab: "grav", + 0xeeac: "gravatar", + 0xeead: "graylog", + 0xeeae: "greasyfork", + 0xeeaf: "greatlearning", + 0xeeb0: "greenhouse", + 0xeeb1: "greensock", + 0xeeb2: "griddotai", + 0xeeb3: "gridsome", + 0xeeb4: "grocy", + 0xeeb5: "groupme", + 0xeeb6: "groupon", + 0xeeb8: "grunt", + 0xeeb9: "gsk", + 0xeeba: "gsmarenadotcom", + 0xeebb: "gstreamer", + 0xeebc: "gtk", + 0xeebd: "guangzhoumetro", + 0xeebe: "guilded", + 0xeebf: "guitarpro", + 0xeec0: "gulp", + 0xeec1: "gumroad", + 0xeec2: "gumtree", + 0xeec3: "gunicorn", + 0xeec4: "gurobi", + 0xeec5: "gusto", + 0xeec6: "gutenberg", + 0xeec7: "handm", + 0xeec8: "h3", + 0xeec9: "habr", + 0xeeca: "hackclub", + 0xeecb: "hackthebox", + 0xeecc: "hackaday", + 0xeecd: "hackernoon", + 0xeece: "hackerearth", + 0xeecf: "hackerone", + 0xeed0: "hackerrank", + 0xeed1: "hackster", + 0xeed2: "hal", + 0xeed3: "handlebarsdotjs", + 0xeed4: "handshake", + 0xeed5: "handshakeProtocol", + 0xeed6: "happycow", + 0xeed7: "harbor", + 0xeed8: "harmonyos", + 0xeed9: "hashicorp", + 0xeeda: "hashnode", + 0xeedb: "haskell", + 0xeedc: "hasura", + 0xeedd: "hatenabookmark", + 0xeede: "haveibeenpwned", + 0xeedf: "haxe", + 0xeee0: "hbo", + 0xeee1: "hcl", + 0xeee2: "hdfcbank", + 0xeee3: "headlessui", + 0xeee4: "headphonezone", + 0xeee5: "headspace", + 0xeee6: "hearth", + 0xeee7: "hearthisdotat", + 0xeee8: "hedera", + 0xeee9: "helium", + 0xeeea: "helix", + 0xeeeb: "hellofresh", + 0xeeec: "hellyhansen", + 0xeeed: "helm", + 0xeeee: "helpscout", + 0xeeef: "helpdesk", + 0xeef0: "hepsiemlak", + 0xeef1: "here", + 0xeef2: "hermes", + 0xeef4: "hetzner", + 0xeef5: "hevy", + 0xeef6: "hexlet", + 0xeef7: "hexo", + 0xeef8: "hey", + 0xeef9: "hibob", + 0xeefa: "hibernate", + 0xeefb: "hilton", + 0xeefc: "hiltonhotelsandresorts", + 0xeefd: "hitachi", + 0xeefe: "hive", + 0xeeff: "hiveBlockchain", + 0xef00: "hivemq", + 0xef01: "homarr", + 0xef02: "homeassistant", + 0xef03: "homeassistantcommunitystore", + 0xef04: "homeadvisor", + 0xef05: "homebrew", + 0xef06: "homebridge", + 0xef07: "homepage", + 0xef08: "homify", + 0xef09: "honda", + 0xef0a: "honey", + 0xef0b: "honeybadger", + 0xef0c: "honeygain", + 0xef0d: "hono", + 0xef0e: "honor", + 0xef0f: "hootsuite", + 0xef10: "hoppscotch", + 0xef11: "hostinger", + 0xef12: "hotelsdotcom", + 0xef13: "hotjar", + 0xef14: "hotwire", + 0xef15: "houdini", + 0xef16: "houzz", + 0xef17: "hp", + 0xef18: "hsbc", + 0xef19: "htc", + 0xef1a: "htcvive", + 0xef1b: "htmlacademy", + 0xef1c: "html5", + 0xef1d: "htmx", + 0xef1e: "htop", + 0xef1f: "httpie", + 0xef20: "huawei", + 0xef21: "hubspot", + 0xef22: "huggingface", + 0xef23: "hugo", + 0xef24: "humblebundle", + 0xef25: "humhub", + 0xef26: "hungryjacks", + 0xef27: "husqvarna", + 0xef28: "hyper", + 0xef29: "hyperskill", + 0xef2a: "hyperx", + 0xef2b: "hypothesis", + 0xef2c: "hyprland", + 0xef2d: "hyundai", + 0xef2e: "i18next", + 0xef2f: "i3", + 0xef30: "iata", + 0xef31: "ibeacon", + 0xef32: "iberia", + 0xef33: "iced", + 0xef34: "iceland", + 0xef35: "icicibank", + 0xef36: "icinga", + 0xef37: "icloud", + 0xef38: "icomoon", + 0xef39: "icon", + 0xef3a: "iconfinder", + 0xef3b: "iconify", + 0xef3c: "iconjar", + 0xef3d: "icons8", + 0xef3e: "icq", + 0xef3f: "ieee", + 0xef40: "ifixit", + 0xef41: "ifood", + 0xef42: "ifttt", + 0xef43: "igdb", + 0xef44: "ign", + 0xef45: "iheartradio", + 0xef46: "ikea", + 0xef47: "iledefrancemobilites", + 0xef48: "imagedotsc", + 0xef49: "imagej", + 0xef4a: "imdb", + 0xef4b: "imessage", + 0xef4c: "imgur", + 0xef4d: "immer", + 0xef4e: "immich", + 0xef4f: "imou", + 0xef50: "improvmx", + 0xef51: "indeed", + 0xef52: "indiansuperleague", + 0xef53: "indiehackers", + 0xef54: "indigo", + 0xef55: "inductiveautomation", + 0xef56: "inertia", + 0xef57: "infiniti", + 0xef58: "influxdb", + 0xef59: "infomaniak", + 0xef5a: "infoq", + 0xef5c: "infosys", + 0xef5d: "infracost", + 0xef5e: "ingress", + 0xef5f: "inkdrop", + 0xef60: "inkscape", + 0xef61: "inoreader", + 0xef62: "insomnia", + 0xef63: "inspire", + 0xef64: "insta360", + 0xef65: "instacart", + 0xef66: "instagram", + 0xef67: "instapaper", + 0xef68: "instatus", + 0xef69: "instructables", + 0xef6a: "instructure", + 0xef6b: "intel", + 0xef6c: "intellijidea", + 0xef6d: "interactiondesignfoundation", + 0xef6e: "interactjs", + 0xef6f: "interbase", + 0xef70: "intercom", + 0xef71: "intermarche", + 0xef72: "internetarchive", + 0xef73: "internetcomputer", + 0xef74: "intigriti", + 0xef75: "intuit", + 0xef77: "invoiceninja", + 0xef78: "iobroker", + 0xef79: "ionic", + 0xef7a: "ionos", + 0xef7b: "ios", + 0xef7c: "iota", + 0xef7d: "ipfs", + 0xef7e: "iris", + 0xef7f: "irobot", + 0xef80: "isc2", + 0xef81: "issuu", + 0xef82: "istio", + 0xef83: "itchdotio", + 0xef84: "iterm2", + 0xef85: "itunes", + 0xef86: "itvx", + 0xef87: "iveco", + 0xef88: "jabber", + 0xef89: "jaeger", + 0xef8c: "jameson", + 0xef8d: "jamstack", + 0xef8e: "japanairlines", + 0xef8f: "jasmine", + 0xef90: "javascript", + 0xef91: "jbl", + 0xef92: "jcb", + 0xef93: "jeep", + 0xef94: "jekyll", + 0xef95: "jellyfin", + 0xef96: "jenkins", + 0xef97: "jest", + 0xef98: "jet", + 0xef99: "jetblue", + 0xef9a: "jetbrains", + 0xef9b: "jetpackcompose", + 0xef9c: "jfrog", + 0xef9d: "jfrogpipelines", + 0xef9e: "jhipster", + 0xef9f: "jinja", + 0xefa0: "jio", + 0xefa1: "jira", + 0xefa2: "jirasoftware", + 0xefa3: "jitpack", + 0xefa4: "jitsi", + 0xefa5: "johndeere", + 0xefa6: "joomla", + 0xefa7: "joplin", + 0xefa8: "jordan", + 0xefa9: "jouav", + 0xefaa: "jovian", + 0xefab: "jpeg", + 0xefac: "jquery", + 0xefad: "jrgroup", + 0xefae: "jsdelivr", + 0xefaf: "jsfiddle", + 0xefb0: "json", + 0xefb1: "jsonwebtokens", + 0xefb2: "jsr", + 0xefb3: "jss", + 0xefb4: "juce", + 0xefb5: "juejin", + 0xefb6: "juke", + 0xefb7: "julia", + 0xefb8: "junipernetworks", + 0xefb9: "junit5", + 0xefba: "jupyter", + 0xefbb: "justeat", + 0xefbc: "justgiving", + 0xefbd: "k3s", + 0xefbe: "k6", + 0xefbf: "kaggle", + 0xefc0: "kagi", + 0xefc1: "kahoot", + 0xefc2: "kaios", + 0xefc3: "kakao", + 0xefc4: "kakaotalk", + 0xefc5: "kalilinux", + 0xefc6: "kamailio", + 0xefc7: "kaniko", + 0xefc8: "karlsruherverkehrsverbund", + 0xefc9: "kasasmart", + 0xefca: "kashflow", + 0xefcb: "kaspersky", + 0xefcc: "katana", + 0xefcd: "kaufland", + 0xefce: "kde", + 0xefcf: "kdeplasma", + 0xefd0: "kdenlive", + 0xefd1: "kedro", + 0xefd2: "keenetic", + 0xefd3: "keepachangelog", + 0xefd4: "keepassxc", + 0xefd5: "keeper", + 0xefd6: "keeweb", + 0xefd7: "kentico", + 0xefd8: "keras", + 0xefd9: "keybase", + 0xefda: "keycdn", + 0xefdb: "keycloak", + 0xefdc: "keystone", + 0xefdd: "kfc", + 0xefde: "khanacademy", + 0xefdf: "khronosgroup", + 0xefe0: "kia", + 0xefe1: "kibana", + 0xefe2: "kicad", + 0xefe3: "kick", + 0xefe4: "kickstarter", + 0xefe5: "kik", + 0xefe6: "kingstontechnology", + 0xefe7: "kinopoisk", + 0xefe8: "kinsta", + 0xefe9: "kirby", + 0xefea: "kit", + 0xefeb: "kitsu", + 0xefec: "klarna", + 0xefed: "kleinanzeigen", + 0xefee: "klm", + 0xefef: "klook", + 0xeff0: "knative", + 0xeff1: "knexdotjs", + 0xeff2: "knime", + 0xeff3: "knip", + 0xeff4: "knowledgebase", + 0xeff5: "known", + 0xeff6: "kofi", + 0xeff7: "koa", + 0xeff8: "koc", + 0xeff9: "kodak", + 0xeffa: "kodi", + 0xeffb: "koenigsegg", + 0xeffc: "kofax", + 0xeffd: "komoot", + 0xeffe: "konami", + 0xefff: "kong", + 0xf000: "kongregate", + 0xf001: "konva", + 0xf002: "kotlin", + 0xf003: "koyeb", + 0xf004: "krita", + 0xf005: "ktm", + 0xf006: "ktor", + 0xf007: "kuaishou", + 0xf008: "kubernetes", + 0xf009: "kubuntu", + 0xf00a: "kucoin", + 0xf00b: "kueski", + 0xf00c: "kuma", + 0xf00d: "kununu", + 0xf00e: "kuula", + 0xf00f: "kx", + 0xf010: "kyocera", + 0xf011: "lequipe", + 0xf012: "labview", + 0xf013: "lada", + 0xf014: "lamborghini", + 0xf016: "langchain", + 0xf017: "langflow", + 0xf018: "langgraph", + 0xf019: "languagetool", + 0xf01a: "lapce", + 0xf01b: "laragon", + 0xf01c: "laravel", + 0xf01d: "laravelhorizon", + 0xf01e: "laravelnova", + 0xf01f: "lastdotfm", + 0xf020: "lastpass", + 0xf021: "latex", + 0xf022: "launchpad", + 0xf023: "lazarus", + 0xf024: "lazyvim", + 0xf025: "lbry", + 0xf026: "leaderprice", + 0xf027: "leaflet", + 0xf028: "leagueoflegends", + 0xf029: "leanpub", + 0xf02a: "leetcode", + 0xf02b: "lefthook", + 0xf02c: "legacygames", + 0xf02d: "leica", + 0xf02e: "lemmy", + 0xf02f: "lemonsqueezy", + 0xf030: "lenovo", + 0xf031: "lens", + 0xf032: "leptos", + 0xf033: "lerna", + 0xf034: "leroymerlin", + 0xf035: "leslibraires", + 0xf036: "less", + 0xf037: "letsencrypt", + 0xf038: "letterboxd", + 0xf039: "levelsdotfyi", + 0xf03a: "lg", + 0xf03b: "lining", + 0xf03c: "liberadotchat", + 0xf03d: "liberapay", + 0xf03e: "librariesdotio", + 0xf03f: "librarything", + 0xf040: "libreoffice", + 0xf041: "libreofficebase", + 0xf042: "libreofficecalc", + 0xf043: "libreofficedraw", + 0xf044: "libreofficeimpress", + 0xf045: "libreofficemath", + 0xf046: "libreofficewriter", + 0xf047: "libretranslate", + 0xf048: "libretube", + 0xf049: "librewolf", + 0xf04a: "libuv", + 0xf04b: "lichess", + 0xf04c: "lidl", + 0xf04d: "lifx", + 0xf04e: "lightburn", + 0xf04f: "lighthouse", + 0xf050: "lightning", + 0xf051: "limesurvey", + 0xf052: "line", + 0xf053: "lineageos", + 0xf054: "linear", + 0xf055: "linkerd", + 0xf056: "linkfire", + 0xf057: "linksys", + 0xf058: "linktree", + 0xf059: "linphone", + 0xf05a: "lintcode", + 0xf05b: "linux", + 0xf05c: "linuxcontainers", + 0xf05d: "linuxfoundation", + 0xf05e: "linuxmint", + 0xf05f: "linuxprofessionalinstitute", + 0xf060: "linuxserver", + 0xf061: "lionair", + 0xf062: "liquibase", + 0xf063: "listmonk", + 0xf064: "lit", + 0xf065: "litecoin", + 0xf066: "literal", + 0xf067: "litiengine", + 0xf068: "livechat", + 0xf069: "livejournal", + 0xf06a: "livewire", + 0xf06b: "llvm", + 0xf06c: "lmms", + 0xf06d: "lobsters", + 0xf06e: "local", + 0xf06f: "lodash", + 0xf072: "logmein", + 0xf073: "logseq", + 0xf074: "logstash", + 0xf075: "looker", + 0xf076: "loom", + 0xf077: "loop", + 0xf078: "loopback", + 0xf079: "lootcrate", + 0xf07a: "lospec", + 0xf07b: "lotpolishairlines", + 0xf07c: "lottiefiles", + 0xf07d: "ltspice", + 0xf07e: "lua", + 0xf07f: "lubuntu", + 0xf080: "lucia", + 0xf081: "lucid", + 0xf082: "lucide", + 0xf083: "ludwig", + 0xf084: "lufthansa", + 0xf085: "lumen", + 0xf086: "lunacy", + 0xf087: "lutris", + 0xf088: "lvgl", + 0xf089: "lydia", + 0xf08a: "lyft", + 0xf08b: "maas", + 0xf08c: "macos", + 0xf08d: "macpaw", + 0xf08e: "macys", + 0xf08f: "magasinsu", + 0xf090: "magic", + 0xf091: "magisk", + 0xf092: "mahindra", + 0xf093: "maildotcom", + 0xf094: "maildotru", + 0xf096: "mailchimp", + 0xf097: "mailgun", + 0xf098: "mailtrap", + 0xf099: "mainwp", + 0xf09a: "majorleaguehacking", + 0xf09b: "make", + 0xf09c: "makerbot", + 0xf09d: "malt", + 0xf09e: "malwarebytes", + 0xf09f: "mambaui", + 0xf0a0: "mamp", + 0xf0a1: "man", + 0xf0a2: "manageiq", + 0xf0a3: "manjaro", + 0xf0a4: "mantine", + 0xf0a5: "mapbox", + 0xf0a6: "mapillary", + 0xf0a7: "maplibre", + 0xf0a8: "maptiler", + 0xf0a9: "mariadb", + 0xf0aa: "mariadbfoundation", + 0xf0ab: "markdown", + 0xf0ac: "marko", + 0xf0ad: "marriott", + 0xf0ae: "marvelapp", + 0xf0af: "maserati", + 0xf0b0: "mastercard", + 0xf0b1: "mastercomfig", + 0xf0b2: "mastodon", + 0xf0b3: "materialdesign", + 0xf0b4: "materialdesignicons", + 0xf0b5: "materialformkdocs", + 0xf0b6: "matillion", + 0xf0b7: "matomo", + 0xf0b8: "matrix", + 0xf0b9: "matterdotjs", + 0xf0ba: "mattermost", + 0xf0bb: "matternet", + 0xf0bc: "mautic", + 0xf0bd: "max", + 0xf0be: "maxplanckgesellschaft", + 0xf0bf: "maytag", + 0xf0c0: "mazda", + 0xf0c1: "maze", + 0xf0c2: "mcafee", + 0xf0c3: "mcdonalds", + 0xf0c4: "mclaren", + 0xf0c5: "mdbook", + 0xf0c6: "mdnwebdocs", + 0xf0c7: "mdx", + 0xf0c8: "mealie", + 0xf0c9: "mediafire", + 0xf0ca: "mediamarkt", + 0xf0cb: "mediapipe", + 0xf0cc: "mediatek", + 0xf0cd: "medibangpaint", + 0xf0ce: "medium", + 0xf0cf: "medusa", + 0xf0d0: "meetup", + 0xf0d1: "mega", + 0xf0d2: "meilisearch", + 0xf0d3: "meituan", + 0xf0d4: "meizu", + 0xf0d5: "mendeley", + 0xf0d6: "mentorcruise", + 0xf0d7: "mercadopago", + 0xf0d9: "merck", + 0xf0da: "mercurial", + 0xf0db: "mermaid", + 0xf0dc: "messenger", + 0xf0dd: "meta", + 0xf0de: "metabase", + 0xf0df: "metacritic", + 0xf0e0: "metafilter", + 0xf0e1: "metasploit", + 0xf0e2: "meteor", + 0xf0e3: "metro", + 0xf0e4: "metrodelaciudaddemexico", + 0xf0e5: "metrodemadrid", + 0xf0e6: "metrodeparis", + 0xf0e7: "mewe", + 0xf0e8: "mg", + 0xf0e9: "microeditor", + 0xf0ea: "microbit", + 0xf0eb: "microdotblog", + 0xf0ec: "micropython", + 0xf0ed: "microstation", + 0xf0ee: "microstrategy", + 0xf0ef: "midi", + 0xf0f0: "migadu", + 0xf0f1: "mihoyo", + 0xf0f2: "mikrotik", + 0xf0f3: "milanote", + 0xf0f4: "milvus", + 0xf0f5: "minds", + 0xf0f7: "mingww64", + 0xf0f8: "mini", + 0xf0f9: "minio", + 0xf0fa: "mintlify", + 0xf0fb: "minutemailer", + 0xf0fc: "miraheze", + 0xf0fd: "miro", + 0xf0fe: "misskey", + 0xf0ff: "mitsubishi", + 0xf100: "mix", + 0xf101: "mixcloud", + 0xf102: "mixpanel", + 0xf103: "mlb", + 0xf104: "mlflow", + 0xf105: "mobx", + 0xf106: "mobxstatetree", + 0xf107: "mocha", + 0xf108: "mockserviceworker", + 0xf109: "modal", + 0xf10a: "modin", + 0xf10b: "modrinth", + 0xf10c: "modx", + 0xf10d: "mojeek", + 0xf10e: "moleculer", + 0xf10f: "momenteo", + 0xf110: "monero", + 0xf111: "moneygram", + 0xf112: "mongodb", + 0xf113: "mongoose", + 0xf114: "mongoosedotws", + 0xf115: "monica", + 0xf116: "monkeytie", + 0xf117: "monkeytype", + 0xf118: "monogame", + 0xf119: "monoprix", + 0xf11a: "monster", + 0xf11b: "monzo", + 0xf11c: "moo", + 0xf11d: "moodle", + 0xf11e: "moonrepo", + 0xf11f: "moq", + 0xf120: "moqups", + 0xf121: "morrisons", + 0xf122: "moscowmetro", + 0xf123: "motorola", + 0xf124: "movistar", + 0xf125: "mozilla", + 0xf126: "mpv", + 0xf127: "mqtt", + 0xf128: "msi", + 0xf129: "msibusiness", + 0xf12a: "mta", + 0xf12b: "mtr", + 0xf12c: "mubi", + 0xf12d: "mui", + 0xf12f: "muller", + 0xf130: "mullvad", + 0xf131: "multisim", + 0xf132: "mumble", + 0xf133: "muo", + 0xf134: "mural", + 0xf136: "musicbrainz", + 0xf137: "mxlinux", + 0xf138: "myanimelist", + 0xf139: "myget", + 0xf13a: "myob", + 0xf13b: "myspace", + 0xf13c: "mysql", + 0xf13d: "n26", + 0xf13e: "n8n", + 0xf13f: "namebase", + 0xf140: "namecheap", + 0xf141: "namemc", + 0xf142: "namesilo", + 0xf143: "namuwiki", + 0xf144: "nano", + 0xf145: "nanostores", + 0xf146: "napster", + 0xf147: "nasa", + 0xf148: "nationalgrid", + 0xf149: "nationalrail", + 0xf14a: "nativescript", + 0xf14b: "natsdotio", + 0xf14c: "naver", + 0xf14d: "nba", + 0xf14e: "nbb", + 0xf14f: "nbc", + 0xf150: "ndr", + 0xf151: "near", + 0xf152: "nebula", + 0xf153: "nec", + 0xf154: "neo4j", + 0xf155: "neovim", + 0xf156: "neptune", + 0xf157: "nestjs", + 0xf158: "netapp", + 0xf159: "netbsd", + 0xf15a: "netcup", + 0xf15b: "netdata", + 0xf15c: "neteasecloudmusic", + 0xf15d: "netflix", + 0xf15e: "netgear", + 0xf15f: "netlify", + 0xf160: "nette", + 0xf161: "netto", + 0xf162: "neutralinojs", + 0xf163: "newbalance", + 0xf164: "newjapanprowrestling", + 0xf165: "newrelic", + 0xf166: "newyorktimes", + 0xf167: "newegg", + 0xf168: "nexon", + 0xf169: "nextdotjs", + 0xf16a: "nextbilliondotai", + 0xf16b: "nextcloud", + 0xf16c: "nextdns", + 0xf16d: "nextdoor", + 0xf16e: "nextflow", + 0xf16f: "nextra", + 0xf172: "nfcore", + 0xf173: "nfc", + 0xf174: "nginx", + 0xf175: "nginxproxymanager", + 0xf176: "ngrok", + 0xf177: "ngrx", + 0xf178: "nhl", + 0xf179: "nicehash", + 0xf17a: "niconico", + 0xf17b: "nike", + 0xf17c: "nikon", + 0xf17d: "nim", + 0xf17e: "nissan", + 0xf17f: "nixos", + 0xf180: "nodered", + 0xf181: "nodedotjs", + 0xf182: "nodemon", + 0xf183: "nokia", + 0xf184: "nomad", + 0xf185: "norco", + 0xf186: "nordicsemiconductor", + 0xf187: "nordvpn", + 0xf188: "normalizedotcss", + 0xf189: "norton", + 0xf18a: "norwegian", + 0xf18b: "notepadplusplus", + 0xf18c: "notion", + 0xf18d: "notist", + 0xf18e: "nounproject", + 0xf18f: "novu", + 0xf190: "now", + 0xf191: "npm", + 0xf192: "nrwl", + 0xf193: "nsis", + 0xf194: "ntfy", + 0xf195: "nubank", + 0xf196: "nucleo", + 0xf197: "nuget", + 0xf198: "nuke", + 0xf199: "numba", + 0xf19a: "numpy", + 0xf19b: "nunjucks", + 0xf19c: "nushell", + 0xf19d: "nutanix", + 0xf19e: "nuxt", + 0xf19f: "nvidia", + 0xf1a0: "nvm", + 0xf1a1: "nx", + 0xf1a2: "nxp", + 0xf1a3: "nzxt", + 0xf1a4: "oreilly", + 0xf1a5: "o2", + 0xf1a6: "obb", + 0xf1a7: "obsstudio", + 0xf1a8: "observable", + 0xf1a9: "obsidian", + 0xf1aa: "obtainium", + 0xf1ab: "ocaml", + 0xf1ac: "oclc", + 0xf1ad: "oclif", + 0xf1ae: "octanerender", + 0xf1af: "octave", + 0xf1b0: "octobercms", + 0xf1b1: "octoprint", + 0xf1b2: "octopusdeploy", + 0xf1b3: "oculus", + 0xf1b4: "odin", + 0xf1b5: "odnoklassniki", + 0xf1b6: "odoo", + 0xf1b7: "odysee", + 0xf1b8: "ohdear", + 0xf1b9: "okcupid", + 0xf1ba: "okta", + 0xf1bb: "okx", + 0xf1bc: "ollama", + 0xf1bd: "omadacloud", + 0xf1be: "oneplus", + 0xf1bf: "onlyfans", + 0xf1c0: "onlyoffice", + 0xf1c1: "onnx", + 0xf1c2: "onstar", + 0xf1c3: "opel", + 0xf1c4: "openaccess", + 0xf1c5: "openbadges", + 0xf1c6: "openbugbounty", + 0xf1c7: "opencollective", + 0xf1c8: "opencontainersinitiative", + 0xf1c9: "opensourcehardware", + 0xf1ca: "opensourceinitiative", + 0xf1cb: "open3d", + 0xf1cd: "openaigym", + 0xf1ce: "openapiinitiative", + 0xf1cf: "openbsd", + 0xf1d0: "opencv", + 0xf1d1: "openfaas", + 0xf1d2: "opengl", + 0xf1d3: "openhab", + 0xf1d4: "openid", + 0xf1d5: "openjdk", + 0xf1d6: "openjsfoundation", + 0xf1d7: "openlayers", + 0xf1d8: "openmediavault", + 0xf1d9: "openmined", + 0xf1da: "opennebula", + 0xf1db: "openproject", + 0xf1dc: "openscad", + 0xf1dd: "opensea", + 0xf1de: "opensearch", + 0xf1df: "openssl", + 0xf1e0: "openstack", + 0xf1e1: "openstreetmap", + 0xf1e2: "opensuse", + 0xf1e3: "opentelemetry", + 0xf1e4: "opentext", + 0xf1e5: "opentofu", + 0xf1e6: "openverse", + 0xf1e7: "openvpn", + 0xf1e8: "openwrt", + 0xf1e9: "openzeppelin", + 0xf1ea: "openzfs", + 0xf1eb: "opera", + 0xf1ec: "operagx", + 0xf1ed: "opnsense", + 0xf1ee: "oppo", + 0xf1ef: "opsgenie", + 0xf1f0: "opslevel", + 0xf1f1: "optimism", + 0xf1f2: "orange", + 0xf1f3: "orcid", + 0xf1f4: "org", + 0xf1f5: "organicmaps", + 0xf1f6: "origin", + 0xf1f7: "osano", + 0xf1f8: "osf", + 0xf1f9: "osgeo", + 0xf1fa: "oshkosh", + 0xf1fb: "osmc", + 0xf1fc: "osu", + 0xf1fd: "otto", + 0xf1fe: "outline", + 0xf1ff: "overcast", + 0xf200: "overleaf", + 0xf201: "ovh", + 0xf202: "owasp", + 0xf203: "dependencycheck", + 0xf204: "owncloud", + 0xf205: "oxygen", + 0xf206: "oyo", + 0xf207: "p5dotjs", + 0xf208: "packagist", + 0xf209: "packer", + 0xf20a: "packt", + 0xf20b: "paddle", + 0xf20c: "paddlepaddle", + 0xf20d: "paddypower", + 0xf20e: "pagekit", + 0xf20f: "pagerduty", + 0xf210: "pagespeedinsights", + 0xf211: "pagseguro", + 0xf212: "palantir", + 0xf213: "paloaltonetworks", + 0xf214: "paloaltosoftware", + 0xf215: "panasonic", + 0xf216: "pandas", + 0xf217: "pandora", + 0xf218: "pantheon", + 0xf219: "paperlessngx", + 0xf21a: "paperswithcode", + 0xf21b: "paperspace", + 0xf21c: "paradoxinteractive", + 0xf21d: "paramountplus", + 0xf21e: "paritysubstrate", + 0xf21f: "parrotsecurity", + 0xf220: "parsedotly", + 0xf221: "passport", + 0xf222: "pastebin", + 0xf223: "patreon", + 0xf224: "paychex", + 0xf225: "payhip", + 0xf226: "payloadcms", + 0xf227: "payoneer", + 0xf228: "paypal", + 0xf229: "paytm", + 0xf22a: "pcgamingwiki", + 0xf22b: "pdm", + 0xf22c: "pdq", + 0xf22d: "peakdesign", + 0xf22e: "pearson", + 0xf22f: "peerlist", + 0xf230: "peertube", + 0xf231: "pegasusairlines", + 0xf232: "pelican", + 0xf233: "peloton", + 0xf234: "penny", + 0xf235: "penpot", + 0xf236: "percy", + 0xf237: "perforce", + 0xf238: "perl", + 0xf239: "perplexity", + 0xf23a: "persistent", + 0xf23b: "personio", + 0xf23c: "petsathome", + 0xf23d: "peugeot", + 0xf23e: "pexels", + 0xf23f: "pfsense", + 0xf240: "phabricator", + 0xf241: "philipshue", + 0xf242: "phoenixframework", + 0xf243: "phonepe", + 0xf244: "phosphoricons", + 0xf245: "photobucket", + 0xf246: "photocrowd", + 0xf247: "photon", + 0xf248: "photopea", + 0xf249: "php", + 0xf24a: "phpbb", + 0xf24b: "phpmyadmin", + 0xf24c: "phpstorm", + 0xf24d: "pinetwork", + 0xf24e: "pihole", + 0xf24f: "piaggiogroup", + 0xf250: "piapro", + 0xf251: "picardsurgeles", + 0xf252: "picartodottv", + 0xf253: "picnic", + 0xf254: "picpay", + 0xf255: "picrew", + 0xf256: "picsart", + 0xf257: "picxy", + 0xf258: "pimcore", + 0xf259: "pinboard", + 0xf25a: "pinescript", + 0xf25b: "pingdom", + 0xf25c: "pino", + 0xf25d: "pinterest", + 0xf25e: "pioneerdj", + 0xf25f: "piped", + 0xf260: "pipx", + 0xf261: "pivotaltracker", + 0xf262: "piwigo", + 0xf263: "pix", + 0xf264: "pixabay", + 0xf265: "pixelfed", + 0xf266: "pixiv", + 0xf267: "pixlr", + 0xf268: "pkgsrc", + 0xf269: "planet", + 0xf26a: "planetscale", + 0xf26b: "plangrid", + 0xf26c: "platformdotsh", + 0xf26d: "platformio", + 0xf26e: "platzi", + 0xf26f: "plausibleanalytics", + 0xf270: "playcanvas", + 0xf271: "playerfm", + 0xf272: "playerdotme", + 0xf273: "playstation", + 0xf274: "playstation2", + 0xf275: "playstation3", + 0xf276: "playstation4", + 0xf277: "playstation5", + 0xf278: "playstationportable", + 0xf279: "playstationvita", + 0xf27a: "pleroma", + 0xf27b: "plesk", + 0xf27c: "plex", + 0xf27d: "plotly", + 0xf27e: "plume", + 0xf27f: "pluralsight", + 0xf280: "plurk", + 0xf282: "pm2", + 0xf283: "pnpm", + 0xf285: "pocketcasts", + 0xf286: "pocketbase", + 0xf287: "podcastaddict", + 0xf288: "podcastindex", + 0xf289: "podman", + 0xf28a: "poe", + 0xf28b: "poetry", + 0xf28d: "polars", + 0xf28e: "polestar", + 0xf28f: "polkadot", + 0xf290: "poly", + 0xf291: "polygon", + 0xf292: "polymerproject", + 0xf293: "polywork", + 0xf294: "pond5", + 0xf295: "popos", + 0xf296: "porkbun", + 0xf297: "porsche", + 0xf298: "portainer", + 0xf299: "portswigger", + 0xf29a: "posit", + 0xf29b: "postcss", + 0xf29c: "postgresql", + 0xf29d: "posthog", + 0xf29e: "postman", + 0xf29f: "postmates", + 0xf2a0: "powers", + 0xf2a1: "prdotco", + 0xf2a2: "precommit", + 0xf2a3: "preact", + 0xf2a4: "prefect", + 0xf2a5: "premierleague", + 0xf2a6: "prepbytes", + 0xf2a7: "prestashop", + 0xf2a8: "presto", + 0xf2a9: "prettier", + 0xf2aa: "pretzel", + 0xf2ab: "prevention", + 0xf2ac: "prezi", + 0xf2af: "primefaces", + 0xf2b0: "primeng", + 0xf2b1: "primereact", + 0xf2b2: "primevue", + 0xf2b3: "printables", + 0xf2b4: "prisma", + 0xf2b5: "prismic", + 0xf2b6: "privatedivision", + 0xf2b7: "privateinternetaccess", + 0xf2b8: "protools", + 0xf2b9: "probot", + 0xf2ba: "processingfoundation", + 0xf2bb: "processwire", + 0xf2bc: "producthunt", + 0xf2bd: "progate", + 0xf2be: "progress", + 0xf2bf: "prometheus", + 0xf2c0: "pronounsdotpage", + 0xf2c1: "prosieben", + 0xf2c2: "proteus", + 0xf2c3: "protodotio", + 0xf2c4: "protocolsdotio", + 0xf2c5: "proton", + 0xf2c6: "protoncalendar", + 0xf2c7: "protondrive", + 0xf2c8: "protonmail", + 0xf2c9: "protonvpn", + 0xf2ca: "protondb", + 0xf2cb: "protractor", + 0xf2cc: "proxmox", + 0xf2cd: "pterodactyl", + 0xf2ce: "pubg", + 0xf2cf: "publons", + 0xf2d0: "pubmed", + 0xf2d1: "pug", + 0xf2d2: "pulumi", + 0xf2d3: "puma", + 0xf2d4: "puppet", + 0xf2d5: "puppeteer", + 0xf2d6: "purescript", + 0xf2d7: "purgecss", + 0xf2d8: "purism", + 0xf2d9: "pushbullet", + 0xf2da: "pusher", + 0xf2db: "pwa", + 0xf2dc: "pycharm", + 0xf2dd: "pycqa", + 0xf2de: "pydantic", + 0xf2df: "pyg", + 0xf2e0: "pypi", + 0xf2e1: "pypy", + 0xf2e2: "pyscaffold", + 0xf2e3: "pysyft", + 0xf2e4: "pytest", + 0xf2e5: "python", + 0xf2e6: "pythonanywhere", + 0xf2e7: "pytorch", + 0xf2e8: "pyup", + 0xf2e9: "qantas", + 0xf2ea: "qase", + 0xf2eb: "qatarairways", + 0xf2ec: "qbittorrent", + 0xf2ed: "qemu", + 0xf2ee: "qgis", + 0xf2ef: "qi", + 0xf2f0: "qiita", + 0xf2f1: "qiskit", + 0xf2f2: "qiwi", + 0xf2f3: "qlik", + 0xf2f4: "qmk", + 0xf2f5: "qnap", + 0xf2f6: "qq", + 0xf2f7: "qt", + 0xf2f8: "qualcomm", + 0xf2f9: "qualtrics", + 0xf2fa: "qualys", + 0xf2fb: "quantcast", + 0xf2fc: "quantconnect", + 0xf2fd: "quarkus", + 0xf2fe: "quarto", + 0xf2ff: "quasar", + 0xf300: "qubesos", + 0xf301: "quest", + 0xf302: "quickbooks", + 0xf303: "quicklook", + 0xf304: "quicktime", + 0xf305: "quicktype", + 0xf307: "quizlet", + 0xf308: "quora", + 0xf309: "qwant", + 0xf30a: "qwik", + 0xf30b: "qwiklabs", + 0xf30c: "qzone", + 0xf30d: "r", + 0xf30e: "r3", + 0xf30f: "rabbitmq", + 0xf310: "racket", + 0xf311: "radstudio", + 0xf312: "radar", + 0xf313: "radarr", + 0xf314: "radixui", + 0xf315: "railway", + 0xf316: "rainmeter", + 0xf317: "rakuten", + 0xf318: "ram", + 0xf319: "rancher", + 0xf31a: "rapid", + 0xf31b: "rarible", + 0xf31c: "rasa", + 0xf31d: "raspberrypi", + 0xf31e: "ravelry", + 0xf31f: "ray", + 0xf320: "raycast", + 0xf321: "raylib", + 0xf322: "razer", + 0xf323: "razorpay", + 0xf324: "rclone", + 0xf325: "react", + 0xf326: "reactbootstrap", + 0xf327: "reacthookform", + 0xf328: "reactquery", + 0xf329: "reactrouter", + 0xf32a: "reacttable", + 0xf32b: "reactiveresume", + 0xf32c: "reactivex", + 0xf32d: "reactos", + 0xf32e: "readthedocs", + 0xf32f: "readdotcv", + 0xf330: "readme", + 0xf331: "reason", + 0xf332: "reasonstudios", + 0xf333: "recoil", + 0xf334: "red", + 0xf335: "redbull", + 0xf336: "redcandlegames", + 0xf337: "redhat", + 0xf338: "redhatopenshift", + 0xf339: "redash", + 0xf33a: "redbubble", + 0xf33b: "reddit", + 0xf33c: "redis", + 0xf33d: "redmine", + 0xf33e: "redox", + 0xf33f: "redragon", + 0xf340: "redsys", + 0xf341: "redux", + 0xf342: "reduxsaga", + 0xf343: "redwoodjs", + 0xf344: "reebok", + 0xf345: "refine", + 0xf346: "refinedgithub", + 0xf347: "relay", + 0xf348: "relianceindustrieslimited", + 0xf349: "remark", + 0xf34a: "remedyentertainment", + 0xf34b: "remix", + 0xf34c: "removedotbg", + 0xf34d: "renpy", + 0xf34e: "renault", + 0xf34f: "render", + 0xf350: "renovate", + 0xf351: "renren", + 0xf352: "replicate", + 0xf353: "replit", + 0xf354: "republicofgamers", + 0xf355: "rescript", + 0xf356: "rescuetime", + 0xf357: "researchgate", + 0xf358: "resend", + 0xf359: "resharper", + 0xf35a: "resurrectionremixos", + 0xf35b: "retool", + 0xf35c: "retroarch", + 0xf35d: "retropie", + 0xf35e: "revanced", + 0xf35f: "revealdotjs", + 0xf360: "reverbnation", + 0xf361: "revoltdotchat", + 0xf362: "revolut", + 0xf363: "rewe", + 0xf364: "rezgo", + 0xf365: "rhinoceros", + 0xf366: "rich", + 0xf367: "rider", + 0xf368: "rimacautomobili", + 0xf369: "rime", + 0xf36a: "ring", + 0xf36b: "riotgames", + 0xf36c: "ripple", + 0xf36d: "riscv", + 0xf36e: "riseup", + 0xf36f: "ritzcarlton", + 0xf370: "rive", + 0xf371: "roadmapdotsh", + 0xf372: "roamresearch", + 0xf373: "robinhood", + 0xf374: "roblox", + 0xf375: "robloxstudio", + 0xf376: "roboflow", + 0xf377: "robotframework", + 0xf378: "rocket", + 0xf379: "rocketdotchat", + 0xf37a: "rocksdb", + 0xf37b: "rockstargames", + 0xf37c: "rockwellautomation", + 0xf37d: "rockylinux", + 0xf37e: "roku", + 0xf37f: "roll20", + 0xf380: "rollsroyce", + 0xf381: "rollupdotjs", + 0xf382: "rook", + 0xf383: "roon", + 0xf384: "rootme", + 0xf385: "roots", + 0xf386: "rootsbedrock", + 0xf387: "rootssage", + 0xf388: "ros", + 0xf389: "rossmann", + 0xf38a: "rotaryinternational", + 0xf38b: "rottentomatoes", + 0xf38c: "roundcube", + 0xf38d: "rsocket", + 0xf38e: "rss", + 0xf38f: "rstudioide", + 0xf390: "rte", + 0xf391: "rtl", + 0xf392: "rtlzwei", + 0xf393: "rtm", + 0xf394: "rubocop", + 0xf395: "ruby", + 0xf396: "rubyonrails", + 0xf397: "rubysinatra", + 0xf398: "rubygems", + 0xf399: "rubymine", + 0xf39a: "ruff", + 0xf39b: "rumahweb", + 0xf39c: "rumble", + 0xf39d: "rundeck", + 0xf39e: "runkeeper", + 0xf39f: "runkit", + 0xf3a0: "runrundotit", + 0xf3a1: "rust", + 0xf3a2: "rustdesk", + 0xf3a3: "rxdb", + 0xf3a4: "ryanair", + 0xf3a5: "rye", + 0xf3a6: "s7airlines", + 0xf3a7: "sabanci", + 0xf3a8: "safari", + 0xf3a9: "sage", + 0xf3aa: "sahibinden", + 0xf3ab: "sailfishos", + 0xf3ac: "sailsdotjs", + 0xf3ae: "salla", + 0xf3af: "saltproject", + 0xf3b0: "samsclub", + 0xf3b1: "samsung", + 0xf3b2: "samsungpay", + 0xf3b3: "sanfranciscomunicipalrailway", + 0xf3b5: "sanic", + 0xf3b6: "sanity", + 0xf3b7: "saopaulometro", + 0xf3b8: "sap", + 0xf3b9: "sartorius", + 0xf3ba: "sass", + 0xf3bb: "sat1", + 0xf3bc: "satellite", + 0xf3bd: "saturn", + 0xf3be: "saucelabs", + 0xf3bf: "saudia", + 0xf3c0: "scala", + 0xf3c1: "scalar", + 0xf3c2: "scaleway", + 0xf3c3: "scania", + 0xf3c4: "schneiderelectric", + 0xf3c5: "scikitlearn", + 0xf3c6: "scilab", + 0xf3c7: "scipy", + 0xf3c8: "scopus", + 0xf3c9: "scpfoundation", + 0xf3ca: "scrapbox", + 0xf3cb: "scrapy", + 0xf3cc: "scratch", + 0xf3cd: "screencastify", + 0xf3cf: "scrimba", + 0xf3d0: "scrollreveal", + 0xf3d1: "scrumalliance", + 0xf3d2: "scrutinizerci", + 0xf3d3: "scylladb", + 0xf3d4: "seagate", + 0xf3d5: "searxng", + 0xf3d6: "seat", + 0xf3d7: "seatgeek", + 0xf3d8: "securityscorecard", + 0xf3d9: "sefaria", + 0xf3da: "sega", + 0xf3db: "selenium", + 0xf3dc: "sellfy", + 0xf3dd: "semanticscholar", + 0xf3de: "semanticui", + 0xf3df: "semanticuireact", + 0xf3e0: "semanticweb", + 0xf3e1: "semanticrelease", + 0xf3e2: "semaphoreci", + 0xf3e3: "semrush", + 0xf3e4: "semver", + 0xf3e5: "sencha", + 0xf3e7: "sennheiser", + 0xf3e8: "sensu", + 0xf3e9: "sentry", + 0xf3ea: "sepa", + 0xf3eb: "sequelize", + 0xf3ec: "serverfault", + 0xf3ed: "serverless", + 0xf3ee: "session", + 0xf3ef: "sessionize", + 0xf3f0: "setapp", + 0xf3f1: "sfml", + 0xf3f2: "shadcnui", + 0xf3f3: "shadow", + 0xf3f4: "shanghaimetro", + 0xf3f5: "sharex", + 0xf3f6: "sharp", + 0xf3f7: "shazam", + 0xf3f8: "shell", + 0xf3f9: "shelly", + 0xf3fa: "shenzhenmetro", + 0xf3fb: "shieldsdotio", + 0xf3fc: "shikimori", + 0xf3fd: "shopee", + 0xf3fe: "shopify", + 0xf3ff: "shopware", + 0xf400: "shortcut", + 0xf401: "showpad", + 0xf402: "showtime", + 0xf403: "showwcase", + 0xf405: "sidekiq", + 0xf406: "sidequest", + 0xf407: "siemens", + 0xf408: "sifive", + 0xf409: "signal", + 0xf40a: "silverairways", + 0xf40b: "similarweb", + 0xf40c: "simkl", + 0xf40d: "simpleanalytics", + 0xf40e: "simpleicons", + 0xf40f: "simplelogin", + 0xf410: "simplenote", + 0xf411: "simplex", + 0xf412: "sinaweibo", + 0xf413: "singaporeairlines", + 0xf414: "singlestore", + 0xf415: "sitecore", + 0xf416: "sitepoint", + 0xf417: "siyuan", + 0xf418: "skaffold", + 0xf419: "sketch", + 0xf41a: "sketchfab", + 0xf41b: "sketchup", + 0xf41c: "skillshare", + 0xf41d: "skoda", + 0xf41e: "sky", + 0xf41f: "skypack", + 0xf421: "slackware", + 0xf422: "slashdot", + 0xf423: "slickpic", + 0xf424: "slides", + 0xf425: "slideshare", + 0xf426: "slint", + 0xf427: "smart", + 0xf428: "smartthings", + 0xf429: "smashingmagazine", + 0xf42a: "smrt", + 0xf42b: "smugmug", + 0xf42c: "snapchat", + 0xf42d: "snapcraft", + 0xf42e: "snapdragon", + 0xf42f: "sncf", + 0xf430: "snort", + 0xf431: "snowflake", + 0xf432: "snowpack", + 0xf433: "snyk", + 0xf434: "socialblade", + 0xf435: "society6", + 0xf436: "socket", + 0xf437: "socketdotio", + 0xf438: "softcatala", + 0xf439: "softpedia", + 0xf43a: "sogou", + 0xf43b: "solana", + 0xf43c: "solid", + 0xf43d: "solidity", + 0xf43e: "sololearn", + 0xf43f: "solus", + 0xf440: "sonar", + 0xf444: "sonarr", + 0xf445: "sonatype", + 0xf446: "songkick", + 0xf447: "songoda", + 0xf448: "sonicwall", + 0xf449: "sonos", + 0xf44a: "sony", + 0xf44b: "soriana", + 0xf44c: "soundcharts", + 0xf44d: "soundcloud", + 0xf44e: "sourceengine", + 0xf44f: "sourceforge", + 0xf450: "sourcehut", + 0xf451: "sourcetree", + 0xf452: "southwestairlines", + 0xf453: "spacemacs", + 0xf454: "spaceship", + 0xf455: "spacex", + 0xf456: "spacy", + 0xf457: "sparkar", + 0xf458: "sparkasse", + 0xf459: "sparkfun", + 0xf45a: "sparkpost", + 0xf45b: "spdx", + 0xf45c: "speakerdeck", + 0xf45d: "spectrum", + 0xf45e: "speedtest", + 0xf45f: "speedypage", + 0xf460: "spoj", + 0xf461: "sphinx", + 0xf462: "spigotmc", + 0xf463: "spine", + 0xf464: "spinnaker", + 0xf465: "splunk", + 0xf466: "spond", + 0xf467: "spotify", + 0xf468: "spotlight", + 0xf469: "spreadshirt", + 0xf46a: "spreaker", + 0xf46b: "spring", + 0xf46c: "springCreators", + 0xf46d: "springboot", + 0xf46e: "springsecurity", + 0xf46f: "spyderide", + 0xf470: "sqlalchemy", + 0xf471: "sqlite", + 0xf472: "square", + 0xf473: "squareenix", + 0xf474: "squarespace", + 0xf475: "srgssr", + 0xf476: "ssrn", + 0xf477: "sst", + 0xf478: "stackexchange", + 0xf479: "stackoverflow", + 0xf47a: "stackbit", + 0xf47b: "stackblitz", + 0xf47c: "stackedit", + 0xf47d: "stackhawk", + 0xf47e: "stackshare", + 0xf47f: "stadia", + 0xf480: "staffbase", + 0xf481: "stagetimer", + 0xf482: "standardresume", + 0xf483: "standardjs", + 0xf484: "startrek", + 0xf485: "starbucks", + 0xf486: "stardock", + 0xf487: "starlingbank", + 0xf488: "starship", + 0xf489: "startdotgg", + 0xf48a: "startpage", + 0xf48b: "starz", + 0xf48c: "statamic", + 0xf48d: "statista", + 0xf48e: "statuspage", + 0xf48f: "statuspal", + 0xf490: "steam", + 0xf491: "steamdeck", + 0xf492: "steamdb", + 0xf493: "steamworks", + 0xf494: "steelseries", + 0xf495: "steem", + 0xf496: "steemit", + 0xf497: "steinberg", + 0xf498: "stellar", + 0xf499: "stencil", + 0xf49a: "stencyl", + 0xf49b: "stimulus", + 0xf49c: "stmicroelectronics", + 0xf49d: "stockx", + 0xf49e: "stopstalk", + 0xf49f: "storyblok", + 0xf4a0: "storybook", + 0xf4a1: "strapi", + 0xf4a2: "strava", + 0xf4a3: "streamlabs", + 0xf4a4: "streamlit", + 0xf4a5: "streamrunners", + 0xf4a6: "stremio", + 0xf4a7: "stripe", + 0xf4a8: "strongswan", + 0xf4a9: "stryker", + 0xf4aa: "stubhub", + 0xf4ab: "studio3t", + 0xf4ac: "styledcomponents", + 0xf4ad: "stylelint", + 0xf4ae: "styleshare", + 0xf4af: "stylus", + 0xf4b0: "subaru", + 0xf4b1: "sublimetext", + 0xf4b2: "substack", + 0xf4b3: "subtitleedit", + 0xf4b4: "subversion", + 0xf4b5: "suckless", + 0xf4b6: "sui", + 0xf4b7: "sumologic", + 0xf4b8: "suno", + 0xf4b9: "sunrise", + 0xf4ba: "supabase", + 0xf4bb: "superuser", + 0xf4bc: "supercrease", + 0xf4bd: "supermicro", + 0xf4be: "surfshark", + 0xf4bf: "surrealdb", + 0xf4c0: "surveymonkey", + 0xf4c1: "suse", + 0xf4c2: "suzuki", + 0xf4c3: "svelte", + 0xf4c4: "svg", + 0xf4c5: "svgdotjs", + 0xf4c6: "svgo", + 0xf4c7: "svgtrace", + 0xf4c8: "swagger", + 0xf4c9: "swarm", + 0xf4ca: "sway", + 0xf4cb: "swc", + 0xf4cc: "swift", + 0xf4cd: "swiggy", + 0xf4ce: "swiper", + 0xf4cf: "swr", + 0xf4d0: "symantec", + 0xf4d1: "symbolab", + 0xf4d2: "symfony", + 0xf4d3: "symphony", + 0xf4d4: "sympy", + 0xf4d5: "syncthing", + 0xf4d6: "synology", + 0xf4d7: "system76", + 0xf4d8: "tabelog", + 0xf4d9: "tablecheck", + 0xf4da: "tacobell", + 0xf4db: "tado", + 0xf4dc: "taichigraphics", + 0xf4dd: "taichilang", + 0xf4de: "tails", + 0xf4df: "tailscale", + 0xf4e0: "tailwindcss", + 0xf4e1: "taipy", + 0xf4e2: "taketwointeractivesoftware", + 0xf4e3: "talend", + 0xf4e4: "talenthouse", + 0xf4e5: "talos", + 0xf4e6: "tamiya", + 0xf4e7: "tampermonkey", + 0xf4e8: "taobao", + 0xf4e9: "tapas", + 0xf4ea: "target", + 0xf4eb: "tarom", + 0xf4ec: "task", + 0xf4ed: "tasmota", + 0xf4ee: "tata", + 0xf4ef: "tcs", + 0xf4f0: "tauri", + 0xf4f1: "taxbuzz", + 0xf4f2: "teal", + 0xf4f3: "teamcity", + 0xf4f4: "teamspeak", + 0xf4f5: "teamviewer", + 0xf4f6: "techcrunch", + 0xf4f7: "ted", + 0xf4f8: "teepublic", + 0xf4f9: "teespring", + 0xf4fa: "tekton", + 0xf4fb: "tele5", + 0xf4fc: "telequebec", + 0xf4fd: "telefonica", + 0xf4fe: "telegram", + 0xf4ff: "telegraph", + 0xf500: "temporal", + 0xf501: "tensorflow", + 0xf502: "teradata", + 0xf503: "teratail", + 0xf504: "termius", + 0xf505: "terraform", + 0xf506: "tesco", + 0xf507: "tesla", + 0xf508: "testcafe", + 0xf509: "testin", + 0xf50a: "testinglibrary", + 0xf50b: "testrail", + 0xf50c: "tether", + 0xf50d: "textpattern", + 0xf50e: "tga", + 0xf50f: "thangs", + 0xf510: "thanos", + 0xf511: "thealgorithms", + 0xf512: "theboringcompany", + 0xf513: "theconversation", + 0xf514: "thefinals", + 0xf515: "theguardian", + 0xf516: "theirishtimes", + 0xf517: "themighty", + 0xf518: "themodelsresource", + 0xf519: "themoviedatabase", + 0xf51a: "thenorthface", + 0xf51b: "theodinproject", + 0xf51c: "theregister", + 0xf51d: "thesoundsresource", + 0xf51e: "thespritersresource", + 0xf51f: "thewashingtonpost", + 0xf520: "theweatherchannel", + 0xf521: "thingiverse", + 0xf522: "thinkpad", + 0xf523: "thirdweb", + 0xf524: "threadless", + 0xf525: "threads", + 0xf526: "threedotjs", + 0xf527: "threema", + 0xf528: "thumbtack", + 0xf529: "thunderbird", + 0xf52a: "thunderstore", + 0xf52b: "thurgauerkantonalbank", + 0xf52c: "thymeleaf", + 0xf52d: "ticketmaster", + 0xf52e: "ticktick", + 0xf52f: "tidal", + 0xf530: "tiddlywiki", + 0xf531: "tide", + 0xf532: "tidyverse", + 0xf533: "tietoevry", + 0xf534: "tiktok", + 0xf535: "tildapublishing", + 0xf536: "tile", + 0xf537: "timescale", + 0xf538: "tina", + 0xf539: "tinder", + 0xf53a: "tindie", + 0xf53b: "tinkercad", + 0xf53c: "tinygrad", + 0xf53d: "tinyletter", + 0xf53e: "tistory", + 0xf53f: "tldraw", + 0xf540: "tmux", + 0xf541: "todoist", + 0xf542: "toggl", + 0xf543: "toggltrack", + 0xf544: "tokyometro", + 0xf545: "toll", + 0xf546: "toml", + 0xf547: "tomorrowland", + 0xf548: "tomtom", + 0xf549: "ton", + 0xf54a: "topdotgg", + 0xf54b: "topcoder", + 0xf54c: "toptal", + 0xf54d: "torbrowser", + 0xf54e: "torproject", + 0xf54f: "torizon", + 0xf550: "toshiba", + 0xf551: "totvs", + 0xf552: "tourbox", + 0xf553: "tower", + 0xf554: "toyota", + 0xf555: "tplink", + 0xf556: "tqdm", + 0xf557: "traccar", + 0xf558: "tradingview", + 0xf559: "traefikmesh", + 0xf55a: "traefikproxy", + 0xf55b: "trailforks", + 0xf55c: "trainerroad", + 0xf55d: "trakt", + 0xf55e: "transifex", + 0xf55f: "transmission", + 0xf560: "transportforireland", + 0xf561: "transportforlondon", + 0xf562: "travisci", + 0xf563: "treehouse", + 0xf564: "trello", + 0xf565: "trendmicro", + 0xf566: "treyarch", + 0xf567: "tricentis", + 0xf568: "trilium", + 0xf569: "triller", + 0xf56a: "trillertv", + 0xf56b: "trimble", + 0xf56c: "trino", + 0xf56d: "tripdotcom", + 0xf56e: "tripadvisor", + 0xf56f: "trivago", + 0xf570: "trivy", + 0xf571: "trove", + 0xf572: "trpc", + 0xf573: "truenas", + 0xf574: "trueup", + 0xf575: "trulia", + 0xf576: "trustedshops", + 0xf577: "trustpilot", + 0xf578: "tryitonline", + 0xf579: "tryhackme", + 0xf57a: "tsnode", + 0xf57b: "tubi", + 0xf57c: "tui", + 0xf57d: "tumblr", + 0xf57f: "turbo", + 0xf580: "turborepo", + 0xf581: "turbosquid", + 0xf582: "turkishairlines", + 0xf583: "turso", + 0xf584: "tuta", + 0xf585: "tvtime", + 0xf586: "tv4play", + 0xf588: "twinkly", + 0xf589: "twinmotion", + 0xf58a: "twitch", + 0xf58b: "typeform", + 0xf58c: "typeorm", + 0xf58d: "typer", + 0xf58e: "typescript", + 0xf58f: "typo3", + 0xf590: "typst", + 0xf591: "udotsdotnews", + 0xf592: "uber", + 0xf593: "ubereats", + 0xf594: "ubiquiti", + 0xf595: "ubisoft", + 0xf596: "ublockorigin", + 0xf597: "ubuntu", + 0xf598: "ubuntumate", + 0xf599: "udacity", + 0xf59a: "udemy", + 0xf59b: "ufc", + 0xf59c: "uikit", + 0xf59d: "uipath", + 0xf59e: "ukca", + 0xf59f: "ultralytics", + 0xf5a0: "ulule", + 0xf5a1: "umami", + 0xf5a2: "umbraco", + 0xf5a3: "uml", + 0xf5a4: "unacademy", + 0xf5a5: "underarmour", + 0xf5a6: "underscoredotjs", + 0xf5a7: "undertale", + 0xf5a8: "unicode", + 0xf5a9: "unilever", + 0xf5aa: "uniqlo", + 0xf5ab: "uniqloJa", + 0xf5ac: "unitedairlines", + 0xf5ad: "unitednations", + 0xf5ae: "unity", + 0xf5af: "unjs", + 0xf5b0: "unlicense", + 0xf5b1: "unocss", + 0xf5b2: "unpkg", + 0xf5b3: "unraid", + 0xf5b4: "unrealengine", + 0xf5b5: "unsplash", + 0xf5b6: "untappd", + 0xf5b7: "upcloud", + 0xf5b8: "uphold", + 0xf5b9: "uplabs", + 0xf5ba: "upptime", + 0xf5bb: "ups", + 0xf5bc: "upstash", + 0xf5bd: "uptimekuma", + 0xf5be: "upwork", + 0xf5bf: "usps", + 0xf5c0: "utorrent", + 0xf5c1: "uv", + 0xf5c2: "v", + 0xf5c3: "v0", + 0xf5c4: "v2ex", + 0xf5c5: "v8", + 0xf5c6: "vaadin", + 0xf5c7: "vagrant", + 0xf5c8: "vala", + 0xf5c9: "valorant", + 0xf5ca: "valve", + 0xf5cb: "vapor", + 0xf5cc: "vault", + 0xf5cd: "vaultwarden", + 0xf5ce: "vauxhall", + 0xf5cf: "vbulletin", + 0xf5d0: "vectary", + 0xf5d1: "vectorlogozone", + 0xf5d2: "vectorworks", + 0xf5d3: "veeam", + 0xf5d4: "veed", + 0xf5d5: "veepee", + 0xf5d6: "vega", + 0xf5d7: "vegas", + 0xf5d8: "velocity", + 0xf5d9: "velog", + 0xf5da: "vencord", + 0xf5db: "venmo", + 0xf5dc: "vercel", + 0xf5dd: "verdaccio", + 0xf5de: "veritas", + 0xf5df: "verizon", + 0xf5e0: "vespa", + 0xf5e1: "vestel", + 0xf5e2: "vexxhost", + 0xf5e3: "vfairs", + 0xf5e4: "viadeo", + 0xf5e5: "viaplay", + 0xf5e6: "viber", + 0xf5e7: "viblo", + 0xf5e8: "victoriametrics", + 0xf5e9: "victronenergy", + 0xf5ea: "vim", + 0xf5eb: "vimeo", + 0xf5ec: "vimeolivestream", + 0xf5ed: "virgin", + 0xf5ee: "virginatlantic", + 0xf5ef: "virginmedia", + 0xf5f0: "virtualbox", + 0xf5f1: "virustotal", + 0xf5f2: "visa", + 0xf5f3: "visx", + 0xf5f4: "vite", + 0xf5f5: "vitepress", + 0xf5f6: "vitess", + 0xf5f7: "vitest", + 0xf5f8: "vivawallet", + 0xf5f9: "vivaldi", + 0xf5fa: "vivino", + 0xf5fb: "vivint", + 0xf5fc: "vivo", + 0xf5fd: "vk", + 0xf5fe: "vlcmediaplayer", + 0xf5ff: "vmware", + 0xf600: "vodafone", + 0xf601: "voidlinux", + 0xf602: "voipdotms", + 0xf603: "volkswagen", + 0xf604: "volvo", + 0xf605: "vonage", + 0xf606: "vorondesign", + 0xf607: "vowpalwabbit", + 0xf608: "vox", + 0xf609: "vrchat", + 0xf60a: "vsco", + 0xf60b: "vscodium", + 0xf60c: "vtex", + 0xf60d: "vuedotjs", + 0xf60e: "vuetify", + 0xf60f: "vulkan", + 0xf610: "vultr", + 0xf611: "vyond", + 0xf612: "w3schools", + 0xf613: "wacom", + 0xf614: "wagmi", + 0xf615: "wagtail", + 0xf616: "wails", + 0xf617: "wakatime", + 0xf618: "walkman", + 0xf619: "wallabag", + 0xf61a: "walletconnect", + 0xf61c: "wantedly", + 0xf61d: "wappalyzer", + 0xf61f: "warp", + 0xf620: "wasabi", + 0xf621: "wasmcloud", + 0xf622: "wasmer", + 0xf623: "watchtower", + 0xf624: "wattpad", + 0xf625: "wayland", + 0xf626: "waze", + 0xf627: "wazirx", + 0xf628: "wearos", + 0xf629: "weasyl", + 0xf62a: "webdotde", + 0xf62b: "web3dotjs", + 0xf62c: "webassembly", + 0xf62d: "webauthn", + 0xf62e: "webcomponentsdotorg", + 0xf62f: "webdriverio", + 0xf630: "webex", + 0xf631: "webflow", + 0xf632: "webgl", + 0xf633: "webgpu", + 0xf634: "weblate", + 0xf635: "webmin", + 0xf636: "webmoney", + 0xf637: "webpack", + 0xf638: "webrtc", + 0xf639: "webstorm", + 0xf63a: "webtoon", + 0xf63b: "webtrees", + 0xf63c: "wechat", + 0xf63d: "wegame", + 0xf63e: "weightsandbiases", + 0xf63f: "welcometothejungle", + 0xf640: "wellfound", + 0xf641: "wellsfargo", + 0xf642: "wemo", + 0xf644: "westernunion", + 0xf645: "wetransfer", + 0xf646: "wezterm", + 0xf647: "wgpu", + 0xf648: "whatsapp", + 0xf649: "wheniwork", + 0xf64a: "wikidotgg", + 0xf64b: "wikidotjs", + 0xf64c: "wikibooks", + 0xf64d: "wikidata", + 0xf64e: "wikimediacommons", + 0xf64f: "wikimediafoundation", + 0xf650: "wikipedia", + 0xf651: "wikiquote", + 0xf652: "wikiversity", + 0xf653: "wikivoyage", + 0xf654: "winamp", + 0xf655: "wine", + 0xf656: "wipro", + 0xf657: "wire", + 0xf658: "wireguard", + 0xf659: "wireshark", + 0xf65a: "wise", + 0xf65b: "wish", + 0xf65c: "wistia", + 0xf65d: "wix", + 0xf65e: "wizzair", + 0xf65f: "wolfram", + 0xf660: "wolframlanguage", + 0xf661: "wolframmathematica", + 0xf662: "wondershare", + 0xf663: "wondersharefilmora", + 0xf664: "woo", + 0xf665: "woocommerce", + 0xf666: "wordpress", + 0xf667: "workplace", + 0xf668: "worldhealthorganization", + 0xf669: "wpengine", + 0xf66a: "wprocket", + 0xf66b: "wpexplorer", + 0xf66c: "writedotas", + 0xf66d: "wwe", + 0xf66e: "wwise", + 0xf66f: "wxt", + 0xf670: "wykop", + 0xf671: "wyze", + 0xf672: "x", + 0xf673: "xdotorg", + 0xf674: "xampp", + 0xf675: "xcode", + 0xf676: "xdadevelopers", + 0xf677: "xendit", + 0xf678: "xero", + 0xf679: "xfce", + 0xf67a: "xiaohongshu", + 0xf67b: "xiaomi", + 0xf67c: "xing", + 0xf67d: "xml", + 0xf67e: "xmpp", + 0xf67f: "xo", + 0xf680: "xrp", + 0xf681: "xsplit", + 0xf682: "xstate", + 0xf683: "xubuntu", + 0xf684: "ycombinator", + 0xf685: "yabai", + 0xf686: "yale", + 0xf687: "yamahacorporation", + 0xf688: "yamahamotorcorporation", + 0xf689: "yaml", + 0xf68a: "yandexcloud", + 0xf68b: "yarn", + 0xf68c: "yelp", + 0xf68d: "yeti", + 0xf68e: "yii", + 0xf68f: "yoast", + 0xf690: "yolo", + 0xf691: "youhodler", + 0xf692: "youtube", + 0xf693: "youtubegaming", + 0xf694: "youtubekids", + 0xf695: "youtubemusic", + 0xf696: "youtubeshorts", + 0xf697: "youtubestudio", + 0xf698: "youtubetv", + 0xf699: "yr", + 0xf69a: "yubico", + 0xf69b: "yunohost", + 0xf69c: "zabka", + 0xf69d: "zaim", + 0xf69e: "zalando", + 0xf69f: "zalo", + 0xf6a0: "zap", + 0xf6a1: "zapier", + 0xf6a2: "zara", + 0xf6a3: "zazzle", + 0xf6a4: "zcash", + 0xf6a5: "zcool", + 0xf6a6: "zdf", + 0xf6a7: "zebpay", + 0xf6a8: "zebratechnologies", + 0xf6a9: "zedindustries", + 0xf6aa: "zelle", + 0xf6ab: "zend", + 0xf6ac: "zendesk", + 0xf6ad: "zenn", + 0xf6ae: "zenodo", + 0xf6af: "zensar", + 0xf6b0: "zerodha", + 0xf6b1: "zerotier", + 0xf6b2: "zettlr", + 0xf6b3: "zhihu", + 0xf6b4: "zig", + 0xf6b5: "zigbee", + 0xf6b6: "zigbee2mqtt", + 0xf6b7: "ziggo", + 0xf6b8: "zilch", + 0xf6b9: "zillow", + 0xf6ba: "zincsearch", + 0xf6bb: "zingat", + 0xf6bc: "zod", + 0xf6bd: "zoho", + 0xf6be: "zoiper", + 0xf6bf: "zomato", + 0xf6c0: "zoom", + 0xf6c1: "zorin", + 0xf6c2: "zotero", + 0xf6c3: "zsh", + 0xf6c4: "zulip", + 0xf6c5: "zyte", }; diff --git a/lib/graceful_migrations.dart b/lib/graceful_migrations.dart index 40fb0c28..7266cdf2 100644 --- a/lib/graceful_migrations.dart +++ b/lib/graceful_migrations.dart @@ -15,7 +15,6 @@ import "package:flow/services/user_preferences.dart"; import "package:flow/utils/utils.dart"; import "package:logging/logging.dart"; import "package:shared_preferences/shared_preferences.dart"; -import "package:simple_icons_flow/simple_icons_flow.dart"; final Logger _log = Logger("GracefulMigrations"); @@ -321,8 +320,8 @@ void migrateHomePendingTransactionsRange() async { /// /// Simple Icons reassigns code points every release, so a stored code point is /// only meaningful for the version it was saved with. Flow shipped -/// simple_icons 14.6.1; [legacySimpleIconsCodepoints] maps those code points -/// forward to the bundled 16.20.0 build, from which we recover the stable slug. +/// simple_icons 14.6.1; [legacySimpleIconsCodepointToSlug] maps those code +/// points straight to their stable slug, independent of the bundled version. /// This is the *only* remaining use of that table — once this migration has /// propagated, the migration and the table can both be deleted. Future<void> migrateSimpleIconsToSlug() async { @@ -339,23 +338,14 @@ Future<void> migrateSimpleIconsToSlug() async { if (ok != null) return; try { - // 16.20.0 code point -> slug, built once from the bundled font. - final Map<int, String> codePointToSlug = { - for (final entry in SimpleIcons.values.entries) - entry.value.codePoint: entry.key, - }; - String? slugForIconCode(String iconCode) { final FlowIconData? parsed = FlowIconData.tryParse(iconCode); if (parsed is! IconFlowIcon) return null; if (parsed.iconData.fontFamily != "SimpleIcons") return null; - // Stored code points are 14.6.1; map them forward before resolving. - // A value already at 16.20.0 isn't a table key, so it passes through. - final int codePoint = - legacySimpleIconsCodepoints[parsed.iconData.codePoint] ?? - parsed.iconData.codePoint; - return codePointToSlug[codePoint]; + // Stored code points are 14.6.1. Slugs missing from the bundled + // build (removed upstream) are left as they are. + return legacySimpleIconsCodepointToSlug[parsed.iconData.codePoint]; } final List<Account> changedAccounts = []; diff --git a/pubspec.yaml b/pubspec.yaml index 39d05c1c..171d6633 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,7 +57,7 @@ dependencies: lottie: ^3.3.2 markdown: ^7.3.1 markdown_quill: ^4.3.0 - material_symbols_icons_flow: ^4.2928.1 + material_symbols_icons_flow: ^4.2960.0 moment_dart: ^5.3.2 objectbox: ^5.1.0 objectbox_flutter_libs: ^5.1.0 @@ -73,7 +73,7 @@ dependencies: shake: ^3.0.0 share_plus: ^12.0.1 shared_preferences: ^2.5.4 - simple_icons_flow: ^16.20.0 + simple_icons_flow: ^16.23.0 smooth_page_indicator: ^2.0.1 timezone: ^0.11.0 toastification: ^3.0.3 From 45389665305baadf8fe47c25ed6d101988560693 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Tue, 29 Sep 2026 14:41:44 +0800 Subject: [PATCH 42/57] fix mask numbers at startup --- lib/graceful_migrations.dart | 5 +++++ lib/main.dart | 10 ++++++++++ lib/prefs/transitive.dart | 7 ------- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/graceful_migrations.dart b/lib/graceful_migrations.dart index 7266cdf2..c6ffbef0 100644 --- a/lib/graceful_migrations.dart +++ b/lib/graceful_migrations.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:flow/data/flow_icon.dart"; import "package:flow/data/legacy_simple_icons_codepoints.dart"; import "package:flow/data/transaction_filter.dart"; @@ -70,6 +72,9 @@ void migratePrivacyPreferencesToUserPreferences() async { final bool privacyMode = LocalPreferences().privacyMode.get(); UserPreferencesService().privacyModeUponLaunch = privacyMode; + if (privacyMode) { + unawaited(TransitiveLocalPreferences().sessionPrivacyMode.set(true)); + } await prefs.setString("flow.migration.$migrationUuid", "ok"); } catch (e) { diff --git a/lib/main.dart b/lib/main.dart index a3e938cc..2b4374c9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -143,6 +143,16 @@ void main() async { startupLog.severe("Failed to initialize UserPreferencesService", e); } + // Must run after [UserPreferencesService] has loaded from ObjectBox + unawaited( + TransitiveLocalPreferences().sessionPrivacyMode + .set(UserPreferencesService().privacyModeUponLaunch) + .catchError((error) { + startupLog.warning("Failed to seed session privacy mode", error); + return false; + }), + ); + // Depends on [UserPreferencesService] for the primary currency startupLog.fine("Initializing exchange rates service"); ExchangeRatesService().init(); diff --git a/lib/prefs/transitive.dart b/lib/prefs/transitive.dart index 3f8067e8..17b31146 100644 --- a/lib/prefs/transitive.dart +++ b/lib/prefs/transitive.dart @@ -10,7 +10,6 @@ import "package:flow/entity/category.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction_tag.dart"; import "package:flow/objectbox.dart"; -import "package:flow/prefs/local_preferences.dart"; import "package:flow/services/accounts.dart"; import "package:flow/services/transactions.dart"; import "package:flow/services/user_preferences.dart"; @@ -157,12 +156,6 @@ class TransitiveLocalPreferences { _log.warning("Cannot update transitive properties", e, stackTrace); } - try { - unawaited(sessionPrivacyMode.set(LocalPreferences().privacyMode.get())); - } catch (e) { - _log.warning("Failed to seed sessionPrivacyMode from privacyMode", e); - } - try { if (transitiveLastTimeFrecencyUpdated.get() == null || !transitiveLastTimeFrecencyUpdated.get()!.isAtSameDayAs( From 74ce429cceafd66be8a436bfeffd0c394f8a2856 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Tue, 29 Sep 2026 14:41:44 +0800 Subject: [PATCH 43/57] fix transfers losing location and attachments --- lib/objectbox/actions.dart | 26 ++++++++++-- lib/routes/transaction_page.dart | 1 + test/database_test.dart | 71 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 3953de0d..3e651d92 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -18,6 +18,7 @@ import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction/extensions/base.dart"; import "package:flow/entity/transaction/extensions/default/geo.dart"; import "package:flow/entity/transaction/extensions/default/recurring.dart"; +import "package:flow/entity/transaction/wrapper.dart"; import "package:flow/entity/transaction/extensions/default/transfer.dart"; import "package:flow/entity/transaction_tag.dart"; import "package:flow/l10n/extensions.dart"; @@ -945,8 +946,21 @@ extension AccountActions on Account { "to": targetAccount.name, }); - final List<TransactionExtension> filteredExtensions = - extensions?.where((ext) => ext is! Transfer).toList() ?? []; + // Copy per side, a shared instance only binds to `from`. Stale + // [Recurring] (e.g., from an edited transfer) is dropped. + List<TransactionExtension> extensionsFor(String transactionUuid) => + ExtensionsWrapper.parse( + ExtensionsWrapper( + extensions?.where((ext) => ext is! Transfer).toList() ?? [], + ).serialize(), + ).data + .where( + (ext) => + ext is! Recurring || + (recurrence == null && ext.relatedTransactionUuid == null), + ) + .map((ext) => ext..setRelatedTransactionUuid(transactionUuid)) + .toList(); transactionDate ??= recurrence?.range.from ?? DateTime.now(); @@ -968,7 +982,11 @@ extension AccountActions on Account { amount: -amount, title: resolvedTitle, description: description, - extensions: [...filteredExtensions, transferData, ?recurringExtension], + extensions: [ + ...extensionsFor(fromTransactionUuid), + transferData, + ?recurringExtension, + ], uuidOverride: fromTransactionUuid, createdDate: createdDate, transactionDate: transactionDate, @@ -982,7 +1000,7 @@ extension AccountActions on Account { title: resolvedTitle, description: description, extensions: [ - ...filteredExtensions, + ...extensionsFor(toTransactionUuid), transferData.copyWith(relatedTransactionUuid: fromTransactionUuid), if (recurringExtension != null) recurringExtension.copyWith( diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index b77f06a6..24f3c663 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -1161,6 +1161,7 @@ class _TransactionPageState extends State<TransactionPage> { conversionRate: crossCurrencyTransfer ? _conversionRate : null, recurrence: _recurrence, tags: _selectedTags, + attachments: _attachments, ); _currentlyEditing.permanentlyDelete(true); diff --git a/test/database_test.dart b/test/database_test.dart index 7409bf29..e2176e9f 100644 --- a/test/database_test.dart +++ b/test/database_test.dart @@ -3,6 +3,8 @@ import "dart:io"; import "package:flow/data/money.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/transaction.dart"; +import "package:flow/entity/transaction/extensions/default/geo.dart"; +import "package:flow/entity/transaction/extensions/default/recurring.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/objectbox/objectbox.g.dart"; @@ -232,6 +234,75 @@ void main() { }, ); + test("Transfer keeps location on both sides, including edits", () async { + final Query<Account> accountQuery = ObjectBox() + .box<Account>() + .query(Account_.currency.equals("MNT")) + .build(); + final List<Account> mntAccounts = await accountQuery.findAsync(); + accountQuery.close(); + + final Geo geo = Geo(uuid: "geo", latitude: 47.9, longitude: 106.9); + + final (fromTxnId, toTxnId) = mntAccounts[0].transferTo( + targetAccount: mntAccounts[1], + amount: 100.0, + extensions: [geo], + ); + + final Transaction fromTxn = (await TransactionsService().getOne( + fromTxnId, + ))!; + final Transaction toTxn = (await TransactionsService().getOne(toTxnId))!; + + expect(fromTxn.extensions.geo?.latitude, equals(47.9)); + expect(toTxn.extensions.geo?.latitude, equals(47.9)); + + // Editing a transfer re-creates it from the old transaction's extensions + final (editedFromId, editedToId) = mntAccounts[0].transferTo( + targetAccount: mntAccounts[1], + amount: 200.0, + extensions: fromTxn.extensions.data, + ); + fromTxn.permanentlyDelete(true); + + final Transaction editedFrom = (await TransactionsService().getOne( + editedFromId, + ))!; + final Transaction editedTo = (await TransactionsService().getOne( + editedToId, + ))!; + + expect(editedFrom.extensions.geo?.longitude, equals(106.9)); + expect(editedTo.extensions.geo?.longitude, equals(106.9)); + expect(editedFrom.extensions.recurring, isNull); + }); + + test("Generated recurring transfer keeps its Recurring link", () async { + final Query<Account> accountQuery = ObjectBox() + .box<Account>() + .query(Account_.currency.equals("MNT")) + .build(); + final List<Account> mntAccounts = await accountQuery.findAsync(); + accountQuery.close(); + + final (fromTxnId, toTxnId) = mntAccounts[0].transferTo( + targetAccount: mntAccounts[1], + amount: 100.0, + extensions: [ + Recurring(uuid: "series", initialTransactionDate: DateTime.now()), + ], + ); + + final Transaction fromTxn = (await TransactionsService().getOne( + fromTxnId, + ))!; + final Transaction toTxn = (await TransactionsService().getOne(toTxnId))!; + + expect(fromTxn.extensions.recurring?.uuid, equals("series")); + expect(toTxn.extensions.recurring?.uuid, equals("series")); + }); + tearDownAll(() async { await testCleanupObject( instance: ObjectBox(), From 6db2ed36a540193d800c92342daaa4e50e760e11 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Tue, 29 Sep 2026 14:41:44 +0800 Subject: [PATCH 44/57] add option to hide zero decimals --- 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_CN.json | 2 ++ assets/l10n/zh_TW.json | 2 ++ lib/data/money.dart | 5 +++- lib/entity/user_preferences.dart | 4 +++ lib/entity/user_preferences.g.dart | 2 ++ lib/objectbox/objectbox-model.json | 7 ++++- lib/objectbox/objectbox.g.dart | 23 ++++++++++++++-- .../money_formatting_preferences_page.dart | 21 +++++++++++++++ lib/services/user_preferences.dart | 6 +++++ lib/widgets/general/money_text_builder.dart | 10 +++++++ test/unit/data/money_format_test.dart | 27 +++++++++++++++++++ 25 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 test/unit/data/money_format_test.dart diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index ef005013..96b0c882 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "افتراضي", "preferences.moneyFormatting.showApproximatePrimaryAmount": "إظهار المبلغ التقريبي", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "عرض المبالغ الأجنبية بعملتك الرئيسية أيضاً", + "preferences.moneyFormatting.hideZeroDecimals": "إخفاء الكسور الصفرية", + "preferences.moneyFormatting.hideZeroDecimals.description": "عرض \"3\" بدلاً من \"3.00\"", "preferences.moneyFormatting.useCurrencySymbol": "استخدام رمز العملة", "preferences.moneyFormatting.useCurrencySymbol.description": "مثلاً، \"$5\" بدلاً من \"5USD\"", "preferences.numpad": "لوحة الأرقام", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 77b385c2..57dcb731 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Па змаўчанні", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Паказваць прыблізную суму", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Таксама паказваць замежныя сумы ў асноўнай валюце", + "preferences.moneyFormatting.hideZeroDecimals": "Хаваць нулявыя дробы", + "preferences.moneyFormatting.hideZeroDecimals.description": "Паказваць «3» замест «3,00»", "preferences.moneyFormatting.useCurrencySymbol": "Выкарыстоўваць сімвал валюты", "preferences.moneyFormatting.useCurrencySymbol.description": "напр., \"$5\" замест \"5USD\"", "preferences.numpad": "Лічбавая панэль", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 17c2ac3b..ab080d40 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Výchozí", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Zobrazit přibližnou částku", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Zobrazovat cizí částky i v primární měně", + "preferences.moneyFormatting.hideZeroDecimals": "Skrýt nulová desetinná místa", + "preferences.moneyFormatting.hideZeroDecimals.description": "Zobrazit „3“ místo „3,00“", "preferences.moneyFormatting.useCurrencySymbol": "Používat symbol měny", "preferences.moneyFormatting.useCurrencySymbol.description": "např. \"5 $\" místo \"5USD\"", "preferences.numpad": "Číselná klávesnice", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index fc106bb2..4c0dbf4b 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Standard", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Ungefähren Betrag anzeigen", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Fremdwährungsbeträge auch in der Hauptwährung anzeigen", + "preferences.moneyFormatting.hideZeroDecimals": "Nachkommastellen bei ganzen Beträgen ausblenden", + "preferences.moneyFormatting.hideZeroDecimals.description": "z.B. „3“ statt „3,00“", "preferences.moneyFormatting.useCurrencySymbol": "Währungssymbol verwenden", "preferences.moneyFormatting.useCurrencySymbol.description": "z.B. „5€“ statt „5 EUR“", "preferences.numpad": "Ziffernblock", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b95a5eb6..a0915175 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Default", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Show approximate amount", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Also show foreign amounts in your primary currency", + "preferences.moneyFormatting.hideZeroDecimals": "Hide zero decimals", + "preferences.moneyFormatting.hideZeroDecimals.description": "e.g., \"3\" instead of \"3.00\"", "preferences.moneyFormatting.useCurrencySymbol": "Use currency symbol", "preferences.moneyFormatting.useCurrencySymbol.description": "e.g., \"$5\" instead of \"5USD\"", "preferences.numpad": "Numpad", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 6f721d1d..12920bc3 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Predeterminado", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Mostrar importe aproximado", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Mostrar también importes extranjeros en tu moneda principal", + "preferences.moneyFormatting.hideZeroDecimals": "Ocultar decimales en cero", + "preferences.moneyFormatting.hideZeroDecimals.description": "Mostrar \"3\" en lugar de \"3,00\"", "preferences.moneyFormatting.useCurrencySymbol": "Usar símbolo de moneda", "preferences.moneyFormatting.useCurrencySymbol.description": "ej., \"5$\" en lugar de \"5USD\"", "preferences.numpad": "Teclado numérico", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 9d65fd5c..aab4a140 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "پیش‌فرض", "preferences.moneyFormatting.showApproximatePrimaryAmount": "نمایش مبلغ تقریبی", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "مبالغ ارزی را به ارز اصلی هم نشان بده", + "preferences.moneyFormatting.hideZeroDecimals": "پنهان کردن اعشار صفر", + "preferences.moneyFormatting.hideZeroDecimals.description": "نمایش «3» به جای «3.00»", "preferences.moneyFormatting.useCurrencySymbol": "استفاده از نماد ارز", "preferences.moneyFormatting.useCurrencySymbol.description": "مثلاً «$5» به‌جای «5USD»", "preferences.numpad": "صفحه‌کلید عددی", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 2be9c49a..b064936f 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Par défaut", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Afficher le montant approximatif", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Afficher aussi les montants étrangers dans votre devise principale", + "preferences.moneyFormatting.hideZeroDecimals": "Masquer les décimales nulles", + "preferences.moneyFormatting.hideZeroDecimals.description": "Afficher « 3 » au lieu de « 3,00 »", "preferences.moneyFormatting.useCurrencySymbol": "Utiliser le symbole de la devise", "preferences.moneyFormatting.useCurrencySymbol.description": "par exemple, \"5 €\" au lieu de \"5 EUR\"", "preferences.numpad": "Clavier numérique", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 5da894ff..67597c29 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Predefinito", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Mostra importo approssimativo", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Mostra anche gli importi esteri nella valuta principale", + "preferences.moneyFormatting.hideZeroDecimals": "Nascondi decimali a zero", + "preferences.moneyFormatting.hideZeroDecimals.description": "Mostra \"3\" invece di \"3,00\"", "preferences.moneyFormatting.useCurrencySymbol": "Usa simbolo valuta", "preferences.moneyFormatting.useCurrencySymbol.description": "Ad esempio, \"€5\" invece di \"5 EUR\"", "preferences.numpad": "Tastierino numerico", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 30a193d6..d8b664d1 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Өгөгдмөл", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Ойролцоо дүнг харуулах", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Гадаад валютын дүнг үндсэн валютаар давхар харуулах", + "preferences.moneyFormatting.hideZeroDecimals": "Бүхэл дүнгийн бутархайг нуух", + "preferences.moneyFormatting.hideZeroDecimals.description": "Жишээлбэл, \"3.00\" гэхийн оронд \"3\"", "preferences.moneyFormatting.useCurrencySymbol": "Тэмдэгт ашиглах", "preferences.moneyFormatting.useCurrencySymbol.description": "Жишээлбэл, \"MNT 3,200\" гэхийн оронд \"3,200₮\"", "preferences.numpad": "Тоон товчлуур", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index c9ff67a2..b65cd5ce 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Domyślny", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Pokaż przybliżoną kwotę", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Pokazuj też kwoty obce w walucie głównej", + "preferences.moneyFormatting.hideZeroDecimals": "Ukryj zerowe miejsca dziesiętne", + "preferences.moneyFormatting.hideZeroDecimals.description": "Pokazuj „3” zamiast „3,00”", "preferences.moneyFormatting.useCurrencySymbol": "Użyj symbolu waluty", "preferences.moneyFormatting.useCurrencySymbol.description": "np. \"$5\" zamiast \"5USD\"", "preferences.numpad": "Klawiatura numeryczna", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 0588bfb5..4bafe6c4 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "По умолчанию", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Показывать примерную сумму", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Также показывать иностранные суммы в основной валюте", + "preferences.moneyFormatting.hideZeroDecimals": "Скрывать нулевые дроби", + "preferences.moneyFormatting.hideZeroDecimals.description": "Показывать «3» вместо «3,00»", "preferences.moneyFormatting.useCurrencySymbol": "Использовать символ валюты", "preferences.moneyFormatting.useCurrencySymbol.description": "например, \"$5\" вместо \"5USD\"", "preferences.numpad": "Цифровая клавиатура", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index c37ae9ec..e6ae1221 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "Varsayılan", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Yaklaşık tutarı göster", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Yabancı tutarları ana para biriminde de göster", + "preferences.moneyFormatting.hideZeroDecimals": "Sıfır ondalıkları gizle", + "preferences.moneyFormatting.hideZeroDecimals.description": "\"3,00\" yerine \"3\" göster", "preferences.moneyFormatting.useCurrencySymbol": "Para birimi simgesini kullan", "preferences.moneyFormatting.useCurrencySymbol.description": "örneğin, \"5$\" yerine \"5 ABD Doları\"", "preferences.numpad": "Sayısal tuş takımı", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index b48e6e7b..978cf290 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "За замовчуванням", "preferences.moneyFormatting.showApproximatePrimaryAmount": "Показувати приблизну суму", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "Також показувати іноземні суми в основній валюті", + "preferences.moneyFormatting.hideZeroDecimals": "Приховувати нульові дроби", + "preferences.moneyFormatting.hideZeroDecimals.description": "Показувати «3» замість «3,00»", "preferences.moneyFormatting.useCurrencySymbol": "Використовувати символ валюти", "preferences.moneyFormatting.useCurrencySymbol.description": "наприклад, \"$5\" замість \"5USD\"", "preferences.numpad": "Цифрова клавіатура", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index e3693226..7c1ff736 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "默认", "preferences.moneyFormatting.showApproximatePrimaryAmount": "显示近似金额", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "同时以主要货币显示外币金额", + "preferences.moneyFormatting.hideZeroDecimals": "整数金额不显示小数", + "preferences.moneyFormatting.hideZeroDecimals.description": "显示“3”而不是“3.00”", "preferences.moneyFormatting.useCurrencySymbol": "使用货币符号", "preferences.moneyFormatting.useCurrencySymbol.description": "例如:使用“$5”取代“5USD”", "preferences.numpad": "数字键盘", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 3384f0fb..a37dc088 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -380,6 +380,8 @@ "preferences.moneyFormatting.setICUPattern.default": "預設", "preferences.moneyFormatting.showApproximatePrimaryAmount": "顯示約略金額", "preferences.moneyFormatting.showApproximatePrimaryAmount.description": "同時以主要貨幣顯示外幣金額", + "preferences.moneyFormatting.hideZeroDecimals": "整數金額不顯示小數", + "preferences.moneyFormatting.hideZeroDecimals.description": "顯示「3」而非「3.00」", "preferences.moneyFormatting.useCurrencySymbol": "使用貨幣符號", "preferences.moneyFormatting.useCurrencySymbol.description": "例如:使用「$5」取代「5USD」", "preferences.numpad": "數字鍵盤", diff --git a/lib/data/money.dart b/lib/data/money.dart index c9430865..cbed6e1c 100644 --- a/lib/data/money.dart +++ b/lib/data/money.dart @@ -184,7 +184,10 @@ class Money { final int totalDecimalPlaces = CurrencyRegistryService() .detectDecimalPrecision(amountToFormat, currency); - if (preferredDecimalPlaces != null) { + if (totalDecimalPlaces == 0 && + UserPreferencesService().hideZeroDecimals) { + decimalDigits = 0; + } else if (preferredDecimalPlaces != null) { decimalDigits = math.max( 2, math.min(totalDecimalPlaces, preferredDecimalPlaces), diff --git a/lib/entity/user_preferences.dart b/lib/entity/user_preferences.dart index 92d8ba67..712a804b 100644 --- a/lib/entity/user_preferences.dart +++ b/lib/entity/user_preferences.dart @@ -87,6 +87,9 @@ class UserPreferences implements EntityBase { String? icuCurrencyFormattingPattern; + /// Omits decimals for whole amounts, e.g., $3 instead of $3.00 + bool hideZeroDecimals; + /// Serialized version of [DateFormatPreset] String? dateFormatPreset; @@ -192,6 +195,7 @@ class UserPreferences implements EntityBase { this.iCloudBackupsToKeep = 10, this.autoBackupIntervalInHours = 72, this.icuCurrencyFormattingPattern, + this.hideZeroDecimals = false, this.dateFormatPreset, this.primaryCurrency, this.primaryAccountUuid, diff --git a/lib/entity/user_preferences.g.dart b/lib/entity/user_preferences.g.dart index 5c66c6f5..97624f5d 100644 --- a/lib/entity/user_preferences.g.dart +++ b/lib/entity/user_preferences.g.dart @@ -42,6 +42,7 @@ UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) => (json['autoBackupIntervalInHours'] as num?)?.toInt() ?? 72, icuCurrencyFormattingPattern: json['icuCurrencyFormattingPattern'] as String?, + hideZeroDecimals: json['hideZeroDecimals'] as bool? ?? false, dateFormatPreset: json['dateFormatPreset'] as String?, primaryCurrency: json['primaryCurrency'] as String?, primaryAccountUuid: json['primaryAccountUuid'] as String?, @@ -84,6 +85,7 @@ Map<String, dynamic> _$UserPreferencesToJson( 'privacyModeUponLaunch': instance.privacyModeUponLaunch, 'privacyModeUponShaking': instance.privacyModeUponShaking, 'icuCurrencyFormattingPattern': instance.icuCurrencyFormattingPattern, + 'hideZeroDecimals': instance.hideZeroDecimals, 'dateFormatPreset': instance.dateFormatPreset, 'primaryCurrency': instance.primaryCurrency, 'primaryAccountUuid': instance.primaryAccountUuid, diff --git a/lib/objectbox/objectbox-model.json b/lib/objectbox/objectbox-model.json index 45410f2d..9935e349 100644 --- a/lib/objectbox/objectbox-model.json +++ b/lib/objectbox/objectbox-model.json @@ -362,7 +362,7 @@ }, { "id": "10:7829328581176695647", - "lastPropertyId": "32:5426961238010158288", + "lastPropertyId": "33:7003500790338467583", "name": "UserPreferences", "properties": [ { @@ -517,6 +517,11 @@ "id": "32:5426961238010158288", "name": "dateFormatPreset", "type": 9 + }, + { + "id": "33:7003500790338467583", + "name": "hideZeroDecimals", + "type": 1 } ], "relations": [] diff --git a/lib/objectbox/objectbox.g.dart b/lib/objectbox/objectbox.g.dart index dd131ce7..c19eff0c 100644 --- a/lib/objectbox/objectbox.g.dart +++ b/lib/objectbox/objectbox.g.dart @@ -455,7 +455,7 @@ final _entities = <obx_int.ModelEntity>[ obx_int.ModelEntity( id: const obx_int.IdUid(10, 7829328581176695647), name: 'UserPreferences', - lastPropertyId: const obx_int.IdUid(32, 5426961238010158288), + lastPropertyId: const obx_int.IdUid(33, 7003500790338467583), flags: 0, properties: <obx_int.ModelProperty>[ obx_int.ModelProperty( @@ -639,6 +639,12 @@ final _entities = <obx_int.ModelEntity>[ type: 9, flags: 0, ), + obx_int.ModelProperty( + id: const obx_int.IdUid(33, 7003500790338467583), + name: 'hideZeroDecimals', + type: 1, + flags: 0, + ), ], relations: <obx_int.ModelRelation>[], backlinks: <obx_int.ModelBacklink>[], @@ -1672,7 +1678,7 @@ obx_int.ModelDefinition getObjectBoxModel() { final dateFormatPresetOffset = object.dateFormatPreset == null ? null : fbb.writeString(object.dateFormatPreset!); - fbb.startTable(33); + fbb.startTable(34); fbb.addInt64(0, object.id); fbb.addOffset(1, uuidOffset); fbb.addBool(2, object.combineTransfers); @@ -1703,6 +1709,7 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addOffset(29, homePendingTransactionsTimeRangeSerializedOffset); fbb.addBool(30, object.transactionListAbsoluteDateHeaders); fbb.addOffset(31, dateFormatPresetOffset); + fbb.addBool(32, object.hideZeroDecimals); fbb.finish(fbb.endTable()); return object.id; }, @@ -1777,6 +1784,12 @@ obx_int.ModelDefinition getObjectBoxModel() { final icuCurrencyFormattingPatternParam = const fb.StringReader( asciiOptimization: true, ).vTableGetNullable(buffer, rootOffset, 30); + final hideZeroDecimalsParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 68, + false, + ); final dateFormatPresetParam = const fb.StringReader( asciiOptimization: true, ).vTableGetNullable(buffer, rootOffset, 66); @@ -1833,6 +1846,7 @@ obx_int.ModelDefinition getObjectBoxModel() { iCloudBackupsToKeep: iCloudBackupsToKeepParam, autoBackupIntervalInHours: autoBackupIntervalInHoursParam, icuCurrencyFormattingPattern: icuCurrencyFormattingPatternParam, + hideZeroDecimals: hideZeroDecimalsParam, dateFormatPreset: dateFormatPresetParam, primaryCurrency: primaryCurrencyParam, primaryAccountUuid: primaryAccountUuidParam, @@ -2728,6 +2742,11 @@ class UserPreferences_ { static final dateFormatPreset = obx.QueryStringProperty<UserPreferences>( _entities[6].properties[29], ); + + /// See [UserPreferences.hideZeroDecimals]. + static final hideZeroDecimals = obx.QueryBooleanProperty<UserPreferences>( + _entities[6].properties[30], + ); } /// [Budget] entity fields to define ObjectBox queries. diff --git a/lib/routes/preferences/money_formatting_preferences_page.dart b/lib/routes/preferences/money_formatting_preferences_page.dart index 169eaebb..0109f83b 100644 --- a/lib/routes/preferences/money_formatting_preferences_page.dart +++ b/lib/routes/preferences/money_formatting_preferences_page.dart @@ -26,6 +26,7 @@ class _MoneyFormattingPreferencesPageState final bool showApproximatePrimaryAmount = LocalPreferences() .showApproximatePrimaryAmount .get(); + final bool hideZeroDecimals = UserPreferencesService().hideZeroDecimals; return Scaffold( appBar: AppBar(title: Text("preferences.moneyFormatting".t(context))), @@ -81,6 +82,18 @@ class _MoneyFormattingPreferencesPageState value: showApproximatePrimaryAmount, onChanged: updateShowApproximatePrimaryAmount, ), + CheckboxListTile( + title: Text( + "preferences.moneyFormatting.hideZeroDecimals".t(context), + ), + subtitle: Text( + "preferences.moneyFormatting.hideZeroDecimals.description".t( + context, + ), + ), + value: hideZeroDecimals, + onChanged: updateHideZeroDecimals, + ), ListTile( title: Text( "preferences.moneyFormatting.setICUPattern".t(context), @@ -123,6 +136,14 @@ class _MoneyFormattingPreferencesPageState if (mounted) setState(() {}); } + void updateHideZeroDecimals(bool? newHideZeroDecimals) { + if (newHideZeroDecimals == null) return; + + UserPreferencesService().hideZeroDecimals = newHideZeroDecimals; + + if (mounted) setState(() {}); + } + void updateCustomICUCurrencyFormatter() async { final Optional<String?>? result = await showModalBottomSheet( context: context, diff --git a/lib/services/user_preferences.dart b/lib/services/user_preferences.dart index ad5ab017..a9223dd5 100644 --- a/lib/services/user_preferences.dart +++ b/lib/services/user_preferences.dart @@ -320,6 +320,12 @@ class UserPreferencesService { ObjectBox().box<UserPreferences>().put(value); } + bool get hideZeroDecimals => value.hideZeroDecimals; + set hideZeroDecimals(bool newHideZeroDecimals) { + value.hideZeroDecimals = newHideZeroDecimals; + ObjectBox().box<UserPreferences>().put(value); + } + DateFormatPreset get dateFormatPreset => DateFormatPreset.tryParse(value.dateFormatPreset) ?? .system; set dateFormatPreset(DateFormatPreset newDateFormatPreset) { diff --git a/lib/widgets/general/money_text_builder.dart b/lib/widgets/general/money_text_builder.dart index 0b4117c0..8634e407 100644 --- a/lib/widgets/general/money_text_builder.dart +++ b/lib/widgets/general/money_text_builder.dart @@ -1,5 +1,6 @@ import "package:flow/data/money.dart"; import "package:flow/prefs/local_preferences.dart"; +import "package:flow/services/user_preferences.dart"; import "package:flow/utils/utils.dart"; import "package:flutter/material.dart"; @@ -64,6 +65,7 @@ class _MoneyTextBuilderState extends State<MoneyTextBuilder> { _privacyModeUpdate, ); LocalPreferences().useCurrencySymbol.addListener(_useCurrencySymbolUpdate); + UserPreferencesService().valueNotifier.addListener(_userPreferencesUpdate); globalPrivacyMode = TransitiveLocalPreferences().sessionPrivacyMode.get(); globalUseCurrencySymbol = LocalPreferences().useCurrencySymbol.get(); @@ -94,6 +96,9 @@ class _MoneyTextBuilderState extends State<MoneyTextBuilder> { LocalPreferences().useCurrencySymbol.removeListener( _useCurrencySymbolUpdate, ); + UserPreferencesService().valueNotifier.removeListener( + _userPreferencesUpdate, + ); super.dispose(); } @@ -117,6 +122,11 @@ class _MoneyTextBuilderState extends State<MoneyTextBuilder> { setState(() {}); } + void _userPreferencesUpdate() { + if (!mounted) return; + setState(() {}); + } + String getString() { final Money? money = widget.money; diff --git a/test/unit/data/money_format_test.dart b/test/unit/data/money_format_test.dart new file mode 100644 index 00000000..8aadb7a3 --- /dev/null +++ b/test/unit/data/money_format_test.dart @@ -0,0 +1,27 @@ +import "package:flow/data/money.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:intl/intl.dart"; + +void main() { + setUp(() { + Intl.defaultLocale = "en_US"; + }); + + tearDown(() { + UserPreferencesService().value.hideZeroDecimals = false; + }); + + test("Shows zero decimals by default", () { + expect(Money(3.0, "USD").formatted, equals(r"$3.00")); + }); + + test("Hides zero decimals per amount when enabled", () { + UserPreferencesService().value.hideZeroDecimals = true; + + expect(Money(3.0, "USD").formatted, equals(r"$3")); + expect(Money(1.23, "USD").formatted, equals(r"$1.23")); + expect(Money(1.5, "USD").formatted, equals(r"$1.50")); + expect(Money(1200000.0, "USD").formattedCompact, equals(r"$1.2M")); + }); +} From 99e76d174e5bf02fbc316e98f32c9a45c838814f Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Tue, 29 Sep 2026 14:41:44 +0800 Subject: [PATCH 45/57] icons: resolve legacy brand code points on parse --- lib/data/flow_icon.dart | 19 ++++++++++++----- lib/graceful_migrations.dart | 14 ++++++------- test/unit/data/flow_icon_test.dart | 33 ++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 test/unit/data/flow_icon_test.dart diff --git a/lib/data/flow_icon.dart b/lib/data/flow_icon.dart index 427c47f3..a79bb2c5 100644 --- a/lib/data/flow_icon.dart +++ b/lib/data/flow_icon.dart @@ -3,6 +3,7 @@ import "dart:typed_data"; import "dart:ui" as ui; import "package:cross_file/cross_file.dart"; +import "package:flow/data/legacy_simple_icons_codepoints.dart"; import "package:flow/objectbox.dart"; import "package:flutter/material.dart"; import "package:path/path.dart" as path; @@ -80,12 +81,10 @@ class IconFlowIcon extends FlowIconData { /// original package name, so we remap on parse to keep their glyphs /// resolvable. The font families and code points are unchanged. /// - /// Note: `simple_icons` brand icons are migrated to [SimpleIconFlowIcon] - /// (slug-based) by `migrateSimpleIconsToSlug`; the entry below is only a - /// best-effort fallback for any un-migrated legacy/backup data. + /// Brand icons stored by code point are resolved to [SimpleIconFlowIcon] + /// in [parse] instead. static const Map<String, String> _fontPackageMigration = { "material_symbols_icons": "material_symbols_icons_flow", - "simple_icons": "simple_icons_flow", }; @override @@ -97,11 +96,21 @@ class IconFlowIcon extends FlowIconData { final payload = serialized.split(":")[1]; final [fontFamily, fontPackage, codePointHex] = payload.split(","); + final int codePoint = int.parse(codePointHex, radix: 16); + + // Brand icons are stored by slug since 0.23, so a code point here is + // always from simple_icons 14.6.1 (e.g., tags, old backups). Brands removed + // upstream get an empty slug, which renders a neutral fallback. + if (fontFamily == "SimpleIcons") { + return FlowIconData.simpleIcon( + legacySimpleIconsCodepointToSlug[codePoint] ?? "", + ); + } return FlowIconData.icon( IconData( // ignore: non_const_argument_for_const_parameter - int.parse(codePointHex, radix: 16), + codePoint, // ignore: non_const_argument_for_const_parameter fontFamily: fontFamily, // ignore: non_const_argument_for_const_parameter diff --git a/lib/graceful_migrations.dart b/lib/graceful_migrations.dart index c6ffbef0..a9797930 100644 --- a/lib/graceful_migrations.dart +++ b/lib/graceful_migrations.dart @@ -327,8 +327,6 @@ void migrateHomePendingTransactionsRange() async { /// only meaningful for the version it was saved with. Flow shipped /// simple_icons 14.6.1; [legacySimpleIconsCodepointToSlug] maps those code /// points straight to their stable slug, independent of the bundled version. -/// This is the *only* remaining use of that table — once this migration has -/// propagated, the migration and the table can both be deleted. Future<void> migrateSimpleIconsToSlug() async { const String migrationUuid = "598a1c1d-1d53-44e0-9035-e005c5420538"; @@ -343,14 +341,14 @@ Future<void> migrateSimpleIconsToSlug() async { if (ok != null) return; try { + // [IconFlowIcon.parse] resolves legacy brand code points to slugs. + // Slugs missing from the bundled build (removed upstream) are left as + // they are. String? slugForIconCode(String iconCode) { + if (!iconCode.startsWith("IconFlowIcon:")) return null; final FlowIconData? parsed = FlowIconData.tryParse(iconCode); - if (parsed is! IconFlowIcon) return null; - if (parsed.iconData.fontFamily != "SimpleIcons") return null; - - // Stored code points are 14.6.1. Slugs missing from the bundled - // build (removed upstream) are left as they are. - return legacySimpleIconsCodepointToSlug[parsed.iconData.codePoint]; + if (parsed is! SimpleIconFlowIcon || parsed.slug.isEmpty) return null; + return parsed.slug; } final List<Account> changedAccounts = []; diff --git a/test/unit/data/flow_icon_test.dart b/test/unit/data/flow_icon_test.dart new file mode 100644 index 00000000..b0e9da37 --- /dev/null +++ b/test/unit/data/flow_icon_test.dart @@ -0,0 +1,33 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + test("Legacy brand code points resolve to slugs", () { + final FlowIconData? paypal = FlowIconData.tryParse( + "IconFlowIcon:SimpleIcons,simple_icons,f228", + ); + + expect(paypal, isA<SimpleIconFlowIcon>()); + expect((paypal as SimpleIconFlowIcon).slug, equals("paypal")); + expect(paypal.iconData, isNotNull); + }); + + test("Brands removed upstream don't resolve to another brand", () { + final FlowIconData? amazon = FlowIconData.tryParse( + "IconFlowIcon:SimpleIcons,simple_icons_flow,ea78", + ); + + expect(amazon, isA<SimpleIconFlowIcon>()); + expect((amazon as SimpleIconFlowIcon).iconData, isNull); + }); + + test("Material Symbols stay code point based", () { + final FlowIconData? icon = FlowIconData.tryParse( + "IconFlowIcon:MaterialSymbolsRounded,material_symbols_icons,e5ca", + ); + + expect(icon, isA<IconFlowIcon>()); + expect((icon as IconFlowIcon).iconData.codePoint, equals(0xe5ca)); + expect(icon.iconData.fontPackage, equals("material_symbols_icons_flow")); + }); +} From 244d0e0ecc797e7fdfa70204ffb64447f5a1077f Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Tue, 29 Sep 2026 14:41:44 +0800 Subject: [PATCH 46/57] update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da215bb5..88475ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ * Added "Worth knowing" to the stats tab: on-device observations like spending spikes, unusual categories, and charges that look recurring, closes [#761](https://github.com/flow-mn/flow/issues/761) +* Added an option to hide zero decimals (e.g., "$3" instead of "$3.00") in + Money formatting ### Changes @@ -27,6 +29,9 @@ * Fixed exchange rates being fetched for the wrong currency at startup, and not refreshing after changing the primary currency +* Fixed "Mask numbers at startup" not working +* Fixed transfers losing their location and attachments after editing +* Fixed brand icons in tags and old backups showing the wrong brand ## 0.24.1 From 015ddde1666c7fd48b09af2e512e175b40bc02b8 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Tue, 29 Sep 2026 14:51:10 +0800 Subject: [PATCH 47/57] 0.25.0+360 --- pubspec.lock | 8 ++++---- pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 6a1a7b85..6551d4a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1218,10 +1218,10 @@ packages: dependency: "direct main" description: name: material_symbols_icons_flow - sha256: "31f66ea83ac9f2a73ac9b04ae5f75696a71c4bc754d31172082e127fc1a9b8c2" + sha256: "350e7f8c118f3bea6bba996579fb6eceb5dfe2fa96835a251653cf1cd257ac2b" url: "https://pub.dev" source: hosted - version: "4.2928.1" + version: "4.2960.0" meta: dependency: transitive description: @@ -1818,10 +1818,10 @@ packages: dependency: "direct main" description: name: simple_icons_flow - sha256: "929fffba2872fb1687b467f28a91d82ad4117468314f776ac799ca04eac164ea" + sha256: c7ac49006c41788881dab01b24262fdb1c429ea36746496c7a267b85bdb15c7e url: "https://pub.dev" source: hosted - version: "16.20.0" + version: "16.23.0" simple_sparse_list: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 171d6633..4cc0739d 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.25.0+359" +version: "0.25.0+360" environment: sdk: ">=3.10.0 <4.0.0" From 8480d3d352b436590de12bd30631814f8b30a6d5 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Tue, 29 Sep 2026 15:01:13 +0800 Subject: [PATCH 48/57] test hide zero decimals for jpy, krw, twd --- test/unit/data/money_format_test.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/unit/data/money_format_test.dart b/test/unit/data/money_format_test.dart index 8aadb7a3..e5d55dc9 100644 --- a/test/unit/data/money_format_test.dart +++ b/test/unit/data/money_format_test.dart @@ -14,6 +14,9 @@ void main() { test("Shows zero decimals by default", () { expect(Money(3.0, "USD").formatted, equals(r"$3.00")); + expect(Money(1500.0, "JPY").formatted, equals("¥1,500.00")); + expect(Money(1500.0, "KRW").formatted, equals("₩1,500.00")); + expect(Money(1500.0, "TWD").formatted, equals(r"NT$1,500.00")); }); test("Hides zero decimals per amount when enabled", () { @@ -24,4 +27,14 @@ void main() { expect(Money(1.5, "USD").formatted, equals(r"$1.50")); expect(Money(1200000.0, "USD").formattedCompact, equals(r"$1.2M")); }); + + test("Hides zero decimals for any currency when enabled", () { + UserPreferencesService().value.hideZeroDecimals = true; + + expect(Money(1500.0, "JPY").formatted, equals("¥1,500")); + expect(Money(1500.5, "JPY").formatted, equals("¥1,500.50")); + expect(Money(1500.0, "KRW").formatted, equals("₩1,500")); + expect(Money(1500.0, "TWD").formatted, equals(r"NT$1,500")); + expect(Money(2000.0, "MNT").formatted, equals("₮2,000")); + }); } From 52b04ab0682d9e63b4b88743fce7ebc1e71c9355 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Wed, 30 Sep 2026 00:26:55 +0800 Subject: [PATCH 49/57] add variable amount recurring transactions --- CHANGELOG.md | 3 + 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_CN.json | 2 + assets/l10n/zh_TW.json | 2 + lib/data/insights/insight_adapter.dart | 9 +- lib/data/insights/insight_engine.dart | 11 +- lib/data/insights/insight_request.dart | 12 +- lib/data/insights/insight_thresholds.dart | 4 + lib/data/insights/recurring_series.dart | 31 +++- lib/entity/recurring_transaction.dart | 5 + lib/entity/recurring_transaction.g.dart | 2 + .../extensions/default/recurring.dart | 6 + .../extensions/default/recurring.g.dart | 2 + lib/objectbox/actions.dart | 26 +++ lib/objectbox/objectbox-model.json | 7 +- lib/objectbox/objectbox.g.dart | 23 ++- lib/routes.dart | 2 + lib/routes/stats/recurring_page.dart | 51 ++++- lib/routes/transaction_page.dart | 56 +++++- lib/services/recurring_transactions.dart | 21 ++- .../extensions/recurring_transaction.dart | 24 +++ lib/utils/extensions/transaction.dart | 32 ++++ lib/widgets/flow_card.dart | 6 +- lib/widgets/general/money_text.dart | 6 +- .../grouped_transactions_list_view.dart | 10 +- .../home/stats/bento/recurring_tile.dart | 25 ++- lib/widgets/insights/insight_actions.dart | 2 +- lib/widgets/insights/insight_summary.dart | 4 +- .../recurring/recurring_summary_header.dart | 8 + lib/widgets/transaction_list_tile.dart | 1 + test/unit/insights/insight_engine_test.dart | 61 +++++- test/unit/insights/recurring_series_test.dart | 32 ++++ test/unit/variable_recurring_test.dart | 175 ++++++++++++++++++ 46 files changed, 658 insertions(+), 31 deletions(-) create mode 100644 test/unit/variable_recurring_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 88475ef3..5bbfabfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ [#761](https://github.com/flow-mn/flow/issues/761) * Added an option to hide zero decimals (e.g., "$3" instead of "$3.00") in Money formatting +* Recurring transactions can now have a varying amount (e.g., utility bills). + They're added as pending with an estimate, and ask for the actual amount + when you confirm. "Worth knowing" suggests these too ### Changes diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index 96b0c882..8cd99925 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -972,6 +972,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "سيؤدي هذا الإجراء إلى حذف جميع المعاملات المرتبطة، وإيقاف إنشاء معاملات جديدة. استعادة المعاملات من سلة المهملات لن تؤدي إلى إنشاء معاملات جديدة.", "transaction.recurring.edit": "تعديل المعاملة المتكررة", "transaction.recurring.setup": "إعداد المعاملة المتكررة", + "transaction.recurring.variableAmount": "المبلغ متغير", + "transaction.recurring.variableAmount.description": "يطلب المبلغ في كل مرة", "transaction.tags": "الوسوم", "transaction.tags.add": "إضافة وسوم", "transaction.tags.contact.name": "اسم جهة الاتصال", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 57dcb731..7bbc4697 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -985,6 +985,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Гэтае дзеянне выдаліць усе звязаныя транзакцыі і спыніць стварэнне новых. Аднаўленне транзакцый з кошыка не прымусіць іх ствараць новыя транзакцыі.", "transaction.recurring.edit": "Рэдагаваць перыядычную транзакцыю", "transaction.recurring.setup": "Наладзіць перыядычную транзакцыю", + "transaction.recurring.variableAmount": "Сума змяняецца", + "transaction.recurring.variableAmount.description": "Пытаецца суму кожны раз", "transaction.tags": "Тэгі", "transaction.tags.add": "Дадаць тэгі", "transaction.tags.contact.name": "Імя кантакту", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index ab080d40..202aaa57 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -973,6 +973,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Tato akce odstraní všechny související transakce a zastaví vytváření nových. Obnovení transakcí z koše nezpůsobí opětovné vytváření nových transakcí.", "transaction.recurring.edit": "Upravit opakující se transakci", "transaction.recurring.setup": "Nastavit opakující se transakci", + "transaction.recurring.variableAmount": "Částka se mění", + "transaction.recurring.variableAmount.description": "Pokaždé se zeptá na částku", "transaction.tags": "Štítky", "transaction.tags.add": "Přidat štítky", "transaction.tags.contact.name": "Jméno kontaktu", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 4c0dbf4b..5402ae98 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -959,6 +959,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Damit löschst du alle dazugehörigen Buchungen. Es werden auch keine neuen Buchungen mehr erstellt. Wenn du die Buchungen aus dem Papierkorb wiederherstellst, werden trotzdem keine neuen Buchungen erstellt.", "transaction.recurring.edit": "Wiederkehrende Buchung bearbeiten", "transaction.recurring.setup": "Wiederkehrende Buchung einrichten", + "transaction.recurring.variableAmount": "Betrag variiert", + "transaction.recurring.variableAmount.description": "Fragt jedes Mal nach dem Betrag", "transaction.tags": "Tags", "transaction.tags.add": "Tags hinzufügen", "transaction.tags.contact.name": "Kontaktname", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index a0915175..42a86942 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -954,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "This action will delete all related transactions, and stop new transactions from being created. Recovering the transactions from trash bin wouldn't cause it to create new transactions.", "transaction.recurring.edit": "Edit recurring transaction", "transaction.recurring.setup": "Setup recurring transaction", + "transaction.recurring.variableAmount": "Amount varies", + "transaction.recurring.variableAmount.description": "Asks for the amount each time", "transaction.tags": "Tags", "transaction.tags.add": "Add tags", "transaction.tags.contact.name": "Contact name", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 12920bc3..02d1e8c0 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -960,6 +960,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Esta acción eliminará todas las transacciones relacionadas e impedirá que se creen nuevas. Recuperar las transacciones de la papelera no hará que se creen nuevas transacciones.", "transaction.recurring.edit": "Editar transacción recurrente", "transaction.recurring.setup": "Configurar transacción recurrente", + "transaction.recurring.variableAmount": "El importe varía", + "transaction.recurring.variableAmount.description": "Pide el importe cada vez", "transaction.tags": "Etiquetas", "transaction.tags.add": "Añadir etiquetas", "transaction.tags.contact.name": "Nombre del contacto", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index aab4a140..01d27259 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -954,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "این کار همه تراکنش‌های مرتبط را حذف می‌کند و ساخت تراکنش‌های جدید را متوقف می‌کند. بازیابی تراکنش‌ها از سطل زباله باعث ساخت دوباره تراکنش‌های جدید نمی‌شود.", "transaction.recurring.edit": "ویرایش تراکنش تکرارشونده", "transaction.recurring.setup": "تنظیم تراکنش تکرارشونده", + "transaction.recurring.variableAmount": "مبلغ متغیر است", + "transaction.recurring.variableAmount.description": "هر بار مبلغ را می‌پرسد", "transaction.tags": "برچسب‌ها", "transaction.tags.add": "افزودن برچسب‌ها", "transaction.tags.contact.name": "نام مخاطب", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index b064936f..a492e550 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -960,6 +960,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Cette action supprimera toutes les transactions associées et empêchera la création de nouvelles transactions. La récupération des transactions depuis la corbeille ne provoquera pas la création de nouvelles transactions.", "transaction.recurring.edit": "Modifier la transaction récurrente", "transaction.recurring.setup": "Configurer la transaction récurrente", + "transaction.recurring.variableAmount": "Montant variable", + "transaction.recurring.variableAmount.description": "Demande le montant à chaque fois", "transaction.tags": "Étiquettes", "transaction.tags.add": "Ajouter des étiquettes", "transaction.tags.contact.name": "Nom du contact", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 67597c29..5bbb620d 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -960,6 +960,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Questa azione eliminerà tutte le transazioni correlate e impedirà la creazione di nuove transazioni. Il recupero delle transazioni dal cestino non causerà la creazione di nuove transazioni.", "transaction.recurring.edit": "Modifica transazione ricorrente", "transaction.recurring.setup": "Configurare la transazione ricorrente", + "transaction.recurring.variableAmount": "Importo variabile", + "transaction.recurring.variableAmount.description": "Chiede l'importo ogni volta", "transaction.tags": "Tag", "transaction.tags.add": "Aggiungi tag", "transaction.tags.contact.name": "Nome del contatto", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index d8b664d1..2534c144 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -954,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Энэ үйлдэл нь холбоотой бүх гүйлгээг устгах бөгөөд, цаашид шинэ гүйлгээ үүсгэхгүй. Хогийн савнаас устгсан гүйлгээнүүдийг сэргээх боломжтой боловч шинэ гүйлгээг дахиж үүсэхгүй.", "transaction.recurring.edit": "Давтамжит гүйлгээг засварлах", "transaction.recurring.setup": "Давтамжит гүйлгээ тохируулах", + "transaction.recurring.variableAmount": "Дүн өөрчлөгддөг", + "transaction.recurring.variableAmount.description": "Дүнг тухай бүрт асууна", "transaction.tags": "Шошго", "transaction.tags.add": "Шошго нэмэх", "transaction.tags.contact.name": "Хүний нэр", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index b65cd5ce..dfb68f8c 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -979,6 +979,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Ta akcja usunie wszystkie powiązane transakcje i wstrzyma tworzenie nowych. Przywrócenie ich z kosza nie przywróci ich cykliczności.", "transaction.recurring.edit": "Edytuj transakcję cykliczną", "transaction.recurring.setup": "Ustaw transakcję cykliczną", + "transaction.recurring.variableAmount": "Kwota się zmienia", + "transaction.recurring.variableAmount.description": "Pyta o kwotę za każdym razem", "transaction.tags": "Tagi", "transaction.tags.add": "Dodaj tagi", "transaction.tags.contact.name": "Nazwa kontaktu", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 4bafe6c4..9973fb3a 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -985,6 +985,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Это действие удалит все связанные транзакции и прекратит создание новых транзакций. Восстановление транзакций из корзины не приведет к созданию новых транзакций.", "transaction.recurring.edit": "Редактировать повторяющуюся транзакцию", "transaction.recurring.setup": "Настроить повторяющуюся транзакцию", + "transaction.recurring.variableAmount": "Сумма меняется", + "transaction.recurring.variableAmount.description": "Спрашивает сумму каждый раз", "transaction.tags": "Теги", "transaction.tags.add": "Добавить теги", "transaction.tags.contact.name": "Имя контакта", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index e6ae1221..0ec4b31d 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -954,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Bu işlem, ilgili tüm işlemleri silecek ve yeni işlemlerin oluşturulmasını durduracaktır. İşlemleri çöp kutusundan kurtarmak yeni işlemlerin oluşturulmasına neden olmayacaktır.", "transaction.recurring.edit": "Yinelenen işlemi düzenle", "transaction.recurring.setup": "Yinelenen işlemi ayarlayın", + "transaction.recurring.variableAmount": "Tutar değişken", + "transaction.recurring.variableAmount.description": "Her seferinde tutarı sorar", "transaction.tags": "Etiketler", "transaction.tags.add": "Etiket ekle", "transaction.tags.contact.name": "Kişi adı", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 978cf290..306c5867 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -985,6 +985,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "Ця дія видалить усі пов'язані транзакції та припинить створення нових транзакцій. Відновлення транзакцій з кошика не призведе до створення нових транзакцій.", "transaction.recurring.edit": "Редагувати повторювану транзакцію", "transaction.recurring.setup": "Налаштувати повторювану транзакцію", + "transaction.recurring.variableAmount": "Сума змінюється", + "transaction.recurring.variableAmount.description": "Запитує суму щоразу", "transaction.tags": "Теги", "transaction.tags.add": "Додати теги", "transaction.tags.contact.name": "Ім’я контакту", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index 7c1ff736..9f0787c3 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -954,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "这将删除所有相关的交易,并停止创建新交易。即使从回收站恢复交易,也不会让它重新开始创建新交易。", "transaction.recurring.edit": "编辑周期性交易", "transaction.recurring.setup": "设置周期性交易", + "transaction.recurring.variableAmount": "金额不固定", + "transaction.recurring.variableAmount.description": "每次询问金额", "transaction.tags": "标签", "transaction.tags.add": "添加标签", "transaction.tags.contact.name": "联系人名称", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index a37dc088..4069c40f 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -954,6 +954,8 @@ "transaction.recurring.delete.deleteAllDisclaimer": "這將刪除所有相關的交易,並停止建立新交易。即使從垃圾桶復原交易,也不會讓它重新開始建立新交易。", "transaction.recurring.edit": "編輯週期性交易", "transaction.recurring.setup": "設定週期性交易", + "transaction.recurring.variableAmount": "金額不固定", + "transaction.recurring.variableAmount.description": "每次詢問金額", "transaction.tags": "標籤", "transaction.tags.add": "新增標籤", "transaction.tags.contact.name": "聯絡人名稱", diff --git a/lib/data/insights/insight_adapter.dart b/lib/data/insights/insight_adapter.dart index 4c4caffc..6dfb765c 100644 --- a/lib/data/insights/insight_adapter.dart +++ b/lib/data/insights/insight_adapter.dart @@ -84,7 +84,14 @@ List<InsightRecurringTemplate> insightTemplatesOf( if (amount == null) continue; - result.add(InsightRecurringTemplate(title: template.title, amount: amount)); + result.add( + InsightRecurringTemplate( + title: template.title, + amount: amount, + uuid: recurring.uuid, + variableAmount: recurring.variableAmount, + ), + ); } return result; diff --git a/lib/data/insights/insight_engine.dart b/lib/data/insights/insight_engine.dart index 5f6dc352..0d2a9ac3 100644 --- a/lib/data/insights/insight_engine.dart +++ b/lib/data/insights/insight_engine.dart @@ -608,10 +608,19 @@ class InsightEngine { series.priceChangeIndex! - (series.previousPriceIndex ?? 0) >= 2 && _chargedThisMonth(series); + bool _isSuggestableVariable(RecurringSeries series) => + series.cadence == .monthly && + series.occurrences.length >= + InsightThresholds.variableSuggestionMinOccurrences && + coefficientOfVariation( + series.occurrences.map((item) => item.amount).toList(), + ) <= + InsightThresholds.variableSuggestionMaxVariation; + List<RecurringChargeInsight> _recurringCharges() => [ for (final RecurringSeries series in _series) // A price change says more this month; the suggestion can wait. - if (series.isFixedPrice && + if ((series.isFixedPrice || _isSuggestableVariable(series)) && !series.isTracked && _chargedThisMonth(series) && !_changedPriceThisMonth(series) && diff --git a/lib/data/insights/insight_request.dart b/lib/data/insights/insight_request.dart index 8b52ebbd..5367887a 100644 --- a/lib/data/insights/insight_request.dart +++ b/lib/data/insights/insight_request.dart @@ -42,7 +42,17 @@ class InsightRecurringTemplate { /// Absolute amount, in the primary currency. final double amount; - const InsightRecurringTemplate({required this.title, required this.amount}); + final String? uuid; + + /// Amount changes each time, so only the title has to match. + final bool variableAmount; + + const InsightRecurringTemplate({ + required this.title, + required this.amount, + this.uuid, + this.variableAmount = false, + }); } /// An insight the UI has shown before, used for the cooldown. diff --git a/lib/data/insights/insight_thresholds.dart b/lib/data/insights/insight_thresholds.dart index 872699b9..325c1aef 100644 --- a/lib/data/insights/insight_thresholds.dart +++ b/lib/data/insights/insight_thresholds.dart @@ -75,6 +75,10 @@ abstract final class InsightThresholds { /// month boundaries, never suggested. static const double variableChargeMaxVariation = 0.5; + /// Variable bills need a longer, steadier history to be suggested. + static const int variableSuggestionMinOccurrences = 4; + static const double variableSuggestionMaxVariation = 0.35; + /// Amounts further apart than this ratio are split into separate series. static const double amountClusterRatio = 1.25; diff --git a/lib/data/insights/recurring_series.dart b/lib/data/insights/recurring_series.dart index 828703bc..d93eabc0 100644 --- a/lib/data/insights/recurring_series.dart +++ b/lib/data/insights/recurring_series.dart @@ -193,11 +193,20 @@ RecurringSeries? _detect( final List<double> amounts = items.map((item) => item.amount.abs()).toList(); - final ({bool isFixedPrice, int? priceChangeIndex, int? previousPriceIndex})? - price = _price(amounts, rhythm.cadence); + ({bool isFixedPrice, int? priceChangeIndex, int? previousPriceIndex})? price = + _price(amounts, rhythm.cadence); if (price == null) return null; + // Tracked as variable, so there's no price to change + if (_isVariableTemplate(key, items.last.title, templates)) { + price = ( + isFixedPrice: false, + priceChangeIndex: null, + previousPriceIndex: null, + ); + } + final List<RecurringOccurrence> occurrences = items .map( (item) => RecurringOccurrence( @@ -451,6 +460,7 @@ bool _matchesTemplate( return templates.any((template) { if (normalizeTitle(template.title) != title) return false; + if (template.variableAmount) return true; final double amount = template.amount.abs(); final double tolerance = amount * InsightThresholds.templateAmountTolerance; @@ -459,6 +469,23 @@ bool _matchesTemplate( }); } +bool _isVariableTemplate( + String key, + String? seriesTitle, + List<InsightRecurringTemplate> templates, +) { + final String? title = normalizeTitle(seriesTitle); + + return templates.any( + (template) => + template.variableAmount && + ((template.uuid != null && + (key == "recurring:${template.uuid}" || + key.startsWith("recurring:${template.uuid}:"))) || + (title != null && normalizeTitle(template.title) == title)), + ); +} + /// Ties go to the most recent value. String? _mostCommon(Iterable<String?> values) { final List<String?> list = values.toList(); diff --git a/lib/entity/recurring_transaction.dart b/lib/entity/recurring_transaction.dart index 800c3700..379b6baa 100644 --- a/lib/entity/recurring_transaction.dart +++ b/lib/entity/recurring_transaction.dart @@ -82,9 +82,14 @@ class RecurringTransaction extends EntityBase { bool disabled; + /// Amount changes each time, e.g., utility bills. Generated transactions + /// are pending with an estimate, and ask for the amount upon confirmation. + bool variableAmount; + RecurringTransaction({ this.id = 0, this.disabled = false, + this.variableAmount = false, required this.rules, required this.jsonTransactionTemplate, required this.range, diff --git a/lib/entity/recurring_transaction.g.dart b/lib/entity/recurring_transaction.g.dart index dc6d722c..03be8714 100644 --- a/lib/entity/recurring_transaction.g.dart +++ b/lib/entity/recurring_transaction.g.dart @@ -10,6 +10,7 @@ RecurringTransaction _$RecurringTransactionFromJson( Map<String, dynamic> json, ) => RecurringTransaction( disabled: json['disabled'] as bool? ?? false, + variableAmount: json['variableAmount'] as bool? ?? false, rules: (json['rules'] as List<dynamic>).map((e) => e as String).toList(), jsonTransactionTemplate: json['jsonTransactionTemplate'] as String, range: json['range'] as String, @@ -39,6 +40,7 @@ Map<String, dynamic> _$RecurringTransactionToJson( const UTCDateTimeConverter().toJson, ), 'disabled': instance.disabled, + 'variableAmount': instance.variableAmount, }; Value? _$JsonConverterFromJson<Json, Value>( diff --git a/lib/entity/transaction/extensions/default/recurring.dart b/lib/entity/transaction/extensions/default/recurring.dart index e47ea41d..48c75ab6 100644 --- a/lib/entity/transaction/extensions/default/recurring.dart +++ b/lib/entity/transaction/extensions/default/recurring.dart @@ -23,11 +23,15 @@ class Recurring extends TransactionExtension implements Jasonable { /// transaction is updated. final bool locked; + /// Amount is an estimate until confirmed. + final bool variableAmount; + Recurring({ required super.uuid, required this.initialTransactionDate, this.relatedTransactionUuid, this.locked = false, + this.variableAmount = false, }) : super(); @override @@ -44,6 +48,7 @@ class Recurring extends TransactionExtension implements Jasonable { String? relatedTransactionUuid, String? uuid, bool? locked, + bool? variableAmount, }) { return Recurring( initialTransactionDate: @@ -52,6 +57,7 @@ class Recurring extends TransactionExtension implements Jasonable { relatedTransactionUuid ?? this.relatedTransactionUuid, uuid: uuid ?? this.uuid, locked: locked ?? this.locked, + variableAmount: variableAmount ?? this.variableAmount, ); } } diff --git a/lib/entity/transaction/extensions/default/recurring.g.dart b/lib/entity/transaction/extensions/default/recurring.g.dart index 16374446..c9d794eb 100644 --- a/lib/entity/transaction/extensions/default/recurring.g.dart +++ b/lib/entity/transaction/extensions/default/recurring.g.dart @@ -13,6 +13,7 @@ Recurring _$RecurringFromJson(Map<String, dynamic> json) => Recurring( ), relatedTransactionUuid: json['relatedTransactionUuid'] as String?, locked: json['locked'] as bool? ?? false, + variableAmount: json['variableAmount'] as bool? ?? false, ); Map<String, dynamic> _$RecurringToJson(Recurring instance) => <String, dynamic>{ @@ -20,5 +21,6 @@ Map<String, dynamic> _$RecurringToJson(Recurring instance) => <String, dynamic>{ 'relatedTransactionUuid': instance.relatedTransactionUuid, 'initialTransactionDate': instance.initialTransactionDate.toIso8601String(), 'locked': instance.locked, + 'variableAmount': instance.variableAmount, 'key': instance.key, }; diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 3e651d92..aa9ea236 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -634,6 +634,27 @@ extension TransactionActions on Transaction { } } + /// Confirms with the actual [amount], keeping the sign. The other side of + /// a transfer follows through the conversion rate. + bool confirmWithAmount(double amount, [bool updateTransactionDate = true]) { + final bool outgoing = this.amount.isNegative; + + this.amount = outgoing ? -amount.abs() : amount.abs(); + TransactionsService().updateOneSync(this); + + final Transaction? related = TransactionsService() + .findTransferRelatedTransactionSync(this); + + if (related != null) { + final double rate = extensions.transfer?.conversionRate ?? 1.0; + + related.amount = outgoing ? amount.abs() * rate : -amount.abs() / rate; + TransactionsService().updateOneSync(related); + } + + return confirm(true, updateTransactionDate); + } + /// Returns the ObjectBox ID for the newly created transaction int duplicate() { if (extensions.transfer case Transfer transferDetails) { @@ -902,6 +923,7 @@ extension AccountActions on Account { bool? isPending, double? conversionRate = 1.0, Recurrence? recurrence, + bool variableRecurringAmount = false, List<String>? extraTags, }) { if (conversionRate == 0) { @@ -922,6 +944,7 @@ extension AccountActions on Account { isPending: isPending, conversionRate: 1.0 / (conversionRate ?? 1.0), recurrence: recurrence, + variableRecurringAmount: variableRecurringAmount, extraTags: extraTags, tags: tags, attachments: attachments, @@ -1022,6 +1045,7 @@ extension AccountActions on Account { recurrence: recurrence!, uuidOverride: recurringTransactionUuid, transferToAccountUuid: targetAccount.uuid, + variableAmount: variableRecurringAmount, ); } @@ -1043,6 +1067,7 @@ extension AccountActions on Account { bool? isPending, TransactionSubtype? subtype, Recurrence? recurrence, + bool variableRecurringAmount = false, List<String>? extraTags, double? latitude, double? longitude, @@ -1140,6 +1165,7 @@ extension AccountActions on Account { identifier: uuid, recurrence: recurrence!, uuidOverride: recurringTransactionUuid, + variableAmount: variableRecurringAmount, ); } diff --git a/lib/objectbox/objectbox-model.json b/lib/objectbox/objectbox-model.json index 9935e349..e86f65eb 100644 --- a/lib/objectbox/objectbox-model.json +++ b/lib/objectbox/objectbox-model.json @@ -592,7 +592,7 @@ }, { "id": "12:800756592587838565", - "lastPropertyId": "11:420551111786793892", + "lastPropertyId": "12:7730179234101270201", "name": "RecurringTransaction", "properties": [ { @@ -640,6 +640,11 @@ "id": "10:651096412135846367", "name": "lastGeneratedTransactionDate", "type": 10 + }, + { + "id": "12:7730179234101270201", + "name": "variableAmount", + "type": 1 } ], "relations": [] diff --git a/lib/objectbox/objectbox.g.dart b/lib/objectbox/objectbox.g.dart index c19eff0c..308e151e 100644 --- a/lib/objectbox/objectbox.g.dart +++ b/lib/objectbox/objectbox.g.dart @@ -724,7 +724,7 @@ final _entities = <obx_int.ModelEntity>[ obx_int.ModelEntity( id: const obx_int.IdUid(12, 800756592587838565), name: 'RecurringTransaction', - lastPropertyId: const obx_int.IdUid(11, 420551111786793892), + lastPropertyId: const obx_int.IdUid(12, 7730179234101270201), flags: 0, properties: <obx_int.ModelProperty>[ obx_int.ModelProperty( @@ -781,6 +781,12 @@ final _entities = <obx_int.ModelEntity>[ type: 10, flags: 0, ), + obx_int.ModelProperty( + id: const obx_int.IdUid(12, 7730179234101270201), + name: 'variableAmount', + type: 1, + flags: 0, + ), ], relations: <obx_int.ModelRelation>[], backlinks: <obx_int.ModelBacklink>[], @@ -1979,7 +1985,7 @@ obx_int.ModelDefinition getObjectBoxModel() { final transferToAccountUuidOffset = object.transferToAccountUuid == null ? null : fbb.writeString(object.transferToAccountUuid!); - fbb.startTable(12); + fbb.startTable(13); fbb.addInt64(0, object.id); fbb.addOffset(1, uuidOffset); fbb.addOffset(2, jsonTransactionTemplateOffset); @@ -1992,6 +1998,7 @@ obx_int.ModelDefinition getObjectBoxModel() { 9, object.lastGeneratedTransactionDate?.millisecondsSinceEpoch, ); + fbb.addBool(11, object.variableAmount); fbb.finish(fbb.endTable()); return object.id; }, @@ -2012,6 +2019,12 @@ obx_int.ModelDefinition getObjectBoxModel() { 16, false, ); + final variableAmountParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 26, + false, + ); final rulesParam = const fb.ListReader<String>( fb.StringReader(asciiOptimization: true), lazy: false, @@ -2040,6 +2053,7 @@ obx_int.ModelDefinition getObjectBoxModel() { final object = RecurringTransaction( id: idParam, disabled: disabledParam, + variableAmount: variableAmountParam, rules: rulesParam, jsonTransactionTemplate: jsonTransactionTemplateParam, range: rangeParam, @@ -2845,6 +2859,11 @@ class RecurringTransaction_ { /// See [RecurringTransaction.lastGeneratedTransactionDate]. static final lastGeneratedTransactionDate = obx.QueryDateProperty<RecurringTransaction>(_entities[8].properties[8]); + + /// See [RecurringTransaction.variableAmount]. + static final variableAmount = obx.QueryBooleanProperty<RecurringTransaction>( + _entities[8].properties[9], + ); } /// [TransactionTag] entity fields to define ObjectBox queries. diff --git a/lib/routes.dart b/lib/routes.dart index 5050bd6a..47840eb4 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -153,6 +153,8 @@ final GoRouter router = GoRouter( initialRecurrence: Recurrence.tryParse( state.uri.queryParameters["recurrence"] ?? "", ), + initialVariableAmount: + state.uri.queryParameters["variableAmount"] == "true", ), fullscreenDialog: true, ), diff --git a/lib/routes/stats/recurring_page.dart b/lib/routes/stats/recurring_page.dart index 81e31431..85baf98b 100644 --- a/lib/routes/stats/recurring_page.dart +++ b/lib/routes/stats/recurring_page.dart @@ -3,6 +3,8 @@ import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/category.dart"; import "package:flow/entity/recurring_transaction.dart"; import "package:flow/entity/transaction.dart"; +import "package:flow/entity/transaction/extensions/default/recurring.dart"; +import "package:flow/entity/transaction/wrapper.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/services/categories.dart"; @@ -56,6 +58,10 @@ class _RecurringPageState extends State<RecurringPage> double totalIncome = 0.0; double totalExpense = 0.0; + /// Whether the totals include variable amount estimates + bool approximateIncome = false; + bool approximateExpense = false; + /// Maps an already-logged occurrence's list key to its real transaction id. /// Occurrences absent here are still upcoming previews — badged with an eye /// and non-openable. @@ -94,6 +100,8 @@ class _RecurringPageState extends State<RecurringPage> income: Money(totalIncome, primaryCurrency), expense: Money(totalExpense, primaryCurrency), count: occurrences.length, + approximateIncome: approximateIncome, + approximateExpense: approximateExpense, ), const SizedBox(height: 16.0), switch ((activeCount, occurrences.isEmpty)) { @@ -246,11 +254,14 @@ class _RecurringPageState extends State<RecurringPage> final Map<String, int> loggedIds = {}; double income = 0.0; double expense = 0.0; + bool incomeEstimated = false; + bool expenseEstimated = false; int active = 0; for (final RecurringTransaction recurring in recurrings) { // Validate the template once; a stale currency code throws on `money`. - if (_decodeTemplate(recurring) == null) continue; + final Transaction? template = _decodeTemplate(recurring); + if (template == null) continue; active++; @@ -260,6 +271,8 @@ class _RecurringPageState extends State<RecurringPage> TransactionFilter(extraTag: recurring.extensionIdentifierTag), ); + final double estimate = recurring.estimateAmount(template, logged); + final String? categoryUuid = recurring.template.categoryUuid; final Category? category = categoryUuid == null ? null @@ -284,6 +297,10 @@ class _RecurringPageState extends State<RecurringPage> loggedIds[_occurrenceKey(occurrence)] = loggedMatch.id; } + final bool estimated = + recurring.variableAmount && + _estimateOccurrence(occurrence, loggedMatch, estimate); + final double? converted = occurrence.money.tryConvertAmount( primaryCurrency, rates, @@ -296,8 +313,10 @@ class _RecurringPageState extends State<RecurringPage> switch (occurrence.type) { case TransactionType.income: income += converted.abs(); + incomeEstimated |= estimated; case TransactionType.expense: expense += converted.abs(); + expenseEstimated |= estimated; case TransactionType.transfer: break; } @@ -311,6 +330,8 @@ class _RecurringPageState extends State<RecurringPage> activeCount = active; totalIncome = income; totalExpense = expense; + approximateIncome = incomeEstimated; + approximateExpense = expenseEstimated; missingRates = missing; } finally { busy = false; @@ -318,6 +339,34 @@ class _RecurringPageState extends State<RecurringPage> } } + /// Shows the confirmed amount if [logged] has one, [estimate] otherwise. + /// + /// Returns whether [occurrence] ended up with an estimate. + bool _estimateOccurrence( + Transaction occurrence, + Transaction? logged, + double estimate, + ) { + if (logged != null && + logged.isPending != true && + logged.accountUuid == occurrence.accountUuid) { + occurrence.amount = logged.amount; + return false; + } + + occurrence + ..amount = estimate + ..extensions = ExtensionsWrapper([ + ...occurrence.extensions.data.where((ext) => ext is! Recurring), + Recurring( + uuid: occurrence.extensions.recurring?.uuid ?? occurrence.uuid, + initialTransactionDate: occurrence.transactionDate, + variableAmount: true, + ), + ]); + return true; + } + Transaction? _decodeTemplate(RecurringTransaction recurring) { try { final Transaction template = recurring.template; diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index 24f3c663..1babc241 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -81,15 +81,20 @@ class TransactionPage extends StatefulWidget { /// Suggested recurrence for a transaction that isn't recurring yet. final Recurrence? initialRecurrence; + /// Suggested [RecurringTransaction.variableAmount] for [initialRecurrence] + final bool initialVariableAmount; + bool get isNewTransaction => transactionId == 0; const TransactionPage.create({super.key, this.params}) : transactionId = 0, - initialRecurrence = null; + initialRecurrence = null, + initialVariableAmount = false; const TransactionPage.edit({ super.key, required this.transactionId, this.initialRecurrence, + this.initialVariableAmount = false, }) : params = null; @override @@ -144,6 +149,8 @@ class _TransactionPageState extends State<TransactionPage> { Recurrence? _recurrence; + bool _variableAmount = false; + DateTime? _transactionDate; DateTime get transactionDate => _transactionDate ?? DateTime.now(); @@ -249,8 +256,10 @@ class _TransactionPageState extends State<TransactionPage> { _currentlyEditing.extensions.recurring?.uuid, ); _recurrence = _recurringTransaction?.recurrence; + _variableAmount = _recurringTransaction?.variableAmount ?? false; } else { _recurrence = widget.initialRecurrence; + _variableAmount = widget.initialVariableAmount; } } } @@ -525,10 +534,28 @@ class _TransactionPageState extends State<TransactionPage> { child: AnimatedSize( duration: const Duration(milliseconds: 300), child: _recurrence != null - ? SelectRecurrence( - initialValue: _recurrence, - onChanged: updateRecurrence, - startBounds: startBounds, + ? Column( + mainAxisSize: .min, + children: [ + SelectRecurrence( + initialValue: _recurrence, + onChanged: updateRecurrence, + startBounds: startBounds, + ), + SwitchListTile( + title: Text( + "transaction.recurring.variableAmount" + .t(context), + ), + subtitle: Text( + "transaction.recurring.variableAmount.description" + .t(context), + ), + secondary: Icon(Symbols.tune_rounded), + value: _variableAmount, + onChanged: updateVariableAmount, + ), + ], ) : ListTile( leading: Icon(Symbols.repeat_rounded), @@ -978,6 +1005,12 @@ class _TransactionPageState extends State<TransactionPage> { setState(() {}); } + void updateVariableAmount(bool value) { + setState(() { + _variableAmount = value; + }); + } + void _postSelectTransactionDate() async { final bool pendingTransactionsRequireConfrimation = LocalPreferences() .pendingTransactions @@ -1119,7 +1152,8 @@ class _TransactionPageState extends State<TransactionPage> { if (originalTransactionWasRecurring) { final List<RecurringUpdateMode> availableModes = [ - if (_recurrence == _recurringTransaction!.recurrence) + if (_recurrence == _recurringTransaction!.recurrence && + _variableAmount == _recurringTransaction!.variableAmount) RecurringUpdateMode.current, RecurringUpdateMode.thisAndFuture, ]; @@ -1160,6 +1194,7 @@ class _TransactionPageState extends State<TransactionPage> { isPending: _isPending, conversionRate: crossCurrencyTransfer ? _conversionRate : null, recurrence: _recurrence, + variableRecurringAmount: _variableAmount, tags: _selectedTags, attachments: _attachments, ); @@ -1216,6 +1251,7 @@ class _TransactionPageState extends State<TransactionPage> { transferToAccountUuid: isTransfer ? _selectedAccountTransferTo?.uuid : null, + variableAmount: _variableAmount, ); } @@ -1271,6 +1307,7 @@ class _TransactionPageState extends State<TransactionPage> { _recurrence?.range ?? recurringTransaction.timeRange; recurringTransaction.recurrenceRules = _recurrence?.rules ?? recurringTransaction.recurrenceRules; + recurringTransaction.variableAmount = _variableAmount; recurringTransaction.transferToAccountUuid = _selectedAccountTransferTo?.uuid ?? recurringTransaction.transferToAccountUuid; @@ -1320,6 +1357,7 @@ class _TransactionPageState extends State<TransactionPage> { isPending: _isPending, conversionRate: crossCurrencyTransfer ? _conversionRate : null, recurrence: _recurrence, + variableRecurringAmount: _variableAmount, tags: _selectedTags, attachments: _attachments, latitude: _geo?.latitude, @@ -1335,6 +1373,7 @@ class _TransactionPageState extends State<TransactionPage> { extensions: extensions, isPending: _isPending, recurrence: _recurrence, + variableRecurringAmount: _variableAmount, tags: _selectedTags, attachments: _attachments, latitude: _geo?.latitude, @@ -1377,7 +1416,10 @@ class _TransactionPageState extends State<TransactionPage> { _attachments?.map((attachment) => attachment.uuid).toSet(), _currentlyEditing.attachments.map((file) => file.uuid).toSet(), ) || - _currentlyEditing.transactionDate != _transactionDate; + _currentlyEditing.transactionDate != _transactionDate || + _variableAmount != + (_recurringTransaction?.variableAmount ?? + widget.initialVariableAmount); } return _amount != 0 || diff --git a/lib/services/recurring_transactions.dart b/lib/services/recurring_transactions.dart index 930e0fc5..64ad50e2 100644 --- a/lib/services/recurring_transactions.dart +++ b/lib/services/recurring_transactions.dart @@ -188,13 +188,20 @@ class RecurringTransactionsService { ); } - final bool isPending = nextOccurence.isAfter(anchor) - ? PendingTransactionsLocalPreferences().requireConfrimation.get() - : false; + // Variable amounts are estimates, so they wait for the actual amount + final bool isPending = + recurringTransaction.variableAmount || + (nextOccurence.isAfter(anchor) && + PendingTransactionsLocalPreferences().requireConfrimation.get()); + + final double amount = recurringTransaction.estimateAmount( + template, + relatedTransactions, + ); if (to == null) { from.createAndSaveTransaction( - amount: template.amount, + amount: amount, category: category, tags: tags, title: template.title, @@ -207,6 +214,7 @@ class RecurringTransactionsService { initialTransactionDate: nextOccurence, uuid: recurringTransaction.uuid, relatedTransactionUuid: generatedTransactionUuid, + variableAmount: recurringTransaction.variableAmount, ), ], isPending: isPending, @@ -219,7 +227,7 @@ class RecurringTransactionsService { final (int fromObjectId, int toObjectId) = from.transferTo( targetAccount: to, tags: tags, - amount: template.amount.abs(), + amount: amount.abs(), title: template.title, description: template.description, transactionDate: nextOccurence, @@ -227,6 +235,7 @@ class RecurringTransactionsService { Recurring( uuid: recurringTransaction.uuid, initialTransactionDate: nextOccurence, + variableAmount: recurringTransaction.variableAmount, ), ], isPending: isPending, @@ -298,6 +307,7 @@ class RecurringTransactionsService { required Recurrence recurrence, String? uuidOverride, String? transferToAccountUuid, + bool variableAmount = false, }) { if (identifier == null) { throw ArgumentError("identifier must be a Transaction or an identifier"); @@ -319,6 +329,7 @@ class RecurringTransactionsService { uuid: uuidOverride ?? const Uuid().v4(), jsonTransactionTemplate: jsonEncode(transaction.toJson()), transferToAccountUuid: transferToAccountUuid, + variableAmount: variableAmount, range: recurrence.range.encodeShort(), rules: recurrence.rules.map((e) => e.serialize()).toList(), lastGeneratedTransactionDate: transaction.transactionDate, diff --git a/lib/utils/extensions/recurring_transaction.dart b/lib/utils/extensions/recurring_transaction.dart index 1e508880..7e2eaf26 100644 --- a/lib/utils/extensions/recurring_transaction.dart +++ b/lib/utils/extensions/recurring_transaction.dart @@ -1,4 +1,5 @@ import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction/extensions/default/recurring.dart"; extension RecurringTransactionHelpers on RecurringTransaction { @@ -6,4 +7,27 @@ extension RecurringTransactionHelpers on RecurringTransaction { uuid: uuid, initialTransactionDate: DateTime.now(), ).extensionIdentifierTag; + + /// Amount for the next occurrence. With [variableAmount], it's the latest + /// confirmed amount among [logged], falling back to the [template]'s. + double estimateAmount(Transaction template, Iterable<Transaction> logged) { + if (!variableAmount) return template.amount; + + Transaction? latest; + + for (final Transaction transaction in logged) { + if (transaction.isPending == true || transaction.isDeleted == true) { + continue; + } + // Transfers log both sides; the template is the outgoing one + if (transaction.isTransfer && !transaction.amount.isNegative) continue; + + if (latest == null || + transaction.transactionDate.isAfter(latest.transactionDate)) { + latest = transaction; + } + } + + return latest?.amount ?? template.amount; + } } diff --git a/lib/utils/extensions/transaction.dart b/lib/utils/extensions/transaction.dart index f5ea09bd..17caa442 100644 --- a/lib/utils/extensions/transaction.dart +++ b/lib/utils/extensions/transaction.dart @@ -6,7 +6,9 @@ import "package:flow/entity/transaction/extensions/default/recurring.dart"; import "package:flow/entity/transaction/extensions/default/transfer.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/routes/transaction_page/input_amount_sheet.dart"; import "package:flow/routes/transaction_page/select_recurring_update_mode_sheet.dart"; import "package:flow/services/recurring_transactions.dart"; import "package:flow/services/transactions.dart"; @@ -27,6 +29,36 @@ extension TransactionHelpers on Transaction { ); } + /// Pending estimate of a variable amount recurring transaction + bool get isAmountEstimate => + isPending == true && + isDeleted != true && + extensions.recurring?.variableAmount == true; + + /// Confirms, asking for the actual amount first if it's an estimate. + Future<bool> confirmPrompted( + BuildContext context, [ + bool updateTransactionDate = true, + ]) async { + if (!isAmountEstimate) return confirm(true, updateTransactionDate); + + final double? amount = await showModalBottomSheet<double>( + context: context, + builder: (context) => InputAmountSheet( + initialAmount: this.amount.abs(), + currency: currency, + title: title, + lockSign: true, + allowNegative: false, + ), + isScrollControlled: true, + ); + + if (amount == null) return false; + + return confirmWithAmount(amount, updateTransactionDate); + } + bool holdable([DateTime? anchor]) { if (isDeleted == true) return false; if (isPending == true) return false; diff --git a/lib/widgets/flow_card.dart b/lib/widgets/flow_card.dart index fa26c72c..0ff5efcb 100644 --- a/lib/widgets/flow_card.dart +++ b/lib/widgets/flow_card.dart @@ -11,11 +11,15 @@ class FlowCard extends StatelessWidget { final TransactionType type; final Money flow; + /// Prefixes "~" when [flow] includes estimates + final bool approximate; + const FlowCard({ super.key, required this.flow, required this.type, this.autoSizeGroup, + this.approximate = false, }); @override @@ -33,7 +37,7 @@ class FlowCard extends StatelessWidget { alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), child: AutoSizeText( - flow.abs().formatted, + "${approximate ? "~" : ""}${flow.abs().formatted}", style: context.textTheme.displaySmall?.copyWith( color: type.color(context), ), diff --git a/lib/widgets/general/money_text.dart b/lib/widgets/general/money_text.dart index 6463e5a1..a5aa7b29 100644 --- a/lib/widgets/general/money_text.dart +++ b/lib/widgets/general/money_text.dart @@ -21,6 +21,9 @@ class MoneyText extends StatefulWidget { final bool displayAbsoluteAmount; final bool omitCurrency; + /// Prefixes "~" for estimates, e.g., "~$42" + final bool approximate; + /// Uses 3-letter-code instead of the currency symbol. /// /// e.g., '€' instead of 'EUR' @@ -55,6 +58,7 @@ class MoneyText extends StatefulWidget { this.initiallyAbbreviated = false, this.displayAbsoluteAmount = false, this.omitCurrency = false, + this.approximate = false, this.maxLines = 1, this.overrideUseCurrencySymbol, this.overrideObscure, @@ -100,7 +104,7 @@ class _MoneyTextState extends State<MoneyText> { widget.onTap != null || widget.tapToToggleAbbreviation; return MoneyTextRaw( - text: text, + text: widget.approximate ? "~$text" : text, style: widget.style, textAlign: widget.textAlign, maxLines: widget.maxLines, diff --git a/lib/widgets/grouped_transactions_list_view.dart b/lib/widgets/grouped_transactions_list_view.dart index 11c46a22..1424363c 100644 --- a/lib/widgets/grouped_transactions_list_view.dart +++ b/lib/widgets/grouped_transactions_list_view.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/objectbox/actions.dart"; @@ -199,7 +201,13 @@ class _GroupedTransactionsListViewState .updateDateUponConfirmation .get(); - transaction.confirm(confirm, updateTransactionDate); + if (confirm) { + unawaited( + transaction.confirmPrompted(context, updateTransactionDate), + ); + } else { + transaction.confirm(false, updateTransactionDate); + } }, duplicateFn: () => transaction.duplicate(), overrideObscure: widget.overrideObscure, diff --git a/lib/widgets/home/stats/bento/recurring_tile.dart b/lib/widgets/home/stats/bento/recurring_tile.dart index af3fd4d1..3740de8c 100644 --- a/lib/widgets/home/stats/bento/recurring_tile.dart +++ b/lib/widgets/home/stats/bento/recurring_tile.dart @@ -1,10 +1,13 @@ import "package:flow/data/money.dart"; +import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/recurring_transaction.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/services/recurring_transactions.dart"; +import "package:flow/services/transactions.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions.dart"; +import "package:flow/utils/extensions/recurring_transaction.dart"; import "package:flow/utils/primary_currency_dependent_state.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/home/stats/bento/bento_tile.dart"; @@ -32,6 +35,9 @@ class _RecurringTileState extends State<RecurringTile> double outflow = 0.0; int upcoming = 0; + /// Whether [outflow] includes variable amount estimates + bool approximate = false; + @override Widget build(BuildContext context) { return BentoTile( @@ -56,6 +62,7 @@ class _RecurringTileState extends State<RecurringTile> style: context.textTheme.headlineSmall, autoSize: true, initiallyAbbreviated: true, + approximate: approximate, ), const SizedBox(height: 8.0), Text( @@ -87,6 +94,7 @@ class _RecurringTileState extends State<RecurringTile> double totalOutflow = 0.0; int count = 0; + bool estimated = false; for (final RecurringTransaction recurring in recurrings) { final Transaction? template = _decodeTemplate(recurring); @@ -97,13 +105,14 @@ class _RecurringTileState extends State<RecurringTile> // the outflow total, leaving the two figures describing different sets. if (template.type != TransactionType.expense) continue; - final Money? money = _templateMoney(template); + final Money? money = _estimateMoney(recurring, template); if (money == null) continue; final List<DateTime> occurrences = recurring.recurrence.occurrences( subrange: window, ); count += occurrences.length; + estimated |= recurring.variableAmount && occurrences.isNotEmpty; final double? converted = money.tryConvertAmount( primaryCurrency, @@ -116,6 +125,7 @@ class _RecurringTileState extends State<RecurringTile> outflow = totalOutflow; upcoming = count; + approximate = estimated; loaded = true; } finally { busy = false; @@ -131,9 +141,18 @@ class _RecurringTileState extends State<RecurringTile> } } - Money? _templateMoney(Transaction template) { + Money? _estimateMoney(RecurringTransaction recurring, Transaction template) { try { - return template.money; + if (!recurring.variableAmount) return template.money; + + final List<Transaction> logged = TransactionsService().findManySync( + TransactionFilter(extraTag: recurring.extensionIdentifierTag), + ); + + return Money( + recurring.estimateAmount(template, logged), + template.currency, + ); } catch (_) { return null; } diff --git a/lib/widgets/insights/insight_actions.dart b/lib/widgets/insights/insight_actions.dart index a0074b32..e2331965 100644 --- a/lib/widgets/insights/insight_actions.dart +++ b/lib/widgets/insights/insight_actions.dart @@ -35,7 +35,7 @@ abstract final class InsightActions { ); await context.push( - "/transaction/${latest.id}?recurrence=${Uri.encodeQueryComponent(recurrence.serialize())}", + "/transaction/${latest.id}?recurrence=${Uri.encodeQueryComponent(recurrence.serialize())}&variableAmount=${!series.isFixedPrice}", ); } diff --git a/lib/widgets/insights/insight_summary.dart b/lib/widgets/insights/insight_summary.dart index 7206c9e1..439173e2 100644 --- a/lib/widgets/insights/insight_summary.dart +++ b/lib/widgets/insights/insight_summary.dart @@ -227,7 +227,9 @@ class _InsightSummaryBuilder { final RecurringSeries series = insight.series; final Map<String, String> values = { - "amount": format.money(series.typicalAmount), + "amount": + "${series.isFixedPrice ? "" : "~"}" + "${format.money(series.typicalAmount)}", "times": _t("times", series.occurrences.length), ...InsightSummary.dayValues(series.anchorDay), "weekday": DateTime(2024, 1, series.anchorDay).toMoment().format("dddd"), diff --git a/lib/widgets/stats/recurring/recurring_summary_header.dart b/lib/widgets/stats/recurring/recurring_summary_header.dart index c4734214..6232b7f9 100644 --- a/lib/widgets/stats/recurring/recurring_summary_header.dart +++ b/lib/widgets/stats/recurring/recurring_summary_header.dart @@ -15,11 +15,17 @@ class RecurringSummaryHeader extends StatelessWidget { final Money expense; final int count; + /// Whether [income] or [expense] include variable amount estimates + final bool approximateIncome; + final bool approximateExpense; + const RecurringSummaryHeader({ super.key, required this.income, required this.expense, required this.count, + this.approximateIncome = false, + this.approximateExpense = false, }); @override @@ -37,6 +43,7 @@ class RecurringSummaryHeader extends StatelessWidget { flow: income, type: TransactionType.income, autoSizeGroup: autoSizeGroup, + approximate: approximateIncome, ), ), const SizedBox(width: 12.0), @@ -45,6 +52,7 @@ class RecurringSummaryHeader extends StatelessWidget { flow: expense, type: TransactionType.expense, autoSizeGroup: autoSizeGroup, + approximate: approximateExpense, ), ), ], diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index 18be1c2f..4c636b4b 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -240,6 +240,7 @@ class TransactionListTile extends StatelessWidget { transaction.money, displayAbsoluteAmount: transaction.isTransfer && combineTransfers, + approximate: transaction.isAmountEstimate, style: context.textTheme.bodyLarge?.copyWith( color: transaction.type.color(context), fontWeight: FontWeight.bold, diff --git a/test/unit/insights/insight_engine_test.dart b/test/unit/insights/insight_engine_test.dart index 9cb558e3..caf512f0 100644 --- a/test/unit/insights/insight_engine_test.dart +++ b/test/unit/insights/insight_engine_test.dart @@ -1029,7 +1029,7 @@ void main() { expect(ledger.analyze(august, now: lateAugust).insights, isEmpty); }); - test("a variable bill isn't suggested", () { + test("a steady variable bill is suggested", () { final TestLedger ledger = withCharge("Electricity", [ 82.0, 140.0, @@ -1039,7 +1039,64 @@ void main() { 88.0, ]); - expect(ledger.analyze(august, now: lateAugust).insights, isEmpty); + final RecurringChargeInsight insight = only<RecurringChargeInsight>( + ledger.analyze(august, now: lateAugust), + ).single; + + expect(insight.series.isFixedPrice, isFalse); + }); + + test("an erratic variable bill isn't suggested", () { + final TestLedger ledger = withCharge("Electricity", [ + 50.0, + 140.0, + 60.0, + 130.0, + 45.0, + 120.0, + ]); + + expect( + only<RecurringChargeInsight>(ledger.analyze(august, now: lateAugust)), + isEmpty, + ); + }); + + test("a variable bill needs 4 charges to be suggested", () { + final TestLedger ledger = withCharge("Electricity", [82.0, 140.0, 95.0]); + + expect( + only<RecurringChargeInsight>(ledger.analyze(august, now: lateAugust)), + isEmpty, + ); + }); + + test("a variable template tracks the bill regardless of amount", () { + final TestLedger ledger = withCharge("Electricity", [ + 82.0, + 140.0, + 95.0, + 61.0, + 120.0, + 88.0, + ]); + + expect( + only<RecurringChargeInsight>( + ledger.analyze( + august, + now: lateAugust, + templates: const [ + InsightRecurringTemplate( + title: "Electricity", + amount: 300.0, + variableAmount: true, + ), + ], + ), + ), + isEmpty, + ); }); test("only the biggest suggestion per month", () { diff --git a/test/unit/insights/recurring_series_test.dart b/test/unit/insights/recurring_series_test.dart index 58b9b012..1f4688bd 100644 --- a/test/unit/insights/recurring_series_test.dart +++ b/test/unit/insights/recurring_series_test.dart @@ -448,6 +448,38 @@ void main() { expect(found.single.isTracked, isTrue); }); + test("a tracked variable bill has no price change", () { + final TestLedger ledger = TestLedger(); + final List<double> amounts = [40.0, 40.0, 40.0, 60.0, 60.0, 60.0]; + final List<DateTime> dates = monthly(3); + + for (int i = 0; i < dates.length; i++) { + ledger.spend( + dates[i], + amounts[i], + title: "Water", + recurringUuid: "r-1", + ); + } + + expect(detect(ledger).single.priceChangeIndex, 3); + + final RecurringSeries series = detect( + ledger, + templates: const [ + InsightRecurringTemplate( + title: "Water", + amount: 40.0, + uuid: "r-1", + variableAmount: true, + ), + ], + ).single; + + expect(series.isFixedPrice, isFalse); + expect(series.priceChangeIndex, isNull); + }); + test("a template with another title or amount doesn't match", () { final List<RecurringSeries> found = detect( charges(monthly(3)), diff --git a/test/unit/variable_recurring_test.dart b/test/unit/variable_recurring_test.dart new file mode 100644 index 00000000..fd352414 --- /dev/null +++ b/test/unit/variable_recurring_test.dart @@ -0,0 +1,175 @@ +import "dart:io"; + +import "package:flow/entity/account.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flow/services/transactions.dart"; +import "package:flow/utils/extensions/recurring_transaction.dart"; +import "package:flow/utils/extensions/transaction.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path/path.dart" as path; +import "package:recurrence/recurrence.dart"; + +import "../objectbox_erase.dart"; + +void main() { + late ObjectBox obx; + late Account account; + late Account other; + + final String directory = path.join( + Directory.current.path, + ".objectbox_test_variable_recurring", + ); + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + obx = await ObjectBox.initialize( + customDirectory: directory, + subdirectory: "variable_recurring", + ); + + account = Account(name: "Card", currency: "USD", iconCode: "irrelevant"); + other = Account(name: "Savings", currency: "EUR", iconCode: "irrelevant"); + obx.box<Account>().putMany([account, other]); + }); + + tearDownAll(() async { + await testCleanupObject( + instance: obx, + directory: path.join(directory, "variable_recurring"), + ); + }); + + setUp(() { + obx.box<Transaction>().removeAll(); + obx.box<RecurringTransaction>().removeAll(); + }); + + /// A monthly rule that started 3 months ago, created from a -40 charge. + Future<RecurringTransaction> monthlyRule({required bool variable}) async { + final DateTime start = DateTime.now().subtract(const Duration(days: 90)); + + final int id = account.createAndSaveTransaction( + amount: -40.0, + title: "Electricity", + transactionDate: start, + ); + final Transaction first = obx.box<Transaction>().get(id)!; + + final Recurrence recurrence = Recurrence.fromIndefinitely( + rules: [RecurrenceRule.monthly(start.day)], + start: start, + ); + + final RecurringTransaction rule = RecurringTransaction( + jsonTransactionTemplate: "", + rules: recurrence.rules.map((rule) => rule.serialize()).toList(), + range: recurrence.range.encodeShort(), + variableAmount: variable, + lastGeneratedTransactionDate: start, + )..template = first; + + obx.box<RecurringTransaction>().put(rule); + + await RecurringTransactionsService().synchronizeAll(); + + return rule; + } + + List<Transaction> generated(RecurringTransaction rule) => + obx.box<Transaction>().getAll().where((transaction) { + return transaction.extensions.recurring?.uuid == rule.uuid; + }).toList() + ..sort((a, b) => a.transactionDate.compareTo(b.transactionDate)); + + test("Variable amounts are generated as pending estimates", () async { + final RecurringTransaction rule = await monthlyRule(variable: true); + final List<Transaction> items = generated(rule); + + expect(items, isNotEmpty); + for (final Transaction item in items) { + expect(item.isPending, isTrue); + expect(item.amount, -40.0); + expect(item.isAmountEstimate, isTrue); + } + }); + + test("Fixed amounts aren't estimates", () async { + final RecurringTransaction rule = await monthlyRule(variable: false); + final List<Transaction> items = generated(rule); + + expect(items, isNotEmpty); + expect(items.where((item) => item.isAmountEstimate), isEmpty); + }); + + test( + "Confirming takes the actual amount, and it's the next estimate", + () async { + final RecurringTransaction rule = await monthlyRule(variable: true); + final Transaction first = generated(rule).first; + + expect(first.confirmWithAmount(55.0, false), isTrue); + + final Transaction confirmed = TransactionsService().getOneSync(first.id)!; + expect(confirmed.amount, -55.0); + expect(confirmed.isPending, isFalse); + expect(confirmed.isAmountEstimate, isFalse); + + expect(rule.estimateAmount(rule.template, generated(rule)), -55.0); + }, + ); + + test("Estimate ignores pending ones and falls back to the template", () { + final RecurringTransaction rule = RecurringTransaction( + jsonTransactionTemplate: "{}", + rules: [], + range: "", + variableAmount: true, + ); + final Transaction template = Transaction( + uuid: "t", + amount: -40.0, + currency: "USD", + ); + + expect( + rule.estimateAmount(template, [ + Transaction(uuid: "t", amount: -99.0, currency: "USD", isPending: true), + ]), + -40.0, + ); + + rule.variableAmount = false; + expect( + rule.estimateAmount(template, [ + Transaction(uuid: "t", amount: -99.0, currency: "USD"), + ]), + -40.0, + ); + }); + + test("Confirming a transfer updates both sides", () { + final (int fromId, int toId) = account.transferTo( + targetAccount: other, + amount: 100.0, + conversionRate: 0.5, + isPending: true, + ); + + final Transaction from = TransactionsService().getOneSync(fromId)!; + expect(from.confirmWithAmount(120.0, false), isTrue); + + final Transaction fromAfter = TransactionsService().getOneSync(fromId)!; + final Transaction toAfter = TransactionsService().getOneSync(toId)!; + + expect(fromAfter.amount, -120.0); + expect(toAfter.amount, 60.0); + expect(fromAfter.isPending, isFalse); + expect(toAfter.isPending, isFalse); + }); +} From 5d0355f3068611d4eefb2079c4038c247ab49c1b Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Wed, 30 Sep 2026 00:52:08 +0800 Subject: [PATCH 50/57] fix default recurrence not saving --- CHANGELOG.md | 2 + .../transaction_page/select_recurrence.dart | 7 ++ test/widget/select_recurrence_test.dart | 77 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 test/widget/select_recurrence_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bbfabfb..30fcd5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ * Fixed exchange rates being fetched for the wrong currency at startup, and not refreshing after changing the primary currency * Fixed "Mask numbers at startup" not working +* Fixed recurring transactions not being set up when saved with the default + recurrence * Fixed transfers losing their location and attachments after editing * Fixed brand icons in tags and old backups showing the wrong brand diff --git a/lib/routes/transaction_page/select_recurrence.dart b/lib/routes/transaction_page/select_recurrence.dart index 45ffde9f..5e1c7dea 100644 --- a/lib/routes/transaction_page/select_recurrence.dart +++ b/lib/routes/transaction_page/select_recurrence.dart @@ -53,6 +53,13 @@ class _SelectRecurrenceState extends State<SelectRecurrence> { void initState() { super.initState(); _setRecurrence(widget.initialValue); + + // Report the default, so saving it untouched still sets up recurrence + if (widget.initialValue == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onChanged(_recurrence); + }); + } } @override diff --git a/test/widget/select_recurrence_test.dart b/test/widget/select_recurrence_test.dart new file mode 100644 index 00000000..79541b6d --- /dev/null +++ b/test/widget/select_recurrence_test.dart @@ -0,0 +1,77 @@ +import "package:flow/l10n/flow_localizations.dart"; +import "package:flow/routes/transaction_page/select_recurrence.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:recurrence/recurrence.dart"; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await FlowLocalizations(const Locale("en")).load(); + }); + + Future<List<Recurrence>> pump( + WidgetTester tester, { + Recurrence? initialValue, + }) async { + final List<Recurrence> changes = []; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [_TestLocalizationsDelegate()], + home: Scaffold( + body: SingleChildScrollView( + child: SelectRecurrence( + initialValue: initialValue, + onChanged: changes.add, + startBounds: null, + ), + ), + ), + ), + ); + await tester.pump(); + + return changes; + } + + testWidgets("reports the default, so saving it untouched works", ( + tester, + ) async { + final List<Recurrence> changes = await pump(tester); + + expect(changes, hasLength(1)); + expect(changes.single.rules.single, isA<MonthlyRecurrenceRule>()); + }); + + testWidgets("doesn't report an initial value back", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [MonthlyRecurrenceRule(day: 3)], + start: DateTime(2026, 1, 3), + ), + ); + + expect(changes, isEmpty); + }); +} + +/// [FlowLocalizations.delegate] also syncs home widgets over a platform +/// channel, which isn't there in widget tests. +class _TestLocalizationsDelegate + extends LocalizationsDelegate<FlowLocalizations> { + const _TestLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future<FlowLocalizations> load(Locale locale) => + SynchronousFuture(FlowLocalizations(locale)); + + @override + bool shouldReload(_TestLocalizationsDelegate old) => false; +} From 4aec24530f4f5189f2cdd3baf237ed8c3b7e3385 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Wed, 30 Sep 2026 00:57:05 +0800 Subject: [PATCH 51/57] variable recurring: audit fixes --- 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_CN.json | 2 +- assets/l10n/zh_TW.json | 2 +- lib/data/insights/recurring_series.dart | 22 ++++++--------- lib/objectbox/actions.dart | 5 +++- lib/routes/stats/recurring_page.dart | 28 +++++++++---------- lib/routes/transaction_page.dart | 2 +- .../extensions/recurring_transaction.dart | 7 ++++- lib/utils/extensions/transaction.dart | 10 +++++-- .../insights/insight_evidence_sheet.dart | 4 ++- 23 files changed, 59 insertions(+), 51 deletions(-) diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index 8cd99925..6badd884 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -892,7 +892,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "إنفاق {month} مقارنةً بوسيط الأشهر {months} السابقة.", "tabs.stats.worthKnowing.rule.newCategory": "يظهر عندما يكون لفئة إنفاق لأول مرة منذ {months} أشهر.", "tabs.stats.worthKnowing.rule.priceChange": "يظهر عندما تتغير دفعة متكررة بنسبة {percent} أو أكثر.", - "tabs.stats.worthKnowing.rule.recurringCharge": "دفعات بالعنوان نفسه ومبلغ ثابت تتكرر بانتظام.", + "tabs.stats.worthKnowing.rule.recurringCharge": "دفعات بالعنوان نفسه تتكرر بانتظام. الفواتير المتغيرة، مثل الكهرباء والماء، تحتاج إلى {count} دفعات على الأقل.", "tabs.stats.worthKnowing.seeTransactions": "عرض هذه المعاملات", "tabs.stats.worthKnowing.times": "{count} مرات حتى الآن", "tabs.stats.worthKnowing.times.many": "{count} مرة حتى الآن", diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 7bbc4697..dcd7d71e 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -884,7 +884,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Выдаткі за перыяд «{month}» у параўнанні з медыянай {months} папярэдніх мес.", "tabs.stats.worthKnowing.rule.newCategory": "З'яўляецца, калі ў катэгорыі ўпершыню за {months} мес. ёсць выдаткі.", "tabs.stats.worthKnowing.rule.priceChange": "З'яўляецца, калі рэгулярны плацёж мяняецца на {percent} ці больш.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Плацяжы з аднолькавай назвай і сталай сумай, якія рэгулярна паўтараюцца.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Плацяжы з аднолькавай назвай, якія рэгулярна паўтараюцца. Для зменлівых рахункаў, як камунальныя, патрэбна не менш за {count} плацяжы.", "tabs.stats.worthKnowing.seeTransactions": "Паказаць гэтыя транзакцыі", "tabs.stats.worthKnowing.times": "ужо {count} разоў", "tabs.stats.worthKnowing.times.few": "ужо {count} разы", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index 202aaa57..cffd980d 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -881,7 +881,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Výdaje za {month} ve srovnání s mediánem {months} předchozích měsíců.", "tabs.stats.worthKnowing.rule.newCategory": "Zobrazí se, když má kategorie výdaje poprvé za {months} měsíců.", "tabs.stats.worthKnowing.rule.priceChange": "Zobrazí se, když se pravidelná platba změní o {percent} nebo více.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Platby se stejným názvem a stálou částkou, které se pravidelně opakují.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Platby se stejným názvem, které se pravidelně opakují. Proměnlivé účty, jako energie, potřebují alespoň {count} platby.", "tabs.stats.worthKnowing.seeTransactions": "Zobrazit tyto transakce", "tabs.stats.worthKnowing.times": "zatím {count}krát", "tabs.stats.worthKnowing.times.few": "zatím {count}krát", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index 5402ae98..3f7a7c77 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Ausgaben im {month}, verglichen mit dem Median der letzten {months} Monate.", "tabs.stats.worthKnowing.rule.newCategory": "Erscheint, wenn eine Kategorie zum ersten Mal seit {months} Monaten Ausgaben hat.", "tabs.stats.worthKnowing.rule.priceChange": "Erscheint, wenn sich eine wiederkehrende Zahlung um {percent} oder mehr ändert.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Zahlungen mit gleichem Titel und festem Betrag, die regelmäßig wiederkehren.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Zahlungen mit gleichem Titel, die regelmäßig wiederkehren. Schwankende Rechnungen wie Nebenkosten brauchen mindestens {count} Zahlungen.", "tabs.stats.worthKnowing.seeTransactions": "Diese Buchungen ansehen", "tabs.stats.worthKnowing.times": "bisher {count}-mal", "tabs.stats.worthKnowing.times.one": "bisher {count}-mal", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 42a86942..971896a3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Spending in {month}, compared with the median of the previous {months} months.", "tabs.stats.worthKnowing.rule.newCategory": "Shown when a category has spending for the first time in {months} months.", "tabs.stats.worthKnowing.rule.priceChange": "Shown when a repeating charge changes by {percent} or more.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Charges with the same title and a steady amount, repeating on a regular schedule.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Charges with the same title, repeating on a regular schedule. Bills that vary, like utilities, need at least {count} charges.", "tabs.stats.worthKnowing.seeTransactions": "See these transactions", "tabs.stats.worthKnowing.times": "{count} times so far", "tabs.stats.worthKnowing.times.one": "{count} time so far", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 02d1e8c0..5bbb77e3 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Gasto de {month}, comparado con la mediana de los {months} meses anteriores.", "tabs.stats.worthKnowing.rule.newCategory": "Aparece cuando una categoría tiene gastos por primera vez en {months} meses.", "tabs.stats.worthKnowing.rule.priceChange": "Aparece cuando un cargo periódico cambia un {percent} o más.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Cargos con el mismo título e importe estable que se repiten con regularidad.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Cargos con el mismo título que se repiten con regularidad. Las facturas variables, como los suministros, necesitan al menos {count} cargos.", "tabs.stats.worthKnowing.seeTransactions": "Ver estas transacciones", "tabs.stats.worthKnowing.times": "{count} veces hasta ahora", "tabs.stats.worthKnowing.times.one": "{count} vez hasta ahora", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 01d27259..6c67a6ee 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "هزینهٔ {month}، در مقایسه با میانهٔ {months} ماه قبل.", "tabs.stats.worthKnowing.rule.newCategory": "وقتی دسته‌ای برای اولین بار در {months} ماه هزینه داشته باشد نمایش داده می‌شود.", "tabs.stats.worthKnowing.rule.priceChange": "وقتی یک پرداخت تکراری {percent} یا بیشتر تغییر کند نمایش داده می‌شود.", - "tabs.stats.worthKnowing.rule.recurringCharge": "پرداخت‌هایی با عنوان یکسان و مبلغ ثابت که مرتب تکرار می‌شوند.", + "tabs.stats.worthKnowing.rule.recurringCharge": "پرداخت‌هایی با عنوان یکسان که مرتب تکرار می‌شوند. قبض‌های متغیر، مثل آب و برق، دست‌کم {count} پرداخت لازم دارند.", "tabs.stats.worthKnowing.seeTransactions": "دیدن این تراکنش‌ها", "tabs.stats.worthKnowing.times": "تا حالا {count} بار", "tabs.stats.worthKnowing.times.one": "تا حالا {count} بار", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index a492e550..bef1f673 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Dépenses de {month}, comparées à la médiane des {months} mois précédents.", "tabs.stats.worthKnowing.rule.newCategory": "Affiché quand une catégorie a des dépenses pour la première fois depuis {months} mois.", "tabs.stats.worthKnowing.rule.priceChange": "Affiché quand un prélèvement régulier change de {percent} ou plus.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Prélèvements au même titre et au montant stable, qui reviennent régulièrement.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Prélèvements au même titre, qui reviennent régulièrement. Les factures variables, comme l'électricité, demandent au moins {count} prélèvements.", "tabs.stats.worthKnowing.seeTransactions": "Voir ces transactions", "tabs.stats.worthKnowing.times": "{count} fois jusqu'ici", "tabs.stats.worthKnowing.times.one": "{count} fois jusqu'ici", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 5bbb620d..ed830e8c 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Spesa di {month}, confrontata con la mediana dei {months} mesi precedenti.", "tabs.stats.worthKnowing.rule.newCategory": "Appare quando una categoria ha spese per la prima volta da {months} mesi.", "tabs.stats.worthKnowing.rule.priceChange": "Appare quando un addebito ricorrente cambia del {percent} o più.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Addebiti con lo stesso titolo e importo stabile, che si ripetono regolarmente.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Addebiti con lo stesso titolo, che si ripetono regolarmente. Le bollette variabili, come le utenze, richiedono almeno {count} addebiti.", "tabs.stats.worthKnowing.seeTransactions": "Vedi queste transazioni", "tabs.stats.worthKnowing.times": "{count} volte finora", "tabs.stats.worthKnowing.times.one": "{count} volta finora", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 2534c144..9677deeb 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "{month}: зарлагыг өмнөх {months} сарын ердийн (медиан) дүнтэй харьцуулав.", "tabs.stats.worthKnowing.rule.newCategory": "Ангилалд {months} сард анх удаа зарлага гарвал харагдана.", "tabs.stats.worthKnowing.rule.priceChange": "Давтагддаг төлбөр {percent} буюу түүнээс их өөрчлөгдвөл харагдана.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Ижил нэртэй, тогтмол дүнтэй, тогтмол давтагддаг төлбөрүүд.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Ижил нэртэй, тогтмол давтагддаг төлбөрүүд. Цахилгаан гэх мэт дүн нь өөрчлөгддөг төлбөрт дор хаяж {count} гүйлгээ шаардлагатай.", "tabs.stats.worthKnowing.seeTransactions": "Эдгээр гүйлгээг харах", "tabs.stats.worthKnowing.times": "одоогоор {count} удаа", "tabs.stats.worthKnowing.times.one": "одоогоор {count} удаа", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index dfb68f8c..81b9f6f2 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -884,7 +884,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Wydatki w miesiącu {month} w porównaniu z medianą {months} poprzednich miesięcy.", "tabs.stats.worthKnowing.rule.newCategory": "Pojawia się, gdy kategoria ma wydatki pierwszy raz od {months} miesięcy.", "tabs.stats.worthKnowing.rule.priceChange": "Pojawia się, gdy cykliczna opłata zmienia się o {percent} lub więcej.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Opłaty o tym samym tytule i stałej kwocie, powtarzające się regularnie.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Opłaty o tym samym tytule, powtarzające się regularnie. Zmienne rachunki, jak media, wymagają co najmniej {count} opłat.", "tabs.stats.worthKnowing.seeTransactions": "Zobacz te transakcje", "tabs.stats.worthKnowing.times": "{count} razy dotąd", "tabs.stats.worthKnowing.times.few": "{count} razy dotąd", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 9973fb3a..d4032232 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -884,7 +884,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Расходы за период «{month}» в сравнении с медианой {months} прошлых мес.", "tabs.stats.worthKnowing.rule.newCategory": "Показывается, когда в категории впервые за {months} мес. появляются расходы.", "tabs.stats.worthKnowing.rule.priceChange": "Показывается, когда регулярный платёж меняется на {percent} или больше.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Платежи с одинаковым названием и стабильной суммой, которые повторяются регулярно.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Платежи с одинаковым названием, которые повторяются регулярно. Для меняющихся счетов, как коммунальные, нужно не меньше {count} платежей.", "tabs.stats.worthKnowing.seeTransactions": "Показать эти транзакции", "tabs.stats.worthKnowing.times": "{count} раз уже", "tabs.stats.worthKnowing.times.few": "{count} раза уже", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index 0ec4b31d..b3d52679 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "{month} harcaması, önceki {months} ayın medyanıyla karşılaştırıldı.", "tabs.stats.worthKnowing.rule.newCategory": "Bir kategoride {months} aydır ilk kez harcama olduğunda gösterilir.", "tabs.stats.worthKnowing.rule.priceChange": "Düzenli bir ödeme {percent} veya daha fazla değiştiğinde gösterilir.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Aynı başlıklı, sabit tutarlı ve düzenli tekrarlanan ödemeler.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Aynı başlıklı ve düzenli tekrarlanan ödemeler. Faturalar gibi değişken tutarlı ödemeler için en az {count} ödeme gerekir.", "tabs.stats.worthKnowing.seeTransactions": "Bu işlemleri gör", "tabs.stats.worthKnowing.times": "şimdiye kadar {count} kez", "tabs.stats.worthKnowing.times.one": "şimdiye kadar {count} kez", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 306c5867..bc8f298a 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -884,7 +884,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "Витрати за період «{month}» порівняно з медіаною {months} попередніх міс.", "tabs.stats.worthKnowing.rule.newCategory": "З'являється, коли в категорії вперше за {months} міс. є витрати.", "tabs.stats.worthKnowing.rule.priceChange": "З'являється, коли регулярний платіж змінюється на {percent} або більше.", - "tabs.stats.worthKnowing.rule.recurringCharge": "Платежі з однаковою назвою та сталою сумою, що регулярно повторюються.", + "tabs.stats.worthKnowing.rule.recurringCharge": "Платежі з однаковою назвою, що регулярно повторюються. Для змінних рахунків, як комунальні, потрібно щонайменше {count} платежі.", "tabs.stats.worthKnowing.seeTransactions": "Показати ці транзакції", "tabs.stats.worthKnowing.times": "уже {count} разів", "tabs.stats.worthKnowing.times.few": "уже {count} рази", diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index 9f0787c3..092d10a1 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "{month}的支出,与前 {months} 个月的中位数对比。", "tabs.stats.worthKnowing.rule.newCategory": "当某分类 {months} 个月来首次有支出时显示。", "tabs.stats.worthKnowing.rule.priceChange": "当定期扣款变动 {percent} 或以上时显示。", - "tabs.stats.worthKnowing.rule.recurringCharge": "标题相同、金额稳定、按固定周期重复的扣款。", + "tabs.stats.worthKnowing.rule.recurringCharge": "标题相同、按固定周期重复的扣款。金额浮动的账单(如水电费)至少需要 {count} 次扣款。", "tabs.stats.worthKnowing.seeTransactions": "查看这些交易", "tabs.stats.worthKnowing.times": "已 {count} 次", "tabs.stats.worthKnowing.times.one": "已 {count} 次", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index 4069c40f..20fb496b 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -877,7 +877,7 @@ "tabs.stats.worthKnowing.rule.monthPacePast": "{month}的支出,與前 {months} 個月的中位數比較。", "tabs.stats.worthKnowing.rule.newCategory": "當某分類 {months} 個月來首次有支出時顯示。", "tabs.stats.worthKnowing.rule.priceChange": "當定期扣款變動 {percent} 或以上時顯示。", - "tabs.stats.worthKnowing.rule.recurringCharge": "標題相同、金額穩定、按固定週期重複的扣款。", + "tabs.stats.worthKnowing.rule.recurringCharge": "標題相同、按固定週期重複的扣款。金額浮動的帳單(如水電費)至少需要 {count} 次扣款。", "tabs.stats.worthKnowing.seeTransactions": "查看這些交易", "tabs.stats.worthKnowing.times": "已 {count} 次", "tabs.stats.worthKnowing.times.one": "已 {count} 次", diff --git a/lib/data/insights/recurring_series.dart b/lib/data/insights/recurring_series.dart index d93eabc0..23b2b1a3 100644 --- a/lib/data/insights/recurring_series.dart +++ b/lib/data/insights/recurring_series.dart @@ -199,7 +199,7 @@ RecurringSeries? _detect( if (price == null) return null; // Tracked as variable, so there's no price to change - if (_isVariableTemplate(key, items.last.title, templates)) { + if (_isVariableTemplate(key, templates)) { price = ( isFixedPrice: false, priceChangeIndex: null, @@ -471,20 +471,14 @@ bool _matchesTemplate( bool _isVariableTemplate( String key, - String? seriesTitle, List<InsightRecurringTemplate> templates, -) { - final String? title = normalizeTitle(seriesTitle); - - return templates.any( - (template) => - template.variableAmount && - ((template.uuid != null && - (key == "recurring:${template.uuid}" || - key.startsWith("recurring:${template.uuid}:"))) || - (title != null && normalizeTitle(template.title) == title)), - ); -} +) => templates.any( + (template) => + template.variableAmount && + template.uuid != null && + (key == "recurring:${template.uuid}" || + key.startsWith("recurring:${template.uuid}:")), +); /// Ties go to the most recent value. String? _mostCommon(Iterable<String?> values) { diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index aa9ea236..2bd11839 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -646,7 +646,10 @@ extension TransactionActions on Transaction { .findTransferRelatedTransactionSync(this); if (related != null) { - final double rate = extensions.transfer?.conversionRate ?? 1.0; + final double rate = switch (extensions.transfer?.conversionRate) { + double value when value != 0 => value, + _ => 1.0, + }; related.amount = outgoing ? amount.abs() * rate : -amount.abs() / rate; TransactionsService().updateOneSync(related); diff --git a/lib/routes/stats/recurring_page.dart b/lib/routes/stats/recurring_page.dart index 85baf98b..20bbb871 100644 --- a/lib/routes/stats/recurring_page.dart +++ b/lib/routes/stats/recurring_page.dart @@ -339,7 +339,7 @@ class _RecurringPageState extends State<RecurringPage> } } - /// Shows the confirmed amount if [logged] has one, [estimate] otherwise. + /// Shows the amount [logged] has, [estimate] otherwise. /// /// Returns whether [occurrence] ended up with an estimate. bool _estimateOccurrence( @@ -347,23 +347,21 @@ class _RecurringPageState extends State<RecurringPage> Transaction? logged, double estimate, ) { - if (logged != null && - logged.isPending != true && - logged.accountUuid == occurrence.accountUuid) { + if (logged != null && logged.accountUuid == occurrence.accountUuid) { occurrence.amount = logged.amount; - return false; + if (logged.isPending != true) return false; + } else { + occurrence.amount = estimate; } - occurrence - ..amount = estimate - ..extensions = ExtensionsWrapper([ - ...occurrence.extensions.data.where((ext) => ext is! Recurring), - Recurring( - uuid: occurrence.extensions.recurring?.uuid ?? occurrence.uuid, - initialTransactionDate: occurrence.transactionDate, - variableAmount: true, - ), - ]); + occurrence.extensions = ExtensionsWrapper([ + ...occurrence.extensions.data.where((ext) => ext is! Recurring), + Recurring( + uuid: occurrence.extensions.recurring?.uuid ?? occurrence.uuid, + initialTransactionDate: occurrence.transactionDate, + variableAmount: true, + ), + ]); return true; } diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index 1babc241..a8717302 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -535,7 +535,7 @@ class _TransactionPageState extends State<TransactionPage> { duration: const Duration(milliseconds: 300), child: _recurrence != null ? Column( - mainAxisSize: .min, + mainAxisSize: MainAxisSize.min, children: [ SelectRecurrence( initialValue: _recurrence, diff --git a/lib/utils/extensions/recurring_transaction.dart b/lib/utils/extensions/recurring_transaction.dart index 7e2eaf26..55e81271 100644 --- a/lib/utils/extensions/recurring_transaction.dart +++ b/lib/utils/extensions/recurring_transaction.dart @@ -22,12 +22,17 @@ extension RecurringTransactionHelpers on RecurringTransaction { // Transfers log both sides; the template is the outgoing one if (transaction.isTransfer && !transaction.amount.isNegative) continue; + // Confirming may move [Transaction.transactionDate] to now if (latest == null || - transaction.transactionDate.isAfter(latest.transactionDate)) { + _scheduledDate(transaction).isAfter(_scheduledDate(latest))) { latest = transaction; } } return latest?.amount ?? template.amount; } + + DateTime _scheduledDate(Transaction transaction) => + transaction.extensions.recurring?.initialTransactionDate ?? + transaction.transactionDate; } diff --git a/lib/utils/extensions/transaction.dart b/lib/utils/extensions/transaction.dart index 17caa442..12f2fddf 100644 --- a/lib/utils/extensions/transaction.dart +++ b/lib/utils/extensions/transaction.dart @@ -54,7 +54,7 @@ extension TransactionHelpers on Transaction { isScrollControlled: true, ); - if (amount == null) return false; + if (amount == null || amount == 0) return false; return confirmWithAmount(amount, updateTransactionDate); } @@ -313,6 +313,8 @@ class BulkTransactions { } /// Confirms every transaction (and its transfer partner), leaving pending. + /// + /// Skips estimates, they need the actual amount. static int confirm( Iterable<Transaction> transactions, { bool updateTransactionDate = true, @@ -323,8 +325,12 @@ class BulkTransactions { final List<Transaction> toUpdate = []; final Set<String> seen = {}; + int count = 0; + for (final Transaction t in list) { + if (t.isAmountEstimate) continue; if (!seen.add(t.uuid)) continue; + count++; t.isPending = false; if (updateTransactionDate && !t.extraTags.contains(Transaction.importedFromSiriTag)) { @@ -348,7 +354,7 @@ class BulkTransactions { } catch (e, stackTrace) { _log.severe("Bulk confirm failed", e, stackTrace); } - return list.length; + return count; } /// Sets [category] on every non-transfer transaction. diff --git a/lib/widgets/insights/insight_evidence_sheet.dart b/lib/widgets/insights/insight_evidence_sheet.dart index 5daf7bbc..3ce47fd7 100644 --- a/lib/widgets/insights/insight_evidence_sheet.dart +++ b/lib/widgets/insights/insight_evidence_sheet.dart @@ -346,7 +346,9 @@ class _InsightEvidenceSheetState extends State<InsightEvidenceSheet> { NewCategoryInsight() => "$_prefix.rule.newCategory".t(context, { "months": months, }), - RecurringChargeInsight() => "$_prefix.rule.recurringCharge".t(context), + RecurringChargeInsight() => "$_prefix.rule.recurringCharge".t(context, { + "count": InsightThresholds.variableSuggestionMinOccurrences, + }), PriceChangeInsight() => "$_prefix.rule.priceChange".t(context, { "percent": format.percent(InsightThresholds.priceChangeMinRatio), }), From e48a3a7887bd83e69d1a836cacd46cdf806f0c63 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Wed, 30 Sep 2026 00:57:05 +0800 Subject: [PATCH 52/57] 0.25.0+361 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 4cc0739d..2ae0539c 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.25.0+360" +version: "0.25.0+361" environment: sdk: ">=3.10.0 <4.0.0" From ea31b86330bcaeac7840dad0a7826285936f51cc Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Wed, 30 Sep 2026 01:00:00 +0800 Subject: [PATCH 53/57] ios: update pods, project deployment target --- ios/Podfile.lock | 4 ++-- ios/Runner.xcodeproj/project.pbxproj | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index bd1b76c0..993ece26 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -41,7 +41,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 flutter_dynamic_icon_plus: a7c81df360708aa1c9538f89cd96958fdda608ea icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2 ObjectBox: 2aae8ad350012cb53c6e7848064307cf68617f73 @@ -50,4 +50,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 1ac52b241a59f6195eb0dcd62320a80726757816 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d6d72121..3d615064 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -613,7 +613,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -882,7 +882,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -936,7 +936,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; From 3444325fadcfc1204603d2082e470849d1490941 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 2 Oct 2026 15:58:50 +0800 Subject: [PATCH 54/57] fix recurring start date not moving the rule --- CHANGELOG.md | 4 + lib/routes/transaction_page.dart | 32 +- .../transaction_page/select_recurrence.dart | 24 +- .../select_recurrence_sheet.dart | 10 +- lib/services/recurring_transactions.dart | 11 +- lib/utils/extensions/recurrence.dart | 13 + test/unit/recurring_schedule_test.dart | 328 ++++++++++++++++++ test/unit/utils/recurrence_test.dart | 75 ++++ test/widget/select_recurrence_test.dart | 117 ++++++- 9 files changed, 585 insertions(+), 29 deletions(-) create mode 100644 lib/utils/extensions/recurrence.dart create mode 100644 test/unit/recurring_schedule_test.dart create mode 100644 test/unit/utils/recurrence_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 30fcd5dd..901e42cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ * Fixed "Mask numbers at startup" not working * Fixed recurring transactions not being set up when saved with the default recurrence +* Fixed recurring transactions repeating on the wrong day after changing the + start date +* Fixed recurring start date not going earlier than the transaction date. It + now moves the transaction date with it * Fixed transfers losing their location and attachments after editing * Fixed brand icons in tags and old backups showing the wrong brand diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index a8717302..bc4f5ed3 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -40,6 +40,7 @@ import "package:flow/services/recurring_transactions.dart"; import "package:flow/services/transactions.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/theme/theme.dart"; +import "package:flow/utils/extensions/recurrence.dart"; import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/approximate_money_text.dart"; import "package:flow/widgets/general/button.dart"; @@ -962,6 +963,7 @@ class _TransactionPageState extends State<TransactionPage> { setState(() { _transactionDate = result ?? _transactionDate; + _moveRecurrenceStart(); }); _postSelectTransactionDate(); @@ -983,6 +985,7 @@ class _TransactionPageState extends State<TransactionPage> { microsecond: 0, millisecond: 0, ); + _moveRecurrenceStart(); }); _postSelectTransactionDate(); @@ -995,8 +998,9 @@ class _TransactionPageState extends State<TransactionPage> { } void updateRecurrence(Recurrence? recurrence) { - if (widget.isNewTransaction) { - _transactionDate = recurrence?.range.from; + // The series starts with this transaction + if (_recurringTransaction == null && recurrence != null) { + _transactionDate = recurrence.range.from; } _recurrence = recurrence; @@ -1005,6 +1009,14 @@ class _TransactionPageState extends State<TransactionPage> { setState(() {}); } + /// Keeps a series that isn't saved yet starting at the transaction date + void _moveRecurrenceStart() { + if (_recurringTransaction != null) return; + if (_recurrence?.range.from == transactionDate) return; + + _recurrence = _recurrence?.startingAt(transactionDate); + } + void updateVariableAmount(bool value) { setState(() { _variableAmount = value; @@ -1082,13 +1094,14 @@ class _TransactionPageState extends State<TransactionPage> { context: context, builder: (context) => SelectRecurrenceSheet( initialValue: _recurrence, - startBounds: transactionDate.rangeToMax(), + startBounds: TimeRange.allTime(), + defaultStart: transactionDate, ), ); - _recurrence ??= result; + if (result == null || _recurrence != null) return; - setState(() {}); + updateRecurrence(result); } void onTagsChanged(List<TransactionTag> newTags) { @@ -1516,14 +1529,7 @@ class _TransactionPageState extends State<TransactionPage> { } TimeRange? getStartBounds() { - if (widget.isNewTransaction || _currentlyEditing == null) { - return TimeRange.allTime(); - } - - if (!_currentlyEditing.isRecurring) { - return (_transactionDate ?? _currentlyEditing.transactionDate) - .rangeToMax(); - } + if (_recurringTransaction == null) return TimeRange.allTime(); return null; } diff --git a/lib/routes/transaction_page/select_recurrence.dart b/lib/routes/transaction_page/select_recurrence.dart index 5e1c7dea..1d44708c 100644 --- a/lib/routes/transaction_page/select_recurrence.dart +++ b/lib/routes/transaction_page/select_recurrence.dart @@ -5,6 +5,7 @@ import "package:flow/routes/transaction_page/select_recurrence/input_occurrences import "package:flow/routes/transaction_page/select_recurrence/select_until_mode_sheet.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/extensions/custom_popups.dart"; +import "package:flow/utils/extensions/recurrence.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons_flow/symbols.dart"; import "package:moment_dart/moment_dart.dart"; @@ -32,11 +33,15 @@ class SelectRecurrence extends StatefulWidget { final TimeRange? startBounds; + /// Where the default starts when there's no [initialValue] + final DateTime? defaultStart; + const SelectRecurrence({ super.key, required this.onChanged, required this.startBounds, this.initialValue, + this.defaultStart, }); @override @@ -220,20 +225,18 @@ class _SelectRecurrenceState extends State<SelectRecurrence> { ); if (result == null) return; - _recurrence = _recurrence.copyWith( - range: CustomTimeRange(result, _recurrence.range.to), - ); - + _setFrom(result); if (!mounted) return; - setState(() {}); - widget.onChanged(_recurrence); final DateTime? resultWithTime = await context.pickTime(anchor: result); if (resultWithTime == null) return; - _recurrence = _recurrence.copyWith( - range: CustomTimeRange(resultWithTime, _recurrence.range.to), - ); + _setFrom(resultWithTime); + } + + void _setFrom(DateTime from) { + _recurrence = _recurrence.startingAt(from); + if (!mounted) return; setState(() {}); widget.onChanged(_recurrence); @@ -361,7 +364,8 @@ class _SelectRecurrenceState extends State<SelectRecurrence> { rules: [ MonthlyRecurrenceRule(day: recurrence?.range.from.day ?? 1), ], - start: DateTime.now().startOfSecond(), + start: (widget.defaultStart ?? DateTime.now()) + .startOfSecond(), )) .realign(); diff --git a/lib/routes/transaction_page/select_recurrence_sheet.dart b/lib/routes/transaction_page/select_recurrence_sheet.dart index 6babc868..b38c6bfc 100644 --- a/lib/routes/transaction_page/select_recurrence_sheet.dart +++ b/lib/routes/transaction_page/select_recurrence_sheet.dart @@ -14,7 +14,14 @@ class SelectRecurrenceSheet extends StatefulWidget { final TimeRange? startBounds; - const SelectRecurrenceSheet({super.key, this.initialValue, this.startBounds}); + final DateTime? defaultStart; + + const SelectRecurrenceSheet({ + super.key, + this.initialValue, + this.startBounds, + this.defaultStart, + }); @override State<SelectRecurrenceSheet> createState() => _SelectRecurrenceSheetState(); @@ -52,6 +59,7 @@ class _SelectRecurrenceSheetState extends State<SelectRecurrenceSheet> { onChanged: (value) => setState(() => _recurrence = value), startBounds: widget.startBounds, initialValue: widget.initialValue, + defaultStart: widget.defaultStart, ), ); } diff --git a/lib/services/recurring_transactions.dart b/lib/services/recurring_transactions.dart index 64ad50e2..1ed9afed 100644 --- a/lib/services/recurring_transactions.dart +++ b/lib/services/recurring_transactions.dart @@ -41,7 +41,10 @@ class RecurringTransactionsService { /// constructor: `FlowState.initState`'s post-frame callback decides when /// to do the first sync so it doesn't race with first-frame rendering or /// other startup work. - Future<void> synchronizeAll() => _synchronizeAll(); + /// + /// [anchor] stands in for now, defaults to [DateTime.now]. + Future<void> synchronizeAll({DateTime? anchor}) => + _synchronizeAll(anchor: anchor); Future<void> _synchronize( RecurringTransaction recurringTransaction, { @@ -258,7 +261,7 @@ class RecurringTransactionsService { "$loggingPrefix Next occurrence is before anchor: $anchor, trying to create another one", ); - await _synchronize(recurringTransaction); + await _synchronize(recurringTransaction, anchor: anchor); } _log.fine( @@ -277,7 +280,7 @@ class RecurringTransactionsService { /// be called over and over again. /// /// Current rule is one transaction in the future for the recurrence. - Future<void> _synchronizeAll() async { + Future<void> _synchronizeAll({DateTime? anchor}) async { _log.fine("Synchronizing recurring transactions"); final Query<RecurringTransaction> query = activeRecurringsQb().build(); @@ -286,7 +289,7 @@ class RecurringTransactionsService { try { for (var item in items) { - await _synchronize(item); + await _synchronize(item, anchor: anchor); } } finally { query.close(); diff --git a/lib/utils/extensions/recurrence.dart b/lib/utils/extensions/recurrence.dart new file mode 100644 index 00000000..f1d8c46e --- /dev/null +++ b/lib/utils/extensions/recurrence.dart @@ -0,0 +1,13 @@ +import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; + +extension RecurrenceHelpers on Recurrence { + /// Moves the start to [from]. Rules follow it, e.g., monthly lands on + /// [from]'s day. Runs indefinitely if it'd end before [from]. + Recurrence startingAt(DateTime from) => copyWith( + range: CustomTimeRange( + from, + range.to.isBefore(from) ? Moment.maxValue : range.to, + ), + ).realign(); +} diff --git a/test/unit/recurring_schedule_test.dart b/test/unit/recurring_schedule_test.dart new file mode 100644 index 00000000..aa2a719e --- /dev/null +++ b/test/unit/recurring_schedule_test.dart @@ -0,0 +1,328 @@ +import "dart:io"; + +import "package:flow/entity/account.dart"; +import "package:flow/entity/recurring_transaction.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/entity/transaction/extensions/default/recurring.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/prefs/pending_transactions.dart"; +import "package:flow/services/recurring_transactions.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:path/path.dart" as path; +import "package:recurrence/recurrence.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +import "../objectbox_erase.dart"; + +void main() { + late ObjectBox obx; + late Account account; + late Account other; + + final String directory = path.join( + Directory.current.path, + ".objectbox_test_recurring_schedule", + ); + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + PendingTransactionsLocalPreferences.initialize(_MemoryPreferences()); + + obx = await ObjectBox.initialize( + customDirectory: directory, + subdirectory: "recurring_schedule", + ); + + account = Account(name: "Card", currency: "USD", iconCode: "irrelevant"); + other = Account(name: "Savings", currency: "USD", iconCode: "irrelevant"); + obx.box<Account>().putMany([account, other]); + }); + + tearDownAll(() async { + await testCleanupObject( + instance: obx, + directory: path.join(directory, "recurring_schedule"), + ); + }); + + setUp(() { + obx.box<Transaction>().removeAll(); + obx.box<RecurringTransaction>().removeAll(); + }); + + /// A series the way saving a transaction sets it up: the first transaction + /// is logged at [start], and the rest is generated as of [now]. + Future<RecurringTransaction> series({ + required DateTime start, + required DateTime now, + required List<RecurrenceRule> rules, + DateTime? end, + Account? transferTo, + }) async { + final Recurrence recurrence = Recurrence( + range: CustomTimeRange(start, end ?? Moment.maxValue), + rules: rules, + ); + + final RecurringTransaction rule = RecurringTransaction( + jsonTransactionTemplate: "", + rules: recurrence.rules.map((rule) => rule.serialize()).toList(), + range: recurrence.range.encodeShort(), + transferToAccountUuid: transferTo?.uuid, + lastGeneratedTransactionDate: start, + ); + + final Recurring extension = Recurring( + uuid: rule.uuid, + initialTransactionDate: start, + ); + + late final int id; + + if (transferTo == null) { + id = account.createAndSaveTransaction( + amount: -40.0, + title: "Rent", + transactionDate: start, + extensions: [extension], + ); + } else { + (id, _) = account.transferTo( + targetAccount: transferTo, + amount: 40.0, + transactionDate: start, + extensions: [extension], + ); + } + + rule.template = obx.box<Transaction>().get(id)!; + obx.box<RecurringTransaction>().put(rule); + + await RecurringTransactionsService().synchronizeAll(anchor: now); + + return rule; + } + + /// Dates of the transactions in [rule]'s series, one per occurrence + List<DateTime> dates(RecurringTransaction rule) => + obx + .box<Transaction>() + .getAll() + .where( + (transaction) => + transaction.extensions.recurring?.uuid == rule.uuid && + !(transaction.isTransfer && transaction.amount > 0), + ) + .map((transaction) => transaction.transactionDate.date) + .toList() + ..sort(); + + test("Monthly keeps its day after a start in the past", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 9, 30, 14, 30), + now: DateTime(2026, 10, 2, 9), + rules: [RecurrenceRule.monthly(30)], + ); + + expect(dates(rule), [DateTime(2026, 9, 30), DateTime(2026, 10, 30)]); + }); + + test("Only the one ahead is pending", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 7, 30), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + ); + + final Iterable<Transaction> pending = obx.box<Transaction>().getAll().where( + (transaction) => transaction.isPending == true, + ); + + expect(dates(rule), hasLength(4)); + expect(pending.single.transactionDate.date, DateTime(2026, 10, 30)); + }); + + test("Catches up on a start months ago, plus one ahead", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 6, 15, 8), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(15)], + ); + + expect(dates(rule), [ + DateTime(2026, 6, 15), + DateTime(2026, 7, 15), + DateTime(2026, 8, 15), + DateTime(2026, 9, 15), + DateTime(2026, 10, 15), + ]); + }); + + test("Monthly on the 31st clamps to shorter months", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 12, 31, 10), + now: DateTime(2027, 4, 15), + rules: [RecurrenceRule.monthly(31)], + ); + + expect(dates(rule), [ + DateTime(2026, 12, 31), + DateTime(2027, 1, 31), + DateTime(2027, 2, 28), + DateTime(2027, 3, 31), + DateTime(2027, 4, 30), + ]); + }); + + test("Weekly keeps its weekday", () async { + final DateTime start = DateTime(2026, 9, 16, 18); + + final RecurringTransaction rule = await series( + start: start, + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.weekly(start.weekday)], + ); + + expect(dates(rule), [ + DateTime(2026, 9, 16), + DateTime(2026, 9, 23), + DateTime(2026, 9, 30), + DateTime(2026, 10, 7), + ]); + }); + + test("Every 2 weeks counts from the start", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 9, 1, 12), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.interval(const Duration(days: 14))], + ); + + expect(dates(rule), [ + DateTime(2026, 9, 1), + DateTime(2026, 9, 15), + DateTime(2026, 9, 29), + DateTime(2026, 10, 13), + ]); + }); + + test("Yearly keeps its date", () async { + final RecurringTransaction rule = await series( + start: DateTime(2024, 9, 30), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.yearly(9, 30)], + ); + + expect(dates(rule), [ + DateTime(2024, 9, 30), + DateTime(2025, 9, 30), + DateTime(2026, 9, 30), + DateTime(2027, 9, 30), + ]); + }); + + test("Stops at the end of the range", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 6, 30, 9), + end: DateTime(2026, 8, 30, 9), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + ); + + expect(dates(rule), [ + DateTime(2026, 6, 30), + DateTime(2026, 7, 30), + DateTime(2026, 8, 30), + ]); + }); + + test("A start in the future generates nothing more yet", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 10, 30), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + ); + + expect(dates(rule), [DateTime(2026, 10, 30)]); + }); + + test("Synchronizing again doesn't duplicate", () async { + final DateTime now = DateTime(2026, 10, 2); + + final RecurringTransaction rule = await series( + start: DateTime(2026, 8, 30), + now: now, + rules: [RecurrenceRule.monthly(30)], + ); + + final List<DateTime> before = dates(rule); + + await RecurringTransactionsService().synchronizeAll(anchor: now); + await RecurringTransactionsService().synchronizeAll(anchor: now); + + expect(before, hasLength(3)); + expect(dates(rule), before); + }); + + test("Generates the next one once time passes", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 9, 30), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + ); + + await RecurringTransactionsService().synchronizeAll( + anchor: DateTime(2026, 10, 30, 12), + ); + + expect(dates(rule), [ + DateTime(2026, 9, 30), + DateTime(2026, 10, 30), + DateTime(2026, 11, 30), + ]); + }); + + test("Transfers follow the schedule on both sides", () async { + final RecurringTransaction rule = await series( + start: DateTime(2026, 8, 30, 7), + now: DateTime(2026, 10, 2), + rules: [RecurrenceRule.monthly(30)], + transferTo: other, + ); + + expect(dates(rule), [ + DateTime(2026, 8, 30), + DateTime(2026, 9, 30), + DateTime(2026, 10, 30), + ]); + expect( + obx.box<Transaction>().getAll().where((x) => x.amount > 0), + hasLength(3), + ); + }); +} + +class _MemoryPreferences extends Fake implements SharedPreferencesWithCache { + final Map<String, Object> _values = {}; + + @override + bool containsKey(String key) => _values.containsKey(key); + + @override + Object? get(String key) => _values[key]; + + @override + bool? getBool(String key) => _values[key] as bool?; + + @override + int? getInt(String key) => _values[key] as int?; + + @override + Future<void> setBool(String key, bool value) async => _values[key] = value; + + @override + Future<void> setInt(String key, int value) async => _values[key] = value; +} diff --git a/test/unit/utils/recurrence_test.dart b/test/unit/utils/recurrence_test.dart new file mode 100644 index 00000000..85b8e23b --- /dev/null +++ b/test/unit/utils/recurrence_test.dart @@ -0,0 +1,75 @@ +import "package:flow/utils/extensions/recurrence.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; + +void main() { + Recurrence startingOct2(RecurrenceRule rule, [DateTime? end]) => Recurrence( + range: CustomTimeRange(DateTime(2026, 10, 2, 9), end ?? Moment.maxValue), + rules: [rule], + ); + + final DateTime sep30 = DateTime(2026, 9, 30, 14, 30); + + test("startingAt moves the start, even earlier", () { + final Recurrence moved = startingOct2( + RecurrenceRule.monthly(2), + ).startingAt(sep30); + + expect(moved.range.from, sep30); + expect(moved.range.to, Moment.maxValue); + }); + + test("startingAt takes monthly, weekly and yearly rules along", () { + expect(startingOct2(RecurrenceRule.monthly(2)).startingAt(sep30).rules, [ + RecurrenceRule.monthly(30), + ]); + expect( + startingOct2( + RecurrenceRule.weekly(DateTime.friday), + ).startingAt(sep30).rules, + [RecurrenceRule.weekly(DateTime.wednesday)], + ); + expect(startingOct2(RecurrenceRule.yearly(10, 2)).startingAt(sep30).rules, [ + RecurrenceRule.yearly(9, 30), + ]); + }); + + test("startingAt leaves intervals alone", () { + final RecurrenceRule every2Weeks = RecurrenceRule.interval( + const Duration(days: 14), + ); + + expect(startingOct2(every2Weeks).startingAt(sep30).rules, [every2Weeks]); + }); + + test("startingAt lists occurrences on the new day", () { + final Recurrence moved = startingOct2( + RecurrenceRule.monthly(2), + ).startingAt(sep30); + + final List<DateTime> next = moved.occurrences( + subrange: CustomTimeRange(sep30, DateTime(2027, 1, 1)), + ); + + expect(next.map((date) => date.day).toSet(), {30}); + }); + + test("startingAt keeps an end that's still ahead", () { + final DateTime end = DateTime(2027, 1, 2, 9); + + expect( + startingOct2(RecurrenceRule.monthly(2), end).startingAt(sep30).range.to, + end, + ); + }); + + test("startingAt past the end runs indefinitely", () { + final Recurrence moved = startingOct2( + RecurrenceRule.monthly(2), + DateTime(2026, 10, 20), + ).startingAt(DateTime(2026, 11, 5)); + + expect(moved.range.to, Moment.maxValue); + }); +} diff --git a/test/widget/select_recurrence_test.dart b/test/widget/select_recurrence_test.dart index 79541b6d..56531e55 100644 --- a/test/widget/select_recurrence_test.dart +++ b/test/widget/select_recurrence_test.dart @@ -1,8 +1,10 @@ import "package:flow/l10n/flow_localizations.dart"; import "package:flow/routes/transaction_page/select_recurrence.dart"; +import "package:flow/theme/flow_custom_colors.dart"; import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter_test/flutter_test.dart"; +import "package:moment_dart/moment_dart.dart"; import "package:recurrence/recurrence.dart"; void main() { @@ -15,18 +17,30 @@ void main() { Future<List<Recurrence>> pump( WidgetTester tester, { Recurrence? initialValue, + DateTime? defaultStart, + TimeRange? startBounds, }) async { final List<Recurrence> changes = []; await tester.pumpWidget( MaterialApp( localizationsDelegates: const [_TestLocalizationsDelegate()], + theme: ThemeData( + extensions: const [ + FlowCustomColors( + income: Color(0xFF32CC70), + expense: Color(0xFFFF4040), + semi: Color(0xFF888888), + ), + ], + ), home: Scaffold( body: SingleChildScrollView( child: SelectRecurrence( initialValue: initialValue, + defaultStart: defaultStart, onChanged: changes.add, - startBounds: null, + startBounds: startBounds, ), ), ), @@ -57,6 +71,107 @@ void main() { expect(changes, isEmpty); }); + + /// Picks [day] of the shown month as the start, skipping the time + Future<void> pickStart(WidgetTester tester, int day) async { + await tester.tap(find.byType(ListTile).at(1)); + await tester.pumpAndSettle(); + await tester.tap(find.text("$day")); + await tester.tap(find.text("OK")); + await tester.pumpAndSettle(); + await tester.tap(find.text("Cancel")); + await tester.pumpAndSettle(); + } + + testWidgets("default starts at the given date, on its day", (tester) async { + final List<Recurrence> changes = await pump( + tester, + defaultStart: DateTime(2026, 9, 30, 14, 30), + ); + + expect(changes.single.range.from, DateTime(2026, 9, 30, 14, 30)); + expect(changes.single.rules.single, MonthlyRecurrenceRule(day: 30)); + }); + + testWidgets("moving the start moves the monthly day with it", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [MonthlyRecurrenceRule(day: 2)], + start: DateTime(2026, 10, 2), + ), + startBounds: TimeRange.allTime(), + ); + + await pickStart(tester, 30); + + expect(changes.last.range.from, DateTime(2026, 10, 30)); + expect(changes.last.rules.single, MonthlyRecurrenceRule(day: 30)); + }); + + testWidgets("moving the start moves the weekday with it", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [WeeklyRecurrenceRule(weekday: DateTime.friday)], + start: DateTime(2026, 10, 2), + ), + startBounds: TimeRange.allTime(), + ); + + await pickStart(tester, 7); + + expect( + changes.last.rules.single, + WeeklyRecurrenceRule(weekday: DateTime.wednesday), + ); + }); + + testWidgets("moving the start moves the yearly date with it", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [RecurrenceRule.yearly(10, 2)], + start: DateTime(2026, 10, 2), + ), + startBounds: TimeRange.allTime(), + ); + + await pickStart(tester, 30); + + expect(changes.last.rules.single, RecurrenceRule.yearly(10, 30)); + }); + + testWidgets("a start past the end drops the end", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence( + range: CustomTimeRange(DateTime(2026, 10, 2), DateTime(2026, 10, 20)), + rules: [MonthlyRecurrenceRule(day: 2)], + ), + startBounds: TimeRange.allTime(), + ); + + await pickStart(tester, 30); + + expect(changes.last.range.to, Moment.maxValue); + }); + + testWidgets("start can't be moved without bounds", (tester) async { + final List<Recurrence> changes = await pump( + tester, + initialValue: Recurrence.fromIndefinitely( + rules: [MonthlyRecurrenceRule(day: 2)], + start: DateTime(2026, 10, 2), + ), + ); + + await tester.tap(find.byType(ListTile).at(1)); + await tester.pumpAndSettle(); + + expect(find.byType(DatePickerDialog), findsNothing); + expect(changes, isEmpty); + }); } /// [FlowLocalizations.delegate] also syncs home widgets over a platform From 926a486c70d0e6794e8706ef4eeb4c63fdb594ea Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Fri, 2 Oct 2026 15:58:55 +0800 Subject: [PATCH 55/57] 0.25.0+362 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 2ae0539c..6f48d79d 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.25.0+361" +version: "0.25.0+362" environment: sdk: ">=3.10.0 <4.0.0" From 5b544c9ad59448d15d325a915ff5d15057d40550 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sun, 4 Oct 2026 14:05:06 +0800 Subject: [PATCH 56/57] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 901e42cc..daafa434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ when you tap a selected slice again * Updated icons: Material Symbols 4.2960.0 and Simple Icons (brand icons) 16.23.0 +* iOS 15 or later is now required ### Fixes From faeab54a0eb04a85ed0751c8ae9e9cd031849fc2 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar <batmend@gege.mn> Date: Sun, 4 Oct 2026 14:05:06 +0800 Subject: [PATCH 57/57] 0.25.0+363 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 6f48d79d..908dd611 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.25.0+362" +version: "0.25.0+363" environment: sdk: ">=3.10.0 <4.0.0"