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
@@ -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