feat: make core pages local-first offline

This commit is contained in:
2026-08-21 12:40:01 +08:00
parent 8828851631
commit fefa2d74e6
20 changed files with 921 additions and 188 deletions
+57 -30
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
@@ -7,6 +9,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class AddPage extends StatefulWidget {
@@ -36,6 +39,7 @@ class _AddPageState extends State<AddPage> {
DateTime _occurredAt = ShanghaiTime.now;
bool _saving = false;
bool _loadingCategories = true;
int _loadRevision = 0;
String get _categoryType => _tab == 'transfer'
? _transferDirection == 'in'
? 'income'
@@ -60,7 +64,7 @@ class _AddPageState extends State<AddPage> {
@override
void initState() {
super.initState();
_loadCategories();
unawaited(_loadCategories());
}
@override
@@ -71,38 +75,60 @@ class _AddPageState extends State<AddPage> {
}
Future<void> _loadCategories() async {
setState(() => _loadingCategories = true);
try {
final results = await Future.wait([
TxApi.categories('expense'),
TxApi.categories('income'),
]);
if (!mounted) return;
final revision = ++_loadRevision;
final expense = TxApi.categoriesLocal('expense');
final income = TxApi.categoriesLocal('income');
if (mounted) {
setState(() {
_categoriesByType['expense'] = results[0];
_categoriesByType['income'] = results[1];
if (_selectedByType['expense'] == null && results[0].isNotEmpty) {
_selectedByType['expense'] = results[0].first;
}
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
_selectedByType['income'] = results[1].first;
}
_selectedByType['transfer_out'] ??= results[0].isEmpty
? null
: results[0].first;
_selectedByType['transfer_in'] ??= results[1].isEmpty
? null
: results[1].first;
_applyCategories(expense, income);
_loadingCategories = false;
});
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _loadingCategories = false);
}
await Future.wait([
TxApi.categoriesRemote('expense').then<void>((values) {
if (!mounted || revision != _loadRevision) return;
setState(() {
_applyCategories(values, _categoriesByType['income'] ?? const []);
});
}, onError: (_) {}),
TxApi.categoriesRemote('income').then<void>((values) {
if (!mounted || revision != _loadRevision) return;
setState(() {
_applyCategories(_categoriesByType['expense'] ?? const [], values);
});
}, onError: (_) {}),
]);
}
void _applyCategories(List<CategoryItem> expense, List<CategoryItem> income) {
_categoriesByType['expense'] = expense;
_categoriesByType['income'] = income;
_selectedByType['expense'] = _preserveSelection(
_selectedByType['expense'],
expense,
);
_selectedByType['income'] = _preserveSelection(
_selectedByType['income'],
income,
);
_selectedByType['transfer_out'] = _preserveSelection(
_selectedByType['transfer_out'],
expense,
);
_selectedByType['transfer_in'] = _preserveSelection(
_selectedByType['transfer_in'],
income,
);
}
CategoryItem? _preserveSelection(
CategoryItem? selected,
List<CategoryItem> values,
) {
if (values.isEmpty) return null;
if (selected == null) return values.first;
return values.where((item) => item.id == selected.id).firstOrNull ??
values.first;
}
void _switchTab(String tab) {
@@ -239,6 +265,7 @@ class _AddPageState extends State<AddPage> {
),
onPressed: () => context.pop(),
),
actions: [BackendStatusIcon(onRetry: _loadCategories)],
),
body: SafeArea(
child: LayoutBuilder(