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)); + }); +}