docs: M2 开工前分析 10 份报告入档 + ADR-009~015 拍板决策
CI / docs-build (push) Successful in 1m3s

- iteration-2 报告 01-08(PM 拆解/后端/Flutter 评估/现实核查/UI 规范/埋点规划/证据基线/Git 规划),04、06 已由正式角色复核定稿
- mkdocs 挂「第二迭代」导航,build --strict 通过
- ADR-009 新建 patbond-pet 模块、ADR-010 照片剪出 M2、ADR-011 dev 主干/master 发布、ADR-012 北极星与 H1-H4、ADR-013 废弃 health_record_action、ADR-014 DEBT-1 随 M2、ADR-015 照护人邀请后置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 13:54:35 +08:00
parent 5537f92227
commit 1891d9b7b4
10 changed files with 2007 additions and 0 deletions
@@ -0,0 +1,210 @@
# 03 · Flutter 前端技术评估(M2:宠物健康档案)
> 作者:Frontend Developer
> 日期:2026-09-07
> 依据:第一迭代收官报告(iteration-1/08、12、13 号)、ADR-005 珊瑚橙正典、`patbond-doc/docs/api/openapi.yaml`
> 性质:开工前评估,只读分析 + 验证性测试,未改动任何生产代码。
## 0. 基线验证
```text
$ flutter test # patbond-flutter @ Flutter 3.44.6 stable
00:03 +34: All tests passed! # 34 个测试全绿,与第一迭代收官记录一致
```
测试分布:auth_repository 10、token_refresher 5、login_page 4、register_page 4、analytics_service 4、app_text_field 3、primary_button 3、widget_test(导航冒烟)1。**M2 以 34 为基线**,收官时只增不减。
## 1. 现状盘点(实际读码结论)
### 1.1 路由结构
- **无命名路由、无 go_router**。根路由是 `app.dart` 里基于 `SessionManager.status``AnimatedSwitcher`(Splash ↔ 登录 ↔ 主壳,300ms fade),不走 Navigator。
- 主壳 `main_shell_page.dart``IndexedStack` + `NavigationBar` 承载 5 个 Tab(首页/创作/**档案**/服务/我的),Tab 切换是 `setState`**不产生路由事件**。
- 二级页用 `Navigator.push``MaterialPageRoute``core/navigation/fade_route.dart``fadePageRoute`),目前**都没有传 `RouteSettings.name`**。
- `MaterialApp` 目前没有挂任何 `navigatorObservers`——RouteObserver 是空白,正好是遗留项 2 的落点。
### 1.2 状态管理与数据层
- 模式统一为 **ChangeNotifier + 构造器注入**,无第三方状态库:`AppState`demo 数据 + shared_preferences 持久化)、`SessionManager`(认证状态机 + flutter_secure_storage)。页面通过 `ListenableBuilder`/`AnimatedBuilder` 订阅。
- 网络层已完备:`ApiClient.request()`(错误信封 → 类型化异常;401/40101 单飞刷新后重放一次;429 → `ApiRateLimitException`)、`AuthInterceptor``requiresAuth` extra 标记 + `X-Device-Id`)。**健康档案接口可直接复用,无需动网络层**。
- 仓储模式已确立:`AuthRepository` 抽象接口 + `ApiAuthRepository` 实现,widget 测试注入假实现。健康档案照此办理即可。
- 模型全部手写 `fromJson/toJson``lib/models/models.dart`),无 codegen。已有 `PetProfile` / `VaccineRecord` / `VaccineItem`,但它们是 **demo 数据形态**(如 `birthday` 为字符串、无服务端 id),对接后端契约时需要新建模型而非硬改。
### 1.3 档案 Tab 现状(M2 主改造对象)
`features/pets/pets_page.dart`724 行)目前完全跑在 `AppState` demo 数据上:宠物资料卡 + 疫苗进度 + 硬编码的「成长足迹」时间线(两条写死的 `_TimelineTile`)+ 硬编码健康提醒/本月花费。编辑走 `showModalBottomSheet``EditPetSheet` / `VaccineSheet`),表单校验是 **SnackBar 弹错的旧模式**,未用登录纵切确立的 errorText 受控模式。M2 的健康档案页面族基本等于**重写这一 Tab 及其下钻页**。
### 1.4 Analytics 模块现状(与 13 号规范有实质偏差,须在 M2 修正)
实际读 `lib/analytics/analytics_service.dart` 与装配代码,发现四处与 13 号埋点规范 / 收官记录不一致:
| # | 规范/记忆中的状态 | 代码实际状态 | 位置 |
| --- | --- | --- | --- |
| 1 | shared_preferences 分段队列、500 条上限、指数退避 | **纯内存队列**,攒 20 条上传一次,失败整批丢弃(M0 简化注释自认) | `analytics_service.dart:18-25` |
| 2 | `eventId` 用 UUIDv7 | 用 `Uuid().v4()` | `analytics_service.dart:50` |
| 3 | `sessionId` 冷启动/后台 30 分钟重生成 | **每个事件随机生成一个 v4**(注释标注 M0 简化) | `analytics_service.dart:55` |
| 4 | 5 个挂接点已挂 3 个 | `ApiAuthRepository` 里 login/register/logout 挂接点齐全(成功/失败共 5 处 track),**但 `app.dart` 组装时根本没传 analytics 实例**——生产构建里 `_analytics` 恒为 null,**埋点实际未接线** | `app.dart:46-50``auth_repository.dart:38,45` |
另有两处小问题顺带记录:`appVersion` / `osVersion` 是硬编码字符串(TODO 注 package_info_plus / device_info_plus);`Platform.isAndroid` 判断在 web/桌面上会抛(当前只出 mobile 包,暂不阻塞)。
**结论**:两个遗留项(sessionId 生命周期、page_viewed)落地前,必须先把 AnalyticsService 在 `app.dart` 接线,否则做了也是空转。接线本身改动极小(见 §3.1 清单第 3 条)。
### 1.5 主题与设计债
主题体系健康:语义 token`AppColors`/`AppRadius`)集中于 `app_theme.dart`,健康档案新页面直接引用 token 即可,无需扩色板;健康类语义色(`success`/`successInk`/`successSurface` sage 系)现成可用。DEBT-1TagPill 11px sage 文字对比约 2.4:1)在档案时间线的状态标签处会**高频复现**——健康档案是 TagPill 密度最高的页面族,建议 M2 内一并偿还(方案归 UI Designer 定,候选:文字换 `successInk`、或加深底色;前端改动约 1 处组件 + 全局回归)。
### 1.6 契约依赖(阻塞项)
`openapi.yaml` 当前只有 5 条 auth/me 路径,**尚无任何 pet/health 接口**。健康档案前端开发严格依赖契约冻结先行(延续第一迭代流程)。前端可先行的部分:两个遗留埋点项、页面骨架/空态/表单 UI、模型与仓储接口留假实现。
## 2. 健康档案页面族方案草案
### 2.1 页面与路由规划
沿用「档案 Tab 为入口、Navigator.push 下钻」的现有结构,不引入新路由框架(权衡见 §4-A):
| 页面 | 形态 | 路由名(供 page_viewed | 说明 |
| --- | --- | --- | --- |
| 档案首页(重构 PetsPage | Tab 页 | `pet_archive`(Tab 视图名) | 宠物资料卡 + 健康概览 + 健康记录时间线(倒序、按类型图标区分),替换现硬编码内容 |
| 健康记录列表(如首页时间线只展示近 N 条) | push | `/health/records` | 全量时间线,支持按类型筛选;列表即时间线,**不做独立列表页与时间线两套 UI** |
| 记录详情 | push | `/health/record` | 只读展示 + 编辑/删除入口 |
| 新增/编辑记录表单 | push 全屏页 | `/health/record/edit` | 类型(疫苗/驱虫/体检/就诊/体重…按契约枚举)、日期、标题/机构、备注、数值字段随类型联动 |
| 宠物资料编辑 | 保留 bottom sheet | sheet 不计路由曝光) | 沿用 `EditPetSheet` 交互形态,校验改造为 errorText 受控模式 |
要点:
- 新增/编辑用**全屏 push 页而非 bottom sheet**:健康记录字段多于宠物资料,sheet 内长表单 + 键盘 + 校验错误的可用性差;也让 page_viewed 能自然覆盖(权衡见 §4-B)。
- 所有 `Navigator.push` 从 M2 起**必须传 `RouteSettings(name: ...)`**,这是 page_viewed 的取数来源(§3.2)。
- 目录按现约定放 `lib/features/health/``health_models.dart``health_repository.dart``health_store.dart``pet_archive_page.dart``record_detail_page.dart``record_edit_page.dart`
### 2.2 状态管理与数据层(沿用现有模式,零新依赖)
```
HealthRepository(抽象接口)
Future<PetDetail> getPet();
Future<List<HealthRecord>> listRecords({RecordType? type});
Future<HealthRecord> createRecord(HealthRecordDraft draft); // Idempotency-Key: uuid.v4(沿用注册的幂等键模式)
Future<HealthRecord> updateRecord(String id, HealthRecordDraft draft);
Future<void> deleteRecord(String id);
ApiHealthRepository implements HealthRepository // ApiClient.request(..., requiresAuth: true)
HealthStore extends ChangeNotifier // 列表/宠物数据 + 加载状态机,页面 ListenableBuilder 订阅
```
- `HealthStore` 持一个显式加载状态机 `idle → loading → ready / empty / failed`,替代 `AppState.isReady` 那种单布尔(列表页需要区分空态与失败态)。
- 装配处在 `app.dart``_buildRepository()` 同层:复用同一个 `ApiClient` 实例,`MainShellPage` 构造器注入 store;测试注入 `FakeHealthRepository`(复刻 auth 测试的注入手法,`test/helpers/` 已有先例)。
- 模型手写 JSON(延续现约定,不引 codegen);字段名以冻结后的契约为准,**不复用 demo 形态的 `PetProfile`/`VaccineRecord`**demo 模型与 `AppState` 中对应字段在档案 Tab 重构完成后择机下线。
- 错误处理复用类型化异常分层,与登录纵切一致:`ApiBusinessException` 按 code 映射字段级/表单级文案;`SessionExpiredException` 由状态机自动送回登录页(无需页面处理);`ApiNetworkException` → SnackBar + 重试;`ApiRateLimitException` → 表单级横幅。
### 2.3 表单校验(复用登录纵切的 errorText 受控模式)
`record_edit_page.dart` 逐条复刻 `login_page.dart` 已验证的模式:
- 每字段一个 `String? _xxxError` state + `AppTextField(errorText: ...)`
- blur 校验:`Focus(onFocusChange: (has) { if (!has) _validateXxxOnBlur(); })`
- 输入即清错:`onChanged` 里清本字段错误与表单级横幅;
- 提交前全量校验,服务端字段级错误(如契约给出 422 字段错误)映射回对应 `errorText`,业务级错误走 `InlineErrorBanner` + `SemanticsService.sendAnnouncement`(无障碍播报,登录页已有先例);
- 提交中 `PrimaryButton(isLoading: true)` + 字段 `enabled: !_submitting`
- 非文本控件(日期、类型选择)错误提示:`AppTextField` 之外的控件没有 errorText 通道,用控件下方 12px `AppColors.error` 辅助文案行,样式对齐 `errorStyle`
同时把 `EditPetSheet` / `VaccineSheet` 的 SnackBar 弹错**改造为同一模式**,消除仓库内两套校验风格并存。
### 2.4 加载 / 空态 / 离线
| 态 | 处理 |
| --- | --- |
| 加载 | 首屏 `CircularProgressIndicator`(复用主壳 isReady 的样式);M2 不做骨架屏(页面族小,收益低) |
| 空态 | 无任何健康记录:插画位(爪印 Icon + `surfaceTint` 底)+ 引导文案 + 「记录第一条」CTA 直达新增表单 |
| 失败 | 列表加载失败:页内错误态 + 重试按钮(复刻 Splash 失败态版式);操作失败按 §2.2 错误分层 |
| 下拉刷新 | `RefreshIndicator` 包列表,成功静默、失败 SnackBar |
| 离线 | M2 推荐**只读缓存**:列表成功响应 JSON 落 shared_preferences(非敏感数据,符合 13 号规范的存储红线),冷启动/断网先渲染缓存并标注「展示的是上次同步数据」,后台刷新成功后替换;**写操作不做离线排队**(冲突处理复杂度不匹配 M2 体量),断网提交直接走网络错误分层。权衡见 §4-C,待拍板 |
## 3. 两个遗留高优先项:实现方案与改动点清单
两项都建议排在 **M2 第一波**(不依赖健康契约冻结,可与契约评审并行),且共享前置:把 AnalyticsService 在 `app.dart` 接线(§1.4 #4)。
### 3.1 sessionId 生命周期(WidgetsBindingObserver
**方案**(对齐 13 号规范 §3.1 `session_tracker.dart` 设计):
新建 `lib/analytics/session_tracker.dart`
```dart
class SessionTracker with WidgetsBindingObserver {
// 冷启动:构造时生成 sessionId = Uuid().v7()
// didChangeAppLifecycleState:
// paused/inactive → 记 _lastPausedAt(内存即可,进程死了本来就是冷启动)
// resumed → 距 _lastPausedAt 超 30 分钟则重新生成 sessionId
String get sessionId;
}
```
- 30 分钟阈值做成构造参数(默认 30min),时钟做成 `DateTime Function() now` 注入,测试免等待。
- `lastActiveAt` 落不落 shared_preferences:规范原文要求持久化(`pb.analytics.lastActiveAt`),但其唯一作用是跨进程判定,而**冷启动本来就必然换新 sessionId**,持久化无增量价值——建议**不持久化,纯内存**(偏离规范一处,需数据侧确认,待拍板 §4-D)。
**改动点清单**
| # | 文件 | 改动 |
| --- | --- | --- |
| 1 | `lib/analytics/session_tracker.dart` | 新建(约 40 行) |
| 2 | `lib/analytics/analytics_service.dart` | 构造器增加 `String Function() getSessionId`;删除 `'sessionId': const Uuid().v4()` 改为调用注入的 getter;顺手把 `eventId``v4()``v7()`uuid ^4.6.0 原生支持,对齐规范) |
| 3 | `lib/app/app.dart` | `initState` 实例化 `AnalyticsService` + `SessionTracker``WidgetsBinding.instance.addObserver(tracker)``_buildRepository()` 把 analytics 传入 `ApiAuthRepository`**修复未接线**);`dispose` removeObserver |
| 4 | `test/analytics/session_tracker_test.dart` | 新建:冷启动生成、resume<30min 不变、resume≥30min 重生成、连续 pause/resume 幂等(`TestWidgetsFlutterBinding.handleAppLifecycleStateChanged` 驱动 + 注入假时钟) |
| 5 | `test/analytics/analytics_service_test.dart` | 现有 4 测试补断言:同一 tracker 下多事件 sessionId 相同 |
### 3.2 page_viewed 路由埋点(RouteObserver
**方案**`NavigatorObserver` 派生类而非 `RouteObserver<PageRoute>` + RouteAware(后者要求每个页面 State mixin RouteAware 并注册/注销,N 个页面 N 处样板;前者集中一处、页面零侵入。权衡见 §4-E)。
新建 `lib/analytics/analytics_route_observer.dart`
```dart
class AnalyticsRouteObserver extends NavigatorObserver {
// didPush / didPop / didReplace:取 route.settings.name
// 非空且非 sheet/dialogroute is PageRoute)才 track('page_viewed', {'pageName': name, 'previousPageName': ...})
// didPop 上报的是「回退后重新曝光的前一页」
}
```
覆盖三类非 Navigator 的「页面曝光」需手动补点(这是本仓库路由结构的特殊性,纯 RouteObserver 覆盖不到):
1. **主壳 Tab 切换**IndexedStack 无路由事件):`MainShellPage.selectTab` 内 trackTab 名映射 `home / create / pet_archive / services / profile`;初始 Tab 在 `initState` 补一次。
2. **认证状态机切页**(根部 AnimatedSwitcher 无路由事件):Splash/登录/主壳的切换在 `app.dart``_homeForStatus` 分支处补点(或仅对 login 页补,待拍板颗粒度)。
3. bottom sheet 不计入 page_viewed(与 §2.1 约定一致)。
`page_viewed` 是**事件字典 v1(11 个 auth 事件)之外的新事件**,需要在 13 号字典追加条目(`eventVersion: 1`,属性:`pageName``previousPageName`、可选 `source`: `push/pop/tab/auth_switch`),字典变更须经数据侧确认——前端不擅自开报。
**改动点清单**
| # | 文件 | 改动 |
| --- | --- | --- |
| 1 | `lib/analytics/analytics_route_observer.dart` | 新建(约 50 行) |
| 2 | `lib/app/app.dart` | `MaterialApp(navigatorObservers: [analyticsRouteObserver])` |
| 3 | `lib/features/main/main_shell_page.dart` | 注入 analytics(或回调);`selectTab` + `initState` 补 Tab 曝光点 |
| 4 | 现有全部 `Navigator.push` 调用点(`main_shell_page.dart` openPost、`login_page.dart` _goRegister、`fade_route.dart` 签名加可选 settings | 补 `RouteSettings(name: ...)`;M2 新页面从第一天就带 name |
| 5 | `patbond-doc` 13 号字典 | 追加 `page_viewed` 条目(数据侧评审后) |
| 6 | `test/analytics/analytics_route_observer_test.dart` | 新建:push/pop/无名路由不报/sheet 不报;Tab 切换补点在 shell 冒烟测试中断言 |
**注意**:两项落地后事件量将从「每会话 <10 条」上升(page_viewed 是高频事件),§1.4 #1 的内存队列(失败整批丢弃)会放大数据丢失。建议把 13 号规范的 **shared_preferences 分段队列**列入 M2 第二波(不阻塞两个遗留项,但应在健康档案功能埋点铺开前就位)。
## 4. 权衡与待拍板
| # | 议题 | 选项 | 推荐 |
| --- | --- | --- | --- |
| A | 路由框架 | ① 维持 Navigator 1.0 + push;② 引入 go_router | **①**。页面族仅 3 个下钻页,无 deep link 需求;go_router 迁移波及登录纵切已验证的 AnimatedSwitcher 认证切换结构,风险收益不匹配。deep link 需求出现时(推送直达记录详情)再评估 |
| B | 新增/编辑表单形态 | ① 全屏 push 页;② bottom sheet(与 EditPetSheet 一致) | **①**。字段多 + 键盘 + errorText 校验在 sheet 内可用性差,且 sheet 不产生路由事件、埋点需再补点。代价:与宠物资料编辑(保留 sheet)形态不一,需 UI Designer 认可 |
| C | 离线策略 | ① 纯在线 + 失败重试;② 只读缓存最近列表;③ 完整离线(写排队+冲突解决) | **②**。成本约一个缓存读写封装,显著改善弱网首屏;③ 明确出 M2 范围 |
| D | sessionTracker 的 lastActiveAt 是否持久化 | ① 按规范落 prefs;② 纯内存 | **②**(理由见 §3.1)。属对 13 号规范的偏离,需数据侧点头 |
| E | page_viewed 采集机制 | ① NavigatorObserver 集中式;② RouteObserver + RouteAware 分布式 | **①**。零页面侵入、单点测试;②仅在需要「页面 resume 时长统计」时更优,当前事件不含时长 |
| F | 埋点队列升级时机 | ① M2 第二波做分段队列;② 推 M3 | **①**(理由见 §3.2 注意),且 `EventQueue` 接口规范里已设计好,实现面可控 |
| G | DEBT-1TagPill 对比度) | 修复方案归 UI Designer | 建议纳入 M2(§1.5),健康档案是 TagPill 最密页面 |
## 5. 风险与依赖小结
1. **契约冻结是关键路径**openapi.yaml 尚无 health 接口;第一波先做两个埋点遗留项 + 表单/空态骨架可完全并行。
2. **埋点未接线**(§1.4 #4)是收官记录与代码的最大出入,接线动作已并入 §3.1 清单第 3 条,成本极低但必须做。
3. 档案 Tab 重构会触碰 `AppState` demo 数据的退役边界(pet/vaccines 字段),首页问候卡、主壳头像也引用 `appState.pet`——重构时需全局 grep 引用面,避免半迁移状态。
4. 本评估未改任何生产代码;测试基线 34 全绿已复验。
---
**Frontend Developer** · 2026-09-07