Files

521 lines
15 KiB
Dart
Raw Permalink Normal View History

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';
2026-04-10 18:50:08 +08:00
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';
2026-04-03 16:56:47 +08:00
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<EryaoApp> createState() => _EryaoAppState();
}
class _EryaoAppState extends State<EryaoApp> {
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;
2026-04-10 18:50:08 +08:00
late final NotificationApi _notificationApi;
late final NotificationRepository _notificationRepository;
late final NotificationBloc _notificationBloc;
Locale _locale = const Locale('zh');
String _timezone = 'Asia/Shanghai';
2026-04-03 16:56:47 +08:00
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
);
int _creditsBalance = 0;
bool _loadingCredits = false;
String? _loadedCreditsUserEmail;
bool _loadingHistory = false;
String? _loadedHistoryUserEmail;
List<DivinationResultData> _historyRecords = const <DivinationResultData>[];
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);
2026-04-10 18:50:08 +08:00
_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: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
})
.whenComplete(() {
_loadingHistory = false;
});
}
Future<void> _refreshCredits({required String userEmail}) async {
final balance = await _divinationApi.getPointsBalance();
if (!mounted) {
return;
}
setState(() {
_creditsBalance = balance.availableBalance;
_loadedCreditsUserEmail = userEmail;
});
}
Future<void> _handleDivinationCompleted(DivinationResultData result) async {
final user = _authBloc.state.user;
if (user == null) {
return;
}
final optimisticRecords = _mergeAndSortHistory(<DivinationResultData>[
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(<DivinationResultData>[
...records,
...optimisticRecords,
]);
_loadedHistoryUserEmail = user.email;
});
} catch (error, stackTrace) {
_logger.warning(
message: 'Failed to refresh history after divination completion',
extra: <String, dynamic>{
'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: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
}
}
Future<void> _handleHistorySessionDeleted(String threadId) async {
final user = _authBloc.state.user;
if (user == null) {
return;
}
final rollback = List<DivinationResultData>.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<void> _deleteHistorySessionRemote({
required String threadId,
required String userEmail,
required List<DivinationResultData> rollback,
}) async {
try {
await _divinationApi.deleteSession(threadId: threadId);
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to delete history session',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{'threadId': threadId},
);
if (!mounted) {
return;
}
setState(() {
_historyRecords = rollback;
_loadedHistoryUserEmail = userEmail;
});
}
}
List<DivinationResultData> _mergeAndSortHistory(
List<DivinationResultData> input,
) {
final seen = <String>{};
final deduped = <DivinationResultData>[];
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<void> _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<ProfileSettingsV1> _uploadAvatar(String filePath) async {
final updated = await _profileApi.uploadAvatar(filePath);
if (!mounted) {
return updated;
}
setState(() {
_profileSettings = updated;
});
return updated;
}
Future<ProfileSettingsV1> _saveProfile(ProfileSettingsV1 updated) async {
final saved = await _profileApi.updateSettings(updated);
if (!mounted) {
return saved;
}
setState(() {
_profileSettings = saved;
});
return saved;
}
2026-04-10 10:40:44 +08:00
Future<void> _deleteAccount() async {
await _profileApi.deleteAccount();
if (!mounted) {
return;
}
setState(() {
_profileSettings = ProfileSettingsV1.defaultsForLocale(_locale);
_historyRecords = const <DivinationResultData>[];
_creditsBalance = 0;
_loadedProfileUserEmail = null;
_loadedHistoryUserEmail = null;
_loadedCreditsUserEmail = null;
});
}
Future<void> _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();
2026-04-10 18:50:08 +08:00
_notificationBloc.dispose();
super.dispose();
}
Future<void> _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(() {
2026-04-03 16:56:47 +08:00
_locale = locale;
_timezone = timezone;
2026-04-03 16:56:47 +08:00
_profileSettings = ProfileSettingsV1.defaultsForLocale(locale);
});
}
await _authBloc.start();
}
2026-04-03 16:56:47 +08:00
Future<void> _handleInterfaceLanguageChanged(String languageTag) async {
final locale = localeFromLanguageTag(languageTag);
await _sessionStore.saveLocaleTag(languageTag);
if (!mounted) {
return;
}
setState(() {
_locale = locale;
2026-04-03 16:56:47 +08:00
_profileSettings = _profileSettings.copyWith(
preferences: _profileSettings.preferences.copyWith(
language: languageTag,
2026-04-03 16:56:47 +08:00
),
);
});
}
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,
2026-04-03 16:56:47 +08:00
currentLocale: _locale,
profileSettings: _profileSettings,
historyRecords: _historyRecords,
coinBalance: _creditsBalance,
divinationApi: _divinationApi,
2026-04-10 18:50:08 +08:00
notificationBloc: _notificationBloc,
notificationRepository: _notificationRepository,
2026-04-03 16:56:47 +08:00
onLocaleChanged: _handleInterfaceLanguageChanged,
onProfileSettingsChanged: _saveProfileSettings,
onSaveProfile: _saveProfile,
onUploadAvatar: _uploadAvatar,
onDivinationCompleted: _handleDivinationCompleted,
onDeleteHistorySession: _handleHistorySessionDeleted,
onLogout: _authBloc.logout,
2026-04-10 10:40:44 +08:00
onDeleteAccount: _deleteAccount,
onBalanceChanged: _handleBalanceChanged,
);
}
return LoginScreen(
currentLocale: _locale,
2026-04-03 16:56:47 +08:00
onLocaleChanged: (_) {},
onRequestOtp: _authBloc.sendOtp,
onLoginWithOtp: (email, otp) {
return _authBloc.loginWithOtp(
email: email,
otp: otp,
language: languageTagFromLocale(_locale),
timezone: _timezone,
);
},
);
}
}