From d9b84ee661a14c4515ee2151a4ae2436a94d8cb4 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 19 Jul 2026 15:45:21 +0800 Subject: [PATCH 01/12] Update CONTRIBUTING.md --- CONTRIBUTING.md | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) 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. From f42c75e63f4edeee6768b6265926207c4b92056d Mon Sep 17 00:00:00 2001 From: Tynox Date: Sun, 26 Jul 2026 10:54:51 +0800 Subject: [PATCH 02/12] feat: add Simplified Chinese. --- assets/l10n/zh_CN.json | 883 ++++++++++++++++++++++++++++++ lib/l10n/supported_languages.dart | 1 + scripts/translate_missing.dart | 1 + 3 files changed, 885 insertions(+) create mode 100644 assets/l10n/zh_CN.json diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json new file mode 100644 index 00000000..ae031195 --- /dev/null +++ b/assets/l10n/zh_CN.json @@ -0,0 +1,883 @@ +{ + "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": "您的个人理财追踪器", + "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@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.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.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/lib/l10n/supported_languages.dart b/lib/l10n/supported_languages.dart index 8383ac8b..e47b37cb 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)", "简体中文 (中国)"), const Locale("zh", "TW"): ("Chinese (Traditional, Taiwan)", "正體中文 (台灣)"), }; 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)", }; From e1f7f3cfaca730142842ee7ab4e4df90423939fa Mon Sep 17 00:00:00 2001 From: Tynox Date: Sun, 26 Jul 2026 11:01:36 +0800 Subject: [PATCH 03/12] fix: fix Simplified Chinese region issue in supported languages. --- lib/l10n/supported_languages.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/l10n/supported_languages.dart b/lib/l10n/supported_languages.dart index e47b37cb..7790a118 100644 --- a/lib/l10n/supported_languages.dart +++ b/lib/l10n/supported_languages.dart @@ -19,6 +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)", "简体中文 (中国)"), + const Locale("zh", "CN"): ("Chinese (Simplified, China Mainland)", "简体中文 (中国大陆)"), const Locale("zh", "TW"): ("Chinese (Traditional, Taiwan)", "正體中文 (台灣)"), }; From 4db2788a5b5a7a2f68f2bef0061d2e2b7a803c08 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 26 Jul 2026 14:30:13 +0800 Subject: [PATCH 04/12] fix(l10n): Chinese measure word, zh_CN typo, en double negative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - zh_CN: 中位数字消费 -> 中位数消费; the stray 字 read as "median digit" - zh_CN/zh_TW: add missing measure word to upcomingCharges ({count} 笔/筆预定扣款), matching every other count string - en: deleteCloudBackupConfirmation said "This action is not irreversible!" — a double negative telling users a destructive backup deletion could be undone Claude-Session: https://claude.ai/code/session_01JUAzk892137fKesAsZbGnN --- assets/l10n/en.json | 2 +- assets/l10n/zh_CN.json | 6 +++--- assets/l10n/zh_TW.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e2535664..e6c76df1 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -546,7 +546,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 index ae031195..4b046be5 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -693,8 +693,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": "支出", @@ -713,7 +713,7 @@ "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.medianPurchase": "你的中位数消费为 {value}。", "tabs.stats.analytics.wrapped.noExpenses": "尚未记录支出。", "tabs.stats.analytics.wrapped.noTransactions": "本月尚无交易。", "tabs.stats.analytics.wrapped.spendMostOn": "你最多花在 {value}。", diff --git a/assets/l10n/zh_TW.json b/assets/l10n/zh_TW.json index f67886b8..0027fef7 100644 --- a/assets/l10n/zh_TW.json +++ b/assets/l10n/zh_TW.json @@ -693,8 +693,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": "支出", From 9e79cabb85310d225bc3ac7fc5ddfd144d58d242 Mon Sep 17 00:00:00 2001 From: RoomWithOutRoof <166608075+Jah-yee@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:54:41 +0800 Subject: [PATCH 05/12] fix: correct 'occured' to 'occurred' typo in Dart and l10n files (#751) --- assets/l10n/en.json | 4 ++-- lib/entity/transaction/wrapper.dart | 2 +- lib/main.dart | 2 +- lib/routes/preferences/button_order_preferences_page.dart | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e6c76df1..36ad3ae3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -187,7 +187,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.invalidZip": "Not a valid Flow zip file", "error.input.mustBeNotEmpty": "Please fill out this field", @@ -199,7 +199,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", 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/main.dart b/lib/main.dart index f7567f9e..0b39f0d9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -552,7 +552,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/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; From 0349eeab414e594c2cdebcd16062d435ca4e9394 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 3 Aug 2026 14:59:33 +0800 Subject: [PATCH 06/12] deps: bump pie_menu to 3.8.3 Routine bump, along with the transitive test/meta/matcher/vector_math versions pub resolved with it. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ --- pubspec.lock | 28 ++++++++++++++-------------- pubspec.yaml | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 7a09d3f2..8b4a5c98 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1202,10 +1202,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1226,10 +1226,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mgrs_dart: dependency: transitive description: @@ -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: @@ -1927,26 +1927,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: "direct main" description: @@ -2063,10 +2063,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 11207372..95af092a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 From c06d20a070510b4466e4eff148015da95075d046 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 3 Aug 2026 15:01:20 +0800 Subject: [PATCH 07/12] budgets: count pending spend, and draw it as a ghost tail Pending transactions were excluded from budget spend outright. That makes a budget read healthy right up until a scheduled payment clears, at which point it lurches. Rent dated the 28th is money the period is already committed to; leaving it out means the number answers "what has cleared" while the user is asking "how much room is left". So `spent` now includes pending, and everything derived from it -- ratio, status, insights, alerts -- is measured on the committed total. The pending slice stays recoverable as `pendingSpent`, because counting it is not the same as pretending it has already gone: every bar fills solid to `confirmedRatio` and continues to `ratio` as a lighter ghost. `projectedRatio` deliberately does not extrapolate the pending part. A scheduled lump sum is already dated, so running it through the run rate would count it once for being committed and again for every day left in the period -- rent on the 28th would project a 4.5x overshoot on the 2nd with nothing actually spent. Confirmed spend is extrapolated as before, the pending remainder is added at face value. BulletChart is rewritten alongside it. The track had 10% headroom past whichever of value/target was larger, so a bar sat at ~76% of its width while the label beside it read 84%, and the same budget drew differently depending on how far over it was. It now spans exactly 0..target: an overrun fills the track and recolours instead of rescaling it. The old qualitative band went with it -- it marked the target, which is now the end of the track -- and the target tick became a pace tick, neutral rather than `primary`, since a green accent colour made it the same hue as a healthy bar. payloadVersion goes to 3: `spent`/`ratio` mean something new, and a v2 extension would draw the larger figure as though all of it had cleared. Both extensions default `confirmedRatio` to `ratio` when decoding, so the degraded case is one solid bar rather than an all-ghost one. Glance has no alpha modifier, so the Android ghost is three pre-multiplied colour resources rather than a modifier on the status colour. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ --- .../mn/flow/flow/glance/BudgetPayload.kt | 17 +- .../mn/flow/flow/glance/BudgetPinned.kt | 8 +- .../mn/flow/flow/glance/BudgetRollup.kt | 7 +- .../mn/flow/flow/glance/BudgetWidgetUi.kt | 49 +++++- .../app/src/main/res/values/widget_colors.xml | 10 ++ ios/Flow Widgets/BudgetPayload.swift | 19 ++- ios/Flow Widgets/BudgetPinnedWidget.swift | 17 +- ios/Flow Widgets/BudgetRollupWidget.swift | 10 +- ios/Flow Widgets/BudgetWidgetStyle.swift | 53 +++++- lib/data/budget_progress.dart | 36 +++- lib/data/budget_spec.dart | 7 +- lib/objectbox/actions.dart | 2 + lib/routes/budget_detail_page.dart | 11 +- lib/services/budget.dart | 36 +++- lib/services/budget_widget_sync.dart | 11 +- lib/widgets/analytics/bullet_chart.dart | 132 +++++++++----- lib/widgets/budgets/budget_card.dart | 24 +-- lib/widgets/home/stats/bento/budget_tile.dart | 2 + test/unit/budget_isolate_test.dart | 31 +++- test/unit/budget_progress_test.dart | 77 +++++++++ test/unit/budget_widget_payload_test.dart | 30 +++- test/widget/bullet_chart_test.dart | 161 ++++++++++++++++++ 22 files changed, 657 insertions(+), 93 deletions(-) create mode 100644 test/widget/bullet_chart_test.dart 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..c21c3382 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 @@ -138,7 +138,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..96467e94 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 @@ -193,7 +193,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..bf0cf157 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 @@ -49,6 +49,16 @@ object BudgetWidgetUi { BudgetStatus.HEALTHY -> ColorProvider(R.color.income_green) } + /** + * [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) + } + /** Every budget widget opens the app; matches the Summary widget. */ fun launchAppIntent(context: Context): Intent = Intent(context, MainActivity::class.java).apply { @@ -91,10 +101,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 +128,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/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..c7642eb1 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 @@ -204,7 +208,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 +229,7 @@ struct FlowBudgetRollupWidget: Widget { percent: 124, percentLabel: "124%", ratio: 1.24, + confirmedRatio: 1.24, status: .over, statusLabel: "Хязгаар хэтэрсэн", daysLeft: 9, @@ -243,6 +248,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/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/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/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/test/unit/budget_isolate_test.dart b/test/unit/budget_isolate_test.dart index 7f4d9210..192529a1 100644 --- a/test/unit/budget_isolate_test.dart +++ b/test/unit/budget_isolate_test.dart @@ -178,7 +178,7 @@ void main() { ); }); - test("pending transactions do not count", () async { + test("pending transactions count, and stay separable", () async { final Account account = makeAccount(); spend(account, 20.0); @@ -197,10 +197,31 @@ void main() { final List progresses = await BudgetService() .computeAllProgressAsync(); - expect( - forName(progresses, "Everything").spent.amount, - moreOrLessEquals(20.0), - ); + final BudgetProgress progress = forName(progresses, "Everything"); + + // Scheduled spending is spending the period is already committed to, so it + // counts against the limit... + expect(progress.spent.amount, moreOrLessEquals(520.0)); + expect(progress.pendingSpent.amount, moreOrLessEquals(500.0)); + // ...while staying recoverable, so the bar can draw it as a ghost tail + // rather than as money that has already gone. + expect(progress.confirmedSpent.amount, moreOrLessEquals(20.0)); + }); + + test("a budget with nothing pending reports none", () async { + final Account account = makeAccount(); + spend(account, 20.0); + + makeBudget(name: "Nothing pending"); + + final List progresses = await BudgetService() + .computeAllProgressAsync(); + + final BudgetProgress progress = forName(progresses, "Nothing pending"); + + expect(progress.spent.amount, moreOrLessEquals(20.0)); + expect(progress.pendingSpent.amount, moreOrLessEquals(0.0)); + expect(progress.hasPending, isFalse); }); test( diff --git a/test/unit/budget_progress_test.dart b/test/unit/budget_progress_test.dart index 14d88715..d4b2e66a 100644 --- a/test/unit/budget_progress_test.dart +++ b/test/unit/budget_progress_test.dart @@ -26,6 +26,7 @@ void main() { BudgetProgress progressOf({ required double spent, + double pending = 0.0, double limit = 100.0, DateTime? asOf, TimeRange? range, @@ -34,6 +35,7 @@ void main() { budget: budgetOf(amount: limit), range: range ?? june, spent: Money(spent, "USD"), + pendingSpent: Money(pending, "USD"), limit: Money(limit, "USD"), asOf: asOf ?? DateTime(2026, 6, 21), hasMissingData: hasMissingData, @@ -69,6 +71,81 @@ void main() { }); }); + group("the pending split", () { + test("pending is part of spent, not something added to it", () { + final BudgetProgress p = progressOf(spent: 60.0, pending: 25.0); + + expect(p.spent.amount, moreOrLessEquals(60.0)); + expect(p.confirmedSpent.amount, moreOrLessEquals(35.0)); + expect(p.ratio, moreOrLessEquals(0.6)); + expect(p.confirmedRatio, moreOrLessEquals(0.35)); + }); + + test("a scheduled payment can be what tips a budget over", () { + // The whole point of counting pending: 80 spent with 30 more already + // committed is not a healthy budget, however little has cleared. + final BudgetProgress p = progressOf(spent: 110.0, pending: 30.0); + + expect(p.status, BudgetStatus.over); + expect(p.primaryInsight, BudgetInsightType.over); + expect(p.confirmedSpent.amount, moreOrLessEquals(80.0)); + }); + + test("no pending leaves the confirmed figures identical to spent", () { + final BudgetProgress p = progressOf(spent: 84.0); + + expect(p.hasPending, isFalse); + expect(p.confirmedSpent.amount, moreOrLessEquals(p.spent.amount)); + expect(p.confirmedRatio, moreOrLessEquals(p.ratio)); + }); + + test("an all-pending budget has nothing confirmed to draw", () { + final BudgetProgress p = progressOf(spent: 40.0, pending: 40.0); + + expect(p.hasPending, isTrue); + expect(p.confirmedSpent.amount, moreOrLessEquals(0.0)); + expect(p.confirmedRatio, moreOrLessEquals(0.0)); + }); + + test("a scheduled lump sum is not extrapolated as a run rate", () { + // 2 of 30 days elapsed, nothing actually spent, one 30.0 payment already + // scheduled for later in the month. Running that through the rate would + // project a 4.5x overshoot and cry "overpacing" about a healthy budget. + final BudgetProgress p = progressOf( + spent: 30.0, + pending: 30.0, + asOf: DateTime(2026, 6, 3), + ); + + expect(p.projectedRatio, moreOrLessEquals(0.3)); + expect(p.pace, BudgetPace.under); + expect(p.primaryInsight, isNot(BudgetInsightType.overpacing)); + }); + + test("confirmed spend is still extrapolated normally", () { + // Same period position, but the 30.0 actually cleared — two days in and + // already 30% down really is on course to overshoot. + final BudgetProgress p = progressOf( + spent: 30.0, + asOf: DateTime(2026, 6, 3), + ); + + expect(p.projectedRatio, greaterThan(1.0)); + expect(p.pace, BudgetPace.over); + }); + + test("a non-positive limit zeroes confirmedRatio too, not just ratio", () { + final BudgetProgress p = progressOf( + spent: 50.0, + pending: 20.0, + limit: 0.0, + ); + + expect(p.ratio, 0.0); + expect(p.confirmedRatio, 0.0); + }); + }); + group("status thresholds", () { test("healthy below the warning threshold", () { expect(progressOf(spent: 89.0).status, BudgetStatus.healthy); diff --git a/test/unit/budget_widget_payload_test.dart b/test/unit/budget_widget_payload_test.dart index 699f28c3..b2c0e803 100644 --- a/test/unit/budget_widget_payload_test.dart +++ b/test/unit/budget_widget_payload_test.dart @@ -72,12 +72,13 @@ void main() { return account; } - void spend(Account account, double amount) { + void spend(Account account, double amount, {bool pending = false}) { final Transaction transaction = Transaction( amount: -amount.abs(), currency: "USD", uuid: const Uuid().v4(), transactionDate: insideThisMonth, + isPending: pending ? true : null, ); transaction.account.target = account; obx.box().put(transaction); @@ -109,7 +110,7 @@ void main() { final Map payload = await BudgetWidgetSync.buildPayload(); expect(payload["version"], BudgetWidgetSync.payloadVersion); - expect(payload["version"], 2); + expect(payload["version"], 3); }); test("no budgets yields an empty list, not a missing key", () async { @@ -145,6 +146,7 @@ void main() { expect(budget["percentLabel"], isA()); expect(budget["percentLabel"], isNotEmpty); expect(budget["ratio"], isA()); + expect(budget["confirmedRatio"], isA()); expect(budget["status"], "healthy"); expect(budget["daysLeft"], isA()); // May be absent when translations aren't loaded — see the labels test. @@ -168,12 +170,36 @@ void main() { // Everything the amount-free rendering draws from, none of it monetary. expect(budget["percent"], 92); expect(budget["ratio"], moreOrLessEquals(0.92)); + // The ghost tail is geometry, so it survives "Hide amounts" too. + expect(budget["confirmedRatio"], moreOrLessEquals(0.92)); expect(budget["status"], "warning"); expect(budget["periodLabel"], isNotEmpty); expect(budget["name"], isNotEmpty); }, ); + test("pending spend ships as a ghost split, not as a second total", () async { + makeBudget("Groceries", 100.0); + final Account account = makeAccount(); + spend(account, 30.0); + spend(account, 20.0, pending: true); + + final Map payload = await BudgetWidgetSync.buildPayload(); + + final Map budget = + (payload["budgets"] as List).single as Map; + + // `ratio` is the committed total — that is what the percentage says, and + // what the widget's bar has to reach. + expect(budget["ratio"], moreOrLessEquals(0.5)); + expect(budget["percent"], 50); + // `confirmedRatio` is where the solid fill stops and the ghost starts. + expect(budget["confirmedRatio"], moreOrLessEquals(0.3)); + // Deliberately no money field for the pending slice: nothing renders it, and + // an unrendered amount is a "Hide amounts" leak waiting to happen. + expect(budget.containsKey("pendingSpent"), isFalse); + }); + test("ratio is left unclamped so an overrun is representable", () async { makeBudget("Groceries", 100.0); spend(makeAccount(), 150.0); diff --git a/test/widget/bullet_chart_test.dart b/test/widget/bullet_chart_test.dart new file mode 100644 index 00000000..3dedd7c6 --- /dev/null +++ b/test/widget/bullet_chart_test.dart @@ -0,0 +1,161 @@ +import "package:flow/theme/flow_custom_colors.dart"; +import "package:flow/widgets/analytics/bullet_chart.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +/// Geometry guards for the budget progress bar. +/// +/// Every number a budget shows the user is a label *except* this bar, so a +/// scale bug here is invisible to every other test in the suite — it reads as a +/// bar that simply looks a bit off, which is exactly how the old `* 1.1` +/// headroom survived: it drew 9% spend at 8.2% of the track and pinned the +/// limit tick at a constant 90.9% for every under-budget budget. +void main() { + const double trackWidth = 200.0; + const double barHeight = 16.0; + + Widget wrap(Widget child) => MaterialApp( + theme: ThemeData( + extensions: const [ + FlowCustomColors( + income: Color(0xFF32CC70), + expense: Color(0xFFC42525), + semi: Color(0xFF888888), + ), + ], + ), + home: Scaffold( + body: Center( + child: SizedBox(width: trackWidth, height: barHeight, child: child), + ), + ), + ); + + /// Track first, then ghost, fill and pace tick in Stack order. `Container` + /// renders its decoration through a `DecoratedBox`, so every layer shows up + /// here — and only the layers actually drawn do. + List layers(WidgetTester tester) => tester + .widgetList(find.byType(DecoratedBox)) + .map((box) => tester.getRect(find.byWidget(box))) + .toList(); + + testWidgets("the fill is as tall as the track it sits in", (tester) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 50.0, target: 100.0)), + ); + + final List rects = layers(tester); + expect(rects, hasLength(2)); + + final Rect track = rects[0]; + final Rect fill = rects[1]; + + // The old inset was `height * 0.28` top and bottom with no left inset, so + // the fill read as a short bar shoved against the track's rounded cap. + expect(fill.height, track.height); + expect(fill.top, track.top); + expect(fill.left, track.left); + }); + + testWidgets("the fill width is the percentage, with no headroom", ( + tester, + ) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 50.0, target: 100.0)), + ); + + final List rects = layers(tester); + expect(rects[1].width, moreOrLessEquals(trackWidth / 2)); + }); + + testWidgets("an overrun fills the track rather than rescaling it", ( + tester, + ) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 150.0, target: 100.0)), + ); + + final List rects = layers(tester); + expect(rects[1].width, moreOrLessEquals(trackWidth)); + }); + + testWidgets("a tiny non-zero spend still draws at least a dot", ( + tester, + ) async { + await tester.pumpWidget(wrap(const BulletChart(value: 0.4, target: 100.0))); + + // 0.4% of 200px is under a pixel — a bar that thin reads as "nothing + // spent", which is a different thing entirely. + expect(layers(tester)[1].width, barHeight); + }); + + testWidgets("nothing spent draws no fill at all", (tester) async { + await tester.pumpWidget(wrap(const BulletChart(value: 0.0, target: 100.0))); + + expect(layers(tester), hasLength(1)); + }); + + testWidgets("pending draws a ghost tail past the confirmed fill", ( + tester, + ) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 60.0, target: 100.0, pending: 25.0)), + ); + + final List rects = layers(tester); + expect(rects, hasLength(3)); + + final Rect ghost = rects[1]; + final Rect fill = rects[2]; + + // The ghost runs the whole committed length with the solid fill layered + // over it, so the seam is a rounded cap nested inside a rounded cap. + expect(ghost.width, moreOrLessEquals(trackWidth * 0.6)); + expect(fill.width, moreOrLessEquals(trackWidth * 0.35)); + expect(ghost.left, fill.left); + }); + + testWidgets("no pending means no ghost layer is drawn", (tester) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 60.0, target: 100.0)), + ); + + expect(layers(tester), hasLength(2)); + }); + + testWidgets("the pace tick sits at the elapsed fraction", (tester) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 9.0, target: 100.0, paceRatio: 0.25)), + ); + + final List rects = layers(tester); + expect(rects, hasLength(3)); + + final Rect track = rects[0]; + final Rect tick = rects[2]; + + // A quarter of the way along — not the constant ~91% the old target tick + // parked at for every budget that wasn't over. + expect(tick.center.dx - track.left, moreOrLessEquals(trackWidth * 0.25)); + expect(tick.height, track.height); + }); + + testWidgets("the pace tick is hidden at either extreme", (tester) async { + await tester.pumpWidget( + wrap(const BulletChart(value: 9.0, target: 100.0, paceRatio: 0.0)), + ); + expect(layers(tester), hasLength(2)); + + // A finished period would pin it to the right edge, where it says nothing. + await tester.pumpWidget( + wrap(const BulletChart(value: 9.0, target: 100.0, paceRatio: 1.0)), + ); + expect(layers(tester), hasLength(2)); + }); + + testWidgets("a non-positive target draws an empty track", (tester) async { + await tester.pumpWidget(wrap(const BulletChart(value: 50.0, target: 0.0))); + + expect(layers(tester), hasLength(1)); + }); +} From edf7cb510b3cf97435a346b7c11bb1db7161f5a1 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 3 Aug 2026 15:01:42 +0800 Subject: [PATCH 08/12] budgets: open what the widget shows when it's tapped The Android budget widgets built a bare `Intent(context, MainActivity)`. That carries no action and no data, so every tap cold-opened the home tab -- a widget showing "Groceries, 112%" dropped you somewhere with no budget on it, and the roll-up sent you to the same place as the pinned one. The quick-entry widgets already deep-link through the `flow-mn` scheme the manifest accepts, so this just uses the same road. The intent is pinned to our own package. `flow-mn` is a custom scheme, which any installed app may register for, so without setPackage a widget tap could raise a chooser or open a Flow-lookalike. On the app side, the URI handler is an allowlist of three paths, not a catch-all forward. Anything on the device can claim a custom scheme, so forwarding an arbitrary path from one into the router would be an open redirect into any route in the app -- including the destructive ones. Unmatched URIs are logged and dropped. `/budgets/:id` still routes through the existing redirect, so an id that no longer resolves lands on the list instead of a dead page. The iOS roll-up moves from `/budgets` to `/stats/budgets` for the same reason the Android one points there: that widget is the overview in miniature, so a tap should expand what it shows rather than drop you on the adjacent list. The Android chrome changes with it. The widgets drew a rounded card inside an 8dp inset inside the launcher's own rounded container, which reads as a border around a screenshot rather than as the widget itself. One `appWidgetBackground()` surface now *is* the widget, at the system radius, so the corners match every other widget on the home screen instead of a hard-coded 16dp that only agrees with some launchers. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ --- .../mn/flow/flow/glance/BudgetPinned.kt | 31 +++++--- .../mn/flow/flow/glance/BudgetRollup.kt | 10 ++- .../mn/flow/flow/glance/BudgetWidgetUi.kt | 76 +++++++++++++------ ios/Flow Widgets/BudgetRollupWidget.swift | 5 +- lib/services/navigation.dart | 29 ++++++- 5 files changed, 110 insertions(+), 41 deletions(-) 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 c21c3382..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( 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 96467e94..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 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 bf0cf157..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 /** @@ -59,39 +61,65 @@ object BudgetWidgetUi { BudgetStatus.HEALTHY -> ColorProvider(R.color.budget_ghost_income) } - /** Every budget widget opens the app; matches the Summary widget. */ - fun launchAppIntent(context: Context): Intent = - Intent(context, MainActivity::class.java).apply { + /** + * 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() } } diff --git a/ios/Flow Widgets/BudgetRollupWidget.swift b/ios/Flow Widgets/BudgetRollupWidget.swift index c7642eb1..069fffe4 100644 --- a/ios/Flow Widgets/BudgetRollupWidget.swift +++ b/ios/Flow Widgets/BudgetRollupWidget.swift @@ -194,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") 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"); } } From e3a0d9f094b9de9120d23d6d1ae313e31cd5ebd4 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 3 Aug 2026 15:01:48 +0800 Subject: [PATCH 09/12] budgets: make "new budget" a button, not a list row It sat as a ListTile above the budget cards, so the one action on the page looked like the first item in the list it creates into. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ --- lib/routes/budgets_page.dart | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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(), From 0773ba35d8ac636b8cffa360376c73b68b12c3c7 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 3 Aug 2026 15:05:36 +0800 Subject: [PATCH 10/12] l10n: fill in the budget strings for zh_CN zh_CN.json was contributed against develop, which has no budgets, so merging this branch left it 66 keys short of en.json and the integrity test red. Machine-translated, like every other locale here, and worth a native pass before release. Inserted in en.json's key order rather than appended: json_integrity_test compares positionally, so scripts/translate_missing alone would not have satisfied it. Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ --- assets/l10n/zh_CN.json | 66 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/assets/l10n/zh_CN.json b/assets/l10n/zh_CN.json index 4b046be5..5d8006ec 100644 --- a/assets/l10n/zh_CN.json +++ b/assets/l10n/zh_CN.json @@ -34,6 +34,61 @@ "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": "您目前没有任何分类", @@ -113,6 +168,7 @@ "enum.ImportV2Progress@success": "成功", "enum.ImportV2Progress@waitingConfirmation": "等待确认中", "enum.ImportV2Progress@writingAccounts": "正在写入账户", + "enum.ImportV2Progress@writingBudgets": "正在写入预算", "enum.ImportV2Progress@writingCategories": "正在写入分类", "enum.ImportV2Progress@writingFileAttachments": "正在写入文件附件", "enum.ImportV2Progress@writingProfile": "正在写入个人资料", @@ -189,6 +245,7 @@ "error.failedLocalAuth": "身份验证失败,请再试一次。", "error.input.cropFailed": "裁剪图片时发生错误", "error.input.duplicate.accountName": "名称“{}”已被使用。请尝试其他名称。", + "error.input.duplicate.budgetName": "你已有名为“{}”的预算。请换一个名称。", "error.input.invalidZip": "不是有效的 Flow zip 文件", "error.input.mustBeNotEmpty": "请填写此字段", "error.input.noFilePicked": "尚未选择文件", @@ -643,6 +700,15 @@ "tabs.profile.support": "支持 Flow", "tabs.profile.withLoveFromTheCreator": "来自 sadespresso 🤍 的作品", "tabs.stats": "统计", + "tabs.stats.analytics.budgets": "预算", + "tabs.stats.analytics.budgets.empty": "设置支出预算", + "tabs.stats.analytics.budgets.nearingCount": "{count} 个接近上限", + "tabs.stats.analytics.budgets.nearingCount.one": "{count} 个接近上限", + "tabs.stats.analytics.budgets.onTrack": "全部进度正常", + "tabs.stats.analytics.budgets.overCount": "{count} 个超出限额", + "tabs.stats.analytics.budgets.overCount.one": "{count} 个超出限额", + "tabs.stats.analytics.budgets.tracked": "{count} 个预算", + "tabs.stats.analytics.budgets.tracked.one": "{count} 个预算", "tabs.stats.analytics.calendar": "日历", "tabs.stats.analytics.calendar.priciestDay": "你花费最高的一天是 {value}。", "tabs.stats.analytics.calendar.spentIn": "在 {} 的支出", From ef11a65b6fb4051503ac60868bfc059c894f1e34 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 3 Aug 2026 15:05:39 +0800 Subject: [PATCH 11/12] 0.24.0+355 Claude-Session: https://claude.ai/code/session_014o8coDD67GU2x4P5nxSrFZ --- CHANGELOG.md | 6 +++++- pubspec.yaml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) 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/pubspec.yaml b/pubspec.yaml index 95af092a..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" From 2983b1e458b4239099ab6207f37b3d48a87d082d Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 3 Aug 2026 15:14:16 +0800 Subject: [PATCH 12/12] update lock --- pubspec.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 8b4a5c98..1e31cd06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1202,10 +1202,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1226,10 +1226,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1927,26 +1927,26 @@ packages: dependency: "direct dev" description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.17" timezone: dependency: "direct main" description: @@ -2063,10 +2063,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: