Files
lixi 1891d9b7b4
CI / docs-build (push) Successful in 1m3s
docs: M2 开工前分析 10 份报告入档 + ADR-009~015 拍板决策
- 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>
2026-09-07 13:54:35 +08:00

211 lines
18 KiB
Markdown
Raw Permalink 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.
# 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