592 lines
19 KiB
Dart
592 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../../core/auth/session_store.dart';
|
|
import '../../../divination/presentation/screens/divination_screen.dart';
|
|
import '../../../settings/data/models/profile_settings.dart';
|
|
import '../../../settings/presentation/screens/settings_screen.dart';
|
|
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';
|
|
import '../../../../shared/widgets/toast/toast.dart';
|
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({
|
|
super.key,
|
|
required this.account,
|
|
required this.sessionStore,
|
|
required this.currentLocale,
|
|
required this.profileSettings,
|
|
required this.coinBalance,
|
|
required this.onLocaleChanged,
|
|
required this.onLogout,
|
|
});
|
|
|
|
final String account;
|
|
final SessionStore sessionStore;
|
|
final Locale currentLocale;
|
|
final ProfileSettingsV1 profileSettings;
|
|
final int coinBalance;
|
|
final Future<void> Function(String languageTag) onLocaleChanged;
|
|
final Future<void> Function() onLogout;
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
bool _showNotificationDot = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
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 l10n = AppLocalizations.of(context)!;
|
|
final colors = Theme.of(context).colorScheme;
|
|
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
|
final historyItems = [
|
|
_HistoryItemData(
|
|
question: l10n.historyQuestion1,
|
|
category: _HistoryCategory.career,
|
|
guaName: l10n.guaName1,
|
|
sign: _HistorySign.good,
|
|
),
|
|
_HistoryItemData(
|
|
question: l10n.historyQuestion2,
|
|
category: _HistoryCategory.love,
|
|
guaName: l10n.guaName2,
|
|
sign: _HistorySign.normal,
|
|
),
|
|
_HistoryItemData(
|
|
question: l10n.historyQuestion3,
|
|
category: _HistoryCategory.money,
|
|
guaName: l10n.guaName3,
|
|
sign: _HistorySign.best,
|
|
),
|
|
];
|
|
|
|
return Scaffold(
|
|
backgroundColor: colors.surfaceContainerLow,
|
|
body: 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.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: () => _showSnack(context, l10n.featurePending),
|
|
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),
|
|
);
|
|
}).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,
|
|
settings: widget.profileSettings,
|
|
coinBalance: widget.coinBalance,
|
|
onInterfaceLanguageChanged: widget.onLocaleChanged,
|
|
onLogout: widget.onLogout,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _onStartDivination() {
|
|
Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute<void>(builder: (_) => const DivinationScreen()));
|
|
}
|
|
|
|
void _showSnack(BuildContext context, String message) {
|
|
Toast.show(context, message, type: ToastType.info);
|
|
}
|
|
}
|
|
|
|
class _HistoryCard extends StatelessWidget {
|
|
const _HistoryCard({required this.item});
|
|
|
|
final _HistoryItemData item;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final colors = Theme.of(context).colorScheme;
|
|
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
|
|
|
final categoryLabel = switch (item.category) {
|
|
_HistoryCategory.career => l10n.categoryCareer,
|
|
_HistoryCategory.love => l10n.categoryLove,
|
|
_HistoryCategory.money => l10n.categoryMoney,
|
|
};
|
|
|
|
final categoryStyle = switch (item.category) {
|
|
_HistoryCategory.career => (
|
|
palette.categoryCareerBg,
|
|
palette.categoryCareerText,
|
|
),
|
|
_HistoryCategory.love => (
|
|
palette.categoryLoveBg,
|
|
palette.categoryLoveText,
|
|
),
|
|
_HistoryCategory.money => (
|
|
palette.categoryMoneyBg,
|
|
palette.categoryMoneyText,
|
|
),
|
|
};
|
|
|
|
final signLabel = switch (item.sign) {
|
|
_HistorySign.best => l10n.signBest,
|
|
_HistorySign.good => l10n.signGood,
|
|
_HistorySign.normal => l10n.signNormal,
|
|
};
|
|
|
|
final signStyle = switch (item.sign) {
|
|
_HistorySign.best => (palette.historyGoldBg, palette.historyGoldText),
|
|
_HistorySign.good => (colors.surfaceContainerHighest, colors.primary),
|
|
_HistorySign.normal => (palette.historyGrayBg, palette.historyGrayText),
|
|
};
|
|
|
|
return Card(
|
|
margin: EdgeInsets.zero,
|
|
color: colors.surface,
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.question,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Wrap(
|
|
spacing: AppSpacing.sm,
|
|
runSpacing: AppSpacing.sm,
|
|
children: [
|
|
_Tag(
|
|
label: categoryLabel,
|
|
background: categoryStyle.$1,
|
|
foreground: categoryStyle.$2,
|
|
),
|
|
_Tag(
|
|
label: item.guaName,
|
|
background: palette.historyBlueBg,
|
|
foreground: palette.historyBlueText,
|
|
),
|
|
_Tag(
|
|
label: signLabel,
|
|
background: signStyle.$1,
|
|
foreground: signStyle.$2,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Tag extends StatelessWidget {
|
|
const _Tag({
|
|
required this.label,
|
|
required this.background,
|
|
required this.foreground,
|
|
});
|
|
|
|
final String label;
|
|
final Color background;
|
|
final Color foreground;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.sm,
|
|
vertical: AppSpacing.xs,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: foreground),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _HistoryCategory { career, love, money }
|
|
|
|
enum _HistorySign { best, good, normal }
|
|
|
|
class _HistoryItemData {
|
|
const _HistoryItemData({
|
|
required this.question,
|
|
required this.category,
|
|
required this.guaName,
|
|
required this.sign,
|
|
});
|
|
|
|
final String question;
|
|
final _HistoryCategory category;
|
|
final String guaName;
|
|
final _HistorySign sign;
|
|
}
|