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
+1
View File
@@ -188,6 +188,7 @@ class _AddPageState extends State<AddPage> {
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(), note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
paymentMethod: _paymentMethod, paymentMethod: _paymentMethod,
occurredAt: _occurredAt, occurredAt: _occurredAt,
localFirst: true,
); );
TransactionEvents.notifyChanged(); TransactionEvents.notifyChanged();
if (mounted) context.pop(amount); if (mounted) context.pop(amount);
@@ -65,7 +65,13 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
final result = await _showEditor(); final result = await _showEditor();
if (result == null) return; if (result == null) return;
try { 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(); await _load();
} catch (error) { } catch (error) {
if (mounted) _showError(error); if (mounted) _showError(error);
@@ -82,6 +88,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
iconKey: result.icon, iconKey: result.icon,
colorKey: result.color, colorKey: result.color,
sortOrder: category.sortOrder, sortOrder: category.sortOrder,
localFirst: true,
); );
await _load(); await _load();
} catch (error) { } catch (error) {
@@ -375,7 +382,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
); );
if (!confirmed) return; if (!confirmed) return;
try { try {
await CategoryApi.delete(category.id); await CategoryApi.delete(category.id, localFirst: true);
await _load(); await _load();
} catch (error) { } catch (error) {
if (mounted) _showError(error); if (mounted) _showError(error);
@@ -410,7 +417,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
final ids = _custom.map((category) => category.id).toList(); final ids = _custom.map((category) => category.id).toList();
setState(() => _savingOrder = true); setState(() => _savingOrder = true);
try { try {
await CategoryApi.reorder(_type, ids); await CategoryApi.reorder(_type, ids, localFirst: true);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_reordering = false; _reordering = false;
+47 -24
View File
@@ -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/local_database.dart';
import 'package:miaoji_zhang/shared/services/session_store.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/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class TxItem { class TxItem {
@@ -370,10 +371,7 @@ class TxApi {
static Future<List<CategoryItem>> categories(String type) async { static Future<List<CategoryItem>> categories(String type) async {
final session = SessionStore.instance; final session = SessionStore.instance;
if (session.shouldUseLocalOnly) { if (session.shouldUseLocalOnly) {
return LocalDatabase.instance return categoriesLocal(type);
.categories(type)
.map(CategoryItem.fromJson)
.toList();
} }
try { try {
final response = await _dio.get( final response = await _dio.get(
@@ -387,18 +385,17 @@ class TxApi {
.toList(); .toList();
} catch (error) { } catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow; if (!isConnectivityError(error) || !session.isAccount) rethrow;
return LocalDatabase.instance return categoriesLocal(type);
.categories(type)
.map(CategoryItem.fromJson)
.toList();
} }
} }
static List<CategoryItem> categoriesLocal(String type) => LocalDatabase static List<CategoryItem> categoriesLocal(String type) {
.instance LocalDatabase.instance.ensureDefaultCategories(type: type);
.categories(type) return LocalDatabase.instance
.map(CategoryItem.fromJson) .categories(type)
.toList(); .map(CategoryItem.fromJson)
.toList();
}
static Future<List<CategoryItem>> categoriesRemote(String type) async { static Future<List<CategoryItem>> categoriesRemote(String type) async {
final response = await _dio.get( final response = await _dio.get(
@@ -407,8 +404,10 @@ class TxApi {
); );
final values = response.data as List; final values = response.data as List;
LocalDatabase.instance.cacheCategories(values, replaceType: type); LocalDatabase.instance.cacheCategories(values, replaceType: type);
return values LocalDatabase.instance.ensureDefaultCategories(type: type);
.map((item) => CategoryItem.fromJson(item as Map<String, dynamic>)) return LocalDatabase.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList(); .toList();
} }
@@ -429,6 +428,7 @@ class TxApi {
String? recognitionOccurrenceId, String? recognitionOccurrenceId,
String? evidenceFingerprint, String? evidenceFingerprint,
String? recognitionConfidence, String? recognitionConfidence,
bool localFirst = false,
}) async { }) async {
final payload = <String, dynamic>{ final payload = <String, dynamic>{
'ledgerId': _ledgerId, 'ledgerId': _ledgerId,
@@ -456,7 +456,10 @@ class TxApi {
'recognitionConfidence': recognitionConfidence, 'recognitionConfidence': recognitionConfidence,
}; };
final session = SessionStore.instance; 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); final local = LocalDatabase.instance.createTransaction(payload);
if (_queueOfflineChanges) { if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync( LocalDatabase.instance.enqueueSync(
@@ -465,6 +468,7 @@ class TxApi {
'create', 'create',
payload, payload,
); );
_scheduleBackgroundSync();
} }
return TxItem.fromJson(local); return TxItem.fromJson(local);
} }
@@ -1698,8 +1702,9 @@ class CategoryApi {
String name, String name,
String iconKey, String iconKey,
String colorKey, String colorKey,
String type, String type, {
) async { bool localFirst = false,
}) async {
final payload = { final payload = {
'name': name, 'name': name,
'iconKey': iconKey, 'iconKey': iconKey,
@@ -1707,7 +1712,8 @@ class CategoryApi {
'type': type, 'type': type,
}; };
final session = SessionStore.instance; final session = SessionStore.instance;
if (session.shouldUseLocalOnly || if (localFirst ||
session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline) { ApiClient.availability.value == BackendAvailability.offline) {
final local = LocalDatabase.instance.createCategory( final local = LocalDatabase.instance.createCategory(
name, name,
@@ -1722,6 +1728,7 @@ class CategoryApi {
'create', 'create',
payload, payload,
); );
_scheduleBackgroundSync();
} }
return CategoryItem.fromJson(local); return CategoryItem.fromJson(local);
} }
@@ -1754,6 +1761,7 @@ class CategoryApi {
required String iconKey, required String iconKey,
required String colorKey, required String colorKey,
required int sortOrder, required int sortOrder,
bool localFirst = false,
}) async { }) async {
final payload = { final payload = {
'name': name, 'name': name,
@@ -1762,7 +1770,8 @@ class CategoryApi {
'sortOrder': sortOrder, 'sortOrder': sortOrder,
}; };
final session = SessionStore.instance; final session = SessionStore.instance;
if (session.shouldUseLocalOnly || if (localFirst ||
session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline || ApiClient.availability.value == BackendAvailability.offline ||
id < 0) { id < 0) {
final local = LocalDatabase.instance.updateCategory( final local = LocalDatabase.instance.updateCategory(
@@ -1774,6 +1783,7 @@ class CategoryApi {
); );
if (session.isAccount && session.cloudSyncEnabled) { if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', id, 'update', payload); LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
_scheduleBackgroundSync();
} }
return CategoryItem.fromJson(local); return CategoryItem.fromJson(local);
} }
@@ -1796,10 +1806,15 @@ class CategoryApi {
} }
} }
static Future<void> reorder(String type, List<int> categoryIds) async { static Future<void> reorder(
String type,
List<int> categoryIds, {
bool localFirst = false,
}) async {
LocalDatabase.instance.reorderCategories(type, categoryIds); LocalDatabase.instance.reorderCategories(type, categoryIds);
final session = SessionStore.instance; final session = SessionStore.instance;
if (session.shouldUseLocalOnly || if (localFirst ||
session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline || ApiClient.availability.value == BackendAvailability.offline ||
categoryIds.any((id) => id < 0)) { categoryIds.any((id) => id < 0)) {
if (session.isAccount && session.cloudSyncEnabled) { if (session.isAccount && session.cloudSyncEnabled) {
@@ -1807,6 +1822,7 @@ class CategoryApi {
'type': type, 'type': type,
'categoryIds': categoryIds, 'categoryIds': categoryIds,
}); });
_scheduleBackgroundSync();
} }
return; return;
} }
@@ -1824,9 +1840,10 @@ class CategoryApi {
} }
} }
static Future<void> delete(int id) async { static Future<void> delete(int id, {bool localFirst = false}) async {
final session = SessionStore.instance; final session = SessionStore.instance;
if (session.shouldUseLocalOnly || if (localFirst ||
session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline || ApiClient.availability.value == BackendAvailability.offline ||
id < 0) { id < 0) {
LocalDatabase.instance.deleteCategory(id); LocalDatabase.instance.deleteCategory(id);
@@ -1834,6 +1851,7 @@ class CategoryApi {
LocalDatabase.instance.enqueueSync('category', id, 'delete', { LocalDatabase.instance.enqueueSync('category', id, 'delete', {
'id': id, 'id': id,
}); });
_scheduleBackgroundSync();
} }
return; return;
} }
@@ -1847,3 +1865,8 @@ class CategoryApi {
} }
} }
} }
void _scheduleBackgroundSync() {
SyncService.instance.refreshLocalStatus();
unawaited(SyncService.instance.run());
}
@@ -16,12 +16,12 @@ class LocalDatabase {
static const _secureStorage = FlutterSecureStorage(); static const _secureStorage = FlutterSecureStorage();
@visibleForTesting @visibleForTesting
static LocalDatabase inMemoryForTesting() { static LocalDatabase inMemoryForTesting({bool seedDefaults = true}) {
final database = LocalDatabase._(); final database = LocalDatabase._();
database._database = sqlite3.openInMemory(); database._database = sqlite3.openInMemory();
database._namespace = 'test'; database._namespace = 'test';
database._migrate(); database._migrate();
database._seedDefaults(); if (seedDefaults) database._seedDefaults();
return database; return database;
} }
@@ -69,7 +69,11 @@ class LocalDatabase {
_database = database; _database = database;
_namespace = namespace; _namespace = namespace;
_migrate(); _migrate();
if (namespace == 'guest') _seedDefaults(); if (namespace == 'guest') {
_seedDefaults();
} else {
ensureDefaultCategories();
}
} catch (_) { } catch (_) {
database.close(); database.close();
rethrow; 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() { Map<String, dynamic> guestSnapshot() {
final customCategories = _db final customCategories = _db
.select(''' .select('''
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.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'; import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
void main() { void main() {
@@ -79,6 +80,31 @@ void main() {
expect(companion.toJson(), source); 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('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () { test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () {
final contracts = <String, List<String>>{ final contracts = <String, List<String>>{
'lib/features/home/pages/home_page.dart': [ 'lib/features/home/pages/home_page.dart': [
@@ -92,6 +118,7 @@ void main() {
"categoriesLocal('expense')", "categoriesLocal('expense')",
"categoriesRemote('expense')", "categoriesRemote('expense')",
"categoriesRemote('income')", "categoriesRemote('income')",
'localFirst: true',
], ],
'lib/features/stats/stats_page.dart': [ 'lib/features/stats/stats_page.dart': [
'periodStatsLocal', 'periodStatsLocal',
@@ -104,6 +131,7 @@ void main() {
'lib/features/settings/category_manage_page.dart': [ 'lib/features/settings/category_manage_page.dart': [
'categoriesLocal', 'categoriesLocal',
'categoriesRemote', 'categoriesRemote',
'localFirst: true',
], ],
'lib/features/settings/recycle_bin_page.dart': [ 'lib/features/settings/recycle_bin_page.dart': [
'recycleBinLocal', 'recycleBinLocal',