- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源 - 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调) - Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录 - 门禁:mkdocs build --strict 通过(零警告)
23 KiB
12 · UI 设计还原度 QA 与登录页组装稿
作者:UI Designer 日期:2026-09-04 依据:04-ui-login-design-spec.md(下称「04 规范」)、08-flutter-theme-report.md、
AI宠物_iOS_UI设计稿.html(品牌正典)、patbond-flutter工作区实现代码(未提交 diff) 性质:只读 QA + 组装级设计稿;需修正项由后续工单执行,本轮不改代码
1. QA 总结论
实现质量高,还原度好于预期。逐项对照后:
- 需修正:2 项(1 项视觉回归 + 1 项主题遗漏),另有 1 项既有设计债列入规范修订。
- 可接受偏离:3 项(含已声明的
minimumSize,裁决为采纳并正式写入规范)。 - 最严重项:首页促销卡换用
brandGradient后,白色文字对比度从旧靛蓝渐变的 ≥4.5:1 跌至 1.75–2.75:1,是本次换色新引入的可读性回归(见 §3 FIX-1)。 - 色值零误差:
app_theme.dart全部 token 与 04 规范 §1.1 及 HTML 正典逐字节一致(用正典 HTML 提取的 19 个 hex 值交叉验证)。 - 5 个登录组件的构造参数足以照 §5 组装稿直接拼装登录/注册/Splash,无需先改组件(一处失焦校验用
Focus包裹解决,见 §5.4)。
2. 设计 QA 偏差清单
2.1 主题 token(lib/core/theme/app_theme.dart)
| # | 检查项 | 规范值 | 实现值 | 判定 |
|---|---|---|---|---|
| T1 | primary |
#FF6F4C |
0xFFFF6F4C,注释明确"不用于承载文字的实心填充" |
符合 |
| T2 | primaryStrong / primaryDark |
#D6431A / #7A2E12 |
一致 | 符合 |
| T3 | accent / accentDark |
#FFB648 / #7A4B0A |
一致 | 符合 |
| T4 | canvas / surface / surfaceTint |
#FFF7ED / #FFFFFF / #FFE8D6 |
一致;scaffoldBackgroundColor: canvas |
符合 |
| T5 | ink / muted / border |
#3E2A1F / #9C8977 / #F0DCC8 |
一致 | 符合 |
| T6 | 成功色族 | success #7FA88A / successInk #3F5744 / successSurface #E8F0E8 |
一致(successInk/successSurface 为合理的 token 化拆分) |
符合 |
| T7 | error |
#D0342C |
一致,且 colorScheme.error/onError 显式覆盖 |
符合 |
| T8 | brandGradient |
135° #FF6F4C → #FFB648 |
topLeft → bottomRight(即 135°),色一致 |
符合 |
| T9 | 圆角 token | 12/16/18/24/999 | AppRadius.sm/md/lg/xl/pill 一致 |
符合 |
| T10 | 字号层级 | 22/w800、18/w800、15/w700、14/1.5、12/1.4 muted | textTheme 一致;输入框内文字走 M3 bodyLarge 默认 16,满足 §1.2"输入框内 16" |
符合 |
| T11 | filledButtonTheme |
高 52、圆角 16、primaryStrong 填充、白字 15/w700、disabled = onSurface 12%/38% |
全部落实;disabled 用 ink.withAlpha(31/97)(=12.2%/38%,以 ink 代 onSurface 更贴暖色系) |
符合 |
| T12 | minimumSize |
规范建议 Size.fromHeight(52) |
Size(64, 52)(已声明偏离) |
可接受偏离,采纳为规范修订值(裁决见 §2.4) |
| T13 | errorBorder / focusedErrorBorder / errorStyle |
error 1.5px / error 1.5px / 12px error 色 | 全部一致 | 符合 |
| T14 | helperStyle |
§3.1 注册页 helperText"12 muted" | 未定义,将回落到 M3 默认(bodySmall + 种子生成的 onSurfaceVariant,非 muted #9C8977) |
需修正(FIX-2,一行改动) |
| T15 | ColorScheme.fromSeed 派生组件色 |
规范未逐一指定 | Chip/SegmentedButton 等用种子生成暖调色板 | 可接受偏离(登录纵切不涉及;哪个组件刺眼就补哪个 componentTheme,不整体重调) |
| T16 | 品牌字体 Baloo 2 / 圆润中文标题体 | 规范允许后置 | 未引入,BrandMark 用系统字体 32/w700 |
可接受偏离(04 规范 §1.2 原文允许) |
2.2 五个组件(lib/core/widgets/)
| # | 组件 · 检查项 | 判定 | 说明 |
|---|---|---|---|
| C1 | AppTextField 错误态 |
符合 | errorText 由 InputDecoration 原生渲染(TalkBack/VoiceOver 自动关联),描边走主题 errorBorder;有测试 |
| C2 | AppTextField 禁用态 |
符合 | enabled=false 时整体 Opacity 0.6,对齐 §4.1"60% 不透明度" |
| C3 | AppTextField 密码切换 |
符合 | obscurable 默认遮蔽;切换按钮 constraints 44×44、带中文 tooltip、禁用时同步禁用;有测试锁定切换行为 |
| C4 | PrimaryButton loading |
符合 | 20×20 白圈 strokeWidth 2.5、外层 SizedBox 锁 52 高尺寸不变、onPressed 置 null 锁点击、disabledBackgroundColor: primaryStrong 保持珊瑚填充(§4.3"loading 不是置灰"),测试逐项锁定 |
| C5 | PrimaryButton 禁用 |
符合 | onPressed: null 走主题 disabled(ink 12%/38%),符合 §4.4 |
| C6 | InlineErrorBanner |
符合 | error 底 withAlpha(20)(=7.84%,即 8%×255=20.4 的正确取整)、radiusSm、padding 12、error_outline 18 + 13px error 字、全宽 |
| C7 | BrandMark |
符合 | logo 72(可参数化)+ brandGradient radiusXl 圆底 + Icons.pets 占位(§2.1 允许 v1 替代);字标 32/w700 primary;slogan 14 muted 可传 null——正好满足 Splash/登录共用与 §5.2 过渡前提。字标 primary 于 canvas 上约 2.6:1,属品牌字标(logo 豁免),与规范原文一致 |
| C8 | AuthScaffold |
符合,附组装约束 | SafeArea + 滚动 + 水平 padding 24 + Scaffold 默认键盘避让。注意:ConstrainedBox 只给 minHeight,子 Column 高度仍无上界,页面内不能用 Spacer/Expanded 做弹性空间(会布局异常),垂直居中必须用 mainAxisAlignment.center + 固定间距——§5 组装稿已按此约束编写,不算缺陷 |
| C9 | 组件缺口:AppTextField 未暴露 focusNode/validator |
可接受 | 失焦校验用外层 Focus(onFocusChange:) 包裹即可(见 §5.4),v1 不需改组件;若后续表单变多,建议加 focusNode 参数(建议级,非工单) |
2.3 需修正项汇总(供开工单)
| 编号 | 严重度 | 位置 | 问题 | 修正建议 |
|---|---|---|---|---|
| FIX-1 | 高(视觉回归) | lib/features/home/home_page.dart _PromoCard(约 619 行起) |
促销卡底从旧靛蓝深色渐变换成 brandGradient(亮橙→亮琥珀)后,卡上 17px/w800 白色标题与 12px 白色副文字对比度仅 2.75:1(primary 端)至 1.75:1(accent 端),17px 加粗未达 large-text 门槛(18.7px),全部不达 AA。这也违反 04 规范 §1.1 自己的约定"brandGradient 不承载正文文字"。换色前白字在靛蓝上是达标的,属本次迁移新引入的回归 |
促销卡渐变改为 LinearGradient(colors: [AppColors.primaryStrong, AppColors.primary])(文字在左侧 primaryStrong 端,白字 4.5:1 达标;右侧 primary 端只放"去使用"白底按钮与装饰)。备选:整卡 primaryStrong 纯色 + accent 装饰爪印。brandGradient 本身不动,故事环等纯装饰用法不受影响 |
| FIX-2 | 低(一行) | app_theme.dart inputDecorationTheme |
缺 helperStyle,注册页密码 helperText 颜色将是种子派生灰而非 muted |
补 helperStyle: TextStyle(color: AppColors.muted, fontSize: 12) |
| DEBT-1 | 低(既有设计债,不阻塞纵切) | lib/widgets/common.dart TagPill |
11px/w700 文字直接用传入色:默认 primary(2.75:1)与服务页 success sage(约 2.4:1)都不达标。08 报告第 6 条已指出 sage 一例并正确地未擅改——责任在规范本身,本报告在此修订:TagPill 应"底用 color 8%、文字用配套深变体"(primary→primaryDark、success→successInk、accent→accentDark) |
给 TagPill 增加可选 inkColor 参数(默认按上述映射),另开工单,与登录纵切解耦 |
2.4 已声明偏离的设计裁决:minimumSize: Size(64, 52)
裁决:采纳,并以 Size(64, 52) 作为规范修订值(04 规范 §6.1 建议的 Size.fromHeight(52) 作废)。理由:
Size.fromHeight(52)展开为Size(double.infinity, 52),会给所有FilledButton无限最小宽度,Row 内既有按钮(首页"去使用"、服务页"立即预约"、对话框"确认恢复")必然布局异常——Frontend 的判断正确,64恰是 Material 默认最小宽,语义上等于"只约束高度"。- 登录页全宽诉求本就该由
PrimaryButton的width: double.infinity承担(组件职责),而非全局主题(全局职责)。实现的分工比规范原稿更对。 - 副作用(既有小按钮 40→52 高)方向正确:52 高触控目标优于 40(§6.4 要求 ≥44)。若后续对话框内按钮显厚重,方案是补一个 compact 变体或对话框内改
TextButton,不回退全局 52。
3. 五个 Tab 换色抽查
抽查方式:全量 grep 硬编码色值 + 逐页 diff 复核 + 未列入 diff 的三个文件(services / post_detail / main_shell)的 token 引用核对。
残留硬编码:仅 2 处,均为已声明保留的功能色——home_page.dart:536 雨 #0284C7、:538 雪 #0891B2。旧靛蓝/slate/indigo 系(#4F46E5、#EEF2FF、#475569、#E2E8F0、Colors.indigo* 等)全量清零。
逐页视觉协调性结论:
| 页面 | 结论 |
|---|---|
| home | 问候卡 primaryDark 12px/w600 于 surfaceTint→canvas 渐变上约 7:1,达标;装饰爪印 primary.withAlpha(34) 语义对;天气"多云→muted、阴天→ink"暖灰化协调(阴天用深棕做图标色略重,可接受);促销卡见 FIX-1;促销卡反白按钮前景改 primaryStrong 是正确的 AA 修正 |
| create | 图片压暗 ink.withAlpha(204) + scrim 上 Colors.white70 副文字,协调达标 |
| pets | 健康提醒卡 successSurface 底 + successInk 文字 + success.withAlpha(140) 描边,是成功色族的标准用法;进度环轨道 border 对 |
| profile | 深色头卡背景随 ink 自动变暖深棕,卡上白字 / accent amber 徽语 / Colors.white70 统计 label / Colors.white24 分隔线,全部协调达标(amber #FFB648 于 #3E2A1F 上约 7:1)——这是"深色底"抽查重点,无深字深底问题 |
| services | ⭐ 评分徽章白底 + ink 字达标;"认证服务" TagPill(success) 归 DEBT-1;Colors.white.withAlpha(235) 徽章底为合理 scrim |
| main_shell / post_detail | 仅引用 token 字段,随主题自动切换;导航栏 white.withAlpha(245) 与 surface 一致;点赞/收藏 primary 图标为装饰性着色,达标豁免 |
4. 组装总则(三页共用)
- 垂直弹性:
AuthScaffold内禁用Spacer(§2.2 C8)。登录页居中 =Column(mainAxisAlignment: MainAxisAlignment.center)+ 首尾SizedBox;注册页顶部对齐 = 默认MainAxisAlignment.start。 - 提交中整表单锁定:
_submitting == true时所有AppTextField.enabled = false、切换链接onPressed = null、PrimaryButton.isLoading = true。 - 错误三层模型(对齐 04 规范 §4.2,全 app 后续网络页面沿用):
| 层 | 触发 | 呈现 | 组件 |
|---|---|---|---|
| 字段级 | 本地校验失败;服务端 409/422 可归属字段的冲突(用户名已存在 / 手机号已注册) | 对应 AppTextField.errorText,该字段 onChanged 即清除 |
AppTextField |
| 表单级 | 401"用户名或密码错误"、429"尝试次数过多,请稍后再试"等无法归属字段的业务错误 | 主按钮上方横幅,出现时 SemanticsService.announce(message, TextDirection.ltr) 播报;任一字段 onChanged 即清除 |
InlineErrorBanner |
| 瞬态 | 超时、断网 | floating SnackBar"网络异常,请检查网络后重试"+ action"重试"(重放 _submit) |
ScaffoldMessenger |
- 任何服务端异常文本/错误码不直接透出,一律映射为上表文案。
- 提交成功后调
TextInput.finishAutofillContext()(触发系统保存密码),随认证状态机 300ms fade 进MainShellPage。
5. 登录页组装稿(lib/features/auth/login_page.dart)
页面状态:_accountCtrl、_passwordCtrl、_accountError、_passwordError、_formError(String?)、_submitting(bool)。
AuthScaffold( // 无 appBar
child: AutofillGroup(
child: Column(
mainAxisAlignment: MainAxisAlignment.center, // 垂直居中,禁 Spacer
children: [
const SizedBox(height: 48), // §2.1 顶部最小留白
const BrandMark(), // 默认 size 72 + 默认 slogan
const SizedBox(height: 48),
Focus( // 失焦校验,见 §5.4
onFocusChange: (has) { if (!has) _validateAccountOnBlur(); },
child: AppTextField(
label: '用户名 / 手机号',
controller: _accountCtrl,
prefixIcon: Icons.person_outline_rounded,
errorText: _accountError,
enabled: !_submitting,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
onChanged: (_) => _clearErrors(field: Field.account), // 清本字段 + 表单级
),
),
const SizedBox(height: 16),
Focus(
onFocusChange: (has) { if (!has) _validatePasswordOnBlur(); },
child: AppTextField(
label: '密码',
controller: _passwordCtrl,
prefixIcon: Icons.lock_outline_rounded,
obscurable: true,
errorText: _passwordError,
enabled: !_submitting,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onChanged: (_) => _clearErrors(field: Field.password),
onSubmitted: (_) => _submit(), // 键盘 done 直接提交
),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(label: '登录', isLoading: _submitting, onPressed: _submit),
const SizedBox(height: 16),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
const Text('还没有账号?',
style: TextStyle(color: AppColors.muted, fontSize: 14)),
TextButton( // 点击区 ≥44 高由 minimumSize 保证
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
onPressed: _submitting ? null : _goRegister, // push RegisterPage
child: const Text('立即注册'),
),
]),
const SizedBox(height: 24), // 底部留白
// 协议行:v1 协议页未就绪,整行不渲染(§2.2)
// 预留区(短信验证码/第三方登录):不渲染任何占位(§2.4 / DoD)
],
),
),
)
校验与提交:两字段仅做非空校验(去首尾空格),失焦(曾聚焦过才触发)+ 提交时各校验一次;不做格式强校验。_submit():先总校验,有字段错即 return;置 _submitting,调 POST /auth/login;结果映射按 §4 表(登录页无 409 场景,401/429 → 横幅)。
6. 注册页组装稿(lib/features/auth/register_page.dart)
字段按 ADR-004:仅用户名 + 手机号 + 密码 + 确认密码;短信验证码行、第三方登录预留区一律不渲染。
AuthScaffold(
appBar: AppBar( // 透明返回栏(§3.1)
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: AppColors.ink, // 返回箭头用 ink
),
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // 顶部左对齐
children: [
const SizedBox(height: 8),
Text('创建账号', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
Text('加入 Patbond,记录毛孩子的每一天',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 32),
// 四个字段统一模式:Focus 包裹做失焦校验,间距 16
Focus(onFocusChange: (has) { if (!has) _validateUsername(); },
child: AppTextField(
label: '用户名',
controller: _usernameCtrl,
prefixIcon: Icons.person_outline_rounded,
errorText: _usernameError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.newUsername],
onChanged: (_) => _clearError(Field.username),
)),
const SizedBox(height: 16),
Focus(onFocusChange: (has) { if (!has) _validatePhone(); },
child: AppTextField(
label: '手机号',
controller: _phoneCtrl,
prefixIcon: Icons.phone_iphone_rounded,
errorText: _phoneError,
enabled: !_submitting,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.telephoneNumber],
onChanged: (_) => _clearError(Field.phone),
)),
const SizedBox(height: 16),
Focus(onFocusChange: (has) { if (!has) _validatePassword(); },
child: AppTextField(
label: '密码',
controller: _passwordCtrl,
prefixIcon: Icons.lock_outline_rounded,
obscurable: true,
errorText: _passwordError,
helperText: '密码 8–32 位,需包含字母和数字', // 出错时被 errorText 替换
enabled: !_submitting,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.newPassword],
onChanged: (_) => _clearError(Field.password),
)),
const SizedBox(height: 16),
Focus(onFocusChange: (has) { if (!has) _validateConfirm(); },
child: AppTextField(
label: '确认密码',
controller: _confirmCtrl,
prefixIcon: Icons.lock_outline_rounded,
obscurable: true,
errorText: _confirmError,
enabled: !_submitting,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.newPassword],
onChanged: (_) => _clearError(Field.confirm),
onSubmitted: (_) => _submit(),
)),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 32),
PrimaryButton(label: '注册', isLoading: _submitting, onPressed: _submit),
const SizedBox(height: 16),
Center(child: Row(mainAxisSize: MainAxisSize.min, children: [
const Text('已有账号?',
style: TextStyle(color: AppColors.muted, fontSize: 14)),
TextButton(/* 同登录页样式 */,
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
child: const Text('直接登录')),
])),
const SizedBox(height: 24),
],
),
),
)
校验规则(失焦即校验、提交再总校验,文案照 04 规范 §3.2):用户名非空 + 3–20 位字母开头字母/数字/下划线;手机号 ^1\d{10}$;密码 8–32 位含字母和数字;确认密码与密码一致(密码字段变更时若确认已有值也重校验一致性)。
409 映射(字段级):用户名冲突 → _usernameError = '该用户名已被使用';手机号已注册 → _phoneError = '该手机号已注册,可直接登录'(v1 文案自带出路,"去登录"链接可后置)。401 在注册页理论上不出现,其余不可归属错误 → 横幅;网络 → SnackBar。注册成功即建立会话,finishAutofillContext() 后直接 fade 进首页,不回登录页。
7. Splash 组装稿(lib/features/auth/splash_page.dart)
状态机:checking(默认)→ failed(refresh 网络错误/超时且本地有 token)。成功/无 token 不换态,直接 300ms fade 路由。
Scaffold(
backgroundColor: AppColors.canvas,
body: Center(
child: state == SplashState.checking
? Column(mainAxisSize: MainAxisSize.min, children: [
const BrandMark(), // 与登录页同一构造,保证 §5.2 过渡对位
const SizedBox(height: 32),
// 仅当等待 >300ms 才显示(进入页面时启动 300ms 定时器置 _showSpinner)
SizedBox.square(dimension: 20,
child: _showSpinner
? const CircularProgressIndicator(
strokeWidth: 2.5, color: AppColors.primary)
: null), // 占位保高度,避免出现时跳动
])
: Column(mainAxisSize: MainAxisSize.min, children: [ // 错误态(§5.3)
const BrandMark(),
const SizedBox(height: 24),
const Text('网络连接失败,无法恢复登录',
style: TextStyle(color: AppColors.ink, fontSize: 14)),
const SizedBox(height: 16),
OutlinedButton( // 次级按钮,高 44
style: OutlinedButton.styleFrom(
minimumSize: const Size(120, 44),
foregroundColor: AppColors.primaryStrong,
side: const BorderSide(color: AppColors.border),
),
onPressed: _retryRefresh,
child: const Text('重试')),
const SizedBox(height: 8),
TextButton( // 逃生通道:清除凭证进登录页
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
minimumSize: const Size(44, 44)),
onPressed: _clearCredentialsAndGoLogin,
child: const Text('改用账号登录')),
]),
),
)
流程约束(照 04 规范 §5.1,实现方注意):Splash 最短停留 500ms;refresh 客户端超时 5s;网络失败不得清除本地 refresh token(只有服务端明确 401 才清);所有页面切换用 PageRouteBuilder + FadeTransition 300ms。
8. 遗留给实现方的备忘
- FIX-1、FIX-2 见 §2.3,建议与登录页开发同一工单批执行(FIX-2 直接影响注册页 helperText 观感)。
Focus(onFocusChange:)包裹方案依赖祖先 Focus 节点聚合子孙焦点状态,Flutter 语义保证成立;若嫌样板多,可给AppTextField加focusNode参数(建议级)。- 三页均未用
Form/validator——AppTextField是 errorText 受控模式,校验状态放页面 state,这与组件现状一致,勿混用两套校验。 - DEBT-1(TagPill)另开工单,与登录纵切解耦。
UI Designer · 2026-09-04