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

18 KiB
Raw Blame History

M3.5 第一批 · 客户端体验修复(Flutter)

  • 单号M3.5-01 中文本地化 / M3.5-02 日期选择器可用性 / M3.5-03 「本月花费」卡可自查
  • 仓库patbond-flutter,分支 devmain 已受保护,本单只推 dev
  • 基线dev HEAD 0e87413502 测试全绿
  • 范围红线:纯客户端。不动 patbond-api、不动 openapi.yaml、不碰契约。 用户反馈的另外 3 项(宠物头像、用户资料编辑、资料页真实数据)需契约变更, 本单不涉及,留给第二批走正式迭代流程。

1. 三项修复:根因与修法

1.1 M3.5-01 中文本地化(根因:完全没配)

根因lib/app/app.dartMaterialApp 从一迭代建起就没有 localizationsDelegates / supportedLocales / localepubspec.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/ddzh 顺序年在前)
月份年份表头 September 2026 2026年9月

修法

  1. pubspec.yamlflutter_localizationssdk 依赖)与 intl: ^0.20.2 flutter_localizations 的日期符号/数字格式底座,显式直接依赖以锁版本)。
  2. 新增 lib/app/app_localization.dart:把 Global{Material,Cupertino,Widgets}Localizations.delegate 三件套、appSupportedLocalesappLocale = Locale('zh','CN') 收在一处常量。 收在一处的理由widget 测试若只 pumpWidget(MaterialApp(home: ...)) 而 不挂 delegate,测到的「中文」是假的(仍是英文兜底);测试直接引用同一份常量, 生产与测试不会各配一套而漂移。
  3. lib/app/app.dartMaterialApp 挂上三者。
  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 一行放得下。 弹窗形状对齐 cardThemeradius 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 本就返回 monthISO 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 花费卡标签改用该函数。
  • 可点提示:_SummaryCardonTap != null 时右上角补 chevron_right 16pxmuted)。沿用项目既有可点行/卡的表达方式(宠物列表卡 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_testMaterialApp 都挂上了 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、不计入常规测试套件),驱动真实 AppLinux 桌面 GTK 渲染管线 + 真实 HTTP,仅注入内存 token 存储因桌面无 keyring)。

截图方案说明:本机是 Wayland 会话,X11 的 import -window root 取不到根窗口 (实测 exit=1),改为把整棵 App 包一层 RepaintBoundarytoImage() 直出真实渲染像素(含 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-eventsoccurredAt = nowamountCents = 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 无 diff151 文件 0 changed
live 实测 client_ux_live_test.dart 1 passed(门控,不计入 526

新增 24 个测试的分布:

  • test/core/widgets/app_date_picker_test.dart14):dateOnly/today/ isDateSelectable 纯函数;日历模式中文文案实际渲染(并反向断言 Select date/OK/Cancel findsNothing);手输切换按钮在位 + 切换后 「输入日期」;手输敲入日期即返回;取消返 null / 确认抹时分秒; initialDate 越界夹取;「今天」回调今天且不开弹窗 / 越界隐藏 / 禁用态 / 44×44 触控;datePickerTheme 色对(含 disabled → muted)与渲染层 选中日 Ink 圆底取 primaryStrong
  • test/app/app_localization_test.dart1):根 MaterialApp 实际挂上三件套 delegate + locale zh-CN,并从运行期 MaterialLocalizations 取回中文文案 (守住「delegate 一行」不被回删)。
  • test/features/pets/health_record_display_test.dart3): monthlyExpenseCardLabel 同年 / 跨年 / 非法串三组。
  • test/features/pets/health_event_form_page_test.dart3):先手输改到 2026-04-09(复现用户那格)再点「今天」一键回今天且触发 started 埋点; 选择器中文 + firstDate/lastDate 业务约束不变;提交中禁用「今天」。
  • test/features/pets/care_reminder_form_page_test.dart2):firstDate 就是 今天的那一格——未选日期时「今天」也在位且一键清掉必填校验错; 约束仍是 [今天, 今年+5]
  • test/features/pets/pet_detail_page_test.dart1):四张数据卡都有 chevron_right 可点提示。

既有测试的口径调整(非新增):pet_detail_page_test 两处「本月花费」断言改为 实际月份,且样本 monthlyExpense.month 改用当月串,使断言不随年份漂移 (跨年格式由纯函数单测覆盖)。


6. 遗留与移交

  • 未做:月份网格选择器(需自绘/引包)。若第二批有余量可评估,但当前 年份网格 + 手输 + 「今天」已覆盖实测暴露的全部痛点。
  • 未做SegmentedButton 选中态仍是 fromSeed 派生的粉底(实测截图 06 可见), 与品牌 surfaceTint + primaryDark 的既有语言不一致。这是 M2 遗留的既有债, 不在本单范围,建议并入后续主题收敛单。
  • 不在本单:宠物头像、用户资料编辑、资料页真实数据——需契约变更, 走第二批正式迭代流程。
  • 契约:零改动。openapi.yaml 未触碰,patbond-api 未触碰。