feat: 迁移珊瑚橙主题体系并新增认证基础组件(ADR-005)
- app_theme.dart 重写为语义 token(AppColors/AppRadius),落地 primaryStrong/error 等 AA 对比度色 - 五个页面与公共组件的旧靛蓝硬编码色值全部替换为 token,仅换色不动布局 - 新增 BrandMark/AppTextField/PrimaryButton/InlineErrorBanner/AuthScaffold 及 6 个 widget 测试 - 门禁:dart format(0 changed)/ flutter analyze(0 issues)/ flutter test(7 passed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+107
-19
@@ -1,21 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 语义色 token(ADR-005:以 AI宠物_iOS_UI设计稿.html 珊瑚橙暖色系为品牌正典,
|
||||
/// 详见 iteration-1-reports/04-ui-login-design-spec.md §1.1)。
|
||||
abstract final class AppColors {
|
||||
static const primary = Color(0xFF4F46E5);
|
||||
static const canvas = Color(0xFFF8FAFC);
|
||||
static const ink = Color(0xFF0F172A);
|
||||
static const muted = Color(0xFF64748B);
|
||||
static const border = Color(0xFFE2E8F0);
|
||||
static const success = Color(0xFF10B981);
|
||||
/// 品牌珊瑚橙:图标、装饰、渐变起点;不用于承载文字的实心填充(对比度不足)。
|
||||
static const primary = Color(0xFFFF6F4C);
|
||||
|
||||
/// 加深珊瑚:实心按钮填充、可点击文字链接(白字约 4.5:1,WCAG AA)。
|
||||
static const primaryStrong = Color(0xFFD6431A);
|
||||
|
||||
/// 浅色底上的强调文字、按钮反白替代。
|
||||
static const primaryDark = Color(0xFF7A2E12);
|
||||
|
||||
/// amber:渐变终点、徽章、会员/促销 CTA。
|
||||
static const accent = Color(0xFFFFB648);
|
||||
|
||||
/// amber 底上的文字。
|
||||
static const accentDark = Color(0xFF7A4B0A);
|
||||
|
||||
/// 页面背景(奶油底)。
|
||||
static const canvas = Color(0xFFFFF7ED);
|
||||
|
||||
/// 卡片、输入框背景。
|
||||
static const surface = Color(0xFFFFFFFF);
|
||||
|
||||
/// peach:图标底、占位块、选中指示。
|
||||
static const surfaceTint = Color(0xFFFFE8D6);
|
||||
|
||||
/// 主文字。
|
||||
static const ink = Color(0xFF3E2A1F);
|
||||
|
||||
/// 次级文字、占位符。
|
||||
static const muted = Color(0xFF9C8977);
|
||||
|
||||
/// 描边、分隔线。
|
||||
static const border = Color(0xFFF0DCC8);
|
||||
|
||||
/// sage:成功提示的图标/描边。
|
||||
static const success = Color(0xFF7FA88A);
|
||||
|
||||
/// 成功提示底上的文字。
|
||||
static const successInk = Color(0xFF3F5744);
|
||||
|
||||
/// 成功提示底色。
|
||||
static const successSurface = Color(0xFFE8F0E8);
|
||||
|
||||
static const warning = Color(0xFFF59E0B);
|
||||
|
||||
/// 校验错误文字、错误描边、失败提示(白底上约 5.4:1,AA 达标)。
|
||||
static const error = Color(0xFFD0342C);
|
||||
|
||||
/// 品牌渐变:hero 区、头像环、装饰性大面积(不承载正文文字)。
|
||||
static const brandGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [primary, accent],
|
||||
);
|
||||
}
|
||||
|
||||
/// 圆角 token(设计规范 §1.3)。
|
||||
abstract final class AppRadius {
|
||||
/// 小按钮、内嵌 CTA、错误横幅。
|
||||
static const sm = 12.0;
|
||||
|
||||
/// 主按钮、SnackBar、菜单组。
|
||||
static const md = 16.0;
|
||||
|
||||
/// 输入框、feed 卡。
|
||||
static const lg = 18.0;
|
||||
|
||||
/// 大卡片。
|
||||
static const xl = 24.0;
|
||||
|
||||
/// 芯片、徽章。
|
||||
static const pill = 999.0;
|
||||
}
|
||||
|
||||
ThemeData buildAppTheme() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: AppColors.primary,
|
||||
brightness: Brightness.light,
|
||||
surface: Colors.white,
|
||||
);
|
||||
surface: AppColors.surface,
|
||||
).copyWith(error: AppColors.error, onError: Colors.white);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
@@ -48,40 +114,62 @@ ThemeData buildAppTheme() {
|
||||
bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4),
|
||||
),
|
||||
cardTheme: const CardThemeData(
|
||||
color: Colors.white,
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||
side: BorderSide(color: Color(0xFFF1F5F9)),
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
|
||||
side: BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryStrong,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: AppColors.ink.withAlpha(31),
|
||||
disabledForegroundColor: AppColors.ink.withAlpha(97),
|
||||
minimumSize: const Size(64, 52),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(18)),
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
|
||||
borderSide: BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(18)),
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
|
||||
borderSide: BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(18)),
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
|
||||
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
|
||||
borderSide: BorderSide(color: AppColors.error, width: 1.5),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
|
||||
borderSide: BorderSide(color: AppColors.error, width: 1.5),
|
||||
),
|
||||
errorStyle: TextStyle(color: AppColors.error, fontSize: 12),
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
indicatorColor: const Color(0xFFE0E7FF),
|
||||
backgroundColor: AppColors.surface,
|
||||
indicatorColor: AppColors.surfaceTint,
|
||||
height: 72,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
return TextStyle(
|
||||
@@ -90,7 +178,7 @@ ThemeData buildAppTheme() {
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
color: states.contains(WidgetState.selected)
|
||||
? AppColors.primary
|
||||
? AppColors.primaryStrong
|
||||
: AppColors.muted,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
|
||||
/// 表单输入框封装(设计规范 §2.2 / §4.1)。
|
||||
///
|
||||
/// 密码模式([obscurable] 为 true)内置可见性切换按钮,点击目标 ≥44×44。
|
||||
/// 提交中禁用时整体 60% 不透明度。
|
||||
class AppTextField extends StatefulWidget {
|
||||
const AppTextField({
|
||||
required this.label,
|
||||
super.key,
|
||||
this.controller,
|
||||
this.prefixIcon,
|
||||
this.errorText,
|
||||
this.helperText,
|
||||
this.enabled = true,
|
||||
this.obscurable = false,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.autofillHints,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController? controller;
|
||||
final IconData? prefixIcon;
|
||||
final String? errorText;
|
||||
final String? helperText;
|
||||
final bool enabled;
|
||||
|
||||
/// 是否为密码模式:默认遮蔽输入,并显示可见性切换按钮。
|
||||
final bool obscurable;
|
||||
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final Iterable<String>? autofillHints;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
@override
|
||||
State<AppTextField> createState() => _AppTextFieldState();
|
||||
}
|
||||
|
||||
class _AppTextFieldState extends State<AppTextField> {
|
||||
bool obscured = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: widget.enabled ? 1 : 0.6,
|
||||
child: TextFormField(
|
||||
controller: widget.controller,
|
||||
enabled: widget.enabled,
|
||||
obscureText: widget.obscurable && obscured,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.textInputAction,
|
||||
autofillHints: widget.autofillHints,
|
||||
onChanged: widget.onChanged,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
errorText: widget.errorText,
|
||||
helperText: widget.helperText,
|
||||
prefixIcon: widget.prefixIcon == null
|
||||
? null
|
||||
: Icon(widget.prefixIcon, color: AppColors.muted),
|
||||
suffixIcon: widget.obscurable
|
||||
? IconButton(
|
||||
tooltip: obscured ? '显示密码' : '隐藏密码',
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
),
|
||||
onPressed: widget.enabled
|
||||
? () => setState(() => obscured = !obscured)
|
||||
: null,
|
||||
icon: Icon(
|
||||
obscured ? Icons.visibility_off : Icons.visibility,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 认证页统一框架:SafeArea + 滚动 + 水平 padding 24 + 键盘避让(设计规范 §6.2)。
|
||||
///
|
||||
/// 登录 / 注册 / 找回密码共用。[appBar] 供注册页传入透明返回栏;
|
||||
/// 键盘弹出时内容可滚动,防止溢出。
|
||||
class AuthScaffold extends StatelessWidget {
|
||||
const AuthScaffold({required this.child, super.key, this.appBar});
|
||||
|
||||
final Widget child;
|
||||
final PreferredSizeWidget? appBar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
|
||||
/// 品牌区:logo + 字标 + slogan(设计规范 §2.2 / §5.2)。
|
||||
///
|
||||
/// Splash 与登录页共用,保证两处渲染一致,是淡入过渡成立的前提。
|
||||
class BrandMark extends StatelessWidget {
|
||||
const BrandMark({super.key, this.size = 72, this.slogan = '和毛孩子在一起的每一天'});
|
||||
|
||||
/// logo 图形边长。
|
||||
final double size;
|
||||
|
||||
/// 传 null 时不渲染 slogan 行。
|
||||
final String? slogan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
|
||||
),
|
||||
child: Icon(Icons.pets, size: size * 0.5, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Patbond',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (slogan != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
slogan!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 14),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
|
||||
/// 表单级错误横幅(设计规范 §4.2)。
|
||||
///
|
||||
/// 用于无法归属单一字段的业务错误(如 401「用户名或密码错误」),
|
||||
/// 停留在表单上下文里供用户对照修改,不用 SnackBar。
|
||||
class InlineErrorBanner extends StatelessWidget {
|
||||
const InlineErrorBanner({required this.message, super.key});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withAlpha(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(AppRadius.sm)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 18, color: AppColors.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: AppColors.error,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
|
||||
/// 主操作按钮:全宽 × 52 高,`primaryStrong` 填充(设计规范 §2.2 / §4.3 / §4.4)。
|
||||
///
|
||||
/// [isLoading] 为 true 时文字替换为 20×20 白色转圈,按钮尺寸不变、不可再点,
|
||||
/// 填充保持 `primaryStrong`(loading 不是禁用态,不置灰)。
|
||||
class PrimaryButton extends StatelessWidget {
|
||||
const PrimaryButton({
|
||||
required this.label,
|
||||
super.key,
|
||||
this.onPressed,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
/// 传 null 进入禁用态(主题 disabled 置灰)。
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: FilledButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: isLoading
|
||||
? FilledButton.styleFrom(
|
||||
disabledBackgroundColor: AppColors.primaryStrong,
|
||||
)
|
||||
: null,
|
||||
child: isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user