diff --git a/frontend/lib/features/add/add_page.dart b/frontend/lib/features/add/add_page.dart index cfc0eb1..1ffb2d1 100644 --- a/frontend/lib/features/add/add_page.dart +++ b/frontend/lib/features/add/add_page.dart @@ -188,6 +188,7 @@ class _AddPageState extends State { note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(), paymentMethod: _paymentMethod, occurredAt: _occurredAt, + localFirst: true, ); TransactionEvents.notifyChanged(); if (mounted) context.pop(amount); diff --git a/frontend/lib/features/settings/category_manage_page.dart b/frontend/lib/features/settings/category_manage_page.dart index caaf531..f3556e0 100644 --- a/frontend/lib/features/settings/category_manage_page.dart +++ b/frontend/lib/features/settings/category_manage_page.dart @@ -65,7 +65,13 @@ class _CategoryManagePageState extends State { final result = await _showEditor(); if (result == null) return; try { - await CategoryApi.create(result.name, result.icon, result.color, _type); + await CategoryApi.create( + result.name, + result.icon, + result.color, + _type, + localFirst: true, + ); await _load(); } catch (error) { if (mounted) _showError(error); @@ -82,6 +88,7 @@ class _CategoryManagePageState extends State { iconKey: result.icon, colorKey: result.color, sortOrder: category.sortOrder, + localFirst: true, ); await _load(); } catch (error) { @@ -375,7 +382,7 @@ class _CategoryManagePageState extends State { ); if (!confirmed) return; try { - await CategoryApi.delete(category.id); + await CategoryApi.delete(category.id, localFirst: true); await _load(); } catch (error) { if (mounted) _showError(error); @@ -410,7 +417,7 @@ class _CategoryManagePageState extends State { final ids = _custom.map((category) => category.id).toList(); setState(() => _savingOrder = true); try { - await CategoryApi.reorder(_type, ids); + await CategoryApi.reorder(_type, ids, localFirst: true); if (!mounted) return; setState(() { _reordering = false; diff --git a/frontend/lib/shared/api/business_api.dart b/frontend/lib/shared/api/business_api.dart index b25355f..bc31296 100644 --- a/frontend/lib/shared/api/business_api.dart +++ b/frontend/lib/shared/api/business_api.dart @@ -8,6 +8,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/services/local_database.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; +import 'package:miaoji_zhang/shared/services/sync_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class TxItem { @@ -370,10 +371,7 @@ class TxApi { static Future> categories(String type) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { - return LocalDatabase.instance - .categories(type) - .map(CategoryItem.fromJson) - .toList(); + return categoriesLocal(type); } try { final response = await _dio.get( @@ -387,18 +385,17 @@ class TxApi { .toList(); } catch (error) { if (!isConnectivityError(error) || !session.isAccount) rethrow; - return LocalDatabase.instance - .categories(type) - .map(CategoryItem.fromJson) - .toList(); + return categoriesLocal(type); } } - static List categoriesLocal(String type) => LocalDatabase - .instance - .categories(type) - .map(CategoryItem.fromJson) - .toList(); + static List categoriesLocal(String type) { + LocalDatabase.instance.ensureDefaultCategories(type: type); + return LocalDatabase.instance + .categories(type) + .map(CategoryItem.fromJson) + .toList(); + } static Future> categoriesRemote(String type) async { final response = await _dio.get( @@ -407,8 +404,10 @@ class TxApi { ); final values = response.data as List; LocalDatabase.instance.cacheCategories(values, replaceType: type); - return values - .map((item) => CategoryItem.fromJson(item as Map)) + LocalDatabase.instance.ensureDefaultCategories(type: type); + return LocalDatabase.instance + .categories(type) + .map(CategoryItem.fromJson) .toList(); } @@ -429,6 +428,7 @@ class TxApi { String? recognitionOccurrenceId, String? evidenceFingerprint, String? recognitionConfidence, + bool localFirst = false, }) async { final payload = { 'ledgerId': _ledgerId, @@ -456,7 +456,10 @@ class TxApi { 'recognitionConfidence': recognitionConfidence, }; final session = SessionStore.instance; - if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) { + if (localFirst || + session.shouldUseLocalOnly || + categoryId < 0 || + _ledgerId < 0) { final local = LocalDatabase.instance.createTransaction(payload); if (_queueOfflineChanges) { LocalDatabase.instance.enqueueSync( @@ -465,6 +468,7 @@ class TxApi { 'create', payload, ); + _scheduleBackgroundSync(); } return TxItem.fromJson(local); } @@ -1698,8 +1702,9 @@ class CategoryApi { String name, String iconKey, String colorKey, - String type, - ) async { + String type, { + bool localFirst = false, + }) async { final payload = { 'name': name, 'iconKey': iconKey, @@ -1707,7 +1712,8 @@ class CategoryApi { 'type': type, }; final session = SessionStore.instance; - if (session.shouldUseLocalOnly || + if (localFirst || + session.shouldUseLocalOnly || ApiClient.availability.value == BackendAvailability.offline) { final local = LocalDatabase.instance.createCategory( name, @@ -1722,6 +1728,7 @@ class CategoryApi { 'create', payload, ); + _scheduleBackgroundSync(); } return CategoryItem.fromJson(local); } @@ -1754,6 +1761,7 @@ class CategoryApi { required String iconKey, required String colorKey, required int sortOrder, + bool localFirst = false, }) async { final payload = { 'name': name, @@ -1762,7 +1770,8 @@ class CategoryApi { 'sortOrder': sortOrder, }; final session = SessionStore.instance; - if (session.shouldUseLocalOnly || + if (localFirst || + session.shouldUseLocalOnly || ApiClient.availability.value == BackendAvailability.offline || id < 0) { final local = LocalDatabase.instance.updateCategory( @@ -1774,6 +1783,7 @@ class CategoryApi { ); if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync('category', id, 'update', payload); + _scheduleBackgroundSync(); } return CategoryItem.fromJson(local); } @@ -1796,10 +1806,15 @@ class CategoryApi { } } - static Future reorder(String type, List categoryIds) async { + static Future reorder( + String type, + List categoryIds, { + bool localFirst = false, + }) async { LocalDatabase.instance.reorderCategories(type, categoryIds); final session = SessionStore.instance; - if (session.shouldUseLocalOnly || + if (localFirst || + session.shouldUseLocalOnly || ApiClient.availability.value == BackendAvailability.offline || categoryIds.any((id) => id < 0)) { if (session.isAccount && session.cloudSyncEnabled) { @@ -1807,6 +1822,7 @@ class CategoryApi { 'type': type, 'categoryIds': categoryIds, }); + _scheduleBackgroundSync(); } return; } @@ -1824,9 +1840,10 @@ class CategoryApi { } } - static Future delete(int id) async { + static Future delete(int id, {bool localFirst = false}) async { final session = SessionStore.instance; - if (session.shouldUseLocalOnly || + if (localFirst || + session.shouldUseLocalOnly || ApiClient.availability.value == BackendAvailability.offline || id < 0) { LocalDatabase.instance.deleteCategory(id); @@ -1834,6 +1851,7 @@ class CategoryApi { LocalDatabase.instance.enqueueSync('category', id, 'delete', { 'id': id, }); + _scheduleBackgroundSync(); } return; } @@ -1847,3 +1865,8 @@ class CategoryApi { } } } + +void _scheduleBackgroundSync() { + SyncService.instance.refreshLocalStatus(); + unawaited(SyncService.instance.run()); +} diff --git a/frontend/lib/shared/services/local_database.dart b/frontend/lib/shared/services/local_database.dart index ccea0cf..9df2848 100644 --- a/frontend/lib/shared/services/local_database.dart +++ b/frontend/lib/shared/services/local_database.dart @@ -16,12 +16,12 @@ class LocalDatabase { static const _secureStorage = FlutterSecureStorage(); @visibleForTesting - static LocalDatabase inMemoryForTesting() { + static LocalDatabase inMemoryForTesting({bool seedDefaults = true}) { final database = LocalDatabase._(); database._database = sqlite3.openInMemory(); database._namespace = 'test'; database._migrate(); - database._seedDefaults(); + if (seedDefaults) database._seedDefaults(); return database; } @@ -69,7 +69,11 @@ class LocalDatabase { _database = database; _namespace = namespace; _migrate(); - if (namespace == 'guest') _seedDefaults(); + if (namespace == 'guest') { + _seedDefaults(); + } else { + ensureDefaultCategories(); + } } catch (_) { database.close(); rethrow; @@ -295,6 +299,80 @@ class LocalDatabase { } } + /// Ensures an account can still render the category picker and create a + /// manual transaction before its first successful category refresh. + /// + /// Account fallback IDs mirror the server's seeded system categories. The + /// remote refresh remains authoritative and will update these rows in place. + void ensureDefaultCategories({String? type}) { + final isGuestNamespace = _namespace == 'guest'; + const expense = <(int, String, String, String, String, int)>[ + (1, '餐饮', 'food', 'coral', 'expense', 0), + (2, '饮品', 'cup', 'cyan', 'expense', 1), + (3, '购物', 'cart', 'blue', 'expense', 2), + (4, '交通', 'metro', 'teal', 'expense', 3), + (5, '住房', 'house', 'sand', 'expense', 4), + (6, '娱乐', 'game', 'violet', 'expense', 5), + (7, '医疗', 'pill', 'red', 'expense', 6), + (8, '学习', 'book', 'indigo', 'expense', 7), + (9, '服饰', 'shirt', 'plum', 'expense', 8), + (10, '人情', 'gift', 'rose', 'expense', 9), + (11, '旅行', 'plane', 'sky', 'expense', 10), + (12, '其他', 'tag', 'graphite', 'expense', 11), + ]; + const accountIncome = <(int, String, String, String, String, int)>[ + (13, '工资', 'money', 'mint', 'income', 0), + (14, '兼职', 'briefcase', 'forest', 'income', 1), + (15, '理财', 'chart', 'navy', 'income', 2), + (16, '红包', 'gift', 'orange', 'income', 3), + (17, '报销', 'card', 'amber', 'income', 4), + (18, '奖金', 'sparkle', 'aqua', 'income', 5), + (19, '其他', 'tag', 'lime', 'income', 6), + ]; + const guestIncome = <(int, String, String, String, String, int)>[ + (101, '工资', 'money', 'mint', 'income', 10), + (102, '兼职', 'parttime', 'forest', 'income', 20), + (103, '理财', 'invest', 'navy', 'income', 30), + (104, '红包', 'redpacket', 'orange', 'income', 40), + (105, '报销', 'reimburse', 'amber', 'income', 50), + (106, '奖金', 'bonus', 'aqua', 'income', 60), + (107, '其他', 'tag', 'lime', 'income', 70), + ]; + final catalogs = >{ + 'expense': expense, + 'income': isGuestNamespace ? guestIncome : accountIncome, + }; + final now = DateTime.now().toUtc().toIso8601String(); + for (final entry in catalogs.entries) { + if (type != null && entry.key != type) continue; + final active = _db.select( + '''SELECT 1 FROM categories + WHERE type = ? AND is_deleted = 0 AND is_custom = 0 LIMIT 1''', + [entry.key], + ); + if (active.isNotEmpty) continue; + for (final item in entry.value) { + _db.execute( + ''' + INSERT INTO categories + (id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + icon_key = excluded.icon_key, + color_key = excluded.color_key, + type = excluded.type, + sort_order = excluded.sort_order, + is_custom = 0, + is_deleted = 0, + updated_at = excluded.updated_at + ''', + [item.$1, item.$2, item.$3, item.$4, item.$5, item.$6, now], + ); + } + } + } + Map guestSnapshot() { final customCategories = _db .select(''' diff --git a/frontend/test/offline_local_first_test.dart b/frontend/test/offline_local_first_test.dart index fb1485b..58d17fc 100644 --- a/frontend/test/offline_local_first_test.dart +++ b/frontend/test/offline_local_first_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/auth_api.dart'; +import 'package:miaoji_zhang/shared/services/local_database.dart'; import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; void main() { @@ -79,6 +80,31 @@ void main() { expect(companion.toJson(), source); }); + test('账号分类缓存为空时会补齐支出和收入兜底并允许本地记账', () { + final database = LocalDatabase.inMemoryForTesting(seedDefaults: false); + addTearDown(database.close); + + database.ensureDefaultCategories(); + final expense = database.categories('expense'); + final income = database.categories('income'); + + expect(expense, hasLength(12)); + expect(income, hasLength(7)); + expect(expense.first['name'], '餐饮'); + expect(income.first['name'], '工资'); + expect(income.first['id'], 13); + + final transaction = database.createTransaction({ + 'ledgerId': 1, + 'categoryId': expense.first['id'], + 'type': 'expense', + 'amount': 18.5, + 'occurredAt': DateTime.utc(2026, 8, 21).toIso8601String(), + }); + expect(transaction['amount'], 18.5); + expect(transaction['categoryName'], '餐饮'); + }); + test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () { final contracts = >{ 'lib/features/home/pages/home_page.dart': [ @@ -92,6 +118,7 @@ void main() { "categoriesLocal('expense')", "categoriesRemote('expense')", "categoriesRemote('income')", + 'localFirst: true', ], 'lib/features/stats/stats_page.dart': [ 'periodStatsLocal', @@ -104,6 +131,7 @@ void main() { 'lib/features/settings/category_manage_page.dart': [ 'categoriesLocal', 'categoriesRemote', + 'localFirst: true', ], 'lib/features/settings/recycle_bin_page.dart': [ 'recycleBinLocal',