321 lines
12 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|