feat: make core pages local-first offline
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
@@ -10,6 +12,7 @@ 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_icons.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart';
|
||||
import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
@@ -28,6 +31,7 @@ class HomePageState extends State<HomePage> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
late DateTime _month;
|
||||
int _loadRevision = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -36,7 +40,7 @@ class HomePageState extends State<HomePage> {
|
||||
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
||||
TransactionEvents.revision.addListener(_refreshTransactions);
|
||||
CurrentLedgerStore.instance.addListener(_refreshLedger);
|
||||
refresh();
|
||||
unawaited(refresh());
|
||||
}
|
||||
|
||||
void _refreshCompanion() {
|
||||
@@ -60,33 +64,56 @@ class HomePageState extends State<HomePage> {
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final revision = ++_loadRevision;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await CurrentLedgerStore.instance.ensureLoaded();
|
||||
final results = await Future.wait<dynamic>([
|
||||
TxApi.month(_month.year, _month.month),
|
||||
BudgetApi.get(_month.year, _month.month),
|
||||
]);
|
||||
if (mounted) {
|
||||
await CurrentLedgerStore.instance.loadCached();
|
||||
final localSummary = TxApi.monthLocal(_month.year, _month.month);
|
||||
final localBudgets = BudgetApi.getLocal(_month.year, _month.month);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() {
|
||||
_summary = results[0] as MonthSummary;
|
||||
_budgets = results[1] as BudgetsData;
|
||||
_summary = localSummary;
|
||||
_budgets = localBudgets;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
final summary = await TxApi.month(_month.year, _month.month);
|
||||
if (mounted) setState(() => _summary = summary);
|
||||
} catch (fallbackError) {
|
||||
if (mounted) {
|
||||
setState(() => _error = apiErrorMessage(fallbackError));
|
||||
}
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() {
|
||||
_error = apiErrorMessage(error);
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Future.wait([
|
||||
() async {
|
||||
try {
|
||||
await CurrentLedgerStore.instance.refreshRemote();
|
||||
} catch (_) {}
|
||||
}(),
|
||||
() async {
|
||||
try {
|
||||
final summary = await TxApi.monthRemote(_month.year, _month.month);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _summary = summary);
|
||||
}
|
||||
} catch (_) {}
|
||||
}(),
|
||||
() async {
|
||||
try {
|
||||
final budgets = await BudgetApi.getRemote(_month.year, _month.month);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _budgets = budgets);
|
||||
}
|
||||
} catch (_) {}
|
||||
}(),
|
||||
]);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +202,7 @@ class HomePageState extends State<HomePage> {
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
BackendStatusIcon(onRetry: refresh),
|
||||
IconButton(
|
||||
icon: AppIcons.icon(
|
||||
AppIcons.search,
|
||||
|
||||
@@ -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('清空'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
||||
@@ -6,6 +8,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
@@ -25,13 +28,14 @@ class _ReportPageState extends State<ReportPage> {
|
||||
String? _error;
|
||||
DateTime _anchor = ShanghaiTime.now;
|
||||
_ReportKind _kind = _ReportKind.monthly;
|
||||
int _loadRevision = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
||||
CurrentLedgerStore.instance.addListener(_load);
|
||||
_load();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,24 +50,33 @@ class _ReportPageState extends State<ReportPage> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final revision = ++_loadRevision;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final report = switch (_kind) {
|
||||
_ReportKind.weekly => await ReportApi.weekly(_anchor),
|
||||
_ReportKind.monthly => await ReportApi.monthlyPeriod(
|
||||
_anchor.year,
|
||||
_anchor.month,
|
||||
),
|
||||
_ReportKind.yearly => await ReportApi.yearly(_anchor.year),
|
||||
final period = switch (_kind) {
|
||||
_ReportKind.weekly => 'week',
|
||||
_ReportKind.monthly => 'month',
|
||||
_ReportKind.yearly => 'year',
|
||||
};
|
||||
if (mounted) setState(() => _report = report);
|
||||
final local = ReportApi.local(period, _anchor);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _report = local);
|
||||
}
|
||||
final remote = await ReportApi.remote(period, _anchor);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _report = remote);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) setState(() => _error = apiErrorMessage(error));
|
||||
if (mounted && revision == _loadRevision && _report == null) {
|
||||
setState(() => _error = apiErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +121,7 @@ class _ReportPageState extends State<ReportPage> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(SessionStore.instance.aiEnabled ? 'AI 报告' : '报告'),
|
||||
actions: [BackendStatusIcon(onRetry: _load)],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:async';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.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/async_error_view.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||
|
||||
class StatsPage extends StatefulWidget {
|
||||
@@ -23,12 +25,13 @@ class _StatsPageState extends State<StatsPage> {
|
||||
PeriodStats? _stats;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
int _loadRevision = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
CurrentLedgerStore.instance.addListener(_load);
|
||||
_load();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -38,6 +41,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final revision = ++_loadRevision;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
@@ -45,12 +49,25 @@ class _StatsPageState extends State<StatsPage> {
|
||||
});
|
||||
}
|
||||
try {
|
||||
final stats = await TxApi.periodStats(_period, _anchor);
|
||||
if (mounted) setState(() => _stats = stats);
|
||||
final local = TxApi.periodStatsLocal(_period, _anchor);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _stats = local);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) setState(() => _error = apiErrorMessage(error));
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _error = apiErrorMessage(error));
|
||||
}
|
||||
}
|
||||
try {
|
||||
final remote = await TxApi.periodStatsRemote(_period, _anchor);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _stats = remote);
|
||||
}
|
||||
} catch (_) {
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +110,10 @@ class _StatsPageState extends State<StatsPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final stats = _stats;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('统计')),
|
||||
appBar: AppBar(
|
||||
title: Text('统计'),
|
||||
actions: [BackendStatusIcon(onRetry: _load)],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
@@ -105,7 +125,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
JzOption(value: 'month', label: '月'),
|
||||
JzOption(value: 'year', label: '年'),
|
||||
],
|
||||
onChanged: _loading ? null : _selectPeriod,
|
||||
onChanged: _selectPeriod,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@@ -171,7 +191,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: '上一个周期',
|
||||
onPressed: _loading ? null : () => _shift(-1),
|
||||
onPressed: () => _shift(-1),
|
||||
icon: Icon(Icons.chevron_left_rounded),
|
||||
),
|
||||
SizedBox(
|
||||
@@ -184,9 +204,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '下一个周期',
|
||||
onPressed: _loading || _isAfterCurrentPeriod(nextAnchor)
|
||||
? null
|
||||
: () => _shift(1),
|
||||
onPressed: _isAfterCurrentPeriod(nextAnchor) ? null : () => _shift(1),
|
||||
icon: Icon(Icons.chevron_right_rounded),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user