feat: 实现用户画像、占卜历史与后端用户管理模块
This commit is contained in:
@@ -56,13 +56,9 @@ class AuthBloc extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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.runtimeType}',
|
||||
error: error.runtimeType.toString(),
|
||||
@@ -72,9 +68,6 @@ class AuthBloc extends ChangeNotifier {
|
||||
_logger.info(message: 'User logged out');
|
||||
_state = const AuthState(status: AuthStatus.unauthenticated);
|
||||
notifyListeners();
|
||||
if (caughtError != null) {
|
||||
Error.throwWithStackTrace(caughtError, caughtStackTrace!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleUnauthorized401() async {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../settings/presentation/screens/legal_document_screen.dart';
|
||||
import '../../../settings/presentation/utils/legal_document_assets.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
|
||||
@@ -156,17 +157,48 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
return l10n.errorRequestGeneric;
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration({
|
||||
required String hintText,
|
||||
required IconData icon,
|
||||
}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return InputDecoration(
|
||||
hintText: hintText,
|
||||
filled: true,
|
||||
fillColor: colors.surface.withValues(alpha: 0.92),
|
||||
prefixIcon: Icon(icon, color: colors.primary),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
borderSide: BorderSide(color: colors.primary, width: 1.6),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPolicyDialog(String title, String content) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
builder: (dialogContext) {
|
||||
return AppModalDialog(
|
||||
title: title,
|
||||
message: content,
|
||||
icon: Icons.description_outlined,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(AppLocalizations.of(context)!.dialogConfirm),
|
||||
AppModalDialogAction(
|
||||
label: AppLocalizations.of(dialogContext)!.dialogConfirm,
|
||||
primary: true,
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -197,214 +229,271 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_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.xxl),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.emailHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
hintText: l10n.codeHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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.full),
|
||||
),
|
||||
),
|
||||
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.full),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
),
|
||||
onPressed: canLogin ? _login : null,
|
||||
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 = () => _openLegalDocument(
|
||||
LegalDocumentType.privacyPolicy,
|
||||
),
|
||||
),
|
||||
TextSpan(text: l10n.agreementSeparator),
|
||||
TextSpan(
|
||||
text: l10n.termsOfService,
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => _openLegalDocument(
|
||||
LegalDocumentType.termsOfService,
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colors.secondaryContainer.withValues(alpha: 0.55),
|
||||
colors.primaryContainer.withValues(alpha: 0.42),
|
||||
colors.surfaceContainerLow,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -86,
|
||||
right: -42,
|
||||
child: Container(
|
||||
width: 180,
|
||||
height: 180,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colors.primary.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -110,
|
||||
left: -34,
|
||||
child: Container(
|
||||
width: 210,
|
||||
height: 210,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colors.secondary.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final bottomInset = MediaQuery.of(
|
||||
context,
|
||||
).viewInsets.bottom;
|
||||
return SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.lg + bottomInset,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: AppSpacing.xxxl),
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface.withValues(
|
||||
alpha: 0.9,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.full,
|
||||
),
|
||||
border: Border.all(
|
||||
color: colors.primary.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(
|
||||
AppSpacing.md,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxxl),
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: _inputDecoration(
|
||||
hintText: l10n.emailHint,
|
||||
icon: Icons.alternate_email,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
maxLength: 6,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: _inputDecoration(
|
||||
hintText: l10n.codeHint,
|
||||
icon: Icons.lock_outline,
|
||||
).copyWith(counterText: ''),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
SizedBox(
|
||||
width: 128,
|
||||
height: 52,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.primary,
|
||||
foregroundColor: colors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.full,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: _sendCode,
|
||||
child: Text(
|
||||
_isSending
|
||||
? l10n.sending
|
||||
: _countdown > 0
|
||||
? l10n.retryAfter(_countdown)
|
||||
: l10n.sendCode,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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.full,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
),
|
||||
onPressed: canLogin ? _login : null,
|
||||
child: Text(
|
||||
l10n.login,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _agreementChecked,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_agreementChecked = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: colors.onSurface),
|
||||
children: [
|
||||
TextSpan(text: l10n.agreementPrefix),
|
||||
TextSpan(
|
||||
text: l10n.privacyPolicy,
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
decoration:
|
||||
TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () =>
|
||||
_openLegalDocument(
|
||||
LegalDocumentType
|
||||
.privacyPolicy,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: l10n.agreementSeparator,
|
||||
),
|
||||
TextSpan(
|
||||
text: l10n.termsOfService,
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
decoration:
|
||||
TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () =>
|
||||
_openLegalDocument(
|
||||
LegalDocumentType
|
||||
.termsOfService,
|
||||
),
|
||||
),
|
||||
TextSpan(text: l10n.agreementAnd),
|
||||
TextSpan(
|
||||
text: l10n.disclaimer,
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
decoration:
|
||||
TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => _showPolicyDialog(
|
||||
l10n.disclaimer,
|
||||
l10n.disclaimerContent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user