feat: 添加视觉设计语言系统并重构认证页面UI

- 新增 visual_design_language.md 设计规范文档
- 新增 auth 设计 tokens (authBackground, authCard, authInput, feedback 系列等)
- 重构登录/注册/验证码/重置密码页面为新设计系统
- 新增 AuthHeroHeader, AuthSurfaceCard, AuthSection, AuthField, PasswordField 组件
- 重构 AppBanner 和 Toast 支持多类型配置 (info/success/warning/error)
- 后端 AgentScope: 重整 schemas/prompts/tools 作用域, 新增协议文档
- 更新 AGENTS.md 集成视觉设计语言约束
This commit is contained in:
qzl
2026-03-13 14:10:13 +08:00
parent fb3c649db7
commit a10a2db27a
100 changed files with 6333 additions and 4800 deletions
@@ -0,0 +1,38 @@
import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/calendar/data/services/calendar_service.dart';
import '../notifications/local_notification_service.dart';
class AuthSessionBootstrapper {
AuthSessionBootstrapper({
required CalendarService calendarService,
required LocalNotificationService notificationService,
}) : _calendarService = calendarService,
_notificationService = notificationService;
final CalendarService _calendarService;
final LocalNotificationService _notificationService;
String? _syncedUserId;
Future<void> syncForAuthState(AuthState state) async {
if (state is! AuthAuthenticated) {
_syncedUserId = null;
return;
}
if (_syncedUserId == state.user.id) {
return;
}
_syncedUserId = state.user.id;
try {
final now = DateTime.now();
final end = now.add(const Duration(days: 90));
final events = await _calendarService.getEventsForRange(now, end);
await _notificationService.rebuildUpcomingReminders(events);
} catch (_) {
// ignore reminder bootstrap failures
}
}
}
+44
View File
@@ -27,6 +27,8 @@ class AppColors {
static const blue600 = Color(0xFF2563EB);
static const blue500 = Color(0xFF3B82F6);
static const blue400 = Color(0xFF60A5FA);
static const blue300 = Color(0xFF93C5FD);
static const blue200 = Color(0xFFBFDBFE);
static const blue100 = Color(0xFFDBEAFE);
static const blue50 = Color(0xFFEFF6FF);
@@ -81,6 +83,48 @@ class AppColors {
static const appIconBorder = Color(0xFFC7DDFB);
static const appTitle = Color(0xFF1E293B);
static const authBackgroundTop = Color(0xFFF4F8FF);
static const authBackgroundBottom = Color(0xFFF8FAFC);
static const authBackgroundOrb = Color(0xFFDCEBFF);
static const authCardBackground = Color(0xFFFCFDFE);
static const authCardBorder = Color(0xFFE5ECF6);
static const authCardHighlight = Color(0xFFFFFFFF);
static const authSectionBackground = Color(0xFFF7FAFE);
static const authSectionBorder = Color(0xFFE4EBF5);
static const authInputBackground = Color(0xFFF6F9FD);
static const authInputBorder = Color(0xFFD9E4F1);
static const authInputFocus = Color(0xFF8EB8F3);
static const authInputIcon = Color(0xFF8A9BB2);
static const authPrimaryButton = Color(0xFF2F6FD6);
static const authPrimaryButtonPressed = Color(0xFF245FC0);
static const authPrimaryButtonDisabled = Color(0xFFD9E3F2);
static const authPrimaryButtonText = Color(0xFFF8FBFF);
static const authSecondaryButtonBackground = Color(0xFFF4F8FF);
static const authSecondaryButtonBorder = Color(0xFFD8E4F6);
static const authSecondaryButtonText = Color(0xFF315D9C);
static const authLinkText = Color(0xFF356CC8);
static const authLinkMuted = Color(0xFF70839E);
static const feedbackInfoSurface = Color(0xFFF3F8FF);
static const feedbackInfoBorder = Color(0xFFD6E5FB);
static const feedbackInfoIcon = Color(0xFF2D6CDF);
static const feedbackInfoText = Color(0xFF26476F);
static const feedbackSuccessSurface = Color(0xFFF1FBF6);
static const feedbackSuccessBorder = Color(0xFFCDECD9);
static const feedbackSuccessIcon = Color(0xFF129268);
static const feedbackSuccessText = Color(0xFF1E5A46);
static const feedbackWarningSurface = Color(0xFFFFF8ED);
static const feedbackWarningBorder = Color(0xFFF4DFC0);
static const feedbackWarningIcon = Color(0xFFD68A18);
static const feedbackWarningText = Color(0xFF7A5821);
static const feedbackErrorSurface = Color(0xFFFFF4F3);
static const feedbackErrorBorder = Color(0xFFF1D2D0);
static const feedbackErrorIcon = Color(0xFFD14F4B);
static const feedbackErrorText = Color(0xFF7E3735);
static const todoBg = Color(0xFFF8FAFC);
static const todoCardBg = Color(0xFFFFFFFF);
@@ -2,17 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/banner/app_banner.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../widgets/auth_page_scaffold.dart';
import '../../presentation/cubits/login_cubit.dart';
import '../../data/auth_repository.dart';
import '../../presentation/bloc/auth_bloc.dart';
import '../../presentation/bloc/auth_event.dart';
import '../../data/auth_repository.dart';
import '../../presentation/cubits/login_cubit.dart';
import '../widgets/auth_field.dart';
import '../widgets/auth_page_scaffold.dart';
import '../widgets/password_field.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@@ -36,7 +39,6 @@ class LoginView extends StatefulWidget {
class _LoginViewState extends State<LoginView> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
@@ -50,7 +52,9 @@ class _LoginViewState extends State<LoginView> {
cubit.emailChanged(_emailController.text);
cubit.passwordChanged(_passwordController.text);
if (!cubit.state.isValid) return;
if (!cubit.state.isValid) {
return;
}
final response = await cubit.submit();
if (response != null && mounted) {
@@ -64,178 +68,115 @@ class _LoginViewState extends State<LoginView> {
return AuthPageScaffold(
mainContentKey: const Key('login_main_content'),
footerKey: const Key('login_footer'),
mainContent: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildAppIcon(),
const SizedBox(height: 24),
_buildAppTitle(),
const SizedBox(height: 32),
_buildFormContainer(),
],
),
footer: _buildFooter(),
);
}
mainContent: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const AuthHeroHeader(showBrand: true),
SizedBox(height: AppSpacing.xxl),
BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
final fieldError = state.email.displayError != null
? state.email.error
: state.password.displayError != null
? state.password.error
: null;
Widget _buildAppIcon() {
return Container(
width: 104,
height: 104,
decoration: BoxDecoration(
color: AppColors.appIconRing,
borderRadius: BorderRadius.circular(52),
border: Border.all(color: AppColors.appIconBorder, width: 1),
),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(38),
child: Image.asset(
'assets/images/logo.png',
width: 76,
height: 76,
fit: BoxFit.cover,
),
),
),
);
}
Widget _buildAppTitle() {
return const Text(
'linksy',
style: TextStyle(
fontFamily: 'Playfair Display',
fontSize: 34,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
color: AppColors.appTitle,
letterSpacing: 0.5,
),
);
}
Widget _buildFormContainer() {
return BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
final fieldError = state.email.displayError != null
? state.email.error
: state.password.displayError != null
? state.password.error
: null;
return SizedBox(
width: 327,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildInput(
label: '邮箱',
hint: '请输入邮箱',
controller: _emailController,
hasError: state.email.displayError != null,
),
const SizedBox(height: 12),
_buildPasswordInput(state.password.displayError != null),
const SizedBox(height: 12),
if (state.errorMessage != null)
AppBanner(message: state.errorMessage!, type: ToastType.error)
else if (fieldError != null)
AppBanner(message: fieldError, type: ToastType.warning),
const SizedBox(height: 12),
AppButton(
text: '登录',
onPressed: state.status == FormzSubmissionStatus.inProgress
? null
: _handleLogin,
),
const SizedBox(height: 12),
_buildForgotPassword(),
],
),
);
},
);
}
Widget _buildInput({
required String label,
required String hint,
required TextEditingController controller,
bool hasError = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: controller,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: hint,
errorText: hasError ? ' ' : null,
),
),
],
);
}
Widget _buildPasswordInput(bool hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'密码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
hintText: '请输入密码',
errorText: hasError ? ' ' : null,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
return AuthSurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'登录账号',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
SizedBox(height: AppSpacing.xs),
SizedBox(height: AppSpacing.xl),
AuthSection(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthField(
label: '邮箱',
hint: 'name@example.com',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: AppSpacing.lg),
PasswordField(
controller: _passwordController,
label: '密码',
hint: '请输入密码',
),
if (state.errorMessage != null ||
fieldError != null)
Padding(
padding: const EdgeInsets.only(
top: AppSpacing.lg,
),
child: AppBanner(
message:
state.errorMessage ?? fieldError!,
type: state.errorMessage != null
? ToastType.error
: ToastType.warning,
title: state.errorMessage != null
? '登录失败'
: '请检查输入',
),
),
],
),
),
SizedBox(height: AppSpacing.xl),
AppButton(
text: '登录',
onPressed:
state.status == FormzSubmissionStatus.inProgress
? null
: _handleLogin,
isLoading:
state.status ==
FormzSubmissionStatus.inProgress,
),
SizedBox(height: AppSpacing.sm),
Align(
alignment: Alignment.centerRight,
child: LinkButton(
text: '忘记密码?',
onTap: () => context.push('/reset-password'),
),
),
],
),
);
},
),
],
),
),
),
],
);
}
Widget _buildForgotPassword() {
return LinkButton(
text: '忘记密码',
onTap: () => context.push('/reset-password'),
);
}
Widget _buildFooter() {
return LinkButton(
text: '还没有账号?去注册',
onTap: () => context.push('/register'),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'还没有账号',
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
),
LinkButton(text: '去注册', onTap: () => context.push('/register')),
],
),
);
}
}
@@ -4,17 +4,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/banner/app_banner.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../presentation/cubits/register_cubit.dart';
import '../../data/auth_repository.dart';
import '../../presentation/cubits/register_cubit.dart';
import '../widgets/auth_field.dart';
import '../widgets/auth_page_scaffold.dart';
import '../widgets/password_field.dart';
class RegisterScreen extends StatelessWidget {
const RegisterScreen({super.key});
@@ -75,7 +78,6 @@ class _RegisterViewState extends State<RegisterView> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _inviteCodeController = TextEditingController();
bool _obscureText = true;
@override
void dispose() {
@@ -131,223 +133,164 @@ class _RegisterViewState extends State<RegisterView> {
@override
Widget build(BuildContext context) {
return AuthPageScaffold(
mainContent: Column(
mainAxisSize: MainAxisSize.min,
mainContent: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 388),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const AuthHeroHeader(showBrand: true),
SizedBox(height: AppSpacing.xxl),
BlocBuilder<RegisterCubit, RegisterState>(
builder: (context, state) {
return AuthSurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'创建账号',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
SizedBox(height: AppSpacing.lg),
AuthSection(
title: '基础信息',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthField(
label: '昵称',
hint: '请输入昵称(3-30字符)',
controller: _nicknameController,
),
SizedBox(height: AppSpacing.lg),
AuthField(
label: '邮箱',
hint: 'name@example.com',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: AppSpacing.lg),
PasswordField(
controller: _passwordController,
label: '密码',
hint: '请输入至少 6 位密码',
),
],
),
),
SizedBox(height: AppSpacing.md),
AuthSection(
title: '邀请码(选填)',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FixedLengthCodeInput(
controller: _inviteCodeController,
length: _inviteCodeLength,
semanticLabel: '邀请码输入框',
uppercase: true,
allowedCharacters: _inviteAllowedChars,
onChanged: (value) {
context
.read<RegisterCubit>()
.inviteCodeChanged(value);
},
),
SizedBox(height: AppSpacing.md),
AppBanner(
title: '邀请码',
message: '4 位,支持 A-H/J-N/P-Z 与 2-9。',
type: ToastType.info,
),
],
),
),
if (state.errorMessage != null) ...[
SizedBox(height: AppSpacing.lg),
AppBanner(
title: '注册失败',
message: state.errorMessage!,
type: ToastType.error,
),
],
SizedBox(height: AppSpacing.lg),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Container(
height: 6,
decoration: BoxDecoration(
color: AppColors.authPrimaryButton,
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
),
),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Container(
height: 6,
decoration: BoxDecoration(
color: AppColors.authSectionBorder,
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
),
),
],
),
SizedBox(height: AppSpacing.sm),
const Text(
'第 1 步:完善基础信息',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.authLinkMuted,
),
),
SizedBox(height: AppSpacing.lg),
AppButton(
text: '下一步',
onPressed:
state.status ==
FormzSubmissionStatus.inProgress ||
state.isSending
? null
: _handleNext,
isLoading: state.isSending,
),
],
),
);
},
),
],
),
),
),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildAppIcon(),
const SizedBox(height: 24),
_buildAppTitle(),
const SizedBox(height: 24),
_buildFormContainer(),
const Text(
'已有账号?',
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
),
LinkButton(text: '去登录', onTap: () => context.pop()),
],
),
footer: _buildFooter(),
);
}
Widget _buildAppIcon() {
return Container(
width: 104,
height: 104,
decoration: BoxDecoration(
color: AppColors.appIconRing,
borderRadius: BorderRadius.circular(52),
border: Border.all(color: AppColors.appIconBorder, width: 1),
),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(38),
child: Image.asset(
'assets/images/logo.png',
width: 76,
height: 76,
fit: BoxFit.cover,
),
),
),
);
}
Widget _buildAppTitle() {
return const Text(
'linksy',
style: TextStyle(
fontFamily: 'Playfair Display',
fontSize: 34,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
color: AppColors.appTitle,
letterSpacing: 0.5,
),
);
}
Widget _buildFormContainer() {
return BlocBuilder<RegisterCubit, RegisterState>(
builder: (context, state) {
return SizedBox(
width: 327,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildInput('昵称', '请输入昵称', _nicknameController),
const SizedBox(height: 12),
_buildInput('邮箱', '请输入邮箱', _emailController),
const SizedBox(height: 12),
_buildPasswordInput(),
const SizedBox(height: 12),
_buildInviteCodeInput(),
const SizedBox(height: 12),
_buildStepIndicator(),
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: AppBanner(
message: state.errorMessage!,
type: ToastType.error,
),
),
const SizedBox(height: 12),
AppButton(
text: '下一步',
onPressed:
state.status == FormzSubmissionStatus.inProgress ||
state.isSending
? null
: _handleNext,
),
],
),
);
},
);
}
Widget _buildInviteCodeInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'邀请码(选填)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
FixedLengthCodeInput(
controller: _inviteCodeController,
length: _inviteCodeLength,
semanticLabel: '邀请码输入框',
uppercase: true,
allowedCharacters: _inviteAllowedChars,
onChanged: (value) {
context.read<RegisterCubit>().inviteCodeChanged(value);
},
),
const SizedBox(height: 6),
const Text(
'4 位邀请码,支持 A-H/J-N/P-Z 与 2-9',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.slate500,
),
),
],
);
}
Widget _buildInput(
String label,
String hint,
TextEditingController controller,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: controller,
decoration: InputDecoration(hintText: hint),
),
],
);
}
Widget _buildPasswordInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'密码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: _passwordController,
obscureText: _obscureText,
decoration: InputDecoration(
hintText: '请输入至少 6 位密码',
suffixIcon: IconButton(
icon: Icon(
_obscureText ? Icons.visibility_off : Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: () {
setState(() {
_obscureText = !_obscureText;
});
},
),
),
),
],
);
}
Widget _buildStepIndicator() {
return Row(
children: [
Expanded(
child: Container(
height: 4,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(99),
),
),
),
const SizedBox(width: 6),
Expanded(
child: Container(
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFDCE3EC),
borderRadius: BorderRadius.circular(99),
),
),
),
],
);
}
Widget _buildFooter() {
return LinkButton(text: '已有账号?去登录', onTap: () => context.pop());
}
}
@@ -2,32 +2,34 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:formz/formz.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/banner/app_banner.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../presentation/cubits/register_cubit.dart';
import '../../presentation/bloc/auth_bloc.dart';
import '../../presentation/bloc/auth_event.dart';
import '../../presentation/cubits/register_cubit.dart';
import '../widgets/auth_page_scaffold.dart';
class RegisterVerificationScreen extends StatelessWidget {
final RegisterCubit? cubit;
const RegisterVerificationScreen({super.key, this.cubit});
final RegisterCubit? cubit;
@override
Widget build(BuildContext context) {
final registerCubit =
cubit ?? (GoRouterState.of(context).extra as RegisterCubit?);
if (registerCubit == null) {
return Scaffold(
body: Center(child: Text('Error: RegisterCubit not found')),
return const Scaffold(
body: Center(child: Text('RegisterCubit not found')),
);
}
@@ -52,11 +54,6 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
int _countdown = 0;
bool _firstSendCompleted = false;
@override
void initState() {
super.initState();
}
@override
void dispose() {
_countdownTimer?.cancel();
@@ -85,15 +82,11 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
cubit.verificationCodeChanged(_codeController.text);
if (!cubit.state.isStep2Valid) {
String? errorMsg;
if (_codeController.text.isEmpty) {
errorMsg = '请输入验证码';
} else {
errorMsg = '验证码必须是 6 位数字';
}
if (mounted) {
Toast.show(context, errorMsg, type: ToastType.warning);
}
Toast.show(
context,
_codeController.text.isEmpty ? '请输入验证码' : '验证码必须是 6 位数字',
type: ToastType.warning,
);
return;
}
@@ -117,228 +110,132 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
@override
Widget build(BuildContext context) {
return AuthPageScaffold(
mainContent: Column(
mainAxisSize: MainAxisSize.min,
mainContent: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 388),
child: BlocConsumer<RegisterCubit, RegisterState>(
listener: (context, state) {
if (!mounted) {
return;
}
if (state.status == FormzSubmissionStatus.failure &&
state.errorMessage != null) {
Toast.show(context, state.errorMessage!, type: ToastType.error);
if (!_firstSendCompleted) {
_firstSendCompleted = true;
setState(() {
_countdown = 0;
});
}
}
if (state.status == FormzSubmissionStatus.success &&
!_firstSendCompleted) {
_firstSendCompleted = true;
_startCountdown();
Toast.show(context, '验证码已发送', type: ToastType.info);
}
},
builder: (context, state) {
final isSubmitting =
state.status == FormzSubmissionStatus.inProgress;
final canResend = _countdown == 0 && !isSubmitting;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const AuthHeroHeader(showBrand: true),
SizedBox(height: AppSpacing.xl),
AuthSurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'验证邮箱',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
SizedBox(height: AppSpacing.lg),
AuthSection(
title: '验证码',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '邮箱验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context
.read<RegisterCubit>()
.verificationCodeChanged(value);
},
),
SizedBox(height: AppSpacing.md),
AppBanner(
title: '验证码',
message: canResend
? '6 位数字验证码。'
: '$_countdown 秒后可重新发送。',
type: ToastType.info,
),
],
),
),
SizedBox(height: AppSpacing.lg),
AppButton(
text: '完成注册',
onPressed: isSubmitting ? null : _handleComplete,
isLoading: isSubmitting,
),
SizedBox(height: AppSpacing.sm),
Center(
child: LinkButton(
text: canResend ? '重新发送验证码' : '$_countdown 秒后重发',
onTap: canResend ? _handleResendCode : null,
enabled: canResend,
),
),
],
),
),
],
);
},
),
),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildAppIcon(),
const SizedBox(height: 24),
_buildAppTitle(),
const SizedBox(height: 24),
_buildFormContainer(),
const Text(
'已有账号?',
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
),
LinkButton(text: '去登录', onTap: () => context.go('/')),
],
),
footer: _buildFooter(),
);
}
Widget _buildAppIcon() {
return Container(
width: 104,
height: 104,
decoration: BoxDecoration(
color: AppColors.appIconRing,
borderRadius: BorderRadius.circular(52),
border: Border.all(color: AppColors.appIconBorder, width: 1),
),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(38),
child: Image.asset(
'assets/images/logo.png',
width: 76,
height: 76,
fit: BoxFit.cover,
),
),
),
);
}
Widget _buildAppTitle() {
return const Text(
'linksy',
style: TextStyle(
fontFamily: 'Playfair Display',
fontSize: 34,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
color: AppColors.appTitle,
letterSpacing: 0.5,
),
);
}
Widget _buildFormContainer() {
return BlocConsumer<RegisterCubit, RegisterState>(
listener: (context, state) {
if (!mounted) return;
if (state.status == FormzSubmissionStatus.failure &&
state.errorMessage != null) {
Toast.show(context, state.errorMessage!, type: ToastType.error);
if (!_firstSendCompleted) {
_firstSendCompleted = true;
setState(() {
_countdown = 0;
});
}
}
if (state.status == FormzSubmissionStatus.success &&
!_firstSendCompleted) {
_firstSendCompleted = true;
_startCountdown();
Toast.show(
context,
'验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册',
type: ToastType.info,
duration: const Duration(seconds: 5),
);
}
},
builder: (context, state) {
return SizedBox(
width: 327,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCodeInput(state),
const SizedBox(height: 12),
_buildStepIndicator(),
const SizedBox(height: 12),
AppButton(
text: '完成注册',
onPressed: state.status == FormzSubmissionStatus.inProgress
? null
: _handleComplete,
),
],
),
);
},
);
}
Widget _buildCodeInput(RegisterState state) {
final canResend =
_countdown == 0 && state.status != FormzSubmissionStatus.inProgress;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'邮箱验证码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: SizedBox(
height: 40,
child: FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '邮箱验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context.read<RegisterCubit>().verificationCodeChanged(
value,
);
},
),
),
),
const SizedBox(width: 8),
_buildResendButton(canResend, state),
],
),
],
);
}
Widget _buildResendButton(bool canResend, RegisterState state) {
final canPress =
canResend && state.status != FormzSubmissionStatus.inProgress;
return SizedBox(
width: 70,
height: 44,
child: TextButton(
onPressed: canPress ? _handleResendCode : null,
style: TextButton.styleFrom(
backgroundColor: canResend ? AppColors.primary : AppColors.slate100,
foregroundColor: canResend ? AppColors.white : AppColors.slate400,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
padding: EdgeInsets.zero,
),
child: state.status == FormzSubmissionStatus.inProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.slate400),
),
)
: Text(
canResend ? '重发' : '${_countdown}s',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
);
}
Widget _buildStepIndicator() {
return Row(
children: [
Expanded(
child: Container(
height: 4,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(99),
),
),
),
const SizedBox(width: 6),
Expanded(
child: Container(
height: 4,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(99),
),
),
),
],
);
}
Widget _buildFooter() {
return LinkButton(text: '已有账号?去登录', onTap: () => context.go('/'));
}
}
@@ -2,16 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/banner/app_banner.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../presentation/cubits/reset_password_cubit.dart';
import '../../data/auth_repository.dart';
import '../../presentation/cubits/reset_password_cubit.dart';
import '../widgets/auth_field.dart';
import '../widgets/auth_page_scaffold.dart';
import '../widgets/password_field.dart';
class ResetPasswordScreen extends StatelessWidget {
const ResetPasswordScreen({super.key});
@@ -37,8 +41,6 @@ class _ResetPasswordViewState extends State<ResetPasswordView> {
final _codeController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
@override
void dispose() {
@@ -59,6 +61,15 @@ class _ResetPasswordViewState extends State<ResetPasswordView> {
await cubit.submit();
}
void _handleSendCode(ResetPasswordState state) {
if (state.codeSent) {
context.read<ResetPasswordCubit>().resendCode();
return;
}
context.read<ResetPasswordCubit>().sendCode();
}
@override
Widget build(BuildContext context) {
return BlocListener<ResetPasswordCubit, ResetPasswordState>(
@@ -82,261 +93,175 @@ class _ResetPasswordViewState extends State<ResetPasswordView> {
}
},
child: AuthPageScaffold(
mainContent: Column(
mainAxisSize: MainAxisSize.min,
mainContent: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 392),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const AuthHeroHeader(title: '忘记密码'),
SizedBox(height: AppSpacing.xxl),
BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
builder: (context, state) {
final isSending =
state.status == FormzSubmissionStatus.inProgress &&
!state.codeSent;
final isSubmitting =
state.status == FormzSubmissionStatus.inProgress &&
state.codeSent;
return AuthSurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'找回访问权限',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
SizedBox(height: AppSpacing.xs),
SizedBox(height: AppSpacing.xl),
AuthSection(
title: '第 1 步:验证邮箱',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthField(
label: '邮箱',
hint: 'name@example.com',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
onChanged: (value) {
context
.read<ResetPasswordCubit>()
.emailChanged(value);
},
),
SizedBox(height: AppSpacing.lg),
AppButton(
text: state.resendCountdown > 0
? '${state.resendCountdown} 秒后可重发'
: state.codeSent
? '重新发送验证码'
: '发送验证码',
onPressed:
state.resendCountdown > 0 || isSending
? null
: () => _handleSendCode(state),
isOutlined: state.codeSent,
isLoading: isSending,
),
],
),
),
if (state.codeSent) ...[
SizedBox(height: AppSpacing.lg),
AuthSection(
title: '第 2 步:输入验证码并设置新密码',
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
AppBanner(
title: '验证码已发送',
message: state.resendCountdown > 0
? '如未收到邮件,可在 ${state.resendCountdown} 秒后重新发送。'
: '如果没有收到邮件,可以再次发送验证码。',
type: ToastType.info,
),
SizedBox(height: AppSpacing.lg),
const Text(
'验证码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
SizedBox(height: AppSpacing.sm),
FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '重置密码验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context
.read<ResetPasswordCubit>()
.codeChanged(value);
},
),
SizedBox(height: AppSpacing.lg),
PasswordField(
controller: _passwordController,
label: '新密码',
hint: '请输入新密码(至少 6 位)',
onChanged: (value) {
context
.read<ResetPasswordCubit>()
.newPasswordChanged(value);
},
),
SizedBox(height: AppSpacing.lg),
PasswordField(
controller: _confirmPasswordController,
label: '确认密码',
hint: '请再次输入新密码',
onChanged: (value) {
context
.read<ResetPasswordCubit>()
.confirmPasswordChanged(value);
},
),
],
),
),
SizedBox(height: AppSpacing.xl),
AppButton(
text: '重置密码',
onPressed: isSubmitting ? null : _handleSubmit,
isLoading: isSubmitting,
),
],
],
),
);
},
),
],
),
),
),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildTitle(),
const SizedBox(height: 32),
_buildFormContainer(),
const Text(
'想起密码了?',
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
),
LinkButton(text: '返回登录', onTap: () => context.go('/')),
],
),
),
);
}
Widget _buildTitle() {
return const Text(
'忘记密码',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
);
}
Widget _buildFormContainer() {
return BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
builder: (context, state) {
return SizedBox(
width: 327,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildEmailInput(state.email.displayError != null),
const SizedBox(height: 12),
_buildCodeInput(state),
const SizedBox(height: 12),
_buildPasswordInput(state.newPassword.displayError != null),
const SizedBox(height: 12),
_buildConfirmPasswordInput(
state.confirmPassword.displayError != null,
),
const SizedBox(height: 24),
_buildSubmitButton(state),
const SizedBox(height: 16),
_buildBackToLogin(),
],
),
);
},
);
}
Widget _buildEmailInput(bool hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'邮箱',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
onChanged: (value) {
context.read<ResetPasswordCubit>().emailChanged(value);
},
decoration: InputDecoration(
hintText: '请输入邮箱',
errorText: hasError ? ' ' : null,
),
),
],
);
}
Widget _buildCodeInput(ResetPasswordState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'验证码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '重置密码验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context.read<ResetPasswordCubit>().codeChanged(value);
},
),
),
const SizedBox(width: 12),
SizedBox(
height: 40,
child: TextButton(
onPressed:
state.resendCountdown > 0 ||
state.status == FormzSubmissionStatus.inProgress
? null
: () {
if (state.codeSent) {
context.read<ResetPasswordCubit>().resendCode();
} else {
context.read<ResetPasswordCubit>().sendCode();
}
},
style: TextButton.styleFrom(
backgroundColor: state.codeSent
? AppColors.background
: AppColors.primary,
foregroundColor: state.codeSent
? AppColors.primary
: AppColors.primaryForeground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
padding: const EdgeInsets.symmetric(horizontal: 14),
),
child: Text(
state.resendCountdown > 0
? '${state.resendCountdown}'
: (state.codeSent ? '重新发送' : '发送验证码'),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
);
}
Widget _buildPasswordInput(bool hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'新密码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
onChanged: (value) {
context.read<ResetPasswordCubit>().newPasswordChanged(value);
},
decoration: InputDecoration(
hintText: '请输入新密码(至少 6 位)',
errorText: hasError ? ' ' : null,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
),
],
);
}
Widget _buildConfirmPasswordInput(bool hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'确认密码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
onChanged: (value) {
context.read<ResetPasswordCubit>().confirmPasswordChanged(value);
},
decoration: InputDecoration(
hintText: '请再次输入新密码',
errorText: hasError ? ' ' : null,
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_off
: Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
),
),
],
);
}
Widget _buildSubmitButton(ResetPasswordState state) {
final isLoading = state.status == FormzSubmissionStatus.inProgress;
final isDisabled = isLoading || !state.codeSent;
return AppButton(
text: '重置密码',
onPressed: isDisabled ? null : _handleSubmit,
);
}
Widget _buildBackToLogin() {
return LinkButton(text: '返回登录', onTap: () => context.go('/'));
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
class AuthField extends StatelessWidget {
const AuthField({
super.key,
required this.label,
required this.hint,
required this.controller,
this.keyboardType,
this.obscureText = false,
this.suffixIcon,
this.onChanged,
});
final String label;
final String hint;
final TextEditingController controller;
final TextInputType? keyboardType;
final bool obscureText;
final Widget? suffixIcon;
final ValueChanged<String>? onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
SizedBox(height: AppSpacing.sm),
Semantics(
label: label,
textField: true,
child: TextField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
onChanged: onChanged,
style: const TextStyle(fontSize: 16, color: AppColors.slate900),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.slate400,
),
filled: true,
fillColor: AppColors.authInputBackground,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.authInputBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.authInputFocus),
),
),
),
),
],
);
}
}
@@ -1,5 +1,3 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
@@ -23,44 +21,280 @@ class AuthPageScaffold extends StatelessWidget {
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final viewportHeight = math.max(
constraints.maxHeight - keyboardInset,
AppSpacing.none,
);
return SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.none,
AppSpacing.xxl,
keyboardInset + AppSpacing.xxl,
),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewportHeight),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
key: mainContentKey,
child: Center(child: mainContent),
backgroundColor: AppColors.authBackgroundBottom,
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.authBackgroundTop,
AppColors.authBackgroundBottom,
],
),
),
child: Stack(
children: [
const _AuthBackgroundOrbs(),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.md,
AppSpacing.lg,
keyboardInset + AppSpacing.md,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
if (footer != null)
Container(key: footerKey, child: footer),
SizedBox(height: AppSpacing.xxl),
],
),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
KeyedSubtree(
key: mainContentKey,
child: mainContent,
),
if (footer != null) ...[
SizedBox(height: AppSpacing.md),
KeyedSubtree(key: footerKey, child: footer!),
],
],
),
),
),
);
},
),
);
},
),
],
),
),
);
}
}
class AuthHeroHeader extends StatelessWidget {
const AuthHeroHeader({
super.key,
this.title,
this.subtitle,
this.showBrand = false,
});
final String? title;
final String? subtitle;
final bool showBrand;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (showBrand) ...[
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: AppColors.appIconRing,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.appIconBorder),
boxShadow: [
BoxShadow(
color: AppColors.blue300.withValues(alpha: 0.28),
blurRadius: 30,
offset: const Offset(0, 16),
),
],
),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.full),
child: Image.asset(
'assets/images/logo.png',
width: 58,
height: 58,
fit: BoxFit.cover,
),
),
),
),
SizedBox(height: AppSpacing.lg),
const Text(
'linksy',
style: TextStyle(
fontFamily: 'Playfair Display',
fontSize: 34,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
color: AppColors.appTitle,
letterSpacing: 0.4,
),
),
],
if (title != null) ...[
if (showBrand) SizedBox(height: AppSpacing.lg),
Text(
title!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
letterSpacing: -0.2,
),
),
],
if (subtitle != null) ...[
SizedBox(height: AppSpacing.sm),
Text(
subtitle!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
height: 1.45,
color: AppColors.authLinkMuted,
),
),
],
],
);
}
}
class AuthSurfaceCard extends StatelessWidget {
const AuthSurfaceCard({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
color: AppColors.authCardBackground,
borderRadius: BorderRadius.circular(AppRadius.xxl),
border: Border.all(color: AppColors.authCardBorder),
boxShadow: [
BoxShadow(
color: AppColors.blue200.withValues(alpha: 0.18),
blurRadius: 34,
offset: const Offset(0, 18),
),
BoxShadow(
color: AppColors.slate900.withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: child,
);
}
}
class AuthSection extends StatelessWidget {
const AuthSection({
super.key,
this.title,
this.description,
required this.child,
});
final String? title;
final String? description;
final Widget child;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.slate800,
),
),
if (description != null) ...[
SizedBox(height: AppSpacing.xs),
Text(
description!,
style: const TextStyle(
fontSize: 13,
height: 1.4,
color: AppColors.authLinkMuted,
),
),
],
SizedBox(height: AppSpacing.md),
],
child,
],
);
}
}
class _AuthBackgroundOrbs extends StatelessWidget {
const _AuthBackgroundOrbs();
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Stack(
children: [
Positioned(
top: -72,
left: -38,
child: _Orb(
size: 168,
color: AppColors.authBackgroundOrb.withValues(alpha: 0.42),
),
),
Positioned(
top: 108,
right: -32,
child: _Orb(
size: 120,
color: AppColors.blue100.withValues(alpha: 0.32),
),
),
Positioned(
bottom: 36,
left: 24,
child: _Orb(
size: 92,
color: AppColors.blue50.withValues(alpha: 0.7),
),
),
],
),
);
}
}
class _Orb extends StatelessWidget {
const _Orb({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import 'auth_field.dart';
class PasswordField extends StatefulWidget {
const PasswordField({
super.key,
required this.controller,
required this.label,
required this.hint,
this.onChanged,
});
final TextEditingController controller;
final String label;
final String hint;
final ValueChanged<String>? onChanged;
@override
State<PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<PasswordField> {
bool _obscured = true;
void _toggleVisibility() {
setState(() {
_obscured = !_obscured;
});
}
@override
Widget build(BuildContext context) {
return AuthField(
label: widget.label,
hint: widget.hint,
controller: widget.controller,
obscureText: _obscured,
onChanged: widget.onChanged,
suffixIcon: IconButton(
onPressed: _toggleVisibility,
tooltip: _obscured ? '显示密码' : '隐藏密码',
icon: Icon(
_obscured ? Icons.visibility_off_rounded : Icons.visibility_rounded,
color: AppColors.authInputIcon,
),
),
);
}
}
@@ -26,7 +26,7 @@ class InboxMessageResponse {
final InboxMessageType messageType;
final String? scheduleItemId;
final String? friendshipId;
final String? content;
final Map<String, dynamic>? content;
final bool isRead;
final InboxMessageStatus status;
final DateTime createdAt;
@@ -52,7 +52,7 @@ class InboxMessageResponse {
messageType: InboxMessageType.fromJson(json['message_type'] as String),
scheduleItemId: json['schedule_item_id'] as String?,
friendshipId: json['friendship_id'] as String?,
content: json['content'] as String?,
content: json['content'] as Map<String, dynamic>?,
isRead: json['is_read'] as bool,
status: InboxMessageStatus.fromJson(json['status'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
@@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:flutter/material.dart' hide BackButton;
import 'package:go_router/go_router.dart';
@@ -139,13 +137,8 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
}
}
Map<String, dynamic>? _parseCalendarContent(String? content) {
if (content == null) return null;
try {
return jsonDecode(content) as Map<String, dynamic>;
} catch (_) {
return null;
}
Map<String, dynamic>? _parseCalendarContent(Map<String, dynamic>? content) {
return content;
}
Future<(String calendarTitle, String senderName)?> _getCalendarInviteInfo(
@@ -250,7 +243,7 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
if (friendRequest == null) return;
final title = '${friendRequest.sender.username} 请求添加您为好友';
final description = message.content;
final description = message.content?['message'] as String?;
final statusText = isReadOnly
? (friendRequest.status == 'accepted'
? '已接受'
@@ -579,13 +572,8 @@ class _MessageCard extends StatelessWidget {
return '${friendRequest!.sender.username} 请求添加您为好友';
}
if (message.messageType == InboxMessageType.calendar) {
try {
final data =
jsonDecode(message.content ?? '{}') as Map<String, dynamic>;
return data['title'] as String? ?? '日历邀请';
} catch (_) {
return '日历邀请';
}
final data = message.content;
return data?['title'] as String? ?? '日历邀请';
}
return '系统消息';
}
@@ -594,11 +582,7 @@ class _MessageCard extends StatelessWidget {
if (message.messageType == InboxMessageType.calendar) {
Map<String, dynamic>? data;
if (message.content != null) {
try {
data = jsonDecode(message.content!) as Map<String, dynamic>;
} catch (_) {
data = null;
}
data = message.content;
}
if (data == null) return '点击查看详情';
@@ -617,6 +601,6 @@ class _MessageCard extends StatelessWidget {
}
return '点击查看详情';
}
return message.content ?? '点击查看详情';
return message.content?['message'] as String? ?? '点击查看详情';
}
}
@@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
@@ -19,13 +17,8 @@ class CalendarInviteCard extends StatelessWidget {
});
String? get eventTitle {
if (message.content == null) return null;
try {
final data = jsonDecode(message.content!) as Map<String, dynamic>;
return data['title'] as String?;
} catch (_) {
return null;
}
final data = message.content;
return data?['title'] as String?;
}
@override
@@ -101,13 +94,8 @@ class CalendarUpdateCard extends StatelessWidget {
const CalendarUpdateCard({super.key, required this.message, this.onTap});
String? get eventTitle {
if (message.content == null) return null;
try {
final data = jsonDecode(message.content!) as Map<String, dynamic>;
return data['title'] as String?;
} catch (_) {
return null;
}
final data = message.content;
return data?['title'] as String?;
}
@override
@@ -190,13 +178,8 @@ class CalendarDeleteCard extends StatelessWidget {
const CalendarDeleteCard({super.key, required this.message});
String? get eventTitle {
if (message.content == null) return null;
try {
final data = jsonDecode(message.content!) as Map<String, dynamic>;
return data['title'] as String?;
} catch (_) {
return null;
}
final data = message.content;
return data?['title'] as String?;
}
@override
+33 -22
View File
@@ -1,50 +1,61 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/config/env.dart';
import 'core/di/injection.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';
import 'core/notifications/local_notification_service.dart';
import 'core/router/app_router.dart';
import 'core/startup/auth_session_bootstrapper.dart';
import 'core/theme/app_theme.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/bloc/auth_event.dart';
import 'features/auth/presentation/bloc/auth_state.dart';
import 'features/calendar/data/services/calendar_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies();
final notificationService = sl<LocalNotificationService>();
await notificationService.initialize();
final authBloc = sl<AuthBloc>();
authBloc.add(AuthStarted());
try {
final now = DateTime.now();
final end = now.add(const Duration(days: 90));
final events = await sl<CalendarService>().getEventsForRange(now, end);
await notificationService.rebuildUpcomingReminders(events);
} catch (_) {
// ignore startup sync failures
}
runApp(LinksyApp(authBloc: authBloc));
runApp(
LinksyApp(
authBloc: authBloc,
sessionBootstrapper: AuthSessionBootstrapper(
calendarService: sl<CalendarService>(),
notificationService: sl<LocalNotificationService>(),
),
),
);
}
class LinksyApp extends StatelessWidget {
final AuthBloc authBloc;
final AuthSessionBootstrapper sessionBootstrapper;
const LinksyApp({super.key, required this.authBloc});
const LinksyApp({
super.key,
required this.authBloc,
required this.sessionBootstrapper,
});
@override
Widget build(BuildContext context) {
return BlocProvider<AuthBloc>.value(
value: authBloc,
child: MaterialApp.router(
title: 'Linksy',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
routerConfig: createAppRouter(authBloc),
child: BlocListener<AuthBloc, AuthState>(
listenWhen: (previous, current) => previous != current,
listener: (context, state) {
unawaited(sessionBootstrapper.syncForAuthState(state));
},
child: MaterialApp.router(
title: 'Linksy',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
routerConfig: createAppRouter(authBloc),
),
),
);
}
+104 -38
View File
@@ -1,74 +1,140 @@
import 'package:flutter/material.dart';
import '../../core/theme/design_tokens.dart';
class AppButton extends StatelessWidget {
const AppButton({
super.key,
required this.text,
this.onPressed,
this.isOutlined = false,
this.height = 52,
this.isLoading = false,
});
final String text;
final VoidCallback? onPressed;
final bool isOutlined;
final double height;
final bool isLoading;
const AppButton({
super.key,
required this.text,
this.onPressed,
this.isOutlined = false,
this.height = 44,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
final isDisabled = onPressed == null || isLoading;
if (isOutlined) {
return SizedBox(
height: height,
child: OutlinedButton(
onPressed: isLoading ? null : onPressed,
style: OutlinedButton.styleFrom(
backgroundColor: AppColors.background,
foregroundColor: AppColors.slate500,
side: const BorderSide(color: AppColors.input),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
backgroundColor: isDisabled
? AppColors.authSecondaryButtonBackground.withValues(
alpha: 0.55,
)
: AppColors.authSecondaryButtonBackground,
foregroundColor: isDisabled
? AppColors.authLinkMuted
: AppColors.authSecondaryButtonText,
side: BorderSide(
color: isDisabled
? AppColors.authSecondaryButtonBorder.withValues(alpha: 0.7)
: AppColors.authSecondaryButtonBorder,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
),
child: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: AppColors.authSecondaryButtonText,
),
)
: Text(
text,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
),
);
}
return SizedBox(
height: height,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primaryForeground,
),
)
: Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
boxShadow: isDisabled
? const []
: [
BoxShadow(
color: AppColors.blue300.withValues(alpha: 0.24),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: SizedBox(
height: height,
width: double.infinity,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ButtonStyle(
elevation: const WidgetStatePropertyAll(0),
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return AppColors.authPrimaryButtonDisabled;
}
if (states.contains(WidgetState.pressed)) {
return AppColors.authPrimaryButtonPressed;
}
return AppColors.authPrimaryButton;
}),
foregroundColor: const WidgetStatePropertyAll(
AppColors.authPrimaryButtonText,
),
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) {
return AppColors.white.withValues(alpha: 0.08);
}
return null;
}),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: AppSpacing.xl),
),
),
child: isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: AppColors.authPrimaryButtonText,
),
)
: Text(
text,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: isDisabled
? AppColors.authLinkMuted
: AppColors.authPrimaryButtonText,
),
),
),
),
);
}
+37 -7
View File
@@ -7,12 +7,14 @@ class AppBanner extends StatelessWidget {
final String message;
final ToastType type;
final bool visible;
final String? title;
const AppBanner({
super.key,
required this.message,
this.type = ToastType.warning,
this.visible = true,
this.title,
});
@override
@@ -23,19 +25,47 @@ class AppBanner extends StatelessWidget {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: config.backgroundColor,
borderRadius: BorderRadius.circular(AppRadius.sm),
color: config.surfaceColor,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: config.borderColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(config.icon, size: 16, color: config.iconColor),
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: config.iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Icon(config.icon, size: 16, color: config.iconColor),
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(fontSize: 13, color: config.textColor),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title ?? config.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: config.textColor,
),
),
const SizedBox(height: 2),
Text(
message,
style: TextStyle(
fontSize: 13,
height: 1.35,
color: config.textColor,
),
),
],
),
),
],
@@ -60,7 +60,9 @@ class _FixedLengthCodeInputState extends State<FixedLengthCodeInput> {
void _onFocusChanged() {
if (_isFocused != _focusNode.hasFocus) {
_isFocused = _focusNode.hasFocus;
setState(() {
_isFocused = _focusNode.hasFocus;
});
}
}
@@ -98,56 +100,80 @@ class _FixedLengthCodeInputState extends State<FixedLengthCodeInput> {
@override
Widget build(BuildContext context) {
final chars = widget.controller.text.split('');
final slotHeight = AppSpacing.xl * 2;
final slotHeight = AppSpacing.xl * 2 + AppSpacing.sm;
final slotSpacing = AppSpacing.sm;
final isComplete = chars.length == widget.length;
return Semantics(
label: widget.semanticLabel,
child: GestureDetector(
onTap: () => _focusNode.requestFocus(),
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: slotHeight,
child: Stack(
alignment: Alignment.center,
children: [
Opacity(
opacity: 0,
child: SizedBox(
width: double.infinity,
height: slotHeight,
child: TextField(
controller: widget.controller,
focusNode: _focusNode,
keyboardType: widget.keyboardType,
inputFormatters: [
LengthLimitingTextInputFormatter(widget.length),
],
onChanged: _handleRawChanged,
autofillHints: const [AutofillHints.oneTimeCode],
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
color: AppColors.authSectionBackground,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(
color: _isFocused
? AppColors.authInputFocus
: AppColors.authSectionBorder,
),
boxShadow: _isFocused
? [
BoxShadow(
color: AppColors.blue200.withValues(alpha: 0.28),
blurRadius: 18,
offset: const Offset(0, 8),
),
]
: const [],
),
child: SizedBox(
height: slotHeight,
child: Stack(
alignment: Alignment.center,
children: [
Opacity(
opacity: 0,
child: SizedBox(
width: double.infinity,
height: slotHeight,
child: TextField(
controller: widget.controller,
focusNode: _focusNode,
keyboardType: widget.keyboardType,
inputFormatters: [
LengthLimitingTextInputFormatter(widget.length),
],
onChanged: _handleRawChanged,
autofillHints: const [AutofillHints.oneTimeCode],
),
),
),
),
IgnorePointer(
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (var index = 0; index < widget.length; index++) ...[
Expanded(
child: _buildCodeCell(
index: index,
chars: chars,
slotHeight: slotHeight,
IgnorePointer(
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (var index = 0; index < widget.length; index++) ...[
Expanded(
child: _buildCodeCell(
index: index,
chars: chars,
slotHeight: slotHeight,
isComplete: isComplete,
),
),
),
if (index != widget.length - 1)
SizedBox(width: slotSpacing),
if (index != widget.length - 1)
SizedBox(width: slotSpacing),
],
],
],
),
),
),
],
],
),
),
),
),
@@ -158,6 +184,7 @@ class _FixedLengthCodeInputState extends State<FixedLengthCodeInput> {
required int index,
required List<String> chars,
required double slotHeight,
required bool isComplete,
}) {
final hasChar = index < chars.length;
final isActive =
@@ -168,18 +195,31 @@ class _FixedLengthCodeInputState extends State<FixedLengthCodeInput> {
height: slotHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.sm),
color: hasChar ? AppColors.white : AppColors.authInputBackground,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isActive ? AppColors.primary : AppColors.slate300,
color: isActive
? AppColors.authPrimaryButton
: isComplete
? AppColors.authSecondaryButtonBorder
: AppColors.authInputBorder,
),
boxShadow: isActive
? [
BoxShadow(
color: AppColors.blue200.withValues(alpha: 0.32),
blurRadius: 14,
offset: const Offset(0, 6),
),
]
: const [],
),
child: Text(
hasChar ? chars[index] : '',
style: const TextStyle(
style: TextStyle(
fontSize: AppSpacing.xl,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
color: hasChar ? AppColors.slate900 : AppColors.authLinkMuted,
),
),
);
+23 -18
View File
@@ -20,28 +20,33 @@ class LinkButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveColor = foregroundColor ?? AppColors.slate500;
final color = enabled
? (foregroundColor ?? AppColors.authLinkText)
: AppColors.slate300;
return SizedBox(
height: 44,
child: TextButton(
onPressed: enabled ? onTap : null,
style: TextButton.styleFrom(
foregroundColor: enabled ? effectiveColor : AppColors.slate300,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
return TextButton(
onPressed: enabled ? onTap : null,
style: TextButton.styleFrom(
minimumSize: const Size(0, 40),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: color,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: Text(
text,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
textAlign: textAlign,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
child: Text(
text,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color,
),
textAlign: textAlign,
),
);
}
}
+82 -29
View File
@@ -48,21 +48,25 @@ class _ToastWidgetState extends State<_ToastWidget>
late AnimationController _controller;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
bool _dismissed = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 250),
duration: const Duration(milliseconds: 280),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
begin: const Offset(0, -0.18),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(_controller);
_fadeAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.forward();
@@ -70,8 +74,13 @@ class _ToastWidgetState extends State<_ToastWidget>
}
void _dismiss() {
if (!mounted) return;
_controller.reverse().then((_) => widget.onDismiss());
if (!mounted || _dismissed) return;
_dismissed = true;
_controller.reverse().then((_) {
if (mounted) {
widget.onDismiss();
}
});
}
@override
@@ -85,7 +94,7 @@ class _ToastWidgetState extends State<_ToastWidget>
final config = ToastTypeConfig.fromType(widget.type);
return Positioned(
top: MediaQuery.of(context).padding.top + 16,
top: MediaQuery.of(context).padding.top + 12,
left: 16,
right: 16,
child: SlideTransition(
@@ -94,30 +103,74 @@ class _ToastWidgetState extends State<_ToastWidget>
opacity: _fadeAnimation,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: config.backgroundColor,
borderRadius: BorderRadius.circular(AppRadius.md),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
child: SafeArea(
bottom: false,
child: GestureDetector(
onTap: _dismiss,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: config.surfaceColor,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: config.borderColor),
boxShadow: [
BoxShadow(
color: AppColors.slate900.withValues(alpha: 0.08),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
],
),
child: Row(
children: [
Icon(config.icon, size: 20, color: config.iconColor),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.message,
style: TextStyle(fontSize: 14, color: config.textColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: config.iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Icon(
config.icon,
size: 18,
color: config.iconColor,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
config.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: config.textColor,
),
),
const SizedBox(height: 2),
Text(
widget.message,
style: TextStyle(
fontSize: 14,
height: 1.35,
color: config.textColor,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.close_rounded,
size: 18,
color: config.textColor.withValues(alpha: 0.72),
),
],
),
],
),
),
),
),
@@ -3,41 +3,53 @@ import 'toast_type.dart';
import '../../../core/theme/design_tokens.dart';
class ToastTypeConfig {
final Color backgroundColor;
final Color surfaceColor;
final Color borderColor;
final Color iconColor;
final Color textColor;
final String label;
final IconData icon;
const ToastTypeConfig({
required this.backgroundColor,
required this.surfaceColor,
required this.borderColor,
required this.iconColor,
required this.textColor,
required this.label,
required this.icon,
});
static ToastTypeConfig fromType(ToastType type) => switch (type) {
ToastType.success => const ToastTypeConfig(
backgroundColor: Color(0xFFECFDF5),
iconColor: AppColors.success,
textColor: Color(0xFF065F46),
surfaceColor: AppColors.feedbackSuccessSurface,
borderColor: AppColors.feedbackSuccessBorder,
iconColor: AppColors.feedbackSuccessIcon,
textColor: AppColors.feedbackSuccessText,
label: '成功',
icon: Icons.check_circle_outline,
),
ToastType.warning => const ToastTypeConfig(
backgroundColor: Color(0xFFFFFBEB),
iconColor: AppColors.warning,
textColor: Color(0xFF92400E),
surfaceColor: AppColors.feedbackWarningSurface,
borderColor: AppColors.feedbackWarningBorder,
iconColor: AppColors.feedbackWarningIcon,
textColor: AppColors.feedbackWarningText,
label: '提醒',
icon: Icons.warning_amber_rounded,
),
ToastType.error => const ToastTypeConfig(
backgroundColor: Color(0xFFFEF2F2),
iconColor: AppColors.error,
textColor: Color(0xFF991B1B),
surfaceColor: AppColors.feedbackErrorSurface,
borderColor: AppColors.feedbackErrorBorder,
iconColor: AppColors.feedbackErrorIcon,
textColor: AppColors.feedbackErrorText,
label: '错误',
icon: Icons.error_outline,
),
ToastType.info => const ToastTypeConfig(
backgroundColor: Color(0xFFEFF6FF),
iconColor: Color(0xFF3B82F6),
textColor: Color(0xFF1E40AF),
surfaceColor: AppColors.feedbackInfoSurface,
borderColor: AppColors.feedbackInfoBorder,
iconColor: AppColors.feedbackInfoIcon,
textColor: AppColors.feedbackInfoText,
label: '提示',
icon: Icons.info_outline,
),
};