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
+1
View File
@@ -183,6 +183,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
}
Future<void> _resumeServices() async {
unawaited(ApiClient.instance.probe());
await _runSafely(RecognitionImportService.configureNativeContext);
await _runSafely(RecognitionImportService.importAutomatic);
await _runSafely(PushService.instance.refresh);
+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(
+46 -18
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';
@@ -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('清空'),
),
),
],
),
+25 -11
View File
@@ -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: [
+29 -11
View File
@@ -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),
),
],
+28 -1
View File
@@ -4,6 +4,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
enum BackendAvailability { unknown, online, offline }
class ApiClient {
ApiClient._();
static final ApiClient instance = ApiClient._();
@@ -12,6 +14,9 @@ class ApiClient {
static const _legacyTokenKey = 'auth_token';
static final _tokenKey = 'auth_token_${BackendIdentity.scope}';
static final sessionExpired = ValueNotifier<int>(0);
static final availability = ValueNotifier<BackendAvailability>(
BackendAvailability.unknown,
);
static bool _handlingUnauthorized = false;
static const String baseUrl = BackendIdentity.baseUrl;
@@ -31,6 +36,10 @@ class ApiClient {
)
..interceptors.add(
InterceptorsWrapper(
onResponse: (response, handler) {
availability.value = BackendAvailability.online;
handler.next(response);
},
onRequest: (options, handler) async {
final token = await _storage.read(key: _tokenKey);
if (token != null) {
@@ -39,6 +48,11 @@ class ApiClient {
handler.next(options);
},
onError: (error, handler) async {
if (isConnectivityError(error)) {
availability.value = BackendAvailability.offline;
} else if (error.response != null) {
availability.value = BackendAvailability.online;
}
final unauthorized = error.response?.statusCode == 401;
final data = error.response?.data;
final aiDenied =
@@ -74,6 +88,18 @@ class ApiClient {
}
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<void> probe() async {
try {
await dio.get<void>(
'/api/public/brand',
options: Options(receiveTimeout: const Duration(seconds: 10)),
);
} catch (_) {
// The interceptor owns the reachability state transition.
}
}
Future<void> clearToken() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _legacyTokenKey);
@@ -91,8 +117,9 @@ String apiErrorMessage(Object e) {
if (e is StateError) return e.message;
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['message'] != null)
if (data is Map && data['message'] != null) {
return data['message'] as String;
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.receiveTimeout) {
+48 -9
View File
@@ -1,5 +1,9 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
@@ -19,6 +23,15 @@ class AiCompanion {
roastLevel = json['roastLevel'] as int,
stickerFrequency = json['stickerFrequency'] as int,
proactiveLevel = json['proactiveLevel'] as int;
Map<String, dynamic> toJson() => {
'avatarKey': avatarKey,
'personaKey': personaKey,
'customName': customName,
'roastLevel': roastLevel,
'stickerFrequency': stickerFrequency,
'proactiveLevel': proactiveLevel,
};
}
class UserProfile {
@@ -49,8 +62,9 @@ class UserProfile {
required this.nickname,
required this.appMode,
required this.onboardingDone,
this.aiCompanion,
this.aiEnabled = false,
}) : aiCompanion = null;
});
}
class AuthLoginResult {
@@ -120,6 +134,20 @@ class AuthApi {
}
}
static Future<AiCompanion?> cachedCompanion() async {
final userId = SessionStore.instance.userId;
if (userId == null) return null;
final value = (await SharedPreferences.getInstance()).getString(
_companionCacheKey(userId),
);
if (value == null) return null;
try {
return AiCompanion.fromJson(jsonDecode(value) as Map<String, dynamic>);
} catch (_) {
return null;
}
}
static Future<UserProfile> completeOnboarding({
required String appMode,
required String avatarKey,
@@ -272,13 +300,24 @@ class AuthApi {
);
}
static Future<void> _cacheProfile(UserProfile profile) =>
SessionStore.instance.activateAccount(
userId: profile.userId,
username: profile.username,
nickname: profile.nickname,
appMode: profile.appMode,
onboardingDone: profile.onboardingDone,
aiEnabled: profile.aiEnabled,
static Future<void> _cacheProfile(UserProfile profile) async {
await SessionStore.instance.activateAccount(
userId: profile.userId,
username: profile.username,
nickname: profile.nickname,
appMode: profile.appMode,
onboardingDone: profile.onboardingDone,
aiEnabled: profile.aiEnabled,
);
final companion = profile.aiCompanion;
if (companion != null) {
await (await SharedPreferences.getInstance()).setString(
_companionCacheKey(profile.userId),
jsonEncode(companion.toJson()),
);
}
}
static String _companionCacheKey(int userId) =>
'ai_companion_${BackendIdentity.scope}_$userId';
}
+163 -17
View File
@@ -244,6 +244,20 @@ class TxApi {
static bool get _queueOfflineChanges =>
SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled;
static MonthSummary monthLocal(int year, int month) => MonthSummary.fromJson(
LocalDatabase.instance.monthSummary(year, month, _ledgerId),
);
static Future<MonthSummary> monthRemote(int year, int month) async {
final response = await _dio.get(
'/api/transactions/month',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheMonthSummary(json);
return MonthSummary.fromJson(json);
}
static Future<MonthSummary> month(int year, int month) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
@@ -330,6 +344,29 @@ class TxApi {
}
}
static PeriodStats periodStatsLocal(String period, DateTime anchor) =>
PeriodStats.fromJson(
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
);
static Future<PeriodStats> periodStatsRemote(
String period,
DateTime anchor,
) async {
final response = await _dio.get(
'/api/transactions/stats/period',
queryParameters: {
'period': period,
'anchor':
'${anchor.year.toString().padLeft(4, '0')}-'
'${anchor.month.toString().padLeft(2, '0')}-'
'${anchor.day.toString().padLeft(2, '0')}',
'ledgerId': _ledgerId,
},
);
return PeriodStats.fromJson(response.data as Map<String, dynamic>);
}
static Future<List<CategoryItem>> categories(String type) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
@@ -357,6 +394,24 @@ class TxApi {
}
}
static List<CategoryItem> categoriesLocal(String type) => LocalDatabase
.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
static Future<List<CategoryItem>> categoriesRemote(String type) async {
final response = await _dio.get(
'/api/categories',
queryParameters: {'type': type},
);
final values = response.data as List;
LocalDatabase.instance.cacheCategories(values, replaceType: type);
return values
.map((item) => CategoryItem.fromJson(item as Map<String, dynamic>))
.toList();
}
static Future<TxItem> create({
required int categoryId,
required String type,
@@ -532,7 +587,9 @@ class TxApi {
includeDeleted: true,
)?['updatedAt'];
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
LocalDatabase.instance.softDeleteTransaction(id);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync('transaction', id, 'delete', {
@@ -613,7 +670,8 @@ class TxApi {
static Future<List<TxItem>> recycleBin() async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline) {
return LocalDatabase.instance
.recycleBin(_ledgerId)
.map(TxItem.fromJson)
@@ -638,13 +696,32 @@ class TxApi {
}
}
static List<TxItem> recycleBinLocal() => LocalDatabase.instance
.recycleBin(_ledgerId)
.map(TxItem.fromJson)
.toList();
static Future<List<TxItem>> recycleBinRemote() async {
final response = await _dio.get(
'/api/transactions/recycle-bin',
queryParameters: {'ledgerId': _ledgerId},
);
final values = response.data as List;
LocalDatabase.instance.cacheTransactions(values);
return values
.map((item) => TxItem.fromJson(item as Map<String, dynamic>))
.toList();
}
static Future<TxItem> restore(int id) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
includeDeleted: true,
)?['updatedAt'];
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
final local = LocalDatabase.instance.restoreTransaction(id);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync('transaction', id, 'restore', {
@@ -984,6 +1061,18 @@ class BudgetApi {
static final _dio = ApiClient.instance.dio;
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
static BudgetsData getLocal(int year, int month) => BudgetsData.fromJson(
LocalDatabase.instance.budgets(year, month, _ledgerId),
);
static Future<BudgetsData> getRemote(int year, int month) async {
final response = await _dio.get(
'/api/budgets',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
return BudgetsData.fromJson(response.data as Map<String, dynamic>);
}
static Future<BudgetsData> get(int year, int month) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
@@ -1294,6 +1383,29 @@ class ReportApi {
static final _dio = ApiClient.instance.dio;
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
static PeriodReport local(String period, DateTime anchor) =>
_localPeriod(period, anchor);
static Future<PeriodReport> remote(String period, DateTime anchor) async {
if (period == 'month') {
return PeriodReport.fromMonthly(await monthly(anchor.year, anchor.month));
}
final path = period == 'week'
? '/api/reports/weekly'
: '/api/reports/yearly';
final query = period == 'week'
? {
'date':
'${anchor.year.toString().padLeft(4, '0')}-'
'${anchor.month.toString().padLeft(2, '0')}-'
'${anchor.day.toString().padLeft(2, '0')}',
'ledgerId': _ledgerId,
}
: {'year': anchor.year, 'ledgerId': _ledgerId};
final response = await _dio.get(path, queryParameters: query);
return PeriodReport.fromJson(response.data as Map<String, dynamic>);
}
static Future<PeriodReport> weekly(DateTime date) async {
if (SessionStore.instance.shouldUseLocalOnly) {
return _localPeriod('week', date);
@@ -1595,7 +1707,8 @@ class CategoryApi {
'type': type,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline) {
final local = LocalDatabase.instance.createCategory(
name,
iconKey,
@@ -1649,7 +1762,9 @@ class CategoryApi {
'sortOrder': sortOrder,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
final local = LocalDatabase.instance.updateCategory(
id,
name,
@@ -1662,16 +1777,31 @@ class CategoryApi {
}
return CategoryItem.fromJson(local);
}
final response = await _dio.put('/api/categories/$id', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheCategories([json]);
return CategoryItem.fromJson(json);
try {
final response = await _dio.put('/api/categories/$id', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheCategories([json]);
return CategoryItem.fromJson(json);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
final local = LocalDatabase.instance.updateCategory(
id,
name,
iconKey,
colorKey,
sortOrder,
);
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
return CategoryItem.fromJson(local);
}
}
static Future<void> reorder(String type, List<int> categoryIds) async {
LocalDatabase.instance.reorderCategories(type, categoryIds);
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || categoryIds.any((id) => id < 0)) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
categoryIds.any((id) => id < 0)) {
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
'type': type,
@@ -1680,15 +1810,25 @@ class CategoryApi {
}
return;
}
await _dio.put(
'/api/categories/reorder',
data: {'type': type, 'categoryIds': categoryIds},
);
try {
await _dio.put(
'/api/categories/reorder',
data: {'type': type, 'categoryIds': categoryIds},
);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
'type': type,
'categoryIds': categoryIds,
});
}
}
static Future<void> delete(int id) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
if (session.shouldUseLocalOnly ||
ApiClient.availability.value == BackendAvailability.offline ||
id < 0) {
LocalDatabase.instance.deleteCategory(id);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
@@ -1697,7 +1837,13 @@ class CategoryApi {
}
return;
}
await _dio.delete('/api/categories/$id');
LocalDatabase.instance.deleteCategory(id);
try {
await _dio.delete('/api/categories/$id');
LocalDatabase.instance.deleteCategory(id);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
LocalDatabase.instance.deleteCategory(id);
LocalDatabase.instance.enqueueSync('category', id, 'delete', {'id': id});
}
}
}
+63 -2
View File
@@ -1,5 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
class BrandConfig {
@@ -41,6 +45,13 @@ class AvatarItem {
defaultName = j['defaultName'] as String,
speechTic = j['speechTic'] as String? ?? '',
imageUrl = j['imageUrl'] as String?;
Map<String, dynamic> toJson() => {
'key': key,
'defaultName': defaultName,
'speechTic': speechTic,
'imageUrl': imageUrl,
};
}
class PersonaItem {
@@ -50,6 +61,13 @@ class PersonaItem {
name = j['name'] as String,
description = j['description'] as String? ?? '',
sampleLine = j['sampleLine'] as String? ?? '';
Map<String, dynamic> toJson() => {
'key': key,
'name': name,
'description': description,
'sampleLine': sampleLine,
};
}
class CompanionDisplay {
@@ -145,16 +163,59 @@ class PublicConfigApi {
static Future<List<AvatarItem>> avatars() async {
final res = await _dio.get('/api/public/avatars');
return (res.data as List)
final values = (res.data as List)
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
.toList();
await _cacheCatalog(
_avatarCacheKey,
values.map((e) => e.toJson()).toList(),
);
return values;
}
static Future<List<PersonaItem>> personas() async {
final res = await _dio.get('/api/public/personas');
return (res.data as List)
final values = (res.data as List)
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
.toList();
await _cacheCatalog(
_personaCacheKey,
values.map((e) => e.toJson()).toList(),
);
return values;
}
static Future<List<AvatarItem>> cachedAvatars() async =>
(await _cachedCatalog(_avatarCacheKey)).map(AvatarItem.fromJson).toList();
static Future<List<PersonaItem>> cachedPersonas() async =>
(await _cachedCatalog(
_personaCacheKey,
)).map(PersonaItem.fromJson).toList();
static String get _avatarCacheKey =>
'public_avatars_${BackendIdentity.scope}';
static String get _personaCacheKey =>
'public_personas_${BackendIdentity.scope}';
static Future<void> _cacheCatalog(
String key,
List<Map<String, dynamic>> values,
) async {
await (await SharedPreferences.getInstance()).setString(
key,
jsonEncode(values),
);
}
static Future<List<Map<String, dynamic>>> _cachedCatalog(String key) async {
final value = (await SharedPreferences.getInstance()).getString(key);
if (value == null) return const [];
try {
return (jsonDecode(value) as List).cast<Map<String, dynamic>>().toList();
} catch (_) {
return const [];
}
}
static String? _normalizeName(String? value) {
@@ -52,6 +52,17 @@ class CurrentLedgerStore extends ChangeNotifier {
return _loading ??= _loadCached().whenComplete(() => _loading = null);
}
Future<void> refreshRemote() async {
if (!SessionStore.instance.isAccount ||
SessionStore.instance.shouldUseLocalOnly) {
return;
}
final response = await _dio.get('/api/ledgers');
final values = response.data as List;
LocalDatabase.instance.cacheLedgers(values);
_apply(values);
}
Future<void> _loadCached() async {
_apply(LocalDatabase.instance.ledgers());
}
@@ -78,11 +89,24 @@ class CurrentLedgerStore extends ChangeNotifier {
final items = values
.map((item) => LedgerInfo.fromJson(item as Map<String, dynamic>))
.toList();
_ledgers = items;
_current =
final current =
items.where((item) => item.isDefault).firstOrNull ??
(items.isEmpty ? null : items.first);
notifyListeners();
final changed =
_current?.id != current?.id ||
_ledgers.length != items.length ||
Iterable<int>.generate(items.length).any((index) {
final before = _ledgers[index];
final after = items[index];
return before.id != after.id ||
before.name != after.name ||
before.iconKey != after.iconKey ||
before.isDefault != after.isDefault ||
before.transactionCount != after.transactionCount;
});
_ledgers = items;
_current = current;
if (changed) notifyListeners();
}
Future<void> select(int id) async {
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
class BackendStatusIcon extends StatelessWidget {
final Future<void> Function() onRetry;
const BackendStatusIcon({super.key, required this.onRetry});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<BackendAvailability>(
valueListenable: ApiClient.availability,
builder: (context, availability, _) {
if (availability != BackendAvailability.offline) {
return const SizedBox.shrink();
}
return IconButton(
tooltip: '当前显示本地数据,点击重试',
onPressed: onRetry,
icon: AppIcons.icon(
AppIcons.offline,
size: 18,
color: AppTheme.orange,
),
);
},
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
void main() {
tearDown(() {
ApiClient.availability.value = BackendAvailability.unknown;
});
testWidgets('离线状态只显示可点击且可访问的云朵重试图标', (tester) async {
var retries = 0;
ApiClient.availability.value = BackendAvailability.offline;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(
actions: [
BackendStatusIcon(
onRetry: () async {
retries++;
},
),
],
),
),
),
);
final retry = find.byTooltip('当前显示本地数据,点击重试');
expect(retry, findsOneWidget);
await tester.tap(retry);
await tester.pump();
expect(retries, 1);
ApiClient.availability.value = BackendAvailability.online;
await tester.pump();
expect(retry, findsNothing);
});
test('连接、发送和接收失败都会被识别为后端不可达', () {
DioException failure(DioExceptionType type) => DioException(
requestOptions: RequestOptions(path: '/test'),
type: type,
);
expect(
isConnectivityError(failure(DioExceptionType.connectionError)),
isTrue,
);
expect(
isConnectivityError(failure(DioExceptionType.connectionTimeout)),
isTrue,
);
expect(isConnectivityError(failure(DioExceptionType.sendTimeout)), isTrue);
expect(
isConnectivityError(failure(DioExceptionType.receiveTimeout)),
isTrue,
);
expect(isConnectivityError(failure(DioExceptionType.badResponse)), isFalse);
});
test('AI 伙伴缓存 DTO 可无损往返', () {
final source = {
'avatarKey': 'cat',
'personaKey': 'gentle',
'customName': '小记',
'roastLevel': 20,
'stickerFrequency': 40,
'proactiveLevel': 60,
};
final companion = AiCompanion.fromJson(source);
expect(companion.toJson(), source);
});
test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () {
final contracts = <String, List<String>>{
'lib/features/home/pages/home_page.dart': [
'monthLocal',
'monthRemote',
'BudgetApi.getLocal',
'BudgetApi.getRemote',
'refreshRemote',
],
'lib/features/add/add_page.dart': [
"categoriesLocal('expense')",
"categoriesRemote('expense')",
"categoriesRemote('income')",
],
'lib/features/stats/stats_page.dart': [
'periodStatsLocal',
'periodStatsRemote',
],
'lib/features/stats/report_page.dart': [
'ReportApi.local',
'ReportApi.remote',
],
'lib/features/settings/category_manage_page.dart': [
'categoriesLocal',
'categoriesRemote',
],
'lib/features/settings/recycle_bin_page.dart': [
'recycleBinLocal',
'recycleBinRemote',
],
'lib/features/settings/companion_page.dart': [
'cachedCompanion',
'cachedAvatars',
'cachedPersonas',
'forceRemote: true',
],
};
for (final entry in contracts.entries) {
final source = File(entry.key).readAsStringSync();
expect(source, contains('_loadRevision'), reason: entry.key);
expect(source, contains('BackendStatusIcon'), reason: entry.key);
for (final token in entry.value) {
expect(source, contains(token), reason: '${entry.key}: $token');
}
}
final companion = File(
'lib/features/settings/companion_page.dart',
).readAsStringSync();
expect(companion, isNot(contains('if (!_loaded)')));
expect(companion, contains('BackendAvailability.online'));
});
test('AI 缓存按后端和账号隔离,回收站破坏性操作只允许在线执行', () {
final auth = File('lib/shared/api/auth_api.dart').readAsStringSync();
final config = File('lib/shared/api/config_api.dart').readAsStringSync();
final recycle = File(
'lib/features/settings/recycle_bin_page.dart',
).readAsStringSync();
expect(auth, contains("'ai_companion_\${BackendIdentity.scope}_\$userId'"));
expect(config, contains("'public_avatars_\${BackendIdentity.scope}'"));
expect(config, contains("'public_personas_\${BackendIdentity.scope}'"));
expect(recycle, contains('availability != BackendAvailability.online'));
expect(recycle, contains('if (online)'));
});
}