2026-04-08 17:23:02 +08:00
|
|
|
import 'dart:async';
|
|
|
|
|
|
2026-04-02 18:39:35 +08:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
|
|
|
|
import '../../../../core/auth/session_store.dart';
|
2026-04-13 14:52:22 +08:00
|
|
|
import '../../../../data/network/api_client.dart';
|
2026-04-03 16:56:47 +08:00
|
|
|
import '../../../divination/presentation/screens/divination_screen.dart';
|
2026-04-06 01:28:10 +08:00
|
|
|
import '../../../divination/presentation/screens/divination_result_screen.dart';
|
2026-04-08 17:23:02 +08:00
|
|
|
import '../../../divination/data/apis/divination_api.dart';
|
2026-04-06 01:28:10 +08:00
|
|
|
import '../../../divination/data/models/divination_params.dart';
|
|
|
|
|
import '../../../divination/data/models/divination_result.dart';
|
2026-04-10 18:50:08 +08:00
|
|
|
import '../../../notifications/data/repositories/notification_repository.dart';
|
|
|
|
|
import '../../../notifications/presentation/bloc/notification_bloc.dart';
|
|
|
|
|
import '../../../notifications/presentation/screens/notification_center_screen.dart';
|
2026-04-13 14:52:22 +08:00
|
|
|
import '../../../settings/data/apis/invite_api.dart';
|
2026-04-03 16:56:47 +08:00
|
|
|
import '../../../settings/data/models/profile_settings.dart';
|
2026-04-13 14:52:22 +08:00
|
|
|
import '../../../settings/data/repositories/invite_repository.dart';
|
2026-04-03 16:56:47 +08:00
|
|
|
import '../../../settings/presentation/screens/settings_screen.dart';
|
2026-04-13 14:52:22 +08:00
|
|
|
import '../../../../app/di/injection.dart';
|
2026-04-02 18:39:35 +08:00
|
|
|
import '../../../../l10n/app_localizations.dart';
|
|
|
|
|
import '../../../../shared/theme/app_color_palette.dart';
|
|
|
|
|
import '../../../../shared/theme/design_tokens.dart';
|
|
|
|
|
import '../../../../shared/widgets/bottom_nav_bar.dart';
|
2026-04-08 17:23:02 +08:00
|
|
|
import '../../../../shared/widgets/divination/divination_summary_card.dart';
|
2026-04-02 18:39:35 +08:00
|
|
|
|
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
|
|
|
const HomeScreen({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.account,
|
|
|
|
|
required this.sessionStore,
|
2026-04-03 16:56:47 +08:00
|
|
|
required this.currentLocale,
|
|
|
|
|
required this.profileSettings,
|
2026-04-06 01:28:10 +08:00
|
|
|
required this.historyRecords,
|
2026-04-03 16:56:47 +08:00
|
|
|
required this.coinBalance,
|
2026-04-08 17:23:02 +08:00
|
|
|
required this.divinationApi,
|
2026-04-10 18:50:08 +08:00
|
|
|
required this.notificationBloc,
|
|
|
|
|
required this.notificationRepository,
|
2026-04-03 16:56:47 +08:00
|
|
|
required this.onLocaleChanged,
|
2026-04-06 01:28:10 +08:00
|
|
|
required this.onProfileSettingsChanged,
|
2026-04-07 18:43:58 +08:00
|
|
|
required this.onSaveProfile,
|
2026-04-06 01:28:10 +08:00
|
|
|
required this.onUploadAvatar,
|
|
|
|
|
required this.onDivinationCompleted,
|
2026-04-08 17:23:02 +08:00
|
|
|
required this.onDeleteHistorySession,
|
2026-04-02 18:39:35 +08:00
|
|
|
required this.onLogout,
|
2026-04-10 10:40:44 +08:00
|
|
|
required this.onDeleteAccount,
|
2026-04-02 18:39:35 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final String account;
|
|
|
|
|
final SessionStore sessionStore;
|
2026-04-03 16:56:47 +08:00
|
|
|
final Locale currentLocale;
|
|
|
|
|
final ProfileSettingsV1 profileSettings;
|
2026-04-06 01:28:10 +08:00
|
|
|
final List<DivinationResultData> historyRecords;
|
2026-04-03 16:56:47 +08:00
|
|
|
final int coinBalance;
|
2026-04-08 17:23:02 +08:00
|
|
|
final DivinationApi divinationApi;
|
2026-04-10 18:50:08 +08:00
|
|
|
final NotificationBloc notificationBloc;
|
|
|
|
|
final NotificationRepository notificationRepository;
|
2026-04-03 16:56:47 +08:00
|
|
|
final Future<void> Function(String languageTag) onLocaleChanged;
|
2026-04-06 01:28:10 +08:00
|
|
|
final Future<void> Function(ProfileSettingsV1 settings)
|
|
|
|
|
onProfileSettingsChanged;
|
2026-04-07 18:43:58 +08:00
|
|
|
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
|
|
|
|
onSaveProfile;
|
2026-04-06 01:28:10 +08:00
|
|
|
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
|
|
|
|
final Future<void> Function(DivinationResultData result)
|
|
|
|
|
onDivinationCompleted;
|
2026-04-08 17:23:02 +08:00
|
|
|
final Future<void> Function(String threadId) onDeleteHistorySession;
|
2026-04-02 18:39:35 +08:00
|
|
|
final Future<void> Function() onLogout;
|
2026-04-10 10:40:44 +08:00
|
|
|
final Future<void> Function() onDeleteAccount;
|
2026-04-02 18:39:35 +08:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
2026-04-07 18:43:58 +08:00
|
|
|
MainTab _currentTab = MainTab.home;
|
2026-04-13 14:52:22 +08:00
|
|
|
late final InviteRepository _inviteRepository;
|
2026-04-02 18:39:35 +08:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
2026-04-13 14:52:22 +08:00
|
|
|
final inviteApi = InviteApi(
|
|
|
|
|
apiClient: ApiClient(
|
|
|
|
|
baseUrl: appDependencies.backendUrl,
|
|
|
|
|
tokenProvider: widget.sessionStore.getToken,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
_inviteRepository = InviteRepositoryImpl(inviteApi: inviteApi);
|
2026-04-02 18:39:35 +08:00
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
_tryShowWelcomeDialog();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _tryShowWelcomeDialog() async {
|
|
|
|
|
final hasRead = await widget.sessionStore.hasReadWelcome();
|
|
|
|
|
if (hasRead || !mounted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await showDialog<void>(
|
|
|
|
|
context: context,
|
|
|
|
|
barrierDismissible: false,
|
|
|
|
|
builder: (context) {
|
|
|
|
|
return _WelcomeDialog(
|
|
|
|
|
onDone: () async {
|
|
|
|
|
await widget.sessionStore.setWelcomeRead(true);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colors = Theme.of(context).colorScheme;
|
2026-04-06 01:28:10 +08:00
|
|
|
final historyItems = widget.historyRecords;
|
2026-04-02 18:39:35 +08:00
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
|
backgroundColor: colors.surfaceContainerLow,
|
2026-04-07 18:43:58 +08:00
|
|
|
body: IndexedStack(
|
|
|
|
|
index: _currentTab == MainTab.home ? 0 : 1,
|
|
|
|
|
children: [
|
|
|
|
|
_HomeTab(
|
|
|
|
|
historyItems: historyItems,
|
|
|
|
|
sessionStore: widget.sessionStore,
|
|
|
|
|
userId: widget.account,
|
2026-04-08 17:23:02 +08:00
|
|
|
divinationApi: widget.divinationApi,
|
2026-04-07 18:43:58 +08:00
|
|
|
onDivinationCompleted: widget.onDivinationCompleted,
|
2026-04-08 17:23:02 +08:00
|
|
|
onDeleteHistorySession: widget.onDeleteHistorySession,
|
2026-04-07 18:43:58 +08:00
|
|
|
allowVibration: widget.profileSettings.notification.allowVibration,
|
2026-04-10 18:50:08 +08:00
|
|
|
notificationBloc: widget.notificationBloc,
|
|
|
|
|
notificationRepository: widget.notificationRepository,
|
2026-04-15 18:56:41 +08:00
|
|
|
profileSettings: widget.profileSettings,
|
|
|
|
|
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
2026-04-07 18:43:58 +08:00
|
|
|
),
|
|
|
|
|
_ProfileTab(
|
|
|
|
|
account: widget.account,
|
|
|
|
|
settings: widget.profileSettings,
|
|
|
|
|
coinBalance: widget.coinBalance,
|
2026-04-13 14:52:22 +08:00
|
|
|
inviteRepository: _inviteRepository,
|
2026-04-07 18:43:58 +08:00
|
|
|
onLocaleChanged: widget.onLocaleChanged,
|
|
|
|
|
onSettingsChanged: widget.onProfileSettingsChanged,
|
|
|
|
|
onSaveProfile: widget.onSaveProfile,
|
|
|
|
|
onUploadAvatar: widget.onUploadAvatar,
|
|
|
|
|
onLogout: widget.onLogout,
|
2026-04-10 10:40:44 +08:00
|
|
|
onDeleteAccount: widget.onDeleteAccount,
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
bottomNavigationBar: BottomNavBar(
|
|
|
|
|
currentTab: _currentTab,
|
|
|
|
|
onTabChange: (tab) {
|
|
|
|
|
setState(() => _currentTab = tab);
|
|
|
|
|
},
|
|
|
|
|
onLogoTap: _onStartDivination,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onStartDivination() {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
builder: (_) => DivinationScreen(
|
|
|
|
|
sessionStore: widget.sessionStore,
|
|
|
|
|
userId: widget.account,
|
|
|
|
|
onCompleted: widget.onDivinationCompleted,
|
|
|
|
|
allowVibration: widget.profileSettings.notification.allowVibration,
|
2026-04-15 18:56:41 +08:00
|
|
|
profileSettings: widget.profileSettings,
|
|
|
|
|
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
2026-04-07 18:43:58 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _HomeTab extends StatelessWidget {
|
|
|
|
|
const _HomeTab({
|
|
|
|
|
required this.historyItems,
|
|
|
|
|
required this.sessionStore,
|
|
|
|
|
required this.userId,
|
2026-04-08 17:23:02 +08:00
|
|
|
required this.divinationApi,
|
2026-04-07 18:43:58 +08:00
|
|
|
required this.onDivinationCompleted,
|
2026-04-08 17:23:02 +08:00
|
|
|
required this.onDeleteHistorySession,
|
2026-04-07 18:43:58 +08:00
|
|
|
required this.allowVibration,
|
2026-04-10 18:50:08 +08:00
|
|
|
required this.notificationBloc,
|
|
|
|
|
required this.notificationRepository,
|
2026-04-15 18:56:41 +08:00
|
|
|
required this.profileSettings,
|
|
|
|
|
required this.onProfileSettingsChanged,
|
2026-04-07 18:43:58 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final List<DivinationResultData> historyItems;
|
|
|
|
|
final SessionStore sessionStore;
|
|
|
|
|
final String userId;
|
2026-04-08 17:23:02 +08:00
|
|
|
final DivinationApi divinationApi;
|
2026-04-07 18:43:58 +08:00
|
|
|
final Future<void> Function(DivinationResultData result)
|
|
|
|
|
onDivinationCompleted;
|
2026-04-08 17:23:02 +08:00
|
|
|
final Future<void> Function(String threadId) onDeleteHistorySession;
|
2026-04-07 18:43:58 +08:00
|
|
|
final bool allowVibration;
|
2026-04-10 18:50:08 +08:00
|
|
|
final NotificationBloc notificationBloc;
|
|
|
|
|
final NotificationRepository notificationRepository;
|
2026-04-15 18:56:41 +08:00
|
|
|
final ProfileSettingsV1 profileSettings;
|
|
|
|
|
final Future<void> Function(ProfileSettingsV1 settings)
|
|
|
|
|
onProfileSettingsChanged;
|
2026-04-07 18:43:58 +08:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
final colors = Theme.of(context).colorScheme;
|
|
|
|
|
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
|
|
|
|
|
|
|
|
|
return SafeArea(
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
padding: const EdgeInsets.only(
|
|
|
|
|
top: AppSpacing.lg,
|
|
|
|
|
bottom: AppSpacing.lg,
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
l10n.historyTitle,
|
|
|
|
|
style: Theme.of(
|
|
|
|
|
context,
|
|
|
|
|
).textTheme.titleLarge?.copyWith(color: colors.primary),
|
|
|
|
|
),
|
|
|
|
|
IconButton(
|
|
|
|
|
onPressed: () {
|
2026-04-10 18:50:08 +08:00
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
builder: (_) => NotificationCenterScreen(
|
|
|
|
|
repository: notificationRepository,
|
2026-04-13 14:52:22 +08:00
|
|
|
onUnreadCountChanged: () {
|
|
|
|
|
return notificationBloc.handleEvent(
|
|
|
|
|
RefreshUnreadCount(),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-04-10 18:50:08 +08:00
|
|
|
),
|
|
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
);
|
|
|
|
|
},
|
2026-04-10 18:50:08 +08:00
|
|
|
icon: ListenableBuilder(
|
|
|
|
|
listenable: notificationBloc,
|
|
|
|
|
builder: (context, _) {
|
|
|
|
|
final count = notificationBloc.state.unreadCount;
|
|
|
|
|
if (count > 0) {
|
|
|
|
|
return Badge(
|
|
|
|
|
label: Text(count > 99 ? '99+' : '$count'),
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.notifications,
|
|
|
|
|
color: colors.primary,
|
|
|
|
|
size: 28,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return Icon(
|
|
|
|
|
Icons.notifications,
|
|
|
|
|
color: colors.primary,
|
|
|
|
|
size: 28,
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-04-07 18:43:58 +08:00
|
|
|
),
|
|
|
|
|
tooltip: l10n.notify,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.xl),
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
|
|
|
|
child: Container(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
|
|
|
gradient: LinearGradient(
|
|
|
|
|
colors: [colors.primary, palette.accentPurple],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
2026-04-02 18:39:35 +08:00
|
|
|
children: [
|
2026-04-07 18:43:58 +08:00
|
|
|
Icon(Icons.auto_awesome, color: colors.onPrimary, size: 48),
|
|
|
|
|
const SizedBox(height: AppSpacing.lg),
|
2026-04-02 18:39:35 +08:00
|
|
|
Text(
|
2026-04-07 18:43:58 +08:00
|
|
|
l10n.startJourney,
|
|
|
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
|
|
|
color: colors.onPrimary,
|
|
|
|
|
fontWeight: FontWeight.w700,
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.sm),
|
|
|
|
|
Text(
|
|
|
|
|
l10n.journeySubtitle,
|
2026-04-02 18:39:35 +08:00
|
|
|
style: Theme.of(
|
|
|
|
|
context,
|
2026-04-07 18:43:58 +08:00
|
|
|
).textTheme.bodyMedium?.copyWith(color: colors.onPrimary),
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
const SizedBox(height: AppSpacing.lg),
|
|
|
|
|
FilledButton(
|
|
|
|
|
style: FilledButton.styleFrom(
|
|
|
|
|
backgroundColor: colors.surface,
|
|
|
|
|
foregroundColor: colors.primary,
|
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
),
|
|
|
|
|
onPressed: () {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
builder: (_) => DivinationScreen(
|
|
|
|
|
sessionStore: sessionStore,
|
|
|
|
|
userId: userId,
|
|
|
|
|
onCompleted: onDivinationCompleted,
|
|
|
|
|
allowVibration: allowVibration,
|
2026-04-15 18:56:41 +08:00
|
|
|
profileSettings: profileSettings,
|
|
|
|
|
onProfileSettingsChanged:
|
|
|
|
|
onProfileSettingsChanged,
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
|
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
child: Text(l10n.startNow),
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.xl),
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
2026-04-08 17:23:02 +08:00
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
l10n.historyTitle,
|
|
|
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
|
|
|
),
|
|
|
|
|
if (historyItems.length > 4)
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
builder: (_) => DivinationHistoryScreen(
|
|
|
|
|
initialItems: historyItems,
|
|
|
|
|
divinationApi: divinationApi,
|
|
|
|
|
onDeleteHistorySession: onDeleteHistorySession,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
child: Text(l10n.more),
|
|
|
|
|
),
|
|
|
|
|
],
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
|
if (historyItems.isEmpty)
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
height: 200,
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
2026-04-02 18:39:35 +08:00
|
|
|
children: [
|
|
|
|
|
Text(
|
2026-04-07 18:43:58 +08:00
|
|
|
l10n.noRecords,
|
2026-04-02 18:39:35 +08:00
|
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
const SizedBox(height: AppSpacing.sm),
|
|
|
|
|
Text(l10n.noRecordsSubtitle),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
else
|
|
|
|
|
Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: historyItems.take(4).map((item) {
|
2026-04-08 17:23:02 +08:00
|
|
|
final threadId = item.threadId;
|
2026-04-07 18:43:58 +08:00
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(
|
|
|
|
|
left: AppSpacing.md,
|
|
|
|
|
right: AppSpacing.md,
|
|
|
|
|
bottom: AppSpacing.md,
|
|
|
|
|
),
|
2026-04-08 17:23:02 +08:00
|
|
|
child: threadId == null
|
|
|
|
|
? _HistoryCard(
|
|
|
|
|
item: item,
|
|
|
|
|
onTap: () {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
builder: (_) => DivinationResultScreen(
|
|
|
|
|
data: item,
|
|
|
|
|
divinationApi: null,
|
|
|
|
|
enableIntroTransition: false,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
: Dismissible(
|
|
|
|
|
key: ValueKey<String>('home-history-$threadId'),
|
|
|
|
|
direction: DismissDirection.endToStart,
|
|
|
|
|
background: Container(
|
|
|
|
|
alignment: Alignment.centerRight,
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: AppSpacing.lg,
|
|
|
|
|
),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: colors.errorContainer,
|
|
|
|
|
borderRadius: BorderRadius.circular(
|
|
|
|
|
AppRadius.md,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.delete_outline,
|
|
|
|
|
color: colors.onErrorContainer,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
confirmDismiss: (_) async => true,
|
|
|
|
|
onDismissed: (_) {
|
|
|
|
|
unawaited(onDeleteHistorySession(threadId));
|
|
|
|
|
},
|
|
|
|
|
child: _HistoryCard(
|
|
|
|
|
item: item,
|
|
|
|
|
onTap: () {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
builder: (_) => DivinationResultScreen(
|
|
|
|
|
data: item,
|
|
|
|
|
divinationApi: divinationApi,
|
|
|
|
|
enableIntroTransition: false,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
2026-04-06 01:28:10 +08:00
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
);
|
|
|
|
|
}).toList(),
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
2026-04-07 18:43:58 +08:00
|
|
|
],
|
2026-04-02 18:39:35 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-07 18:43:58 +08:00
|
|
|
}
|
2026-04-02 18:39:35 +08:00
|
|
|
|
2026-04-08 17:23:02 +08:00
|
|
|
class DivinationHistoryScreen extends StatefulWidget {
|
|
|
|
|
const DivinationHistoryScreen({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.initialItems,
|
|
|
|
|
required this.divinationApi,
|
|
|
|
|
required this.onDeleteHistorySession,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final List<DivinationResultData> initialItems;
|
|
|
|
|
final DivinationApi divinationApi;
|
|
|
|
|
final Future<void> Function(String threadId) onDeleteHistorySession;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<DivinationHistoryScreen> createState() =>
|
|
|
|
|
_DivinationHistoryScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _DivinationHistoryScreenState extends State<DivinationHistoryScreen> {
|
|
|
|
|
late List<DivinationResultData> _items;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_items = List<DivinationResultData>.from(
|
|
|
|
|
widget.initialItems,
|
|
|
|
|
growable: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
final colors = Theme.of(context).colorScheme;
|
|
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
|
appBar: AppBar(title: Text(l10n.historyTitle)),
|
|
|
|
|
backgroundColor: colors.surfaceContainerLow,
|
|
|
|
|
body: _items.isEmpty
|
|
|
|
|
? Center(child: Text(l10n.noRecords))
|
|
|
|
|
: ListView.builder(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
|
|
|
|
itemCount: _items.length,
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final item = _items[index];
|
|
|
|
|
final threadId = item.threadId;
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(
|
|
|
|
|
left: AppSpacing.md,
|
|
|
|
|
right: AppSpacing.md,
|
|
|
|
|
bottom: AppSpacing.md,
|
|
|
|
|
),
|
|
|
|
|
child: threadId == null
|
|
|
|
|
? _HistoryCard(
|
|
|
|
|
item: item,
|
|
|
|
|
onTap: () {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
builder: (_) => DivinationResultScreen(
|
|
|
|
|
data: item,
|
|
|
|
|
divinationApi: null,
|
|
|
|
|
enableIntroTransition: false,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
: Dismissible(
|
|
|
|
|
key: ValueKey<String>('history-$threadId'),
|
|
|
|
|
direction: DismissDirection.endToStart,
|
|
|
|
|
background: Container(
|
|
|
|
|
alignment: Alignment.centerRight,
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: AppSpacing.lg,
|
|
|
|
|
),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: colors.errorContainer,
|
|
|
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
|
|
|
),
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.delete_outline,
|
|
|
|
|
color: colors.onErrorContainer,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
confirmDismiss: (_) async => true,
|
|
|
|
|
onDismissed: (_) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_items.removeAt(index);
|
|
|
|
|
});
|
|
|
|
|
unawaited(widget.onDeleteHistorySession(threadId));
|
|
|
|
|
},
|
|
|
|
|
child: _HistoryCard(
|
|
|
|
|
item: item,
|
|
|
|
|
onTap: () {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
builder: (_) => DivinationResultScreen(
|
|
|
|
|
data: item,
|
|
|
|
|
divinationApi: widget.divinationApi,
|
|
|
|
|
enableIntroTransition: false,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 18:43:58 +08:00
|
|
|
class _ProfileTab extends StatelessWidget {
|
|
|
|
|
const _ProfileTab({
|
|
|
|
|
required this.account,
|
|
|
|
|
required this.settings,
|
|
|
|
|
required this.coinBalance,
|
2026-04-13 14:52:22 +08:00
|
|
|
required this.inviteRepository,
|
2026-04-07 18:43:58 +08:00
|
|
|
required this.onLocaleChanged,
|
|
|
|
|
required this.onSettingsChanged,
|
|
|
|
|
required this.onSaveProfile,
|
|
|
|
|
required this.onUploadAvatar,
|
|
|
|
|
required this.onLogout,
|
2026-04-10 10:40:44 +08:00
|
|
|
required this.onDeleteAccount,
|
2026-04-07 18:43:58 +08:00
|
|
|
});
|
2026-04-03 16:56:47 +08:00
|
|
|
|
2026-04-07 18:43:58 +08:00
|
|
|
final String account;
|
|
|
|
|
final ProfileSettingsV1 settings;
|
|
|
|
|
final int coinBalance;
|
2026-04-13 14:52:22 +08:00
|
|
|
final InviteRepository inviteRepository;
|
2026-04-07 18:43:58 +08:00
|
|
|
final Future<void> Function(String languageTag) onLocaleChanged;
|
|
|
|
|
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
|
|
|
|
|
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
|
|
|
|
onSaveProfile;
|
|
|
|
|
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
|
|
|
|
final Future<void> Function() onLogout;
|
2026-04-10 10:40:44 +08:00
|
|
|
final Future<void> Function() onDeleteAccount;
|
2026-04-02 18:39:35 +08:00
|
|
|
|
2026-04-07 18:43:58 +08:00
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return SettingsScreen(
|
|
|
|
|
account: account,
|
|
|
|
|
settings: settings,
|
|
|
|
|
coinBalance: coinBalance,
|
2026-04-13 14:52:22 +08:00
|
|
|
inviteRepository: inviteRepository,
|
2026-04-07 18:43:58 +08:00
|
|
|
onInterfaceLanguageChanged: onLocaleChanged,
|
|
|
|
|
onSettingsChanged: onSettingsChanged,
|
|
|
|
|
onSaveProfile: onSaveProfile,
|
|
|
|
|
onUploadAvatar: onUploadAvatar,
|
|
|
|
|
onLogout: onLogout,
|
2026-04-10 10:40:44 +08:00
|
|
|
onDeleteAccount: onDeleteAccount,
|
2026-04-07 18:43:58 +08:00
|
|
|
);
|
2026-04-02 18:39:35 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _HistoryCard extends StatelessWidget {
|
2026-04-06 01:28:10 +08:00
|
|
|
const _HistoryCard({required this.item, required this.onTap});
|
2026-04-02 18:39:35 +08:00
|
|
|
|
2026-04-06 01:28:10 +08:00
|
|
|
final DivinationResultData item;
|
|
|
|
|
final VoidCallback onTap;
|
2026-04-02 18:39:35 +08:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
final colors = Theme.of(context).colorScheme;
|
|
|
|
|
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
|
|
|
|
|
2026-04-06 01:28:10 +08:00
|
|
|
final categoryLabel = switch (item.params.questionType) {
|
2026-04-08 17:23:02 +08:00
|
|
|
QuestionType.career => l10n.questionTypeCareer,
|
|
|
|
|
QuestionType.love => l10n.questionTypeLove,
|
|
|
|
|
QuestionType.wealth => l10n.questionTypeWealth,
|
|
|
|
|
QuestionType.fortune => l10n.questionTypeFortune,
|
|
|
|
|
QuestionType.dream => l10n.questionTypeDream,
|
|
|
|
|
QuestionType.health => l10n.questionTypeHealth,
|
|
|
|
|
QuestionType.study => l10n.questionTypeStudy,
|
|
|
|
|
QuestionType.search => l10n.questionTypeSearch,
|
|
|
|
|
QuestionType.other => l10n.questionTypeOther,
|
2026-04-02 18:39:35 +08:00
|
|
|
};
|
|
|
|
|
|
2026-04-06 01:28:10 +08:00
|
|
|
final categoryStyle = switch (item.params.questionType) {
|
|
|
|
|
QuestionType.career || QuestionType.study => (
|
2026-04-02 18:39:35 +08:00
|
|
|
palette.categoryCareerBg,
|
|
|
|
|
palette.categoryCareerText,
|
|
|
|
|
),
|
2026-04-06 01:28:10 +08:00
|
|
|
QuestionType.love => (palette.categoryLoveBg, palette.categoryLoveText),
|
|
|
|
|
_ => (palette.categoryMoneyBg, palette.categoryMoneyText),
|
2026-04-02 18:39:35 +08:00
|
|
|
};
|
|
|
|
|
|
2026-04-06 01:28:10 +08:00
|
|
|
final normalizedSignType = item.signType.trim();
|
|
|
|
|
final isBestSign = normalizedSignType.contains('上上');
|
|
|
|
|
final isGoodSign = !isBestSign && normalizedSignType.contains('中上');
|
|
|
|
|
final isWorstSign = normalizedSignType.contains('下下');
|
|
|
|
|
|
|
|
|
|
final signLabel = isBestSign
|
|
|
|
|
? l10n.signTypeShangShang
|
|
|
|
|
: isGoodSign
|
|
|
|
|
? l10n.signTypeZhongShang
|
|
|
|
|
: isWorstSign
|
|
|
|
|
? l10n.signTypeXiaXia
|
|
|
|
|
: l10n.signTypeZhongXia;
|
|
|
|
|
|
|
|
|
|
final signStyle = isBestSign
|
|
|
|
|
? (palette.historyGoldBg, palette.historyGoldText)
|
|
|
|
|
: isGoodSign
|
|
|
|
|
? (colors.surfaceContainerHighest, colors.primary)
|
|
|
|
|
: isWorstSign
|
|
|
|
|
? (colors.errorContainer, colors.onErrorContainer)
|
|
|
|
|
: (palette.historyGrayBg, palette.historyGrayText);
|
|
|
|
|
|
2026-04-08 17:23:02 +08:00
|
|
|
return SizedBox(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
child: DivinationSummaryCard(
|
|
|
|
|
question: item.params.question,
|
2026-04-06 01:28:10 +08:00
|
|
|
onTap: onTap,
|
2026-04-08 17:23:02 +08:00
|
|
|
leading: Icon(
|
|
|
|
|
Icons.auto_awesome,
|
|
|
|
|
color: palette.historyBlueText,
|
|
|
|
|
size: 22,
|
|
|
|
|
),
|
|
|
|
|
leadingBackgroundColor: palette.historyBlueBg,
|
|
|
|
|
tags: [
|
|
|
|
|
DivinationSummaryTagData(
|
|
|
|
|
label: categoryLabel,
|
|
|
|
|
background: categoryStyle.$1,
|
|
|
|
|
foreground: categoryStyle.$2,
|
2026-04-06 01:28:10 +08:00
|
|
|
),
|
2026-04-08 17:23:02 +08:00
|
|
|
DivinationSummaryTagData(
|
|
|
|
|
label: item.guaName,
|
|
|
|
|
background: palette.historyBlueBg,
|
|
|
|
|
foreground: palette.historyBlueText,
|
2026-04-06 01:28:10 +08:00
|
|
|
),
|
2026-04-08 17:23:02 +08:00
|
|
|
DivinationSummaryTagData(
|
|
|
|
|
label: signLabel,
|
|
|
|
|
background: signStyle.$1,
|
|
|
|
|
foreground: signStyle.$2,
|
|
|
|
|
),
|
|
|
|
|
],
|
2026-04-06 01:28:10 +08:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 18:39:35 +08:00
|
|
|
class _WelcomeDialog extends StatefulWidget {
|
|
|
|
|
const _WelcomeDialog({required this.onDone});
|
|
|
|
|
|
|
|
|
|
final Future<void> Function() onDone;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<_WelcomeDialog> createState() => _WelcomeDialogState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _WelcomeDialogState extends State<_WelcomeDialog> {
|
|
|
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
|
bool _hasScrolledToBottom = false;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_scrollController.addListener(_handleScroll);
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
_syncScrollState();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_scrollController.removeListener(_handleScroll);
|
|
|
|
|
_scrollController.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleScroll() {
|
|
|
|
|
_syncScrollState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _syncScrollState() {
|
|
|
|
|
if (!_scrollController.hasClients) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final max = _scrollController.position.maxScrollExtent;
|
|
|
|
|
final current = _scrollController.offset;
|
|
|
|
|
final canReadAll = max <= AppSpacing.xs || current >= max - AppSpacing.md;
|
|
|
|
|
if (_hasScrolledToBottom == canReadAll) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setState(() {
|
|
|
|
|
_hasScrolledToBottom = canReadAll;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
final colors = Theme.of(context).colorScheme;
|
|
|
|
|
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
|
|
|
|
|
|
|
|
|
return Dialog(
|
|
|
|
|
insetPadding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: AppSpacing.lg,
|
|
|
|
|
vertical: AppSpacing.xl,
|
|
|
|
|
),
|
|
|
|
|
child: ConstrainedBox(
|
|
|
|
|
constraints: const BoxConstraints(maxHeight: 620),
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
l10n.welcomeDialogTitle,
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
|
|
|
color: colors.primary,
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.lg),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
controller: _scrollController,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
l10n.welcomeParagraph1,
|
|
|
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
|
Text(
|
|
|
|
|
l10n.welcomeParagraph2,
|
|
|
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
|
Text(
|
|
|
|
|
l10n.welcomeParagraph3,
|
|
|
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.lg),
|
|
|
|
|
Text(
|
|
|
|
|
l10n.warningTitle,
|
|
|
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
|
|
|
?.copyWith(color: palette.warning),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.xs),
|
|
|
|
|
Text(
|
|
|
|
|
l10n.warningBody,
|
|
|
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
|
|
|
color: palette.warning,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
|
if (!_hasScrolledToBottom)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
|
|
|
|
child: Text(
|
|
|
|
|
l10n.scrollHint,
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
child: FilledButton(
|
|
|
|
|
onPressed: _hasScrolledToBottom
|
|
|
|
|
? () async {
|
|
|
|
|
await widget.onDone();
|
|
|
|
|
if (!context.mounted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
style: FilledButton.styleFrom(
|
|
|
|
|
backgroundColor: _hasScrolledToBottom
|
|
|
|
|
? colors.primary
|
|
|
|
|
: colors.outline,
|
|
|
|
|
foregroundColor: colors.onPrimary,
|
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
vertical: AppSpacing.sm,
|
|
|
|
|
),
|
|
|
|
|
child: Text(
|
|
|
|
|
_hasScrolledToBottom
|
|
|
|
|
? l10n.understood
|
|
|
|
|
: l10n.readAllFirst,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|