feat(home): 重构首页为底部导航栏布局,支持首页与个人页面切换

This commit is contained in:
qzl
2026-04-07 18:43:58 +08:00
parent c121c1092f
commit 6e82053ea7
3 changed files with 314 additions and 216 deletions
+18 -1
View File
@@ -242,15 +242,31 @@ class _EryaoAppState extends State<EryaoApp> {
return 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> _saveProfileSettings(ProfileSettingsV1 next) async { Future<void> _saveProfileSettings(ProfileSettingsV1 next) async {
try { 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) { if (!mounted) {
return; return;
} }
setState(() { setState(() {
_profileSettings = saved; _profileSettings = saved;
}); });
if (oldLanguage != newLanguage) {
await _handleInterfaceLanguageChanged(newLanguage);
}
} catch (error, stackTrace) { } catch (error, stackTrace) {
_logger.error( _logger.error(
message: 'Failed to save profile settings via API', message: 'Failed to save profile settings via API',
@@ -341,6 +357,7 @@ class _EryaoAppState extends State<EryaoApp> {
coinBalance: _creditsBalance, coinBalance: _creditsBalance,
onLocaleChanged: _handleInterfaceLanguageChanged, onLocaleChanged: _handleInterfaceLanguageChanged,
onProfileSettingsChanged: _saveProfileSettings, onProfileSettingsChanged: _saveProfileSettings,
onSaveProfile: _saveProfile,
onUploadAvatar: _uploadAvatar, onUploadAvatar: _uploadAvatar,
onDivinationCompleted: _handleDivinationCompleted, onDivinationCompleted: _handleDivinationCompleted,
onLogout: _authBloc.logout, onLogout: _authBloc.logout,
@@ -25,6 +25,7 @@ class HomeScreen extends StatefulWidget {
required this.coinBalance, required this.coinBalance,
required this.onLocaleChanged, required this.onLocaleChanged,
required this.onProfileSettingsChanged, required this.onProfileSettingsChanged,
required this.onSaveProfile,
required this.onUploadAvatar, required this.onUploadAvatar,
required this.onDivinationCompleted, required this.onDivinationCompleted,
required this.onLogout, required this.onLogout,
@@ -39,6 +40,8 @@ class HomeScreen extends StatefulWidget {
final Future<void> Function(String languageTag) onLocaleChanged; final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings) final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged; onProfileSettingsChanged;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar; final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function(DivinationResultData result) final Future<void> Function(DivinationResultData result)
onDivinationCompleted; onDivinationCompleted;
@@ -49,7 +52,7 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
bool _showNotificationDot = true; MainTab _currentTab = MainTab.home;
@override @override
void initState() { void initState() {
@@ -79,224 +82,41 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
final historyItems = widget.historyRecords; final historyItems = widget.historyRecords;
return Scaffold( return Scaffold(
backgroundColor: colors.surfaceContainerLow, backgroundColor: colors.surfaceContainerLow,
body: SafeArea( body: IndexedStack(
child: SingleChildScrollView( index: _currentTab == MainTab.home ? 0 : 1,
padding: const EdgeInsets.only( children: [
top: AppSpacing.lg, _HomeTab(
bottom: AppSpacing.lg, historyItems: historyItems,
sessionStore: widget.sessionStore,
userId: widget.account,
onDivinationCompleted: widget.onDivinationCompleted,
allowVibration: widget.profileSettings.notification.allowVibration,
), ),
child: Column( _ProfileTab(
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<void>(
builder: (_) => _HistoryRecordsScreen(
records: historyItems,
onOpenResult: (item) {
Navigator.of(context).push(
MaterialPageRoute<void>(
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<void>(
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<void>(
builder: (_) => SettingsScreen(
account: widget.account, account: widget.account,
settings: widget.profileSettings, settings: widget.profileSettings,
coinBalance: widget.coinBalance, coinBalance: widget.coinBalance,
onInterfaceLanguageChanged: widget.onLocaleChanged, onLocaleChanged: widget.onLocaleChanged,
onSettingsChanged: widget.onProfileSettingsChanged, onSettingsChanged: widget.onProfileSettingsChanged,
onSaveProfile: widget.onSaveProfile,
onUploadAvatar: widget.onUploadAvatar, onUploadAvatar: widget.onUploadAvatar,
onLogout: widget.onLogout, onLogout: widget.onLogout,
), ),
), ],
); ),
} bottomNavigationBar: BottomNavBar(
currentTab: _currentTab,
onTabChange: (tab) {
setState(() => _currentTab = tab);
},
onLogoTap: _onStartDivination,
),
);
} }
void _onStartDivination() { void _onStartDivination() {
@@ -306,13 +126,219 @@ class _HomeScreenState extends State<HomeScreen> {
sessionStore: widget.sessionStore, sessionStore: widget.sessionStore,
userId: widget.account, userId: widget.account,
onCompleted: widget.onDivinationCompleted, onCompleted: widget.onDivinationCompleted,
allowVibration: widget.profileSettings.notification.allowVibration,
), ),
), ),
); );
} }
}
void _showSnack(BuildContext context, String message) { class _HomeTab extends StatelessWidget {
Toast.show(context, message, type: ToastType.info); const _HomeTab({
required this.historyItems,
required this.sessionStore,
required this.userId,
required this.onDivinationCompleted,
required this.allowVibration,
});
final List<DivinationResultData> historyItems;
final SessionStore sessionStore;
final String userId;
final Future<void> 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<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: () {
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<void>(
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<void>(
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<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;
@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<DivinationResultData> records; final List<DivinationResultData> records;
final ValueChanged<DivinationResultData> onOpenResult; final ValueChanged<DivinationResultData> onOpenResult;
String _itemKey(DivinationResultData item) {
return '${item.guaName}_${item.binaryCode}_${item.params.question}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -486,12 +516,42 @@ class _HistoryRecordsScreen extends StatelessWidget {
), ),
) )
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.all(AppSpacing.md), padding: EdgeInsets.only(
top: AppSpacing.md,
bottom: AppSpacing.md,
),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = records[index]; final item = records[index];
return _HistoryCard( return Dismissible(
item: item, key: ValueKey(_itemKey(item)),
onTap: () => onOpenResult(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), separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.md),
+24 -3
View File
@@ -75,10 +75,31 @@ class _NavItem extends StatelessWidget {
final bool selected; final bool selected;
final VoidCallback onTap; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final iconColor = colors.primary;
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md), borderRadius: BorderRadius.circular(AppRadius.md),
@@ -87,12 +108,12 @@ class _NavItem extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, color: iconColor), Icon(selected ? _filledIcon : _outlinedIcon, color: colors.primary),
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
Text( Text(
label, label,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: iconColor, color: colors.primary,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500, fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
), ),
), ),