fix: support offline manual bookkeeping

This commit is contained in:
2026-08-21 15:43:01 +08:00
parent 9bb45aac83
commit 979684a06e
5 changed files with 167 additions and 30 deletions
@@ -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 = <String, List<(int, String, String, String, String, int)>>{
'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<String, dynamic> guestSnapshot() {
final customCategories = _db
.select('''