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
@@ -4,6 +4,7 @@ import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
class CategoryManagePage extends StatefulWidget {
@@ -20,6 +21,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
bool _reordering = false;
bool _savingOrder = false;
bool _orderDirty = false;
int _loadRevision = 0;
List<CategoryItem> get _custom =>
_cats.where((category) => category.isCustom).toList();
@@ -31,19 +33,31 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
}
Future<void> _load() async {
final revision = ++_loadRevision;
setState(() => _loading = true);
try {
final cats = await TxApi.categories(_type);
if (mounted) {
final type = _type;
final cached = TxApi.categoriesLocal(type);
if (mounted && revision == _loadRevision) {
setState(() {
_cats = cats;
_cats = cached;
_orderDirty = false;
_loading = false;
});
}
final remote = await TxApi.categoriesRemote(type);
if (mounted && revision == _loadRevision && type == _type) {
setState(() => _cats = remote);
}
} catch (error) {
if (mounted) _showError(error);
if (mounted && revision == _loadRevision && _loading) {
setState(() => _loading = false);
_showError(error);
}
} finally {
if (mounted) setState(() => _loading = false);
if (mounted && revision == _loadRevision && _loading) {
setState(() => _loading = false);
}
}
}
@@ -424,6 +438,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
appBar: AppBar(
title: Text('分类管理'),
actions: [
BackendStatusIcon(onRetry: _load),
if (_custom.length > 1)
TextButton(
onPressed: _savingOrder ? null : _toggleReorder,
@@ -5,6 +5,7 @@ import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
/// AI 性格设置页(P5):形象/性格从后台 API 动态拉取
class CompanionPage extends StatefulWidget {
@@ -16,7 +17,9 @@ class CompanionPage extends StatefulWidget {
class _CompanionPageState extends State<CompanionPage> {
String _avatar = 'cat', _persona = 'sassy_cat';
double _roast = 60, _sticker = 70, _proactive = 40;
bool _saving = false, _loaded = false;
bool _saving = false;
bool _refreshing = false;
int _loadRevision = 0;
List<AvatarItem> _avatars = [];
List<PersonaItem> _personas = [];
@@ -40,27 +43,52 @@ class _CompanionPageState extends State<CompanionPage> {
}
Future<void> _load() async {
try {
final p = await AuthApi.me();
if (mounted && p.aiCompanion != null)
setState(() {
_avatar = p.aiCompanion!.avatarKey;
_persona = p.aiCompanion!.personaKey;
_roast = p.aiCompanion!.roastLevel.toDouble();
_sticker = p.aiCompanion!.stickerFrequency.toDouble();
_proactive = p.aiCompanion!.proactiveLevel.toDouble();
});
} catch (_) {}
try {
final av = await PublicConfigApi.avatars();
final ps = await PublicConfigApi.personas();
if (mounted)
setState(() {
_avatars = av;
_personas = ps;
});
} catch (_) {}
if (mounted) setState(() => _loaded = true);
final revision = ++_loadRevision;
setState(() => _refreshing = true);
final cached = await Future.wait<Object?>([
AuthApi.cachedCompanion(),
PublicConfigApi.cachedAvatars(),
PublicConfigApi.cachedPersonas(),
]);
if (!mounted || revision != _loadRevision) return;
_applyCompanion(cached[0] as AiCompanion?);
setState(() {
_avatars = cached[1] as List<AvatarItem>;
_personas = cached[2] as List<PersonaItem>;
});
await Future.wait([
AuthApi.me(forceRemote: true).then<void>((profile) {
if (mounted && revision == _loadRevision) {
_applyCompanion(profile.aiCompanion);
}
}, onError: (_) {}),
PublicConfigApi.avatars().then<void>((values) {
if (mounted && revision == _loadRevision) {
setState(() => _avatars = values);
}
}, onError: (_) {}),
PublicConfigApi.personas().then<void>((values) {
if (mounted && revision == _loadRevision) {
setState(() => _personas = values);
}
}, onError: (_) {}),
]);
if (mounted && revision == _loadRevision) {
setState(() => _refreshing = false);
}
}
void _applyCompanion(AiCompanion? companion) {
if (companion == null || !mounted) return;
setState(() {
_avatar = companion.avatarKey;
_persona = companion.personaKey;
_roast = companion.roastLevel.toDouble();
_sticker = companion.stickerFrequency.toDouble();
_proactive = companion.proactiveLevel.toDouble();
});
}
Future<void> _save() async {
@@ -80,26 +108,20 @@ class _CompanionPageState extends State<CompanionPage> {
context,
).showSnackBar(const SnackBar(content: Text('已保存')));
} catch (e) {
if (mounted)
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
}
} finally {
if (mounted) setState(() => _saving = false);
if (mounted) {
setState(() => _saving = false);
}
}
}
@override
Widget build(BuildContext context) {
if (!_loaded)
return const Scaffold(
body: Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
),
);
final avatars = _avatars.isNotEmpty
? _avatars
.map(
@@ -122,7 +144,16 @@ class _CompanionPageState extends State<CompanionPage> {
: _fallbackPersonas;
return Scaffold(
appBar: AppBar(title: Text('AI 性格设置')),
appBar: AppBar(
title: Text('AI 性格设置'),
actions: [BackendStatusIcon(onRetry: _load)],
bottom: _refreshing
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(minHeight: 2),
)
: null,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -236,19 +267,24 @@ class _CompanionPageState extends State<CompanionPage> {
),
),
SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _save,
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('保存设置'),
ValueListenableBuilder<BackendAvailability>(
valueListenable: ApiClient.availability,
builder: (context, availability, _) => ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: availability == BackendAvailability.online && !_saving
? _save
: null,
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('保存设置'),
),
),
],
),
@@ -6,6 +6,7 @@ import 'package:miaoji_zhang/shared/services/transaction_events.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.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';
class RecycleBinPage extends StatefulWidget {
const RecycleBinPage({super.key});
@@ -17,6 +18,7 @@ class RecycleBinPage extends StatefulWidget {
class _RecycleBinPageState extends State<RecycleBinPage> {
List<TxItem> _items = const [];
bool _loading = true;
int _loadRevision = 0;
@override
void initState() {
@@ -25,15 +27,30 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
}
Future<void> _load() async {
final revision = ++_loadRevision;
setState(() => _loading = true);
try {
await CurrentLedgerStore.instance.ensureLoaded();
final items = await TxApi.recycleBin();
if (mounted) setState(() => _items = items);
await CurrentLedgerStore.instance.loadCached();
final cached = TxApi.recycleBinLocal();
if (mounted && revision == _loadRevision) {
setState(() {
_items = cached;
_loading = false;
});
}
final remote = await TxApi.recycleBinRemote();
if (mounted && revision == _loadRevision) {
setState(() => _items = remote);
}
} catch (error) {
if (mounted) _showError(error);
if (mounted && revision == _loadRevision && _loading) {
setState(() => _loading = false);
_showError(error);
}
} finally {
if (mounted) setState(() => _loading = false);
if (mounted && revision == _loadRevision && _loading) {
setState(() => _loading = false);
}
}
}
@@ -48,6 +65,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
}
Future<void> _permanentDelete(TxItem item) async {
if (ApiClient.availability.value != BackendAvailability.online) return;
final confirmed = await _confirm('永久删除', '永久删除后无法恢复,聊天中的账单卡片会显示为已删除。');
if (!confirmed) return;
try {
@@ -59,7 +77,10 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
}
Future<void> _clear() async {
if (_items.isEmpty) return;
if (_items.isEmpty ||
ApiClient.availability.value != BackendAvailability.online) {
return;
}
final confirmed = await _confirm('清空回收站', '将永久删除当前账本回收站中的全部账单,无法恢复。');
if (!confirmed) return;
try {
@@ -79,12 +100,14 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
);
Future<void> _showActions(TxItem item) async {
final online = ApiClient.availability.value == BackendAvailability.online;
final action = await showJzOptionSheet<String>(
context,
title: item.note ?? item.categoryName,
options: const [
JzOption(value: 'restore', label: '恢复账单'),
JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
options: [
const JzOption(value: 'restore', label: '恢复账单'),
if (online)
const JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
],
);
if (action == 'restore') await _restore(item);
@@ -103,9 +126,16 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
appBar: AppBar(
title: Text('${CurrentLedgerStore.instance.currentName} · 回收站'),
actions: [
TextButton(
onPressed: _items.isEmpty ? null : _clear,
child: Text('清空'),
BackendStatusIcon(onRetry: _load),
ValueListenableBuilder<BackendAvailability>(
valueListenable: ApiClient.availability,
builder: (context, availability, _) => TextButton(
onPressed:
_items.isEmpty || availability != BackendAvailability.online
? null
: _clear,
child: Text('清空'),
),
),
],
),