- 01 任务拆解:用户实测 6 项反馈的分类与处置;**数据模型审计推翻迁移预估** (nickname/avatar_asset_id 列 V1/V3 早已存在、purpose 白名单是配置项)→ 本批零迁移 - 02 第一批已交付:中文本地化 + 日期录入收口 + 花费卡月份(flutter 502→526) - 6 项待拍板(首页 demo 裁剪范围、获赞端点形态、头像写权限档等) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 KiB
M3.5 第一批 · 客户端体验修复(Flutter)
- 单号:M3.5-01 中文本地化 / M3.5-02 日期选择器可用性 / M3.5-03 「本月花费」卡可自查
- 仓库:
patbond-flutter,分支dev(main已受保护,本单只推 dev) - 基线:
devHEAD0e87413,502 测试全绿 - 范围红线:纯客户端。不动
patbond-api、不动openapi.yaml、不碰契约。 用户反馈的另外 3 项(宠物头像、用户资料编辑、资料页真实数据)需契约变更, 本单不涉及,留给第二批走正式迭代流程。
1. 三项修复:根因与修法
1.1 M3.5-01 中文本地化(根因:完全没配)
根因:lib/app/app.dart 的 MaterialApp 从一迭代建起就没有
localizationsDelegates / supportedLocales / locale,pubspec.yaml 也没有
flutter_localizations。Flutter 在缺 delegate 时静默回退内置的
DefaultMaterialLocalizations(只有英文),于是业务自绘文案全中文、Material
内置组件全英文,同一个弹窗里中英混排。实测确认的英文兜底文案:
| 位置 | 英文兜底 | 挂上 zh-CN 后 |
|---|---|---|
| 日期选择器标题 | Select date |
选择日期 |
| 确认 / 取消 | OK / Cancel |
确定 / 取消 |
| 手输模式标签 | Enter Date |
输入日期 |
| 模式切换按钮 tooltip | Switch to input / Switch to calendar |
切换到输入模式 / 切换到日历模式 |
| 手输格式报错 | Invalid format. |
格式无效。 |
| 越界报错 | Out of range. |
超出范围。 |
| 手输提示格式 | mm/dd/yyyy |
yyyy/mm/dd(zh 顺序年在前) |
| 月份年份表头 | September 2026 |
2026年9月 |
修法:
pubspec.yaml加flutter_localizations(sdk 依赖)与intl: ^0.20.2(flutter_localizations的日期符号/数字格式底座,显式直接依赖以锁版本)。- 新增
lib/app/app_localization.dart:把Global{Material,Cupertino,Widgets}Localizations.delegate三件套、appSupportedLocales、appLocale = Locale('zh','CN')收在一处常量。 收在一处的理由:widget 测试若只pumpWidget(MaterialApp(home: ...))而 不挂 delegate,测到的「中文」是假的(仍是英文兜底);测试直接引用同一份常量, 生产与测试不会各配一套而漂移。 lib/app/app.dart的MaterialApp挂上三者。- 单语言 zh-CN(不列
Locale('en')):避免设备语言为英文时回退英文, 再次造出「业务中文 + 组件英文」的混排。
顺带修的配色(用户截图里日期选择器是暗红棕,脱离品牌色):
根因是 buildAppTheme() 里 ColorScheme.fromSeed(seedColor: #FF6F4C) 派生出的
M3 调和色被日期选择器直接吃掉,而项目从未定制 datePickerTheme。新增的
_datePickerTheme 只复用 05 号规范(iteration-2/05、iteration-3/05)已审计过
的色对,不新造任何色值:
| 位置 | 色对 | 对比度 | 来源 |
|---|---|---|---|
| 头部(帮助文字 + 标题 + 模式切换图标) | surfaceTint #FFE8D6 底 + primaryDark #7A2E12 |
7.98:1 | 选中 chip 同款(iteration-2/05 §3 D7) |
| 年份下拉 / 上下月箭头 | 白底 + primaryDark |
8.74:1 | 同上族 |
| 选中日 / 选中年 | primaryStrong #D6431A 实底 + 白字 |
4.49:1 | FAB / 头像徽标同款(iteration-2/05 §2) |
| 今日(未选中)文字与 1.5px 描边 | 白底 + primaryStrong |
4.49:1(非文字门槛 3:1) | 同上 |
| 未选中日 / 年 | 白底 + ink #3E2A1F |
≥12:1 | 正文主色 |
| 星期表头 | 白底 + inkSoft #6B5A4A |
6.59:1 | 承载信息的次级文字(DEBT-2) |
| 越界不可选日 | 白底 + muted #9C8977 |
3.36:1 | 禁用态,DEBT-2 允许的 muted 用途 |
| 确定 | 白底 + primaryStrong 文字 |
4.49:1 | 可点击文字链接 |
| 取消 | 白底 + inkSoft 文字 |
6.59:1 | 次级动作 |
另外 headerHeadlineStyle 取 22px(默认 32):中文 formatMediumDate 是
「9月10日周四」5~6 字,横屏侧栏头部宽度下 26px 起就折行,实测截图确认 22 一行放得下。
弹窗形状对齐 cardTheme(radius xl 24 + border 描边),elevation: 0 +
surfaceTintColor: transparent 去掉 M3 的紫调 tint 叠色。
1.2 M3.5-02 日期选择器可用性(根因:月份只能逐月切)
根因:Flutter 原生 showDatePicker 的日历模式只给了年份网格,月份必须靠
< > 逐月点。用户从 9 月要回到 4 月得点 5 次,已实际造成误录——他把当月
(2026-09)的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是聚合坏了。
次要根因是手输快路虽然原生就有(头部铅笔按钮),但在英文兜底下提示是
mm/dd/yyyy、报错是 Invalid format.,中文用户看不懂也不敢用。
修法:新增 lib/core/widgets/app_date_picker.dart 共享层,7 处调用点全部收口。
pickAppDate({context, initialDate, firstDate, lastDate}):initialEntryMode: DatePickerEntryMode.calendar(日历首屏,保留头部铅笔 切手输)。initialDate自动夹进[firstDate, lastDate],防原生越界断言(调用方常传 「当前值 ?? 今天」,而「到期日期」的firstDate就是今天,历史值可能已越界)。- 返回值统一
dateOnly()抹掉时分秒。 - 中文文案一律交给本地化,不在此硬编码(不传
helpText/confirmText/fieldHintText等),避免两处文案漂移。
AppDateFieldTrailing({firstDate, lastDate, onToday, enabled}):日期行尾部统一 形态 = 「今天」按钮 + 日历图标。今天越界时按钮自动隐藏,只留图标;enabled: false(提交中)时按钮禁用;触控目标 44×44(项目最小口径), 文字primaryStrong白底 4.49:1,带设为今天tooltip。- 各调用点原有的
firstDate/lastDate业务约束原样传入,一字未改 (健康事件lastDate: now不许未来、到期日firstDate: now不许补记过去、 疫苗allowFuture双态、生日lastDate: now)。已由 widget 测试直接断言DatePickerDialog.firstDate/lastDate,防后续改动悄悄放宽。
1.3 M3.5-03 「本月花费」卡可自查(根因:无法自证记到哪个月)
根因:卡片标签硬编码「本月花费」,而服务端 summary.monthlyExpense 本就返回
month(ISO year-month,如 2026-09,按 tz 归月)。用户看不到实际月份,
所以无法自证「我这条记录到底落在哪个月」,把正确的 ¥0 当成统计故障。
核实结论:不改后端聚合逻辑。用户记录落在 2026-04-09、当天是 2026-09-10, 「本月花费 ¥0」是正确行为。本单只让客户端把口径亮出来。
修法:
lib/features/pets/health_record_display.dart新增纯函数monthlyExpenseCardLabel(String month, {DateTime? now}):- 同年 → 「9 月花费」。
- 跨年(服务端归月年份 ≠ 设备当前年份,如设备已跨到 1 月而窗口仍是去年 12 月) → 「2026/12 花费」补年份消歧。
- 串非法 → 退回「本月花费」(不崩、不显示脏值)。
pet_detail_page.dart花费卡标签改用该函数。- 可点提示:
_SummaryCard在onTap != null时右上角补chevron_right(16px,muted)。沿用项目既有可点行/卡的表达方式(宠物列表卡pets_page.dart:221、健康提醒卡pet_detail_page.dart:692、资料页设置行profile_page.dart:125全是chevron_right),不自创。 - 同时把整卡包一层
MergeSemantics:读屏一次读全「¥128.50,9 月花费,按钮」, 而不是两段孤立文字。没有用excludeSemantics——那会连带丢掉 InkWell 的 可激活性,读屏用户就点不动了。
2. 日期选择器方案的取舍理由
2.1 入口模式:为什么是 calendar 而不是 calendarOnly / input
| 候选 | 结论 | 理由 |
|---|---|---|
DatePickerEntryMode.calendar(选用) |
✅ | 日历首屏对「今天/最近几天」(健康记录的绝对多数)一眼可点;头部铅笔按钮保留,日期已知时一行敲完。两条路都在,代价是多一次点击。 |
calendarOnly |
❌ | 恰好砍掉手输按钮。任务里提到「评估是否该放开」——评估结论是相反方向:它会把「快速录入一个已知日期」这条唯一的快路堵死。 |
input(手输首屏) |
❌ | 「记今天」这类高频场景反而更慢(要敲 8 个数字 + 认格式),且首屏不给日历会让不确定日期的用户懵。 |
inputOnly |
❌ | 无日历可翻,比现状更糟。 |
补充:手输这条路只有在 M3.5-01 之后才真正可用(此前提示 mm/dd/yyyy、
报错 Invalid format.),所以「本地化」与「日期可用性」实际是同一个修复的两半。
2.2 「今天」快捷键:为什么放在表单行而不是弹窗内
先说被否掉的方案:原生 showDatePicker 无法注入自定义动作。
builder 参数只能包裹整个 Dialog,拿不到它的内部选中态,也就没法「把日历跳到
今天并选中」;把按钮塞进 Column 里还会因为 Dialog 在无界高度下贪心布局而
溢出,并且按钮会浮在遮罩上与弹窗视觉脱节。自绘一个带月份网格的选择器成本远超
本单范围。
选定方案:「今天」放在调用方的日期行尾部(AppDateFieldTrailing),
一次实现、7 处统一:
- 更快:一键落值,连弹窗都不用开(原方案是「开弹窗 → 找今天 → 确定」3 步)。
- 绕开根因:「记今天的事」是健康记录的主场景,这条路整段避开了容易走错的 月份导航。
- 零风险:不与 Flutter 弹窗内部结构较劲,不影响 a11y 与布局。
- 一致:一个共享 widget,7 处形态完全相同(5 处原来是裸的日历图标, 2 处对话框里原来什么都没有,现在统一成「今天 + 日历图标」)。
一处判断说明:pet_form_page 的「生日(可选)」也挂了「今天」。语义上是
「今天出生的新生宠物」,合法但少见;为了 7 处形态一致仍然保留,代价可忽略。
2.3 什么没有做
- 没有实现月份网格选择器(需自绘或引三方包,超出本单「纯客户端小修」范围)。 年份网格 + 手输 + 「今天」三条路已经覆盖了实测暴露的全部痛点。
- 没有改任何
firstDate/lastDate业务约束。
3. 7 处调用点收敛情况
改造前 grep -rn showDatePicker lib/ 命中 7 处裸调用;改造后 lib/ 下
showDatePicker 只出现在 app_date_picker.dart 内部一次。
| # | 调用点 | 字段 | 业务约束(未改) | pickAppDate |
「今天」 |
|---|---|---|---|---|---|
| 1 | pets/pet_form_page.dart |
生日(可选) | [1990, 今天] |
✅ | ✅ |
| 2 | pets/weight_form_page.dart |
称重日期 | [1990, 今天] |
✅ | ✅ |
| 3 | pets/health_event_form_page.dart |
发生日期 | [1990, 今天](不许未来) |
✅ | ✅ |
| 4 | pets/care_reminder_form_page.dart |
到期日期 | [今天, 今年+5](不许补记过去) |
✅ | ✅ |
| 5 | pets/vaccination_form_page.dart |
接种/下次日期 | [1990, allowFuture ? 今年+5 : 今天] |
✅ | ✅ |
| 6 | pets/vaccination_records_page.dart |
标记完成对话框 · 接种/下次 | 同上 | ✅ | ✅(原无 trailing) |
| 7 | pets/care_reminders_page.dart |
标记完成对话框 · 完成日期 | [1990, 今天] |
✅ | ✅(原无 trailing) |
删掉的重复代码:7 份手写的 showDatePicker(...) 参数块 + 5 份手写的
trailing: const Icon(Icons.calendar_month_outlined, color: AppColors.muted)。
测试侧同步:vaccination_records_page_test / care_reminders_page_test /
vaccination_form_page_test / care_reminder_form_page_test /
health_event_form_page_test 的 MaterialApp 都挂上了 zh-CN delegate,
tester.tap(find.text('OK')) 相应改为 '确定'——让 pets 域的 widget 测试与
生产环境一致,而不是在英文兜底下测。
4. compose 桌面实测记录
4.1 环境
cd <你的工作区>/patbond-api
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package
docker compose up -d --build
docker compose ps # 六容器:postgres / minio / auth / user / pet / community 全 running
cd <你的工作区>/patbond-flutter
PATBOND_UX_LIVE=1 flutter test integration_test/client_ux_live_test.dart -d linux
新增的 integration_test/client_ux_live_test.dart 沿用 M3 既有 live 测试的形态
(环境变量门控、默认 skip、不计入常规测试套件),驱动真实 App(Linux 桌面
GTK 渲染管线 + 真实 HTTP,仅注入内存 token 存储因桌面无 keyring)。
截图方案说明:本机是 Wayland 会话,X11 的 import -window root 取不到根窗口
(实测 exit=1),改为把整棵 App 包一层 RepaintBoundary 后 toImage()
直出真实渲染像素(含 Overlay 里的弹窗),落到 build/ux-live/*.png。
4.2 逐步所见
| # | 截图 | 所见 |
|---|---|---|
| 01 | 01-pet-form.png |
建档表单;「生日(可选)」行右侧是新的「今天 + 日历图标」 |
| 02 | 02-date-picker-zh.png |
日期选择器:标题「选择日期」、头部 2026年9月(原 September 2026)、星期表头「一二三四五六日」、底部**「取消」/「确定」**;配色为 peach 头部 + 深棕字 + 珊瑚红实底选中日(不再是暗红棕);左下角铅笔按钮在位 |
| 03 | 03-date-input-zh.png |
点铅笔切手输:标签**「输入日期」**、输入框预填 2025/9/1(zh 年在前)、焦点边框珊瑚色、「取消」/「确定」中文 |
| 04 | 04-date-typed.png |
敲入 2024/03/15 → 确定 → 表单行显示 2024-03-15(未点任何月份箭头) |
| 05 | 05-today-shortcut.png |
点「今天」→ 表单行直接变 2026-09-10,弹窗未打开(DatePickerDialog findsNothing 断言通过) |
| 06 | 06-pets-list.png |
档案列表含种子宠物「实测豆豆」 |
| 07 | 07-pet-detail-expense-card.png |
详情页四张数据卡每张右上角都有 > 可点提示;花费卡显示 ¥128.50 / 「9 月花费」,「本月花费」已不存在 |
4.3 花费卡口径的真链路验证
种子数据经真实 HTTP 下到 pet 服务(POST /api/v1/pets +
POST /api/v1/pets/{id}/health-events,occurredAt = now、amountCents = 12850),
详情页 GET /pets/{id}/summary?tz=+08:00 返回 month: 2026-09 →
卡片渲染「9 月花费 ¥128.50」。这正是用户当初无法自证的那一格:记录落在当月才计入,
标签现在直接把「当月是几月」写在卡上。
实测断言全部通过(00:08 +1: All tests passed!),收尾 docker compose down 已执行。
5. 测试数变化
| 项 | 改造前 | 改造后 |
|---|---|---|
flutter test |
502 passed / 2 skipped | 526 passed / 2 skipped(+24) |
flutter analyze |
No issues | No issues |
dart format |
无 diff | 无 diff(151 文件 0 changed) |
| live 实测 | — | client_ux_live_test.dart 1 passed(门控,不计入 526) |
新增 24 个测试的分布:
test/core/widgets/app_date_picker_test.dart(14):dateOnly/today/isDateSelectable纯函数;日历模式中文文案实际渲染(并反向断言Select date/OK/CancelfindsNothing);手输切换按钮在位 + 切换后 「输入日期」;手输敲入日期即返回;取消返 null / 确认抹时分秒;initialDate越界夹取;「今天」回调今天且不开弹窗 / 越界隐藏 / 禁用态 / 44×44 触控;datePickerTheme色对(含 disabled →muted)与渲染层 选中日Ink圆底取primaryStrong。test/app/app_localization_test.dart(1):根MaterialApp实际挂上三件套 delegate +locale zh-CN,并从运行期MaterialLocalizations取回中文文案 (守住「delegate 一行」不被回删)。test/features/pets/health_record_display_test.dart(3):monthlyExpenseCardLabel同年 / 跨年 / 非法串三组。test/features/pets/health_event_form_page_test.dart(3):先手输改到 2026-04-09(复现用户那格)再点「今天」一键回今天且触发 started 埋点; 选择器中文 +firstDate/lastDate业务约束不变;提交中禁用「今天」。test/features/pets/care_reminder_form_page_test.dart(2):firstDate就是 今天的那一格——未选日期时「今天」也在位且一键清掉必填校验错; 约束仍是[今天, 今年+5]。test/features/pets/pet_detail_page_test.dart(1):四张数据卡都有chevron_right可点提示。
既有测试的口径调整(非新增):pet_detail_page_test 两处「本月花费」断言改为
实际月份,且样本 monthlyExpense.month 改用当月串,使断言不随年份漂移
(跨年格式由纯函数单测覆盖)。
6. 遗留与移交
- 未做:月份网格选择器(需自绘/引包)。若第二批有余量可评估,但当前 年份网格 + 手输 + 「今天」已覆盖实测暴露的全部痛点。
- 未做:
SegmentedButton选中态仍是fromSeed派生的粉底(实测截图 06 可见), 与品牌surfaceTint + primaryDark的既有语言不一致。这是 M2 遗留的既有债, 不在本单范围,建议并入后续主题收敛单。 - 不在本单:宠物头像、用户资料编辑、资料页真实数据——需契约变更, 走第二批正式迭代流程。
- 契约:零改动。
openapi.yaml未触碰,patbond-api未触碰。