Files
patbond-doc/docs/development/iterations/iteration-3.5/02-client-ux-fixes.md
T
lixi 95976ae2c3
CI / docs-build (push) Failing after 1s
docs: M3.5 体验补齐——任务拆解与第一批客户端修复报告入档
- 01 任务拆解:用户实测 6 项反馈的分类与处置;**数据模型审计推翻迁移预估**
  (nickname/avatar_asset_id 列 V1/V3 早已存在、purpose 白名单是配置项)→ 本批零迁移
- 02 第一批已交付:中文本地化 + 日期录入收口 + 花费卡月份(flutter 502→526)
- 6 项待拍板(首页 demo 裁剪范围、获赞端点形态、头像写权限档等)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 09:27:17 +08:00

290 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 未触碰。