docs: M3.5 体验补齐——任务拆解与第一批客户端修复报告入档
CI / docs-build (push) Failing after 1s

- 01 任务拆解:用户实测 6 项反馈的分类与处置;**数据模型审计推翻迁移预估**
  (nickname/avatar_asset_id 列 V1/V3 早已存在、purpose 白名单是配置项)→ 本批零迁移
- 02 第一批已交付:中文本地化 + 日期录入收口 + 花费卡月份(flutter 502→526)
- 6 项待拍板(首页 demo 裁剪范围、获赞端点形态、头像写权限档等)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 09:27:17 +08:00
parent 1bcfe444c6
commit 95976ae2c3
3 changed files with 434 additions and 0 deletions
@@ -0,0 +1,289 @@
# M3.5 第一批 · 客户端体验修复(Flutter)
- **单号**M3.5-01 中文本地化 / M3.5-02 日期选择器可用性 / M3.5-03 「本月花费」卡可自查
- **仓库**`patbond-flutter`,分支 `dev``main` 已受保护,本单只推 dev
- **基线**`dev` HEAD `0e87413`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月 |
**修法**
1. `pubspec.yaml``flutter_localizations`sdk 依赖)与 `intl: ^0.20.2`
`flutter_localizations` 的日期符号/数字格式底座,显式直接依赖以锁版本)。
2. 新增 `lib/app/app_localization.dart`:把 `Global{Material,Cupertino,Widgets}Localizations.delegate`
三件套、`appSupportedLocales``appLocale = Locale('zh','CN')` 收在一处常量。
**收在一处的理由**widget 测试若只 `pumpWidget(MaterialApp(home: ...))`
不挂 delegate,测到的「中文」是假的(仍是英文兜底);测试直接引用同一份常量,
生产与测试不会各配一套而漂移。
3. `lib/app/app.dart``MaterialApp` 挂上三者。
4. **单语言 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 环境
```bash
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
```
```bash
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`/`Cancel` findsNothing);手输切换按钮在位 + 切换后
「输入日期」;手输敲入日期即返回;取消返 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` 未触碰。