diff --git a/lib/data/repositories/local/local_budget_repository.dart b/lib/data/repositories/local/local_budget_repository.dart index 4b1d7f770..47503f974 100644 --- a/lib/data/repositories/local/local_budget_repository.dart +++ b/lib/data/repositories/local/local_budget_repository.dart @@ -46,16 +46,16 @@ class LocalBudgetRepository implements BudgetRepository { // 每条新建预算分配一个 UUID,跨设备 LWW 用。syncId 在 DB schema 上允许 // NULL,只是为了 v22 migration 对老数据兼容;新建走这里永远填。 return await db.into(db.budgets).insert( - BudgetsCompanion.insert( - ledgerId: ledgerId, - type: d.Value(type), - categoryId: d.Value(categoryId), - amount: amount, - period: d.Value(period), - startDay: d.Value(startDay), - syncId: d.Value(_uuid.v4()), - ), - ); + BudgetsCompanion.insert( + ledgerId: ledgerId, + type: d.Value(type), + categoryId: d.Value(categoryId), + amount: amount, + period: d.Value(period), + startDay: d.Value(startDay), + syncId: d.Value(_uuid.v4()), + ), + ); } @override @@ -78,8 +78,7 @@ class LocalBudgetRepository implements BudgetRepository { @override Future deleteBudget(int id) async { // 先获取预算信息,判断是否为总预算 - final budget = await (db.select(db.budgets) - ..where((b) => b.id.equals(id))) + final budget = await (db.select(db.budgets)..where((b) => b.id.equals(id))) .getSingleOrNull(); if (budget == null) return; @@ -99,7 +98,10 @@ class LocalBudgetRepository implements BudgetRepository { Future getTotalBudget(int ledgerId) async { // 使用 .get() 然后取第一个,避免多条脏数据时报错 final budgets = await (db.select(db.budgets) - ..where((b) => b.ledgerId.equals(ledgerId) & b.type.equals('total') & b.enabled.equals(true)) + ..where((b) => + b.ledgerId.equals(ledgerId) & + b.type.equals('total') & + b.enabled.equals(true)) ..orderBy([(b) => d.OrderingTerm(expression: b.createdAt)])) .get(); return budgets.firstOrNull; @@ -107,20 +109,32 @@ class LocalBudgetRepository implements BudgetRepository { @override Future> getCategoryBudgets(int ledgerId) async { - return await (db.select(db.budgets) - ..where((b) => b.ledgerId.equals(ledgerId) & b.type.equals('category') & b.enabled.equals(true))) + final rows = await (db.select(db.budgets) + ..where((b) => + b.ledgerId.equals(ledgerId) & + b.type.equals('category') & + b.enabled.equals(true))) .get(); + + // 老版本允许同一分类创建多条预算,Cloud 的读接口按 syncId 字典序最大 + // 的记录展示。本地采用相同 keeper 规则,避免升级后把历史重复行全部画 + // 出来;数据仍保留,后续同步/清理不会因一次只读查询而丢失。 + final byCategory = {}; + for (final row in rows) { + final categoryId = row.categoryId; + if (categoryId == null) continue; + final current = byCategory[categoryId]; + if (current == null || _preferBudget(row, current)) { + byCategory[categoryId] = row; + } + } + return byCategory.values.toList(); } @override Future getBudgetByCategory(int ledgerId, int categoryId) async { - return await (db.select(db.budgets) - ..where((b) => - b.ledgerId.equals(ledgerId) & - b.type.equals('category') & - b.categoryId.equals(categoryId) & - b.enabled.equals(true))) - .getSingleOrNull(); + final budgets = await getCategoryBudgets(ledgerId); + return budgets.where((b) => b.categoryId == categoryId).firstOrNull; } @override @@ -316,4 +330,13 @@ class LocalBudgetRepository implements BudgetRepository { if (v is num) return v.toDouble(); return 0.0; } + + bool _preferBudget(Budget candidate, Budget current) { + final candidateSyncId = candidate.syncId ?? ''; + final currentSyncId = current.syncId ?? ''; + if (candidateSyncId != currentSyncId) { + return candidateSyncId.compareTo(currentSyncId) > 0; + } + return candidate.createdAt.isAfter(current.createdAt); + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b3341331f..864661160 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2485,6 +2485,7 @@ "budgetAmountHint": "Enter budget amount", "budgetCategoryLabel": "Select Category", "budgetCategoryHint": "Select budget category", + "budgetCategoryAlreadyExists": "This category already has a budget", "budgetStartDayLabel": "Start Day", "budgetPeriodLabel": "Period", "budgetSaveSuccess": "Budget saved", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 349435bae..205ffee51 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1746,6 +1746,7 @@ "budgetAmountHint": "예산 금액을 입력하세요", "budgetCategoryLabel": "카테고리 선택", "budgetCategoryHint": "예산 카테고리를 선택하세요", + "budgetCategoryAlreadyExists": "이 카테고리에는 이미 예산이 있습니다", "budgetStartDayLabel": "시작일", "budgetPeriodLabel": "기간", "budgetSaveSuccess": "예산이 저장되었습니다", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c4dd52ddd..625b3dad4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -10970,6 +10970,12 @@ abstract class AppLocalizations { /// **'Select budget category'** String get budgetCategoryHint; + /// No description provided for @budgetCategoryAlreadyExists. + /// + /// In en, this message translates to: + /// **'This category already has a budget'** + String get budgetCategoryAlreadyExists; + /// No description provided for @budgetStartDayLabel. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index eb5eeea4d..b05507414 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -5740,6 +5740,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get budgetCategoryHint => 'Select budget category'; + @override + String get budgetCategoryAlreadyExists => 'This category already has a budget'; + @override String get budgetStartDayLabel => 'Start Day'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 40bedeb0c..9cc8caa96 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -5740,6 +5740,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get budgetCategoryHint => '예산 카테고리를 선택하세요'; + @override + String get budgetCategoryAlreadyExists => '이 카테고리에는 이미 예산이 있습니다'; + @override String get budgetStartDayLabel => '시작일'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a3651de9a..03eed0e25 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -5740,6 +5740,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get budgetCategoryHint => '请选择预算分类'; + @override + String get budgetCategoryAlreadyExists => '该分类已设置预算'; + @override String get budgetStartDayLabel => '起始日'; @@ -13252,6 +13255,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get budgetCategoryHint => '請選擇預算分類'; + @override + String get budgetCategoryAlreadyExists => '該分類已設定預算'; + @override String get budgetStartDayLabel => '起始日'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f9fe7a7fc..15b9a0352 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2415,6 +2415,7 @@ "budgetAmountHint": "请输入预算金额", "budgetCategoryLabel": "选择分类", "budgetCategoryHint": "请选择预算分类", + "budgetCategoryAlreadyExists": "该分类已设置预算", "budgetStartDayLabel": "起始日", "budgetPeriodLabel": "周期", "budgetSaveSuccess": "预算保存成功", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 313ff000a..f11bc63fc 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -577,6 +577,7 @@ "budgetAmountLabel": "預算金額", "budgetCategoryBudgets": "分類預算", "budgetCategoryHint": "請選擇預算分類", + "budgetCategoryAlreadyExists": "該分類已設定預算", "budgetCategoryLabel": "選擇分類", "budgetDailyAvailable": "日均可用 {amount}", "@budgetDailyAvailable": { diff --git a/lib/pages/budget/budget_edit_page.dart b/lib/pages/budget/budget_edit_page.dart index d4bf45e7a..78aa7bdf5 100644 --- a/lib/pages/budget/budget_edit_page.dart +++ b/lib/pages/budget/budget_edit_page.dart @@ -196,9 +196,11 @@ class _BudgetEditPageState extends ConsumerState { SizedBox(height: 12.0.scaled(context, ref)), TextField( controller: _amountController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')), + FilteringTextInputFormatter.allow( + RegExp(r'^\d+\.?\d{0,2}')), ], style: TextStyle( fontSize: 24, @@ -257,7 +259,8 @@ class _BudgetEditPageState extends ConsumerState { : BeeTokens.surface(context), borderRadius: BorderRadius.circular(12), border: Border.all( - color: isSelected && !disabled ? primary : BeeTokens.border(context), + color: + isSelected && !disabled ? primary : BeeTokens.border(context), width: isSelected && !disabled ? 2 : 1, ), ), @@ -266,15 +269,21 @@ class _BudgetEditPageState extends ConsumerState { Icon( icon, size: 32.0.scaled(context, ref), - color: isSelected && !disabled ? primary : BeeTokens.iconSecondary(context), + color: isSelected && !disabled + ? primary + : BeeTokens.iconSecondary(context), ), SizedBox(height: 8.0.scaled(context, ref)), Text( label, style: TextStyle( fontSize: 14, - fontWeight: isSelected && !disabled ? FontWeight.w600 : FontWeight.w400, - color: isSelected && !disabled ? primary : BeeTokens.textSecondary(context), + fontWeight: isSelected && !disabled + ? FontWeight.w600 + : FontWeight.w400, + color: isSelected && !disabled + ? primary + : BeeTokens.textSecondary(context), ), ), ], @@ -302,7 +311,10 @@ class _BudgetEditPageState extends ConsumerState { width: 36.0.scaled(context, ref), height: 36.0.scaled(context, ref), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( @@ -350,11 +362,22 @@ class _BudgetEditPageState extends ConsumerState { Future _selectCategory() async { final repo = ref.read(repositoryProvider); + final ledgerId = ref.read(currentLedgerIdProvider); final categories = await repo.getAllCategories(); - - // 只显示支出类父分类 + final existingBudgets = await repo.getCategoryBudgets(ledgerId); + final usedCategoryIds = existingBudgets + .where((b) => b.id != widget.budget?.id && b.categoryId != null) + .map((b) => b.categoryId!) + .toSet(); + + // 只显示尚未设置预算的支出类父分类。此前所有分类始终可选,导致同一 + // 分类能生成多个 syncId;Cloud 读接口会去重,所以表现为同步计数增加、 + // Web 列表却看不到新增记录。 final expenseCategories = categories - .where((c) => c.kind == 'expense' && c.parentId == null) + .where((c) => + c.kind == 'expense' && + c.parentId == null && + !usedCategoryIds.contains(c.id)) .toList(); if (!mounted) return; @@ -461,12 +484,26 @@ class _BudgetEditPageState extends ConsumerState { return; } + final repo = ref.read(repositoryProvider); + final ledgerId = ref.read(currentLedgerIdProvider); + + // 选择器过滤负责正常交互;保存前再检查一次,防止弹窗打开期间另一设备 + // 同步进同分类预算,或旧数据/并发操作绕过 UI 造成重复。 + if (!_isEditing && _type == 'category') { + final existing = + await repo.getBudgetByCategory(ledgerId, _selectedCategoryId!); + if (existing != null) { + if (mounted) { + showToast(context, l10n.budgetCategoryAlreadyExists); + } + return; + } + } + + if (!mounted) return; setState(() => _isLoading = true); try { - final repo = ref.read(repositoryProvider); - final ledgerId = ref.read(currentLedgerIdProvider); - if (_isEditing) { await repo.updateBudget( widget.budget!.id, diff --git a/lib/pages/budget/budget_page.dart b/lib/pages/budget/budget_page.dart index 23a864a78..b42b65ddc 100644 --- a/lib/pages/budget/budget_page.dart +++ b/lib/pages/budget/budget_page.dart @@ -65,7 +65,12 @@ class BudgetPage extends ConsumerWidget { ref.watch(currentLedgerProvider).asData?.value?.currency ?? 'CNY'; final currencySymbol = getCurrencySymbol(currencyCode); - if (overview == null || overview.totalBudget == null) { + final categoryBudgets = overview?.categoryBudgets ?? const []; + final hasTotalBudget = overview?.totalBudget != null; + + // 分类预算可以独立于总预算存在。旧逻辑只判断 totalBudget,导致 Web 或 + // App 创建的分类预算已经落库、同步计数也正确,但整个页面仍显示为空。 + if (!hasTotalBudget && categoryBudgets.isEmpty) { return _buildEmptyState(context, ref, l10n); } @@ -75,13 +80,15 @@ class BudgetPage extends ConsumerWidget { vertical: 8.0.scaled(context, ref), ), children: [ - // 总预算概览卡片 - _buildTotalBudgetCard(context, ref, overview, l10n, currencySymbol), - SizedBox(height: 12.0.scaled(context, ref)), + // 总预算概览卡片(分类预算不要求先创建总预算) + if (hasTotalBudget) ...[ + _buildTotalBudgetCard(context, ref, overview!, l10n, currencySymbol), + SizedBox(height: 12.0.scaled(context, ref)), + ], // 分类预算列表 - if (overview.categoryBudgets.isNotEmpty) + if (categoryBudgets.isNotEmpty) _buildCategoryBudgetsCard( - context, ref, overview.categoryBudgets, l10n, currencySymbol), + context, ref, categoryBudgets, l10n, currencySymbol), SizedBox(height: 12.0.scaled(context, ref)), // 首页显示开关 _buildSettingsCard(context, ref, l10n), @@ -89,7 +96,6 @@ class BudgetPage extends ConsumerWidget { ); } - Widget _buildEmptyState( BuildContext context, WidgetRef ref, AppLocalizations l10n) { // §7 共享账本 Editor 视角:预算空时不显示"添加"CTA(owner-only) @@ -118,8 +124,8 @@ class BudgetPage extends ConsumerWidget { if (!isEditorInShared) ElevatedButton.icon( onPressed: () => _addBudget(context), - icon: Icon(Icons.add, - color: BeeTokens.buttonPrimaryText(context)), + icon: + Icon(Icons.add, color: BeeTokens.buttonPrimaryText(context)), label: Text(l10n.budgetAddTotal), style: ElevatedButton.styleFrom( backgroundColor: BeeTokens.buttonPrimary(context), diff --git a/test/repositories/budget_category_dedup_test.dart b/test/repositories/budget_category_dedup_test.dart new file mode 100644 index 000000000..c14b97b0b --- /dev/null +++ b/test/repositories/budget_category_dedup_test.dart @@ -0,0 +1,57 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:beecount/data/db.dart'; +import 'package:beecount/data/repositories/local/local_budget_repository.dart'; + +void main() { + late BeeDatabase db; + late LocalBudgetRepository repo; + + setUp(() async { + db = BeeDatabase.forTesting(NativeDatabase.memory()); + repo = LocalBudgetRepository(db); + await db.into(db.ledgers).insert( + LedgersCompanion.insert(name: '测试账本'), + ); + await db.into(db.categories).insert( + CategoriesCompanion.insert( + name: '住房', + kind: 'expense', + syncId: const Value('cat-housing'), + ), + ); + }); + + tearDown(() async => db.close()); + + test('历史重复分类预算按 Cloud 相同规则只返回 syncId 最大的一条', () async { + await db.into(db.budgets).insert( + BudgetsCompanion.insert( + ledgerId: 1, + type: const Value('category'), + categoryId: const Value(1), + amount: 100, + syncId: const Value('budget-a'), + ), + ); + await db.into(db.budgets).insert( + BudgetsCompanion.insert( + ledgerId: 1, + type: const Value('category'), + categoryId: const Value(1), + amount: 200, + syncId: const Value('budget-z'), + ), + ); + + final budgets = await repo.getCategoryBudgets(1); + final byCategory = await repo.getBudgetByCategory(1, 1); + + expect(budgets, hasLength(1)); + expect(budgets.single.syncId, 'budget-z'); + expect(budgets.single.amount, 200); + expect(byCategory?.syncId, 'budget-z'); + }); +} diff --git a/test/widgets/budget_edit_page_test.dart b/test/widgets/budget_edit_page_test.dart new file mode 100644 index 000000000..80c493d82 --- /dev/null +++ b/test/widgets/budget_edit_page_test.dart @@ -0,0 +1,124 @@ +import 'package:drift/drift.dart'; +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:shared_preferences/shared_preferences.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/budget/budget_edit_page.dart'; +import 'package:beecount/providers/database_providers.dart'; + +void main() { + late BeeDatabase db; + late LocalRepository repo; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + db = BeeDatabase.forTesting(NativeDatabase.memory()); + repo = LocalRepository(db); + await db.into(db.ledgers).insert( + LedgersCompanion.insert(name: '测试账本'), + ); + await db.into(db.categories).insert( + CategoriesCompanion.insert( + name: '住房', + kind: 'expense', + syncId: const Value('cat-housing'), + ), + ); + await db.into(db.categories).insert( + CategoriesCompanion.insert( + name: '餐饮', + kind: 'expense', + syncId: const Value('cat-food'), + ), + ); + }); + + tearDown(() async => db.close()); + + Widget host() => ProviderScope( + overrides: [ + repositoryProvider.overrideWithValue(repo), + currentLedgerIdProvider.overrideWith((ref) => 1), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh'), + home: const BudgetEditPage(isCategory: true), + ), + ); + + Future pumpUi(WidgetTester tester) async { + // 页面头部可能存在持续主题动效,不能用 pumpAndSettle 等待动画归零。 + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + } + + Future disposeUi(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + // Riverpod 释放 Drift 查询流时会安排一个零时长计时器。 + // 此时页面中的持续动效已经移除,可以安全等待清理任务全部结束。 + await tester.pumpAndSettle(); + } + + testWidgets('分类选择器不再列出已经设置预算的分类', (tester) async { + await db.into(db.budgets).insert( + BudgetsCompanion.insert( + ledgerId: 1, + type: const Value('category'), + categoryId: const Value(1), + amount: 1000, + syncId: const Value('budget-housing'), + ), + ); + + await tester.pumpWidget(host()); + await pumpUi(tester); + await tester.tap(find.text('请选择预算分类')); + await pumpUi(tester); + + expect(find.text('住房'), findsNothing); + expect(find.text('餐饮'), findsOneWidget); + + // 关闭弹窗,让 _selectCategory 的 Future 完整结束。 + await tester.tap(find.byIcon(Icons.close)); + await pumpUi(tester); + await disposeUi(tester); + }); + + testWidgets('选择分类后若同步进同分类预算,保存时阻止重复创建', (tester) async { + await tester.pumpWidget(host()); + await pumpUi(tester); + await tester.tap(find.text('请选择预算分类')); + await pumpUi(tester); + await tester.tap(find.text('住房')); + await pumpUi(tester); + + // 模拟选择器关闭后,另一设备同步进同分类预算。 + await db.into(db.budgets).insert( + BudgetsCompanion.insert( + ledgerId: 1, + type: const Value('category'), + categoryId: const Value(1), + amount: 1000, + syncId: const Value('budget-remote'), + ), + ); + + await tester.enterText(find.byType(TextField).last, '2000'); + await tester.tap(find.text('保存')); + await tester.pump(); + + expect(find.text('该分类已设置预算'), findsOneWidget); + expect(await db.select(db.budgets).get(), hasLength(1)); + + // showToastOnOverlay 的移除计时器。 + await tester.pump(const Duration(seconds: 3)); + await disposeUi(tester); + }); +} diff --git a/test/widgets/budget_page_test.dart b/test/widgets/budget_page_test.dart new file mode 100644 index 000000000..7ce2eabfd --- /dev/null +++ b/test/widgets/budget_page_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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/budget_repository.dart'; +import 'package:beecount/l10n/app_localizations.dart'; +import 'package:beecount/pages/budget/budget_page.dart'; +import 'package:beecount/providers/budget_providers.dart'; +import 'package:beecount/providers/database_providers.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Ledger ledger() => Ledger( + id: 1, + name: '测试账本', + currency: 'CNY', + type: 'personal', + createdAt: DateTime(2026, 1, 1), + syncId: 'ledger-1', + myRole: 'owner', + memberCount: 2, + isShared: true, + monthStartDay: 1, + ); + + testWidgets('只有分类预算、没有总预算时仍显示分类预算列表', (tester) async { + final overview = BudgetOverview( + totalBudget: null, + categoryBudgets: [ + CategoryBudgetUsage( + budgetId: 1, + categoryId: 10, + categoryName: '住房', + categoryIcon: 'home', + usage: BudgetUsage(used: 100, budget: 1000), + ), + ], + daysRemaining: 10, + dailyAvailable: 0, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentLedgerProvider.overrideWith( + (ref) => Stream.value(ledger()), + ), + budgetOverviewProvider.overrideWith((ref) async => overview), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh'), + home: const BudgetPage(), + ), + ), + ); + // PrimaryHeader 下包含可持续运行的主题动效;固定推进测试时钟,避免 + // pumpAndSettle 等待无穷动画直到超时。 + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('分类预算'), findsOneWidget); + expect(find.text('住房'), findsOneWidget); + expect(find.text('还没有设置预算'), findsNothing); + }); +}