diff --git a/docs/contributing/ISOLATED_APP_CLOUD_QA_ZH.md b/docs/contributing/ISOLATED_APP_CLOUD_QA_ZH.md index dc3b273e7..ead4f0e65 100644 --- a/docs/contributing/ISOLATED_APP_CLOUD_QA_ZH.md +++ b/docs/contributing/ISOLATED_APP_CLOUD_QA_ZH.md @@ -2,7 +2,7 @@ 流程 skill 随项目维护在 `.agents/skills/isolated-app-cloud-qa`;Codex 与 Claude Code 项目安装见 [项目 skill](PROJECT_SKILLS_ZH.md)。不再需要 honeycomb 或全局安装。新 run 自动记录 skill 仓库 SHA、完整目录 hash 与版本,并保存私有源码副本;可用 `--skill-dir` 指定另一份源码。旧 `--skill-repo` 仅保留兼容,已存在 run 的 manifest 和数据不会迁移。 -用于需要真实 iOS App UI、真实 Cloud 数据持久化和双向同步的验收。当前支持首页复制交易、父子分类改名同步、Web 交易图片和 MCP 小票附件四种场景。单元/widget 测试与 fake-provider 同步测试仍是快速回归层,实服同步另行执行。 +用于需要真实 iOS App UI、真实 Cloud 数据持久化和双向同步的验收。当前支持首页复制交易、父子分类改名同步、Web 交易图片、MCP 小票附件和账户金额可信度五种场景。单元/widget 测试与 fake-provider 同步测试仍是快速回归层,实服同步另行执行。 ## 环境要求 @@ -38,6 +38,8 @@ MCP 小票使用 `prepare --scenario mcp-receipt-attachments --cloud-ref `。真实 App 页面创建欠款卡与溢缴卡、消费、超额还款、再次消费、检查普通账户全部流水及导入外币 CSV;上传后等待 `QA_STAGE_READY_WEB_ACCOUNT_TRUST`。在同一 QA 网页把「QA信用卡」改名为「QA Web信用卡」,并用正数 5000 创建欠款卡「QA Web欠款卡」,再由 App 拉回,核对账户身份、初始余额、交易关系、币种与重复同步。Web 首页另核验账户全局余额与当前账本交易排行。正常入口重启核验导入交易可见及最终账户字段持久化。 + Cloud 默认取 `origin/main`;需要联调独立修复分支时,通过 `--cloud-ref <分支或 SHA>` 指定已提交的版本。manifest 记录解析后的实际 SHA,Cloud 当前 checkout 不切换。 命令打印本次私有 run 路径、新模拟器 UDID 和本地 Cloud origin。将打印的实际路径赋值给 `qa_run_dir`,不要引用旧 run: diff --git a/docs/design/DESIGN_TOKENS.md b/docs/design/DESIGN_TOKENS.md index eac9b91dc..a8b30eaab 100644 --- a/docs/design/DESIGN_TOKENS.md +++ b/docs/design/DESIGN_TOKENS.md @@ -20,6 +20,13 @@ Container( ) ``` +同页的单选视图/流水筛选统一使用 `widgets/ui/ui.dart` 导出的 +`CapsuleSwitcher`:灰色 `surfaceCapsule` 轨道,日间默认黑底白字选中, +暗色模式使用主题色选中胶囊;选中文字由 `textOnColor` 根据背景明暗决定。 +各页面直接使用组件默认配色。调用方持有选中值和业务筛选; +不另用默认 Material `ChoiceChip` 拼装 tabs。选项能完整放下时等宽, +窄屏或大字号下横向滚动;组件提供选中语义并遵循系统减少动画设置。 + --- ## 1. 背景色 Token (Surface) diff --git a/integration_test/account_balance_trust_live_test.dart b/integration_test/account_balance_trust_live_test.dart new file mode 100644 index 000000000..566020907 --- /dev/null +++ b/integration_test/account_balance_trust_live_test.dart @@ -0,0 +1,492 @@ +// Run only in the verified QA bundle on a simulator owned by this run. +import 'dart:convert'; + +import 'package:beecount/main.dart' show MainApp; +import 'package:beecount/data/repositories/local/local_repository.dart'; +import 'package:beecount/pages/account/account_edit_page.dart'; +import 'package:beecount/pages/account/account_detail_page.dart'; +import 'package:beecount/pages/account/accounts_page.dart'; +import 'package:beecount/pages/transaction/transaction_editor_page.dart'; +import 'package:beecount/pages/data/import_confirm_page.dart'; +import 'package:beecount/pages/data/import_page.dart'; +import 'package:beecount/widgets/biz/amount_editor_sheet.dart'; +import 'package:beecount/data/db.dart' as schema; +import 'package:beecount/pages/main/home_page.dart'; +import 'package:beecount/providers/database_providers.dart'; +import 'package:beecount/providers/language_provider.dart'; +import 'package:beecount/providers/sync_providers.dart'; +import 'package:beecount/providers/theme_providers.dart'; +import 'package:beecount/providers/ui_state_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_cloud_sync/flutter_cloud_sync.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:home_widget/home_widget.dart'; +import 'package:http/http.dart' as http; +import 'package:integration_test/integration_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const runId = String.fromEnvironment('QA_RUN_ID'); +const origin = String.fromEnvironment('QA_ORIGIN'); +const email = String.fromEnvironment('QA_EMAIL'); +const password = String.fromEnvironment('QA_PASSWORD'); +const appId = String.fromEnvironment('QA_APP_ID'); + +Future request(String method, String path, + {Map? body, String? token}) async { + final uri = Uri.parse(origin).resolve(path); + if (uri.origin != origin || uri.host != '127.0.0.1' || uri.scheme != 'http') { + throw StateError('Refusing a request outside this QA Cloud'); + } + final client = http.Client(); + try { + final req = http.Request(method, uri) + ..followRedirects = false + ..headers['Content-Type'] = 'application/json'; + if (token != null) req.headers['Authorization'] = 'Bearer $token'; + if (body != null) req.body = jsonEncode(body); + return await http.Response.fromStream(await client.send(req)) + .timeout(const Duration(seconds: 30)); + } finally { + client.close(); + } +} + +Future api(String method, String path, + {Map? body, String? token}) async { + final response = await request(method, path, body: body, token: token); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError('QA API $method $path returned ${response.statusCode}'); + } + return jsonDecode(response.body); +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.shouldPropagateDevicePointerEvents = true; + final cases = >[]; + binding.reportData = { + 'run_id': runId, + 'scenario': 'account-balance-trust', + 'cases': cases + }; + testWidgets('production account forms, payments, import and live Cloud', + (tester) async { + expect(appId, 'com.tntlikely.beecount.qa'); + expect((await PackageInfo.fromPlatform()).packageName, appId); + final identity = await api('GET', '/__qa__/identity'); + expect(identity['run_id'], runId); + expect(identity['database_is_isolated'], true); + await api('POST', '/api/v1/auth/register', body: { + 'email': email, + 'password': password, + 'client_type': 'app', + 'device_name': runId + }); + await HomeWidget.setAppGroupId('group.com.tntlikely.beecount.qa'); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('selected_language', 'zh'); + await prefs.setBool('welcome_shown', true); + await prefs.setString('noteDisplayMode', 'note'); + await CloudServiceStore().saveAndActivate(const CloudServiceConfig( + type: CloudBackendType.beecountCloud, + name: 'Isolated QA Cloud', + beecountCloudBaseUrl: origin, + beecountCloudEmail: email, + beecountCloudPassword: password)); + final container = ProviderContainer(); + final runtime = (await container.read(activeCloudRuntimeProvider.future))!; + final cloud = runtime.provider! as BeeCountCloudProvider; + expect(cloud.baseUrl, origin); + await cloud.auth.signInWithEmail(email: email, password: password); + final engine = runtime.syncEngine!; + final repo = container.read(repositoryProvider) as LocalRepository; + final database = container.read(databaseProvider); + final ledgerId = await repo.createLedger(name: 'QA 金额可信度 $runId'); + container.read(currentLedgerIdProvider.notifier).state = ledgerId; + await prefs.setInt('current_ledger_id', ledgerId); + final categoryId = await repo.createCategory( + name: 'QA消费', kind: 'expense', icon: 'restaurant'); + await repo.createCategory(name: '转账', kind: 'transfer', icon: 'swap_horiz'); + final bankId = await repo.createAccount( + ledgerId: ledgerId, + name: 'QA 银行卡', + type: 'bank_card', + initialBalance: 10000); + final usdId = await repo.createAccount( + ledgerId: ledgerId, + name: 'QA美元', + type: 'bank_card', + currency: 'USD', + initialBalance: 100); + final otherLedger = + await repo.createLedger(name: 'QA 跨账本', currency: 'USD'); + await repo.addTransaction( + ledgerId: otherLedger, + type: 'expense', + amount: 20, + accountId: usdId, + happenedAt: DateTime.now(), + note: 'QA 另一账本消费', + currencyCode: 'USD', + nativeAmount: 20); + await repo.addTransaction( + ledgerId: ledgerId, + type: 'expense', + amount: 5, + accountId: usdId, + happenedAt: DateTime.now(), + note: 'QA 外币消费', + currencyCode: 'USD', + nativeAmount: 35); + Future sync() async { + final result = await engine + .sync(ledgerId: ledgerId.toString()) + .timeout(const Duration(seconds: 60)); + expect(result.error, isNull); + } + + await engine.sync(ledgerId: otherLedger.toString()); + await sync(); + final ledger = (await repo.getLedgerById(ledgerId))!; + final login = await api('POST', '/api/v1/auth/login', body: { + 'email': email, + 'password': password, + 'client_type': 'web', + 'device_name': '$runId-web' + }); + final token = login['access_token'] as String; + Future> snapshot() async { + final result = await api( + 'GET', '/api/v1/sync/full?ledger_id=${ledger.syncId}', + token: token); + return jsonDecode(result['snapshot']['payload']['content'] as String) + as Map; + } + + void pass(String id, String detail) => + cases.add({'id': id, 'status': 'PASS', 'detail': detail}); + Future waitUi(bool Function() ready) async { + for (var i = 0; i < 150; i++) { + await tester.pump(const Duration(milliseconds: 100)); + if (ready()) return; + await Future.delayed(const Duration(milliseconds: 50)); + } + await binding.takeScreenshot('failure-account-ui'); + throw StateError('Production account UI did not reach expected state'); + } + + Future tap(String text) async { + await waitUi(() => find.text(text).evaluate().isNotEmpty); + final target = find.text(text).last; + await tester.ensureVisible(target); + await tester.tap(target); + await tester.pump(const Duration(milliseconds: 600)); + } + + Future capture(String name) async { + await tester.pump(const Duration(milliseconds: 400)); + await Future.delayed(const Duration(milliseconds: 300)); + await binding.takeScreenshot(name); + } + + await container.read(primaryColorInitProvider.future); + await container.read(themeModeInitProvider.future); + container.read(themeModeProvider.notifier).state = ThemeMode.light; + await container + .read(languageProvider.notifier) + .setLanguage(const Locale('zh')); + container.read(appInitStateProvider.notifier).state = AppInitState.ready; + await tester.pumpWidget(UncontrolledProviderScope( + container: container, child: const MainApp())); + await waitUi(() => find.byType(HomePage).evaluate().isNotEmpty); + final navigator = Navigator.of(tester.element(find.byType(HomePage).first)); + // Navigation preparation only; all account and transaction writes below use production controls. + Future open(Widget page) async { + navigator.push(MaterialPageRoute(builder: (_) => page)); + await tester.pump(const Duration(milliseconds: 700)); + } + + Future home() async { + navigator.popUntil((route) => route.isFirst); + await tester.pump(const Duration(milliseconds: 700)); + } + + Future fill(String key, String text) async { + final finder = find.byKey(ValueKey(key)); + await waitUi(() => finder.evaluate().isNotEmpty); + await tester.ensureVisible(finder); + await tester.enterText(finder, text); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future createCard(String name, String amount, + {bool overpayment = false, String? limit}) async { + await open(AccountEditPage(ledgerId: ledgerId)); + if (!overpayment) { + final group = find.byKey(const ValueKey('account-type-group')); + final valuation = find.descendant( + of: group, matching: find.text('估值账户')); + await waitUi(() => valuation.evaluate().isNotEmpty); + await tester.ensureVisible(valuation); + await tester.tap(valuation); + await tester.pump(const Duration(milliseconds: 600)); + expect(find.text('信用卡'), findsNothing); + await tester.tap(find.descendant( + of: group, matching: find.text('日常账户'))); + await tester.pump(const Duration(milliseconds: 600)); + } + await tap('信用卡'); + await fill('account-name', name); + if (overpayment) { + final toggle = find.descendant( + of: find.byKey(const ValueKey('account-credit-balance-kind')), + matching: find.text('溢缴款')); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pump(); + } + await fill('account-balance-amount', amount); + if (limit != null) await fill('account-credit-limit', limit); + await capture( + overpayment ? '02-app-overpayment-input' : '01-app-debt-input'); + await tap('保存'); + await waitUi(() => find.byType(AccountEditPage).evaluate().isEmpty); + return (await repo.getAllAccounts()).singleWhere((a) => a.name == name); + } + + final beforeCreate = + (await database.select(database.transactions).get()).length; + final card = await createCard('QA信用卡', '1000', limit: '20000'); + expect(card.initialBalance, -1000); + expect(card.billingDay, isNull); + expect(card.paymentDueDay, isNull); + expect((await database.select(database.transactions).get()).length, + beforeCreate); + final prepaid = await createCard('QA溢缴卡', '200', overpayment: true); + expect(prepaid.initialBalance, 200); + expect(prepaid.creditLimit, isNull); + pass('A01', + 'Production App form accepted positive debt/overpayment amounts and optional metadata; stored -1000/+200 without creating expenses'); + await open(AccountEditPage(account: prepaid, ledgerId: ledgerId)); + await waitUi(() => find + .byKey(const ValueKey('account-balance-amount')) + .evaluate() + .isNotEmpty); + await fill('account-name', 'QA溢缴卡改名'); + await tap('保存'); + await waitUi(() => find.byType(AccountEditPage).evaluate().isEmpty); + expect(await repo.getAccountBalance(prepaid.id), 200); + pass('A02', + 'Editing a positive-balance card name preserved its sign, baseline and transactions'); + Future record(String type, double amount, String note, + {int? to}) async { + await open(TransactionEditorPage( + initialKind: type, + quickAdd: true, + initialCategoryId: type == 'transfer' ? null : categoryId, + initialAccountId: type == 'transfer' ? bankId : card.id, + initialToAccountId: to)); + await waitUi(() => find.byType(AmountEditorSheet).evaluate().isNotEmpty); + await waitUi(() => find + .descendant( + of: find.byType(AmountEditorSheet), + matching: find.byType(CircularProgressIndicator)) + .evaluate() + .isEmpty); + await tester.enterText( + find + .descendant( + of: find.byType(AmountEditorSheet), + matching: find.byType(TextField)) + .first, + note); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(const Duration(milliseconds: 500)); + for (final digit in amount.toInt().toString().split('')) { + await tester.tap(find.text(digit).last); + await tester.pump(const Duration(milliseconds: 100)); + } + await tap('完成'); + await waitUi(() => find.byType(AmountEditorSheet).evaluate().isEmpty); + await home(); + expect( + (await database.select(database.transactions).get()) + .singleWhere((t) => t.note == note) + .amount, + amount); + } + + await record('expense', 1000, 'QA 信用消费'); + expect(await repo.getAccountBalance(card.id), -2000); + await record('transfer', 2200, 'QA 信用还款', to: card.id); + expect(await repo.getAccountBalance(card.id), 200); + expect(await repo.getAccountBalance(bankId), 7800); + expect((await repo.getAccountStats(card.id)).expense, 1000); + await open(AccountDetailPage(account: (await repo.getAccount(card.id))!)); + await waitUi(() => find.text('溢缴款').evaluate().isNotEmpty); + await tester.ensureVisible(find.text('全部流水')); + await tap('收入与转入'); + await waitUi(() => find.textContaining('QA 信用还款', findRichText: true).evaluate().isNotEmpty); + expect(find.text('QA 银行卡 → QA信用卡'), findsOneWidget); + expect(find.text('+2,200'), findsOneWidget); + await tap('全部流水'); + await capture('03-app-after-overpayment'); + await home(); + await record('expense', 300, 'QA 溢缴后消费'); + expect(await repo.getAccountBalance(card.id), -100); + expect((await repo.getAccountStats(card.id)).expense, 1300); + final cny = (await repo.getNetWorthBreakdownByCurrency())['CNY']!; + expect(cny.totalAssets, 8000); + expect(cny.totalLiabilities, -100); + expect(cny.netWorth, 7900); + pass('A03', + 'Production consumption/payment UI recorded expenses once; paying 2200 created +200 overpayment, then spending 300 restored -100 debt; CNY assets/debt/net = 8000/100/7900'); + await open(AccountDetailPage(account: (await repo.getAccount(bankId))!)); + await waitUi(() => find.text('全部流水').evaluate().isNotEmpty); + expect(find.textContaining('QA 信用还款', findRichText: true), findsWidgets); + await tap('收入与转入'); + await tap('全部流水'); + await waitUi(() => find.textContaining('QA 信用还款', findRichText: true).evaluate().isNotEmpty); + expect(find.text('QA 银行卡 → QA信用卡'), findsOneWidget); + expect(find.text('-2,200'), findsOneWidget); + await capture('04-app-all-flows'); + await home(); + pass('A04', + 'Ordinary account defaults to all flows, includes outgoing repayment, and switching filters returns the same transaction'); + await sync(); + var snap = await snapshot(); + expect( + (snap['accounts'] as List) + .cast() + .singleWhere((a) => a['syncId'] == card.syncId)['initialBalance'], + -1000); + final items = (snap['items'] as List).cast(); + expect( + items.singleWhere((t) => t['note'] == 'QA 信用还款')['type'], 'transfer'); + expect(items.singleWhere((t) => t['note'] == 'QA 信用还款')['amount'], 2200); + pass('A05', + 'Real App sync uploaded signed balances and transfer relationships into the authenticated Cloud snapshot'); + debugPrint('QA_STAGE_READY_WEB_ACCOUNT_TRUST'); + var webDone = false; + for (var i = 0; i < 1800; i++) { + snap = await snapshot(); + final accts = (snap['accounts'] as List).cast(); + if (accts.any( + (a) => a['syncId'] == card.syncId && a['name'] == 'QA Web信用卡') && + accts.any((a) => + a['name'] == 'QA Web欠款卡' && a['initialBalance'] == -5000)) { + webDone = true; + break; + } + await tester.pump(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(seconds: 1)); + } + expect(webDone, true, + reason: 'Actual Web UI must rename existing card and create debt card'); + await sync(); + expect((await repo.getAccount(card.id))!.name, 'QA Web信用卡'); + expect(await repo.getAccountBalance(card.id), -100); + final webCard = + (await repo.getAllAccounts()).singleWhere((a) => a.name == 'QA Web欠款卡'); + expect(webCard.initialBalance, -5000); + await open(AccountsPage()); + await waitUi(() => find.text('QA Web欠款卡').evaluate().isNotEmpty); + await capture('05-app-after-web'); + await home(); + pass('A06', + 'Actual Web form rename and positive debt creation pulled back through the production App engine, preserving the existing card balance'); + await open(const ImportPage()); + await open(const ImportConfirmPage( + csvText: + '日期,类型,金额,币种,分类,账户,备注\n2026-10-09,支出,2000,JPY,QA消费,QA日元,QA 日元导入\n2026-10-09,支出,10,CNY,QA消费,QA人民币,QA 人民币导入', + hasHeader: true, + billType: BillSourceType.generic)); + await waitUi(() => find.text('下一步').evaluate().isNotEmpty); + await tap('下一步'); + await tap('开始导入'); + await waitUi(() => find.text('确认账户币种').evaluate().isNotEmpty); + expect(find.text('QA日元 · JPY'), findsOneWidget); + await capture('06-app-currency-preview'); + final beforeImportCount = + (await database.select(database.transactions).get()).length; + await tap('取消'); + expect((await repo.getAllAccounts()).any((a) => a.name == 'QA日元'), false); + expect((await database.select(database.transactions).get()).length, + beforeImportCount); + await tap('开始导入'); + await waitUi(() => find.text('确认账户币种').evaluate().isNotEmpty); + await tap('开始导入'); + await waitUi(() => find.byType(ImportConfirmPage).evaluate().isEmpty); + final jpy = + (await repo.getAllAccounts()).singleWhere((a) => a.name == 'QA日元'); + expect(jpy.currency, 'JPY'); + final imported = (await database.select(database.transactions).get()) + .singleWhere((t) => t.note == 'QA 日元导入'); + expect(imported.currencyCode, 'JPY'); + expect(imported.amount, 2000); + expect(imported.accountId, jpy.id); + await sync(); + snap = await snapshot(); + expect( + (snap['accounts'] as List) + .cast() + .singleWhere((a) => a['syncId'] == jpy.syncId)['currency'], + 'JPY'); + pass('A07', + 'Actual CSV confirmation/import UI created a JPY account in a CNY ledger and preserved original 2000 JPY transaction and Cloud account identity'); + final txCount = (await database.select(database.transactions).get()).length; + await sync(); + await sync(); + expect( + (await database.select(database.transactions).get()).length, txCount); + pass('A08', 'Repeated real synchronization did not duplicate transactions'); + await api('POST', '/__qa__/fault', + body: {'run_id': runId, 'offline': true}); + try { + await record('expense', 10, 'QA 故障后消费'); + final unavailable = await engine.sync(ledgerId: ledgerId.toString()); + expect(unavailable.error, isNotNull); + expect(await repo.getAccountBalance(card.id), -110); + } finally { + await api('POST', '/__qa__/fault', + body: {'run_id': runId, 'offline': false}); + } + await sync(); + await sync(); + snap = await snapshot(); + final recovered = (snap['items'] as List) + .cast() + .where((t) => t['note'] == 'QA 故障后消费') + .toList(); + expect(recovered.length, 1); + expect(recovered.single['amount'], 10); + expect((await database.select(database.transactions).get()).length, + txCount + 1); + pass('A09', + 'QA Cloud API 503 simulation: production UI saved expense locally; recovery synchronized it exactly once, retaining card balance -110'); + await home(); + await tap('明细'); + await capture('07-app-home-final'); + final finalAccounts = await repo.getAllAccounts(); + binding.reportData!.addAll({ + 'ledger_sync_id': ledger.syncId, + 'normal_ui_markers': ['QA 日元导入'], + 'final_accounts': finalAccounts + .map((a) => { + 'sync_id': a.syncId, + 'name': a.name, + 'type': a.type, + 'currency': a.currency, + 'initial_balance': a.initialBalance + }) + .toList(), + 'final_snapshot': await snapshot(), + 'tested_ui_navigation': + 'Production pages opened under MainApp; fixture navigation does not cover every menu entry', + }); + await prefs.setString('qa_run_id', runId); + }, timeout: const Timeout(Duration(minutes: 60))); +} diff --git a/lib/data/repositories/local/local_account_repository.dart b/lib/data/repositories/local/local_account_repository.dart index 8cb6c943f..a0de050c4 100644 --- a/lib/data/repositories/local/local_account_repository.dart +++ b/lib/data/repositories/local/local_account_repository.dart @@ -190,9 +190,9 @@ class LocalAccountRepository implements AccountRepository { type: type != null ? d.Value(type) : const d.Value.absent(), currency: currency != null ? d.Value(currency) : const d.Value.absent(), initialBalance: initialBalance != null ? d.Value(initialBalance) : const d.Value.absent(), - creditLimit: clearCreditCardFields ? const d.Value(null) : (creditLimit != null ? d.Value(creditLimit) : const d.Value.absent()), - billingDay: clearCreditCardFields ? const d.Value(null) : (billingDay != null ? d.Value(billingDay) : const d.Value.absent()), - paymentDueDay: clearCreditCardFields ? const d.Value(null) : (paymentDueDay != null ? d.Value(paymentDueDay) : const d.Value.absent()), + creditLimit: clearCreditCardFields ? d.Value(creditLimit) : (creditLimit != null ? d.Value(creditLimit) : const d.Value.absent()), + billingDay: clearCreditCardFields ? d.Value(billingDay) : (billingDay != null ? d.Value(billingDay) : const d.Value.absent()), + paymentDueDay: clearCreditCardFields ? d.Value(paymentDueDay) : (paymentDueDay != null ? d.Value(paymentDueDay) : const d.Value.absent()), bankName: clearMetadataFields ? const d.Value(null) : (bankName != null ? d.Value(bankName) : const d.Value.absent()), cardLastFour: clearMetadataFields ? const d.Value(null) : (cardLastFour != null ? d.Value(cardLastFour) : const d.Value.absent()), note: clearMetadataFields ? const d.Value(null) : (note != null ? d.Value(note) : const d.Value.absent()), @@ -810,7 +810,7 @@ class LocalAccountRepository implements AccountRepository { for (final account in accounts) { final balance = await getAccountBalance(account.id); - if (isAssetType(account.type)) { + if (isAssetBalance(account.type, balance)) { totalAssets += balance; } else { totalLiabilities += balance; @@ -834,7 +834,7 @@ class LocalAccountRepository implements AccountRepository { final currency = account.currency.toUpperCase(); final prev = result[currency] ?? (totalAssets: 0.0, totalLiabilities: 0.0, netWorth: 0.0); - if (isAssetType(account.type)) { + if (isAssetBalance(account.type, balance)) { result[currency] = ( totalAssets: prev.totalAssets + balance, totalLiabilities: prev.totalLiabilities, @@ -921,7 +921,7 @@ class LocalAccountRepository implements AccountRepository { final rate = ratesToBase[account.currency.toUpperCase()]; if (rate == null) continue; final bal = balances[dayIndex].balance * rate; - if (isAssetType(account.type)) { + if (isAssetBalance(account.type, bal)) { assets += bal; } else { liabilities += bal; @@ -942,7 +942,10 @@ class LocalAccountRepository implements AccountRepository { for (final account in accounts) { final balance = await getAccountBalance(account.id); - typeBalances.update(account.type, (v) => v + balance, ifAbsent: () => balance); + final type = positiveAssetCompositionType(account.type, balance) ?? + (isAssetType(account.type) && balance < 0 ? '_negative_assets_' : null); + if (type == null) continue; + typeBalances.update(type, (v) => v + balance, ifAbsent: () => balance); } return typeBalances.entries @@ -959,7 +962,10 @@ class LocalAccountRepository implements AccountRepository { for (final account in accounts) { final balance = await getAccountBalance(account.id); - final key = (type: account.type, currency: account.currency.toUpperCase()); + final type = positiveAssetCompositionType(account.type, balance) ?? + (isAssetType(account.type) && balance < 0 ? '_negative_assets_' : null); + if (type == null) continue; + final key = (type: type, currency: account.currency.toUpperCase()); balances.update(key, (v) => v + balance, ifAbsent: () => balance); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1d282b598..64d6aa0b8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3033,8 +3033,8 @@ "billingDay": "Billing Day", "paymentDueDay": "Payment Due Day", "creditUsed": "Used", - "creditAvailable": "Available", - "creditCardOwed": "Owed", + "creditAvailable": "Est. available", + "creditCardOwed": "Current debt", "dayOfMonth": "{day}th of each month", "@dayOfMonth": { "placeholders": { @@ -3877,5 +3877,19 @@ "placeholders": { "count": { "type": "int" } } - } + }, + "creditCardOverpayment": "Overpayment", + "creditCardDebtHint": "Enter your total outstanding debt as a positive amount; enter 0 if none.", + "creditCardOverpaymentHint": "Enter the positive amount remaining on the card after overpaying.", + "creditCardAmountInvalid": "Enter a valid amount; credit-card debt or overpayment must be non-negative.", + "creditCardReminderDueRequired": "Set a payment due day before enabling reminders.", + "creditCardEstimateHint": "Estimated from recorded transactions; check your bank for the actual available amount.", + "accountAllFlows": "All transactions", + "accountOutflows": "Expenses & transfers out", + "accountInflows": "Income & transfers in", + "positiveAssetBalance": "Positive asset balances", + "negativeAssetBalanceHint": "Negative account balances are deducted from total assets.", + "importAccountCurrencyPreview": "Confirm account currencies", + "importAccountCurrencyPreviewHint": "Accounts use statement currencies. Conflicting currencies are split without changing existing accounts. Missing currencies follow an existing account, an unambiguous account currency, or the ledger currency.", + "importCurrencyInvalid": "Invalid currency; correct this row:" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 95ee3ceff..9ad6c3b6a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -13175,13 +13175,13 @@ abstract class AppLocalizations { /// No description provided for @creditAvailable. /// /// In en, this message translates to: - /// **'Available'** + /// **'Est. available'** String get creditAvailable; /// No description provided for @creditCardOwed. /// /// In en, this message translates to: - /// **'Owed'** + /// **'Current debt'** String get creditCardOwed; /// No description provided for @dayOfMonth. @@ -16189,6 +16189,90 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Log entries: {count}'** String logExportCount(int count); + + /// No description provided for @creditCardOverpayment. + /// + /// In en, this message translates to: + /// **'Overpayment'** + String get creditCardOverpayment; + + /// No description provided for @creditCardDebtHint. + /// + /// In en, this message translates to: + /// **'Enter your total outstanding debt as a positive amount; enter 0 if none.'** + String get creditCardDebtHint; + + /// No description provided for @creditCardOverpaymentHint. + /// + /// In en, this message translates to: + /// **'Enter the positive amount remaining on the card after overpaying.'** + String get creditCardOverpaymentHint; + + /// No description provided for @creditCardAmountInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid amount; credit-card debt or overpayment must be non-negative.'** + String get creditCardAmountInvalid; + + /// No description provided for @creditCardReminderDueRequired. + /// + /// In en, this message translates to: + /// **'Set a payment due day before enabling reminders.'** + String get creditCardReminderDueRequired; + + /// No description provided for @creditCardEstimateHint. + /// + /// In en, this message translates to: + /// **'Estimated from recorded transactions; check your bank for the actual available amount.'** + String get creditCardEstimateHint; + + /// No description provided for @accountAllFlows. + /// + /// In en, this message translates to: + /// **'All transactions'** + String get accountAllFlows; + + /// No description provided for @accountOutflows. + /// + /// In en, this message translates to: + /// **'Expenses & transfers out'** + String get accountOutflows; + + /// No description provided for @accountInflows. + /// + /// In en, this message translates to: + /// **'Income & transfers in'** + String get accountInflows; + + /// No description provided for @positiveAssetBalance. + /// + /// In en, this message translates to: + /// **'Positive asset balances'** + String get positiveAssetBalance; + + /// No description provided for @negativeAssetBalanceHint. + /// + /// In en, this message translates to: + /// **'Negative account balances are deducted from total assets.'** + String get negativeAssetBalanceHint; + + /// No description provided for @importAccountCurrencyPreview. + /// + /// In en, this message translates to: + /// **'Confirm account currencies'** + String get importAccountCurrencyPreview; + + /// No description provided for @importAccountCurrencyPreviewHint. + /// + /// In en, this message translates to: + /// **'Accounts use statement currencies. Conflicting currencies are split without changing existing accounts. Missing currencies follow an existing account, an unambiguous account currency, or the ledger currency.'** + String get importAccountCurrencyPreviewHint; + + /// No description provided for @importCurrencyInvalid. + /// + /// In en, this message translates to: + /// **'Invalid currency; correct this row:'** + String get importCurrencyInvalid; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d7198db5f..99e55ba72 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -6918,10 +6918,10 @@ class AppLocalizationsEn extends AppLocalizations { String get creditUsed => 'Used'; @override - String get creditAvailable => 'Available'; + String get creditAvailable => 'Est. available'; @override - String get creditCardOwed => 'Owed'; + String get creditCardOwed => 'Current debt'; @override String dayOfMonth(int day) { @@ -8615,4 +8615,46 @@ class AppLocalizationsEn extends AppLocalizations { String logExportCount(int count) { return 'Log entries: $count'; } + + @override + String get creditCardOverpayment => 'Overpayment'; + + @override + String get creditCardDebtHint => 'Enter your total outstanding debt as a positive amount; enter 0 if none.'; + + @override + String get creditCardOverpaymentHint => 'Enter the positive amount remaining on the card after overpaying.'; + + @override + String get creditCardAmountInvalid => 'Enter a valid amount; credit-card debt or overpayment must be non-negative.'; + + @override + String get creditCardReminderDueRequired => 'Set a payment due day before enabling reminders.'; + + @override + String get creditCardEstimateHint => 'Estimated from recorded transactions; check your bank for the actual available amount.'; + + @override + String get accountAllFlows => 'All transactions'; + + @override + String get accountOutflows => 'Expenses & transfers out'; + + @override + String get accountInflows => 'Income & transfers in'; + + @override + String get positiveAssetBalance => 'Positive asset balances'; + + @override + String get negativeAssetBalanceHint => 'Negative account balances are deducted from total assets.'; + + @override + String get importAccountCurrencyPreview => 'Confirm account currencies'; + + @override + String get importAccountCurrencyPreviewHint => 'Accounts use statement currencies. Conflicting currencies are split without changing existing accounts. Missing currencies follow an existing account, an unambiguous account currency, or the ledger currency.'; + + @override + String get importCurrencyInvalid => 'Invalid currency; correct this row:'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 618e0f1c2..b7eee4ed3 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -8615,4 +8615,46 @@ class AppLocalizationsKo extends AppLocalizations { String logExportCount(int count) { return 'Log entries: $count'; } + + @override + String get creditCardOverpayment => 'Overpayment'; + + @override + String get creditCardDebtHint => 'Enter your total outstanding debt as a positive amount; enter 0 if none.'; + + @override + String get creditCardOverpaymentHint => 'Enter the positive amount remaining on the card after overpaying.'; + + @override + String get creditCardAmountInvalid => 'Enter a valid amount; credit-card debt or overpayment must be non-negative.'; + + @override + String get creditCardReminderDueRequired => 'Set a payment due day before enabling reminders.'; + + @override + String get creditCardEstimateHint => 'Estimated from recorded transactions; check your bank for the actual available amount.'; + + @override + String get accountAllFlows => 'All transactions'; + + @override + String get accountOutflows => 'Expenses & transfers out'; + + @override + String get accountInflows => 'Income & transfers in'; + + @override + String get positiveAssetBalance => 'Positive asset balances'; + + @override + String get negativeAssetBalanceHint => 'Negative account balances are deducted from total assets.'; + + @override + String get importAccountCurrencyPreview => 'Confirm account currencies'; + + @override + String get importAccountCurrencyPreviewHint => 'Accounts use statement currencies. Conflicting currencies are split without changing existing accounts. Missing currencies follow an existing account, an unambiguous account currency, or the ledger currency.'; + + @override + String get importCurrencyInvalid => 'Invalid currency; correct this row:'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9908beb28..9ca8606d8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -6912,10 +6912,10 @@ class AppLocalizationsZh extends AppLocalizations { String get creditUsed => '已用额度'; @override - String get creditAvailable => '可用额度'; + String get creditAvailable => '估算可用'; @override - String get creditCardOwed => '待还款'; + String get creditCardOwed => '当前欠款'; @override String dayOfMonth(int day) { @@ -8607,6 +8607,48 @@ class AppLocalizationsZh extends AppLocalizations { String logExportCount(int count) { return '日志数量: $count'; } + + @override + String get creditCardOverpayment => '溢缴款'; + + @override + String get creditCardDebtHint => '填写当前总欠款,输入正数;没有欠款填 0。'; + + @override + String get creditCardOverpaymentHint => '填写多还后留在信用卡里的金额,输入正数。'; + + @override + String get creditCardAmountInvalid => '请输入有效金额;信用卡欠款或溢缴款请填写非负数。'; + + @override + String get creditCardReminderDueRequired => '开启提醒前,请填写还款日。'; + + @override + String get creditCardEstimateHint => '按已记流水估算,实际可用金额以银行为准。'; + + @override + String get accountAllFlows => '全部流水'; + + @override + String get accountOutflows => '支出与转出'; + + @override + String get accountInflows => '收入与转入'; + + @override + String get positiveAssetBalance => '正余额资产'; + + @override + String get negativeAssetBalanceHint => '账户负余额已从总资产扣减。'; + + @override + String get importAccountCurrencyPreview => '确认账户币种'; + + @override + String get importAccountCurrencyPreviewHint => '根据账单币种建立或复用账户;同名多币种及已有账户冲突会按币种拆分,不修改旧账户。缺失币种继承已有账户,否则使用可确定的账户币种或账本币种。'; + + @override + String get importCurrencyInvalid => '币种无效,请修正对应行:'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4a17f33d8..82ac89d87 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2963,8 +2963,8 @@ "billingDay": "账单日", "paymentDueDay": "还款日", "creditUsed": "已用额度", - "creditAvailable": "可用额度", - "creditCardOwed": "待还款", + "creditAvailable": "估算可用", + "creditCardOwed": "当前欠款", "dayOfMonth": "每月{day}日", "@dayOfMonth": { "placeholders": { @@ -3584,5 +3584,19 @@ "syncHealthLedgerMissing": "本地找不到 ledger={id}", "logExportTitle": "=== BeeCount 日志导出 ===", "logExportTime": "导出时间: {time}", - "logExportCount": "日志数量: {count}" + "logExportCount": "日志数量: {count}", + "creditCardOverpayment": "溢缴款", + "creditCardDebtHint": "填写当前总欠款,输入正数;没有欠款填 0。", + "creditCardOverpaymentHint": "填写多还后留在信用卡里的金额,输入正数。", + "creditCardAmountInvalid": "请输入有效金额;信用卡欠款或溢缴款请填写非负数。", + "creditCardReminderDueRequired": "开启提醒前,请填写还款日。", + "creditCardEstimateHint": "按已记流水估算,实际可用金额以银行为准。", + "accountAllFlows": "全部流水", + "accountOutflows": "支出与转出", + "accountInflows": "收入与转入", + "positiveAssetBalance": "正余额资产", + "negativeAssetBalanceHint": "账户负余额已从总资产扣减。", + "importAccountCurrencyPreview": "确认账户币种", + "importAccountCurrencyPreviewHint": "根据账单币种建立或复用账户;同名多币种及已有账户冲突会按币种拆分,不修改旧账户。缺失币种继承已有账户,否则使用可确定的账户币种或账本币种。", + "importCurrencyInvalid": "币种无效,请修正对应行:" } diff --git a/lib/pages/account/account_detail_page.dart b/lib/pages/account/account_detail_page.dart index 9ecf07cb3..b192e8a92 100644 --- a/lib/pages/account/account_detail_page.dart +++ b/lib/pages/account/account_detail_page.dart @@ -12,6 +12,7 @@ import '../../utils/transaction_edit_utils.dart'; import '../../utils/currencies.dart'; import '../../widgets/category_icon.dart'; import '../../utils/account_type_utils.dart'; +import '../../utils/credit_card_amounts.dart'; import '../../widgets/charts/account_category_pie_chart.dart'; import '../transaction/transaction_editor_page.dart'; import 'account_edit_page.dart'; @@ -64,13 +65,14 @@ class AccountTransactionsPaginationNotifier final repo = ref.read(repositoryProvider); final transactions = await repo.getAccountTransactions( accountId, limit: _pageSize, offset: 0, flow: flow); + if (!mounted) return; state = AccountTransactionsPaginationState( transactions: transactions, isLoading: false, hasMore: transactions.length >= _pageSize, ); } catch (_) { - state = state.copyWith(isLoading: false); + if (mounted) state = state.copyWith(isLoading: false); } } @@ -85,13 +87,14 @@ class AccountTransactionsPaginationNotifier offset: state.transactions.length, flow: flow, ); + if (!mounted) return; state = state.copyWith( transactions: [...state.transactions, ...transactions], isLoading: false, hasMore: transactions.length >= _pageSize, ); } catch (_) { - state = state.copyWith(isLoading: false); + if (mounted) state = state.copyWith(isLoading: false); } } @@ -101,13 +104,14 @@ class AccountTransactionsPaginationNotifier final repo = ref.read(repositoryProvider); final transactions = await repo.getAccountTransactions( accountId, limit: _pageSize, offset: 0, flow: flow); + if (!mounted) return; state = AccountTransactionsPaginationState( transactions: transactions, isLoading: false, hasMore: transactions.length >= _pageSize, ); } catch (_) { - state = state.copyWith(isLoading: false); + if (mounted) state = state.copyWith(isLoading: false); } } } @@ -162,12 +166,8 @@ class _AccountDetailPageState extends ConsumerState { super.dispose(); } - /// 列表的资金流向过滤,跟随图表 tab:支出=支出+转出,收入=收入+转入。 - /// 信用卡没有 tab 切换,保持展示全部交易(消费+还款转账)。 - String? get _listFlow { - if (widget.account.type == 'credit_card') return null; - return _detailChartTab == 0 ? 'expense' : 'income'; - } + String? _listFlow; + void _onScroll() { if (_scrollController.position.pixels >= @@ -214,6 +214,7 @@ class _AccountDetailPageState extends ConsumerState { compact: true, actions: [ IconButton( + tooltip: l10n.accountEditTitle, icon: Icon( Icons.edit_outlined, color: BeeTokens.iconPrimary(context), @@ -273,6 +274,21 @@ class _AccountDetailPageState extends ConsumerState { SizedBox(height: 12.0.scaled(context, ref)), + Padding( + padding: EdgeInsets.symmetric( + horizontal: BeeDimens.p12.scaled(context, ref)), + child: CapsuleSwitcher( + selectedValue: _listFlow, + height: 44.0.scaled(context, ref), + options: [ + CapsuleOption(value: null, label: l10n.accountAllFlows), + CapsuleOption(value: 'expense', label: l10n.accountOutflows), + CapsuleOption(value: 'income', label: l10n.accountInflows), + ], + onChanged: (flow) => setState(() => _listFlow = flow), + ), + ), + SizedBox(height: BeeDimens.p8.scaled(context, ref)), // 交易列表(分页) _buildTransactionList( context, @@ -629,7 +645,8 @@ class _AccountDetailPageState extends ConsumerState { ) { if (account.type == 'credit_card') { final creditLimit = account.creditLimit; - final usedAmount = stats.balance < 0 ? -stats.balance : 0.0; + final amounts = CreditCardAmounts(stats.balance, limit: creditLimit); + final usedAmount = amounts.debt; final usageRate = (creditLimit != null && creditLimit > 0) ? (usedAmount / creditLimit).clamp(0.0, 1.0) : 0.0; @@ -657,12 +674,12 @@ class _AccountDetailPageState extends ConsumerState { Row( children: [ Text( - l10n.creditCardOwed, + amounts.overpayment > 0 ? l10n.creditCardOverpayment : l10n.creditCardOwed, style: TextStyle(fontSize: 13, color: BeeTokens.textSecondary(context)), ), const Spacer(), AmountText( - value: usedAmount, + value: amounts.overpayment > 0 ? amounts.overpayment : usedAmount, signed: false, showCurrency: false, useCompactFormat: ref.watch(compactAmountProvider), @@ -676,13 +693,12 @@ class _AccountDetailPageState extends ConsumerState { ], ), // 额度/已用/可用 + 进度条 —— 仅设过额度时 - if (creditLimit != null) ...[ + if (amounts.estimatedAvailable != null) ...[ SizedBox(height: 12.0.scaled(context, ref)), Row( children: [ - Expanded(child: _OverviewStatCell(label: l10n.creditLimit, value: creditLimit)), - Expanded(child: _OverviewStatCell(label: l10n.creditUsed, value: usedAmount)), - Expanded(child: _OverviewStatCell(label: l10n.creditAvailable, value: creditLimit - usedAmount)), + Expanded(child: _OverviewStatCell(label: l10n.creditLimit, value: creditLimit!)), + Expanded(child: _OverviewStatCell(label: l10n.creditAvailable, value: amounts.estimatedAvailable!)), ], ), SizedBox(height: 8.0.scaled(context, ref)), @@ -700,6 +716,11 @@ class _AccountDetailPageState extends ConsumerState { ), ), ], + if (amounts.estimatedAvailable != null) Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(l10n.creditCardEstimateHint, + style: TextStyle(fontSize: 11, color: BeeTokens.textTertiary(context))), + ), // 账单日/还款日信息 if (hasBillingInfo || account.paymentDueDay != null) ...[ SizedBox(height: 10.0.scaled(context, ref)), @@ -972,6 +993,7 @@ class _AccountDetailPageState extends ConsumerState { children: [ if (index > 0) BeeTokens.cardDivider(context), _TransactionTile( + key: ValueKey('account-transaction-${tx.id}'), transaction: tx, currencyCode: currencyCode, primaryColor: primaryColor, @@ -1041,13 +1063,13 @@ class _AccountDetailPageState extends ConsumerState { (accountId: widget.account.id, type: 'income'))); } - void _quickTransfer( + Future _quickTransfer( BuildContext context, db.Account account, { int? toAccountId, int? fromAccountId, - }) { - Navigator.push( + }) async { + await Navigator.push( context, MaterialPageRoute( builder: (_) => TransactionEditorPage( @@ -1058,6 +1080,10 @@ class _AccountDetailPageState extends ConsumerState { ), ), ); + if (!mounted) return; + ref.invalidate(accountStatsProvider(widget.account.id)); + ref.invalidate(accountTransactionsPaginatedProvider); + ref.invalidate(accountCategoryStatsProvider); } } @@ -1210,6 +1236,7 @@ class _TransactionTile extends ConsumerWidget { final int? currentAccountId; const _TransactionTile({ + super.key, required this.transaction, required this.currencyCode, required this.primaryColor, @@ -1240,9 +1267,8 @@ class _TransactionTile extends ConsumerWidget { amountColor = BeeTokens.expenseColor(context, ref); break; case 'transfer': - amountColor = isTransferOut - ? BeeTokens.expenseColor(context, ref) - : BeeTokens.incomeColor(context, ref); + // 正负号说明当前账户的资金方向,转账本身不属于收入或支出。 + amountColor = BeeTokens.textPrimary(context); break; default: amountColor = BeeTokens.textPrimary(context); @@ -1259,30 +1285,20 @@ class _TransactionTile extends ConsumerWidget { String displayTitle; String? displaySubtitle; - String? noteSuffix; // 非转账时,备注接在分类名后面(对齐首页) + String? noteSuffix; // 备注接在分类或转账名称后面。 if (transaction.type == 'transfer') { - if (transaction.note?.isNotEmpty == true) { - displayTitle = transaction.note!; - } else { - displayTitle = l10n.transferTitle; - } - - if (isTransferOut && transaction.toAccountId != null) { - final toAccountAsync = - ref.watch(accountByIdProvider(transaction.toAccountId!)); - final toAccountName = toAccountAsync.value?.name; - if (toAccountName != null) { - displaySubtitle = '${l10n.transferToPrefix} $toAccountName'; - } - } else if (isTransferIn && transaction.accountId != null) { - final fromAccountAsync = - ref.watch(accountByIdProvider(transaction.accountId!)); - final fromAccountName = fromAccountAsync.value?.name; - if (fromAccountName != null) { - displaySubtitle = '${l10n.transferFromPrefix} $fromAccountName'; - } + displayTitle = l10n.transferTitle; + if (transaction.note?.isNotEmpty == true && transaction.note != displayTitle) { + noteSuffix = transaction.note; } + final fromName = transaction.accountId == null + ? null + : ref.watch(accountByIdProvider(transaction.accountId!)).value?.name; + final toName = transaction.toAccountId == null + ? null + : ref.watch(accountByIdProvider(transaction.toAccountId!)).value?.name; + displaySubtitle = '${fromName ?? '-'} → ${toName ?? '-'}'; } else { // 分类名常驻,备注接在分类名后面(对齐首页 / TransactionListItem) displayTitle = category != null @@ -1356,7 +1372,8 @@ class _TransactionTile extends ConsumerWidget { ), if (ledgerName.isNotEmpty) ...[ SizedBox(width: 6.0.scaled(context, ref)), - Container( + Flexible( + child: Container( padding: EdgeInsets.symmetric( horizontal: 6.0.scaled(context, ref), vertical: 2.0.scaled(context, ref), @@ -1368,12 +1385,15 @@ class _TransactionTile extends ConsumerWidget { ), child: Text( ledgerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, color: primaryColor, fontWeight: FontWeight.w500, ), ), + ), ), ], ], @@ -1408,11 +1428,12 @@ class _TransactionTile extends ConsumerWidget { value: transaction.type == 'expense' ? -transaction.amount : transaction.type == 'transfer' - ? (isTransferOut + ? (isTransferOut && !isTransferIn ? -transaction.amount : transaction.amount) : transaction.amount, - signed: true, + signed: transaction.type != 'transfer' || + (isTransferOut != isTransferIn), showCurrency: false, currencyCode: currencyCode, style: TextStyle( diff --git a/lib/pages/account/account_edit_page.dart b/lib/pages/account/account_edit_page.dart index e6452b01e..1396ce699 100644 --- a/lib/pages/account/account_edit_page.dart +++ b/lib/pages/account/account_edit_page.dart @@ -44,6 +44,8 @@ class _AccountEditPageState extends ConsumerState { bool _saving = false; bool _balanceLoaded = true; bool _balanceLoadFailed = false; + bool _creditOverpayment = false; + double? _loadedCurrentBalance; bool _isNameDuplicate = false; String? _nameErrorText; // 账户类型 Tab:0 = 日常账户,1 = 估值账户 @@ -107,7 +109,10 @@ class _AccountEditPageState extends ConsumerState { .getAccountBalance(widget.account!.id); if (!mounted) return; setState(() { - _initialBalanceController.text = balance.toStringAsFixed(2); + _loadedCurrentBalance = balance; + _creditOverpayment = balance > 0; + _initialBalanceController.text = (_selectedType == 'credit_card' + ? balance.abs() : balance).toStringAsFixed(2); _balanceLoaded = true; }); } catch (error) { @@ -144,7 +149,21 @@ class _AccountEditPageState extends ConsumerState { bool get isEditing => widget.account != null; + String? _validateBalance(String? value) { + final text = value?.trim() ?? ''; + if (text.isEmpty && !isEditing) return null; + final parsed = double.tryParse(text); + if (parsed == null || !parsed.isFinite || + (_selectedType == 'credit_card' && parsed < 0)) { + return AppLocalizations.of(context).creditCardAmountInvalid; + } + return null; + } + String _getInitialBalanceLabel(AppLocalizations l10n) { + if (_selectedType == 'credit_card') { + return _creditOverpayment ? l10n.creditCardOverpayment : l10n.creditCardOwed; + } if (isValuationOnlyType(_selectedType)) { return isLiabilityType(_selectedType) ? l10n.valuationCurrentDebt @@ -161,7 +180,8 @@ class _AccountEditPageState extends ConsumerState { } switch (_selectedType) { case 'credit_card': - return l10n.creditCardInitialBalanceHint; + return _creditOverpayment + ? l10n.creditCardOverpaymentHint : l10n.creditCardDebtHint; default: return l10n.accountInitialBalanceHint; } @@ -417,6 +437,16 @@ class _AccountEditPageState extends ConsumerState { void _selectType(String type) { setState(() { final oldType = _selectedType; + if (oldType != type && (oldType == 'credit_card' || type == 'credit_card')) { + final parsed = double.tryParse(_initialBalanceController.text); + if (parsed != null) { + final signed = oldType == 'credit_card' + ? (_creditOverpayment ? parsed : -parsed) : parsed; + _creditOverpayment = signed > 0; + _initialBalanceController.text = + (type == 'credit_card' ? signed.abs() : signed).toStringAsFixed(2); + } + } _selectedType = type; if (oldType == 'credit_card' && type != 'credit_card') { _creditLimitController.clear(); @@ -439,37 +469,6 @@ class _AccountEditPageState extends ConsumerState { color: BeeTokens.textPrimary(context), ); - /// 资产/负债 分段标签 - Widget _segTab(BuildContext context, - {required String label, - required bool selected, - required Color primaryColor, - required VoidCallback onTap}) { - return Expanded( - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: EdgeInsets.symmetric(vertical: 8.0.scaled(context, ref)), - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected ? BeeTokens.surfaceElevated(context) : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - color: selected ? primaryColor : BeeTokens.textSecondary(context), - ), - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -505,7 +504,7 @@ class _AccountEditPageState extends ConsumerState { MediaQuery.of(context).padding.bottom, ), children: [ - // ===== 账户类型(资产/负债 Tab + 缩小网格)===== + // ===== 账户类型(日常/估值 Tab + 缩小网格)===== SectionCard( margin: EdgeInsets.zero, child: Padding( @@ -513,26 +512,15 @@ class _AccountEditPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: BeeTokens.surfaceInput(context), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - _segTab(context, - label: l10n.accountGroupTradable, - selected: _typeTab == 0, - primaryColor: primaryColor, - onTap: () => setState(() => _typeTab = 0)), - _segTab(context, - label: l10n.accountTabValuation, - selected: _typeTab == 1, - primaryColor: primaryColor, - onTap: () => setState(() => _typeTab = 1)), - ], - ), + CapsuleSwitcher( + key: const ValueKey('account-type-group'), + selectedValue: _typeTab, + height: 44.0.scaled(context, ref), + options: [ + CapsuleOption(value: 0, label: l10n.accountGroupTradable), + CapsuleOption(value: 1, label: l10n.accountTabValuation), + ], + onChanged: (value) => setState(() => _typeTab = value), ), SizedBox(height: 16.0.scaled(context, ref)), GridView.count( @@ -574,6 +562,7 @@ class _AccountEditPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( + key: const ValueKey('account-name'), controller: _nameController, decoration: filledDec( label: l10n.accountNameLabel, @@ -649,6 +638,7 @@ class _AccountEditPageState extends ConsumerState { if (!isEditing) Expanded( child: TextFormField( + key: const ValueKey('account-balance-amount'), controller: _initialBalanceController, decoration: filledDec( label: _getInitialBalanceLabel(l10n), @@ -660,25 +650,20 @@ class _AccountEditPageState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true), - validator: (value) { - if (value != null && value.trim().isNotEmpty) { - if (double.tryParse(value.trim()) == null) { - return '请输入有效的金额'; - } - } - return null; - }, + validator: _validateBalance, ), ) else if (!isValuationOnlyType(widget.account!.type) && !isValuationOnlyType(_selectedType)) Expanded( child: TextFormField( + key: const ValueKey('account-balance-amount'), controller: _initialBalanceController, readOnly: !_balanceLoaded || _balanceLoadFailed, decoration: filledDec( - label: l10n.accountBalance, + label: _selectedType == 'credit_card' + ? _getInitialBalanceLabel(l10n) : l10n.accountBalance, prefix: '${getCurrencySymbol(_selectedCurrency)} ', ).copyWith( @@ -706,15 +691,7 @@ class _AccountEditPageState extends ConsumerState { decimal: true, signed: true, ), - validator: (value) { - final parsed = value == null - ? null - : double.tryParse(value.trim()); - if (parsed == null || !parsed.isFinite) { - return '请输入有效的金额'; - } - return null; - }, + validator: _validateBalance, ), ) else @@ -726,6 +703,28 @@ class _AccountEditPageState extends ConsumerState { ), ), + if (isCreditCard) Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CapsuleSwitcher( + key: const ValueKey('account-credit-balance-kind'), + selectedValue: _creditOverpayment, + height: 44.0.scaled(context, ref), + options: [ + CapsuleOption(value: false, label: l10n.creditCardOwed), + CapsuleOption(value: true, label: l10n.creditCardOverpayment), + ], + onChanged: (value) => + setState(() => _creditOverpayment = value), + ), + const SizedBox(height: 8), + Text(_getInitialBalanceHint(l10n), + style: TextStyle(fontSize: 12, color: BeeTokens.textSecondary(context))), + ], + ), + ), // ===== 信用卡信息(仅 credit_card)===== if (isCreditCard) ...[ SizedBox(height: 8.0.scaled(context, ref)), @@ -740,9 +739,10 @@ class _AccountEditPageState extends ConsumerState { SizedBox(height: 12.0.scaled(context, ref)), // 信用额度(必填) TextFormField( + key: const ValueKey('account-credit-limit'), controller: _creditLimitController, decoration: filledDec( - label: '${l10n.creditLimit} *', + label: l10n.creditLimit, hint: l10n.creditLimitHint, prefix: '${getCurrencySymbol(_selectedCurrency)} ', ), @@ -752,7 +752,8 @@ class _AccountEditPageState extends ConsumerState { validator: (value) { final t = value?.trim() ?? ''; final parsed = double.tryParse(t); - if (t.isEmpty || parsed == null || parsed <= 0) { + if (t.isEmpty) return null; + if (parsed == null || !parsed.isFinite || parsed <= 0) { return l10n.creditLimitHint; } return null; @@ -764,7 +765,7 @@ class _AccountEditPageState extends ConsumerState { children: [ Expanded( child: _DayPickerTile( - label: '${l10n.billingDay} *', + label: l10n.billingDay, value: _billingDay, primaryColor: primaryColor, onChanged: (day) => @@ -774,7 +775,7 @@ class _AccountEditPageState extends ConsumerState { SizedBox(width: 12.0.scaled(context, ref)), Expanded( child: _DayPickerTile( - label: '${l10n.paymentDueDay} *', + label: l10n.paymentDueDay, value: _paymentDueDay, primaryColor: primaryColor, onChanged: (day) => @@ -840,29 +841,17 @@ class _AccountEditPageState extends ConsumerState { ), if (_reminderEnabled) ...[ SizedBox(height: 4.0.scaled(context, ref)), - Wrap( - spacing: 8.0.scaled(context, ref), - children: [1, 3, 5, 7].map((days) { - final isSelected = _reminderDaysBefore == days; - return ChoiceChip( - label: Text( - l10n.creditCardReminderDaysBefore(days)), - selected: isSelected, - selectedColor: - primaryColor.withValues(alpha: 0.15), - labelStyle: TextStyle( - fontSize: 12, - color: isSelected - ? primaryColor - : BeeTokens.textSecondary(context), - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - ), - onSelected: (_) => setState( - () => _reminderDaysBefore = days), - ); - }).toList(), + CapsuleSwitcher( + key: const ValueKey('account-credit-reminder-days'), + selectedValue: _reminderDaysBefore, + height: 44.0.scaled(context, ref), + options: [ + for (final days in [1, 3, 5, 7]) + CapsuleOption(value: days, + label: l10n.creditCardReminderDaysBefore(days)), + ], + onChanged: (days) => setState( + () => _reminderDaysBefore = days), ), ], ], @@ -1040,10 +1029,8 @@ class _AccountEditPageState extends ConsumerState { if (!_balanceLoaded || _balanceLoadFailed) return; if (!_formKey.currentState!.validate()) return; - // 信用卡:账单日 / 还款日必填(额度由表单 validator 拦截) - if (_selectedType == 'credit_card' && - (_billingDay == null || _paymentDueDay == null)) { - showToast(context, AppLocalizations.of(context).creditCardDaysRequired); + if (_selectedType == 'credit_card' && _reminderEnabled && _paymentDueDay == null) { + showToast(context, AppLocalizations.of(context).creditCardReminderDueRequired); return; } @@ -1056,6 +1043,10 @@ class _AccountEditPageState extends ConsumerState { final enteredBalance = initialBalanceText.isEmpty ? 0.0 : double.parse(initialBalanceText); var initialBalance = enteredBalance; + if (_selectedType == 'credit_card') { + initialBalance = enteredBalance == 0 ? 0 : + (_creditOverpayment ? enteredBalance : -enteredBalance); + } // 贷款类型:用户输入正数,存储为负数 if (_selectedType == 'loan' && initialBalance > 0) { @@ -1092,13 +1083,14 @@ class _AccountEditPageState extends ConsumerState { var balanceNeedsUpdate = false; var createBalanceAdjustment = false; - final targetBalance = enteredBalance; + final targetBalance = initialBalance; if (!isValuationOnlyType(widget.account!.type) && !isValuationOnlyType(_selectedType)) { final currentBalance = await repo.getAccountBalance(widget.account!.id); if (!mounted) return; - if ((targetBalance - currentBalance).abs() >= 0.0000001) { + // Metadata saves must not overwrite transactions added meanwhile. + if ((targetBalance - (_loadedCurrentBalance ?? currentBalance)).abs() >= 0.0000001) { final choice = await _promptBalanceAdjustmentChoice( currentBalance: currentBalance, targetBalance: targetBalance, @@ -1112,7 +1104,7 @@ class _AccountEditPageState extends ConsumerState { // 如果从信用卡切换到其他类型,清空信用卡字段 final wasCreditCard = widget.account!.type == 'credit_card'; - final clearCreditCardFields = wasCreditCard && !isCreditCard; + final clearCreditCardFields = wasCreditCard; // 元信息字段 final isBankOrCredit = _selectedType == 'bank_card' || _selectedType == 'credit_card'; @@ -1754,6 +1746,11 @@ class _DayPickerTile extends ConsumerWidget { ), ), ), + if (hasValue) IconButton( + tooltip: l10n.commonRemove, + icon: const Icon(Icons.close, size: 16), + onPressed: () => onChanged(null), + ), Icon(Icons.expand_more, size: 18.0.scaled(context, ref), color: BeeTokens.iconTertiary(context)), diff --git a/lib/pages/account/accounts_page.dart b/lib/pages/account/accounts_page.dart index 73aebec03..11682cd80 100644 --- a/lib/pages/account/accounts_page.dart +++ b/lib/pages/account/accounts_page.dart @@ -18,6 +18,7 @@ import '../../l10n/app_localizations.dart'; import '../../styles/tokens.dart'; import '../../utils/ui_scale_extensions.dart'; import '../../utils/account_type_utils.dart'; +import '../../utils/credit_card_amounts.dart'; import '../../utils/currencies.dart'; import '../../widgets/charts/asset_composition_chart.dart'; import '../../widgets/charts/line_chart.dart'; @@ -2240,18 +2241,19 @@ class _AccountCard extends ConsumerWidget { } Widget _buildCreditCardStats(BuildContext context, WidgetRef ref, AppLocalizations l10n, bool isDark) { - final used = stats!.balance < 0 ? -stats!.balance : 0.0; + final amounts = CreditCardAmounts(stats!.balance, limit: account.creditLimit); + final used = amounts.debt; final textColor = isDark ? Colors.white.withValues(alpha: 0.9) : Colors.white; final labelColor = isDark ? Colors.white.withValues(alpha: 0.6) : Colors.white.withValues(alpha: 0.8); // 信用卡按 type 判定;无额度时仅显示当前欠款,不再 fallthrough 到收入/支出卡 final creditLimit = account.creditLimit; - if (creditLimit == null) { + if (creditLimit == null || creditLimit <= 0) { return Align( alignment: Alignment.centerLeft, child: _CardStat( - label: l10n.creditCardOwed, - value: used, + label: amounts.overpayment > 0 ? l10n.creditCardOverpayment : l10n.creditCardOwed, + value: amounts.overpayment > 0 ? amounts.overpayment : used, textColor: textColor, labelColor: labelColor, ref: ref, @@ -2295,8 +2297,8 @@ class _AccountCard extends ConsumerWidget { ), Expanded( child: _CardStat( - label: l10n.creditUsed, - value: used, + label: amounts.overpayment > 0 ? l10n.creditCardOverpayment : l10n.creditCardOwed, + value: amounts.overpayment > 0 ? amounts.overpayment : used, textColor: textColor, labelColor: labelColor, ref: ref, @@ -2311,7 +2313,7 @@ class _AccountCard extends ConsumerWidget { Expanded( child: _CardStat( label: l10n.creditAvailable, - value: creditLimit - used, + value: amounts.estimatedAvailable ?? 0, textColor: textColor, labelColor: labelColor, ref: ref, diff --git a/lib/pages/data/import_confirm_page.dart b/lib/pages/data/import_confirm_page.dart index 9ec666ffc..40897da7b 100644 --- a/lib/pages/data/import_confirm_page.dart +++ b/lib/pages/data/import_confirm_page.dart @@ -13,6 +13,7 @@ import '../../services/import/parsers/alipay_parser.dart'; import '../../services/import/parsers/wechat_parser.dart'; import '../../services/billing/post_processor.dart'; import '../../services/data_import_service.dart'; +import '../../services/import/account_currency_plan.dart'; import '../../utils/date_parser.dart'; import '../../styles/tokens.dart'; import 'import_page.dart'; @@ -120,7 +121,7 @@ class _ImportConfirmPageState extends ConsumerState { body: Column( children: [ PrimaryHeader( - title: AppLocalizations.of(context)!.importPreparing, + title: AppLocalizations.of(context).importPreparing, showBack: true), Expanded( child: Center( @@ -141,7 +142,7 @@ class _ImportConfirmPageState extends ConsumerState { i < header.length && header[i].trim().isNotEmpty) ? header[i].trim() - : AppLocalizations.of(context)!.importColumnNumber(i + 1); + : AppLocalizations.of(context).importColumnNumber(i + 1); return DropdownMenuItem( value: i, child: Text(label, overflow: TextOverflow.ellipsis)); }); @@ -152,8 +153,8 @@ class _ImportConfirmPageState extends ConsumerState { children: [ PrimaryHeader( title: step == 0 - ? AppLocalizations.of(context)!.importConfirmMapping - : AppLocalizations.of(context)!.importCategoryMapping, + ? AppLocalizations.of(context).importConfirmMapping + : AppLocalizations.of(context).importCategoryMapping, showBack: true), Expanded( child: ListView( @@ -161,40 +162,40 @@ class _ImportConfirmPageState extends ConsumerState { children: [ if (step == 0) ...[ if (rows.isEmpty) - Text(AppLocalizations.of(context)!.importNoDataParsed), + Text(AppLocalizations.of(context).importNoDataParsed), Wrap( spacing: 12, runSpacing: 8, children: [ - _mapRow(AppLocalizations.of(context)!.importFieldDate, + _mapRow(AppLocalizations.of(context).importFieldDate, 'date', items()), - _mapRow(AppLocalizations.of(context)!.importFieldType, + _mapRow(AppLocalizations.of(context).importFieldType, 'type', items()), - _mapRow(AppLocalizations.of(context)!.importFieldAmount, + _mapRow(AppLocalizations.of(context).importFieldAmount, 'amount', items()), - _mapRow(AppLocalizations.of(context)!.importFieldCurrency, + _mapRow(AppLocalizations.of(context).importFieldCurrency, 'currency', items()), - _mapRow(AppLocalizations.of(context)!.importFieldCategory, + _mapRow(AppLocalizations.of(context).importFieldCategory, 'category', items()), - _mapRow(AppLocalizations.of(context)!.exportCsvHeaderSubCategory, + _mapRow(AppLocalizations.of(context).exportCsvHeaderSubCategory, 'sub_category', items()), - _mapRow(AppLocalizations.of(context)!.importFieldAccount, + _mapRow(AppLocalizations.of(context).importFieldAccount, 'account', items()), - _mapRow(AppLocalizations.of(context)!.exportCsvHeaderFromAccount, + _mapRow(AppLocalizations.of(context).exportCsvHeaderFromAccount, 'from_account', items()), - _mapRow(AppLocalizations.of(context)!.exportCsvHeaderToAccount, + _mapRow(AppLocalizations.of(context).exportCsvHeaderToAccount, 'to_account', items()), - _mapRow(AppLocalizations.of(context)!.importFieldNote, + _mapRow(AppLocalizations.of(context).importFieldNote, 'note', items()), - _mapRow(AppLocalizations.of(context)!.exportCsvHeaderTags, + _mapRow(AppLocalizations.of(context).exportCsvHeaderTags, 'tags', items()), - _mapRow(AppLocalizations.of(context)!.exportCsvHeaderAttachments, + _mapRow(AppLocalizations.of(context).exportCsvHeaderAttachments, 'attachments', items()), ], ), const SizedBox(height: 12), // 预览仅展示前 N 行,避免大文件一次性渲染导致卡顿 - Text(AppLocalizations.of(context)!.importPreview, + Text(AppLocalizations.of(context).importPreview, style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 6), SizedBox( @@ -227,7 +228,7 @@ class _ImportConfirmPageState extends ConsumerState { Padding( padding: const EdgeInsets.only(top: 6.0), child: Text( - AppLocalizations.of(context)! + AppLocalizations.of(context) .importPreviewLimit( limited.length, totalRows), style: Theme.of(context) @@ -243,16 +244,16 @@ class _ImportConfirmPageState extends ConsumerState { ), ] else ...[ if (mapping['category'] == null) - Text(AppLocalizations.of(context)! + Text(AppLocalizations.of(context) .importCategoryNotSelected), - Text(AppLocalizations.of(context)! + Text(AppLocalizations.of(context) .importCategoryMappingDescription), const SizedBox(height: 8), FutureBuilder>( future: allCategoriesFuture, builder: (context, snap) { final cats = snap.data ?? []; - final l10n = AppLocalizations.of(context)!; + final l10n = AppLocalizations.of(context); final items = >[ DropdownMenuItem( value: null, @@ -324,7 +325,7 @@ class _ImportConfirmPageState extends ConsumerState { children: [ if (importing) Text( - AppLocalizations.of(context)!.importProgress(ok, fail)), + AppLocalizations.of(context).importProgress(ok, fail)), const Spacer(), if (step == 0) FilledButton( @@ -338,7 +339,7 @@ class _ImportConfirmPageState extends ConsumerState { } else { showToast( context, - AppLocalizations.of(context)! + AppLocalizations.of(context) .importSelectCategoryFirst); } return; @@ -346,20 +347,20 @@ class _ImportConfirmPageState extends ConsumerState { _buildDistinctCategories(); setState(() => step = 1); }, - child: Text(AppLocalizations.of(context)!.importNextStep), + child: Text(AppLocalizations.of(context).importNextStep), ) else ...[ OutlinedButton( onPressed: importing ? null : () => setState(() => step = 0), child: Text( - AppLocalizations.of(context)!.importPreviousStep), + AppLocalizations.of(context).importPreviousStep), ), const SizedBox(width: 12), FilledButton( onPressed: importing ? null : _startImport, child: - Text(AppLocalizations.of(context)!.importStartImport), + Text(AppLocalizations.of(context).importStartImport), ), ], ], @@ -382,7 +383,7 @@ class _ImportConfirmPageState extends ConsumerState { child: DropdownButton( isExpanded: true, value: mapping[key], - hint: Text(AppLocalizations.of(context)!.importAutoDetect), + hint: Text(AppLocalizations.of(context).importAutoDetect), items: items, onChanged: (v) => setState(() => mapping[key] = v), ), @@ -409,6 +410,48 @@ class _ImportConfirmPageState extends ConsumerState { final dataStart = widget.hasHeader ? (headerRow + 1) : 0; final total = rows.length - dataStart; + final Map skippedTypes = {}; + late final ImportData importData; + try { + final raw = _buildImportDataFromCsv( + rows: rows, dataStart: dataStart, mapping: mapping, + categoryMapping: categoryMapping, skippedTypes: skippedTypes, + ledgerCurrency: ledgerCurrency, + ); + final existing = await repo.getAllAccounts(); + final plan = planExternalAccountCurrencies(raw, + ledgerCurrency: ledgerCurrency, + existingCurrencies: {for (final account in existing) account.name: account.currency}, + ); + if (!mounted) return; + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog(context: context, builder: (ctx) => AlertDialog( + title: Text(l10n.importAccountCurrencyPreview), + content: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(l10n.importAccountCurrencyPreviewHint), + const SizedBox(height: 12), + for (final account in plan.data.accounts) Text('${account.name} · ${account.currency}'), + if (plan.mappings.isNotEmpty) ...[ + const Divider(), + for (final mapping in plan.mappings) Text(mapping), + ], + ])), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(l10n.commonCancel)), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(l10n.importStartImport)), + ], + )); + if (!mounted) return; + if (confirmed != true) { setState(() => importing = false); return; } + importData = plan.data; + } catch (error) { + if (mounted) { + setState(() => importing = false); + showToast(context, AppLocalizations.of(context).importTransactionFailed('$error')); + } + return; + } // 初始化全局进度 container.read(importProgressProvider.notifier).state = ImportProgress( running: true, @@ -418,6 +461,7 @@ class _ImportConfirmPageState extends ConsumerState { fail: 0, ); + if (!currentContext.mounted) return; bool dialogOpen = true; // 进度弹窗(可转后台) showDialog( @@ -431,7 +475,7 @@ class _ImportConfirmPageState extends ConsumerState { return AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text(AppLocalizations.of(context)!.importInProgress), + title: Text(AppLocalizations.of(context).importInProgress), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -441,7 +485,7 @@ class _ImportConfirmPageState extends ConsumerState { const SizedBox(height: 8), // 实时进度文案(每50条更新一次,足够流畅) Text( - AppLocalizations.of(context)! + AppLocalizations.of(context) .importProgressDetail(p.done, p.fail, p.ok, p.total), style: Theme.of(dctx) .textTheme @@ -462,7 +506,7 @@ class _ImportConfirmPageState extends ConsumerState { } }, child: - Text(AppLocalizations.of(context)!.importBackgroundImport), + Text(AppLocalizations.of(context).importBackgroundImport), ), TextButton( onPressed: () { @@ -470,7 +514,7 @@ class _ImportConfirmPageState extends ConsumerState { dialogOpen = false; Navigator.of(dctx).pop(); }, - child: Text(AppLocalizations.of(context)!.importCancelImport), + child: Text(AppLocalizations.of(context).importCancelImport), ), ], ); @@ -481,20 +525,7 @@ class _ImportConfirmPageState extends ConsumerState { // 定义进度变量 int done = 0; - // 收集跳过的类型(用于提示用户) - final Map skippedTypes = {}; - try { - // 使用统一导入服务:将CSV数据转换为ImportData格式 - final importData = _buildImportDataFromCsv( - rows: rows, - dataStart: dataStart, - mapping: mapping, - categoryMapping: categoryMapping, - skippedTypes: skippedTypes, - ledgerCurrency: ledgerCurrency, - ); - // 调用统一导入服务 final result = await dataImportService.importData( repo, @@ -533,7 +564,7 @@ class _ImportConfirmPageState extends ConsumerState { } catch (e) { // 导入失败 if (mounted) { - showToast(context, AppLocalizations.of(context)!.importTransactionFailed('$e')); + showToast(context, AppLocalizations.of(context).importTransactionFailed('$e')); } fail = total - ok; // 更新失败数 } @@ -581,8 +612,8 @@ class _ImportConfirmPageState extends ConsumerState { // 显示导入完成提示 final cancelledText = - _cancelled ? AppLocalizations.of(currentContext)!.importCancelled : ''; - final l10nToast = AppLocalizations.of(currentContext)!; + _cancelled ? AppLocalizations.of(currentContext).importCancelled : ''; + final l10nToast = AppLocalizations.of(currentContext); // 构建提示信息 String message = l10nToast.importCompleted(cancelledText, fail, ok); @@ -902,14 +933,13 @@ class _ImportConfirmPageState extends ConsumerState { } } + if (currencyStr != null && !RegExp(r'^[A-Z]{3,8}$').hasMatch(currencyStr)) { + throw FormatException('${AppLocalizations.of(context).importCurrencyInvalid} ${i + 1}: $currencyStr'); + } transactions.add(ImportTransaction( type: type, amount: amount, - // 币种列有值且像 ISO code(3-8 位字母)才采纳,脏值回退兜底链 - currencyCode: (currencyStr != null && - RegExp(r'^[A-Z]{3,8}$').hasMatch(currencyStr)) - ? currencyStr - : null, + currencyCode: currencyStr, categoryName: finalCategoryName, categoryKind: categoryKind, categoryId: categoryId, diff --git a/lib/pages/main/analytics_page.dart b/lib/pages/main/analytics_page.dart index 751442629..6808cbcd5 100644 --- a/lib/pages/main/analytics_page.dart +++ b/lib/pages/main/analytics_page.dart @@ -9,7 +9,6 @@ import '../../widgets/charts/line_chart.dart'; import '../../widgets/charts/category_pie_chart.dart'; import '../../widgets/analytics/analytics_summary.dart'; import '../../widgets/analytics/category_rank_row.dart'; -import '../../widgets/ui/capsule_switcher.dart'; import '../../l10n/app_localizations.dart'; import '../../services/export/share_poster_service.dart'; import '../../data/db.dart' as db; @@ -601,6 +600,7 @@ class _AnalyticsPageState extends ConsumerState { bottom: Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: CapsuleSwitcher( + height: 40, selectedValue: _scope, options: [ CapsuleOption( diff --git a/lib/services/import/account_currency_plan.dart b/lib/services/import/account_currency_plan.dart new file mode 100644 index 000000000..2429ee8d3 --- /dev/null +++ b/lib/services/import/account_currency_plan.dart @@ -0,0 +1,97 @@ +import '../data_import_service.dart'; + +/// Only external tables lack account definitions. Backups keep their own protocol. +class AccountCurrencyPlan { + final ImportData data; + final List mappings; + const AccountCurrencyPlan(this.data, this.mappings); +} + +AccountCurrencyPlan planExternalAccountCurrencies( + ImportData source, { + required String ledgerCurrency, + required Map existingCurrencies, +}) { + final explicit = >{}; + Iterable names(ImportTransaction tx) => [ + tx.accountName, + tx.fromAccountName, + tx.toAccountName + ].whereType(); + for (final tx in source.transactions) { + if (tx.currencyCode == null) continue; + for (final name in names(tx)) { + explicit.putIfAbsent(name, () => {}).add(tx.currencyCode!.toUpperCase()); + } + } + String currency(ImportTransaction tx) { + if (tx.currencyCode != null) return tx.currencyCode!.toUpperCase(); + final account = names(tx).firstOrNull; + if (account != null) { + final existing = existingCurrencies[account]; + if (existing != null) return existing.toUpperCase(); + final codes = explicit[account]; + if (codes != null && codes.length == 1) return codes.single; + } + return ledgerCurrency.toUpperCase(); + } + + final allCurrencies = >{}; + for (final tx in source.transactions) { + for (final name in names(tx)) { + allCurrencies.putIfAbsent(name, () => {}).add(currency(tx)); + } + } + final occupied = {...existingCurrencies}; + final resolved = <(String, String), String>{}; + final accounts = {}; + final mappings = []; + String? resolve(String? name, String code) { + if (name == null) return null; + final key = (name, code); + return resolved.putIfAbsent(key, () { + final existing = existingCurrencies[name]?.toUpperCase(); + final split = (existing != null && existing != code) || + (existing == null && allCurrencies[name]!.length > 1); + var target = split ? '$name $code' : name; + var suffix = 2; + while (occupied.containsKey(target) && + occupied[target]!.toUpperCase() != code) { + target = '$name $code ${suffix++}'; + } + occupied[target] = code; + accounts[target] = + ImportAccount(name: target, type: 'cash', currency: code); + if (target != name) mappings.add('$name ($code) → $target'); + return target; + }); + } + + final transactions = source.transactions.map((tx) { + final code = currency(tx); + return ImportTransaction( + type: tx.type, + amount: tx.amount, + currencyCode: code, + happenedAt: tx.happenedAt, + categoryName: tx.categoryName, + categoryKind: tx.categoryKind, + categoryId: tx.categoryId, + note: tx.note, + accountName: resolve(tx.accountName, code), + fromAccountName: resolve(tx.fromAccountName, code), + toAccountName: resolve(tx.toAccountName, code), + tagNames: tx.tagNames, + attachments: tx.attachments, + syncId: tx.syncId, + ); + }).toList(); + return AccountCurrencyPlan( + ImportData( + accounts: accounts.values.toList(), + transactions: transactions, + categories: source.categories, + tags: source.tags, + ), + mappings); +} diff --git a/lib/styles/tokens.dart b/lib/styles/tokens.dart index 3ebcdfd10..f434985b6 100644 --- a/lib/styles/tokens.dart +++ b/lib/styles/tokens.dart @@ -309,6 +309,12 @@ class BeeTokens { static Color primary(BuildContext context) => Theme.of(context).colorScheme.primary; + /// 实色背景上的文字,兼容用户自定义主题色。 + static Color textOnColor(Color background) => + ThemeData.estimateBrightnessForColor(background) == Brightness.light + ? primaryTextStatic + : Colors.white; + /// 辅助色 static Color secondary(BuildContext context) => Theme.of(context).colorScheme.secondary; diff --git a/lib/utils/account_type_utils.dart b/lib/utils/account_type_utils.dart index a7b26169d..e90d4d96a 100644 --- a/lib/utils/account_type_utils.dart +++ b/lib/utils/account_type_utils.dart @@ -15,6 +15,16 @@ AccountClassification getAccountClassification(String type) { /// 是否为资产类型 bool isAssetType(String type) => !isLiabilityType(type); +/// Credit-card overpayments are assets, without offsetting another card's debt. +bool isAssetBalance(String type, double balance) => + isAssetType(type) || (type == 'credit_card' && balance > 0); + +/// Composition shows positive positions, not absolute negative balances. +String? positiveAssetCompositionType(String type, double balance) { + if (balance <= 0 || !isAssetBalance(type, balance)) return null; + return type == 'credit_card' ? 'credit_card_overpayment' : type; +} + /// 是否为负债类型 bool isLiabilityType(String type) { return type == 'credit_card' || type == 'loan'; @@ -93,6 +103,8 @@ IconData getIconForAccountType(String type) { String getAccountTypeLabel(BuildContext context, String type) { final l10n = AppLocalizations.of(context); switch (type) { + case 'credit_card_overpayment': + return l10n.creditCardOverpayment; case 'cash': return l10n.accountTypeCash; case 'bank_card': diff --git a/lib/utils/credit_card_amounts.dart b/lib/utils/credit_card_amounts.dart new file mode 100644 index 000000000..14e683df6 --- /dev/null +++ b/lib/utils/credit_card_amounts.dart @@ -0,0 +1,13 @@ +/// Signed balances remain the storage/sync contract. UI amounts are non-negative. +class CreditCardAmounts { + final double balance; + final double? limit; + + const CreditCardAmounts(this.balance, {this.limit}); + + double get debt => balance < 0 ? -balance : 0; + double get overpayment => balance > 0 ? balance : 0; + double? get estimatedAvailable => limit == null || limit! <= 0 + ? null + : (limit! + balance).clamp(0.0, double.infinity); +} diff --git a/lib/widgets/charts/asset_composition_chart.dart b/lib/widgets/charts/asset_composition_chart.dart index 6652f14b2..89241c6f4 100644 --- a/lib/widgets/charts/asset_composition_chart.dart +++ b/lib/widgets/charts/asset_composition_chart.dart @@ -136,7 +136,7 @@ class _AssetCompositionChartState extends ConsumerState { ? (selectedSlice.type == '_other_' ? l10n.commonOther : getAccountTypeLabel(context, selectedSlice.type)) - : l10n.assetComposition, + : l10n.positiveAssetBalance, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: BeeTokens.textTertiary(context), fontSize: 10, @@ -160,6 +160,11 @@ class _AssetCompositionChartState extends ConsumerState { ], ), ), + if (widget.data.any((d) => d.totalBalance < 0)) Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(l10n.negativeAssetBalanceHint, + style: TextStyle(fontSize: 11, color: BeeTokens.textTertiary(context))), + ), const SizedBox(height: 8), Wrap( spacing: 16, diff --git a/lib/widgets/ui/capsule_switcher.dart b/lib/widgets/ui/capsule_switcher.dart index 20eba53fe..65db57d8f 100644 --- a/lib/widgets/ui/capsule_switcher.dart +++ b/lib/widgets/ui/capsule_switcher.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../../styles/tokens.dart'; @@ -16,7 +18,10 @@ class CapsuleOption { }); } -/// 通用胶囊切换器组件 +/// 通用单选切换器:日间黑色选中胶囊、等宽选项与可访问的选中状态。 +/// +/// 标签能完整放下时等宽铺满;窄屏或大字号时横向滚动,不截断标签。 +/// 由调用方持有选中值,组件不承载筛选业务。 class CapsuleSwitcher extends StatelessWidget { final T selectedValue; final List> options; @@ -37,76 +42,118 @@ class CapsuleSwitcher extends StatelessWidget { this.selectedBackgroundColor, this.selectedTextColor, this.unselectedTextColor, - this.height = 40, + this.height = 44, this.borderRadius, }); @override Widget build(BuildContext context) { - final isDark = BeeTokens.isDark(context); + if (options.isEmpty) return const SizedBox.shrink(); final bg = backgroundColor ?? BeeTokens.surfaceCapsule(context); - final selectedBg = selectedBackgroundColor ?? (isDark ? BeeTokens.primary(context) : Colors.black); - final selectedFg = selectedTextColor ?? Colors.white; - final unselectedFg = unselectedTextColor ?? BeeTokens.textPrimary(context); - final radius = borderRadius ?? BorderRadius.circular(20); + final selectedBg = selectedBackgroundColor ?? + (BeeTokens.isDark(context) ? BeeTokens.primary(context) : Colors.black); + final selectedFg = selectedTextColor ?? BeeTokens.textOnColor(selectedBg); + final unselectedFg = + unselectedTextColor ?? BeeTokens.textPrimary(context); + final textStyle = (Theme.of(context).textTheme.bodyMedium ?? + const TextStyle(fontSize: 14)) + .copyWith(fontWeight: FontWeight.w600); + final textScaler = MediaQuery.textScalerOf(context); + final widths = []; + var textHeight = 0.0; + for (final option in options) { + final painter = TextPainter( + text: TextSpan(text: option.label, style: textStyle), + textDirection: Directionality.of(context), + textScaler: textScaler, + )..layout(); + widths.add(painter.width.ceilToDouble() + + BeeDimens.p12 * 2 + + (option.showArrow && option.onTap != null ? 22 : 0)); + textHeight = math.max(textHeight, painter.height); + painter.dispose(); + } + final controlHeight = math.max(height, textHeight + BeeDimens.p16 + 6); + final radius = borderRadius ?? BorderRadius.circular(controlHeight / 2); + final segmentRadius = BorderRadius.circular((controlHeight - 6) / 2); + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 160); Widget buildSegment(CapsuleOption option) { final selected = selectedValue == option.value; - return Expanded( - child: GestureDetector( - onTap: () => onChanged(option.value), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - height: height - 6, // 减去padding - decoration: BoxDecoration( - color: selected ? selectedBg : Colors.transparent, - borderRadius: BorderRadius.circular((height - 6) / 2), - ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Text( - option.label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: selected ? selectedFg : unselectedFg, - fontWeight: FontWeight.w600, + final fg = selected ? selectedFg : unselectedFg; + return Semantics( + button: true, + selected: selected, + inMutuallyExclusiveGroup: true, + child: AnimatedContainer( + duration: duration, + height: controlHeight - 6, + decoration: BoxDecoration( + color: selected ? selectedBg : Colors.transparent, + borderRadius: segmentRadius, + ), + child: Material( + type: MaterialType.transparency, + borderRadius: segmentRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + borderRadius: segmentRadius, + hoverColor: BeeTokens.surfaceHover(context), + focusColor: BeeTokens.surfaceHover(context), + onTap: () => onChanged(option.value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: BeeDimens.p12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(option.label, + maxLines: 1, style: textStyle.copyWith(color: fg)), + if (option.showArrow && option.onTap != null) ...[ + const SizedBox(width: 4), + InkWell( + onTap: option.onTap, + borderRadius: BorderRadius.circular(BeeDimens.radius12), + child: Icon(Icons.arrow_drop_down, size: 18, color: fg), ), + ], + ], ), - if (option.showArrow && option.onTap != null) ...[ - const SizedBox(width: 4), - InkWell( - onTap: option.onTap, - borderRadius: BorderRadius.circular(12), - child: Icon( - Icons.arrow_drop_down, - size: 18, - color: selected ? selectedFg : unselectedFg, - ), - ), - ] - ], + ), ), ), ), ); } - return Container( - height: height, - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: bg, - borderRadius: radius, - ), - child: Row( - children: options - .map((option) => buildSegment(option)) - .expand((widget) => [widget, const SizedBox(width: 4)]) - .take(options.length * 2 - 1) // 移除最后一个SizedBox - .toList(), - ), - ); + return LayoutBuilder(builder: (context, constraints) { + final equalWidth = constraints.hasBoundedWidth && + widths.reduce(math.max) * options.length + + 4 * (options.length - 1) + + 6 <= + constraints.maxWidth; + final children = [ + for (var i = 0; i < options.length; i++) ...[ + if (i > 0) const SizedBox(width: 4), + if (equalWidth) + Expanded(child: buildSegment(options[i])) + else + SizedBox(width: widths[i], child: buildSegment(options[i])), + ], + ]; + final row = Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration(color: bg, borderRadius: radius), + child: Row(mainAxisSize: MainAxisSize.min, children: children), + ); + return equalWidth + ? row + : ClipRRect( + borderRadius: radius, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, child: row), + ); + }); } -} \ No newline at end of file +} diff --git a/lib/widgets/ui/ui.dart b/lib/widgets/ui/ui.dart index 7fbe34f03..775439bc6 100644 --- a/lib/widgets/ui/ui.dart +++ b/lib/widgets/ui/ui.dart @@ -10,3 +10,4 @@ export 'searchable_dropdown.dart'; export 'message_popover_menu.dart'; export 'bee_popup_menu.dart'; export 'skeleton.dart'; +export 'capsule_switcher.dart'; diff --git a/scripts/qa/integration_permissions.swift b/scripts/qa/integration_permissions.swift new file mode 100644 index 000000000..7b74f9d47 --- /dev/null +++ b/scripts/qa/integration_permissions.swift @@ -0,0 +1,23 @@ +import XCTest + +// This runner is installed only after its IDs and the new QA UDID are verified. +final class QASmoke: XCTestCase { + func testNotificationPermissionOnOwnedDevice() { + continueAfterFailure = false + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let deadline = Date().addingTimeInterval(90) + while Date() < deadline { + let alert = springboard.alerts.firstMatch + if alert.waitForExistence(timeout: 1) { + let text = alert.label + " " + alert.staticTexts.allElementsBoundByIndex.map { $0.label }.joined(separator: " ") + if text.contains("发送通知") || text.lowercased().contains("send you notifications") { + let allow = alert.buttons.matching(NSPredicate(format: "label IN %@", ["允许", "Allow"])) + XCTAssertEqual(allow.count, 1) + allow.firstMatch.tap() + return + } + } + } + // Some scenarios never request notifications; no permission mutation is needed. + } +} diff --git a/scripts/qa/isolated_app_cloud.py b/scripts/qa/isolated_app_cloud.py index 10e541450..85297ea9e 100644 --- a/scripts/qa/isolated_app_cloud.py +++ b/scripts/qa/isolated_app_cloud.py @@ -24,6 +24,7 @@ GROUP_ID = 'group.com.tntlikely.beecount.qa' PROJECT = Path(__file__).resolve().parents[2] SCENARIOS = { + 'account-balance-trust': 'integration_test/account_balance_trust_live_test.dart', 'transaction-copy': 'integration_test/transaction_copy_live_test.dart', 'category-parent': 'integration_test/category_parent_live_test.dart', 'web-transaction-images': 'integration_test/web_transaction_images_live_test.dart', @@ -252,7 +253,8 @@ def snapshot(root, manifest): for p in (PROJECT / directory).rglob('*') if p.is_file() and '__pycache__' not in p.parts] digest = hashlib.sha256() for name in sorted(set(files)): - if not name or name == 'AGENTS.md': + # Tool entry aliases are metadata, not App source; keep code symlink rejection intact. + if not name or name == 'AGENTS.md' or name.startswith('.claude/skills/'): continue if (PROJECT / name).is_symlink(): # Tracked skill symlinks (.claude/skills/* -> .agents/skills/*, #539) @@ -374,6 +376,7 @@ def run(root, manifest, flutter): subprocess.run(['xcrun', 'simctl', 'boot', manifest['udid']], check=True) subprocess.run(['xcrun', 'simctl', 'bootstatus', manifest['udid'], '-b'], check=True) backup_app(root, manifest) + permission_process, permission_log = integration_permissions(root, manifest) env = dict(os.environ, QA_EVIDENCE_DIR=str(root / 'evidence')) args = [flutter, 'drive', '-d', manifest['udid'], '--use-application-binary', str(app), '--driver', 'test_driver/transaction_copy_live_test.dart', @@ -381,10 +384,13 @@ def run(root, manifest, flutter): print('Running UI and live Cloud acceptance on the owned QA simulator', flush=True) with (root / 'raw-logs/drive.log').open('w') as log: result = subprocess.run(args, cwd=root / 'app-source', env=env, stdout=log, stderr=log) + permission_exit = permission_process.wait(timeout=150) + permission_log.close() + manifest['integration_permissions_exit_code'] = permission_exit manifest['drive_exit_code'] = result.returncode write_json(root / 'manifest.json', manifest) backup_app(root, manifest) - if result.returncode: + if result.returncode or permission_exit: raise RuntimeError(f'Acceptance failed; QA data and private logs preserved in {root}') db_path = inside(root, root / 'cloud-data/beecount.db') with sqlite3.connect(f'file:{db_path}?mode=ro', uri=True) as db: @@ -395,9 +401,10 @@ def run(root, manifest, flutter): 'created_by_user_id, tag_sync_ids_json, attachments_json ' 'FROM read_tx_projection ORDER BY sync_id')] migration = db.execute('SELECT version_num FROM alembic_version').fetchone()[0] + accounts = [dict(r) for r in db.execute('SELECT sync_id, name, account_type, currency, initial_balance, credit_limit, billing_day, payment_due_day FROM user_account_projection ORDER BY sync_id')] categories = [dict(r) for r in db.execute('SELECT sync_id, name, kind, level, parent_name, parent_sync_id ' 'FROM user_category_projection ORDER BY sync_id')] - write_json(root / 'evidence/cloud-projection.json', dict(migration=migration, transactions=rows, categories=categories)) + write_json(root / 'evidence/cloud-projection.json', dict(migration=migration, transactions=rows, categories=categories, accounts=accounts)) public = {k: manifest[k] for k in ('run_id', 'app_id', 'app_sha', 'cloud_sha', 'runtime', 'udid', 'cloud_origin', 'source_hash', 'artifact_hash', 'verified_bundles', 'drive_exit_code')} public.update(tool_versions=manifest.get('tool_versions'), skill_sha=manifest.get('skill_sha'), resolved_lock_sha256=manifest.get('resolved_lock_sha256')) @@ -613,6 +620,29 @@ def verify_smoke_bundles(native): raise ValueError('Native UI test bundle must have an independent QA identity') +def integration_permissions(root, manifest): + """Watch only notification prompts on the owned QA simulator during Flutter UI tests.""" + device(manifest) + source = inside(root, root / 'app-source') + native = inside(root, root / 'native-integration-permissions') + native.mkdir() + shutil.copy2(source / 'scripts/qa/integration_permissions.swift', native / 'QASmoke.swift') + subprocess.run(['ruby', str(source / 'scripts/qa/create_smoke_project.rb'), str(native)], check=True) + args = ['xcodebuild', '-project', str(native / 'QASmoke.xcodeproj'), '-scheme', 'QASmoke', + '-configuration', 'Debug', '-sdk', 'iphonesimulator', + '-destination', f'id={manifest["udid"]}', '-derivedDataPath', str(native / 'build'), + '-parallel-testing-enabled', 'NO', 'CODE_SIGNING_ALLOWED=NO'] + with (root / 'raw-logs/integration-permissions-build.log').open('w') as log: + subprocess.run([args[0], 'build-for-testing', *args[1:]], stdout=log, stderr=log, check=True) + verify_smoke_bundles(native) + device(manifest) + log = (root / 'raw-logs/integration-permissions-test.log').open('w') + proc = subprocess.Popen([args[0], 'test-without-building', *args[1:], + '-resultBundlePath', str(native / 'result.xcresult')], + stdout=log, stderr=log) + return proc, log + + def normal_ui_check(root, manifest): """Handle first-launch OS permission dialogs and assert the real home UI.""" device(manifest) @@ -688,6 +718,15 @@ def restart_check(root, manifest, flutter): copy = next(r for r in rows if r['sync_id'] == report['copy_sync_id']) if copy['note'] != 'QA Cloud 修改后' or copy['amount'] != 55.5: raise ValueError('Normal App restart did not retain the synchronized copy') + elif manifest.get('scenario') == 'account-balance-trust': + with sqlite3.connect(f'file:{app_db}?mode=ro', uri=True) as db: + db.row_factory = sqlite3.Row + accounts = [dict(r) for r in db.execute('SELECT sync_id, name, type, currency, initial_balance FROM accounts ORDER BY sync_id')] + for expected in report['final_accounts']: + actual = next((a for a in accounts if a['sync_id'] == expected['sync_id']), None) + if actual is None or any(actual[k] != expected[k] for k in ('name', 'type', 'currency', 'initial_balance')): + raise ValueError('Normal restart lost synchronized account identity or fields') + rows = dict(transactions=rows, accounts=accounts) elif manifest.get('scenario') == 'category-parent': with sqlite3.connect(f'file:{app_db}?mode=ro', uri=True) as db: db.row_factory = sqlite3.Row diff --git a/test/repositories/account_credit_amounts_test.dart b/test/repositories/account_credit_amounts_test.dart new file mode 100644 index 000000000..fbf23dc80 --- /dev/null +++ b/test/repositories/account_credit_amounts_test.dart @@ -0,0 +1,105 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:beecount/data/db.dart'; +import 'package:beecount/data/repositories/local/local_repository.dart'; +import 'package:beecount/utils/credit_card_amounts.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + late BeeDatabase db; + late LocalRepository repo; + setUp(() { + db = BeeDatabase.forTesting(NativeDatabase.memory()); + repo = LocalRepository(db); + }); + tearDown(() async => db.close()); + + test('还款为转账,溢缴计资产,消费跨过正余额后重新形成欠款', () async { + final ledger = await repo.createLedger(name: '合成账本'); + final bank = await repo.createAccount( + ledgerId: ledger, name: '银行', initialBalance: 10000); + final card = await repo.createAccount( + ledgerId: ledger, name: '信用卡', type: 'credit_card'); + Future spend(double amount) => repo.addTransaction( + ledgerId: ledger, + type: 'expense', + amount: amount, + accountId: card, + happenedAt: DateTime.now()); + await spend(1000); + expect((await repo.getNetWorthBreakdown()).netWorth, 9000); + await repo.addTransaction( + ledgerId: ledger, + type: 'transfer', + amount: 1200, + accountId: bank, + toAccountId: card, + happenedAt: DateTime.now()); + expect(await repo.getAccountBalance(bank), 8800); + expect(await repo.getAccountBalance(card), 200); + final overpaid = await repo.getNetWorthBreakdown(); + expect(overpaid.totalAssets, 9000); + expect(overpaid.totalLiabilities, 0); + expect(overpaid.netWorth, 9000); + expect((await repo.getAccountStats(card)).expense, 1000); + await spend(150); + expect(await repo.getAccountBalance(card), 50); + await spend(100); + expect(await repo.getAccountBalance(card), -50); + final finalState = await repo.getNetWorthBreakdown(); + expect(finalState.totalAssets, 8800); + expect(finalState.totalLiabilities, -50); + expect(finalState.netWorth, 8750); + final day = DateTime.now(); + final trend = await repo.getNetWorthTrendSeries( + startDate: day, endDate: day, ratesToBase: {'CNY': 1}); + expect(trend.single.net, 8750); + }); + + test('溢缴不抵其他卡欠款;负资产不按绝对值进入构成', () async { + final ledger = await repo.createLedger(name: '合成账本'); + for (final a in [ + ('bank_card', 10000.0), + ('cash', -200.0), + ('credit_card', -1000.0), + ('credit_card', 200.0) + ]) { + await repo.createAccount( + ledgerId: ledger, + name: '${a.$1} ${a.$2}', + type: a.$1, + initialBalance: a.$2); + } + final summary = (await repo.getNetWorthBreakdownByCurrency())['CNY']!; + expect(summary.totalAssets, 10000); + expect(summary.totalLiabilities, -1000); + expect(summary.netWorth, 9000); + final composition = await repo.getAssetCompositionByType(); + expect( + composition + .singleWhere((a) => a.type == 'credit_card_overpayment') + .totalBalance, + 200); + expect( + composition + .singleWhere((a) => a.type == '_negative_assets_') + .totalBalance, + -200); + expect( + composition + .where((a) => a.totalBalance > 0) + .fold(0.0, (s, a) => s + a.totalBalance), + 10200); + }); + + test('欠款、溢缴、额度未知与超额使用同一计算定义', () { + expect( + const CreditCardAmounts(-1000, limit: 20000).estimatedAvailable, 19000); + expect( + const CreditCardAmounts(200, limit: 20000).estimatedAvailable, 20200); + expect(const CreditCardAmounts(-21000, limit: 20000).estimatedAvailable, 0); + expect(const CreditCardAmounts(-1000).estimatedAvailable, isNull); + }); +} diff --git a/test/services/import_account_currency_plan_test.dart b/test/services/import_account_currency_plan_test.dart new file mode 100644 index 000000000..5a534b2b6 --- /dev/null +++ b/test/services/import_account_currency_plan_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:beecount/services/data_import_service.dart'; +import 'package:beecount/services/import/account_currency_plan.dart'; + +void main() { + ImportTransaction tx(String name, String? currency) => ImportTransaction( + type: 'expense', + amount: 2000, + happenedAt: DateTime(2026, 10, 1), + accountName: name, + currencyCode: currency); + test('CNY 账本中的新 JPY 账户保留账户及交易原币', () { + final plan = planExternalAccountCurrencies( + ImportData(transactions: [tx('现金', 'CNY'), tx('日元', 'JPY')]), + ledgerCurrency: 'CNY', + existingCurrencies: {}); + expect(plan.data.accounts.map((a) => a.currency), ['CNY', 'JPY']); + expect(plan.data.transactions.last.currencyCode, 'JPY'); + expect(plan.data.transactions.last.amount, 2000); + expect(plan.mappings, isEmpty); + }); + test('同名多币种拆分,现有账户币种不被覆盖,冲突名称不会复用错误币种', () { + final plan = planExternalAccountCurrencies( + ImportData(transactions: [tx('银行', 'USD'), tx('银行', 'JPY')]), + ledgerCurrency: 'CNY', + existingCurrencies: {'银行': 'CNY', '银行 USD': 'JPY'}); + expect(plan.data.accounts.map((a) => a.name), ['银行 USD 2', '银行 JPY']); + expect(plan.data.transactions.first.accountName, '银行 USD 2'); + expect(plan.mappings.length, 2); + }); + test('缺失币种继承已有账户,否则继承单一明确币种,再回退账本', () { + final plan = planExternalAccountCurrencies( + ImportData(transactions: [ + tx('旧银行', null), + tx('日元', 'JPY'), + tx('日元', null), + tx('钱包', null) + ]), + ledgerCurrency: 'CNY', + existingCurrencies: {'旧银行': 'USD'}); + expect(plan.data.transactions.map((t) => t.currencyCode), + ['USD', 'JPY', 'JPY', 'CNY']); + }); +} diff --git a/test/widgets/account_transfer_presentation_test.dart b/test/widgets/account_transfer_presentation_test.dart new file mode 100644 index 000000000..e474d5887 --- /dev/null +++ b/test/widgets/account_transfer_presentation_test.dart @@ -0,0 +1,87 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:beecount/data/db.dart'; +import 'package:beecount/data/repositories/local/local_repository.dart'; +import 'package:beecount/l10n/app_localizations.dart'; +import 'package:beecount/pages/account/account_detail_page.dart'; +import 'package:beecount/providers/database_providers.dart'; +import 'package:beecount/styles/tokens.dart'; +import 'package:beecount/widgets/biz/amount_text.dart'; + +void main() { + for (final incoming in [true, false]) { + testWidgets('${incoming ? '信用卡转入' : '银行卡转出'}还款显示方向、中性色和转账标题,筛选不影响统计', + (tester) async { + final db = BeeDatabase.forTesting(NativeDatabase.memory()); + final repo = LocalRepository(db); + addTearDown(db.close); + await tester.binding.setSurfaceSize(const Size(500, 1400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final ledgerId = await db + .into(db.ledgers) + .insert(LedgersCompanion.insert(name: '很长的跨账本名称用于确认不会挤掉转账标题')); + final bank = await repo.createAccount( + ledgerId: ledgerId, + name: '农业银行', + type: 'bank_card', + initialBalance: 1000); + final card = await repo.createAccount( + ledgerId: ledgerId, + name: '招商银行', + type: 'credit_card', + initialBalance: -135); + final txId = await repo.addTransaction( + ledgerId: ledgerId, + type: 'transfer', + amount: 135, + accountId: bank, + toAccountId: card, + happenedAt: DateTime.now(), + note: '信用卡还款'); + final accountId = incoming ? card : bank; + await tester.pumpWidget(ProviderScope( + overrides: [ + repositoryProvider.overrideWithValue(repo), + currentLedgerIdProvider.overrideWith((ref) => ledgerId), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh'), + home: + AccountDetailPage(account: (await repo.getAccount(accountId))!), + ))); + await tester.pumpAndSettle(); + final row = find.byKey(ValueKey('account-transaction-$txId')); + expect(row, findsOneWidget); + expect(find.descendant(of: row, matching: find.text('农业银行 → 招商银行')), + findsOneWidget); + expect( + find.descendant( + of: row, matching: find.text('转账 (信用卡还款)', findRichText: true)), + findsOneWidget); + final amount = tester.widget( + find.descendant(of: row, matching: find.byType(AmountText))); + expect(amount.value, incoming ? 135 : -135); + expect(amount.signed, isTrue); + expect(amount.style?.color, BeeTokens.textPrimary(tester.element(row))); + expect(tester.takeException(), isNull); + expect(await repo.getAccountBalance(accountId), incoming ? 0 : 865); + expect(await repo.getAccountCategoryStats(accountId, type: 'income'), isEmpty); + expect(await repo.getAccountCategoryStats(accountId, type: 'expense'), isEmpty); + await tester.ensureVisible(find.text(incoming ? '支出与转出' : '收入与转入')); + await tester.tap(find.text(incoming ? '支出与转出' : '收入与转入')); + await tester.pumpAndSettle(); + expect(row, findsNothing); + await tester.tap(find.text('全部流水')); + await tester.pumpAndSettle(); + expect(row, findsOneWidget); + expect(await repo.getAccountBalance(accountId), incoming ? 0 : 865); + await tester.pumpWidget(const SizedBox()); + // 清理生产 logger 的延迟落盘任务和 drift 的取消订阅定时器。 + await tester.pump(const Duration(seconds: 3)); + }); + } +} diff --git a/test/widgets/capsule_switcher_test.dart b/test/widgets/capsule_switcher_test.dart new file mode 100644 index 000000000..defeca6a4 --- /dev/null +++ b/test/widgets/capsule_switcher_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:beecount/widgets/ui/ui.dart'; + +void main() { + testWidgets('nullable 全部选项可切换,向辅助技术提供选中状态', (tester) async { + final semantics = tester.ensureSemantics(); + String? selected; + await tester.pumpWidget(MaterialApp( + home: StatefulBuilder( + builder: (context, setState) => Scaffold( + body: CapsuleSwitcher( + selectedValue: selected, + options: const [ + CapsuleOption(value: null, label: '全部流水'), + CapsuleOption(value: 'income', label: '收入与转入'), + ], + onChanged: (value) => setState(() => selected = value), + )), + ))); + expect( + tester + .getSemantics(find.text('全部流水')) + .hasFlag(SemanticsFlag.isSelected), + isTrue); + await tester.tap(find.text('收入与转入')); + await tester.pumpAndSettle(); + expect(selected, 'income'); + expect( + tester + .getSemantics(find.text('收入与转入')) + .hasFlag(SemanticsFlag.isSelected), + isTrue); + await tester.tap(find.text('全部流水')); + await tester.pumpAndSettle(); + expect(selected, isNull); + semantics.dispose(); + }); + + testWidgets('箭头打开周期选择器,不触发选项切换', (tester) async { + var opened = 0; + var changed = 0; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: CapsuleSwitcher( + selectedValue: 0, + options: [ + CapsuleOption( + value: 0, label: '月', showArrow: true, onTap: () => opened++) + ], + onChanged: (_) => changed++, + )))); + await tester.tap(find.byIcon(Icons.arrow_drop_down)); + await tester.pumpAndSettle(); + expect(opened, 1); + expect(changed, 0); + await tester.tap(find.text('月')); + expect(changed, 1); + }); + + testWidgets('窄屏、大字号和 RTL 下可滚动到最后一个完整标签', (tester) async { + String? selected; + await tester.pumpWidget(MaterialApp( + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: SizedBox( + width: 200, + child: CapsuleSwitcher( + selectedValue: null, + options: const [ + CapsuleOption(value: null, label: '全部流水'), + CapsuleOption(value: 'expense', label: '支出与转出'), + CapsuleOption(value: 'income', label: '收入与转入'), + ], + onChanged: (value) => selected = value, + ))), + ), + ))); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('收入与转入')); + await tester.pumpAndSettle(); + await tester.tap(find.text('收入与转入')); + expect(selected, 'income'); + expect(tester.takeException(), isNull); + }); +}