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
+40 -7
View File
@@ -6,6 +6,7 @@ This document defines **hard constraints** for Flutter mobile development. Treat
- This file applies to all changes under `apps/**`.
- It extends root routing rules in `AGENTS.md` and workspace global runtime rules.
- It also incorporates the visual design language from `apps/rules/visual_design_language.md` as a binding constraint.
- If rules conflict, apply the stricter requirement.
- Keep Flutter-specific constraints in this file; avoid duplicating them in root `AGENTS.md`.
@@ -15,15 +16,17 @@ This document defines **hard constraints** for Flutter mobile development. Treat
- Colors: `AppColors.*`
- Spacing: `AppSpacing.*`
- Radius: `AppRadius.*`
- **MUST NOT** hardcode any visual values, including (but not limited to): colors, font sizes, spacing, padding/margins, widths/heights, radii, shadows, opacity, or “magic numbers”.
- Examples that are **NOT allowed**: `Color(0xFF...)`, `SizedBox(height: 12)`, `EdgeInsets.all(16)`, `Radius.circular(8)`.
- **MUST NOT** hardcode any visual values.
- Design tokens are the single source of truth for all visual values. Any missing visual semantics should be added to tokens, not approximated locally.
- This ensures consistency with the visual design language defined in `apps/rules/visual_design_language.md`.
## 2) Component Reuse (MUST)
## 2) Component Architecture (MUST)
- **MUST** prefer existing components and established page patterns over creating new UI components.
- **MUST** use:
- Buttons: `AppButton` from `apps/lib/shared/widgets/app_button.dart`
- **MUST NOT** introduce parallel UI systems (custom buttons, custom loading systems, custom input wrappers) unless explicitly required and approved.
- **SHOULD** extract repeated UI patterns into reusable components.
- **SHOULD** prefer existing shared components before creating new ones.
- **SHOULD** place reusable components in `apps/lib/shared/widgets/` following existing naming conventions.
- **MUST NOT** introduce parallel UI systems (e.g., custom button styles, custom loading indicators) that duplicate existing shared components.
- When creating new UI components, ensure they follow the design tokens and visual design language.
## 3) Layout Mapping & Alignment (MUST)
@@ -73,3 +76,33 @@ Agent chat functionality **MUST** follow the AG-UI protocol. **Use the `ag-ui` s
- **MUST NOT** return non-streaming responses for agent chat.
- **MUST NOT** omit required lifecycle events.
- **MUST NOT** use non-AG-UI event formats (except where the spec explicitly allows).
## 8) Visual Design Language (MUST)
All UI/UX work **MUST** follow the visual design language defined in `apps/rules/visual_design_language.md`.
- **MUST** ensure screens feel like a premium personal assistant product, not a wireframe, admin console, or document page.
- **MUST** apply the surface-based design system (background, primary, secondary, interactive surfaces).
- **MUST** follow the motion and interaction feel guidelines (soft, responsive, premium).
- **MUST** achieve visual hierarchy through spacing, surface grouping, radius, depth, density, contrast, scale, and motion—not color alone.
- **MUST** follow the screen-level decision rules:
1. What is the primary focus?
2. What is the surface hierarchy?
3. What needs strongest emphasis?
4. What should be grouped?
5. What should be lightweight/secondary?
6. Where should motion reinforce understanding?
7. How can the result feel more like a premium assistant app and less like a document page?
- **MUST NOT** create UIs that match the anti-patterns listed in the visual design language document:
- plain document page, white slab with blue buttons, spreadsheet-like admin panel
- low-fidelity wireframe, default Flutter demo app, generic template marketplace screen
- full-screen flat white blocks, arbitrary shadow usage, inconsistent card treatments
- raw container stacking without surface semantics
Before finalizing any UI, mentally verify:
- Does this feel like a product, not a page?
- Is there clear hierarchy?
- Do surfaces feel intentional?
- Does the screen feel calm and premium?
- Is the assistant identity visually present?
- Would this look plausible in a polished shipping app?
@@ -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,
),
};
+640
View File
@@ -0,0 +1,640 @@
# Visual Design Language for Flutter Mobile App
This document defines the **visual design language** for the mobile app.
It is intended to guide AI agents, designers, and developers toward a **consistent, premium, mobile-native product experience**.
This file focuses on **visual style, surface hierarchy, interaction feel, motion tone, and design intent**.
Implementation constraints such as token usage, component reuse, testing, and protocol requirements remain defined elsewhere.
---
## 0) Scope and Role (MUST)
- This file applies to all mobile UI work under `apps/**`.
- This file defines the **design intent** and **visual system**.
- This file does **not** replace implementation constraints; it complements them.
- If implementation and visual intent conflict, prefer the stricter rule while preserving as much visual intent as possible.
- The agent **MUST** treat this file as the source of truth for:
- visual tone
- aesthetic direction
- surface hierarchy
- perceived product quality
- motion feel
- assistant-product identity
---
## 1) Product Design Goal (MUST)
The app is a **personal assistant mobile app**.
Its UI must feel like a **premium consumer product**, not a wireframe, dashboard, admin console, or document page.
The overall product impression must be:
- calm
- intelligent
- trustworthy
- soft
- polished
- mobile-native
- slightly futuristic
- assistant-oriented
The UI must communicate:
- clarity without coldness
- capability without heaviness
- elegance without visual noise
- structure without rigidity
The visual result must feel closer to a refined consumer app than to a productivity back office tool.
---
## 2) Core Style Direction (MUST)
The apps visual design language is based on the following style blend:
- **soft blue brand atmosphere**
- **layered card-based interface**
- **subtle depth and hierarchy**
- **selective soft neumorphic influence**
- **light glassmorphism accents where appropriate**
- **modern iOS-inspired spacing and composition**
- **premium startup-product polish**
The UI should feel:
- soft, not blunt
- layered, not flat
- tactile, not decorative
- modern, not trendy for its own sake
- structured, not mechanical
The visual system must avoid extremes:
- not overly flat
- not heavily skeuomorphic
- not toy-like
- not enterprise-heavy
- not overly ornamental
---
## 3) Brand Mood (MUST)
The brand mood is:
- soft blue
- airy
- supportive
- composed
- focused
- warm-tech
- light but capable
The assistant should feel like:
- a calm expert
- a thoughtful companion
- a reliable digital helper
The assistant should **not** feel like:
- a chatbot demo
- a mechanical enterprise workflow engine
- a gaming interface
- a childish mascot product
- a harsh cyberpunk system
Avoid visual moods that are:
- overly playful
- overly sharp
- overly dark and oppressive
- sterile and lifeless
- loud or attention-seeking
---
## 4) Visual Hierarchy Principles (MUST)
Every screen must present a clear and intentional visual hierarchy.
The hierarchy should generally be readable as:
1. page background / spatial field
2. primary surfaces
3. grouped secondary surfaces
4. highlighted interactive elements
5. text and status accents
6. transient states and feedback
The UI must always help the user understand:
- what is primary
- what is grouped
- what is interactive
- what is informational
- what is temporary
- what is currently changing
Hierarchy must not rely on color alone.
It should also be expressed through:
- spacing
- surface grouping
- radius
- depth
- density
- contrast
- scale
- motion
---
## 5) Surface Model (MUST)
The app must be designed as a **surface-based system**, not as a collection of raw containers.
Every major screen should define at least these conceptual surface layers:
- **Background Surface**
The calm spatial field behind all content.
- **Primary Content Surface**
The main assistant response area, key card, major module, or central interaction container.
- **Secondary Grouped Surfaces**
Supporting cards, grouped actions, metadata blocks, previews, summaries, or widgets.
- **Interactive Emphasis Surface**
Elements that deserve stronger presence, such as quick actions, active cards, selected states, or focused modules.
Surfaces must feel intentional and product-grade.
A surface should never read as “just another box”.
Surfaces should feel:
- softly separated
- visually organized
- breathable
- cohesive with surrounding layers
---
## 6) Depth and Elevation Language (MUST)
The UI must use depth carefully and consistently.
Depth should be expressed through:
- layered surfaces
- subtle shadows
- gentle highlights
- tonal contrast
- grouped spacing
- controlled overlap when appropriate
Depth is used to:
- separate surfaces from background
- indicate focus
- elevate important actions
- support tactile feel
- avoid paper-flat layouts
Depth must **not** be used as random decoration.
The app must avoid:
- harsh shadow stacks
- muddy over-layering
- fake 3D gimmicks
- heavy embossed skeuomorphism
- noisy glow effects
The preferred depth quality is:
- soft
- understated
- calm
- premium
- readable
---
## 7) Shape Language (MUST)
The shape language should feel soft, modern, and coherent.
Shapes should communicate:
- friendliness
- calmness
- safety
- clarity
Preferred characteristics:
- rounded corners
- smooth modules
- softened containers
- pill-like or capsule-like actions where appropriate
- continuous visual flow across adjacent surfaces
Avoid:
- sharp aggressive geometry as default
- inconsistent corner treatments
- randomly mixing square and rounded systems
- highly ornamental silhouettes
Shape consistency is important to product polish.
If a screen mixes too many shape styles, it will feel unrefined.
---
## 8) Composition Style (MUST)
The app must use **layered modular composition** rather than flat linear stacking wherever reasonable.
Preferred composition patterns:
- grouped cards
- floating modules
- segmented content blocks
- clearly separated information zones
- visually anchored action regions
- progressive disclosure sections
The screen should feel like a composed product surface, not a long sheet of stacked rectangles.
The design should avoid:
- full-screen blank white slabs
- ungrouped content dumps
- evenly weighted sections with no focal point
- spreadsheet-like layouts
- dashboard density unless explicitly needed
Content layout should guide the eye through the screen in a deliberate way.
---
## 9) Spacing Rhythm (MUST)
Spacing must create visual rhythm, hierarchy, and calmness.
Spacing should:
- separate conceptual groups clearly
- keep related content close enough to feel connected
- create a breathable reading experience
- support scanning within a few seconds
The app should feel:
- compact enough to be useful
- spacious enough to feel premium
Avoid both:
- cramped layouts
- excessively empty layouts
Spacing rhythm should create a sense of:
- confidence
- order
- softness
- ease of use
---
## 10) Color Usage Philosophy (MUST)
The app uses a **soft blue-centered palette**, but blue must be used strategically.
Blue is a brand signal, not a paint bucket.
Use brand color to:
- anchor important actions
- signal focus
- support assistant identity
- reinforce important states
- create premium calmness
Do not use blue by flooding all surfaces equally.
Color distribution must preserve:
- hierarchy
- readability
- tonal balance
- calmness
Preferred color impression:
- light
- trustworthy
- airy
- intelligent
- non-aggressive
Avoid:
- oversaturated color blocks
- excessive accent competition
- flat monochrome sameness
- harsh enterprise-blue overuse
---
## 11) Typography Feel (MUST)
Typography should feel:
- clean
- modern
- readable
- calm
- product-grade
Text hierarchy must be immediately understandable.
The text system should communicate:
- primary focus
- supportive explanation
- metadata
- actionability
- transient status
Avoid:
- text-heavy document appearance
- dense paragraph dumps
- weak heading contrast
- decorative typography
- oversized headline drama unless intentionally needed
Typography should support assistant use cases:
- fast scanning
- digestible summaries
- calm reading
- structured conversation
- confidence in results
---
## 12) Information Density (MUST)
The product is a personal assistant, so information density must be carefully balanced.
The UI must avoid:
- toy-like under-information
- enterprise-dashboard over-information
The ideal density is:
- compact but breathable
- rich but organized
- helpful without overwhelming
Assistant outputs should be:
- quickly scannable
- clearly grouped
- progressively explorable
- structured for decision support
When complexity increases, use:
- grouping
- folding
- summarization
- layered detail reveal
Do not dump all content at the same visual weight.
---
## 13) Interaction Feel (MUST)
Interactions must feel:
- responsive
- soft
- clear
- premium
- mobile-native
The UI should feel alive, but not noisy.
Interaction feedback should reinforce:
- user action
- state transition
- focus shift
- hierarchy change
- successful completion
- temporary waiting
The product should feel smooth and intentional under touch.
Avoid interaction feel that is:
- abrupt
- dead
- jittery
- overly animated
- flashy for its own sake
---
## 14) Motion Language (MUST)
Motion is part of the product language and must be treated as meaningful.
Motion should communicate:
- causality
- continuity
- spatial relationship
- emphasis
- system responsiveness
Preferred motion patterns:
- soft press feedback
- gentle surface transition
- smooth card expand/collapse
- subtle content entrance
- assistant response reveal continuity
- calm loading transitions
- state changes that feel connected rather than replaced
Motion must not feel:
- bouncy in a toy-like way
- abrupt and mechanical
- dramatic and distracting
- overloaded with simultaneous effects
The best motion is:
- noticeable enough to feel polished
- restrained enough to remain calm
---
## 15) Assistant-Specific UI Tone (MUST)
This is not a generic CRUD app.
It is an assistant product.
Therefore, the UI must visually support:
- conversation
- guidance
- summaries
- suggestions
- action handoff
- confidence-building
- clarity of next steps
Assistant screens should feel:
- structured but conversational
- intelligent but approachable
- helpful rather than commanding
Key assistant outputs should feel like:
- curated result modules
- decision-support surfaces
- intelligent summaries
- actionable insight cards
They should not feel like:
- raw logs
- plain transcript dumps
- developer console output
- generic list rows only
---
## 16) Visual Anti-Patterns (MUST NOT)
The UI must not look like any of the following:
- a plain document page
- a white sheet with blue buttons
- a spreadsheet-like admin panel
- a low-fidelity wireframe
- a default Flutter demo app
- an over-glowing concept shot that is not implementable
- a generic template marketplace screen
- a visually noisy Dribbble-style mockup without product discipline
Specifically avoid:
- full-screen flat white blocks with little hierarchy
- arbitrary shadow usage
- inconsistent card treatments
- too many competing accent colors
- overly dense content without grouping
- equally weighted sections with no focus
- raw container stacking with no surface semantics
- excessive decorative gradients
- empty “pretty” layouts with weak usability
---
## 17) Preferred Inspirations (SHOULD)
The product should loosely evoke qualities found in:
- modern iOS app composition
- premium startup productivity apps
- calm AI-native product interfaces
- refined card-based mobile layouts
- soft glass / soft depth surface systems
Useful inspiration qualities include:
- compositional discipline
- strong spacing rhythm
- excellent hierarchy
- restrained polish
- tactile clarity
- premium consumer feel
Inspiration must be translated into this products identity, not copied literally.
---
## 18) Screen-Level Decision Rules (MUST)
When generating or refining a screen, the agent must decide in this order:
1. What is the primary focus of the screen?
2. What is the surface hierarchy?
3. What needs strongest emphasis?
4. What should be grouped together?
5. What should remain lightweight or secondary?
6. Where should motion reinforce understanding?
7. How can the result feel more like a premium assistant app and less like a document page?
When multiple valid layouts exist, prefer the one that:
- has clearer hierarchy
- feels more mobile-native
- has stronger surface semantics
- feels calmer and more polished
- better supports future micro-interactions
- better matches the assistant-product identity
---
## 19) AI Generation Guidance (MUST)
When an AI agent generates UI, it must think in terms of:
- visual hierarchy
- surface system
- product polish
- interaction states
- motion readiness
- assistant identity
- premium mobile composition
It must not think in terms of:
- raw widget stacking only
- “just make it functional”
- generic default mobile layouts
- admin or dashboard templates
- flat page sections without depth
- a document-first visual model
Before finalizing a UI, the agent should mentally verify:
- Does this feel like a product, not a page?
- Is there clear hierarchy?
- Do surfaces feel intentional?
- Does the screen feel calm and premium?
- Is the assistant identity visually present?
- Would this look plausible in a polished shipping app?
If the answer is no, the UI is not finished.
---
## 20) Relationship with Design Tokens (MUST)
This document defines **what the UI should feel like**.
Implementation files define **how those values are encoded**.
Therefore:
- the visual language must be realized through the projects design token system
- any missing visual semantics should be added to tokens or shared theme primitives
- the agent must not bypass the design system to approximate the visual intent locally
The correct workflow is:
1. understand desired visual effect
2. map it into shared tokens / shared components
3. implement consistently
4. preserve future reusability
---
## 21) Final Design Standard (MUST)
Every shipped screen should aim to satisfy all of the following:
- visually coherent
- recognizably premium
- mobile-native
- calm and intelligent
- structurally clear
- not flat
- not noisy
- clearly assistant-oriented
- implementation-realistic
- design-system-compatible
The final bar is not merely:
- “works”
- “renders”
- “uses tokens”
- “passes lint”
The final bar is:
- **feels like a polished personal assistant product**
@@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/core/notifications/local_notification_service.dart';
import 'package:social_app/core/startup/auth_session_bootstrapper.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
import 'package:social_app/features/calendar/data/services/calendar_service.dart';
class MockCalendarService extends Mock implements CalendarService {}
class MockLocalNotificationService extends Mock
implements LocalNotificationService {}
void main() {
late MockCalendarService calendarService;
late MockLocalNotificationService notificationService;
late AuthSessionBootstrapper bootstrapper;
setUp(() {
calendarService = MockCalendarService();
notificationService = MockLocalNotificationService();
bootstrapper = AuthSessionBootstrapper(
calendarService: calendarService,
notificationService: notificationService,
);
});
test('does not fetch calendar events for unauthenticated state', () async {
await bootstrapper.syncForAuthState(AuthUnauthenticated());
verifyNever(() => calendarService.getEventsForRange(any(), any()));
verifyNever(() => notificationService.rebuildUpcomingReminders(any()));
});
test('fetches upcoming events after authenticated state', () async {
when(
() => calendarService.getEventsForRange(any(), any()),
).thenAnswer((_) async => []);
when(
() => notificationService.rebuildUpcomingReminders(any()),
).thenAnswer((_) async {});
await bootstrapper.syncForAuthState(
const AuthAuthenticated(
user: AuthUser(id: 'u1', email: 'a@test.com'),
),
);
verify(() => calendarService.getEventsForRange(any(), any())).called(1);
verify(() => notificationService.rebuildUpcomingReminders(any())).called(1);
});
}