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,14 +82,80 @@ 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(
index: _currentTab == MainTab.home ? 0 : 1,
children: [
_HomeTab(
historyItems: historyItems,
sessionStore: widget.sessionStore,
userId: widget.account,
onDivinationCompleted: widget.onDivinationCompleted,
allowVibration: widget.profileSettings.notification.allowVibration,
),
_ProfileTab(
account: widget.account,
settings: widget.profileSettings,
coinBalance: widget.coinBalance,
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() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationScreen(
sessionStore: widget.sessionStore,
userId: widget.account,
onCompleted: widget.onDivinationCompleted,
allowVibration: widget.profileSettings.notification.allowVibration,
),
),
);
}
}
class _HomeTab extends StatelessWidget {
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( child: SingleChildScrollView(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: AppSpacing.lg, top: AppSpacing.lg,
@@ -101,23 +170,18 @@ class _HomeScreenState extends State<HomeScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
l10n.helloUser( l10n.historyTitle,
widget.account.isEmpty
? l10n.defaultUserName
: widget.account,
),
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleLarge?.copyWith(color: colors.primary), ).textTheme.titleLarge?.copyWith(color: colors.primary),
), ),
Stack(
children: [
IconButton( IconButton(
onPressed: () { onPressed: () {
setState(() { Toast.show(
_showNotificationDot = false; context,
}); l10n.featurePending,
_showSnack(context, l10n.featurePending); type: ToastType.info,
);
}, },
icon: Icon( icon: Icon(
Icons.notifications, Icons.notifications,
@@ -126,21 +190,6 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
tooltip: l10n.notify, 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,
),
),
),
],
),
], ],
), ),
), ),
@@ -158,16 +207,11 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
child: Column( child: Column(
children: [ children: [
Icon( Icon(Icons.auto_awesome, color: colors.onPrimary, size: 48),
Icons.auto_awesome,
color: colors.onPrimary,
size: 48,
),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
Text( Text(
l10n.startJourney, l10n.startJourney,
style: Theme.of(context).textTheme.titleMedium style: Theme.of(context).textTheme.titleMedium?.copyWith(
?.copyWith(
color: colors.onPrimary, color: colors.onPrimary,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -175,9 +219,9 @@ class _HomeScreenState extends State<HomeScreen> {
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
Text( Text(
l10n.journeySubtitle, l10n.journeySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
color: colors.onPrimary, context,
), ).textTheme.bodyMedium?.copyWith(color: colors.onPrimary),
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
FilledButton( FilledButton(
@@ -188,7 +232,18 @@ class _HomeScreenState extends State<HomeScreen> {
borderRadius: BorderRadius.circular(AppRadius.sm), borderRadius: BorderRadius.circular(AppRadius.sm),
), ),
), ),
onPressed: _onStartDivination, onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationScreen(
sessionStore: sessionStore,
userId: userId,
onCompleted: onDivinationCompleted,
allowVibration: allowVibration,
),
),
);
},
child: Text(l10n.startNow), child: Text(l10n.startNow),
), ),
], ],
@@ -198,35 +253,10 @@ class _HomeScreenState extends State<HomeScreen> {
const SizedBox(height: AppSpacing.xl), const SizedBox(height: AppSpacing.xl),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Row( child: Text(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.historyTitle, l10n.historyTitle,
style: Theme.of(context).textTheme.titleMedium, 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), const SizedBox(height: AppSpacing.md),
if (historyItems.isEmpty) if (historyItems.isEmpty)
@@ -248,7 +278,7 @@ class _HomeScreenState extends State<HomeScreen> {
else else
Column( Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: historyItems.map((item) { children: historyItems.take(4).map((item) {
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: AppSpacing.md, left: AppSpacing.md,
@@ -260,8 +290,7 @@ class _HomeScreenState extends State<HomeScreen> {
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => builder: (_) => DivinationResultScreen(data: item),
DivinationResultScreen(data: item),
), ),
); );
}, },
@@ -272,48 +301,45 @@ class _HomeScreenState extends State<HomeScreen> {
], ],
), ),
), ),
),
bottomNavigationBar: BottomNavBar(
currentTab: MainTab.home,
onTabChange: _onTabChange,
onLogoTap: _onStartDivination,
),
); );
} }
}
void _onTabChange(MainTab tab) { class _ProfileTab extends StatelessWidget {
if (tab == MainTab.profile) { const _ProfileTab({
Navigator.of(context).push( required this.account,
MaterialPageRoute<void>( required this.settings,
builder: (_) => SettingsScreen( required this.coinBalance,
account: widget.account, required this.onLocaleChanged,
settings: widget.profileSettings, required this.onSettingsChanged,
coinBalance: widget.coinBalance, required this.onSaveProfile,
onInterfaceLanguageChanged: widget.onLocaleChanged, required this.onUploadAvatar,
onSettingsChanged: widget.onProfileSettingsChanged, required this.onLogout,
onUploadAvatar: widget.onUploadAvatar, });
onLogout: widget.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,
); );
} }
}
void _onStartDivination() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationScreen(
sessionStore: widget.sessionStore,
userId: widget.account,
onCompleted: widget.onDivinationCompleted,
),
),
);
}
void _showSnack(BuildContext context, String message) {
Toast.show(context, message, type: ToastType.info);
}
} }
class _HistoryCard extends StatelessWidget { class _HistoryCard extends StatelessWidget {
@@ -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(
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, item: item,
onTap: () => onOpenResult(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,
), ),
), ),