feat(home): 重构首页为底部导航栏布局,支持首页与个人页面切换
This commit is contained in:
+18
-1
@@ -242,15 +242,31 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
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 {
|
||||
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<EryaoApp> {
|
||||
coinBalance: _creditsBalance,
|
||||
onLocaleChanged: _handleInterfaceLanguageChanged,
|
||||
onProfileSettingsChanged: _saveProfileSettings,
|
||||
onSaveProfile: _saveProfile,
|
||||
onUploadAvatar: _uploadAvatar,
|
||||
onDivinationCompleted: _handleDivinationCompleted,
|
||||
onLogout: _authBloc.logout,
|
||||
|
||||
@@ -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<void> Function(String languageTag) onLocaleChanged;
|
||||
final Future<void> Function(ProfileSettingsV1 settings)
|
||||
onProfileSettingsChanged;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
onSaveProfile;
|
||||
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
||||
final Future<void> Function(DivinationResultData result)
|
||||
onDivinationCompleted;
|
||||
@@ -49,7 +52,7 @@ class HomeScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
bool _showNotificationDot = true;
|
||||
MainTab _currentTab = MainTab.home;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -79,224 +82,41 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
||||
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<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(
|
||||
_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<HomeScreen> {
|
||||
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<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 ValueChanged<DivinationResultData> 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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user