import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import '../core/auth/session_store.dart'; import '../core/localization/system_locale.dart'; import '../core/logging/logger.dart'; import '../core/timezone/system_timezone.dart'; import '../data/network/api_client.dart'; import '../data/storage/local_kv_store.dart'; import '../features/auth/data/apis/auth_api.dart'; import '../features/auth/data/repositories/auth_repository.dart'; import '../features/auth/presentation/bloc/auth_bloc.dart'; import '../features/auth/presentation/bloc/auth_state.dart'; import '../features/auth/presentation/screens/login_screen.dart'; import '../features/divination/data/apis/divination_api.dart'; import '../features/divination/data/models/divination_result.dart'; import '../features/home/presentation/screens/home_screen.dart'; import '../features/notifications/data/apis/notification_api.dart'; import '../features/notifications/data/repositories/notification_repository.dart'; import '../features/notifications/presentation/bloc/notification_bloc.dart'; import '../features/settings/data/apis/profile_api.dart'; import '../features/settings/data/models/profile_settings.dart'; import '../l10n/app_localizations.dart'; import '../shared/widgets/app_loading_indicator.dart'; import 'app_theme.dart'; import 'di/injection.dart'; class EryaoApp extends StatefulWidget { const EryaoApp({super.key}); @override State createState() => _EryaoAppState(); } class _EryaoAppState extends State { static final Logger _logger = getLogger('app.eryao_app'); final SessionStore _sessionStore = SessionStore(LocalKvStore()); late final AuthBloc _authBloc; late final DivinationApi _divinationApi; late final ProfileApi _profileApi; late final NotificationApi _notificationApi; late final NotificationRepository _notificationRepository; late final NotificationBloc _notificationBloc; Locale _locale = const Locale('zh'); String _timezone = 'Asia/Shanghai'; ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale( const Locale('zh'), ); int _creditsBalance = 0; bool _loadingCredits = false; String? _loadedCreditsUserEmail; bool _loadingHistory = false; String? _loadedHistoryUserEmail; List _historyRecords = const []; bool _loadingProfile = false; String? _loadedProfileUserEmail; String? _lastUnreadRefreshedUserId; @override void initState() { super.initState(); final apiClient = ApiClient( baseUrl: appDependencies.backendUrl, tokenProvider: _sessionStore.getToken, onUnauthorized: () { return _authBloc.handleUnauthorized401(); }, ); final authApi = AuthApi(apiClient: apiClient); _divinationApi = DivinationApi(apiClient: apiClient); _profileApi = ProfileApi(apiClient: apiClient); _notificationApi = NotificationApi(apiClient: apiClient); _notificationRepository = NotificationRepositoryImpl( notificationApi: _notificationApi, ); _notificationBloc = NotificationBloc(repository: _notificationRepository); final authRepository = AuthRepositoryImpl( authApi: authApi, sessionStore: _sessionStore, ); _authBloc = AuthBloc(repository: authRepository); _authBloc.addListener(_onAuthStateChanged); _bootstrap(); } void _onAuthStateChanged() { final state = _authBloc.state; if (state.status == AuthStatus.authenticated && state.user != null) { final userId = state.user!.id; if (_lastUnreadRefreshedUserId != userId) { _lastUnreadRefreshedUserId = userId; _notificationBloc.handleEvent(RefreshUnreadCount()); } return; } _lastUnreadRefreshedUserId = null; } void _ensureCreditsLoaded(String userEmail) { if (_loadingCredits) { return; } if (_loadedCreditsUserEmail == userEmail) { return; } _loadingCredits = true; _refreshCredits(userEmail: userEmail).whenComplete(() { _loadingCredits = false; }); } void _ensureHistoryLoaded(String userEmail) { if (_loadingHistory) { return; } if (_loadedHistoryUserEmail == userEmail) { return; } _loadingHistory = true; _divinationApi .getHistoryRecords(userId: userEmail) .then((records) { if (!mounted) { return; } setState(() { _historyRecords = records; _loadedHistoryUserEmail = userEmail; }); }) .catchError((Object error, StackTrace stackTrace) { _logger.warning( message: 'Failed to load divination history', extra: { 'error': error.toString(), 'stackTrace': stackTrace.toString(), }, ); }) .whenComplete(() { _loadingHistory = false; }); } Future _refreshCredits({required String userEmail}) async { final balance = await _divinationApi.getPointsBalance(); if (!mounted) { return; } setState(() { _creditsBalance = balance.availableBalance; _loadedCreditsUserEmail = userEmail; }); } Future _handleDivinationCompleted(DivinationResultData result) async { final user = _authBloc.state.user; if (user == null) { return; } final optimisticRecords = _mergeAndSortHistory([ result, ..._historyRecords, ]); if (!mounted) { return; } setState(() { _historyRecords = optimisticRecords; _loadedHistoryUserEmail = user.email; }); try { final records = await _divinationApi.getHistoryRecords( userId: user.email, ); if (!mounted) { return; } setState(() { _historyRecords = _mergeAndSortHistory([ ...records, ...optimisticRecords, ]); _loadedHistoryUserEmail = user.email; }); } catch (error, stackTrace) { _logger.warning( message: 'Failed to refresh history after divination completion', extra: { 'error': error.toString(), 'stackTrace': stackTrace.toString(), }, ); } try { await _refreshCredits(userEmail: user.email); } catch (error, stackTrace) { _logger.warning( message: 'Failed to refresh credits after divination completion', extra: { 'error': error.toString(), 'stackTrace': stackTrace.toString(), }, ); } } Future _handleHistorySessionDeleted(String threadId) async { final user = _authBloc.state.user; if (user == null) { return; } final rollback = List.from(_historyRecords); if (!mounted) { return; } setState(() { _historyRecords = _historyRecords .where((item) => item.threadId != threadId) .toList(growable: false); _loadedHistoryUserEmail = user.email; }); unawaited( _deleteHistorySessionRemote( threadId: threadId, userEmail: user.email, rollback: rollback, ), ); } Future _deleteHistorySessionRemote({ required String threadId, required String userEmail, required List rollback, }) async { try { await _divinationApi.deleteSession(threadId: threadId); } catch (error, stackTrace) { _logger.error( message: 'Failed to delete history session', error: error, stackTrace: stackTrace, extra: {'threadId': threadId}, ); if (!mounted) { return; } setState(() { _historyRecords = rollback; _loadedHistoryUserEmail = userEmail; }); } } List _mergeAndSortHistory( List input, ) { final seen = {}; final deduped = []; for (final item in input) { final key = _historyKey(item); if (seen.add(key)) { deduped.add(item); } } deduped.sort( (a, b) => b.params.divinationTime.compareTo(a.params.divinationTime), ); return deduped; } String _historyKey(DivinationResultData item) { return [ item.params.question, item.binaryCode, item.changedBinaryCode, item.guaName, item.targetGuaName, item.signType, ].join('|'); } Future _refreshProfile({required String userEmail}) async { if (_loadingProfile) { return; } if (_loadedProfileUserEmail == userEmail) { return; } _loadingProfile = true; try { final profile = await _profileApi.getProfile(); if (!mounted) { return; } final serverLanguage = profile.preferences.language; final serverTimezone = profile.preferences.timezone; final serverLocale = localeFromLanguageTag(serverLanguage); await _sessionStore.saveLocaleTag(serverLanguage); await _sessionStore.saveTimezone(serverTimezone); setState(() { _locale = serverLocale; _timezone = serverTimezone; _profileSettings = profile; _loadedProfileUserEmail = userEmail; }); } finally { _loadingProfile = false; } } Future _uploadAvatar(String filePath) async { final updated = await _profileApi.uploadAvatar(filePath); if (!mounted) { return updated; } setState(() { _profileSettings = updated; }); return updated; } Future _saveProfile(ProfileSettingsV1 updated) async { final saved = await _profileApi.updateSettings(updated); if (!mounted) { return saved; } setState(() { _profileSettings = saved; }); return saved; } Future _deleteAccount() async { await _profileApi.deleteAccount(); if (!mounted) { return; } setState(() { _profileSettings = ProfileSettingsV1.defaultsForLocale(_locale); _historyRecords = const []; _creditsBalance = 0; _loadedProfileUserEmail = null; _loadedHistoryUserEmail = null; _loadedCreditsUserEmail = null; }); } Future _saveProfileSettings(ProfileSettingsV1 next) async { try { final oldLanguage = _profileSettings.preferences.language; final newLanguage = next.preferences.language; final saved = await _profileApi.updateSettings(next); if (!mounted) { return; } setState(() { _profileSettings = saved; }); if (oldLanguage != newLanguage) { await _handleInterfaceLanguageChanged(newLanguage); } } catch (error, stackTrace) { _logger.error( message: 'Failed to save profile settings via API', error: error, stackTrace: stackTrace, ); rethrow; } } @override void dispose() { _authBloc.removeListener(_onAuthStateChanged); _authBloc.dispose(); _notificationBloc.dispose(); super.dispose(); } Future _bootstrap() async { final savedLocaleTag = await _sessionStore.getLocaleTag(); final Locale locale; if (savedLocaleTag != null) { locale = localeFromLanguageTag(savedLocaleTag); } else { final systemLocale = getSystemLocale(); locale = resolveSystemLocale(systemLocale) ?? const Locale('zh'); await _sessionStore.saveLocaleTag(languageTagFromLocale(locale)); } final savedTimezone = await _sessionStore.getTimezone(); final String timezone; if (savedTimezone != null) { timezone = savedTimezone; } else { timezone = await getSystemTimezone(); await _sessionStore.saveTimezone(timezone); } if (mounted) { setState(() { _locale = locale; _timezone = timezone; _profileSettings = ProfileSettingsV1.defaultsForLocale(locale); }); } await _authBloc.start(); } Future _handleInterfaceLanguageChanged(String languageTag) async { final locale = localeFromLanguageTag(languageTag); await _sessionStore.saveLocaleTag(languageTag); if (!mounted) { return; } setState(() { _locale = locale; _profileSettings = _profileSettings.copyWith( preferences: _profileSettings.preferences.copyWith( language: languageTag, ), ); }); } void _handleBalanceChanged(int newBalance) { if (!mounted) { return; } setState(() { _creditsBalance = newBalance; }); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _authBloc, builder: (context, _) { return MaterialApp( debugShowCheckedModeBanner: false, onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle, locale: _locale, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, theme: AppTheme.light(), home: _buildHomeByAuthState(_authBloc.state), ); }, ); } Widget _buildHomeByAuthState(AuthState state) { if (state.status == AuthStatus.initial || state.status == AuthStatus.loading) { return const Scaffold( body: Center( child: AppLoadingIndicator(variant: AppLoadingVariant.surface), ), ); } if (state.status == AuthStatus.authenticated && state.user != null) { _ensureCreditsLoaded(state.user!.email); _ensureHistoryLoaded(state.user!.email); _refreshProfile(userEmail: state.user!.email); return HomeScreen( account: state.user!.email, userId: state.user!.id, sessionStore: _sessionStore, currentLocale: _locale, profileSettings: _profileSettings, historyRecords: _historyRecords, coinBalance: _creditsBalance, divinationApi: _divinationApi, notificationBloc: _notificationBloc, notificationRepository: _notificationRepository, onLocaleChanged: _handleInterfaceLanguageChanged, onProfileSettingsChanged: _saveProfileSettings, onSaveProfile: _saveProfile, onUploadAvatar: _uploadAvatar, onDivinationCompleted: _handleDivinationCompleted, onDeleteHistorySession: _handleHistorySessionDeleted, onLogout: _authBloc.logout, onDeleteAccount: _deleteAccount, onBalanceChanged: _handleBalanceChanged, ); } return LoginScreen( currentLocale: _locale, onLocaleChanged: (_) {}, onRequestOtp: _authBloc.sendOtp, onLoginWithOtp: (email, otp) { return _authBloc.loginWithOtp( email: email, otp: otp, language: languageTagFromLocale(_locale), timezone: _timezone, ); }, ); } }