feat: 切换邮箱认证并重构前后端启动与门禁

This commit is contained in:
qzl
2026-04-02 18:39:35 +08:00
parent 92cdfd9fca
commit 31594558eb
116 changed files with 5608 additions and 628 deletions
@@ -0,0 +1,41 @@
import '../../../../data/network/api_client.dart';
import '../models/session_response.dart';
class AuthApi {
AuthApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
Future<void> sendOtp({required String email}) async {
await _apiClient.postNoContent(
'/api/v1/auth/otp/send',
data: {'email': email},
);
}
Future<SessionResponse> createEmailSession({
required String email,
required String token,
}) async {
final json = await _apiClient.postJson(
'/api/v1/auth/email-session',
data: {'email': email, 'token': token},
);
return SessionResponse.fromJson(json);
}
Future<void> deleteSession({required String refreshToken}) async {
await _apiClient.deleteNoContent(
'/api/v1/auth/sessions',
data: {'refresh_token': refreshToken},
);
}
Future<SessionResponse> refreshSession({required String refreshToken}) async {
final json = await _apiClient.postJson(
'/api/v1/auth/sessions/refresh',
data: {'refresh_token': refreshToken},
);
return SessionResponse.fromJson(json);
}
}
@@ -0,0 +1,6 @@
class AuthUser {
const AuthUser({required this.id, required this.email});
final String id;
final String email;
}
@@ -0,0 +1,45 @@
class SessionResponse {
SessionResponse({
required this.accessToken,
required this.refreshToken,
required this.expiresIn,
required this.tokenType,
required this.userId,
required this.userEmail,
});
final String accessToken;
final String refreshToken;
final int expiresIn;
final String tokenType;
final String userId;
final String userEmail;
factory SessionResponse.fromJson(Map<String, dynamic> json) {
final user = (json['user'] as Map<String, dynamic>?) ?? <String, dynamic>{};
final accessToken = json['access_token'] as String?;
final refreshToken = json['refresh_token'] as String?;
final expiresIn = json['expires_in'] as int?;
final tokenType = json['token_type'] as String?;
final userId = user['id'] as String?;
final userEmail = user['email'] as String?;
if (accessToken == null ||
refreshToken == null ||
expiresIn == null ||
tokenType == null ||
userId == null ||
userEmail == null) {
throw const FormatException('Invalid session response payload');
}
return SessionResponse(
accessToken: accessToken,
refreshToken: refreshToken,
expiresIn: expiresIn,
tokenType: tokenType,
userId: userId,
userEmail: userEmail,
);
}
}
@@ -0,0 +1,86 @@
import '../../../../core/auth/session_store.dart';
import '../apis/auth_api.dart';
import '../models/auth_user.dart';
abstract class AuthRepository {
Future<void> sendOtp(String email);
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
});
Future<AuthUser?> recoverSession();
Future<void> logout();
Future<void> clearLocalSession();
}
class AuthRepositoryImpl implements AuthRepository {
AuthRepositoryImpl({
required AuthApi authApi,
required SessionStore sessionStore,
}) : _authApi = authApi,
_sessionStore = sessionStore;
final AuthApi _authApi;
final SessionStore _sessionStore;
@override
Future<void> sendOtp(String email) async {
await _authApi.sendOtp(email: email);
}
@override
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
}) async {
final session = await _authApi.createEmailSession(email: email, token: otp);
await _sessionStore.saveToken(session.accessToken);
await _sessionStore.saveRefreshToken(session.refreshToken);
await _sessionStore.saveEmail(email);
return AuthUser(id: session.userId, email: email);
}
@override
Future<AuthUser?> recoverSession() async {
final refreshToken = await _sessionStore.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
return null;
}
final session = await _authApi.refreshSession(refreshToken: refreshToken);
await _sessionStore.saveToken(session.accessToken);
await _sessionStore.saveRefreshToken(session.refreshToken);
final savedEmail = await _sessionStore.getEmail();
final email = savedEmail?.isNotEmpty == true
? savedEmail!
: session.userEmail;
if (email.isNotEmpty) {
await _sessionStore.saveEmail(email);
}
return AuthUser(id: session.userId, email: email);
}
@override
Future<void> logout() async {
final refreshToken = await _sessionStore.getRefreshToken();
try {
if (refreshToken != null && refreshToken.isNotEmpty) {
await _authApi.deleteSession(refreshToken: refreshToken);
}
} finally {
await clearLocalSession();
}
}
@override
Future<void> clearLocalSession() async {
await _sessionStore.clearToken();
await _sessionStore.clearRefreshToken();
await _sessionStore.clearEmail();
}
}
@@ -0,0 +1,98 @@
import 'package:flutter/foundation.dart';
import '../../../../core/logging/logger.dart';
import '../../data/repositories/auth_repository.dart';
import 'auth_state.dart';
class AuthBloc extends ChangeNotifier {
AuthBloc({required AuthRepository repository}) : _repository = repository;
final AuthRepository _repository;
final Logger _logger = getLogger('features.auth.bloc');
AuthState _state = AuthState.initial;
bool _handlingUnauthorized = false;
AuthState get state => _state;
Future<void> start() async {
_state = _state.copyWith(status: AuthStatus.loading, errorMessage: null);
notifyListeners();
try {
final user = await _repository.recoverSession();
if (user == null) {
_state = const AuthState(status: AuthStatus.unauthenticated);
} else {
_state = AuthState(status: AuthStatus.authenticated, user: user);
}
notifyListeners();
} catch (error, stackTrace) {
_logger.error(
message: 'Session recovery failed',
error: error,
stackTrace: stackTrace,
);
await _repository.clearLocalSession();
_state = AuthState(
status: AuthStatus.unauthenticated,
errorMessage: _toSafeMessage(error),
);
notifyListeners();
}
}
Future<void> sendOtp(String email) async {
await _repository.sendOtp(email);
}
Future<void> loginWithOtp({
required String email,
required String otp,
}) async {
final user = await _repository.loginWithEmailOtp(email: email, otp: otp);
_logger.info(message: 'User logged in', extra: {'user_id': user.id});
_state = AuthState(status: AuthStatus.authenticated, user: user);
notifyListeners();
}
Future<void> logout() async {
Object? caughtError;
StackTrace? caughtStackTrace;
try {
await _repository.logout();
} catch (error, stackTrace) {
caughtError = error;
caughtStackTrace = stackTrace;
_logger.error(
message: 'User logout failed',
error: error,
stackTrace: stackTrace,
);
}
_logger.info(message: 'User logged out');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
if (caughtError != null) {
Error.throwWithStackTrace(caughtError, caughtStackTrace!);
}
}
Future<void> handleUnauthorized401() async {
if (_handlingUnauthorized) {
return;
}
_handlingUnauthorized = true;
try {
await _repository.clearLocalSession();
_logger.warning(message: 'Session invalidated by 401 callback');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
} finally {
_handlingUnauthorized = false;
}
}
String _toSafeMessage(Object error) {
return 'Request failed, please try again';
}
}
@@ -0,0 +1,25 @@
import '../../data/models/auth_user.dart';
enum AuthStatus { initial, loading, authenticated, unauthenticated }
class AuthState {
const AuthState({required this.status, this.user, this.errorMessage});
final AuthStatus status;
final AuthUser? user;
final String? errorMessage;
AuthState copyWith({
AuthStatus? status,
AuthUser? user,
String? errorMessage,
}) {
return AuthState(
status: status ?? this.status,
user: user ?? this.user,
errorMessage: errorMessage,
);
}
static const AuthState initial = AuthState(status: AuthStatus.initial);
}
@@ -0,0 +1,388 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({
super.key,
required this.onRequestOtp,
required this.onLoginWithOtp,
required this.onLocaleChanged,
required this.currentLocale,
});
final Future<void> Function(String email) onRequestOtp;
final Future<void> Function(String email, String otp) onLoginWithOtp;
final ValueChanged<Locale> onLocaleChanged;
final Locale currentLocale;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final Logger _logger = getLogger('features.auth.login');
final TextEditingController _emailController = TextEditingController();
final TextEditingController _codeController = TextEditingController();
Timer? _timer;
int _countdown = 0;
bool _isSending = false;
bool _agreementChecked = false;
bool get _isValidEmail {
return RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
).hasMatch(_emailController.text.trim());
}
@override
void dispose() {
_timer?.cancel();
_emailController.dispose();
_codeController.dispose();
super.dispose();
}
void _showMessage(String message) {
Toast.show(context, message, type: ToastType.info);
}
Future<void> _sendCode() async {
final l10n = AppLocalizations.of(context)!;
if (!_isValidEmail) {
_showMessage(l10n.invalidEmail);
return;
}
if (_countdown > 0 || _isSending) {
return;
}
setState(() {
_isSending = true;
});
try {
await widget.onRequestOtp(_emailController.text.trim());
if (!mounted) {
return;
}
setState(() {
_isSending = false;
_countdown = 60;
});
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_countdown <= 1) {
timer.cancel();
setState(() {
_countdown = 0;
});
} else {
setState(() {
_countdown -= 1;
});
}
});
} catch (error, stackTrace) {
_logger.error(
message: 'Send OTP failed',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
setState(() {
_isSending = false;
});
_showMessage(_safeErrorMessage(error));
}
}
Future<void> _login() async {
final l10n = AppLocalizations.of(context)!;
if (!_isValidEmail) {
_showMessage(l10n.invalidEmail);
return;
}
if (_codeController.text.length != 6) {
_showMessage(l10n.invalidCode);
return;
}
if (!_agreementChecked) {
_showMessage(l10n.agreementRequired);
return;
}
try {
await widget.onLoginWithOtp(
_emailController.text.trim(),
_codeController.text,
);
if (!mounted) {
return;
}
} catch (error, stackTrace) {
_logger.error(
message: 'Login with OTP failed',
error: error,
stackTrace: stackTrace,
);
_showMessage(_safeErrorMessage(error));
}
}
String _safeErrorMessage(Object error) {
final l10n = AppLocalizations.of(context)!;
if (error is ApiProblem) {
return mapApiProblemToMessage(error, l10n);
}
return l10n.errorRequestGeneric;
}
void _showPolicyDialog(String title, String content) {
showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.dialogConfirm),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final canLogin =
_isValidEmail && _codeController.text.length == 6 && _agreementChecked;
return Scaffold(
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: AppSpacing.xxxl),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.welcomeLogin,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.loginSubtitleEmail,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
PopupMenuButton<Locale>(
icon: Icon(Icons.language, color: colors.primary),
onSelected: widget.onLocaleChanged,
itemBuilder: (context) => [
PopupMenuItem<Locale>(
value: const Locale('zh'),
child: Text(l10n.chinese),
),
PopupMenuItem<Locale>(
value: const Locale('en'),
child: Text(l10n.english),
),
],
),
],
),
const SizedBox(height: AppSpacing.xxl),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: l10n.emailHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
),
const SizedBox(height: AppSpacing.lg),
Row(
children: [
Expanded(
child: TextField(
controller: _codeController,
keyboardType: TextInputType.number,
maxLength: 6,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
counterText: '',
hintText: l10n.codeHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: 130,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.surfaceContainerHighest,
foregroundColor: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
onPressed: _sendCode,
child: Text(
_isSending
? l10n.sending
: _countdown > 0
? l10n.retryAfter(_countdown)
: l10n.sendCode,
),
),
),
],
),
const SizedBox(height: AppSpacing.xl),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
onPressed: canLogin ? _login : null,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm,
),
child: Text(
l10n.login,
style: const TextStyle(fontSize: 16),
),
),
),
),
const SizedBox(height: AppSpacing.md),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: _agreementChecked,
onChanged: (value) {
setState(() {
_agreementChecked = value ?? false;
});
},
),
Flexible(
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodySmall,
children: [
TextSpan(text: l10n.agreementPrefix),
TextSpan(
text: l10n.privacyPolicy,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showPolicyDialog(
l10n.privacyPolicy,
l10n.privacyContent,
),
),
TextSpan(text: l10n.agreementSeparator),
TextSpan(
text: l10n.termsOfService,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showPolicyDialog(
l10n.termsOfService,
l10n.termsContent,
),
),
TextSpan(text: l10n.agreementAnd),
TextSpan(
text: l10n.disclaimer,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showPolicyDialog(
l10n.disclaimer,
l10n.disclaimerContent,
),
),
],
),
),
),
],
),
),
const Spacer(),
Center(
child: Text(
l10n.icp,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.sm),
],
),
),
),
),
);
}
}
@@ -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;
}