Skip to content
67 changes: 45 additions & 22 deletions lib/data/repositories/local/local_budget_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -78,8 +78,7 @@ class LocalBudgetRepository implements BudgetRepository {
@override
Future<void> 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;
Expand All @@ -99,28 +98,43 @@ class LocalBudgetRepository implements BudgetRepository {
Future<Budget?> 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;
}

@override
Future<List<Budget>> 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 = <int, Budget>{};
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<Budget?> 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
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_ko.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1746,6 +1746,7 @@
"budgetAmountHint": "예산 금액을 입력하세요",
"budgetCategoryLabel": "카테고리 선택",
"budgetCategoryHint": "예산 카테고리를 선택하세요",
"budgetCategoryAlreadyExists": "이 카테고리에는 이미 예산이 있습니다",
"budgetStartDayLabel": "시작일",
"budgetPeriodLabel": "기간",
"budgetSaveSuccess": "예산이 저장되었습니다",
Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_localizations_ko.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5740,6 +5740,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get budgetCategoryHint => '예산 카테고리를 선택하세요';

@override
String get budgetCategoryAlreadyExists => '이 카테고리에는 이미 예산이 있습니다';

@override
String get budgetStartDayLabel => '시작일';

Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/app_localizations_zh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5740,6 +5740,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get budgetCategoryHint => '请选择预算分类';

@override
String get budgetCategoryAlreadyExists => '该分类已设置预算';

@override
String get budgetStartDayLabel => '起始日';

Expand Down Expand Up @@ -13252,6 +13255,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get budgetCategoryHint => '請選擇預算分類';

@override
String get budgetCategoryAlreadyExists => '該分類已設定預算';

@override
String get budgetStartDayLabel => '起始日';

Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2415,6 +2415,7 @@
"budgetAmountHint": "请输入预算金额",
"budgetCategoryLabel": "选择分类",
"budgetCategoryHint": "请选择预算分类",
"budgetCategoryAlreadyExists": "该分类已设置预算",
"budgetStartDayLabel": "起始日",
"budgetPeriodLabel": "周期",
"budgetSaveSuccess": "预算保存成功",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_zh_TW.arb
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@
"budgetAmountLabel": "預算金額",
"budgetCategoryBudgets": "分類預算",
"budgetCategoryHint": "請選擇預算分類",
"budgetCategoryAlreadyExists": "該分類已設定預算",
"budgetCategoryLabel": "選擇分類",
"budgetDailyAvailable": "日均可用 {amount}",
"@budgetDailyAvailable": {
Expand Down
63 changes: 50 additions & 13 deletions lib/pages/budget/budget_edit_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,11 @@ class _BudgetEditPageState extends ConsumerState<BudgetEditPage> {
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,
Expand Down Expand Up @@ -257,7 +259,8 @@ class _BudgetEditPageState extends ConsumerState<BudgetEditPage> {
: 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,
),
),
Expand All @@ -266,15 +269,21 @@ class _BudgetEditPageState extends ConsumerState<BudgetEditPage> {
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),
),
),
],
Expand Down Expand Up @@ -302,7 +311,10 @@ class _BudgetEditPageState extends ConsumerState<BudgetEditPage> {
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(
Expand Down Expand Up @@ -350,11 +362,22 @@ class _BudgetEditPageState extends ConsumerState<BudgetEditPage> {

Future<void> _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;
Expand Down Expand Up @@ -461,12 +484,26 @@ class _BudgetEditPageState extends ConsumerState<BudgetEditPage> {
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,
Expand Down
24 changes: 15 additions & 9 deletions lib/pages/budget/budget_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -75,21 +80,22 @@ 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),
],
);
}


Widget _buildEmptyState(
BuildContext context, WidgetRef ref, AppLocalizations l10n) {
// §7 共享账本 Editor 视角:预算空时不显示"添加"CTA(owner-only)
Expand Down Expand Up @@ -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),
Expand Down
Loading