feat: 切换邮箱认证并重构前后端启动与门禁
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/auth/session_store.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.onLogout,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final SessionStore sessionStore;
|
||||
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(bottom: AppSpacing.md),
|
||||
child: _HistoryCard(item: item),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: BottomNavBar(
|
||||
currentTab: MainTab.home,
|
||||
onTabChange: (_) {},
|
||||
onLogoTap: _onStartDivination,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onStartDivination() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
_showSnack(context, l10n.featurePending);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user