3f3d613d99
- 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型
- 后端: 实现 schema/repository/service/router 全套通知 API
- GET /api/v1/notifications (列表+游标分页)
- GET /api/v1/notifications/unread-count
- PATCH /api/v1/notifications/{id}/read (幂等)
- PATCH /api/v1/notifications/mark-all-read (幂等)
- 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url)
- 后端: 19 个单元测试全部通过
- Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI)
- Flutter: Home 页通知按钮接入真实页面,显示未读 badge
- Flutter: 14 个测试全部通过
- 协议文档: notification-inbox-protocol.md 及错误码注册
462 lines
13 KiB
Dart
462 lines
13 KiB
Dart
import 'dart:async';
|
|
|
|
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/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<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;
|
|
late final NotificationApi _notificationApi;
|
|
late final NotificationRepository _notificationRepository;
|
|
late final NotificationBloc _notificationBloc;
|
|
Locale _locale = const Locale('zh');
|
|
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);
|
|
_notificationApi = NotificationApi(apiClient: apiClient);
|
|
_notificationRepository = NotificationRepositoryImpl(
|
|
notificationApi: _notificationApi,
|
|
);
|
|
_notificationBloc = NotificationBloc(repository: _notificationRepository);
|
|
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(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
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<ProfileSettingsV1> _saveProfile(ProfileSettingsV1 updated) async {
|
|
final saved = await _profileApi.updateProfile(updated);
|
|
if (!mounted) {
|
|
return saved;
|
|
}
|
|
setState(() {
|
|
_profileSettings = saved;
|
|
});
|
|
return saved;
|
|
}
|
|
|
|
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.interfaceLanguage;
|
|
final newLanguage = next.preferences.interfaceLanguage;
|
|
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.dispose();
|
|
_notificationBloc.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _bootstrap() async {
|
|
final localeCode = await _sessionStore.getLocaleCode();
|
|
final locale = localeCode == 'en' ? const Locale('en') : const Locale('zh');
|
|
if (mounted) {
|
|
setState(() {
|
|
_locale = locale;
|
|
_profileSettings = ProfileSettingsV1.defaultsForLocale(locale);
|
|
});
|
|
}
|
|
await _authBloc.start();
|
|
}
|
|
|
|
Future<void> _handleInterfaceLanguageChanged(String languageTag) async {
|
|
final locale = localeFromLanguageTag(languageTag);
|
|
await _sessionStore.saveLocaleCode(locale.languageCode);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_locale = locale;
|
|
_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);
|
|
_notificationBloc.handleEvent(RefreshUnreadCount());
|
|
return HomeScreen(
|
|
account: state.user!.email,
|
|
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,
|
|
);
|
|
}
|
|
|
|
return LoginScreen(
|
|
currentLocale: _locale,
|
|
onLocaleChanged: (_) {},
|
|
onRequestOtp: _authBloc.sendOtp,
|
|
onLoginWithOtp: (email, otp) {
|
|
return _authBloc.loginWithOtp(email: email, otp: otp);
|
|
},
|
|
);
|
|
}
|
|
}
|