diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index b615d76..bbd0aba 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -242,15 +242,31 @@ class _EryaoAppState extends State { return updated; } + Future _saveProfile(ProfileSettingsV1 updated) async { + final saved = await _profileApi.updateProfile(updated); + if (!mounted) { + return saved; + } + setState(() { + _profileSettings = saved; + }); + return saved; + } + Future _saveProfileSettings(ProfileSettingsV1 next) async { try { - final saved = await _profileApi.updateProfile(next); + 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', @@ -341,6 +357,7 @@ class _EryaoAppState extends State { coinBalance: _creditsBalance, onLocaleChanged: _handleInterfaceLanguageChanged, onProfileSettingsChanged: _saveProfileSettings, + onSaveProfile: _saveProfile, onUploadAvatar: _uploadAvatar, onDivinationCompleted: _handleDivinationCompleted, onLogout: _authBloc.logout, diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index a9ecdb5..68518c8 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -25,6 +25,7 @@ class HomeScreen extends StatefulWidget { required this.coinBalance, required this.onLocaleChanged, required this.onProfileSettingsChanged, + required this.onSaveProfile, required this.onUploadAvatar, required this.onDivinationCompleted, required this.onLogout, @@ -39,6 +40,8 @@ class HomeScreen extends StatefulWidget { final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) onProfileSettingsChanged; + final Future Function(ProfileSettingsV1 updated) + onSaveProfile; final Future Function(String filePath) onUploadAvatar; final Future Function(DivinationResultData result) onDivinationCompleted; @@ -49,7 +52,7 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - bool _showNotificationDot = true; + MainTab _currentTab = MainTab.home; @override void initState() { @@ -79,224 +82,41 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; - final palette = Theme.of(context).extension()!; final historyItems = widget.historyRecords; return Scaffold( backgroundColor: colors.surfaceContainerLow, - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.only( - top: AppSpacing.lg, - bottom: AppSpacing.lg, + body: IndexedStack( + index: _currentTab == MainTab.home ? 0 : 1, + children: [ + _HomeTab( + historyItems: historyItems, + sessionStore: widget.sessionStore, + userId: widget.account, + onDivinationCompleted: widget.onDivinationCompleted, + allowVibration: widget.profileSettings.notification.allowVibration, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - l10n.helloUser( - widget.account.isEmpty - ? l10n.defaultUserName - : widget.account, - ), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(color: colors.primary), - ), - Stack( - children: [ - IconButton( - onPressed: () { - setState(() { - _showNotificationDot = false; - }); - _showSnack(context, l10n.featurePending); - }, - icon: Icon( - Icons.notifications, - color: colors.primary, - size: 28, - ), - tooltip: l10n.notify, - ), - if (_showNotificationDot) - Positioned( - right: AppSpacing.sm, - top: AppSpacing.sm, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: palette.notificationDot, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - ], - ), - ), - 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( - children: [ - Icon( - Icons.auto_awesome, - color: colors.onPrimary, - size: 48, - ), - const SizedBox(height: AppSpacing.lg), - Text( - l10n.startJourney, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - color: colors.onPrimary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - l10n.journeySubtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.onPrimary, - ), - ), - const SizedBox(height: AppSpacing.lg), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: colors.surface, - foregroundColor: colors.primary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - ), - onPressed: _onStartDivination, - child: Text(l10n.startNow), - ), - ], - ), - ), - ), - const SizedBox(height: AppSpacing.xl), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - l10n.historyTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - TextButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _HistoryRecordsScreen( - records: historyItems, - onOpenResult: (item) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - DivinationResultScreen(data: item), - ), - ); - }, - ), - ), - ); - }, - child: Text(l10n.more), - ), - ], - ), - ), - const SizedBox(height: AppSpacing.md), - if (historyItems.isEmpty) - SizedBox( - width: double.infinity, - height: 200, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - l10n.noRecords, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - Text(l10n.noRecordsSubtitle), - ], - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: historyItems.map((item) { - return Padding( - padding: const EdgeInsets.only( - left: AppSpacing.md, - right: AppSpacing.md, - bottom: AppSpacing.md, - ), - child: _HistoryCard( - item: item, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - DivinationResultScreen(data: item), - ), - ); - }, - ), - ); - }).toList(), - ), - ], - ), - ), - ), - bottomNavigationBar: BottomNavBar( - currentTab: MainTab.home, - onTabChange: _onTabChange, - onLogoTap: _onStartDivination, - ), - ); - } - - void _onTabChange(MainTab tab) { - if (tab == MainTab.profile) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SettingsScreen( + _ProfileTab( account: widget.account, settings: widget.profileSettings, coinBalance: widget.coinBalance, - onInterfaceLanguageChanged: widget.onLocaleChanged, + onLocaleChanged: widget.onLocaleChanged, onSettingsChanged: widget.onProfileSettingsChanged, + onSaveProfile: widget.onSaveProfile, onUploadAvatar: widget.onUploadAvatar, onLogout: widget.onLogout, ), - ), - ); - } + ], + ), + bottomNavigationBar: BottomNavBar( + currentTab: _currentTab, + onTabChange: (tab) { + setState(() => _currentTab = tab); + }, + onLogoTap: _onStartDivination, + ), + ); } void _onStartDivination() { @@ -306,13 +126,219 @@ class _HomeScreenState extends State { sessionStore: widget.sessionStore, userId: widget.account, onCompleted: widget.onDivinationCompleted, + allowVibration: widget.profileSettings.notification.allowVibration, ), ), ); } +} - void _showSnack(BuildContext context, String message) { - Toast.show(context, message, type: ToastType.info); +class _HomeTab extends StatelessWidget { + const _HomeTab({ + required this.historyItems, + required this.sessionStore, + required this.userId, + required this.onDivinationCompleted, + required this.allowVibration, + }); + + final List historyItems; + final SessionStore sessionStore; + final String userId; + final Future Function(DivinationResultData result) + onDivinationCompleted; + final bool allowVibration; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + + 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: () { + Toast.show( + context, + l10n.featurePending, + type: ToastType.info, + ); + }, + icon: Icon( + Icons.notifications, + color: colors.primary, + size: 28, + ), + 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( + children: [ + Icon(Icons.auto_awesome, color: colors.onPrimary, size: 48), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.startJourney, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colors.onPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.journeySubtitle, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colors.onPrimary), + ), + const SizedBox(height: AppSpacing.lg), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: colors.surface, + foregroundColor: colors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationScreen( + sessionStore: sessionStore, + userId: userId, + onCompleted: onDivinationCompleted, + allowVibration: allowVibration, + ), + ), + ); + }, + child: Text(l10n.startNow), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Text( + l10n.historyTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(height: AppSpacing.md), + if (historyItems.isEmpty) + SizedBox( + width: double.infinity, + height: 200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.noRecords, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + Text(l10n.noRecordsSubtitle), + ], + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: historyItems.take(4).map((item) { + return Padding( + padding: const EdgeInsets.only( + left: AppSpacing.md, + right: AppSpacing.md, + bottom: AppSpacing.md, + ), + child: _HistoryCard( + item: item, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationResultScreen(data: item), + ), + ); + }, + ), + ); + }).toList(), + ), + ], + ), + ), + ); + } +} + +class _ProfileTab extends StatelessWidget { + const _ProfileTab({ + required this.account, + required this.settings, + required this.coinBalance, + required this.onLocaleChanged, + required this.onSettingsChanged, + required this.onSaveProfile, + required this.onUploadAvatar, + required this.onLogout, + }); + + final String account; + final ProfileSettingsV1 settings; + final int coinBalance; + final Future Function(String languageTag) onLocaleChanged; + final Future Function(ProfileSettingsV1 settings) onSettingsChanged; + final Future Function(ProfileSettingsV1 updated) + onSaveProfile; + final Future Function(String filePath) onUploadAvatar; + final Future Function() onLogout; + + @override + Widget build(BuildContext context) { + return SettingsScreen( + account: account, + settings: settings, + coinBalance: coinBalance, + onInterfaceLanguageChanged: onLocaleChanged, + onSettingsChanged: onSettingsChanged, + onSaveProfile: onSaveProfile, + onUploadAvatar: onUploadAvatar, + onLogout: onLogout, + ); } } @@ -459,6 +485,10 @@ class _HistoryRecordsScreen extends StatelessWidget { final List records; final ValueChanged onOpenResult; + String _itemKey(DivinationResultData item) { + return '${item.guaName}_${item.binaryCode}_${item.params.question}'; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -486,12 +516,42 @@ class _HistoryRecordsScreen extends StatelessWidget { ), ) : ListView.separated( - padding: const EdgeInsets.all(AppSpacing.md), + padding: EdgeInsets.only( + top: AppSpacing.md, + bottom: AppSpacing.md, + ), itemBuilder: (context, index) { final item = records[index]; - return _HistoryCard( - item: item, - onTap: () => onOpenResult(item), + return Dismissible( + key: ValueKey(_itemKey(item)), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: AppSpacing.lg), + decoration: BoxDecoration( + color: colors.error, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon( + Icons.delete_outline_rounded, + color: colors.onError, + ), + ), + confirmDismiss: (direction) async { + return true; + }, + onDismissed: (direction) { + // TODO: implement delete + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + child: _HistoryCard( + item: item, + onTap: () => onOpenResult(item), + ), + ), ); }, separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.md), diff --git a/apps/lib/shared/widgets/bottom_nav_bar.dart b/apps/lib/shared/widgets/bottom_nav_bar.dart index 80e3545..debf3f5 100644 --- a/apps/lib/shared/widgets/bottom_nav_bar.dart +++ b/apps/lib/shared/widgets/bottom_nav_bar.dart @@ -75,10 +75,31 @@ class _NavItem extends StatelessWidget { final bool selected; final VoidCallback onTap; + IconData get _filledIcon { + switch (icon) { + case Icons.home: + return Icons.home; + case Icons.person: + return Icons.person; + default: + return icon; + } + } + + IconData get _outlinedIcon { + switch (icon) { + case Icons.home: + return Icons.home_outlined; + case Icons.person: + return Icons.person_outline; + default: + return icon; + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final iconColor = colors.primary; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.md), @@ -87,12 +108,12 @@ class _NavItem extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: iconColor), + Icon(selected ? _filledIcon : _outlinedIcon, color: colors.primary), const SizedBox(height: AppSpacing.xs), Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: iconColor, + color: colors.primary, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, ), ),