95976ae2c3
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>
290 lines
18 KiB
Markdown
290 lines
18 KiB
Markdown
# 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` 未触碰。
|