Files
jizhi/frontend/lib/app/app.dart
T

321 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/features/add/screenshot_parse_sheet.dart';
import 'package:miaoji_zhang/features/ai_mode/pages/ai_mode_page.dart';
import 'package:miaoji_zhang/features/auth/pages/login_page.dart';
import 'package:miaoji_zhang/features/auth/pages/register_page.dart';
import 'package:miaoji_zhang/features/auth/pages/splash_page.dart';
import 'package:miaoji_zhang/features/chat/chat_page.dart';
import 'package:miaoji_zhang/features/home/pages/home_page.dart';
import 'package:miaoji_zhang/features/home/pages/main_shell.dart';
import 'package:miaoji_zhang/features/home/pages/search_page.dart';
import 'package:miaoji_zhang/features/onboarding/pages/onboarding_page.dart';
import 'package:miaoji_zhang/features/settings/account_data_page.dart';
import 'package:miaoji_zhang/features/settings/appearance_page.dart';
import 'package:miaoji_zhang/features/settings/budget_page.dart';
import 'package:miaoji_zhang/features/settings/category_manage_page.dart';
import 'package:miaoji_zhang/features/settings/companion_page.dart';
import 'package:miaoji_zhang/features/settings/me_page.dart';
import 'package:miaoji_zhang/features/settings/push_settings_page.dart';
import 'package:miaoji_zhang/features/settings/recycle_bin_page.dart';
import 'package:miaoji_zhang/features/settings/recognition_batch_page.dart';
import 'package:miaoji_zhang/features/settings/legal_document_page.dart';
import 'package:miaoji_zhang/features/settings/screenshot_settings_page.dart';
import 'package:miaoji_zhang/features/settings/sync_conflicts_page.dart';
import 'package:miaoji_zhang/features/stats/report_page.dart';
import 'package:miaoji_zhang/features/stats/stats_page.dart';
import 'package:miaoji_zhang/features/add/add_page.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/recognition_import_service.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/push_service.dart';
import 'package:miaoji_zhang/shared/theme/theme_store.dart';
import 'package:miaoji_zhang/shared/update/update_coordinator.dart';
import 'package:provider/provider.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final router = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/splash',
refreshListenable: SessionStore.instance,
redirect: (_, state) {
final aiOnly =
state.matchedLocation == '/ai-mode' ||
state.matchedLocation == '/companion';
if (aiOnly &&
SessionStore.instance.isAccount &&
!SessionStore.instance.aiEnabled) {
return '/home';
}
return null;
},
routes: [
GoRoute(path: '/splash', builder: (_, __) => const SplashPage()),
GoRoute(
path: '/login',
builder: (_, state) =>
LoginPage(notice: state.uri.queryParameters['notice']),
),
GoRoute(path: '/register', builder: (_, __) => const RegisterPage()),
GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingPage()),
GoRoute(path: '/add', builder: (_, __) => const AddPage()),
GoRoute(path: '/budget', builder: (_, __) => const BudgetPage()),
GoRoute(path: '/account-data', builder: (_, __) => const AccountDataPage()),
GoRoute(path: '/appearance', builder: (_, __) => const AppearancePage()),
GoRoute(
path: '/notification-settings',
builder: (_, __) => const PushSettingsPage(),
),
GoRoute(path: '/recycle-bin', builder: (_, __) => const RecycleBinPage()),
GoRoute(
path: '/sync-conflicts',
builder: (_, __) => const SyncConflictsPage(),
),
GoRoute(path: '/companion', builder: (_, __) => const CompanionPage()),
GoRoute(
path: '/categories',
builder: (_, __) => const CategoryManagePage(),
),
GoRoute(
path: '/report',
builder: (_, state) => ReportPage(
initialPeriod: state.uri.queryParameters['period'],
initialAnchor: DateTime.tryParse(
state.uri.queryParameters['date'] ?? '',
),
),
),
GoRoute(
path: '/legal/:kind',
builder: (_, state) => LegalDocumentPage(
kind: LegalDocumentKind.fromKey(state.pathParameters['kind']),
),
),
GoRoute(path: '/search', builder: (_, __) => const SearchPage()),
GoRoute(
path: '/screenshot-settings',
builder: (_, __) => const ScreenshotSettingsPage(),
),
GoRoute(
path: '/recognition-batches',
builder: (_, state) => RecognitionBatchPage(
initialBatchId: state.uri.queryParameters['batchId'],
),
),
StatefulShellRoute.indexedStack(
builder: (_, __, shell) => MainShell(shell: shell),
branches: [
StatefulShellBranch(
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomePage()),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: '/stats', builder: (_, __) => const StatsPage()),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: '/chat', builder: (_, __) => const ChatTabPage()),
],
),
StatefulShellBranch(
routes: [GoRoute(path: '/me', builder: (_, __) => const MePage())],
),
],
),
GoRoute(path: '/ai-mode', builder: (_, __) => const AiModePage()),
],
);
class MiaoJiApp extends StatefulWidget {
const MiaoJiApp({super.key});
@override
State<MiaoJiApp> createState() => _MiaoJiAppState();
}
class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
bool _showingScreenshotSheet = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
UpdateCoordinator.instance.bindNavigator(_rootNavigatorKey);
ApiClient.sessionExpired.addListener(_handleSessionExpired);
unawaited(_bootstrap());
ScreenshotChannel.onScreenshotReady(
_handleScreenshotReady,
onError: _handleScreenshotError,
);
ScreenshotChannel.onRecognitionAction(_handleRecognitionAction);
PushService.instance.setOpenHandler(_handlePushOpen);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
ApiClient.sessionExpired.removeListener(_handleSessionExpired);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_resumeServices();
}
}
Future<void> _bootstrap() async {
await _runSafely(ScreenshotChannel.cleanupStaleScreenshots);
if (SessionStore.instance.hasSession) {
await _runSafely(CurrentLedgerStore.instance.loadCached);
}
await _runSafely(_restoreRecognitionServices);
await _runSafely(PushService.instance.initialize);
if (mounted) setState(() {});
unawaited(_refreshRemoteState());
}
Future<void> _resumeServices() async {
unawaited(ApiClient.instance.probe());
await _runSafely(_restoreRecognitionServices);
await _runSafely(PushService.instance.refresh);
unawaited(_refreshRemoteState());
}
Future<void> _restoreRecognitionServices() async {
await RecognitionImportService.configureNativeContext();
await ScreenshotChannel.ensureRecognitionKeepAlive();
await RecognitionImportService.importAutomatic();
unawaited(_runSafely(_importAfterAccessibilityReconnect));
}
Future<void> _importAfterAccessibilityReconnect() async {
final status = await ScreenshotChannel.waitForAccessibilityConnection();
if (status.accessibilityConnected) {
await RecognitionImportService.importAutomatic();
}
}
Future<void> _refreshRemoteState() async {
await Future.wait([
_runSafely(PublicConfigApi.init),
_refreshAccountData(),
_runSafely(SyncService.instance.run),
]);
if (mounted) setState(() {});
}
Future<void> _refreshAccountData() async {
final session = SessionStore.instance;
if (!session.isAccount || session.shouldUseLocalOnly) return;
await _runSafely(AuthApi.me);
await _runSafely(
() => CurrentLedgerStore.instance.ensureLoaded(force: true),
);
}
Future<void> _runSafely(Future<void> Function() operation) async {
try {
await operation();
} catch (_) {
// Optional startup and refresh work is isolated from the local app shell.
}
}
Future<void> _handleRecognitionAction(Map<String, dynamic> action) async {
await Future<void>.delayed(const Duration(milliseconds: 180));
final context = _rootNavigatorKey.currentContext;
if (!mounted || context == null || !context.mounted) return;
if (action['action'] == 'open_recognition_settings') {
router.push('/screenshot-settings');
return;
}
await RecognitionImportService.handleAction(context, action);
}
Future<void> _handlePushOpen(PushOpen open) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final context = _rootNavigatorKey.currentContext;
if (!mounted || context == null || !context.mounted) return;
if (!SessionStore.instance.isAccount && open.action == 'budget') {
router.go('/login', extra: null);
return;
}
switch (open.action) {
case 'home':
router.go('/home');
case 'budget':
router.push('/budget');
case 'update':
await UpdateCoordinator.instance.checkManually(context);
case 'none':
break;
}
}
void _handleSessionExpired() {
final context = _rootNavigatorKey.currentContext;
if (context != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('云同步登录已过期,本地记账仍可使用;重新登录后可继续同步')),
);
}
}
void _handleScreenshotError(String message) {
final context = _rootNavigatorKey.currentContext;
if (!mounted || context == null || !context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
if (message.contains('无障碍服务')) {
router.push('/screenshot-settings');
}
}
Future<void> _handleScreenshotReady(String path) async {
if (_showingScreenshotSheet) return;
await Future<void>.delayed(const Duration(milliseconds: 150));
final context = _rootNavigatorKey.currentContext;
if (!mounted || context == null || !context.mounted) return;
_showingScreenshotSheet = true;
try {
await ScreenshotParseSheet.show(context, path);
} finally {
_showingScreenshotSheet = false;
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: ThemeStore.instance,
builder: (context, _) => ChangeNotifierProvider.value(
value: CurrentLedgerStore.instance,
child: MaterialApp.router(
title: PublicConfigApi.appName,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeStore.instance.themeMode,
routerConfig: router,
debugShowCheckedModeBanner: false,
),
),
);
}
}