fix: support offline manual bookkeeping
This commit is contained in:
@@ -188,6 +188,7 @@ class _AddPageState extends State<AddPage> {
|
||||
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
|
||||
paymentMethod: _paymentMethod,
|
||||
occurredAt: _occurredAt,
|
||||
localFirst: true,
|
||||
);
|
||||
TransactionEvents.notifyChanged();
|
||||
if (mounted) context.pop(amount);
|
||||
|
||||
@@ -65,7 +65,13 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
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<CategoryManagePage> {
|
||||
iconKey: result.icon,
|
||||
colorKey: result.color,
|
||||
sortOrder: category.sortOrder,
|
||||
localFirst: true,
|
||||
);
|
||||
await _load();
|
||||
} catch (error) {
|
||||
@@ -375,7 +382,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
);
|
||||
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<CategoryManagePage> {
|
||||
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;
|
||||
|
||||
@@ -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<List<CategoryItem>> 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<CategoryItem> categoriesLocal(String type) => LocalDatabase
|
||||
.instance
|
||||
.categories(type)
|
||||
.map(CategoryItem.fromJson)
|
||||
.toList();
|
||||
static List<CategoryItem> categoriesLocal(String type) {
|
||||
LocalDatabase.instance.ensureDefaultCategories(type: type);
|
||||
return LocalDatabase.instance
|
||||
.categories(type)
|
||||
.map(CategoryItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<List<CategoryItem>> 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<String, dynamic>))
|
||||
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 = <String, dynamic>{
|
||||
'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<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);
|
||||
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<void> delete(int id) async {
|
||||
static Future<void> 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());
|
||||
}
|
||||
|
||||
@@ -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('''
|
||||
|
||||
Reference in New Issue
Block a user