Files
eryao/apps/lib/app/app.dart
T

360 lines
10 KiB
Dart
Raw Normal View History

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/auth/session_store.dart';
import '../core/logging/logger.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/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;
Locale _locale = const Locale('zh');
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;
@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);
final authRepository = AuthRepositoryImpl(
authApi: authApi,
sessionStore: _sessionStore,
);
_authBloc = AuthBloc(repository: authRepository);
_bootstrap();
}
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(),
},
);
}
}
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;
}
setState(() {
_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<void> _saveProfileSettings(ProfileSettingsV1 next) async {
try {
final saved = await _profileApi.updateProfile(next);
if (!mounted) {
return;
}
setState(() {
_profileSettings = saved;
});
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to save profile settings via API',
error: error,
stackTrace: stackTrace,
);
rethrow;
}
}
@override
void dispose() {
_authBloc.dispose();
super.dispose();
}
Future<void> _bootstrap() async {
final localeCode = await _sessionStore.getLocaleCode();
2026-04-03 16:56:47 +08:00
final locale = localeCode == 'en' ? const Locale('en') : const Locale('zh');
if (mounted) {
setState(() {
2026-04-03 16:56:47 +08:00
_locale = locale;
_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.saveLocaleCode(locale.languageCode);
if (!mounted) {
return;
}
setState(() {
_locale = locale;
2026-04-03 16:56:47 +08:00
_profileSettings = _profileSettings.copyWith(
preferences: _profileSettings.preferences.copyWith(
interfaceLanguage: languageTag,
),
);
});
}
@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,
sessionStore: _sessionStore,
2026-04-03 16:56:47 +08:00
currentLocale: _locale,
profileSettings: _profileSettings,
historyRecords: _historyRecords,
coinBalance: _creditsBalance,
2026-04-03 16:56:47 +08:00
onLocaleChanged: _handleInterfaceLanguageChanged,
onProfileSettingsChanged: _saveProfileSettings,
onUploadAvatar: _uploadAvatar,
onDivinationCompleted: _handleDivinationCompleted,
onLogout: _authBloc.logout,
);
}
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);
},
);
}
}