21 Commits

Author SHA1 Message Date
lixi 0e87413360 test: M3 E2E 烟囱脚本(T3-21 收官)
CI / flutter-gates (push) Successful in 3m9s
对 compose 六容器真实后端跑通社区全链路 14 场景,逐条取证 M3 四条验收标准:
① 发布后另一客户端 Feed 可见(FeedCard 字段/裁剪/AuthorSummary 逐项断言)
② 重复点赞不重复计数(3 次 PUT → likeCount 恰为 1,权威终态非 409)
③ 游标分页不丢不重(25 帖 + 双粒度全量翻页逐位比对)
④ 软删内容不再出现在公共 Feed(全量翻页 + 直读/互动面 404/40403)

另覆盖两步上传直传 MinIO 与预签名 GET 字节往返、无签名直访 403、收藏列表、
评论「仅作者可删」拍板语义、关注计数与 42204/自取关 no-op、草稿防枚举逐字节
一致、v3 社区埋点落库与字典边界、幂等键同/异 hash 与按作者隔离。

脚本与 M1/M2 版并列放仓库根目录(不进 test/,不被 flutter test 收集);
token 与预签名 URL 全程脱敏。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 15:02:48 +08:00
lixi 9892b65a19 新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
CI / flutter-gates (push) Successful in 3m9s
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00
lixi f873acf9a3 新增:帖子详情页真实数据整页替换——四态/真九宫格大图/评论区/关注双态,Feed 导航与互动全接线,demo 详情退役(T3-15/16)
CI / flutter-gates (push) Failing after 20s
- post_detail_page 整页重写:内存副本先渲染 + getPost 拉新、四态
 (loading/error+重试/ready/40403 提示后返回 Feed 并刷新);媒体全量
  ——单图原比例(高度钳制 [0.75w,1.33w])、多图真九宫格(D11 轮播
  弃用,本单裁定)+ InteractiveViewer 全屏大图(03 号拍板 E)
- 评论区:游标列表触底补页 + 底部输入条创建(幂等键在数据层)+
  仅本人评论渲染删除入口(40301/40404 服务端兜底);成败埋点对
 (durationMs 取输入会话起点、attemptSeq 会话内递增);CommentTile
  升共享组件(@ 回复前缀呈现、降级作者「宠友」占位)
- 关注双态钮(tonal +关注 / Outlined 已关注 + 取关确认)乐观翻转、
  回滚零动画 + SnackBar,user_followed/unfollowed(post_detail) 挂接;
  本人帖不渲染
- Feed 接通:整卡与评论钮导航详情(T3-14 占位提示移除)、点赞/收藏
  接共享 ToggleSync(详情与卡片同一实例跨页一致)、toggleError
  一次性 SnackBar 消费
- 主壳/装配:openPost 按 postId push;create demo 流不再导航已退役的
  demo 详情(发布页真实化留 T3-17);AppState 在 post_detail/Feed 面
  消费清零
- compose 冒烟(env 门控默认跳过):真链路发帖/点赞幂等/收藏/评论/
  删评一轮 + 断网点赞回滚(生产 ApiClient 异常链)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 13:39:10 +08:00
lixi 92524da8e2 新增:互动埋点与乐观更新视觉基建——字典 v3 互动八事件封装 + ToggleSync 成功上报与触点归因 + LikeButton 三层闪烁抑制动画(T3-16)
- community_interaction_analytics:post_liked/unliked、post_favorited/unfavorited、
  comment_create_succeeded/failed、user_followed/unfollowed 强类型封装(22 号
  白名单键集逐一对齐;textLengthBucket 分桶、httpStatus 由五位码推导、
  会话失效不上报);comment_create_started 锁死 unknown 不封装
- CommunityController:toggleLike/toggleBookmark 增 source 触点归因
 (feed / post_detail),成功响应后经注入的 interactionAnalytics 上报
 (乐观翻转与失败不报);adjustCommentCount 评论计数同源写入
 (详情副本与 Feed 卡片一并更新)
- LikeButton 升 Stateful:点按激活 240ms 弹性缩放 + 120ms 图标淡入、
  取消仅颜色渐出;回滚/对账零动画直接跳变、计数与状态成对更新、
  激活动画未播完等播完再跳(05 号 §3.5/§4);减弱动态设置降级瞬变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 13:38:50 +08:00
lixi 8aac8c52cc 新增:首页 Feed 接入真实数据——四态/尾部三态/聚合曝光埋点,社区 demo 消亡第一页(T3-14)
CI / flutter-gates (push) Successful in 2m41s
- home_page Feed segment 改接 CommunityController:骨架屏首载、空态 CTA、
  错误横幅重试、下拉刷新(失败保留旧列表 + SnackBar)、触底游标翻页
  (失败态只走显式重试,不随滚动风暴自动重打)
- PostCard 三形态(单图通栏 4:3 / 多图折叠封面 +N 角标 / 纯文字 6 行)+
  PostMediaGrid(列数规则 + ink 80% scrim 精算)+ LikeButton(error 族修订
  Colors.red,T3-14 纯展示禁用态,ToggleSync 接线留 T3-15/16)+ FeedSkeleton
  (呼吸动效尊重减弱动态设置);降级作者统一「宠友」占位
- SignedNetworkImage:预签名 URL 缓存 key 剥离 X-Amz-* 签名参数,
  RemoteImage 全仓换用(同图不同签名命中同一缓存条目)
- feed 域埋点:feed_viewed 聚合曝光(浏览段开/结算于切 Tab、切分段、
  退后台、销毁;≥50% 可见 ≥500ms 段内按帖去重,postId 不上报)+
  feed_load_failed(refresh/load_more 双路,会话失效不报)
- integration_test/feed_live_test.dart:compose 六容器 Linux 桌面真链路
  实测入口(PATBOND_FEED_LIVE=1 门控,默认跳过)
- 测试 379 → 421(+42):四态/尾部三态/翻页不丢不重/降级作者/曝光结算
  时机/缓存 key/事件属性全覆盖;analyze 0,format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 13:01:43 +08:00
lixi 1441f0148f 新增:媒体上传客户端——MediaUploader 编排状态机 + 预签名直传 + 进度组件,修正 media 端点误挂 community 服务(T3-13)
CI / flutter-gates (push) Successful in 2m30s
- MediaUploader:选图→压缩(80→60 降质阶梯,10 MiB 超限拒绝)→createUpload
  →预签名 PUT 直传(进度回调)→confirm→ready assetId 交付;并发槽位 2、
  顺序即 position、单图失败不拖垮整批;凭据过期预检与存储侧 403 各自动
  换新凭据一次;失败重试复用压缩产物、从 createUpload 全新开始
- 孤儿防护:未 confirm 的 assetId 只以管线局部变量存在,快照 assetId 与
  ready 态断言绑定,buildAttachRequests 非全员 ready 即抛 StateError
- 线路修正:media 两步上传端点由 user 服务(:8082 MediaController)提供,
  T3-12 误挂 community 客户端(:8084 无 media 路由),补 mediaApi 分端口
  直连,compose 真链路实测证实
- UploadProgressOverlay 四态进度层(05 号规范 §3.3);直传层本地
  HttpServer 三线路测试(200/403/断连);compose 冒烟测试
  (PATBOND_MEDIA_SMOKE=1 启用,默认跳过)
- 新依赖:image_picker、flutter_image_compress(03 号评估选型)
- flutter test 379 全绿(基线 347,+32)、analyze 0、format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 12:21:20 +08:00
lixi 19bd8c1810 新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
CI / flutter-gates (push) Successful in 2m26s
- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射
  (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等),
  未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用
- community_repository:13 路径 19 操作全覆盖;createPost/createComment
  必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/
  关注走 PUT/DELETE 语义幂等
- community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射
- toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫,
  点赞/收藏共用一套参数化状态机,权威终态对账收敛
- community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 +
  游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态
- app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher,
  登出同步 reset
- 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 12:01:25 +08:00
lixi 66f983d680 chore: 凭证防泄漏检查落地——check-secrets 脚本入库 + CI 兜底 step(ADR-021)
CI / flutter-gates (push) Successful in 2m21s
- 新增 scripts/check-secrets.sh 与 scripts/hooks/pre-commit(三仓同构,规则单一来源)
- ci.yml 在 checkout 后新增 Secret scan step(同一脚本 --all),既有 step 未动
- 验收:全仓 234 个已跟踪文件扫描零误报;假凭证自测 9 类命中全拦截

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 16:28:04 +08:00
lixi 4d40c38f06 新增:埋点队列三项完善——30 秒定时冲刷、失败指数退避、anonymousId 持久化(T3-19)
CI / flutter-gates (push) Successful in 2m15s
- 30 秒定时冲刷:AnalyticsService.startPeriodicFlush/stopPeriodicFlush,
  前台期间 Timer.periodic 周期冲刷;SessionTracker 新增 onEnterForeground
  回调,退后台停(并保留既有 flushNow 触发)、回前台恢复,App dispose 收尾。
- 失败退避:网络错误/5xx 后 30s→60s→120s 指数退避封顶 5 分钟,退避窗口
  只挡定时冲刷(flushNow/满 20/冷启动显式触发不受限),上传成功即重置;
  429 改按网络错误同路径保段重试(Retry-After 分支待后端限流落地)。
- anonymousId 持久化:restore 时从 shared_preferences 采用/落盘
  pb.analytics.anonymousId,首次生成后跨冷启动稳定;读取失败降级
  进程内临时 id 不崩溃。
- 测试 272 → 286(+14):fakeAsync 定时/退避 8 个、anonymousId 4 个、
  429 保段 1 个、前后台回调成对 1 个;analyze 0 问题、format 无 diff。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 16:12:19 +08:00
lixi 720865bcb9 test: M2 E2E 烟囱脚本(T2-18 收官)
CI / flutter-gates (push) Successful in 2m12s
11 场景对 compose 真实后端全链路取证:注册登录、建档(含品种)、
体重×2 分页读回、疫苗 scheduled→completed(乐观锁)、健康事件
(整数分金额)、提醒完成流转、摘要四项聚合逐项断言、第二账号四路
404/40401 防枚举核对、跨设备全量读回、v2 埋点 202 逐条 accepted、
并发 PATCH 同 version 409/40902。放根目录与 test_e2e_manual.dart
并列,不入 flutter test。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 14:42:30 +08:00
lixi ba503327f5 新增:照护提醒接入与疫苗流转收尾(T2-14 提醒半边 + T2-13 遗留)
CI / flutter-gates (push) Successful in 2m12s
- 照护提醒页:status 过滤(服务端白名单视图)、due_at ASC、逾期红标
  双通道标识,四态齐备;创建表单(四类)+ 完成(completedAt 必带、
  支持补记)/ 忽略(禁带 completedAt)流转,42202/40902 兜底重拉
- 档案页:「健康提醒」卡改真实待办数据驱动(取代 demo 硬编码文案,
  最近到期一条 + 逾期警示形态),照护提醒导航入口带待办数副行
- 疫苗列表(25 号报告遗留①②):scheduled 行「标记完成/取消登记」
  PATCH 流转;完成对话框补录厂商/批号(契约可选字段);
  42201/40902/40402 兜底
- 埋点:create 三事件 + viewed(recordType=reminder)挂通;
  edit_succeeded/failed 挂 vaccine 流转(failureReason 含 conflict);
  提醒完成/忽略按 06 §7 缺口 3 既定取舍不埋
- 测试 250 → 272 全绿(+22,较基线 +48);analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 14:01:17 +08:00
lixi e186ba3da9 新增:健康事件时间线接入真实数据(T2-14 时间线半边)
- 时间线页:occurred_at DESC cursor 分页 + 月分组组头,四态齐备;
  六类事件经 RecordTypeDot 映射(增补喂养/洗护/测量三型,色族复用
  已审计色对),类型 TagPill 双通道
- 事件录入表单:六类类型选择器、金额以元录入/整数分传输(money.dart
  换算)、UTC 时间戳约定与体重表单一致
- 事件编辑页:顶层 PATCH 差量提交(title/notes/amountCents + version),
  40902 照 T2-12 模式自动经时间线检索取新 version 重提(保留输入)
- 档案页:月度花费卡接 summary.monthlyExpense(分→元展示)、tz 透传
  设备时区固定偏移(T2-13 遗留③)、健康时间线导航入口
- 埋点:health_record 域 create 三事件 + viewed(recordType=health_event)
  挂通;新增 edit_succeeded/failed 封装(failureReason 含 conflict)
- 测试 224 → 250 全绿(+26);analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 13:49:48 +08:00
lixi c91f18a845 新增:疫苗登记/记录列表与档案页摘要卡接入(T2-13 疫苗半边)
CI / flutter-gates (push) Successful in 1m49s
- VaccinationFormPage:目录按宠物物种过滤(四态)、seriesKey 目录 code
  预填、scheduled/completed 状态-日期规则结构化 + 纯函数双重前端拦截,
  42201/40904 后端兜底横幅(均有测试)
- VaccinationRecordsPage:按系列分组、三态 TagPill(含 cancelled 留痕)、
  四态齐备;viewer 隐藏登记入口
- 档案页数据卡行接 GET /pets/{id}/summary 实时聚合:最新体重/疫苗进度/
  下一针三卡,null 语义为空态文案而非 0/0;从记录页返回即重拉摘要
- health_record 埋点装配(app→shell→pets→detail→记录页面族)
- 测试 205 → 224(+19)全绿;analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 13:20:04 +08:00
lixi 5b34fa336b 新增:体重录入与历史列表接入真实数据(T2-13 体重半边)
- WeightRecordsPage:cursor 分页(加载更多/末页收起/翻页失败保留重试)、
  loading/empty/error/retry 四态、下拉刷新;viewer 隐藏录入入口
- WeightFormPage:weightKg 契约区间 (0,500] 与两位小数前端校验 + 40000
  兜底;称重时间转 UTC 上送;错误三层分层沿用登录纵切
- health_record 域埋点强类型封装(06 §1.4:create_started/succeeded/
  failed + viewed,httpStatus 由业务码推导;viewed 按工单口径取列表曝光)
- health_record_display 纯函数(体重解析/展示、疫苗状态映射、42201
  规则前端拦截函数);PetsController 暴露 repository(22 号 §7 交接)
- 测试 177 → 205(+28)全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 13:17:17 +08:00
lixi 97a1f46e3f 重构:宠物列表/详情/编辑页接入真实数据(T2-12,四态齐备)
CI / flutter-gates (push) Successful in 1m31s
- 档案 Tab 替换为真实宠物列表(05 号规范 P1):PetsController 四态
  驱动(loading/empty/error+重试/ready),空态插画 + 建档 CTA,
  下拉刷新,虚线添加卡;页面一律走 T2-11 数据层、不直连 ApiClient
- 新增 PetDetailPage(P2 档案信息部分):内存副本首屏 + getPet 刷新,
  40401 防枚举 → 不存在态返回列表并刷新;仅 owner 显示编辑入口
  (40300 语义);体重/疫苗/时间线区块留待 T2-13/14 接摘要与记录接口
- 新增 PetFormPage(建档/编辑):品种目录接口 + 自定义品种互斥
  (目录失败回落自定义 + 重试)、性别契约必填、生日+估算、芯片号;
  失焦+提交双校验、错误三层模型沿用登录纵切
- 40902 版本冲突:明确提示 + 自动拉取最新 version 重提路径(有测试);
  40903 芯片号冲突字段级报错(有测试)
- 埋点挂接:pet_create_started(表单首次输入、每次进入一次)/
  succeeded / failed 三事件接线;建宠表单路由名 pet_form、详情页
  pet_detail 进入既有 RouteObserver 采集;档案 Tab 页名 pet_archive
  改报 pet_list(字典 v2);编辑表单不带路由名(漏斗到达段不掺编辑)
- app.dart 装配:pet 服务 ApiClient(:8083)共享 TokenRefresher;
  登出 reset 控制器防跨账号泄漏;PetsController.loadBreeds 按物种缓存
- AppState 清理:pets 页原 demo 消费方移除,vaccines 及其模型/持久化
  删除;pet demo 仅存首页问候/创作页/主壳头像(后续工单收敛)
- 头像按 ADR-010 本地占位,不做上传
- 测试 145 → 177 全绿(列表/详情/表单四态与冲突路径 widget 测试、
  控制器 loadBreeds/reset、展示纯函数);analyze 0;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 12:15:32 +08:00
lixi c0a8a56e91 新增:pet 域埋点强类型封装(06 号规划字典 v2 三事件)
- pet_create_started / succeeded / failed 三事件(13 号规范 §3.1
  强类型惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性)
- entryPoint 三枚举(post_register_guide 预留);failureReason 四枚举
  (pet_limit_reached 因产品未设上限未纳入;5xx 与断网/超时客户端
  不可区分,并入 network_error)
- errorCode 携带业务码并推导 httpStatus(code ~/ 100);attemptSeq
  记录表单会话内提交序号
- 单元测试 4 个(属性齐备、可空属性缺席语义)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 11:59:32 +08:00
lixi 3179528c57 新增:档案页共享组件三件 + TagPill 深变体映射(DEBT-1 偿还)
- PetAvatar 四尺寸档(96/64/44/32)收敛头像重复实现:3px 白描边 +
  轻投影,编辑徽标底改 primaryStrong(白图标 4.49:1 达非文字 3:1),
  M2 无上传(ADR-010),url 缺省渲染本地占位形态
- RecordTypeDot 三尺寸档 + 五类记录类型的图标/三色映射唯一出口
  (05 号规范 §2 表,供 T2-13/14 时间线复用)
- EmptyStateIllustration 空态插画区(112 圆 + 标题 + 说明 + 可选 CTA)
- TagPill 增可选 inkColor,缺省按 color 查深变体映射
  (primary→primaryDark 8.74:1 / success→successInk 7.39:1 /
  accent→accentDark 7.07:1 / error→errorDark 5.78:1 / 未命中→ink),
  既有调用零参数回归
- AppColors 新增 errorDark #B02C25、inkSoft #6B5A4A(05 号规范 D8)

flutter test 141/141 全绿;analyze 0 问题(T2-12 第一部分)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 11:57:31 +08:00
lixi 7fb9031f8d 新增:pets feature 数据层(T2-11,契约 v1.2.0 全 18 操作)
CI / flutter-gates (push) Successful in 1m12s
- pet_models.dart:pets 域 DTO 逐字段对齐冻结契约(宠物/品种/体重/
  疫苗目录/疫苗/健康事件/照护提醒/聚合摘要 + cursor 分页信封),
  枚举严格解析,请求体部分更新语义(缺席字段不出现)
- pets_repository.dart:PetsRepository 抽象 + ApiPetsRepository,
  12 路径 18 操作全覆盖;四个 POST 携带 Idempotency-Key;
  复用既有 ApiClient token 拦截与单飞刷新重放
- pet_exceptions.dart:新 8 错误码映射为类型化异常
  (40300/40401/40402/40902/40903/40904/42201/42202),
  仍可按 ApiBusinessException 基类捕获
- pets_controller.dart:宠物档案状态自 AppState 拆出
  (Controller → Repository → API Client 分层,四态就绪供 T2-12)
- money.dart:元/分换算工具(DTO 层保持整数分,UI 层 T2-14 使用)
- api_client.dart:新增 patbondPetApiBaseUrl(:8083,--dart-define
  可覆盖)与 query 参数支持;api_exception.dart 补 pets 域错误码
- 测试 64 → 126:DTO 映射、错误类型化映射、请求线路
  (路径/方法/鉴权/分页/幂等键/tz)、控制器状态机、金额换算

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 11:22:24 +08:00
lixi 33b993ca0c 新增:埋点分段持久化队列(13 号规范 §3.3,M2 第二波)
CI / flutter-gates (push) Successful in 1m6s
- AnalyticsEventStore:shared_preferences 分段存储(每段 ≤20 条、
  总上限 500 超限丢最旧整段)、冷启动恢复、损坏段/损坏索引容错、
  droppedCount 丢弃诊断计数
- AnalyticsService 接入持久化队列:上传拿到终态(202/4xx)才删段
  实现 at-least-once;冲刷按段拼批 ≤50 条循环上传(契约单批上限);
  取批即封段,冲刷在途新事件写入新开放段不丢
- app.dart 冷启动 restore() 恢复离线积压并冲刷(13 号 §3.4 触发点)
- 保留第一波语义:flushNow()、4xx 毒丸丢弃、满 20 条冲刷触发
- 新增 13 个单测(恢复/上限淘汰/损坏容错/202 清段/flushNow 协同/
  分批上传),全套 64 测试全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:04:16 +08:00
lixi 1afec6aad1 fix: 埋点上传链路三处修复 + 注册手机号 +86 前缀
CI / flutter-gates (push) Successful in 1m3s
- events 上传地址接错:AnalyticsService 误用 auth(8081),实际端点在
  user 服务(8082),新增 patbondUserApiBaseUrl 并接线
- 冲刷时机:新增 flushNow(),SessionTracker 首次离开前台触发,
  修复低活跃用户凑不满 20 条事件永不上传
- 毒丸批次:4xx 永久性拒绝不再重回队列无限重试,丢弃并打日志
- Web/桌面 Platform API 降级(kIsWeb + _platformName)
- 注册手机号 UI 固定 +86 前缀、提交拼 E.164;AppTextField 支持 prefixText

51 测试全绿、analyze 0 问题;Linux 桌面实测注册成功,
curl 实测后端 /api/v1/events 校验行为符合契约。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 16:48:36 +08:00
lixi 8ea6265046 fix: Web 平台 Platform.operatingSystemVersion 不支持降级处理
CI / flutter-gates (push) Successful in 1m5s
AnalyticsService._defaultOsVersion() 在 Web 平台调用 Platform.operatingSystemVersion
会抛 UnsupportedError,导致 flutter run -d chrome 启动崩溃。加 kIsWeb 判断,
Web 平台降级为 'web-unknown',Android/iOS 保持动态读取不变。

复现:flutter run -d chrome → 报错 analytics_service.dart:53
修复后:Web 平台正常启动;51 测试全绿、flutter analyze 0 问题。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 15:44:17 +08:00
134 changed files with 29193 additions and 1345 deletions
+4
View File
@@ -26,6 +26,10 @@ jobs:
git remote add origin "$AUTH_URL/${{ github.repository }}.git"
git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head"
git checkout -q refs/ci-head
# 凭证防泄漏兜底(ADR-021):与本地 pre-commit 同一脚本、同一规则表,
# 扫全部已跟踪文件(覆盖本次 push 变更的超集),纯 shell 零外部依赖。
- name: Secret scan
run: sh scripts/check-secrets.sh --all
- name: Install Flutter (flutter-io.cn mirror, toolcache reuse)
run: |
FLUTTER_VERSION=3.44.6
+112
View File
@@ -0,0 +1,112 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。
class _InMemoryTokenStore implements TokenStore {
final Map<String, String> _values = {};
@override
Future<String?> read(String key) async => _values[key];
@override
Future<void> write(String key, String value) async => _values[key] = value;
@override
Future<void> delete(String key) async => _values.remove(key);
}
/// T3-14 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根)并用 scratch 种子脚本发帖,再:
/// PATBOND_FEED_LIVE=1 flutter test integration_test/feed_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面渲染管线 + 真实 HTTP + MinIO 预签名图片):
/// 注册账号 → UI 登录 → Feed 首屏真数据 → 滚动触底游标翻页到底 →
/// 下拉刷新。会话存储注入内存实现(桌面环境无 keyring,不动生产配置)。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_FEED_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 20),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isNotEmpty) return;
}
fail('等待超时:$finder');
}
testWidgets('Feed 桌面真链路:登录 → 首屏 → 游标翻页到底 → 下拉刷新', (tester) async {
// ---- 注册一次性账号(随机凭据,不落持久化)----
final seed = DateTime.now().millisecondsSinceEpoch;
final username = 'feedlive$seed';
final password = 'Live1234!$seed';
final client = HttpClient();
final request = await client.postUrl(
Uri.parse('$authBase/api/v1/auth/register'),
);
request.headers.contentType = ContentType.json;
request.add(
utf8.encode(
jsonEncode({
'username': username,
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
'password': password,
}),
),
);
final response = await request.close();
expect(response.statusCode, 200, reason: '注册测试账号失败');
client.close();
// ---- 启动真实 App(仅注入内存会话存储,其余全为生产实现)----
await tester.pumpWidget(
App(sessionManager: SessionManager(store: _InMemoryTokenStore())),
);
await pumpUntil(tester, find.text('登录'));
await tester.enterText(find.byType(TextField).at(0), username);
await tester.enterText(find.byType(TextField).at(1), password);
await tester.tap(find.text('登录'));
// ---- Feed 首屏:真实 getFeed + 卡片渲染 ----
await pumpUntil(tester, find.byType(PostCard));
expect(find.textContaining('compose 实测'), findsWidgets);
// ---- 触底翻页到「没有更多了」(26 帖 / 服务端页长 20 → 两页取齐)----
final list = find.byType(ListView).first;
for (var i = 0; i < 12; i++) {
await tester.fling(list, const Offset(0, -700), 1500);
await tester.pump(const Duration(milliseconds: 400));
if (find.text('没有更多了').evaluate().isNotEmpty) break;
}
await pumpUntil(tester, find.text('没有更多了'));
// 最早一帖(#1)在第二页尾部——游标翻页取齐的直接证据。
expect(find.textContaining('#1'), findsOneWidget);
// ---- 回顶下拉刷新:整体替换后首屏仍在 ----
for (var i = 0; i < 12; i++) {
await tester.fling(list, const Offset(0, 700), 1500);
await tester.pump(const Duration(milliseconds: 200));
}
await tester.fling(list, const Offset(0, 400), 1000);
await pumpUntil(tester, find.byType(PostCard));
expect(find.textContaining('compose 实测'), findsWidgets);
}, skip: !enabled);
}
+193
View File
@@ -0,0 +1,193 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/community/media_compression.dart';
import 'package:patbond_flutter/features/community/media_picking.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。
class _InMemoryTokenStore implements TokenStore {
final Map<String, String> _values = {};
@override
Future<String?> read(String key) async => _values[key];
@override
Future<void> write(String key, String value) async => _values[key] = value;
@override
Future<void> delete(String key) async => _values.remove(key);
}
/// 1x1 真 PNG(70B):桌面实测的「选中的照片」。
final Uint8List _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF'
'AAH/q842iQAAAABJRU5ErkJggg==',
);
/// 桌面选图替身:Linux 桌面无 image_picker 平台实现,实测注入字节。
class _DesktopPicker implements MediaImagePicker {
@override
Future<List<PickedMediaImage>> pickImages({required int limit}) async => [
PickedMediaImage(bytes: _pngBytes, name: 'live.png'),
];
}
/// 桌面压缩替身:Linux 桌面无 flutter_image_compress 原生实现,原样透传
/// mime 落 image/png,在服务端白名单内)。其余环节全为生产实现。
class _PassthroughCompressor implements MediaImageCompressor {
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
}
/// T3-17 发布链路桌面真链路实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根:docker compose up -d --build),再:
/// PATBOND_PUBLISH_LIVE=1 flutter test integration_test/publish_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面渲染 + 真实 HTTP + MinIO 预签名直传):
/// 注册两个一次性账号 → A 登录 → 创作 Tab「发布动态」→ 输入正文 → 选图
/// 上传(真 createUpload / 真预签名 PUT / 真 confirm)→ 发布(建草稿 →
/// PATCH 迁移)→ 回首页 Feed 新帖置顶可见 → **另起一个全新 App 实例
/// (新会话/新控制器/新 HTTP 客户端)以 B 账号登录,B 的 Feed 同样看到
/// 该帖**(M3 验收「发布后可在另一客户端看到」取证)。
///
/// 桌面替身仅两处:选图与压缩(Linux 无这两个插件的平台实现)。
/// 注意:桌面 `platform` 值为 `linux`,不在契约枚举内 → 埋点批次被服务端
/// 400 整批拒绝(既有预期行为,见 analytics_service.dart 注释);埋点落库
/// 取证走 curl26 号报告 §5c)。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_PUBLISH_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 30),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isNotEmpty) return;
}
fail('等待超时:$finder');
}
Future<void> register(String username, String password, int seed) async {
final client = HttpClient();
final request = await client.postUrl(
Uri.parse('$authBase/api/v1/auth/register'),
);
request.headers.contentType = ContentType.json;
request.add(
utf8.encode(
jsonEncode({
'username': username,
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
'password': password,
}),
),
);
final response = await request.close();
expect(response.statusCode, 200, reason: '注册测试账号失败:$username');
client.close();
}
Future<void> login(
WidgetTester tester,
String username,
String password,
) async {
await pumpUntil(tester, find.text('登录'));
await tester.enterText(find.byType(TextField).at(0), username);
await tester.enterText(find.byType(TextField).at(1), password);
await tester.tap(find.text('登录'));
}
testWidgets('发布桌面真链路:选图上传 → 发布 → Feed 置顶 → 第二账号可见', (tester) async {
final seed = DateTime.now().millisecondsSinceEpoch;
final authorName = 'publive$seed';
final readerName = 'pubread$seed';
const password = 'Live1234!publish';
final marker = '真链路发布 $seed';
await register(authorName, password, seed);
await register(readerName, password, seed + 1);
await tester.pumpWidget(
App(
sessionManager: SessionManager(store: _InMemoryTokenStore()),
mediaUploaderFactory: (repository, analytics) => MediaUploader(
repository: repository,
picker: _DesktopPicker(),
compressor: _PassthroughCompressor(),
analytics: analytics,
),
),
);
// ---- A 登录并发布(正文 + 一张真上传的图)----
await login(tester, authorName, password);
await pumpUntil(tester, find.text('创作'));
await tester.tap(find.text('创作'));
await pumpUntil(tester, find.text('发布动态'));
await tester.tap(find.text('发布动态'));
await pumpUntil(tester, find.widgetWithText(TextButton, '存草稿'));
await tester.enterText(find.byType(TextField).first, marker);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
// 上传全程(createUpload → 预签名 PUT → confirm)走真实链路。
await pumpUntil(tester, find.byType(Image));
final publish = find.widgetWithText(FilledButton, '发布');
for (var i = 0; i < 120; i++) {
await tester.pump(const Duration(milliseconds: 250));
if (tester.widget<FilledButton>(publish).onPressed != null) break;
}
expect(
tester.widget<FilledButton>(publish).onPressed,
isNotNull,
reason: '图片未在 30s 内 ready,发布钮仍禁用',
);
await tester.tap(publish);
// ---- 回首页 Feed:新帖置顶可见 ----
await pumpUntil(tester, find.byType(PostCard));
await pumpUntil(tester, find.textContaining(marker));
final cards = tester.widgetList<PostCard>(find.byType(PostCard)).toList();
expect(cards.first.card.contentPreview, contains(marker));
expect(cards.first.card.mediaCount, 1);
// ---- 第二客户端(全新 App 实例 + 全新会话):B 登录后同样看到该帖 ----
// 换 key 强制重建整棵树:新的 SessionManager / Controller / HTTP 客户端,
// 等价于另一台客户端首次登录(不是同一实例内的账号切换)。
await tester.pumpWidget(
App(
key: const ValueKey('client-b'),
sessionManager: SessionManager(store: _InMemoryTokenStore()),
),
);
await login(tester, readerName, password);
await pumpUntil(tester, find.byType(PostCard));
await pumpUntil(tester, find.textContaining(marker));
final readerCards = tester
.widgetList<PostCard>(find.byType(PostCard))
.toList();
expect(readerCards.first.card.contentPreview, contains(marker));
}, skip: !enabled);
}
+229
View File
@@ -0,0 +1,229 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 13 号规范 §3.3 分段持久化事件队列(shared_preferences 版)。
///
/// 事件按段(每段最多 [segmentCapacity] 条)存储,避免单 key 整队列
/// 重写的 O(n) 放大;总量超过 [maxEvents] 时丢最旧的整段。内存中的
/// 段列表是唯一事实来源,shared_preferences 是尽力而为的镜像——
/// 持久化不可用(如未初始化插件的测试环境)时降级为纯内存队列,
/// 任何存取失败都不向调用方抛出。
///
/// at-least-once 语义:段只在上传拿到终态(202 受理 / 4xx 永久拒绝)
/// 后由调用方删除;应用在响应前被杀,事件仍在本地,冷启动 [restore]
/// 恢复后重发,服务端靠 eventId 幂等去重。
class AnalyticsEventStore {
AnalyticsEventStore({this.maxEvents = 500, this.segmentCapacity = 20});
/// 段 ID 有序列表(旧 → 新),JSON 数组。
static const segIndexKey = 'pb.analytics.segIndex';
/// 单段序列化事件 JSON 数组,key 为前缀 + 段 ID。
static const segKeyPrefix = 'pb.analytics.seg.';
/// 本地累计丢弃计数(溢出淘汰 + 4xx 丢批 + 损坏段),诊断用。
static const droppedCountKey = 'pb.analytics.droppedCount';
final int maxEvents;
final int segmentCapacity;
SharedPreferences? _prefs;
final List<_Segment> _segments = [];
int _seq = 0;
int _droppedCount = 0;
int get length => _segments.fold(0, (sum, seg) => sum + seg.events.length);
int get droppedCount => _droppedCount;
/// 全部待上传事件快照(旧 → 新)。
List<Map<String, dynamic>> get events =>
List.unmodifiable([for (final seg in _segments) ...seg.events]);
/// 冷启动恢复:读回未上传段。损坏段(JSON 解析失败)删 key 丢弃、
/// 计入丢弃数,不崩溃;索引本身损坏则按 key 前缀清扫孤儿段后重建。
/// 恢复的事件排在本实例已入队事件之前(更旧优先上传/淘汰)。
Future<void> restore() async {
try {
_prefs = await SharedPreferences.getInstance();
} catch (error) {
debugPrint('Analytics store: prefs unavailable, memory-only: $error');
return;
}
final prefs = _prefs!;
_droppedCount = prefs.getInt(droppedCountKey) ?? 0;
final restored = <_Segment>[];
final rawIndex = prefs.getString(segIndexKey);
if (rawIndex != null) {
var ids = const <String>[];
try {
ids = [for (final id in jsonDecode(rawIndex) as List) id.toString()];
} catch (error) {
debugPrint('Analytics store: corrupted index, sweeping: $error');
final orphanKeys = prefs
.getKeys()
.where((key) => key.startsWith(segKeyPrefix))
.toList();
for (final key in orphanKeys) {
await _guard(() => prefs.remove(key));
}
_droppedCount += 1;
}
for (final id in ids) {
final raw = prefs.getString('$segKeyPrefix$id');
if (raw == null) continue;
try {
final events = [
for (final event in jsonDecode(raw) as List)
Map<String, dynamic>.from(event as Map),
];
if (events.isNotEmpty) {
restored.add(_Segment(id, events, sealed: true));
}
} catch (error) {
debugPrint('Analytics store: dropping corrupted seg $id: $error');
await _guard(() => prefs.remove('$segKeyPrefix$id'));
_droppedCount += 1;
}
}
}
// restore 前入队的事件此前无处可写,恢复后连同清理过的索引一并落盘。
final preExisting = List<_Segment>.of(_segments);
_segments
..clear()
..addAll(restored)
..addAll(preExisting);
await _evictOverflow();
for (final seg in preExisting.where(_segments.contains)) {
await _persistSegment(seg);
}
await _persistIndex();
await _persistDroppedCount();
}
/// 追加事件到当前开放段并持久化该段(重写 ≤ [segmentCapacity] 条);
/// 段满即封段,下次写入开新段;超总量上限丢最旧整段。
Future<void> add(Map<String, dynamic> event) async {
var open = _segments.isEmpty ? null : _segments.last;
if (open == null || open.sealed || open.events.length >= segmentCapacity) {
open = _Segment(_newSegId(), []);
_segments.add(open);
await _persistIndex();
}
open.events.add(event);
if (open.events.length >= segmentCapacity) open.sealed = true;
await _persistSegment(open);
await _evictOverflow();
}
/// 从最旧段起取整段组一批(≤ [maxBatchEvents] 条)。入选段即封段,
/// 上传在途期间新事件只会写入新的开放段,批内容与对应段不再变化,
/// 上传成功后可安全整段删除(冲刷中新事件不丢)。
UploadBatch takeBatch(int maxBatchEvents) {
final selected = <_Segment>[];
var count = 0;
for (final seg in _segments) {
if (count + seg.events.length > maxBatchEvents) break;
selected.add(seg);
count += seg.events.length;
}
for (final seg in selected) {
seg.sealed = true;
}
return UploadBatch(
segmentIds: [for (final seg in selected) seg.id],
events: [
for (final seg in selected)
...seg.events.map(Map<String, dynamic>.from),
],
);
}
/// 上传终态后删段:202 受理或 4xx 永久拒绝(后者 [countAsDropped]
/// 计入丢弃诊断数)。段可能已被溢出淘汰,缺失即忽略。
Future<void> removeSegments(
List<String> ids, {
bool countAsDropped = false,
}) async {
final idSet = ids.toSet();
var removed = 0;
_segments.removeWhere((seg) {
if (!idSet.contains(seg.id)) return false;
removed += seg.events.length;
return true;
});
if (countAsDropped) _droppedCount += removed;
for (final id in ids) {
await _removeSegmentKey(id);
}
await _persistIndex();
if (countAsDropped) await _persistDroppedCount();
}
Future<void> _evictOverflow() async {
var indexDirty = false;
while (length > maxEvents && _segments.length > 1) {
final victim = _segments.removeAt(0);
_droppedCount += victim.events.length;
await _removeSegmentKey(victim.id);
indexDirty = true;
}
if (indexDirty) {
await _persistIndex();
await _persistDroppedCount();
}
}
Future<void> _persistIndex() => _guard(
() => _prefs?.setString(
segIndexKey,
jsonEncode([for (final seg in _segments) seg.id]),
),
);
Future<void> _persistSegment(_Segment seg) => _guard(
() => _prefs?.setString('$segKeyPrefix${seg.id}', jsonEncode(seg.events)),
);
Future<void> _removeSegmentKey(String id) =>
_guard(() => _prefs?.remove('$segKeyPrefix$id'));
Future<void> _persistDroppedCount() =>
_guard(() => _prefs?.setInt(droppedCountKey, _droppedCount));
/// 持久化是尽力而为的镜像,失败只打日志,绝不影响内存队列。
Future<void> _guard(Future<void>? Function() write) async {
try {
await write();
} catch (error) {
debugPrint('Analytics store: persistence write failed: $error');
}
}
String _newSegId() =>
'${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}-${_seq++}';
}
/// 一次上传的段快照:事件按旧 → 新排列,段 ID 用于成功后删段。
class UploadBatch {
const UploadBatch({required this.segmentIds, required this.events});
final List<String> segmentIds;
final List<Map<String, dynamic>> events;
bool get isEmpty => events.isEmpty;
}
class _Segment {
_Segment(this.id, this.events, {this.sealed = false});
final String id;
final List<Map<String, dynamic>> events;
/// 封段后不再接受追加(满 20 条或已被取入上传批次)。
bool sealed;
}
+4 -1
View File
@@ -19,7 +19,10 @@ enum AnalyticsPageName {
create('create'),
petArchive('pet_archive'),
services('services'),
postDetail('post_detail');
postDetail('post_detail'),
// —— 字典 v3 页面族(22 号报告 §2;本枚举登记 M3 已落地页)——
/// 发布页(P3T3-17 push 全屏页)。
postForm('post_form');
const AnalyticsPageName(this.pageName);
+176 -29
View File
@@ -1,32 +1,54 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
/// Simplified analytics client for M0/M1 (report 13 + ticket 19): track events
/// to backend POST /api/v1/events. In-memory queue flushed every 20 events;
/// failed batches are re-queued (capped at 500, oldest dropped). Persistent
/// segmented queue lands in M2 wave 2. Privacy red-line enforced locally.
/// Analytics client for report 13: track events to backend POST
/// /api/v1/events. Events land in a segmented persistent queue
/// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped),
/// flushed every 20 events, every [flushInterval] while foregrounded
/// ([startPeriodicFlush]) and on leaving foreground; cold start [restore]
/// re-uploads offline backlog. Upload failures back off exponentially
/// (periodic flush only; explicit triggers unaffected). Privacy red-line
/// enforced locally.
class AnalyticsService {
AnalyticsService({
required this.apiBaseUrl,
required this.getAccessToken,
required this.getSessionId,
String? anonymousId,
AnalyticsEventStore? store,
this.flushInterval = const Duration(seconds: 30),
DateTime Function()? now,
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
_anonymousIdInjected = anonymousId != null,
_appVersion = 'unknown',
_osVersion = _defaultOsVersion();
_osVersion = _defaultOsVersion(),
_store = store ?? AnalyticsEventStore(),
_now = now ?? DateTime.now;
/// 异步设置 appVersionapp.dart 启动时从 package_info_plus 读取后注入)。
void setAppVersion(String version) {
_appVersion = version;
}
// 内存队列:满 _flushThreshold 条上传一次;分段持久化队列排 M2 第二波
// 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4
static const _flushThreshold = 20;
// 失败重回队列的容量上限(对齐 13 号规范队列上限),超限丢最旧
static const _maxQueuedEvents = 500;
// 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传
static const _maxBatchEvents = 50;
// 失败退避:首次 30 秒,×2 递增封顶 5 分钟(13 号 §3.4 / iteration-3
// 06 号 §2.3);只挡定时冲刷,显式触发(flushNow / 满 20 / 冷启动)不受限。
static const _backoffInitial = Duration(seconds: 30);
static const _backoffCap = Duration(minutes: 5);
/// anonymousId 持久化 key13 号规范 §3.3):首次生成后跨冷启动稳定,
/// 登录前事件才能跨启动归并(A/B 前置 #4 硬依赖)。
static const anonymousIdKey = 'pb.analytics.anonymousId';
final String apiBaseUrl;
final String? Function()? getAccessToken;
@@ -34,26 +56,61 @@ class AnalyticsService {
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
final String Function() getSessionId;
final String _anonymousId;
/// 前台定时冲刷周期(13 号 §3.4 第 4 触发点),构造参数化便于测试注入。
final Duration flushInterval;
String _anonymousId;
/// 构造显式注入 anonymousId 的测试通道不参与持久化采用/落盘。
final bool _anonymousIdInjected;
String _appVersion;
final String _osVersion;
String? _userId;
bool _flushing = false;
final List<Map<String, dynamic>> _pendingEvents = [];
final AnalyticsEventStore _store;
/// 时钟注入口(照 SessionTracker 先例),退避判定测试免真实等待。
final DateTime Function() _now;
Timer? _flushTimer;
Duration? _backoffDelay;
DateTime? _retryNotBefore;
/// 待上报事件(测试断言用,生产代码不得直接操作)。
@visibleForTesting
List<Map<String, dynamic>> get pendingEvents =>
List.unmodifiable(_pendingEvents);
List<Map<String, dynamic>> get pendingEvents => _store.events;
/// 当前匿名标识(测试断言用)。
@visibleForTesting
String get anonymousId => _anonymousId;
/// 粗粒度 osVersion13 号规范 §4.0:主版本级,如 android-14)。
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
static String _defaultOsVersion() {
if (kIsWeb) {
return 'web-unknown';
}
final major = RegExp(
r'\d+',
).firstMatch(Platform.operatingSystemVersion)?.group(0);
return '${Platform.operatingSystem}-${major ?? 'unknown'}';
}
/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,
/// 上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期。
static String _platformName() {
if (kIsWeb) {
return 'web';
}
if (Platform.isAndroid) {
return 'android';
}
if (Platform.isIOS) {
return 'ios';
}
return Platform.operatingSystem;
}
/// Sets userId after login/restore.
void identify(String userId) {
_userId = userId;
@@ -85,13 +142,13 @@ class AnalyticsService {
'sessionId': getSessionId(),
'clientTs': DateTime.now().toUtc().toIso8601String(),
'appVersion': _appVersion,
'platform': Platform.isAndroid ? 'android' : 'ios',
'platform': _platformName(),
'osVersion': _osVersion,
if (props != null && props.isNotEmpty) 'props': props,
};
_pendingEvents.add(event);
if (_pendingEvents.length >= _flushThreshold) {
await _store.add(event);
if (_store.length >= _flushThreshold) {
await _flush();
}
} catch (error) {
@@ -99,30 +156,105 @@ class AnalyticsService {
}
}
/// 冷启动恢复:先采用持久化 anonymousId,再恢复持久化队列(离线积压
/// 约两周容量),有积压即冲刷一次(13 号规范 §3.4 冷启动触发)。
/// app 启动时调用,不阻塞渲染。
Future<void> restore() async {
try {
await _restoreAnonymousId();
await _store.restore();
if (_store.length > 0) {
await _flush();
}
} catch (error) {
debugPrint('Analytics restore failed: $error');
}
}
/// 采用/落盘持久化 anonymousId:已有存储值则采用(跨启动稳定),
/// 无则把本次生成的落盘。持久化不可用时降级为进程内临时 id,
/// 绝不抛出(埋点旁路原则)。
Future<void> _restoreAnonymousId() async {
if (_anonymousIdInjected) return;
try {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(anonymousIdKey);
if (stored != null && stored.isNotEmpty) {
_anonymousId = stored;
} else {
await prefs.setString(anonymousIdKey, _anonymousId);
}
} catch (error) {
debugPrint('Analytics: anonymousId falls back to ephemeral: $error');
}
}
/// 启动前台定时冲刷(13 号 §3.4 第 4 触发点):长前台会话(刷 Feed
/// 半小时不切页)不再积压不上传。app 启动与回前台时调用,幂等。
void startPeriodicFlush() {
_flushTimer ??= Timer.periodic(flushInterval, (_) => _onFlushTimerTick());
}
/// 停止定时冲刷(退后台与 App dispose 时调用),退避状态保留。
void stopPeriodicFlush() {
_flushTimer?.cancel();
_flushTimer = null;
}
void _onFlushTimerTick() {
// 退避窗口内跳过定时冲刷;显式触发(flushNow / 满 20 / 冷启动)不受限。
final notBefore = _retryNotBefore;
if (notBefore != null && _now().isBefore(notBefore)) return;
_flush();
}
/// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满
/// [_flushThreshold] 条导致事件永不上传)。
Future<void> flushNow() => _flush();
Future<void> _flush() async {
if (_flushing || _pendingEvents.isEmpty) return;
if (_flushing) return;
_flushing = true;
final batch = List<Map<String, dynamic>>.from(_pendingEvents);
_pendingEvents.clear();
try {
await _upload(batch);
} catch (error) {
// 顺手加固(03 §1.4 #1 的一行级缓解):失败不再整批丢弃,
// 重回队首等下次冲刷;上限 500 条,超限丢最旧。真正的
// shared_preferences 分段持久化队列属 M2 第二波。
debugPrint('Analytics upload failed, requeueing batch: $error');
_pendingEvents.insertAll(0, batch);
if (_pendingEvents.length > _maxQueuedEvents) {
_pendingEvents.removeRange(0, _pendingEvents.length - _maxQueuedEvents);
while (true) {
// 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
final batch = _store.takeBatch(_maxBatchEvents);
if (batch.isEmpty) break;
final rejected = await uploadBatch(batch.events);
// 拿到服务端应答即连通性恢复,重置退避。
_resetBackoff();
// at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
// 4xx 批次计入本地丢弃诊断数。
await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
}
} catch (error) {
// 网络错误 / 5xx:段保留在持久化队列,指数退避后由定时冲刷重试,
// 或等显式触发/冷启动重传。
_scheduleBackoff();
debugPrint('Analytics upload failed, events kept queued: $error');
} finally {
_flushing = false;
}
}
Future<void> _upload(List<Map<String, dynamic>> events) async {
void _scheduleBackoff() {
final next = _backoffDelay == null ? _backoffInitial : _backoffDelay! * 2;
_backoffDelay = next > _backoffCap ? _backoffCap : next;
_retryNotBefore = _now().add(_backoffDelay!);
}
void _resetBackoff() {
_backoffDelay = null;
_retryNotBefore = null;
}
/// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃);
/// 网络错误 / 5xx / 429 抛异常(调用方保留段并退避)。
/// protected:测试子类以假上传替换,免起真实 HttpServer。
@protected
@visibleForTesting
Future<bool> uploadBatch(List<Map<String, dynamic>> events) async {
final token = getAccessToken?.call();
final request =
await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events'))
@@ -133,9 +265,24 @@ class AnalyticsService {
request.add(utf8.encode(jsonEncode({'events': events})));
final response = await request.close();
if (response.statusCode == 429) {
// 429 按网络错误同路径处理(保段 + 指数退避):后端限流尚未实现
// iteration-2/09 出入项),Retry-After 分支待其落地后一并做。
throw Exception('Upload rate limited (429), events kept queued');
}
if (response.statusCode >= 400 && response.statusCode < 500) {
// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
debugPrint(
'Analytics batch permanently rejected '
'(${response.statusCode}), dropping ${events.length} events',
);
return true;
}
if (response.statusCode != 202) {
throw Exception('Upload failed with ${response.statusCode}');
}
return false;
}
bool _containsForbiddenField(Map<String, dynamic> props) {
+14 -1
View File
@@ -14,6 +14,8 @@ import 'package:uuid/uuid.dart';
class SessionTracker with WidgetsBindingObserver {
SessionTracker({
this.timeout = const Duration(minutes: 30),
this.onLeaveForeground,
this.onEnterForeground,
DateTime Function()? now,
}) : _now = now ?? DateTime.now,
_sessionId = const Uuid().v7();
@@ -21,6 +23,13 @@ class SessionTracker with WidgetsBindingObserver {
/// 后台超时阈值;构造参数化便于测试注入。
final Duration timeout;
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
final VoidCallback? onLeaveForeground;
/// 回到前台时回调(app 装配层用于恢复 30 秒定时冲刷);
/// 冷启动首个 resumed 不触发(此前未离开过前台)。
final VoidCallback? onEnterForeground;
/// 时钟注入口,测试免真实等待。
final DateTime Function() _now;
@@ -35,13 +44,17 @@ class SessionTracker with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final leftAt = _leftForegroundAt;
if (leftAt != null && _now().difference(leftAt) > timeout) {
if (leftAt != null) {
if (_now().difference(leftAt) > timeout) {
_sessionId = const Uuid().v7();
}
onEnterForeground?.call();
}
_leftForegroundAt = null;
} else if (_lastState == AppLifecycleState.resumed) {
// 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。
_leftForegroundAt = _now();
onLeaveForeground?.call();
}
_lastState = state;
}
+124 -4
View File
@@ -12,15 +12,37 @@ import 'package:patbond_flutter/features/auth/auth_repository.dart';
import 'package:patbond_flutter/features/auth/login_page.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/auth/splash_page.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:patbond_flutter/features/main/main_shell_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/state/app_state.dart';
class App extends StatefulWidget {
const App({super.key, this.sessionManager, this.authRepository});
const App({
super.key,
this.sessionManager,
this.authRepository,
this.petsRepository,
this.communityRepository,
this.mediaUploaderFactory,
});
/// 测试注入口;生产默认走安全存储 + 真实 API。
final SessionManager? sessionManager;
final AuthRepository? authRepository;
final PetsRepository? petsRepository;
final CommunityRepository? communityRepository;
/// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。
final MediaUploaderFactory? mediaUploaderFactory;
@override
State<App> createState() => _AppState();
@@ -30,10 +52,18 @@ class _AppState extends State<App> {
late final AppState appState;
late final SessionManager sessionManager;
late final AuthRepository authRepository;
late final PetsController petsController;
late final CommunityController communityController;
late final PetAnalytics petAnalytics;
late final HealthRecordAnalytics healthRecordAnalytics;
late final FeedAnalytics feedAnalytics;
late final CommunityInteractionAnalytics interactionAnalytics;
late final PostAnalytics postAnalytics;
late final SessionTracker _sessionTracker;
late final AnalyticsService _analytics;
late final PageViewTracker _pageViewTracker;
late final AnalyticsRouteObserver _routeObserver;
TokenRefresher? _sharedRefresher;
@override
void initState() {
@@ -43,15 +73,26 @@ class _AppState extends State<App> {
widget.sessionManager ??
SessionManager(store: const SecureTokenStore());
_sessionTracker = SessionTracker();
_sessionTracker = SessionTracker(
onLeaveForeground: () {
// 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。
_analytics.stopPeriodicFlush();
_analytics.flushNow();
},
onEnterForeground: () => _analytics.startPeriodicFlush(),
);
WidgetsBinding.instance.addObserver(_sessionTracker);
_analytics = AnalyticsService(
apiBaseUrl: patbondApiBaseUrl,
apiBaseUrl: patbondUserApiBaseUrl,
getAccessToken: () => sessionManager.accessToken,
getSessionId: () => _sessionTracker.sessionId,
);
_initAppVersion();
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
_analytics.restore();
// 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。
_analytics.startPeriodicFlush();
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
_routeObserver = AnalyticsRouteObserver(
@@ -60,6 +101,21 @@ class _AppState extends State<App> {
);
authRepository = widget.authRepository ?? _buildRepository();
petsController = PetsController(
repository: widget.petsRepository ?? _buildPetsRepository(),
);
// T3-14Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起
// 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。
interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent);
communityController = CommunityController(
repository: widget.communityRepository ?? _buildCommunityRepository(),
interactionAnalytics: interactionAnalytics,
);
petAnalytics = PetAnalytics(_analytics.trackEvent);
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
feedAnalytics = FeedAnalytics(_analytics.trackEvent);
// T3-17:发布漏斗五事件 + 媒体上传三段(发布页与 MediaUploader 消费)。
postAnalytics = PostAnalytics(_analytics.trackEvent);
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
sessionManager.addListener(_reportAuthStateChange);
@@ -75,9 +131,18 @@ class _AppState extends State<App> {
}
}
/// 三服务分端口直连(ADR-002 无网关),token 刷新单飞经共享
/// [TokenRefresher]22 号报告 §7 交接约定)。
TokenRefresher _ensureRefresher() {
return _sharedRefresher ??= TokenRefresher(
dio: buildPatbondDio(session: sessionManager),
session: sessionManager,
);
}
AuthRepository _buildRepository() {
final dio = buildPatbondDio(session: sessionManager);
final refresher = TokenRefresher(dio: dio, session: sessionManager);
final refresher = _ensureRefresher();
final api = ApiClient(
dio: dio,
session: sessionManager,
@@ -91,6 +156,44 @@ class _AppState extends State<App> {
);
}
PetsRepository _buildPetsRepository() {
final dio = buildPatbondDio(
session: sessionManager,
baseUrl: patbondPetApiBaseUrl,
);
final api = ApiClient(
dio: dio,
session: sessionManager,
refresher: _ensureRefresher(),
);
return ApiPetsRepository(api: api);
}
/// community 服务分端口直连(:8084);media 两步上传端点由 user 服务
/// :8082 MediaController)提供,单独建 mediaApi 直连。token 刷新
/// 单飞经共享 [TokenRefresher]。
CommunityRepository _buildCommunityRepository() {
final dio = buildPatbondDio(
session: sessionManager,
baseUrl: patbondCommunityApiBaseUrl,
);
final api = ApiClient(
dio: dio,
session: sessionManager,
refresher: _ensureRefresher(),
);
final mediaDio = buildPatbondDio(
session: sessionManager,
baseUrl: patbondUserApiBaseUrl,
);
final mediaApi = ApiClient(
dio: mediaDio,
session: sessionManager,
refresher: _ensureRefresher(),
);
return ApiCommunityRepository(api: api, mediaApi: mediaApi);
}
AnalyticsPageName? _resolveRootPage() {
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
return switch (sessionManager.status) {
@@ -101,6 +204,11 @@ class _AppState extends State<App> {
}
void _reportAuthStateChange() {
// 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。
if (sessionManager.status == AuthStatus.unauthenticated) {
petsController.reset();
communityController.reset();
}
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
final page = switch (sessionManager.status) {
AuthStatus.unauthenticated => AnalyticsPageName.login,
@@ -112,9 +220,12 @@ class _AppState extends State<App> {
@override
void dispose() {
_analytics.stopPeriodicFlush();
sessionManager.removeListener(_reportAuthStateChange);
WidgetsBinding.instance.removeObserver(_sessionTracker);
appState.dispose();
petsController.dispose();
communityController.dispose();
if (widget.sessionManager == null) sessionManager.dispose();
super.dispose();
}
@@ -136,6 +247,15 @@ class _AppState extends State<App> {
return MainShellPage(
key: const ValueKey('shell'),
appState: appState,
petsController: petsController,
communityController: communityController,
currentUserId: sessionManager.userId,
petAnalytics: petAnalytics,
healthRecordAnalytics: healthRecordAnalytics,
feedAnalytics: feedAnalytics,
interactionAnalytics: interactionAnalytics,
postAnalytics: postAnalytics,
mediaUploaderFactory: widget.mediaUploaderFactory,
pageViewTracker: _pageViewTracker,
onLogout: authRepository.logout,
);
+27
View File
@@ -0,0 +1,27 @@
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`pets 域体重 / 健康事件,
/// community 域 Feed / 我的帖子 / 评论 / 收藏共用;自 pet_models.dart 上移至 core)。
class CursorPage<T> {
const CursorPage({
required this.items,
required this.nextCursor,
required this.hasMore,
});
factory CursorPage.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) itemFromJson,
) {
return CursorPage(
items: (json['items'] as List)
.map((item) => itemFromJson(item as Map<String, dynamic>))
.toList(),
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
nextCursor: json['nextCursor'] as String?,
hasMore: json['hasMore'] as bool,
);
}
final List<T> items;
final String? nextCursor;
final bool hasMore;
}
+28
View File
@@ -10,6 +10,29 @@ const String patbondApiBaseUrl = String.fromEnvironment(
defaultValue: 'http://127.0.0.1:8081',
);
/// user 服务基地址(/api/v1/me、/api/v1/events):MVP 阶段 auth 与 user
/// 分端口直连(ADR-002 无网关),`--dart-define=PATBOND_USER_API_BASE_URL=...` 注入。
const String patbondUserApiBaseUrl = String.fromEnvironment(
'PATBOND_USER_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8082',
);
/// pet 服务基地址(/api/v1/pets、/api/v1/breeds、/api/v1/vaccine-catalog 等
/// pets 域 12 路径):沿用 auth :8081 / user :8082 的分端口直连模式,
/// `--dart-define=PATBOND_PET_API_BASE_URL=...` 注入。
const String patbondPetApiBaseUrl = String.fromEnvironment(
'PATBOND_PET_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8083',
);
/// community 服务基地址(/api/v1/posts、/api/v1/feed、/api/v1/media 等
/// community/media 域 13 路径):沿用分端口直连模式,
/// `--dart-define=PATBOND_COMMUNITY_API_BASE_URL=...` 注入。
const String patbondCommunityApiBaseUrl = String.fromEnvironment(
'PATBOND_COMMUNITY_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8084',
);
/// 构建全局共用的 Dio 实例。
///
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
@@ -75,6 +98,7 @@ class ApiClient {
String path, {
String method = 'POST',
Object? body,
Map<String, Object?>? query,
Map<String, Object?>? headers,
bool requiresAuth = false,
}) async {
@@ -82,6 +106,7 @@ class ApiClient {
path,
method: method,
body: body,
query: query,
headers: headers,
requiresAuth: requiresAuth,
);
@@ -92,6 +117,7 @@ class ApiClient {
path,
method: method,
body: body,
query: query,
headers: headers,
requiresAuth: requiresAuth,
);
@@ -109,12 +135,14 @@ class ApiClient {
required String method,
required bool requiresAuth,
Object? body,
Map<String, Object?>? query,
Map<String, Object?>? headers,
}) async {
try {
return await _dio.request<Object?>(
path,
data: body,
queryParameters: query,
options: Options(
method: method,
headers: headers,
+58 -1
View File
@@ -22,6 +22,62 @@ abstract final class ApiCodes {
/// 登录失败次数过多,账号临时锁定(HTTP 423,见 openapi.yaml)。
static const loginLocked = 42300;
// ------ pets 域(契约 v1.2.0 冻结,M2 第二波定型 8 个)------
/// 对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。
static const petAccessDenied = 40300;
/// 宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。
static const petNotFound = 40401;
/// 记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。
static const recordNotFound = 40402;
/// 乐观锁版本冲突(提交的 version 已过期;提醒的条件更新守卫落空同码)。
static const versionConflict = 40902;
/// 芯片号已被登记(跨用户唯一)。
static const microchipTaken = 40903;
/// 同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在。
static const vaccinationDoseExists = 40904;
/// 疫苗状态机 / 状态-日期规则违反(HTTP 422)。
static const vaccinationRuleViolation = 42201;
/// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。
static const careReminderRuleViolation = 42202;
// ------ community / media 域(契约 v1.3.0 冻结,M3 第二波定型 9 个)------
/// 对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论)。
static const postAccessDenied = 40301;
/// 帖子不存在 / 已软删 / hidden/archived(含作者)/ 他人 draft
/// (防枚举,全部情况响应一致;互动路径上含作者本人草稿)。
static const postNotFound = 40403;
/// 评论不存在、已删或所属帖子不可见(防枚举合并)。
static const commentNotFound = 40404;
/// media asset 不存在、非本人所有或已删(防枚举合并)。
static const mediaNotFound = 40405;
/// 目标用户不存在或已注销(合并不泄露成因)。
static const communityUserNotFound = 40406;
/// 同 Idempotency-Key 不同 payload(规范化 request_hash 不符)。
static const idempotencyKeyMismatch = 40905;
/// 引用了本人所有但非 readyuploading/failed)状态的 assetHTTP 422)。
static const mediaNotReady = 42203;
/// 自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。
static const selfFollow = 42204;
/// 上传状态不允许确认(非 uploading 态或对象校验未通过,HTTP 422)。
static const mediaUploadStateInvalid = 42205;
}
/// API 调用的类型化异常。页面按类型映射为三层错误呈现
@@ -43,7 +99,8 @@ final class ApiNetworkException extends ApiException {
}
/// 业务错误:错误信封 code != 040000/40100/40900/40901 等)。
final class ApiBusinessException extends ApiException {
/// 领域层可按错误码细分子类型(见 features/pets/pet_exceptions.dart)。
class ApiBusinessException extends ApiException {
const ApiBusinessException({required this.code, required String message})
: super(message);
@@ -0,0 +1,67 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
/// 预签名 URL 的图片内存缓存 key:剥离 SigV4 签名参数(`X-Amz-*`
/// 大小写不敏感),保留其余 query。
///
/// 媒体 URL 每次响应现签(TTL 1 小时),同一对象两次响应的完整 URL
/// 必然不同;若按完整 URL 作缓存 key,同图会反复未命中、重复下载。
/// 对象路径唯一标识 MinIO 对象,剥签名后即稳定 key。
String presignedImageCacheKey(String url) {
final uri = Uri.tryParse(url);
if (uri == null || uri.queryParameters.isEmpty) return url;
final kept = <String, String>{};
var stripped = false;
uri.queryParameters.forEach((key, value) {
if (key.toLowerCase().startsWith('x-amz-')) {
stripped = true;
} else {
kept[key] = value;
}
});
if (!stripped) return url;
// Uri.replace 的 null 语义是「保留原值」,全剥空时手工截断 query。
if (kept.isEmpty) return url.substring(0, url.indexOf('?'));
return uri.replace(queryParameters: kept).toString();
}
/// 以剥签名 key 判等的网络图 provider:同对象的不同签名 URL 命中同一
/// [ImageCache] 条目;未命中时仍以完整签名 URL 发起请求(委托
/// [NetworkImage] 加载,其为 factory-only 接口,无法直接继承)。
class SignedNetworkImage extends ImageProvider<SignedNetworkImage> {
SignedNetworkImage(this.url, {this.scale = 1.0})
: cacheKey = presignedImageCacheKey(url);
final String url;
final double scale;
/// 剥离签名参数后的稳定缓存 key。
final String cacheKey;
@override
Future<SignedNetworkImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<SignedNetworkImage>(this);
@override
ImageStreamCompleter loadImage(
SignedNetworkImage key,
ImageDecoderCallback decode,
) {
final delegate = NetworkImage(key.url, scale: key.scale);
return delegate.loadImage(delegate, decode);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is SignedNetworkImage &&
other.cacheKey == cacheKey &&
other.scale == scale;
}
@override
int get hashCode => Object.hash(cacheKey, scale);
@override
String toString() => 'SignedNetworkImage("$url", cacheKey: "$cacheKey")';
}
+8
View File
@@ -30,6 +30,10 @@ abstract final class AppColors {
/// 主文字。
static const ink = Color(0xFF3E2A1F);
/// 承载信息的次级文字(日期、元数据、字段名;正典 feed-caption 色,
/// 白底 6.59:1 达 AA)。muted 仅限占位符、禁用态、纯装饰(05 号规范 DEBT-2)。
static const inkSoft = Color(0xFF6B5A4A);
/// 次级文字、占位符。
static const muted = Color(0xFF9C8977);
@@ -50,6 +54,10 @@ abstract final class AppColors {
/// 校验错误文字、错误描边、失败提示(白底上约 5.4:1,AA 达标)。
static const error = Color(0xFFD0342C);
/// error 淡底(8% tint)上的文字(error 本身在自家淡底仅 4.44:1 贴线不过;
/// 与 primary→primaryStrong 同构加深,05 号规范 §2/D8)。
static const errorDark = Color(0xFFB02C25);
/// 品牌渐变:hero 区、头像环、装饰性大面积(不承载正文文字)。
static const brandGradient = LinearGradient(
begin: Alignment.topLeft,
+6
View File
@@ -11,6 +11,7 @@ class AppTextField extends StatefulWidget {
super.key,
this.controller,
this.prefixIcon,
this.prefixText,
this.errorText,
this.helperText,
this.enabled = true,
@@ -25,6 +26,10 @@ class AppTextField extends StatefulWidget {
final String label;
final TextEditingController? controller;
final IconData? prefixIcon;
/// 固定前缀文本(如手机号的 '+86 '),仅展示与拼接语义,不进输入值。
final String? prefixText;
final String? errorText;
final String? helperText;
final bool enabled;
@@ -62,6 +67,7 @@ class _AppTextFieldState extends State<AppTextField> {
labelText: widget.label,
errorText: widget.errorText,
helperText: widget.helperText,
prefixText: widget.prefixText,
prefixIcon: widget.prefixIcon == null
? null
: Icon(widget.prefixIcon, color: AppColors.muted),
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/features/community/community_display.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
/// 评论条目(05 号规范 §3.4,demo 气泡形态升共享):
/// `PetAvatar sm32` + 10 + 气泡(`surface` 底、`border` 1px、圆角 16、
/// padding 12)——作者名 13/w700 → 4 → 内容 bodyMedium → 6 → 底行
/// (时间 11 `inkSoft` + 仅本人评论的「删除」入口)。
///
/// - @ 回复(单层平铺,契约 replyToUser)以「回复 @昵称:」前缀呈现;
/// 降级作者统一「宠友」占位(isDegraded 一个判定口)。
/// - [onDelete] 非 null 才渲染删除入口——**权限判定在调用方**(17 号
/// 后端语义:仅评论作者可删,他人可见评论 403/40301);[deleting]
/// 期间入口替换为 14 转圈防重复提交。
/// - 评论点赞(§3.4 底行右端)无契约端点,M3 不渲染。
class CommentTile extends StatelessWidget {
const CommentTile({
required this.comment,
super.key,
this.onDelete,
this.deleting = false,
this.now,
});
final PostComment comment;
/// 删除回调;null = 非本人评论,不渲染删除入口。
final VoidCallback? onDelete;
/// 删除请求在途(入口转圈锁定)。
final bool deleting;
/// 相对时间的参考时钟(测试注入;缺省取当前时间)。
final DateTime? now;
@override
Widget build(BuildContext context) {
final replyTo = comment.replyToUser;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PetAvatar(size: PetAvatarSize.sm, url: comment.author.avatarUrl),
const SizedBox(width: 10),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authorDisplayName(comment.author),
style: const TextStyle(
color: AppColors.ink,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
children: [
if (replyTo != null)
TextSpan(
text: '回复 @${authorDisplayName(replyTo)}',
style: const TextStyle(
color: AppColors.inkSoft,
fontWeight: FontWeight.w600,
),
),
TextSpan(text: comment.content),
],
),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 6),
Row(
children: [
Text(
feedRelativeTime(comment.createdAt, now: now),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 11,
),
),
const Spacer(),
if (onDelete != null)
deleting
? const Padding(
padding: EdgeInsets.all(4),
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.inkSoft,
),
),
)
: Semantics(
label: '删除评论',
button: true,
child: InkWell(
onTap: onDelete,
borderRadius: BorderRadius.circular(
AppRadius.pill,
),
// 视觉 11 字,触控由 padding 撑到 ≥32
//(气泡内行高受限,不足 44 以热区扩展补偿)。
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
child: Text(
'删除',
style: TextStyle(
color: AppColors.inkSoft,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 空态插画区(05 号规范 §3.4):承载引导动作的空态,向上兼容现有
/// `EmptyState`42 图标 + 一行 bodySmall 的纯提示版继续留用)。
///
/// 布局:112 圆形插画区(`surfaceTint` 底 + 56 `primary` 图标,大面积
/// 装饰用法)→ 标题 → 说明(12 `inkSoft` ≤2 行居中)→ 可选 CTA。
/// v1 插画用 Material 图标,正式素材待品牌侧供给后原位替换。
class EmptyStateIllustration extends StatelessWidget {
const EmptyStateIllustration({
required this.icon,
required this.title,
super.key,
this.description,
this.ctaLabel,
this.onCtaPressed,
});
final IconData icon;
final String title;
final String? description;
/// CTA 文案;null 则不渲染按钮(如筛选后空态)。
final String? ctaLabel;
final VoidCallback? onCtaPressed;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 112,
height: 112,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceTint,
),
child: Icon(icon, size: 56, color: AppColors.primary),
),
const SizedBox(height: 16),
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (description != null) ...[
const SizedBox(height: 8),
Text(
description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
height: 1.4,
),
),
],
if (ctaLabel != null) ...[
const SizedBox(height: 24),
FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24),
),
onPressed: onCtaPressed,
child: Text(ctaLabel!),
),
],
],
),
);
}
}
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// Feed 骨架屏单元(05 号规范 §3.7):模拟单图卡——头部行(32 圆 +
/// 两条横条)→ 4:3 通栏块 → 两条正文横条。块色 `surfaceTint`
/// (1.18:1,装饰性占位不受对比度约束)。
///
/// 动效:整体不透明度 0.6 ↔ 1.0 呼吸循环 1200ms;系统「减弱动态效果」
/// 开启时静止在 1.0。首载用法为连排 3 张(P1/P4)。
class FeedSkeleton extends StatefulWidget {
const FeedSkeleton({super.key});
@override
State<FeedSkeleton> createState() => _FeedSkeletonState();
}
class _FeedSkeletonState extends State<FeedSkeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
lowerBound: 0.6,
value: 1,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.of(context).disableAnimations;
if (reduceMotion) {
_controller.stop();
_controller.value = 1;
} else if (!_controller.isAnimating) {
_controller.repeat(reverse: true);
}
return FadeTransition(
opacity: _controller,
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const _SkeletonBlock(width: 32, height: 32, circle: true),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SkeletonBlock(
width: _relative(context, 0.40),
height: 12,
),
const SizedBox(height: 6),
_SkeletonBlock(
width: _relative(context, 0.24),
height: 8,
),
],
),
],
),
),
const AspectRatio(
aspectRatio: 4 / 3,
child: ColoredBox(color: AppColors.surfaceTint),
),
Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SkeletonBlock(width: _relative(context, 0.90), height: 12),
const SizedBox(height: 8),
_SkeletonBlock(width: _relative(context, 0.60), height: 12),
],
),
),
],
),
),
);
}
/// 横条宽度按屏宽比例取(规范 40%/24%/90%/60%),免依赖父约束测量。
double _relative(BuildContext context, double fraction) =>
MediaQuery.of(context).size.width * fraction;
}
class _SkeletonBlock extends StatelessWidget {
const _SkeletonBlock({
required this.width,
required this.height,
this.circle = false,
});
final double width;
final double height;
final bool circle;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColors.surfaceTint,
borderRadius: BorderRadius.circular(circle ? width / 2 : 6),
),
);
}
}
+203
View File
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 点赞 / 收藏按钮语义变体(05 号规范 §3.5:同一组件,图标与语义色
/// 参数化)。点赞激活 = `error` 图标 + `errorDark` 计数(demo 的
/// `Colors.red` 3.13:1 修订弃用,D6);收藏激活 = `accentDark` 同色。
enum LikeButtonVariant {
like(
inactiveIcon: Icons.favorite_border,
activeIcon: Icons.favorite,
activeIconColor: AppColors.error,
activeCountColor: AppColors.errorDark,
),
bookmark(
inactiveIcon: Icons.bookmark_border,
activeIcon: Icons.bookmark,
activeIconColor: AppColors.accentDark,
activeCountColor: AppColors.accentDark,
);
const LikeButtonVariant({
required this.inactiveIcon,
required this.activeIcon,
required this.activeIconColor,
required this.activeCountColor,
});
final IconData inactiveIcon;
final IconData activeIcon;
final Color activeIconColor;
final Color activeCountColor;
}
/// 点赞/收藏交互钮(05 号规范 §3.5):图标 20 + 计数 13/w600,未激活
/// 一律 `inkSoft`6.59:1)。触控 44×44 由 padding 撑足。
///
/// 乐观更新视觉(§3.5/§4 三层闪烁抑制的 UI 半边,状态本体由持有方经
/// ToggleSync 驱动):
///
/// - **点按驱动**的状态变化:激活播 240ms 弹性缩放(1→1.25→1+ 图标
/// 120ms 淡入;取消仅 120ms 颜色渐出、无缩放。
/// - **非点按驱动**的状态变化(失败回滚 / 服务端对账):零动画直接跳变;
/// 若激活动画未播完,等播完再跳(避免动画中途反转的抖动,§4.3a)。
/// - 计数变化一律直接替换,不做滚动动画(回滚时无二次滚动)。
/// - 系统「减弱动态效果」开启时全部降级为瞬变。
///
/// [onPressed] 传 null 即纯展示禁用态(仍按正常色渲染计数与状态)。
class LikeButton extends StatefulWidget {
const LikeButton({
required this.variant,
required this.active,
required this.count,
super.key,
this.onPressed,
this.semanticLabel,
});
final LikeButtonVariant variant;
final bool active;
final int count;
final VoidCallback? onPressed;
final String? semanticLabel;
@override
State<LikeButton> createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton>
with SingleTickerProviderStateMixin {
late final AnimationController _scaleController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
);
late final Animation<double> _scale = TweenSequence<double>([
TweenSequenceItem(
tween: Tween<double>(
begin: 1,
end: 1.25,
).chain(CurveTween(curve: Curves.easeOut)),
weight: 40,
),
TweenSequenceItem(
tween: Tween<double>(
begin: 1.25,
end: 1,
).chain(CurveTween(curve: Curves.easeOutBack)),
weight: 60,
),
]).animate(_scaleController);
/// 当前展示态(回滚等待动画播完期间可短暂落后于 widget.active)。
late bool _displayActive = widget.active;
/// 展示计数与状态成对更新(§4.3c:回滚不出现「心已灭计数未减」中间帧)。
late int _displayCount = widget.count;
/// 最近一次点按的期望目标态;didUpdateWidget 以此区分「点按驱动」
/// (播动画)与「回滚/对账」(零动画跳变)。
bool? _expectedTarget;
/// 图标切换是否走 120ms 淡入淡出(点按驱动);回滚跳变置 false。
bool _fadeSwap = false;
bool get _reduceMotion =>
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
@override
void didUpdateWidget(LikeButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.active == widget.active) {
// 状态未变的计数变化 = 服务端对账:静默替换、不播动画(§4.4)。
_displayCount = widget.count;
return;
}
final tapDriven = _expectedTarget == widget.active;
_expectedTarget = null;
if (tapDriven && !_reduceMotion) {
_fadeSwap = true;
_displayActive = widget.active;
_displayCount = widget.count;
if (widget.active) _scaleController.forward(from: 0);
return;
}
// 回滚 / 对账:零动画、计数与状态成对跳变。激活动画未播完则等
// 播完再跳(§4.3a,避免动画中途反转的抖动)。
_fadeSwap = false;
if (_scaleController.isAnimating) {
_scaleController.forward().whenComplete(() {
if (mounted) {
setState(() {
_displayActive = widget.active;
_displayCount = widget.count;
});
}
});
} else {
_displayActive = widget.active;
_displayCount = widget.count;
}
}
void _handleTap() {
_expectedTarget = !widget.active;
widget.onPressed!();
}
@override
void dispose() {
_scaleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final variant = widget.variant;
final active = _displayActive;
final iconColor = active ? variant.activeIconColor : AppColors.inkSoft;
final countColor = active ? variant.activeCountColor : AppColors.inkSoft;
return Semantics(
label: widget.semanticLabel,
button: widget.onPressed != null,
child: InkWell(
onTap: widget.onPressed == null ? null : _handleTap,
borderRadius: BorderRadius.circular(AppRadius.pill),
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ScaleTransition(
scale: _scale,
child: AnimatedSwitcher(
duration: _fadeSwap
? const Duration(milliseconds: 120)
: Duration.zero,
child: Icon(
active ? variant.activeIcon : variant.inactiveIcon,
key: ValueKey(active),
size: 20,
color: iconColor,
),
),
),
const SizedBox(width: 4),
// 计数直接替换(§3.5:不做滚动动画,避免回滚二次滚动)。
Text(
'$_displayCount',
style: TextStyle(
color: countColor,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// 头像尺寸档(05 号规范 §3.1)。
enum PetAvatarSize {
/// 96:档案页头部(收敛现 104 → 96,与表单页一致)。
xl(96),
/// 64:宠物列表卡。
lg(64),
/// 44:头部宠物切换器(最小触控目标)。
md(44),
/// 32:记录详情等行内。
sm(32);
const PetAvatarSize(this.dimension);
final double dimension;
/// 编辑徽标仅 xl / lg 档提供(05 §3.1)。
bool get supportsBadge => this == xl || this == lg;
}
/// 宠物头像(05 号规范 §3.1):统一原档案页头部 / 编辑 sheet 各写一遍的
/// 头像实现。圆形裁切 + 3px `surface` 白描边 + 轻投影(正典
/// `.patbond-avatar` 规格),可选右下编辑徽标与品牌渐变环。
///
/// M2 首版不做头像上传(ADR-010 / D2-1 裁决),[url] 为 null 时渲染
/// 本地占位形态(`surfaceTint` 底 + `Icons.pets`);有图时经
/// [RemoteImage] 加载(loading / 失败兜底由其内置)。
class PetAvatar extends StatelessWidget {
const PetAvatar({
required this.size,
super.key,
this.url,
this.onTap,
this.showEditBadge = false,
this.ring = false,
this.enabled = true,
this.semanticLabel,
});
final PetAvatarSize size;
/// 头像图 URL;null 为本地占位形态(M2 默认)。
final String? url;
/// 可点击时整体 [InkWell] 圆形 ripple。
final VoidCallback? onTap;
/// 右下编辑徽标(`primaryStrong` 底 + 白 edit 图标,白/primaryStrong
/// 4.49:1 达非文字 3:1;修订现实现的 `primary` 底 2.75:1)。
final bool showEditBadge;
/// 2px `brandGradient` 外环(正典 story 环),仅作「当前选中」纯装饰指示。
final bool ring;
/// 禁用态 60% 不透明度(对齐 AppTextField 禁用惯例)。
final bool enabled;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final dimension = size.dimension;
// 3px 白描边内的图面尺寸。
final imageDimension = dimension - 6;
Widget avatar = Container(
width: dimension,
height: dimension,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surface,
boxShadow: [
BoxShadow(
color: Color(0x14000000), // rgba(0,0,0,0.08)
offset: Offset(0, 4),
blurRadius: 10,
),
],
),
padding: const EdgeInsets.all(3),
child: ClipOval(
child: url == null
? ColoredBox(
color: AppColors.surfaceTint,
child: Icon(
Icons.pets,
color: AppColors.muted,
size: imageDimension * 0.45,
),
)
: RemoteImage(
url: url!,
width: imageDimension,
height: imageDimension,
borderRadius: BorderRadius.circular(imageDimension / 2),
),
),
);
if (ring) {
avatar = Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: AppColors.brandGradient,
),
child: avatar,
);
}
if (showEditBadge && size.supportsBadge) {
avatar = Stack(
clipBehavior: Clip.none,
children: [
avatar,
const Positioned(right: 0, bottom: 0, child: _EditBadge()),
],
);
}
if (onTap != null) {
avatar = InkWell(
onTap: enabled ? onTap : null,
customBorder: const CircleBorder(),
child: avatar,
);
}
if (!enabled) {
avatar = Opacity(opacity: 0.6, child: avatar);
}
if (semanticLabel != null) {
avatar = Semantics(
label: semanticLabel,
button: onTap != null,
child: avatar,
);
}
return avatar;
}
}
/// 32 圆编辑徽标:`primaryStrong` 底 + 白 edit 1505 §3.1 无障碍修订)。
class _EditBadge extends StatelessWidget {
const _EditBadge();
@override
Widget build(BuildContext context) {
return Container(
width: 32,
height: 32,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primaryStrong,
border: Border.fromBorderSide(
BorderSide(color: AppColors.surface, width: 2),
),
),
child: const Icon(Icons.edit, color: Colors.white, size: 15),
);
}
}
+240
View File
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/like_button.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_display.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// 帖子卡三形态(05 号规范 §2.1/§3.1):home 私有 `_PostCard` 的升级
/// 迁移,按媒体形态分支——
///
/// | 形态 | 媒体区 |
/// | --- | --- |
/// | 单图(mediaCount ≤ 1 且有封面) | 通栏出血 4:3 |
/// | 多图(mediaCount > 1 | [PostMediaGrid] 折叠封面(FeedCard 契约只带封面 + 计数),水平 padding 14 |
/// | 纯文字(无封面) | 无媒体区,正文放宽 ≤6 行、15/1.6 |
///
/// 头部行:作者头像 32 + 名字 14/w700 + 时间 12 `inkSoft`;求助帖
/// 元信息尾追加 `TagPill(accent)`。降级作者([AuthorSummary.isDegraded]
/// 渲染占位头像 + 「宠友」。操作行:[LikeButton](点赞 / 收藏)+ 评论
/// 计数 + 分享,各钮触控 44;互动回调传 null 即纯展示(T3-14 形态,
/// ToggleSync 接线属 T3-15/16)。
class PostCard extends StatelessWidget {
const PostCard({
required this.card,
super.key,
this.onTap,
this.onLikeTap,
this.onCommentTap,
this.onBookmarkTap,
this.onShareTap,
this.now,
});
final FeedCard card;
/// 整卡点按(帖子详情入口)。
final VoidCallback? onTap;
final VoidCallback? onLikeTap;
final VoidCallback? onCommentTap;
final VoidCallback? onBookmarkTap;
final VoidCallback? onShareTap;
/// 相对时间的参考时钟(测试注入;缺省取当前时间)。
final DateTime? now;
bool get _isTextOnly => card.coverImage == null;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(context),
if (card.coverImage != null)
card.mediaCount > 1
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: PostMediaGrid(
urls: [card.coverImage!.url],
totalCount: card.mediaCount,
),
)
: AspectRatio(
aspectRatio: 4 / 3,
child: RemoteImage(url: card.coverImage!.url),
),
_body(context),
_actionRow(),
],
),
),
);
}
Widget _header(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
PetAvatar(size: PetAvatarSize.sm, url: card.author.avatarUrl),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authorDisplayName(card.author),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Row(
children: [
Text(
feedRelativeTime(card.publishedAt, now: now),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
if (card.category == PostCategory.help) ...[
const SizedBox(width: 6),
const TagPill('求助', color: AppColors.accent),
],
],
),
],
),
),
],
),
);
}
Widget _body(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (card.title != null) ...[
Text(
card.title!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
],
Text(
card.contentPreview,
// 纯文字帖放宽至 6 行并升字号补偿视觉重量(§2.1)。
maxLines: _isTextOnly ? 6 : 2,
overflow: TextOverflow.ellipsis,
style: _isTextOnly
? const TextStyle(
color: AppColors.ink,
fontSize: 15,
height: 1.6,
)
: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
Widget _actionRow() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
child: Row(
children: [
LikeButton(
variant: LikeButtonVariant.like,
active: card.likedByMe,
count: card.likeCount,
onPressed: onLikeTap,
semanticLabel: '点赞',
),
const SizedBox(width: 4),
_CommentCount(count: card.commentCount, onPressed: onCommentTap),
const SizedBox(width: 4),
LikeButton(
variant: LikeButtonVariant.bookmark,
active: card.bookmarkedByMe,
count: card.bookmarkCount,
onPressed: onBookmarkTap,
semanticLabel: '收藏',
),
const Spacer(),
InkWell(
onTap: onShareTap,
borderRadius: BorderRadius.circular(AppRadius.pill),
child: const SizedBox(
width: 44,
height: 44,
child: Icon(
Icons.ios_share_outlined,
size: 20,
color: AppColors.inkSoft,
),
),
),
],
),
);
}
}
/// 评论计数钮(与 LikeButton 同视觉规格,无激活态)。
class _CommentCount extends StatelessWidget {
const _CommentCount({required this.count, this.onPressed});
final int count;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(AppRadius.pill),
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.chat_bubble_outline,
size: 20,
color: AppColors.inkSoft,
),
const SizedBox(width: 4),
Text(
'$count',
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
+367
View File
@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/upload_progress_overlay.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// 图片九宫格展示态(05 号规范 §3.2)。
///
/// - 列数规则:2、4 图 → 2 列;3、5–9 图 → 3 列;全部 1:1 cover、
/// 格间距 4、单格圆角 `sm`(12)`RemoteImage` 复用(loading tint 块 /
/// 失败图标兜底,缓存 key 已剥签名)。
/// - 超出折叠:最多显 9 格,[totalCount] 超过显示数时末格叠
/// `ink` 80% scrim + 白字「+N」20/w800(合成最亮白图 7.10:160% 档
/// 3.88:1 不达标弃用,§5.1 精算)。
/// - 单图折叠形态:Feed 卡片契约只带封面 + mediaCountFeedCard 裁剪),
/// [urls] 单元素而 [totalCount] > 1 时渲染 4:3 单格 + 右下「+N」角标
/// 胶囊(同 80% scrim 精算)。
///
/// 编辑态(「+」格 / 删除角标 / 进度覆盖层)见同文件 [PostMediaEditGrid]
/// (T3-17 发布页选图区)。
class PostMediaGrid extends StatelessWidget {
const PostMediaGrid({
required this.urls,
super.key,
this.totalCount,
this.onCellTap,
}) : assert(urls.length > 0, 'PostMediaGrid 至少一张图');
/// 可展示的图片 URL(Feed 卡片仅封面一张;详情页全量)。
final List<String> urls;
/// 实际总张数(缺省 = urls.length);大于可展示数时渲染「+N」。
final int? totalCount;
/// 格子点按(全屏浏览入口,T3-15 详情页接线)。
final ValueChanged<int>? onCellTap;
static const _maxCells = 9;
static const _spacing = 4.0;
int get _effectiveTotal => totalCount ?? urls.length;
/// 列数规则(§3.2):2、4 → 2 列;其余 → 3 列(1 图不走网格)。
static int columnsFor(int cellCount) =>
(cellCount == 2 || cellCount == 4) ? 2 : 3;
@override
Widget build(BuildContext context) {
if (urls.length == 1) {
return _CollapsedCover(
url: urls.first,
hiddenCount: _effectiveTotal - 1,
onTap: onCellTap == null ? null : () => onCellTap!(0),
);
}
final cellCount = urls.length > _maxCells ? _maxCells : urls.length;
final overflow = _effectiveTotal - cellCount;
final columns = columnsFor(cellCount);
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: _spacing,
crossAxisSpacing: _spacing,
),
itemCount: cellCount,
itemBuilder: (context, index) {
final isOverflowCell = overflow > 0 && index == cellCount - 1;
Widget cell = RemoteImage(
url: urls[index],
borderRadius: BorderRadius.circular(AppRadius.sm),
);
if (isOverflowCell) {
cell = Stack(
fit: StackFit.expand,
children: [
cell,
DecoratedBox(
decoration: BoxDecoration(
color: AppColors.ink.withAlpha(204),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Center(
child: Text(
'+$overflow',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
),
],
);
}
if (onCellTap != null) {
cell = InkWell(
onTap: () => onCellTap!(index),
borderRadius: BorderRadius.circular(AppRadius.sm),
child: cell,
);
}
return cell;
},
);
}
}
/// 编辑态九宫格(05 号规范 §3.2 编辑态;T3-17 发布页选图区)。
///
/// 与展示态 [PostMediaGrid] 同文件成组,但**独立成类**:展示态以
/// 「至少一张图 + URL 列表」为前提(构造断言),编辑态的常态却是
/// 「零张图 + 一个『+』格」,共用一个构造签名只会让两边都别扭。
///
/// - 固定 3 列(九宫格语义),格间距 4、圆角 `sm`(12)、1:1 `cover`;
/// 缩略图直接渲染选图原始字节([MediaUploadItem.previewBytes]
/// 不落磁盘、不走网络;解码失败回落 surfaceTint 块 + pets 图标)。
/// - 每格叠 [UploadProgressOverlay](六态映射四视觉态);可重试失败格
/// 整格点按重试,终态失败不给重试通栏。
/// - 删除角标:右上 22 圆 `ink` 80% 实底 + 白 close 14,触控热区 32。
/// - 「+」格:虚线 1.5px 圆角 12 + `add_photo_alternate_outlined` 24
/// `inkSoft`[canAdd] 为 false(满 9 张)时隐藏。
/// - 长按拖拽排序未做(05 §6 D9 可选项):删格即整组重排,position 由
/// [MediaUploader.buildAttachRequests] 按当前列表序重发号。
class PostMediaEditGrid extends StatelessWidget {
const PostMediaEditGrid({
required this.items,
super.key,
this.canAdd = true,
this.onAdd,
this.onRemove,
this.onRetry,
});
/// 当前上传项快照(顺序 = position 语义)。
final List<MediaUploadItem> items;
/// 是否渲染「+」格(剩余槽位 > 0)。
final bool canAdd;
final VoidCallback? onAdd;
/// 删格回调,参数为 [MediaUploadItem.localId]。
final ValueChanged<int>? onRemove;
/// 重试回调(仅可重试失败格触发),参数为 localId。
final ValueChanged<int>? onRetry;
static const _spacing = 4.0;
static const _columns = 3;
@override
Widget build(BuildContext context) {
final cellCount = items.length + (canAdd ? 1 : 0);
if (cellCount == 0) return const SizedBox.shrink();
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _columns,
mainAxisSpacing: _spacing,
crossAxisSpacing: _spacing,
),
itemCount: cellCount,
itemBuilder: (context, index) {
if (index == items.length) return _AddCell(onTap: onAdd);
return _EditCell(
item: items[index],
onRemove: onRemove == null
? null
: () => onRemove!(items[index].localId),
onRetry: onRetry == null || !items[index].retryable
? null
: () => onRetry!(items[index].localId),
);
},
);
}
}
class _EditCell extends StatelessWidget {
const _EditCell({required this.item, this.onRemove, this.onRetry});
final MediaUploadItem item;
final VoidCallback? onRemove;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Image.memory(
item.previewBytes,
fit: BoxFit.cover,
gaplessPlayback: true,
// 选图字节无法解码时的兜底(RemoteImage 同款形态)。
errorBuilder: (context, _, _) => const ColoredBox(
color: AppColors.surfaceTint,
child: Center(
child: Icon(Icons.pets, color: AppColors.muted, size: 20),
),
),
),
),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: UploadProgressOverlay(
phase: item.phase,
progress: item.progress,
onRetry: onRetry,
),
),
Positioned(
right: 0,
top: 0,
child: GestureDetector(
onTap: onRemove,
behavior: HitTestBehavior.opaque,
child: const Padding(
// 22 圆角标 + padding 撑到 32 触控热区。
padding: EdgeInsets.all(5),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color(0xCC3E2A1F),
),
child: SizedBox(
width: 22,
height: 22,
child: Center(
child: Icon(Icons.close, size: 14, color: Colors.white),
),
),
),
),
),
),
],
);
}
}
class _AddCell extends StatelessWidget {
const _AddCell({this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: CustomPaint(
painter: const _DashedBorderPainter(),
child: const Center(
child: Icon(
Icons.add_photo_alternate_outlined,
size: 24,
color: AppColors.inkSoft,
),
),
),
);
}
}
/// 「+」格的虚线圆角边框(Flutter 无内置虚线边框,按规范 1.5px 自绘)。
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter();
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = AppColors.border
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final path = Path()
..addRRect(
RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(AppRadius.sm),
),
);
const dash = 5.0;
const gap = 4.0;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
final next = distance + dash;
canvas.drawPath(
metric.extractPath(
distance,
next > metric.length ? metric.length : next,
),
paint,
);
distance = next + gap;
}
}
}
@override
bool shouldRepaint(_DashedBorderPainter oldDelegate) => false;
}
/// 单图折叠形态:4:3 圆角封面 + 右下「+N」胶囊角标。
class _CollapsedCover extends StatelessWidget {
const _CollapsedCover({
required this.url,
required this.hiddenCount,
this.onTap,
});
final String url;
final int hiddenCount;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
Widget cover = AspectRatio(
aspectRatio: 4 / 3,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
url: url,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
if (hiddenCount > 0)
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.ink.withAlpha(204),
borderRadius: BorderRadius.circular(AppRadius.pill),
),
child: Text(
'+$hiddenCount',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
if (onTap != null) {
cover = InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: cover,
);
}
return cover;
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底;
/// T2-14 为六类健康事件增补 feeding / grooming / measurement 三型,
/// 色族复用 05 §2 已审计的四个色对,仅图标与文案区分——对比度结论不变)。
enum RecordType {
weight,
vaccine,
deworming,
medical,
other,
feeding,
grooming,
measurement,
}
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
///
/// 对比度依据 05 §2 自查表:图标于 dot 底 4.167.39:1(非文字 ≥3:1)、
/// 文字于 dot 底 5.788.74:1AA ≥4.5:1)均达标。类型必须以
/// 「图标 + 文字」双通道呈现,不得单靠颜色区分(色盲可辨性)。
class RecordTypeStyle {
const RecordTypeStyle({
required this.icon,
required this.baseColor,
required this.iconColor,
required this.inkColor,
required this.label,
});
final IconData icon;
/// dot 底基色(渲染时 `withAlpha(20)` 即 8% 淡染)。
final Color baseColor;
final Color iconColor;
/// 文字/标签色,供 TagPill、详情页复用。
final Color inkColor;
final String label;
}
/// 五类映射常量(05 §2 表逐行对应)。
const Map<RecordType, RecordTypeStyle> recordTypeStyles = {
RecordType.weight: RecordTypeStyle(
icon: Icons.monitor_weight_outlined,
baseColor: AppColors.primary,
iconColor: AppColors.primaryStrong,
inkColor: AppColors.primaryDark,
label: '体重',
),
RecordType.vaccine: RecordTypeStyle(
icon: Icons.vaccines_outlined,
baseColor: AppColors.success,
iconColor: AppColors.successInk,
inkColor: AppColors.successInk,
label: '疫苗',
),
RecordType.deworming: RecordTypeStyle(
icon: Icons.pest_control,
baseColor: AppColors.accent,
iconColor: AppColors.accentDark,
inkColor: AppColors.accentDark,
label: '驱虫',
),
RecordType.medical: RecordTypeStyle(
icon: Icons.medical_services_outlined,
baseColor: AppColors.error,
iconColor: AppColors.error,
inkColor: AppColors.errorDark,
label: '就医',
),
RecordType.other: RecordTypeStyle(
icon: Icons.sticky_note_2_outlined,
baseColor: AppColors.muted,
iconColor: AppColors.inkSoft,
inkColor: AppColors.inkSoft,
label: '其他',
),
// —— T2-14 健康事件增补(色族复用已审计色对)——
RecordType.feeding: RecordTypeStyle(
icon: Icons.restaurant_outlined,
baseColor: AppColors.success,
iconColor: AppColors.successInk,
inkColor: AppColors.successInk,
label: '喂养',
),
RecordType.grooming: RecordTypeStyle(
icon: Icons.content_cut,
baseColor: AppColors.accent,
iconColor: AppColors.accentDark,
inkColor: AppColors.accentDark,
label: '洗护',
),
RecordType.measurement: RecordTypeStyle(
icon: Icons.straighten_outlined,
baseColor: AppColors.primary,
iconColor: AppColors.primaryStrong,
inkColor: AppColors.primaryDark,
label: '测量',
),
};
/// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。
enum RecordTypeDotSize {
/// 24:表单类型选择器内。
sm(24),
/// 40:时间线条目。
md(40),
/// 56:记录详情页头。
lg(56);
const RecordTypeDotSize(this.dimension);
final double dimension;
}
/// 记录类型圆标(05 号规范 §3.2):正圆淡染底 + 类型图标。
/// 无自身点击态(点击归属父容器)、无禁用态。
class RecordTypeDot extends StatelessWidget {
const RecordTypeDot({required this.type, required this.size, super.key});
final RecordType type;
final RecordTypeDotSize size;
@override
Widget build(BuildContext context) {
final style = recordTypeStyles[type]!;
return Container(
width: size.dimension,
height: size.dimension,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: style.baseColor.withAlpha(20),
),
child: Icon(style.icon, color: style.iconColor, size: size.dimension / 2),
);
}
}
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
/// 上传进度覆盖层(设计规范 05 号 §3.3):叠加在编辑态九宫格单格上,
/// 四视觉态映射 [MediaItemPhase]
///
/// | 视觉态 | 阶段 | 形态 |
/// | --- | --- | --- |
/// | 排队 | queued / compressing | ink 40% scrim + 「等待中」白字胶囊 |
/// | 上传中 | uploading / confirming | scrim + 白色环形进度 36 + 百分比胶囊 |
/// | 成功 | ready | scrim 150ms 淡出,无残留角标 |
/// | 失败 | failed | error 12% scrim + errorDark 图标 + 底部「重试」通栏 |
///
/// 失败态整格点按重试([onRetry],仅可重试失败传入);组件本身不含
/// 图片,由九宫格把它叠在缩略图上(Stack)。
class UploadProgressOverlay extends StatelessWidget {
const UploadProgressOverlay({
required this.phase,
this.progress = 0,
this.onRetry,
super.key,
});
final MediaItemPhase phase;
/// 直传进度 0..1uploading 态显示;confirming 定格 100%)。
final double progress;
/// 失败态整格点按回调;null 即失败不可重试(终态,只展示不响应)。
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return switch (phase) {
MediaItemPhase.queued || MediaItemPhase.compressing => _Scrim(
child: Center(child: _pill(const Text('等待中', style: _pillTextSm))),
),
MediaItemPhase.uploading || MediaItemPhase.confirming => _Scrim(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 36,
height: 36,
child: CircularProgressIndicator(
value: phase == MediaItemPhase.confirming ? 1.0 : progress,
color: Colors.white,
backgroundColor: Colors.white24,
strokeWidth: 3,
),
),
const SizedBox(height: 8),
_pill(
Text(
'${((phase == MediaItemPhase.confirming ? 1.0 : progress) * 100).round()}%',
style: _pillTextXs,
),
),
],
),
),
// 成功:scrim 150ms 淡出后不留任何角标。
MediaItemPhase.ready => const IgnorePointer(
child: AnimatedOpacity(
opacity: 0,
duration: Duration(milliseconds: 150),
child: _Scrim(child: SizedBox.expand()),
),
),
MediaItemPhase.failed => GestureDetector(
onTap: onRetry,
child: Container(
color: AppColors.error.withAlpha(31),
child: Column(
children: [
const Expanded(
child: Center(
child: Icon(
Icons.error_outline,
size: 24,
color: AppColors.errorDark,
),
),
),
if (onRetry != null)
Container(
width: double.infinity,
color: AppColors.errorDark,
padding: const EdgeInsets.symmetric(vertical: 4),
child: const Text(
'重试',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
};
}
static const _pillTextSm = TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
);
static const _pillTextXs = TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
);
/// 白字衬 ink 80% 胶囊底(对 40% scrim 上的合成底色对比度兜底,§3.3)。
Widget _pill(Widget child) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.ink.withAlpha(204),
borderRadius: const BorderRadius.all(Radius.circular(AppRadius.pill)),
),
child: child,
);
}
}
/// ink 40% 全格 scrim。
class _Scrim extends StatelessWidget {
const _Scrim({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(color: AppColors.ink.withAlpha(102), child: child);
}
}
-27
View File
@@ -82,33 +82,6 @@ const initialPet = PetProfile(
avatarUrl: petAvatar,
);
const initialVaccines = VaccineRecord(
completedDoses: 2,
totalDoses: 3,
items: [
VaccineItem(
id: 'v1',
name: '犬五联 第1针',
status: VaccineStatus.completed,
date: '2025-05-12',
),
VaccineItem(
id: 'v2',
name: '狂犬疫苗',
status: VaccineStatus.completed,
date: '2025-06-15',
),
VaccineItem(
id: 'v3',
name: '犬五联 加强针',
status: VaccineStatus.pending,
date: '待定',
),
],
reminderVaccine: '犬五联 加强针',
reminderDate: '2026-08-15',
);
const initialPosts = [
PostModel(
id: 'post1',
+2 -1
View File
@@ -147,7 +147,7 @@ class _RegisterPageState extends State<RegisterPage> {
try {
await widget.authRepository.register(
username: _usernameCtrl.text.trim(),
phone: _phoneCtrl.text.trim(),
phone: '+86${_phoneCtrl.text.trim()}',
password: _passwordCtrl.text,
);
TextInput.finishAutofillContext();
@@ -232,6 +232,7 @@ class _RegisterPageState extends State<RegisterPage> {
label: '手机号',
controller: _phoneCtrl,
prefixIcon: Icons.phone_iphone_rounded,
prefixText: '+86 ',
errorText: _phoneError,
enabled: !_submitting,
keyboardType: TextInputType.phone,
@@ -0,0 +1,336 @@
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/toggle_sync.dart';
/// Feed 首屏四态(同 pets 先例)。
enum FeedPhase { initial, loading, ready, error }
/// 尾部加载更多三态(游标累积流新增,pets 无此并发点)。
enum LoadMorePhase { idle, loading, error }
/// community feature 状态控制器(03 号评估 §2 分层:
/// Page/Widget → CommunityController → CommunityRepository → ApiClient)。
///
/// Tab 级单例(app.dart 装配注入主壳):Feed 是游标累积流,且详情页与
/// 首页共享同一份帖子内存副本(点赞状态跨页一致),不做页面级 state。
/// 评论列表只属详情页,按「页面级状态按页自建」纪律经 [repository]
/// 自取,不膨胀本控制器。服务端是唯一事实来源,内存副本仅作展示缓存。
class CommunityController extends ChangeNotifier {
CommunityController({required this._repository, this._interactionAnalytics}) {
_likeSync = ToggleSync(
read: (id) {
final post = _postCache[id];
if (post != null) {
return ToggleReading(active: post.likedByMe, count: post.likeCount);
}
final card = _cardOrNull(id);
if (card == null) return null;
return ToggleReading(active: card.likedByMe, count: card.likeCount);
},
write: (id, active, count) =>
_writeInteraction(id, likedByMe: active, likeCount: count),
send: (id, target) async {
final state = target
? await _repository.likePost(id)
: await _repository.unlikePost(id);
// 成功响应后上报(06 §1.4 口径;乐观翻转与失败均不报)。
final source = _likeSources[id] ?? InteractionSource.feed;
target
? _interactionAnalytics?.postLiked(source: source)
: _interactionAnalytics?.postUnliked(source: source);
return ToggleOutcome(active: state.liked, count: state.likeCount);
},
generation: () => _generation,
onError: _onToggleError,
);
_bookmarkSync = ToggleSync(
read: (id) {
final post = _postCache[id];
if (post != null) {
return ToggleReading(
active: post.bookmarkedByMe,
count: post.bookmarkCount,
);
}
final card = _cardOrNull(id);
if (card == null) return null;
return ToggleReading(
active: card.bookmarkedByMe,
count: card.bookmarkCount,
);
},
write: (id, active, count) =>
_writeInteraction(id, bookmarkedByMe: active, bookmarkCount: count),
send: (id, target) async {
final state = target
? await _repository.bookmarkPost(id)
: await _repository.unbookmarkPost(id);
final source = _bookmarkSources[id] ?? InteractionSource.feed;
target
? _interactionAnalytics?.postFavorited(source: source)
: _interactionAnalytics?.postUnfavorited(source: source);
return ToggleOutcome(
active: state.bookmarked,
count: state.bookmarkCount,
);
},
generation: () => _generation,
onError: _onToggleError,
);
}
final CommunityRepository _repository;
final CommunityInteractionAnalytics? _interactionAnalytics;
/// 各帖最近一次 toggle 的触点来源(Feed 卡片 / 详情页共享同一实例,
/// 成功响应上报时按发起触点归因)。
final Map<String, InteractionSource> _likeSources = {};
final Map<String, InteractionSource> _bookmarkSources = {};
/// 页面级状态(评论列表、我的帖子、收藏页等)按页直接经仓库取数。
CommunityRepository get repository => _repository;
late final ToggleSync _likeSync;
late final ToggleSync _bookmarkSync;
FeedPhase _phase = FeedPhase.initial;
LoadMorePhase _loadMorePhase = LoadMorePhase.idle;
List<FeedCard> _feed = const [];
String? _nextCursor;
bool _hasMore = false;
/// 首屏加载失败(error 态时非 null)。
ApiException? _lastError;
/// 刷新失败但旧列表被保留时的错误(页面 SnackBar 轻提示后消费)。
ApiException? _refreshError;
/// 加载更多失败(尾部重试条渲染依据)。
ApiException? _loadMoreError;
/// 最近一次点赞/收藏对账失败(T3-15/16 SnackBar 消费)。
ApiException? _toggleError;
/// 刷新代次:整体替换列表后,在途旧代次响应(尾页 / 互动对账)一律丢弃。
int _generation = 0;
/// 详情内存副本(详情页与 Feed 卡片互动状态同源)。
final Map<String, Post> _postCache = {};
bool _disposed = false;
FeedPhase get phase => _phase;
LoadMorePhase get loadMorePhase => _loadMorePhase;
/// 累积的多页 Feed 缓存(服务端 published_at DESC, id DESC 原样保留)。
List<FeedCard> get feed => _feed;
bool get hasMore => _hasMore;
/// ready 且列表为空 → 空态。
bool get isEmpty => _phase == FeedPhase.ready && _feed.isEmpty;
ApiException? get lastError => _lastError;
ApiException? get refreshError => _refreshError;
ApiException? get loadMoreError => _loadMoreError;
ApiException? get toggleError => _toggleError;
/// 详情内存副本(进入详情页先渲染缓存,再 [getPost] 拉新)。
Post? cachedPost(String postId) => _postCache[postId];
/// 首屏加载 / 下拉刷新:丢弃游标从头拉第一页,成功后**整体替换**累积列表。
/// 刷新失败保留旧列表(不清空不闪空态),错误经 [refreshError] 轻提示;
/// 首屏(空列表)失败收敛为 error 态供页面渲染 + retry。
Future<void> refresh() async {
_generation += 1;
final generation = _generation;
_lastError = null;
_refreshError = null;
_loadMorePhase = LoadMorePhase.idle;
_loadMoreError = null;
if (_feed.isEmpty) _phase = FeedPhase.loading;
_notify();
try {
final page = await _repository.getFeed();
if (generation != _generation) return; // 期间又发生过刷新/登出。
_feed = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_phase = FeedPhase.ready;
} on ApiException catch (error) {
if (generation != _generation) return;
if (_feed.isEmpty) {
_lastError = error;
_phase = FeedPhase.error;
} else {
_refreshError = error;
}
}
_notify();
}
/// 滚动近底加载下一页:携带上一页 nextCursor(keyset 翻页不丢不重)。
/// 失败置 [LoadMorePhase.error] 渲染尾部重试条;期间发生过刷新的
/// 旧代次响应直接丢弃(避免「刷新后旧尾页追加」的重复/错位)。
Future<void> loadMore() async {
if (_phase != FeedPhase.ready ||
!_hasMore ||
_loadMorePhase == LoadMorePhase.loading) {
return;
}
final generation = _generation;
_loadMorePhase = LoadMorePhase.loading;
_loadMoreError = null;
_notify();
try {
final page = await _repository.getFeed(cursor: _nextCursor);
if (generation != _generation) return; // 旧代次尾页,丢弃。
_feed = [..._feed, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loadMorePhase = LoadMorePhase.idle;
} on ApiException catch (error) {
if (generation != _generation) return;
_loadMoreError = error;
_loadMorePhase = LoadMorePhase.error;
}
_notify();
}
/// 拉取帖子详情并同步内存副本(Feed 卡片互动字段一并回写)。
Future<Post> getPost(String postId) async {
final post = await _repository.getPost(postId);
_postCache[postId] = post;
_syncCardFromPost(post);
_notify();
return post;
}
/// 点赞/取消点赞(乐观翻转,终态由 [ToggleSync] 对账收敛,不外抛)。
/// [source] 为触点来源(埋点归因),Feed 卡片缺省 feed、详情页传
/// post_detail。
void toggleLike(
String postId, {
InteractionSource source = InteractionSource.feed,
}) {
_likeSources[postId] = source;
_likeSync.toggle(postId);
}
/// 收藏/取消收藏(与点赞同构)。
void toggleBookmark(
String postId, {
InteractionSource source = InteractionSource.feed,
}) {
_bookmarkSources[postId] = source;
_bookmarkSync.toggle(postId);
}
/// 评论创建/删除后的计数调整(详情页与 Feed 卡片同源写入,
/// 服务端 comment_count 由写侧同事务维护,本地只做展示对齐)。
void adjustCommentCount(String postId, int delta) {
final current =
_postCache[postId]?.commentCount ?? _cardOrNull(postId)?.commentCount;
if (current == null) return;
final next = current + delta;
_writeInteraction(postId, commentCount: next < 0 ? 0 : next);
}
/// 消费一次性 toggle 错误(SnackBar 展示后清除)。
void clearToggleError() => _toggleError = null;
/// 登出清空:回 initial 态、清多页缓存与详情副本、丢弃全部在途链,
/// 避免上一账号数据跨会话泄漏;重登后主壳重建,Feed 页重新触发 [refresh]。
void reset() {
_generation += 1;
_phase = FeedPhase.initial;
_loadMorePhase = LoadMorePhase.idle;
_feed = const [];
_nextCursor = null;
_hasMore = false;
_lastError = null;
_refreshError = null;
_loadMoreError = null;
_toggleError = null;
_postCache.clear();
_likeSources.clear();
_bookmarkSources.clear();
_likeSync.reset();
_bookmarkSync.reset();
_notify();
}
FeedCard? _cardOrNull(String postId) {
for (final card in _feed) {
if (card.id == postId) return card;
}
return null;
}
/// 互动字段统一写入口:详情副本与 Feed 卡片同步更新后 notify(同帧反馈)。
void _writeInteraction(
String postId, {
bool? likedByMe,
int? likeCount,
bool? bookmarkedByMe,
int? bookmarkCount,
int? commentCount,
}) {
final post = _postCache[postId];
if (post != null) {
_postCache[postId] = post.copyWithInteraction(
likedByMe: likedByMe,
likeCount: likeCount,
bookmarkedByMe: bookmarkedByMe,
bookmarkCount: bookmarkCount,
commentCount: commentCount,
);
}
final index = _feed.indexWhere((card) => card.id == postId);
if (index != -1) {
_feed = [..._feed]
..[index] = _feed[index].copyWithInteraction(
likedByMe: likedByMe,
likeCount: likeCount,
bookmarkedByMe: bookmarkedByMe,
bookmarkCount: bookmarkCount,
commentCount: commentCount,
);
}
if (post != null || index != -1) _notify();
}
void _syncCardFromPost(Post post) {
final index = _feed.indexWhere((card) => card.id == post.id);
if (index == -1) return;
_feed = [..._feed]
..[index] = _feed[index].copyWithInteraction(
likedByMe: post.likedByMe,
likeCount: post.likeCount,
commentCount: post.commentCount,
bookmarkedByMe: post.bookmarkedByMe,
bookmarkCount: post.bookmarkCount,
);
}
void _onToggleError(String postId, Object error) {
if (error is ApiException) {
_toggleError = error;
} else {
_toggleError = ApiNetworkException('$error');
}
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,59 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
/// 降级作者([AuthorSummary.isDegraded],资料暂不可得或已注销)的
/// 统一占位名(05 号规范:占位头像 + 默认名,客户端不做昵称回退拼装)。
const degradedAuthorName = '宠友';
/// 作者展示名:正常路径 nickname 恒非空(服务端已回退 username);
/// 降级形态统一 [degradedAuthorName]。
String authorDisplayName(AuthorSummary author) =>
author.nickname ?? degradedAuthorName;
/// Feed 卡片元信息的相对时间(正典「2 小时前」语言)。
/// [now] 注入口供测试锁定时钟。
String feedRelativeTime(DateTime time, {DateTime? now}) {
final reference = now ?? DateTime.now();
final difference = reference.difference(time.toLocal());
if (difference.inMinutes < 1) return '刚刚';
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
if (difference.inDays < 1) return '${difference.inHours} 小时前';
if (difference.inDays < 7) return '${difference.inDays} 天前';
final local = time.toLocal();
if (local.year == reference.year) return '${local.month}${local.day}';
return '${local.year}${local.month}${local.day}';
}
/// Feed 加载失败的用户话术(pets 域 petLoadErrorMessage 同构;
/// 服务端原始 message 不上屏)。
String feedLoadErrorMessage(ApiException? error) => switch (error) {
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '动态加载失败,请稍后重试',
};
/// 发布失败的用户话术(T3-17,三条关键语义各自可辨;服务端原始
/// message 不上屏):
///
/// - 40905 同键异 payload:提交标识已被页面重置,再点一次即可;
/// - 42203 asset 未 ready:等图片传完再发;
/// - 网络失败:可重试(同键重放,服务端不会重复建帖)。
String postPublishErrorMessage(ApiException? error) => switch (error) {
IdempotencyMismatchException _ => '提交内容与上次重试不一致,已重置提交标识,请再点一次「发布」',
MediaNotReadyException _ => '有图片还没上传完成,请等图片就绪后再发布',
PostNotFoundException _ => '草稿已不存在(可能已在别处删除),请重新发布',
PostVersionConflictException _ => '草稿在别处被修改过,请重试发布',
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
ApiBusinessException(:final code) when code == ApiCodes.paramError =>
'内容不符合发布要求,请修改后重试',
_ => '发布失败,请稍后重试',
};
/// 草稿保存失败的用户话术(发布页 SnackBar)。
String draftSaveErrorMessage(ApiException? error) => switch (error) {
ApiNetworkException _ => '网络异常,草稿未保存,请重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '草稿保存失败,请重试',
};
@@ -0,0 +1,102 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
/// community / media 域类型化业务异常(契约 v1.3.0 定型的 9 个新错误码,
/// 20 号收口报告 §140902 乐观锁与 pets 域共码,本域映射为独立类型)。
/// 全部继承 [ApiBusinessException],既有按基类捕获的通用错误处理不受影响。
/// 40301:对可见帖子/评论无相应操作权限(改删他人已发布帖、
/// 删他人可见评论——含帖主删他人评论,D3-7 首版不做)。
final class PostAccessDeniedException extends ApiBusinessException {
const PostAccessDeniedException({required super.message})
: super(code: ApiCodes.postAccessDenied);
}
/// 40403:帖子不存在 / 已软删 / hidden/archived(含作者)/ 他人 draft
/// (防枚举,全部情况响应完全一致;互动路径上含作者本人草稿)。
final class PostNotFoundException extends ApiBusinessException {
const PostNotFoundException({required super.message})
: super(code: ApiCodes.postNotFound);
}
/// 40404:评论不存在、已删或所属帖子不可见(防枚举合并)。
final class CommentNotFoundException extends ApiBusinessException {
const CommentNotFoundException({required super.message})
: super(code: ApiCodes.commentNotFound);
}
/// 40405media asset 不存在、非本人所有或已删(防枚举合并)。
final class MediaAssetNotFoundException extends ApiBusinessException {
const MediaAssetNotFoundException({required super.message})
: super(code: ApiCodes.mediaNotFound);
}
/// 40406:目标用户不存在或已注销(合并不泄露成因)。
final class CommunityUserNotFoundException extends ApiBusinessException {
const CommunityUserNotFoundException({required super.message})
: super(code: ApiCodes.communityUserNotFound);
}
/// 40902:帖子编辑乐观锁版本冲突(version 过期)。
/// 客户端处理:刷新详情取新 version 后重提。
final class PostVersionConflictException extends ApiBusinessException {
const PostVersionConflictException({required super.message})
: super(code: ApiCodes.versionConflict);
}
/// 40905:同 Idempotency-Key 不同 payload(规范化 request_hash 不符)。
/// 客户端每次逻辑提交应换新键,重试间保持不变。
final class IdempotencyMismatchException extends ApiBusinessException {
const IdempotencyMismatchException({required super.message})
: super(code: ApiCodes.idempotencyKeyMismatch);
}
/// 42203:引用了本人所有但非 readyuploading/failed)状态的 asset。
final class MediaNotReadyException extends ApiBusinessException {
const MediaNotReadyException({required super.message})
: super(code: ApiCodes.mediaNotReady);
}
/// 42204:自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。
final class SelfFollowException extends ApiBusinessException {
const SelfFollowException({required super.message})
: super(code: ApiCodes.selfFollow);
}
/// 42205:上传状态不允许确认——对象未上传(保持 uploading 可重试)、
/// 大小/类型不符(置 failed 终态)、failed 态再确认(已 ready 幂等 200 除外)。
final class MediaUploadStateException extends ApiBusinessException {
const MediaUploadStateException({required super.message})
: super(code: ApiCodes.mediaUploadStateInvalid);
}
/// 把通用业务异常按 community/media 域错误码升格为类型化异常;
/// 未覆盖的码(40000 参数错误、40401 宠物防枚举等)原样返回,沿用通用处理。
ApiBusinessException mapCommunityBusinessException(ApiBusinessException error) {
return switch (error.code) {
ApiCodes.postAccessDenied => PostAccessDeniedException(
message: error.message,
),
ApiCodes.postNotFound => PostNotFoundException(message: error.message),
ApiCodes.commentNotFound => CommentNotFoundException(
message: error.message,
),
ApiCodes.mediaNotFound => MediaAssetNotFoundException(
message: error.message,
),
ApiCodes.communityUserNotFound => CommunityUserNotFoundException(
message: error.message,
),
ApiCodes.versionConflict => PostVersionConflictException(
message: error.message,
),
ApiCodes.idempotencyKeyMismatch => IdempotencyMismatchException(
message: error.message,
),
ApiCodes.mediaNotReady => MediaNotReadyException(message: error.message),
ApiCodes.selfFollow => SelfFollowException(message: error.message),
ApiCodes.mediaUploadStateInvalid => MediaUploadStateException(
message: error.message,
),
_ => error,
};
}
@@ -0,0 +1,137 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
/// 互动域埋点强类型封装(06 号规划 §1.4 字典 v3 互动八事件;后端白名单
/// 已随 api dev@8089c06 就绪,22 号报告 §1)。沿 pet/feed 域惯例:枚举
/// 编译期锁死,业务代码禁止手拼事件名与属性;只记行为不记内容
/// (隐私红线:postId/commentId 等内容 ID 一律不进 props)。
///
/// 点赞/收藏/关注**不埋失败**(06 §1.4 取舍:幂等写入单点交互,失败率
/// 靠服务端错误率观测);`comment_create_started` 被字典锁死 unknown
/// 不得上报(22 号 §1 末段)。
/// 互动触点来源(06 §1.4 source 枚举)。M3 接 feed / post_detail
/// user_profile / follow_list 随后续页面启用。
enum InteractionSource {
feed('feed'),
postDetail('post_detail'),
userProfile('user_profile'),
followList('follow_list');
const InteractionSource(this.value);
final String value;
}
/// 评论创建失败原因(06 §1.4 失败枚举基底)。网络归并口径同 pet/feed 域:
/// 断网/超时/5xx 均并入 network_errorserver_error 保留兜底。
enum CommentCreateFailureReason {
validationError('validation_error'),
notFound('not_found'),
rateLimited('rate_limited'),
networkError('network_error'),
serverError('server_error');
const CommentCreateFailureReason(this.value);
final String value;
}
/// 类型化异常 → 评论失败原因;会话失效返回 null(应用即将回登录页,
/// 不作为评论失败上报,feed 域同款口径)。
CommentCreateFailureReason? commentCreateFailureReasonOf(ApiException error) =>
switch (error) {
ApiNetworkException _ => CommentCreateFailureReason.networkError,
ApiRateLimitException _ => CommentCreateFailureReason.rateLimited,
SessionExpiredException _ => null,
ApiBusinessException(:final code) => switch (code) {
ApiCodes.paramError => CommentCreateFailureReason.validationError,
ApiCodes.postNotFound ||
ApiCodes.commentNotFound ||
ApiCodes.communityUserNotFound => CommentCreateFailureReason.notFound,
_ => CommentCreateFailureReason.serverError,
},
};
/// 正文规模分桶(06 §1.3 隐私红线 1:不报精确字数)。
/// empty / short(≤50) / medium(51500) / long(>500)。
String textLengthBucketOf(int length) {
if (length <= 0) return 'empty';
if (length <= 50) return 'short';
if (length <= 500) return 'medium';
return 'long';
}
class CommunityInteractionAnalytics {
CommunityInteractionAnalytics(this._track);
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
final TrackEventFn _track;
/// 点赞成功响应后(乐观翻转本身不报;单飞合并链每个实际抵达服务端
/// 并成功的状态变更各报一条,与「成功响应后」的字典口径一致)。
void postLiked({required InteractionSource source}) {
_track('post_liked', {'source': source.value});
}
/// 取消点赞成功响应后。
void postUnliked({required InteractionSource source}) {
_track('post_unliked', {'source': source.value});
}
/// 收藏成功响应后。
void postFavorited({required InteractionSource source}) {
_track('post_favorited', {'source': source.value});
}
/// 取消收藏成功响应后。
void postUnfavorited({required InteractionSource source}) {
_track('post_unfavorited', {'source': source.value});
}
/// 评论提交成功响应后。
///
/// [durationMs]:本次输入会话(首个字符输入)→ 成功响应的耗时
/// (评论不设 started 事件,时长随成功事件带出);
/// [textLength] 经 [textLengthBucketOf] 分桶后上报,精确字数不出端。
void commentCreateSucceeded({
required int durationMs,
required bool isReply,
required int textLength,
}) {
_track('comment_create_succeeded', {
'durationMs': durationMs,
'isReply': isReply,
'textLengthBucket': textLengthBucketOf(textLength),
});
}
/// 评论提交失败。
///
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
/// 业务码推导(`code ~/ 100`pet 域同款口径);[attemptSeq] 为本次
/// 输入会话内第几次提交尝试(从 1 起,成功或清空输入后重置)。
void commentCreateFailed({
required CommentCreateFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('comment_create_failed', {
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
/// 关注成功响应后。
void userFollowed({required InteractionSource source}) {
_track('user_followed', {'source': source.value});
}
/// 取关成功响应后。
void userUnfollowed({required InteractionSource source}) {
_track('user_unfollowed', {'source': source.value});
}
}
@@ -0,0 +1,630 @@
/// community / media 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.3.0
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
/// 以便契约漂移在测试期暴露而非静默吞掉)。
library;
export 'package:patbond_flutter/core/models/cursor_page.dart';
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
for (final value in values) {
if (value.name == raw) return value;
}
throw FormatException('未知的 $field 取值:$raw');
}
DateTime? _dateTimeOrNull(Object? value) =>
value == null ? null : DateTime.parse(value as String);
/// 帖子分类。ai_creation 为 M4 预留值,仅读侧出现(M3 提交即 400/40000)。
enum PostCategory {
general('general'),
help('help'),
aiCreation('ai_creation');
const PostCategory(this.wire);
/// 契约线上取值(aiCreation 的枚举名与线上 snake_case 不同,序列化走本值)。
final String wire;
static PostCategory fromJson(String value) {
for (final category in values) {
if (category.wire == value) return category;
}
throw FormatException('未知的 category 取值:$value');
}
}
/// 帖子状态。hidden/archived(运营态)永不出现在响应(对作者与他人一律
/// 404/40403),枚举保持两值。
enum PostStatus {
draft,
published;
static PostStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 帖子可见性。M3 恒 publicfollowers/private 语义后置,字段保留)。
enum PostVisibility {
public;
static PostVisibility fromJson(String value) =>
_enumFromJson(values, value, 'visibility');
}
/// media asset 类型。M3 仅 image(视频后置,video/document 为向后新增预留)。
enum MediaKind {
image;
static MediaKind fromJson(String value) =>
_enumFromJson(values, value, 'kind');
}
/// 上传用途白名单(M3 定型仅 post_image,决定 objectKey 前缀)。
enum MediaPurpose {
postImage('post_image');
const MediaPurpose(this.wire);
final String wire;
}
/// media asset 状态。deleted 态对外恒 404/40405,不出现在响应。
enum MediaAssetStatus {
uploading,
ready,
failed;
static MediaAssetStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 作者公开摘要(D3-9 方案 B)。正常路径 nickname 恒非空(空昵称由服务端
/// 回退为 username,客户端不做回退拼装);nickname 与 avatarUrl 同为 null
/// 即「降级/墓碑」形态(作者资料暂不可得或已注销),客户端只需一种占位逻辑。
class AuthorSummary {
const AuthorSummary({
required this.userId,
required this.nickname,
required this.avatarUrl,
});
factory AuthorSummary.fromJson(Map<String, dynamic> json) {
return AuthorSummary(
userId: json['userId'] as String,
nickname: json['nickname'] as String?,
// 时效性预签名 GET URL,每次响应现签,不得持久化、过期即重取。
avatarUrl: json['avatarUrl'] as String?,
);
}
final String userId;
final String? nickname;
final String? avatarUrl;
/// 降级/墓碑形态(id-only):页面渲染统一占位。
bool get isDegraded => nickname == null && avatarUrl == null;
}
/// 帖子挂接的一张图(响应形态)。url 为时效性预签名 GET(TTL 默认 1 小时),
/// 每次响应现签,客户端不得持久化、过期即重取。
class PostMediaItem {
const PostMediaItem({
required this.assetId,
required this.position,
required this.isCover,
required this.url,
required this.widthPx,
required this.heightPx,
required this.caption,
});
factory PostMediaItem.fromJson(Map<String, dynamic> json) {
return PostMediaItem(
assetId: json['assetId'] as String,
position: json['position'] as int,
isCover: json['isCover'] as bool,
url: json['url'] as String,
widthPx: json['widthPx'] as int?,
heightPx: json['heightPx'] as int?,
caption: json['caption'] as String?,
);
}
final String assetId;
final int position;
final bool isCover;
final String url;
final int? widthPx;
final int? heightPx;
final String? caption;
}
/// 帖子挂接的一张图(请求形态)。position 全给或全不给(全给须恰为 0..n-1
/// 连续不重复,混合 400/40000);isCover 至多一个 truecaption trim 后 ≤300。
class PostMediaAttachRequest {
const PostMediaAttachRequest({
required this.assetId,
this.position,
this.isCover,
this.caption,
});
final String assetId;
final int? position;
final bool? isCover;
final String? caption;
Map<String, Object?> toJson() => {
'assetId': assetId,
if (position != null) 'position': position,
if (isCover != null) 'isCover': isCover,
if (caption != null) 'caption': caption,
};
}
/// 创建帖子请求。content 必填(纯文字帖合法,media 空数组或缺席);
/// status=published 即创建即发布(服务端写 publishedAt)。
class CreatePostRequest {
const CreatePostRequest({
required this.content,
this.title,
this.category,
this.status,
this.petId,
this.media,
});
final String content;
final String? title;
final PostCategory? category;
final PostStatus? status;
final String? petId;
final List<PostMediaAttachRequest>? media;
Map<String, Object?> toJson() => {
'content': content,
if (title != null) 'title': title,
if (category != null) 'category': category!.wire,
if (status != null) 'status': status!.name,
if (petId != null) 'petId': petId,
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
};
}
/// 编辑帖子 / 发布草稿请求(部分更新:缺席字段不变,不支持清空回 null;
/// version 乐观锁必带)。
///
/// - [publish]`status: published` 状态迁移(draft→published 唯一开放迁移;
/// 对已发布帖重复提交为幂等 no-op,弱网重发不报错)。
/// - [media] 三态:null = 缺席不动;`[]` = 清空为纯文字帖;非空 = 整组替换。
class UpdatePostRequest {
const UpdatePostRequest({
required this.version,
this.title,
this.content,
this.category,
this.petId,
this.publish = false,
this.media,
});
final int version;
final String? title;
final String? content;
final PostCategory? category;
final String? petId;
final bool publish;
final List<PostMediaAttachRequest>? media;
Map<String, Object?> toJson() => {
'version': version,
if (title != null) 'title': title,
if (content != null) 'content': content,
if (category != null) 'category': category!.wire,
if (petId != null) 'petId': petId,
if (publish) 'status': PostStatus.published.name,
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
};
}
/// 帖子完整形态(详情 / 我的帖子列表 / 写响应共用)。
/// 「内容是否编辑过」以 version 为准(互动计数维护亦会推动 updatedAt)。
class Post {
const Post({
required this.id,
required this.author,
required this.petId,
required this.category,
required this.title,
required this.content,
required this.status,
required this.visibility,
required this.media,
required this.likeCount,
required this.commentCount,
required this.bookmarkCount,
required this.likedByMe,
required this.bookmarkedByMe,
required this.createdAt,
required this.updatedAt,
required this.publishedAt,
required this.version,
});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
petId: json['petId'] as String?,
category: PostCategory.fromJson(json['category'] as String),
title: json['title'] as String?,
content: json['content'] as String,
status: PostStatus.fromJson(json['status'] as String),
visibility: PostVisibility.fromJson(json['visibility'] as String),
media: (json['media'] as List)
.map((item) => PostMediaItem.fromJson(item as Map<String, dynamic>))
.toList(),
likeCount: json['likeCount'] as int,
commentCount: json['commentCount'] as int,
bookmarkCount: json['bookmarkCount'] as int,
likedByMe: json['likedByMe'] as bool,
bookmarkedByMe: json['bookmarkedByMe'] as bool,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
// 仅 published 非空(发布时恰写一次)。
publishedAt: _dateTimeOrNull(json['publishedAt']),
version: json['version'] as int,
);
}
final String id;
final AuthorSummary author;
final String? petId;
final PostCategory category;
final String? title;
final String content;
final PostStatus status;
final PostVisibility visibility;
final List<PostMediaItem> media;
final int likeCount;
final int commentCount;
final int bookmarkCount;
final bool likedByMe;
final bool bookmarkedByMe;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? publishedAt;
final int version;
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
Post copyWithInteraction({
int? likeCount,
int? commentCount,
int? bookmarkCount,
bool? likedByMe,
bool? bookmarkedByMe,
}) {
return Post(
id: id,
author: author,
petId: petId,
category: category,
title: title,
content: content,
status: status,
visibility: visibility,
media: media,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
likedByMe: likedByMe ?? this.likedByMe,
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
createdAt: createdAt,
updatedAt: updatedAt,
publishedAt: publishedAt,
version: version,
);
}
}
/// Feed / 收藏列表卡片形态(较 Post 裁剪:只带 coverImage + mediaCount
/// 全文恒走帖子详情端点)。publishedAt 恒非空(谓词只放行 published)。
class FeedCard {
const FeedCard({
required this.id,
required this.author,
required this.category,
required this.title,
required this.contentPreview,
required this.coverImage,
required this.mediaCount,
required this.likeCount,
required this.commentCount,
required this.bookmarkCount,
required this.likedByMe,
required this.bookmarkedByMe,
required this.publishedAt,
});
factory FeedCard.fromJson(Map<String, dynamic> json) {
final cover = json['coverImage'];
return FeedCard(
id: json['id'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
category: PostCategory.fromJson(json['category'] as String),
title: json['title'] as String?,
contentPreview: json['contentPreview'] as String,
// 封面 = 库中唯一 is_cover 行;纯文字帖为 null。
coverImage: cover == null
? null
: PostMediaItem.fromJson(cover as Map<String, dynamic>),
mediaCount: json['mediaCount'] as int,
likeCount: json['likeCount'] as int,
commentCount: json['commentCount'] as int,
bookmarkCount: json['bookmarkCount'] as int,
likedByMe: json['likedByMe'] as bool,
bookmarkedByMe: json['bookmarkedByMe'] as bool,
publishedAt: DateTime.parse(json['publishedAt'] as String),
);
}
final String id;
final AuthorSummary author;
final PostCategory category;
final String? title;
final String contentPreview;
final PostMediaItem? coverImage;
final int mediaCount;
final int likeCount;
final int commentCount;
final int bookmarkCount;
final bool likedByMe;
final bool bookmarkedByMe;
final DateTime publishedAt;
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
FeedCard copyWithInteraction({
int? likeCount,
int? commentCount,
int? bookmarkCount,
bool? likedByMe,
bool? bookmarkedByMe,
}) {
return FeedCard(
id: id,
author: author,
category: category,
title: title,
contentPreview: contentPreview,
coverImage: coverImage,
mediaCount: mediaCount,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
likedByMe: likedByMe ?? this.likedByMe,
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
publishedAt: publishedAt,
);
}
}
/// 创建评论请求。content trim 后 1~2000replyToUserId 可选 @ 回复目标
/// (单层平铺,无楼中楼)。
class CreateCommentRequest {
const CreateCommentRequest({required this.content, this.replyToUserId});
final String content;
final String? replyToUserId;
Map<String, Object?> toJson() => {
'content': content,
if (replyToUserId != null) 'replyToUserId': replyToUserId,
};
}
/// 评论(M3 无评论编辑,不带 updatedAt)。
class PostComment {
const PostComment({
required this.id,
required this.postId,
required this.author,
required this.replyToUser,
required this.content,
required this.createdAt,
});
factory PostComment.fromJson(Map<String, dynamic> json) {
final replyTo = json['replyToUser'];
return PostComment(
id: json['id'] as String,
postId: json['postId'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
// @ 回复目标公开摘要(含降级 id-only 形态);非回复为 null。
replyToUser: replyTo == null
? null
: AuthorSummary.fromJson(replyTo as Map<String, dynamic>),
content: json['content'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final String postId;
final AuthorSummary author;
final AuthorSummary? replyToUser;
final String content;
final DateTime createdAt;
}
/// 创建上传请求(两步上传第一步)。mimeType 白名单
/// image/jpeg|png|webpbyteSize ≤ 1048576010 MiB,服务端配置项);
/// sha256 可选(64 位小写 hex,M3 照收照存不核验)。
class CreateMediaUploadRequest {
const CreateMediaUploadRequest({
required this.kind,
required this.purpose,
required this.mimeType,
required this.byteSize,
this.sha256,
});
final MediaKind kind;
final MediaPurpose purpose;
final String mimeType;
final int byteSize;
final String? sha256;
Map<String, Object?> toJson() => {
'kind': kind.name,
'purpose': purpose.wire,
'mimeType': mimeType,
'byteSize': byteSize,
if (sha256 != null) 'sha256': sha256,
};
}
/// 预签名直传凭据。凭据(uploadUrl 含签名)TTL 默认 10 分钟,过期后
/// 重新创建上传;直传必须原样携带 requiredHeadersContent-Type 已签进
/// 签名,改动即被存储侧拒绝)。凭据会过期,不得持久化。
class MediaUploadCredentials {
const MediaUploadCredentials({
required this.assetId,
required this.uploadUrl,
required this.method,
required this.requiredHeaders,
required this.expiresAt,
});
factory MediaUploadCredentials.fromJson(Map<String, dynamic> json) {
return MediaUploadCredentials(
assetId: json['assetId'] as String,
uploadUrl: json['uploadUrl'] as String,
method: json['method'] as String,
requiredHeaders: (json['requiredHeaders'] as Map<String, dynamic>).map(
(key, value) => MapEntry(key, value as String),
),
expiresAt: DateTime.parse(json['expiresAt'] as String),
);
}
final String assetId;
final String uploadUrl;
/// 契约定型恒为 PUT(enum 单值;直传时按本值发起请求)。
final String method;
final Map<String, String> requiredHeaders;
final DateTime expiresAt;
}
/// media assetconfirm 后的可引用形态)。url 仅 ready 态非空——时效性
/// 预签名 GET(TTL 默认 1 小时),每次响应现签,不得持久化、过期即重取。
class MediaAsset {
const MediaAsset({
required this.id,
required this.kind,
required this.purpose,
required this.mimeType,
required this.byteSize,
required this.widthPx,
required this.heightPx,
required this.status,
required this.url,
required this.readyAt,
required this.createdAt,
});
factory MediaAsset.fromJson(Map<String, dynamic> json) {
return MediaAsset(
id: json['id'] as String,
kind: MediaKind.fromJson(json['kind'] as String),
purpose: json['purpose'] as String,
mimeType: json['mimeType'] as String,
byteSize: json['byteSize'] as int?,
widthPx: json['widthPx'] as int?,
heightPx: json['heightPx'] as int?,
status: MediaAssetStatus.fromJson(json['status'] as String),
url: json['url'] as String?,
readyAt: _dateTimeOrNull(json['readyAt']),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final MediaKind kind;
final String purpose;
final String mimeType;
final int? byteSize;
final int? widthPx;
final int? heightPx;
final MediaAssetStatus status;
final String? url;
final DateTime? readyAt;
final DateTime createdAt;
}
/// 点赞权威终态(乐观更新以此对账回滚,回滚基准取响应值)。
class LikeState {
const LikeState({required this.liked, required this.likeCount});
factory LikeState.fromJson(Map<String, dynamic> json) {
return LikeState(
liked: json['liked'] as bool,
likeCount: json['likeCount'] as int,
);
}
final bool liked;
final int likeCount;
}
/// 收藏权威终态(与点赞同构)。
class BookmarkState {
const BookmarkState({required this.bookmarked, required this.bookmarkCount});
factory BookmarkState.fromJson(Map<String, dynamic> json) {
return BookmarkState(
bookmarked: json['bookmarked'] as bool,
bookmarkCount: json['bookmarkCount'] as int,
);
}
final bool bookmarked;
final int bookmarkCount;
}
/// 关注权威终态;followerCount 为目标用户的粉丝数(实时 COUNT)。
class FollowState {
const FollowState({required this.following, required this.followerCount});
factory FollowState.fromJson(Map<String, dynamic> json) {
return FollowState(
following: json['following'] as bool,
followerCount: json['followerCount'] as int,
);
}
final bool following;
final int followerCount;
}
/// 关注计数(关注数 / 粉丝数 / 我是否已关注;查自己 followedByMe 恒 false)。
class FollowStats {
const FollowStats({
required this.followerCount,
required this.followingCount,
required this.followedByMe,
});
factory FollowStats.fromJson(Map<String, dynamic> json) {
return FollowStats(
followerCount: json['followerCount'] as int,
followingCount: json['followingCount'] as int,
followedByMe: json['followedByMe'] as bool,
);
}
final int followerCount;
final int followingCount;
final bool followedByMe;
}
@@ -0,0 +1,304 @@
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:uuid/uuid.dart';
/// community / media 域仓库接口(契约 v1.3.0 的 13 路径 / 19 操作全覆盖;
/// 页面依赖此抽象,widget / controller 测试注入假实现)。
///
/// media 两步上传本单只到协议层(创建上传 / 确认上传);预签名 PUT
/// 直传对象存储不走业务信封与 Bearer 鉴权,属 T3-13 独立客户端。
abstract class CommunityRepository {
// ---- media 两步上传(协议层)----
Future<MediaUploadCredentials> createMediaUpload(
CreateMediaUploadRequest request,
);
Future<MediaAsset> completeMediaUpload(String assetId);
// ---- 帖子 CRUD / 发布 ----
/// [idempotencyKey]:调用方持键(T3-17 发布页「同键重放」——网络失败重试
/// 沿用同键命中服务端首帖,不重复建帖;缺省则本层每次调用换新键)。
Future<Post> createPost(CreatePostRequest request, {String? idempotencyKey});
Future<Post> getPost(String postId);
Future<Post> updatePost(String postId, UpdatePostRequest request);
Future<void> deletePost(String postId);
Future<CursorPage<Post>> listMyPosts({
int? limit,
String? cursor,
PostStatus? status,
});
// ---- Feedcursor 分页)----
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor});
// ---- 评论 ----
Future<CursorPage<PostComment>> listComments(
String postId, {
int? limit,
String? cursor,
});
Future<PostComment> createComment(
String postId,
CreateCommentRequest request,
);
Future<void> deleteComment(String commentId);
// ---- 点赞 / 收藏(PUT/DELETE 语义幂等,响应权威终态)----
Future<LikeState> likePost(String postId);
Future<LikeState> unlikePost(String postId);
Future<BookmarkState> bookmarkPost(String postId);
Future<BookmarkState> unbookmarkPost(String postId);
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor});
// ---- 关注 ----
Future<FollowState> followUser(String userId);
Future<FollowState> unfollowUser(String userId);
Future<FollowStats> getFollowStats(String userId);
}
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
/// 拦截 + 401/40101 单飞刷新重放);community 域业务错误码升格为类型化异常。
///
/// 幂等:createPost / createComment 两个 POST 按契约**必带**
/// `Idempotency-Key`1~128 字符;每次逻辑提交换新键;token 刷新后的
/// 自动重放沿用同一个键——键在本层每次调用生成一次,重放走同一 headers)。
/// 点赞/收藏/关注为 PUT/DELETE 语义幂等,无需幂等键。
///
/// 端口线路:community 域端点走 community 服务客户端;media 两步上传
/// 端点由 **user 服务**提供(13 号报告 §2MediaController 在
/// patbond-user),经 [mediaApi] 直连——T3-13 真链路实测修正,
/// 未提供时回落主客户端(既有测试桩场景)。
class ApiCommunityRepository implements CommunityRepository {
ApiCommunityRepository({
required ApiClient api,
ApiClient? mediaApi,
this._uuid = const Uuid(),
}) : _api = api,
_mediaApi = mediaApi ?? api;
final ApiClient _api;
final ApiClient _mediaApi;
final Uuid _uuid;
Future<Object?> _request(
String path, {
String method = 'GET',
Object? body,
Map<String, Object?>? query,
bool idempotent = false,
String? idempotencyKey,
bool media = false,
}) async {
try {
return await (media ? _mediaApi : _api).request(
path,
method: method,
body: body,
query: query,
headers: idempotent
? {'Idempotency-Key': idempotencyKey ?? _uuid.v4()}
: null,
requiresAuth: true,
);
} on ApiBusinessException catch (error) {
throw mapCommunityBusinessException(error);
}
}
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
// ---- media ----
@override
Future<MediaUploadCredentials> createMediaUpload(
CreateMediaUploadRequest request,
) async {
final data = await _request(
'/api/v1/media/uploads',
method: 'POST',
body: request.toJson(),
media: true,
);
return MediaUploadCredentials.fromJson(_asMap(data));
}
@override
Future<MediaAsset> completeMediaUpload(String assetId) async {
// 幂等由服务端保证:已 ready 重复 confirm 返回 200 同一 asset。
final data = await _request(
'/api/v1/media/uploads/$assetId/complete',
method: 'POST',
media: true,
);
return MediaAsset.fromJson(_asMap(data));
}
// ---- posts ----
@override
Future<Post> createPost(
CreatePostRequest request, {
String? idempotencyKey,
}) async {
final data = await _request(
'/api/v1/posts',
method: 'POST',
body: request.toJson(),
idempotent: true,
idempotencyKey: idempotencyKey,
);
return Post.fromJson(_asMap(data));
}
@override
Future<Post> getPost(String postId) async {
final data = await _request('/api/v1/posts/$postId');
return Post.fromJson(_asMap(data));
}
@override
Future<Post> updatePost(String postId, UpdatePostRequest request) async {
final data = await _request(
'/api/v1/posts/$postId',
method: 'PATCH',
body: request.toJson(),
);
return Post.fromJson(_asMap(data));
}
@override
Future<void> deletePost(String postId) async {
await _request('/api/v1/posts/$postId', method: 'DELETE');
}
@override
Future<CursorPage<Post>> listMyPosts({
int? limit,
String? cursor,
PostStatus? status,
}) async {
final data = await _request(
'/api/v1/me/posts',
query: {
'limit': ?limit,
'cursor': ?cursor,
if (status != null) 'status': status.name,
},
);
return CursorPage.fromJson(_asMap(data), Post.fromJson);
}
// ---- feed ----
@override
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) async {
final data = await _request(
'/api/v1/feed',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
}
// ---- comments ----
@override
Future<CursorPage<PostComment>> listComments(
String postId, {
int? limit,
String? cursor,
}) async {
final data = await _request(
'/api/v1/posts/$postId/comments',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), PostComment.fromJson);
}
@override
Future<PostComment> createComment(
String postId,
CreateCommentRequest request,
) async {
final data = await _request(
'/api/v1/posts/$postId/comments',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return PostComment.fromJson(_asMap(data));
}
@override
Future<void> deleteComment(String commentId) async {
// 顶层短路径先例:commentId 全局唯一。
await _request('/api/v1/comments/$commentId', method: 'DELETE');
}
// ---- interactions ----
@override
Future<LikeState> likePost(String postId) async {
final data = await _request('/api/v1/posts/$postId/like', method: 'PUT');
return LikeState.fromJson(_asMap(data));
}
@override
Future<LikeState> unlikePost(String postId) async {
final data = await _request('/api/v1/posts/$postId/like', method: 'DELETE');
return LikeState.fromJson(_asMap(data));
}
@override
Future<BookmarkState> bookmarkPost(String postId) async {
final data = await _request(
'/api/v1/posts/$postId/bookmark',
method: 'PUT',
);
return BookmarkState.fromJson(_asMap(data));
}
@override
Future<BookmarkState> unbookmarkPost(String postId) async {
final data = await _request(
'/api/v1/posts/$postId/bookmark',
method: 'DELETE',
);
return BookmarkState.fromJson(_asMap(data));
}
@override
Future<CursorPage<FeedCard>> listMyBookmarks({
int? limit,
String? cursor,
}) async {
final data = await _request(
'/api/v1/me/bookmarks',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
}
// ---- follows ----
@override
Future<FollowState> followUser(String userId) async {
final data = await _request('/api/v1/users/$userId/follow', method: 'PUT');
return FollowState.fromJson(_asMap(data));
}
@override
Future<FollowState> unfollowUser(String userId) async {
final data = await _request(
'/api/v1/users/$userId/follow',
method: 'DELETE',
);
return FollowState.fromJson(_asMap(data));
}
@override
Future<FollowStats> getFollowStats(String userId) async {
final data = await _request('/api/v1/users/$userId/follow-stats');
return FollowStats.fromJson(_asMap(data));
}
}
+114
View File
@@ -0,0 +1,114 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
/// feed 域埋点强类型封装(06 号规划 §1.4 字典 v3;后端白名单已随
/// api dev@8089c06 就绪)。沿 pet_analytics 惯例:枚举编译期锁死,
/// 业务代码禁止手拼事件名与属性;只记行为不记内容(隐私红线:postId
/// 等内容 ID 一律不进 props,曝光去重键只存活于客户端内存)。
/// feed_viewed / feed_load_failed 的 feedTab 枚举(06 §1.4)。
/// M3 T3-14 仅接 hometopic / user_posts / favorites 随后续页面启用。
enum FeedTab {
home('home'),
topic('topic'),
userPosts('user_posts'),
favorites('favorites');
const FeedTab(this.value);
final String value;
}
/// feed_load_failed 的 loadType 枚举。
enum FeedLoadType {
refresh('refresh'),
loadMore('load_more');
const FeedLoadType(this.value);
final String value;
}
/// feed 加载失败原因(06 v3 失败枚举基底)。网络归并口径同 pet 域:
/// 断网/超时/5xx 均并入 network_errorserver_error 保留兜底。
enum FeedLoadFailureReason {
rateLimited('rate_limited'),
networkError('network_error'),
serverError('server_error');
const FeedLoadFailureReason(this.value);
final String value;
}
/// 类型化异常 → 失败原因;会话失效返回 null(应用即将回登录页,
/// 不作为 Feed 加载失败上报)。
FeedLoadFailureReason? feedLoadFailureReasonOf(ApiException error) =>
switch (error) {
ApiNetworkException _ => FeedLoadFailureReason.networkError,
ApiRateLimitException _ => FeedLoadFailureReason.rateLimited,
SessionExpiredException _ => null,
_ => FeedLoadFailureReason.serverError,
};
class FeedAnalytics {
FeedAnalytics(this._track);
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
final TrackEventFn _track;
/// 一个 Feed 浏览段的聚合曝光(06 §1.2 裁定):离开 Feed(路由跳走 /
/// 退后台)时发一条,携带段内曝光卡片数(≥50% 可见 ≥500ms、按帖去重
/// 后的**计数**)、翻页数、刷新数与前台停留时长。
void feedViewed({
required FeedTab feedTab,
required int durationMs,
required int impressionCount,
required int loadMoreCount,
required int refreshCount,
}) {
_track('feed_viewed', {
'feedTab': feedTab.value,
'durationMs': durationMs,
'impressionCount': impressionCount,
'loadMoreCount': loadMoreCount,
'refreshCount': refreshCount,
});
}
/// 刷新或翻页请求失败(M3 验收「分页不丢失不重复」的客户端观测点)。
///
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
/// 业务码推导(`code ~/ 100`pet 域同款口径)。
void feedLoadFailed({
required FeedTab feedTab,
required FeedLoadType loadType,
required FeedLoadFailureReason reason,
int? errorCode,
}) {
_track('feed_load_failed', {
'feedTab': feedTab.value,
'loadType': loadType.value,
'failureReason': reason.value,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
/// [feedLoadFailed] 的异常直通口:会话失效不上报。
void feedLoadFailedFrom(
ApiException error, {
required FeedTab feedTab,
required FeedLoadType loadType,
}) {
final reason = feedLoadFailureReasonOf(error);
if (reason == null) return;
feedLoadFailed(
feedTab: feedTab,
loadType: loadType,
reason: reason,
errorCode: error is ApiBusinessException ? error.code : null,
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'dart:async';
import 'dart:math';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
/// 一个「Feed 浏览段」的聚合器(06 号规划 §1.2 裁定的客户端实现)。
///
/// 段生命周期由页面驱动:进入 Feed(Tab 激活且分段在 Feed)开段;
/// 离开(切 Tab / 切分段 / 退后台 / 页面销毁)时 [settle] 结算并经
/// [FeedAnalytics.feedViewed] 发**一条**聚合事件。
///
/// - 曝光判定:卡片可见面积 ≥ [visibleThreshold] 且持续 ≥ [dwell]
/// 段内按 postId 去重;postId 只作内存去重键、绝不上报(隐私红线 2),
/// 段结束即弃。
/// - durationMs 为前台时长(退后台即结算,段天然前台连续),上限
/// 截断 30 分钟防挂机污染(06 §1.4 实现注意)。
class FeedViewSegment {
FeedViewSegment({DateTime Function()? now})
: _now = now ?? DateTime.now,
_settled = false {
_startedAt = _now();
}
static const visibleThreshold = 0.5;
static const dwell = Duration(milliseconds: 500);
static const maxDuration = Duration(minutes: 30);
final DateTime Function() _now;
late final DateTime _startedAt;
final Set<String> _impressed = <String>{};
final Map<String, Timer> _dwellTimers = <String, Timer>{};
int _loadMoreCount = 0;
int _refreshCount = 0;
bool _settled;
/// 段内曝光卡片数(已去重;仅测试与结算读取)。
int get impressionCount => _impressed.length;
/// 可见性回报:≥50% 起 500ms 驻留计时,跌破或滚出视口即取消;
/// 驻留满即记曝光(去重后不再计时)。
void updateVisibility(String postId, double visibleFraction) {
if (_settled) return;
if (visibleFraction >= visibleThreshold) {
if (_impressed.contains(postId) || _dwellTimers.containsKey(postId)) {
return;
}
_dwellTimers[postId] = Timer(dwell, () {
_dwellTimers.remove(postId);
_impressed.add(postId);
});
} else {
_dwellTimers.remove(postId)?.cancel();
}
}
/// 用户触发的下拉刷新 / 失败重试(首屏自动预取不计)。
void recordRefresh() {
if (!_settled) _refreshCount += 1;
}
/// 触底翻页请求(含尾部失败重试)。
void recordLoadMore() {
if (!_settled) _loadMoreCount += 1;
}
/// 结算:取消在途驻留计时并冻结计数;幂等(重复调用返回 null,
/// 保证一段恰好一条 feed_viewed)。
FeedViewSummary? settle() {
if (_settled) return null;
_settled = true;
for (final timer in _dwellTimers.values) {
timer.cancel();
}
_dwellTimers.clear();
final elapsed = _now().difference(_startedAt);
return FeedViewSummary(
durationMs: min(elapsed.inMilliseconds, maxDuration.inMilliseconds),
impressionCount: _impressed.length,
loadMoreCount: _loadMoreCount,
refreshCount: _refreshCount,
);
}
}
/// [FeedViewSegment.settle] 的结算结果(feed_viewed 专有属性)。
class FeedViewSummary {
const FeedViewSummary({
required this.durationMs,
required this.impressionCount,
required this.loadMoreCount,
required this.refreshCount,
});
final int durationMs;
final int impressionCount;
final int loadMoreCount;
final int refreshCount;
/// 结算即上报的便捷口。
void report(FeedAnalytics analytics, {FeedTab feedTab = FeedTab.home}) {
analytics.feedViewed(
feedTab: feedTab,
durationMs: durationMs,
impressionCount: impressionCount,
loadMoreCount: loadMoreCount,
refreshCount: refreshCount,
);
}
}
@@ -0,0 +1,53 @@
import 'dart:typed_data';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:patbond_flutter/features/community/media_picking.dart';
/// 压缩产物(直传本体)。mimeType 与直传 PUT 的 Content-Type、
/// createUpload 登记值三处必须一致(Content-Type 已签进预签名签名)。
class CompressedMediaImage {
const CompressedMediaImage({required this.bytes, required this.mimeType});
final Uint8List bytes;
final String mimeType;
int get byteSize => bytes.length;
}
/// 压缩抽象:quality 由 MediaUploader 的降质阶梯驱动(80 → 60),
/// 单测注入假实现控制产物大小。
abstract class MediaImageCompressor {
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
});
}
/// 基于 flutter_image_compress 的原生压缩实现(03 号评估 §4.1 选型)。
///
/// 策略(03 号 §4.1 + 13 号报告偏差 #6):长边 ≤2048 重采样、统一转码
/// JPEG(服务端 mime 白名单不收 HEIC,客户端统一出 jpeg);
/// autoCorrectionAngle 矫正方向后不保留 EXIF——顺带剥离 GPS 定位隐私,
/// **不要开 keepExif**。
class NativeMediaImageCompressor implements MediaImageCompressor {
const NativeMediaImageCompressor({this.maxLongEdge = 2048});
final int maxLongEdge;
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async {
final bytes = await FlutterImageCompress.compressWithList(
source.bytes,
minWidth: maxLongEdge,
minHeight: maxLongEdge,
quality: quality,
format: CompressFormat.jpeg,
autoCorrectionAngle: true,
keepExif: false,
);
return CompressedMediaImage(bytes: bytes, mimeType: 'image/jpeg');
}
}
@@ -0,0 +1,82 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
/// 预签名直传失败。
///
/// - [statusCode] 为 null 表示网络层失败(断连/超时),可原凭据重试;
/// - 403 为存储侧拒绝签名(凭据过期/被改动),须重新 createUpload 换新凭据;
/// - 其余状态码按可重试处理(重试走完整重传)。
class MediaDirectUploadException implements Exception {
const MediaDirectUploadException({this.statusCode, required this.message});
final int? statusCode;
final String message;
/// 存储侧拒绝了凭据(签名过期/不符),重试前必须换新凭据。
bool get isCredentialRejected => statusCode == 403;
@override
String toString() => 'MediaDirectUploadException($statusCode): $message';
}
/// 预签名 PUT 直传抽象(两步上传第二步的传输层)。
///
/// 不走业务信封、不带 Bearer 鉴权——鉴权就是 URL 里的签名本身;
/// [headers] 即 createUpload 返回的 requiredHeaders,必须原样携带
/// Content-Type 已签进签名,改动即 403)。
abstract class MediaDirectUploadClient {
Future<void> put({
required String url,
required Map<String, String> headers,
required Uint8List bytes,
void Function(int sent, int total)? onProgress,
});
}
/// 基于裸 Dio 的实现:独立实例,无 AuthInterceptor(预签名 URL 携带
/// Authorization 头会破坏 SigV4 校验),sendTimeout 按 10 MiB 弱网上限放宽。
class DioMediaDirectUploadClient implements MediaDirectUploadClient {
DioMediaDirectUploadClient({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 5),
sendTimeout: const Duration(seconds: 120),
receiveTimeout: const Duration(seconds: 30),
validateStatus: (_) => true,
),
);
final Dio _dio;
@override
Future<void> put({
required String url,
required Map<String, String> headers,
required Uint8List bytes,
void Function(int sent, int total)? onProgress,
}) async {
final Response<Object?> response;
try {
response = await _dio.put<Object?>(
url,
data: Stream<Uint8List>.value(bytes),
options: Options(
headers: {...headers, Headers.contentLengthHeader: bytes.length},
),
onSendProgress: onProgress,
);
} on DioException catch (error) {
throw MediaDirectUploadException(message: '直传网络失败:${error.type.name}');
}
final status = response.statusCode ?? 0;
if (status < 200 || status >= 300) {
throw MediaDirectUploadException(
statusCode: status,
message: '存储侧拒绝直传:HTTP $status',
);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'dart:typed_data';
import 'package:image_picker/image_picker.dart';
/// 待上传的一张原始图(选择器产物,压缩前形态)。
///
/// 只持有内存字节:不落磁盘副本、不携带原始文件路径,配合压缩层剥离
/// EXIF(含 GPS)的隐私纪律(03 号评估 §4.1)。
class PickedMediaImage {
const PickedMediaImage({required this.bytes, this.name});
/// 原始字节(供压缩层消费与格内缩略预览)。
final Uint8List bytes;
/// 原始文件名(仅诊断用途,不参与上传——objectKey 由服务端生成)。
final String? name;
}
/// 图片选择抽象:MediaUploader 只依赖本接口,单测注入假实现,
/// widget 测试无需平台通道。
abstract class MediaImagePicker {
/// 拉起系统选择器,最多返回 [limit] 张;用户取消返回空列表。
Future<List<PickedMediaImage>> pickImages({required int limit});
}
/// 基于 image_picker 的系统选择器实现(03 号评估 §4.1 选型:官方维护、
/// pickMultiImage 多选,M3 不引入重型相册组件)。
class SystemMediaImagePicker implements MediaImagePicker {
SystemMediaImagePicker({ImagePicker? picker})
: _picker = picker ?? ImagePicker();
final ImagePicker _picker;
@override
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
// 仅剩一个空位时用单选(pickMultiImage 的 limit 在部分平台要求 ≥2)。
final files = limit <= 1
? [?await _picker.pickImage(source: ImageSource.gallery)]
: await _picker.pickMultiImage(limit: limit);
final images = <PickedMediaImage>[];
for (final file in files.take(limit)) {
images.add(
PickedMediaImage(bytes: await file.readAsBytes(), name: file.name),
);
}
return images;
}
}
+570
View File
@@ -0,0 +1,570 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/media_compression.dart';
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
import 'package:patbond_flutter/features/community/media_picking.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
/// 单张图的上传阶段(05 号规范 §3.3 四视觉态的底层状态模型)。
///
/// 生命周期:queued → compressing → uploading(progress) → confirming
/// → ready | failed(retryable?)。ready 是唯一可交付态——**assetId 只在
/// ready 态对外可见**(孤儿防护:未 confirm 的 asset 不得被引用)。
enum MediaItemPhase {
queued,
compressing,
uploading,
confirming,
ready,
failed,
}
/// 单张图的不可变状态快照(UI 消费;内部任务状态见 _UploadTask)。
@immutable
class MediaUploadItem {
const MediaUploadItem({
required this.localId,
required this.phase,
required this.previewBytes,
this.progress = 0,
this.assetId,
this.errorMessage,
this.retryable = false,
}) : assert(
(assetId != null) == (phase == MediaItemPhase.ready),
'assetId 与 ready 态严格绑定(孤儿防护)',
);
/// 本地稳定标识(重试/删除寻址用,与服务端无关)。
final int localId;
final MediaItemPhase phase;
/// 缩略预览字节(原图,UI 直接 Image.memory 渲染)。
final Uint8List previewBytes;
/// 直传进度 0..1uploading 有意义;confirming 视作 1.0)。
final double progress;
/// ready 态的可引用 asset 标识;其余态恒为 null(构造期断言兜底)。
final String? assetId;
/// failed 态的用户可读原因。
final String? errorMessage;
/// failed 态是否可重试(false = 终态,如压缩后仍超限)。
final bool retryable;
bool get isReady => phase == MediaItemPhase.ready;
bool get isFailed => phase == MediaItemPhase.failed;
bool get isBusy => !isReady && !isFailed;
}
/// 内部任务:可变状态 + 生命周期旗标(cancelled 后一切在途结果作废)。
class _UploadTask {
_UploadTask({required this.localId, required this.source});
final int localId;
final PickedMediaImage source;
MediaItemPhase phase = MediaItemPhase.queued;
double progress = 0;
String? errorMessage;
bool retryable = false;
bool cancelled = false;
/// 本图第几次上传尝试(媒体三段埋点 attemptSeq,从 1 起;retry 递增)。
int attemptSeq = 1;
/// 本次尝试的 started 时刻(succeeded 的 durationMs 口径)。
DateTime? attemptStartedAt;
/// 压缩产物缓存(重试跳过重压缩)。
CompressedMediaImage? compressed;
/// confirm 成功前的服务端 assetId 只以管线局部变量存在,**不落任务
/// 状态、不出现在 [MediaUploadItem] 快照**——孤儿防护的结构保证;
/// confirm 通过后才写入 [readyAssetId]。
String? readyAssetId;
MediaUploadItem snapshot() => MediaUploadItem(
localId: localId,
phase: phase,
previewBytes: source.bytes,
progress: progress,
assetId: readyAssetId,
errorMessage: errorMessage,
retryable: retryable,
);
}
/// 媒体上传编排器(T3-13,03 号评估 §4.3 冻结接口的定稿实现)。
///
/// 职责:选图 → 压缩(降质阶梯 80→60,仍超 10 MiB 拒绝为终态失败)→
/// createUpload → 预签名 PUT 直传(进度回调;Content-Type 按
/// requiredHeaders 原样携带)→ confirm → ready assetId 交付。
///
/// 语义要点:
/// - **顺序保持**items 顺序 = 加入顺序 = position 语义;并发上传的
/// 完成先后不影响顺序([buildAttachRequests] 按当前列表序发号)。
/// - **单飞槽位**:至多 [maxConcurrentUploads] 张同时占用网络管线,
/// 单图失败不拖垮整批。
/// - **凭据过期**:PUT 前过期预检、存储侧 403 各触发一次自动
/// re-createUpload(换新 assetId 新凭据);再失败交给手动重试。
/// - **失败重试**retry 复用压缩产物、从 createUpload 全新开始
/// (统一覆盖「对象未上传保持 uploading」与「内容不符置 failed 终态」
/// 两种服务端分支——旧 asset 弃引用,由服务端超时清理兜底)。
/// - **孤儿防护**:未 confirm 的 assetId 只以管线局部变量存在;
/// 对外可见的 [MediaUploadItem.assetId] 与 ready 态严格绑定(断言),
/// [buildAttachRequests] 仅在全员 ready 时可用。
/// - 预签名凭据只存内存、用完即弃,不持久化(既有纪律)。
/// - **媒体三段埋点**T3-17):每次尝试恰一条 started,收敛为
/// succeeded / failed 各一条;`sizeBucket` 统一取原图字节数。
class MediaUploader extends ChangeNotifier {
MediaUploader({
required this._repository,
MediaImagePicker? picker,
MediaImageCompressor? compressor,
MediaDirectUploadClient? directUpload,
this._analytics,
this.maxImages = 9,
this.maxConcurrentUploads = 2,
this.maxByteSize = 10 * 1024 * 1024,
DateTime Function()? now,
}) : _picker = picker ?? SystemMediaImagePicker(),
_compressor = compressor ?? const NativeMediaImageCompressor(),
_directUpload = directUpload ?? DioMediaDirectUploadClient(),
_now = now ?? DateTime.now,
_slots = maxConcurrentUploads;
final CommunityRepository _repository;
final MediaImagePicker _picker;
final MediaImageCompressor _compressor;
final MediaDirectUploadClient _directUpload;
/// 媒体上传三段埋点(T3-17 接入;未注入即不上报)。
final PostAnalytics? _analytics;
final DateTime Function() _now;
/// 九宫格上限(05 号规范 §3.2)。
final int maxImages;
final int maxConcurrentUploads;
/// 与服务端 byteSize 上限一致(10 MiB13 号报告偏差 #7)。
final int maxByteSize;
/// 凭据过期预检安全边距:距 expiresAt 不足此值即视为过期,直接换新。
static const credentialsSafetyMargin = Duration(seconds: 30);
/// 压缩降质阶梯(80 常规 → 60 兜底;仍超限即终态拒绝)。
static const qualityLadder = [80, 60];
final List<_UploadTask> _tasks = [];
int _nextLocalId = 1;
bool _picking = false;
int _slots;
final List<Completer<void>> _slotWaiters = [];
// ---- 对外状态 ----
/// 选择器是否拉起中(uploader 级 picking 态)。
bool get isPicking => _picking;
List<MediaUploadItem> get items =>
List.unmodifiable(_tasks.map((task) => task.snapshot()));
bool get isEmpty => _tasks.isEmpty;
int get remainingSlots => maxImages - _tasks.length;
int get readyCount =>
_tasks.where((t) => t.phase == MediaItemPhase.ready).length;
bool get allReady => _tasks.isNotEmpty && readyCount == _tasks.length;
bool get hasFailure => _tasks.any((t) => t.phase == MediaItemPhase.failed);
bool get hasBusyItem => _picking || _tasks.any((t) => t.snapshot().isBusy);
/// 页级汇总进度(05 号规范 §3.3 线性进度条):各图等权。
double get overallProgress {
if (_tasks.isEmpty) return 0;
var sum = 0.0;
for (final task in _tasks) {
sum += switch (task.phase) {
MediaItemPhase.ready => 1.0,
MediaItemPhase.uploading => task.progress,
MediaItemPhase.confirming => 1.0,
_ => 0.0,
};
}
return sum / _tasks.length;
}
/// 交付口(T3-17 发布页组 CreatePostRequest.media 用):
/// 仅全员 ready 时可用——**任何非 ready 项在场即抛 [StateError]**
/// 从类型上杜绝未 confirm asset 被引用。position 按当前列表序 0..n-1。
List<PostMediaAttachRequest> buildAttachRequests({int coverIndex = 0}) {
if (!allReady) {
throw StateError('存在未就绪的上传项,不得引用(孤儿防护)');
}
return [
for (final (index, task) in _tasks.indexed)
PostMediaAttachRequest(
assetId: task.readyAssetId!,
position: index,
isCover: index == coverIndex,
),
];
}
// ---- 操作 ----
/// 拉起系统选择器并把所选图片加入上传管线(剩余槽位自动截断)。
Future<void> pickAndAdd() async {
if (_picking || remainingSlots <= 0) return;
_picking = true;
notifyListeners();
try {
final images = await _picker.pickImages(limit: remainingSlots);
_picking = false;
addImages(images);
} catch (_) {
_picking = false;
notifyListeners();
}
}
/// 直接加入图片(选择器旁路,测试与分享外链场景用)。
void addImages(List<PickedMediaImage> images) {
for (final image in images.take(remainingSlots)) {
final task = _UploadTask(localId: _nextLocalId++, source: image);
_tasks.add(task);
unawaited(_run(task));
}
notifyListeners();
}
/// 重试一张可重试失败图:复用压缩产物,从 createUpload 全新开始。
void retry(int localId) {
final task = _taskOrNull(localId);
if (task == null ||
task.phase != MediaItemPhase.failed ||
!task.retryable) {
return;
}
task.errorMessage = null;
task.retryable = false;
task.progress = 0;
task.attemptSeq += 1;
task.phase = MediaItemPhase.queued;
notifyListeners();
unawaited(_run(task));
}
/// 移除一张图(任意态可移除);在途请求结果一律作废,未 confirm 的
/// 服务端 asset 弃引用(服务端超时清理兜底)。在途任务被移除按
/// `cancelled` 上报一条上传失败(06 §1.4「用户取消」口径)。
void remove(int localId) {
final task = _taskOrNull(localId);
if (task == null) return;
_reportCancelled(task);
task.cancelled = true;
_tasks.remove(task);
notifyListeners();
}
/// 清空全部(发布成功/离开页面时调用);在途任务同 [remove] 记 cancelled。
void reset() {
for (final task in _tasks) {
_reportCancelled(task);
task.cancelled = true;
}
_tasks.clear();
_picking = false;
notifyListeners();
}
_UploadTask? _taskOrNull(int localId) {
for (final task in _tasks) {
if (task.localId == localId) return task;
}
return null;
}
// ---- 管线 ----
Future<void> _run(_UploadTask task) async {
await _acquireSlot();
try {
if (task.cancelled) return;
// 一次尝试恰一条 started(含压缩段:压缩失败也在漏斗内可见)。
task.attemptStartedAt = _now();
_analytics?.mediaUploadStarted(
mediaType: MediaType.image,
byteSize: task.source.bytes.length,
);
final compressed = await _compress(task);
if (compressed == null || task.cancelled) return;
await _uploadAndConfirm(task, compressed);
} finally {
_releaseSlot();
}
}
/// 压缩(降质阶梯);超限终态失败返回 null。
Future<CompressedMediaImage?> _compress(_UploadTask task) async {
final cached = task.compressed;
if (cached != null) return cached;
_transition(task, MediaItemPhase.compressing);
try {
for (final quality in qualityLadder) {
final result = await _compressor.compress(
task.source,
quality: quality,
);
if (task.cancelled) return null;
if (result.byteSize <= maxByteSize) {
task.compressed = result;
return result;
}
}
} catch (_) {
_fail(
task,
message: '图片处理失败',
retryable: true,
reason: MediaUploadFailureReason.unsupportedFormat,
);
return null;
}
_fail(
task,
message: '图片过大,压缩后仍超过 10 MB',
retryable: false,
reason: MediaUploadFailureReason.mediaTooLarge,
);
return null;
}
Future<void> _uploadAndConfirm(
_UploadTask task,
CompressedMediaImage compressed,
) async {
// createUpload:登记 mime/byteSize,取预签名 PUT 凭据(内存态,不持久化)。
MediaUploadCredentials credentials;
try {
credentials = await _createUpload(compressed);
} on Exception catch (error) {
if (!task.cancelled) _failFromApi(task, error);
return;
}
if (task.cancelled) return;
// 直传 PUT:过期预检与存储侧 403 各允许一次自动换新凭据。
_transition(task, MediaItemPhase.uploading);
var renewed = false;
while (true) {
if (_credentialsExpired(credentials)) {
if (renewed) {
_fail(
task,
message: '上传凭据已过期',
retryable: true,
reason: MediaUploadFailureReason.serverError,
);
return;
}
renewed = true;
try {
credentials = await _createUpload(compressed);
} on Exception catch (error) {
if (!task.cancelled) _failFromApi(task, error);
return;
}
if (task.cancelled) return;
// 回到循环顶复检新凭据(服务端时钟异常仍过期即失败,不无限重取)。
continue;
}
try {
await _directUpload.put(
url: credentials.uploadUrl,
headers: credentials.requiredHeaders,
bytes: compressed.bytes,
onProgress: (sent, total) {
if (task.cancelled || total <= 0) return;
task.progress = sent / total;
notifyListeners();
},
);
break;
} on MediaDirectUploadException catch (error) {
if (task.cancelled) return;
if (error.isCredentialRejected && !renewed) {
renewed = true;
try {
credentials = await _createUpload(compressed);
} on Exception catch (creationError) {
_failFromApi(task, creationError);
return;
}
if (task.cancelled) return;
task.progress = 0;
notifyListeners();
continue;
}
_fail(
task,
message: error.statusCode == null ? '网络中断,上传失败' : '上传被存储服务拒绝',
retryable: true,
reason: error.statusCode == null
? MediaUploadFailureReason.networkError
: MediaUploadFailureReason.serverError,
);
return;
}
}
if (task.cancelled) return;
// confirm42205 对象未上传保持 uploading / 内容不符置 failed 终态,
// 客户端统一按「可重试 + 重试换新 asset」处理,两分支都正确收敛。
_transition(task, MediaItemPhase.confirming);
final MediaAsset asset;
try {
asset = await _repository.completeMediaUpload(credentials.assetId);
} on Exception catch (error) {
if (!task.cancelled) _failFromApi(task, error);
return;
}
if (task.cancelled) return;
if (asset.status != MediaAssetStatus.ready) {
_fail(
task,
message: '上传确认未通过',
retryable: true,
reason: MediaUploadFailureReason.serverError,
);
return;
}
task.readyAssetId = asset.id;
task.progress = 1;
_transition(task, MediaItemPhase.ready);
final startedAt = task.attemptStartedAt;
_analytics?.mediaUploadSucceeded(
mediaType: MediaType.image,
byteSize: task.source.bytes.length,
durationMs: startedAt == null
? 0
: _now().difference(startedAt).inMilliseconds,
);
}
Future<MediaUploadCredentials> _createUpload(
CompressedMediaImage compressed,
) {
return _repository.createMediaUpload(
CreateMediaUploadRequest(
kind: MediaKind.image,
purpose: MediaPurpose.postImage,
mimeType: compressed.mimeType,
byteSize: compressed.byteSize,
),
);
}
bool _credentialsExpired(MediaUploadCredentials credentials) =>
!_now().add(credentialsSafetyMargin).isBefore(credentials.expiresAt);
void _failFromApi(_UploadTask task, Exception error) {
// 参数被服务端拒绝(40000mime/byteSize 白名单外)重试无意义,终态。
final isParamError =
error is ApiBusinessException && error.code == ApiCodes.paramError;
_fail(
task,
message: error is ApiBusinessException ? error.message : '网络异常,请重试',
retryable: !isParamError,
// 客户端已本地保证 ≤10 MiB,故 40000 归因为格式白名单外;
// 会话失效不上报(reason 传 null),其余业务/限流并入 server_error。
reason: switch (error) {
ApiBusinessException _ when isParamError =>
MediaUploadFailureReason.unsupportedFormat,
SessionExpiredException _ => null,
ApiNetworkException _ => MediaUploadFailureReason.networkError,
_ => MediaUploadFailureReason.serverError,
},
errorCode: error is ApiBusinessException ? error.code : null,
);
}
void _fail(
_UploadTask task, {
required String message,
required bool retryable,
required MediaUploadFailureReason? reason,
int? errorCode,
}) {
if (task.cancelled) return;
task.phase = MediaItemPhase.failed;
task.errorMessage = message;
task.retryable = retryable;
if (reason != null) {
_analytics?.mediaUploadFailed(
mediaType: MediaType.image,
byteSize: task.source.bytes.length,
reason: reason,
attemptSeq: task.attemptSeq,
errorCode: errorCode,
);
}
notifyListeners();
}
/// 在途任务被删格/清空作废 → `cancelled`(已 ready / 已 failed 不报)。
void _reportCancelled(_UploadTask task) {
if (task.cancelled || !task.snapshot().isBusy) return;
_analytics?.mediaUploadFailed(
mediaType: MediaType.image,
byteSize: task.source.bytes.length,
reason: MediaUploadFailureReason.cancelled,
attemptSeq: task.attemptSeq,
);
}
void _transition(_UploadTask task, MediaItemPhase phase) {
if (task.cancelled) return;
task.phase = phase;
notifyListeners();
}
Future<void> _acquireSlot() {
if (_slots > 0) {
_slots--;
return Future.value();
}
final waiter = Completer<void>();
_slotWaiters.add(waiter);
return waiter.future;
}
void _releaseSlot() {
if (_slotWaiters.isNotEmpty) {
_slotWaiters.removeAt(0).complete();
} else {
_slots++;
}
}
}
/// [MediaUploader] 的构造口(发布页每次进入建一个,退出即 dispose)。
///
/// 生产缺省即 `MediaUploader(repository: ..., analytics: ...)`;注入点为
/// **测试与桌面实测专用**——Linux 桌面既无 image_picker 也无
/// flutter_image_compress 的原生实现,桌面真链路只替换选图与压缩两层,
/// 其余(createUpload / 直传 PUT / confirm)全为生产实现。
typedef MediaUploaderFactory =
MediaUploader Function(
CommunityRepository repository,
PostAnalytics? analytics,
);
+239
View File
@@ -0,0 +1,239 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'
show textLengthBucketOf;
/// post 域埋点强类型封装(06 号规划 §1.4 发布漏斗五事件 + 媒体上传三段;
/// 后端白名单随 api dev@`8089c06` 就绪,22 号报告 §1 键集逐一对齐)。
/// 沿 pet/feed/互动域惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性。
///
/// 隐私纪律(06 §1.3 红线):正文只出分桶不出字数(红线 1);postId /
/// assetId 等内容 ID 一律不进 props(红线 2);媒体只报 [MediaType] 与
/// [mediaSizeBucketOf] 分桶,文件名/路径/URL 禁止(红线 4)。
///
/// **不得上报**22 号 §1 末段锁死为 unknown):`post_impression`、
/// `post_viewed`、`post_like_failed` 等——本文件不提供其封装。
/// 发帖入口(06 §1.4 `post_create_started.entryPoint`)。
/// M3 接 create_tab / feedtopic_detail / pet_detail 随后续页面启用。
enum PostEntryPoint {
createTab('create_tab'),
feed('feed'),
topicDetail('topic_detail'),
petDetail('pet_detail');
const PostEntryPoint(this.value);
final String value;
}
/// 草稿保存触发方式(06 §1.4:**自动保存不埋**,防高频)。
enum DraftSaveTrigger {
/// 「存草稿」按钮显式保存。
manual('manual'),
/// 离开发布页时经「保留草稿?」确认保存。
onExit('on_exit');
const DraftSaveTrigger(this.value);
final String value;
}
/// 发布失败原因(06 §1.4 枚举;`content_rejected` 待拍板未启用,
/// `not_found` 取 §1.4「失败枚举基底」的复用条——草稿已被别处删除)。
enum PostPublishFailureReason {
validationError('validation_error'),
mediaUploadIncomplete('media_upload_incomplete'),
notFound('not_found'),
rateLimited('rate_limited'),
networkError('network_error'),
serverError('server_error');
const PostPublishFailureReason(this.value);
final String value;
}
/// 媒体类型(M3 仅 image;video 随视频能力启用)。
enum MediaType {
image('image'),
video('video');
const MediaType(this.value);
final String value;
}
/// 单文件上传失败原因(06 §1.4 媒体漏斗枚举)。
enum MediaUploadFailureReason {
mediaTooLarge('media_too_large'),
unsupportedFormat('unsupported_format'),
networkError('network_error'),
serverError('server_error'),
/// 用户在上传途中删格 / 离开发布页作废在途任务。
cancelled('cancelled');
const MediaUploadFailureReason(this.value);
final String value;
}
/// 类型化异常 → 发布失败原因;会话失效返回 null(应用即将回登录页,
/// 不作为发布失败上报,feed / 互动域同款口径)。
///
/// 映射取舍(26 号报告 §3 有对照表):42203 恰为
/// [PostPublishFailureReason.mediaUploadIncomplete]40905(同键异
/// payload)归 validation_error——提交内容与幂等键不一致属提交侧问题,
/// 非服务端故障;40902(乐观锁,自动刷新 version 重提仍失败)归
/// server_error 兜底。
PostPublishFailureReason? postPublishFailureReasonOf(ApiException error) =>
switch (error) {
ApiNetworkException _ => PostPublishFailureReason.networkError,
ApiRateLimitException _ => PostPublishFailureReason.rateLimited,
SessionExpiredException _ => null,
ApiBusinessException(:final code) => switch (code) {
ApiCodes.paramError || ApiCodes.idempotencyKeyMismatch =>
PostPublishFailureReason.validationError,
ApiCodes.mediaNotReady =>
PostPublishFailureReason.mediaUploadIncomplete,
ApiCodes.postNotFound ||
ApiCodes.mediaNotFound => PostPublishFailureReason.notFound,
_ => PostPublishFailureReason.serverError,
},
};
/// 媒体大小分桶(06 §1.3 红线 4:不报精确字节数)。
/// `lt_1mb` / `mb_1_5` / `mb_5_20` / `gte_20mb`,以 MiB 为界
/// (与 MediaUploader 的 10 MiB 上限同一进制)。
String mediaSizeBucketOf(int byteSize) {
const mib = 1024 * 1024;
if (byteSize < mib) return 'lt_1mb';
if (byteSize < 5 * mib) return 'mb_1_5';
if (byteSize < 20 * mib) return 'mb_5_20';
return 'gte_20mb';
}
/// 发布漏斗 + 媒体上传三段埋点(22 号白名单 v3 事件 22~29)。
class PostAnalytics {
PostAnalytics(this._track);
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
final TrackEventFn _track;
// ---- 发布漏斗 ----
/// 进入发布页并产生**首次输入**(首个字符或首次选媒体),每次进入记一次。
/// 草稿恢复不算输入(非用户动作,不上报)。
void postCreateStarted({required PostEntryPoint entryPoint}) {
_track('post_create_started', {'entryPoint': entryPoint.value});
}
/// 草稿保存**成功响应后**;仅显式保存与离开时保存(自动保存不埋)。
void postDraftSaved({
required DraftSaveTrigger trigger,
required int mediaCount,
}) {
_track('post_draft_saved', {
'trigger': trigger.value,
'mediaCount': mediaCount,
});
}
/// 发布成功响应后(漏斗事件,H5/H6 核心数据源)。
///
/// [durationMs]`post_create_started` → 发布成功;[textLength] 经
/// [textLengthBucketOf] 分桶后上报,精确字数不出端;[fromDraft] 指
/// 「本次发布基于先前保存/恢复的草稿」(发布内部的建草稿→迁移两步
/// 不算,见 26 号报告 §3)。
void postPublishSucceeded({
required int durationMs,
required int mediaCount,
required int topicCount,
required int textLength,
required bool fromDraft,
}) {
_track('post_publish_succeeded', {
'durationMs': durationMs,
'mediaCount': mediaCount,
'topicCount': topicCount,
'textLengthBucket': textLengthBucketOf(textLength),
'fromDraft': fromDraft,
});
}
/// 发布失败 / 超时 / 本地校验拦截。
///
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位业务码
/// 推导(`code ~/ 100`pet 域同款);[attemptSeq] 为本次发布会话内第几次
/// 尝试(从 1 起,发布成功或离开发布页后重置)。
void postPublishFailed({
required PostPublishFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('post_publish_failed', {
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
/// 删帖成功响应后(单事件风格,无专有属性;失败靠服务端错误率观测)。
/// M3 触点:发布页「不保留草稿」删除服务端草稿。
void postDeleted() {
_track('post_deleted', const {});
}
// ---- 媒体上传三段(逐文件)----
/// 单个文件开始上传(一次尝试恰一条;重试各记一条)。
///
/// [byteSize] 取**选图原文件**字节数,保证同一次尝试三段事件的
/// `sizeBucket` 一致(压缩产物大小不另开一套桶)。
void mediaUploadStarted({
required MediaType mediaType,
required int byteSize,
}) {
_track('post_media_upload_started', {
'mediaType': mediaType.value,
'sizeBucket': mediaSizeBucketOf(byteSize),
});
}
/// 单文件上传成功(confirm 返回 ready 后)。
/// [durationMs]:本次尝试 started → ready。
void mediaUploadSucceeded({
required MediaType mediaType,
required int byteSize,
required int durationMs,
}) {
_track('post_media_upload_succeeded', {
'mediaType': mediaType.value,
'sizeBucket': mediaSizeBucketOf(byteSize),
'durationMs': durationMs,
});
}
/// 单文件失败 / 超时 / 用户取消。
void mediaUploadFailed({
required MediaType mediaType,
required int byteSize,
required MediaUploadFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('post_media_upload_failed', {
'mediaType': mediaType.value,
'sizeBucket': mediaSizeBucketOf(byteSize),
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
}
@@ -0,0 +1,704 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_display.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:uuid/uuid.dart';
/// 发布页(P305 号规范 §2.3;T3-17 真实数据整页落地)。
///
/// 结构:媒体选择区([PostMediaEditGrid] 组装 [MediaUploader])→ 正文
/// → 类目(general / help)→ 位置占位;AppBar 三件套「取消 / 发布动态 /
/// 发布」,另置「存草稿」。
///
/// 两条提交路径(15 号后端语义 §2.4):
///
/// - **直接发布** = `createPost(status: draft)` 建草稿 → `PATCH
/// {status: published}` 迁移发布。两步而非「一步建 published」是为了让
/// 「发布失败但草稿已保存」成为事实而不是话术:迁移这一步失败时草稿
/// 已在服务端,用户内容不会丢。
/// - **存草稿退出** = 同一个 `createPost(status: draft)`(或对已有草稿
/// `PATCH`),随后离页。
///
/// 幂等纪律:建草稿的 `Idempotency-Key` 由**本页持有**——网络失败重试
/// 沿用同键(服务端命中首帖,不重复建帖);表单一经改动即换新键
/// (避免「同键异 payload」的 40905 常态化)。
///
/// 草稿管理最小实现:进页拉取「我的草稿」最新一条并恢复(05 §2.3 的
/// 「已恢复上次草稿」提示条),完整草稿列表页留待(26 号报告 §7)。
class PostComposePage extends StatefulWidget {
const PostComposePage({
required this.controller,
super.key,
this.entryPoint = PostEntryPoint.createTab,
this.analytics,
this.uploaderFactory,
this.now,
});
/// Tab 级单例(app.dart 装配):发布成功后由调用方触发 Feed 刷新。
final CommunityController controller;
/// 入口归因(`post_create_started.entryPoint`)。
final PostEntryPoint entryPoint;
/// post 域埋点(发布漏斗五事件 + 媒体三段,媒体段经 [MediaUploader])。
final PostAnalytics? analytics;
/// [MediaUploader] 构造口(测试 / 桌面实测替换选图与压缩层)。
final MediaUploaderFactory? uploaderFactory;
/// 时钟注入口(durationMs 断言用;缺省取当前时间)。
final DateTime Function()? now;
@override
State<PostComposePage> createState() => _PostComposePageState();
}
class _PostComposePageState extends State<PostComposePage> {
static const _maxContentLength = 1000;
final _contentController = TextEditingController();
final _uuid = const Uuid();
late final MediaUploader _uploader;
PostCategory _category = PostCategory.general;
/// 服务端草稿标识与乐观锁版本(建草稿成功或恢复草稿后非空)。
String? _draftPostId;
int? _draftVersion;
/// 恢复来的草稿既有媒体(uploader 只持本地选图,服务端媒体只读呈现)。
List<PostMediaItem> _draftMedia = const [];
/// 「已恢复上次草稿」提示条可见性。
bool _restoredBannerVisible = false;
/// 草稿恢复期间的输入监听抑制(恢复不是「首次输入」,不发 started)。
bool _restoring = false;
/// 本页是否基于先前保存/恢复的草稿发布(`fromDraft` 口径)。
bool _fromDraft = false;
/// 上一次同步到服务端的 ready assetId 签名(media 三态判定:
/// 与当前一致即 PATCH 缺席不动,不一致才整组替换)。
String? _syncedMediaSignature;
/// 建草稿幂等键(同键重放;表单改动即置 null 换新键)。
String? _idempotencyKey;
bool _publishing = false;
bool _savingDraft = false;
/// 发布失败横幅(页内停留供对照,不用 SnackBar)。
String? _publishErrorMessage;
/// 「草稿已保存」的伴随提示(发布失败时告知内容未丢)。
bool _draftPreservedHint = false;
/// 「已保存草稿 ✓」提示(保存动作后显示)。
bool _draftSavedHint = false;
/// 首次输入已上报 started。
bool _started = false;
DateTime? _startedAt;
/// 本页发布尝试序号(attemptSeq,从 1 起)。
int _publishAttemptSeq = 0;
CommunityController get _controller => widget.controller;
DateTime _nowValue() => (widget.now ?? DateTime.now)();
@override
void initState() {
super.initState();
_uploader = (widget.uploaderFactory ?? _defaultUploaderFactory)(
_controller.repository,
widget.analytics,
);
_uploader.addListener(_onUploaderChanged);
_contentController.addListener(_onContentChanged);
unawaited(_restoreLatestDraft());
}
static MediaUploader _defaultUploaderFactory(
CommunityRepository repository,
PostAnalytics? analytics,
) => MediaUploader(repository: repository, analytics: analytics);
@override
void dispose() {
_uploader.removeListener(_onUploaderChanged);
// 在途上传作废(未 confirm 的 asset 弃引用,服务端超时清理兜底)。
_uploader.reset();
_uploader.dispose();
_contentController.dispose();
super.dispose();
}
// ---- 输入与状态 ----
String get _content => _contentController.text.trim();
bool get _isEmptyForm =>
_content.isEmpty && _uploader.isEmpty && _draftMedia.isEmpty;
/// 发布 gating05 §2.2/§2.3 + 后端 content 必填):正文非空、
/// 在场媒体全部 ready、无在途提交。
bool get _canPublish =>
_content.isNotEmpty &&
(_uploader.isEmpty || _uploader.allReady) &&
!_publishing &&
!_savingDraft;
void _onContentChanged() {
if (_restoring) return;
_markDirty();
_reportStartedOnce();
setState(() {});
}
void _onUploaderChanged() {
if (_uploader.items.isNotEmpty) _reportStartedOnce();
_markDirty();
setState(() {});
}
/// 表单一经改动即弃用旧幂等键(下次提交换新键,杜绝 40905 常态化)。
void _markDirty() {
_idempotencyKey = null;
_draftSavedHint = false;
}
void _reportStartedOnce() {
if (_started) return;
if (_content.isEmpty && _uploader.isEmpty) return;
_started = true;
_startedAt = _nowValue();
widget.analytics?.postCreateStarted(entryPoint: widget.entryPoint);
}
// ---- 草稿恢复(最小实现:最新一条)----
Future<void> _restoreLatestDraft() async {
try {
final page = await _controller.repository.listMyPosts(
limit: 1,
status: PostStatus.draft,
);
if (!mounted || page.items.isEmpty) return;
final draft = page.items.first;
_restoring = true;
_contentController.text = draft.content;
_restoring = false;
setState(() {
_draftPostId = draft.id;
_draftVersion = draft.version;
_draftMedia = draft.media;
// ai_creation(M4 预留读侧值)不在发布页可选集内,回落 general。
_category = draft.category == PostCategory.aiCreation
? PostCategory.general
: draft.category;
_restoredBannerVisible = true;
_fromDraft = true;
_syncedMediaSignature = _mediaSignature();
});
} on ApiException {
// 草稿恢复失败静默降级为「新建」,不阻塞发布(不打扰)。
}
}
void _clearRestoredDraft() {
_restoring = true;
_contentController.clear();
_restoring = false;
setState(() {
_draftMedia = const [];
_restoredBannerVisible = false;
_idempotencyKey = null;
});
}
// ---- 媒体 ----
Future<void> _pickImages() async {
await _uploader.pickAndAdd();
if (!mounted) return;
if (_uploader.remainingSlots <= 0) {
_showSnackBar('最多可选 ${_uploader.maxImages} 张图片');
}
}
String _mediaSignature() => _uploader.items
.where((item) => item.isReady)
.map((item) => item.assetId)
.join(',');
/// 当前选图的挂接请求(全 ready 才可取;封面取首张)。
List<PostMediaAttachRequest>? _mediaAttachOrNull() =>
_uploader.isEmpty ? null : _uploader.buildAttachRequests();
// ---- 发布(建草稿 → 迁移发布)----
Future<void> _publish() async {
if (!_canPublish) return;
FocusScope.of(context).unfocus();
_publishAttemptSeq += 1;
setState(() {
_publishing = true;
_publishErrorMessage = null;
_draftPreservedHint = false;
});
final signature = _mediaSignature();
final fromDraft = _fromDraft && _draftPostId != null;
try {
if (_draftPostId == null) {
final key = _idempotencyKey ??= _uuid.v4();
final draft = await _controller.repository.createPost(
CreatePostRequest(
content: _content,
category: _category,
status: PostStatus.draft,
media: _mediaAttachOrNull(),
),
idempotencyKey: key,
);
_draftPostId = draft.id;
_draftVersion = draft.version;
_syncedMediaSignature = signature;
}
await _patchPublish(signature);
if (!mounted) return;
widget.analytics?.postPublishSucceeded(
durationMs: _elapsedSinceStart(),
mediaCount: _uploader.items.length + _keptDraftMediaCount(signature),
// 话题(TopicChip / 话题选择 sheet)无契约端点,M3 恒 0。
topicCount: 0,
textLength: _content.length,
fromDraft: fromDraft,
);
_uploader.reset();
Navigator.of(context).pop(true);
} on ApiException catch (error) {
if (!mounted) return;
_handlePublishError(error);
} finally {
if (mounted) setState(() => _publishing = false);
}
}
/// PATCH 迁移发布;乐观锁过期(40902,别处改过草稿)自动刷新 version
/// 重提一次。已发布帖重复提交为幂等 no-op(15 号 §2.4),弱网重放安全。
Future<void> _patchPublish(String signature) async {
final media = signature == _syncedMediaSignature
? null // 缺席不动(服务端媒体与本地选图一致)
: _mediaAttachOrNull() ?? const <PostMediaAttachRequest>[];
UpdatePostRequest request(int version) => UpdatePostRequest(
version: version,
content: _content,
category: _category,
publish: true,
media: media,
);
try {
await _controller.repository.updatePost(
_draftPostId!,
request(_draftVersion!),
);
} on PostVersionConflictException {
final latest = await _controller.repository.getPost(_draftPostId!);
_draftVersion = latest.version;
await _controller.repository.updatePost(
_draftPostId!,
request(latest.version),
);
}
_syncedMediaSignature = signature;
}
/// 恢复草稿的服务端既有媒体在本次发布中被保留的张数(mediaCount 口径)。
int _keptDraftMediaCount(String signature) =>
signature == _syncedMediaSignature && _uploader.isEmpty
? _draftMedia.length
: 0;
int _elapsedSinceStart() {
final startedAt = _startedAt;
if (startedAt == null) return 0;
return _nowValue().difference(startedAt).inMilliseconds;
}
void _handlePublishError(ApiException error) {
final reason = postPublishFailureReasonOf(error);
if (reason != null) {
widget.analytics?.postPublishFailed(
reason: reason,
attemptSeq: _publishAttemptSeq,
errorCode: error is ApiBusinessException ? error.code : null,
);
}
if (error is IdempotencyMismatchException) {
// 同键异 payload:弃用旧键,下次提交换新键即可成功。
_idempotencyKey = null;
}
if (error is PostNotFoundException) {
// 草稿在别处被删:解除关联,重试走全新建草稿。
_draftPostId = null;
_draftVersion = null;
_fromDraft = false;
}
setState(() {
_publishErrorMessage = postPublishErrorMessage(error);
// 草稿已在服务端 → 明确告知内容未丢(本单核心提示语义)。
_draftPreservedHint = _draftPostId != null;
});
}
// ---- 存草稿 ----
Future<bool> _saveDraft(DraftSaveTrigger trigger) async {
if (_isEmptyForm) return true;
if (_content.isEmpty) {
_showSnackBar('请先写点什么再保存草稿');
return false;
}
if (_uploader.hasBusyItem) {
_showSnackBar('图片还在上传中,请稍候再保存草稿');
return false;
}
if (_uploader.hasFailure) {
_showSnackBar('有图片上传失败,请重试或删除后再保存草稿');
return false;
}
setState(() {
_savingDraft = true;
_publishErrorMessage = null;
});
final signature = _mediaSignature();
try {
if (_draftPostId == null) {
final key = _idempotencyKey ??= _uuid.v4();
final draft = await _controller.repository.createPost(
CreatePostRequest(
content: _content,
category: _category,
status: PostStatus.draft,
media: _mediaAttachOrNull(),
),
idempotencyKey: key,
);
_draftPostId = draft.id;
_draftVersion = draft.version;
} else {
final updated = await _controller.repository.updatePost(
_draftPostId!,
UpdatePostRequest(
version: _draftVersion!,
content: _content,
category: _category,
media: signature == _syncedMediaSignature
? null
: _mediaAttachOrNull() ?? const <PostMediaAttachRequest>[],
),
);
_draftVersion = updated.version;
}
_syncedMediaSignature = signature;
_fromDraft = true;
widget.analytics?.postDraftSaved(
trigger: trigger,
mediaCount: _uploader.items.length + _keptDraftMediaCount(signature),
);
if (mounted) setState(() => _draftSavedHint = true);
return true;
} on ApiException catch (error) {
if (mounted) _showSnackBar(draftSaveErrorMessage(error));
return false;
} finally {
if (mounted) setState(() => _savingDraft = false);
}
}
/// 「不保留」:已落服务端的草稿一并软删(`post_deleted` 触点)。
Future<void> _discardDraft() async {
final draftId = _draftPostId;
if (draftId == null) return;
try {
await _controller.repository.deletePost(draftId);
widget.analytics?.postDeleted();
} on ApiException {
// 删除失败不拦住离页(草稿留在服务端,下次进页可恢复)。
}
}
// ---- 离页 ----
Future<void> _onCancel() async {
if (_publishing || _savingDraft) return;
if (_isEmptyForm) {
Navigator.of(context).pop(false);
return;
}
final choice = await showDialog<_ExitChoice>(
context: context,
builder: (context) => AlertDialog(
title: const Text('保留草稿?'),
content: const Text('保留后下次进入发布页可继续编辑。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, _ExitChoice.keepEditing),
child: const Text('继续编辑'),
),
TextButton(
onPressed: () => Navigator.pop(context, _ExitChoice.discard),
child: const Text('不保留'),
),
FilledButton(
onPressed: () => Navigator.pop(context, _ExitChoice.keep),
child: const Text('保留'),
),
],
),
);
if (!mounted || choice == null || choice == _ExitChoice.keepEditing) return;
if (choice == _ExitChoice.discard) {
await _discardDraft();
if (!mounted) return;
Navigator.of(context).pop(false);
return;
}
final saved = await _saveDraft(DraftSaveTrigger.onExit);
if (!mounted || !saved) return;
Navigator.of(context).pop(false);
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// ---- 渲染 ----
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PopScope(
canPop: _isEmptyForm && !_publishing && !_savingDraft,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_onCancel());
},
child: Scaffold(
appBar: AppBar(
leadingWidth: 76,
leading: Center(
child: TextButton(
onPressed: _publishing ? null : () => unawaited(_onCancel()),
style: TextButton.styleFrom(foregroundColor: AppColors.ink),
child: const Text('取消'),
),
),
title: const Text('发布动态'),
actions: [
TextButton(
onPressed: _publishing || _savingDraft
? null
: () => unawaited(_saveDraft(DraftSaveTrigger.manual)),
child: const Text('存草稿'),
),
const SizedBox(width: 4),
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 20),
),
onPressed: _canPublish ? () => unawaited(_publish()) : null,
child: _publishing
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('发布'),
),
const SizedBox(width: 12),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
if (_restoredBannerVisible) ...[
_RestoredDraftBanner(onClear: _clearRestoredDraft),
const SizedBox(height: 12),
],
if (_publishErrorMessage != null) ...[
InlineErrorBanner(message: _publishErrorMessage!),
if (_draftPreservedHint) ...[
const SizedBox(height: 6),
const Text(
'草稿已保存,可稍后继续发布',
style: TextStyle(fontSize: 12, color: AppColors.inkSoft),
),
],
const SizedBox(height: 12),
],
if (_draftSavedHint) ...[
const Text(
'已保存草稿 ✓',
style: TextStyle(fontSize: 12, color: AppColors.inkSoft),
),
const SizedBox(height: 12),
],
..._mediaSection(),
if (_uploader.hasBusyItem) ...[
const SizedBox(height: 8),
_UploadSummaryBar(
progress: _uploader.overallProgress,
readyCount: _uploader.readyCount,
total: _uploader.items.length,
),
],
const SizedBox(height: 16),
TextField(
controller: _contentController,
minLines: 6,
maxLines: null,
maxLength: _maxContentLength,
keyboardType: TextInputType.multiline,
decoration: const InputDecoration(
hintText: '分享毛孩子的日常,或向宠友求助…',
alignLabelWithHint: true,
),
),
const SizedBox(height: 12),
Text('分类', style: theme.textTheme.bodySmall),
const SizedBox(height: 7),
Wrap(
spacing: 8,
children: [
for (final entry in const [
(PostCategory.general, '日常分享'),
(PostCategory.help, '求助'),
])
ChoiceChip(
label: Text(entry.$2),
selected: _category == entry.$1,
onSelected: (_) {
_markDirty();
setState(() => _category = entry.$1);
},
),
],
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.location_on_outlined),
title: const Text('添加位置(选填)'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showSnackBar('位置功能即将上线'),
),
],
),
),
);
}
List<Widget> _mediaSection() {
final showDraftMedia = _uploader.isEmpty && _draftMedia.isNotEmpty;
return [
PostMediaEditGrid(
items: _uploader.items,
canAdd: _uploader.remainingSlots > 0,
onAdd: _uploader.isPicking ? null : () => unawaited(_pickImages()),
onRemove: _uploader.remove,
onRetry: _uploader.retry,
),
if (showDraftMedia) ...[
const SizedBox(height: 8),
Text(
'草稿已含 ${_draftMedia.length} 张图片(发布时保留;重新选图将整组替换)',
style: const TextStyle(fontSize: 12, color: AppColors.inkSoft),
),
],
];
}
}
enum _ExitChoice { keep, discard, keepEditing }
/// 「已恢复上次草稿」提示条(05 §2.3:surfaceTint 底、圆角 sm12)。
class _RestoredDraftBanner extends StatelessWidget {
const _RestoredDraftBanner({required this.onClear});
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
decoration: const BoxDecoration(
color: AppColors.surfaceTint,
borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)),
),
child: Row(
children: [
const Expanded(
child: Text(
'已恢复上次草稿',
style: TextStyle(fontSize: 12, color: AppColors.primaryDark),
),
),
TextButton(onPressed: onClear, child: const Text('清空')),
],
),
);
}
}
/// 页级上传汇总条(05 §3.3 末段:线性进度 + 「正在上传 n/N」)。
class _UploadSummaryBar extends StatelessWidget {
const _UploadSummaryBar({
required this.progress,
required this.readyCount,
required this.total,
});
final double progress;
final int readyCount;
final int total;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
'正在上传 $readyCount/$total',
style: const TextStyle(fontSize: 12, color: AppColors.inkSoft),
),
const SizedBox(width: 10),
Expanded(
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
color: AppColors.primaryStrong,
backgroundColor: AppColors.surfaceTint,
),
),
],
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'dart:async';
/// 服务端返回的开关权威终态(LikeState / BookmarkState 的统一投影)。
class ToggleOutcome {
const ToggleOutcome({required this.active, required this.count});
final bool active;
final int count;
}
/// 内存副本当前读数(乐观翻转与回滚校验的基准)。
class ToggleReading {
const ToggleReading({required this.active, required this.count});
final bool active;
final int count;
}
/// 点赞 / 收藏共用的乐观更新小状态机(03 号评估 §3 定稿的数据层部分):
/// **乐观翻转 + 快照回滚 + 单飞合并意图 + 代次守卫**。字段读写与端点
/// 全部参数化,like / bookmark 各持一实例,不复制两份逻辑。
///
/// 对每个 id 的一轮「操作链」:
/// 1. 点击立即经 [write] 翻转内存副本(UI 同帧反馈由持有方 notify);
/// 2. 非在途则记快照、发请求(PUT/DELETE 语义幂等,重放安全);
/// 在途则只把新意图并入 pendingTarget**不发新请求**(单飞);
/// 3. 成功:pendingTarget 与已确认态不一致 → 以 pendingTarget 补发一次
/// (连续快速点击至多两个在途请求,中间抖动全被合并);一致 → 用服务端
/// 权威计数覆盖乐观计数(吸收他人并发造成的偏差),清状态;
/// 4. 失败:恢复链起点快照(先校验 id 仍可读且当前态仍是本轮乐观目标,
/// 避免覆盖新数据),经 [onError] 轻提示,**不自动重试**
/// 5. 代次守卫:响应到达时 [generation] 与链起点不符(期间发生过刷新,
/// 列表已被服务端数据整体替换)→ 丢弃该响应,不覆盖不回滚。
class ToggleSync {
ToggleSync({
required this._read,
required this._write,
required this._send,
required this._generation,
this._onError,
});
final ToggleReading? Function(String id) _read;
final void Function(String id, bool active, int count) _write;
final Future<ToggleOutcome> Function(String id, bool target) _send;
final int Function() _generation;
final void Function(String id, Object error)? _onError;
final Map<String, _ToggleChain> _chains = {};
/// 该 id 是否有请求在途(测试与调试观测口)。
bool isInFlight(String id) => _chains.containsKey(id);
/// 翻转一次。同步完成乐观写入;网络往返在后台收敛,不外抛。
void toggle(String id) {
final current = _read(id);
if (current == null) return; // 已不在列表(刷新剔除),本次点击作废。
final target = !current.active;
final optimisticCount = target
? current.count + 1
: (current.count - 1 < 0 ? 0 : current.count - 1);
_write(id, target, optimisticCount);
final chain = _chains[id];
if (chain != null) {
chain.pendingTarget = target;
return;
}
final started = _ToggleChain(
snapshot: current,
generation: _generation(),
target: target,
);
_chains[id] = started;
unawaited(_run(id, started));
}
/// 登出 / 整体刷新清态:丢弃全部链(在途响应因代次或链失配被丢弃)。
void reset() => _chains.clear();
Future<void> _run(String id, _ToggleChain chain) async {
while (true) {
ToggleOutcome outcome;
try {
outcome = await _send(id, chain.target);
} catch (error) {
if (_chains[id] == chain && _generation() == chain.generation) {
final current = _read(id);
final lastTarget = chain.pendingTarget ?? chain.target;
// 回滚前校验:id 仍可读且当前态仍是本轮乐观写入的目标态。
if (current != null && current.active == lastTarget) {
_write(id, chain.snapshot.active, chain.snapshot.count);
}
_onError?.call(id, error);
}
_release(id, chain);
return;
}
if (_chains[id] != chain || _generation() != chain.generation) {
_release(id, chain);
return; // 代次不符 / 已被 reset:丢弃响应,不覆盖不回滚。
}
final pending = chain.pendingTarget;
if (pending != null && pending != outcome.active) {
chain.target = pending;
chain.pendingTarget = null;
continue; // 以最终意图补发一次。
}
_write(id, outcome.active, outcome.count);
_release(id, chain);
return;
}
}
void _release(String id, _ToggleChain chain) {
if (_chains[id] == chain) _chains.remove(id);
}
}
/// 一轮操作链的在途状态。
class _ToggleChain {
_ToggleChain({
required this.snapshot,
required this.generation,
required this.target,
});
/// 链起点快照(回滚基准)。
final ToggleReading snapshot;
/// 链起点的刷新代次。
final int generation;
/// 当前在途请求的目标态。
bool target;
/// 在途期间用户新点出的最终意图(完成后据此决定是否补发)。
bool? pendingTarget;
}
+46 -30
View File
@@ -7,15 +7,23 @@ import 'package:patbond_flutter/widgets/common.dart';
enum CreationMode { image, video }
/// 创作 Tab。
///
/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)
/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页
/// `PostComposePage`push 全屏)承担——本页顶部「发布动态」入口即其
/// 入口(entryPoint=create_tab),`AppState.publishPost` demo 发布流退役。
class CreatePage extends StatefulWidget {
const CreatePage({
required this.appState,
required this.onPublished,
required this.onOpenCompose,
super.key,
});
final AppState appState;
final ValueChanged<PostModel> onPublished;
/// 真实发布页入口(主壳 push,发布成功后回首页 Feed 刷新)。
final VoidCallback onOpenCompose;
@override
State<CreatePage> createState() => _CreatePageState();
@@ -111,36 +119,13 @@ class _CreatePageState extends State<CreatePage> {
setState(() => tags = [...tags, value]);
}
/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media
/// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写
/// `AppState.posts`)随 T3-17 退役,此处只余占位提示。
void publish() {
if (resultUrl == null || titleController.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先完成生成并填写标题')));
return;
}
final post = PostModel(
id: 'post_user_${DateTime.now().millisecondsSinceEpoch}',
authorName: '萌宠新手(我)',
authorAvatar: userAvatar,
time: '刚刚',
breedTag: 'AI创作',
content:
'${titleController.text.trim()}\n\n${contentController.text.trim()}',
mainImage: resultUrl!,
likes: 1,
tags: [...tags, mode == CreationMode.image ? 'AI生图' : 'AI视频'],
comments: const [],
hasLiked: true,
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')),
);
widget.appState.publishPost(post);
setState(() {
uploaded = false;
resultUrl = null;
generationStep = 0;
titleController.clear();
contentController.clear();
});
widget.onPublished(post);
}
@override
@@ -148,6 +133,8 @@ class _CreatePageState extends State<CreatePage> {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
_ComposeEntryCard(onTap: widget.onOpenCompose),
const SizedBox(height: 18),
SegmentedButton<CreationMode>(
segments: const [
ButtonSegment(
@@ -402,6 +389,35 @@ class _CreatePageState extends State<CreatePage> {
}
}
/// 真实发布入口(T3-17):本页其余部分是 AI 创作模拟(M4),社区发帖
/// 走这里 push 的发布页。
class _ComposeEntryCard extends StatelessWidget {
const _ComposeEntryCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SectionCard(
padding: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: const CircleAvatar(
backgroundColor: AppColors.surfaceTint,
child: Icon(Icons.edit_outlined, color: AppColors.primary),
),
title: const Text(
'发布动态',
style: TextStyle(fontWeight: FontWeight.w800),
),
subtitle: const Text('写点文字、配上照片,分享给宠友'),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _UploadCard extends StatelessWidget {
const _UploadCard({
required this.uploaded,
+384 -134
View File
@@ -1,42 +1,252 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_display.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/community/feed_exposure.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum HomeSegment { feed, services }
/// 首页 TabFeed 段自 T3-14 起消费 [CommunityController] 真实数据
/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与
/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。
class HomePage extends StatefulWidget {
const HomePage({
required this.appState,
required this.onOpenPost,
required this.communityController,
required this.onOpenServices,
required this.onOpenCreate,
required this.onOpenCompose,
super.key,
this.onOpenPost,
this.feedAnalytics,
this.isActive = true,
});
final AppState appState;
final ValueChanged<PostModel> onOpenPost;
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
final CommunityController communityController;
final ValueChanged<bool> onOpenServices;
final VoidCallback onOpenCreate;
/// 发布页入口(T3-17story 环「发布」与空态 CTA 均 push 真实发布页,
/// entryPoint=feed;创作 Tab 的 AI 模拟不再是社区发帖入口)。
final VoidCallback onOpenCompose;
/// 帖子详情导航(T3-15 接通;主壳 push PostDetailPage)。
final ValueChanged<String>? onOpenPost;
/// feed 域埋点(feed_viewed 聚合曝光 + feed_load_failed)。
final FeedAnalytics? feedAnalytics;
/// 首页 Tab 是否为当前可见 TabIndexedStack 各 Tab 常驻构建,
/// 由主壳传入以驱动浏览段开/结算)。
final bool isActive;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
HomeSegment segment = HomeSegment.feed;
String query = '';
String sort = '综合';
List<PostModel> get filteredPosts {
/// 当前 Feed 浏览段(不在 Feed 面上时为 null)。
FeedViewSegment? _viewSegment;
/// 曝光扫描用:postId → 卡片 GlobalKey。
final Map<String, GlobalKey> _cardKeys = {};
final GlobalKey _listKey = GlobalKey();
AppLifecycleState _lastLifecycle = AppLifecycleState.resumed;
CommunityController get _feed => widget.communityController;
bool get _feedSurfaceVisible =>
widget.isActive && segment == HomeSegment.feed;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。
// 首屏自动预取不计入浏览段 refreshCount(非用户动作)。
if (_feed.phase == FeedPhase.initial) {
_refreshFeed(userInitiated: false);
}
if (_feedSurfaceVisible) _startSegment();
}
@override
void didUpdateWidget(HomePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
widget.isActive ? _maybeStartSegment() : _settleSegment();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// 退后台即结算浏览段(06 §1.2:feed_viewed 触发点之一);只在离开
// resumed 的第一次变更结算(inactive/hidden/paused 级联不重复)。
// 回前台若仍在 Feed 面上则开新段。
if (state == AppLifecycleState.resumed) {
_lastLifecycle = state;
_maybeStartSegment();
return;
}
if (_lastLifecycle == AppLifecycleState.resumed) {
_settleSegment();
}
_lastLifecycle = state;
}
@override
void dispose() {
_settleSegment();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// ---- 浏览段开/结算 ----
void _startSegment() {
_viewSegment ??= FeedViewSegment();
_scheduleVisibilityScan();
}
void _maybeStartSegment() {
if (_feedSurfaceVisible && _lastLifecycle == AppLifecycleState.resumed) {
_startSegment();
}
}
void _settleSegment() {
final summary = _viewSegment?.settle();
_viewSegment = null;
if (summary != null && widget.feedAnalytics != null) {
summary.report(widget.feedAnalytics!);
}
}
void _onSegmentChanged(HomeSegment value) {
if (value == segment) return;
setState(() => segment = value);
value == HomeSegment.feed ? _maybeStartSegment() : _settleSegment();
}
// ---- 曝光可见性扫描 ----
bool _scanScheduled = false;
/// 帧末扫描(滚动通知发生在本帧布局前,同步读 RenderBox 是旧位置;
/// 统一调度到 post-frame,一帧至多一次)。
void _scheduleVisibilityScan() {
if (_scanScheduled) return;
_scanScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_scanScheduled = false;
_scanVisibility();
});
}
/// 以列表视口与各卡片 RenderBox 的纵向交叠比例回报可见性
/// (≥50% 且驻留 ≥500ms 才计曝光,判定在 [FeedViewSegment] 内)。
void _scanVisibility() {
final viewSegment = _viewSegment;
if (viewSegment == null || !mounted) return;
final listBox = _listKey.currentContext?.findRenderObject() as RenderBox?;
if (listBox == null || !listBox.attached) return;
final viewportTop = listBox.localToGlobal(Offset.zero).dy;
final viewportBottom = viewportTop + listBox.size.height;
for (final entry in _cardKeys.entries) {
final box = entry.value.currentContext?.findRenderObject() as RenderBox?;
var fraction = 0.0;
if (box != null && box.attached && box.hasSize && box.size.height > 0) {
final top = box.localToGlobal(Offset.zero).dy;
final bottom = top + box.size.height;
final visible =
bottom.clamp(viewportTop, viewportBottom) -
top.clamp(viewportTop, viewportBottom);
fraction = visible / box.size.height;
}
viewSegment.updateVisibility(entry.key, fraction);
}
}
bool _onScrollNotification(ScrollNotification notification) {
if (segment != HomeSegment.feed) return false;
if (notification is ScrollUpdateNotification ||
notification is ScrollEndNotification) {
_scheduleVisibilityScan();
// 滚动近底触发翻页(余量 400 提前预取);失败态不自动重试,
// 只走尾部重试条显式点按(避免滚动风暴反复打失败端点)。
if (notification.metrics.extentAfter < 400 &&
_feed.loadMorePhase == LoadMorePhase.idle) {
_loadMoreFeed();
}
}
return false;
}
// ---- 数据加载与失败埋点 ----
Future<void> _refreshFeed({required bool userInitiated}) async {
if (userInitiated) _viewSegment?.recordRefresh();
await _feed.refresh();
final error = _feed.refreshError ?? _feed.lastError;
if (error != null) {
widget.feedAnalytics?.feedLoadFailedFrom(
error,
feedTab: FeedTab.home,
loadType: FeedLoadType.refresh,
);
}
// 刷新失败但旧列表被保留:SnackBar 轻提示(首屏失败走 error 态横幅)。
if (_feed.refreshError != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(feedLoadErrorMessage(_feed.refreshError))),
);
}
}
Future<void> _loadMoreFeed() async {
if (_feed.phase != FeedPhase.ready ||
!_feed.hasMore ||
_feed.loadMorePhase == LoadMorePhase.loading) {
return;
}
_viewSegment?.recordLoadMore();
await _feed.loadMore();
final error = _feed.loadMoreError;
if (error != null) {
widget.feedAnalytics?.feedLoadFailedFrom(
error,
feedTab: FeedTab.home,
loadType: FeedLoadType.loadMore,
);
}
}
/// 搜索词过滤(demo 交互保留:只过滤已加载的多页缓存,不发检索请求;
/// 契约 v1.3.0 无搜索端点)。
List<FeedCard> get _visibleCards {
final keyword = query.trim().toLowerCase();
if (keyword.isEmpty) return widget.appState.posts;
return widget.appState.posts.where((post) {
return post.content.toLowerCase().contains(keyword) ||
post.authorName.toLowerCase().contains(keyword) ||
post.tags.any((tag) => tag.toLowerCase().contains(keyword));
if (keyword.isEmpty) return _feed.feed;
return _feed.feed.where((card) {
return card.contentPreview.toLowerCase().contains(keyword) ||
(card.title?.toLowerCase().contains(keyword) ?? false) ||
authorDisplayName(card.author).toLowerCase().contains(keyword);
}).toList();
}
@@ -95,10 +305,32 @@ class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async =>
Future<void>.delayed(const Duration(milliseconds: 500)),
return ListenableBuilder(
listenable: _feed,
builder: (context, _) {
// 点赞/收藏对账失败的一次性 SnackBar(§3.5 回滚提示;与详情页
// 共用 controller 的 toggleError 消费口,先消费者清空)。
if (_feed.toggleError != null) {
_feed.clearToggleError();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
});
}
// ready 后(含翻页追加)补一次可见性扫描:无滚动也能记首屏曝光。
if (_viewSegment != null && _feed.phase == FeedPhase.ready) {
_scheduleVisibilityScan();
}
return NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: RefreshIndicator(
onRefresh: () => segment == HomeSegment.feed
? _refreshFeed(userInitiated: true)
: Future<void>.delayed(const Duration(milliseconds: 500)),
child: ListView(
key: _listKey,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
_WeatherStatusBar(
@@ -137,40 +369,24 @@ class _HomePageState extends State<HomePage> {
],
selected: {segment},
showSelectedIcon: false,
onSelectionChanged: (value) {
setState(() => segment = value.first);
},
onSelectionChanged: (value) => _onSegmentChanged(value.first),
),
const SizedBox(height: 18),
if (segment == HomeSegment.feed) ...[
_StoryRow(onCreate: widget.onOpenCreate),
_StoryRow(onCreate: widget.onOpenCompose),
const SizedBox(height: 18),
_PromoCard(onTap: () => widget.onOpenServices(true)),
const SizedBox(height: 18),
if (filteredPosts.isEmpty)
const EmptyState(message: '没有找到相关动态')
else
...filteredPosts.map(
(post) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _PostCard(
post: post,
onTap: () => widget.onOpenPost(post),
onLike: () => widget.appState.updatePost(
post.copyWith(
hasLiked: !post.hasLiked,
likes: post.hasLiked ? post.likes - 1 : post.likes + 1,
),
),
),
),
),
..._feedSection(),
] else ...[
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
const SizedBox(height: 18),
Row(
children: [
Text('附近推荐', style: Theme.of(context).textTheme.titleLarge),
Text(
'附近推荐',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
for (final item in ['综合', '距离', '评分'])
Padding(
@@ -201,7 +417,138 @@ class _HomePageState extends State<HomePage> {
],
],
),
),
);
},
);
}
// ---- Feed 段四态渲染 ----
List<Widget> _feedSection() {
switch (_feed.phase) {
case FeedPhase.initial:
case FeedPhase.loading:
return const [
FeedSkeleton(),
SizedBox(height: 16),
FeedSkeleton(),
SizedBox(height: 16),
FeedSkeleton(),
];
case FeedPhase.error:
return [
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
InlineErrorBanner(
message: feedLoadErrorMessage(_feed.lastError),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _refreshFeed(userInitiated: true),
child: const Text('重试'),
),
],
),
),
];
case FeedPhase.ready:
if (_feed.feed.isEmpty) {
return [
EmptyStateIllustration(
icon: Icons.forum_outlined,
title: '还没有动态',
description: '关注的毛孩子们还没发帖,去逛逛话题吧',
ctaLabel: '发布第一条',
onCtaPressed: widget.onOpenCompose,
),
];
}
final cards = _visibleCards;
_pruneCardKeys(cards);
if (cards.isEmpty) {
return const [EmptyState(message: '没有找到相关动态')];
}
return [
for (final card in cards)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: KeyedSubtree(
key: _cardKeys.putIfAbsent(card.id, GlobalKey.new),
// 点赞/收藏经共享 ToggleSyncsource=feed 缺省);整卡与
// 评论钮进详情(T3-15 导航接通,T3-14 的占位提示移除)。
child: PostCard(
card: card,
onTap: () => widget.onOpenPost?.call(card.id),
onCommentTap: () => widget.onOpenPost?.call(card.id),
onLikeTap: () => _feed.toggleLike(card.id),
onBookmarkTap: () => _feed.toggleBookmark(card.id),
),
),
),
// 搜索过滤中不渲染尾部(过滤只作用于已加载页,翻页语义混淆)。
if (query.trim().isEmpty) _feedTail(),
];
}
}
/// 卡片 key 表随当前列表收敛(刷新整体替换后旧帖的驻留计时作废)。
void _pruneCardKeys(List<FeedCard> cards) {
final alive = {for (final card in cards) card.id};
_cardKeys.removeWhere((id, _) {
if (alive.contains(id)) return false;
_viewSegment?.updateVisibility(id, 0);
return true;
});
}
/// 尾部三态:加载中转圈 / 失败重试 / 到底「没有更多了」。
Widget _feedTail() {
switch (_feed.loadMorePhase) {
case LoadMorePhase.loading:
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: AppColors.primary,
),
),
),
);
case LoadMorePhase.error:
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
Text(
feedLoadErrorMessage(_feed.loadMoreError),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
TextButton(
onPressed: _loadMoreFeed,
child: const Text('加载失败,点此重试'),
),
],
),
);
case LoadMorePhase.idle:
if (_feed.hasMore) return const SizedBox(height: 24);
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'没有更多了',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
),
);
}
}
}
@@ -661,103 +1008,6 @@ class _PromoCard extends StatelessWidget {
}
}
class _PostCard extends StatelessWidget {
const _PostCard({
required this.post,
required this.onTap,
required this.onLike,
});
final PostModel post;
final VoidCallback onTap;
final VoidCallback onLike;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
RemoteImage(
url: post.authorAvatar,
width: 38,
height: 38,
borderRadius: BorderRadius.circular(19),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.authorName,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(
'${post.time} · ${post.breedTag}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const Icon(Icons.more_horiz, color: AppColors.muted),
],
),
),
AspectRatio(
aspectRatio: 4 / 3,
child: RemoteImage(url: post.mainImage),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
children: [
ActionChip(
avatar: Icon(
post.hasLiked
? Icons.favorite
: Icons.favorite_border,
size: 17,
color: post.hasLiked ? Colors.red : AppColors.primary,
),
label: Text('${post.likes}'),
onPressed: onLike,
),
const SizedBox(width: 8),
Chip(
avatar: const Icon(Icons.chat_bubble_outline, size: 16),
label: Text('${post.comments.length}'),
),
const Spacer(),
const Icon(Icons.share_outlined, color: AppColors.muted),
],
),
],
),
),
],
),
),
);
}
}
class _CategoryGrid extends StatelessWidget {
const _CategoryGrid({required this.onTap});
+93 -15
View File
@@ -1,27 +1,73 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:patbond_flutter/features/community/post_compose_page.dart';
import 'package:patbond_flutter/features/create/create_page.dart';
import 'package:patbond_flutter/features/home/home_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/pets_page.dart';
import 'package:patbond_flutter/features/post/post_detail_page.dart';
import 'package:patbond_flutter/features/profile/profile_page.dart';
import 'package:patbond_flutter/features/services/services_page.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class MainShellPage extends StatefulWidget {
const MainShellPage({
required this.appState,
required this.petsController,
required this.communityController,
super.key,
this.currentUserId,
this.petAnalytics,
this.healthRecordAnalytics,
this.feedAnalytics,
this.interactionAnalytics,
this.postAnalytics,
this.mediaUploaderFactory,
this.pageViewTracker,
this.onLogout,
});
final AppState appState;
/// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。
final PetsController petsController;
/// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。
final CommunityController communityController;
/// 当前登录用户 id(详情页评论删除入口 / 关注钮自见性的 UI 判定)。
final String? currentUserId;
/// pet 域埋点强类型封装(建宠漏斗三事件)。
final PetAnalytics? petAnalytics;
/// health_record 域埋点强类型封装(T2-13 创建漏斗三事件 + viewed)。
final HealthRecordAnalytics? healthRecordAnalytics;
/// feed 域埋点(T3-14 聚合曝光 + 加载失败)。
final FeedAnalytics? feedAnalytics;
/// 互动域埋点(T3-16 评论成败对 + 关注对;详情页消费)。
final CommunityInteractionAnalytics? interactionAnalytics;
/// post 域埋点(T3-17 发布漏斗五事件 + 媒体三段;发布页消费)。
final PostAnalytics? postAnalytics;
/// 发布页 [MediaUploader] 构造口(桌面实测 / 测试替换选图与压缩层)。
final MediaUploaderFactory? mediaUploaderFactory;
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
final PageViewTracker? pageViewTracker;
@@ -38,11 +84,12 @@ class _MainShellPageState extends State<MainShellPage> {
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
/// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表)。
/// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表;档案 Tab 自 T2-12
/// 起为真实宠物列表,页名由 pet_archive 改报 pet_list06 §5.2 字典 v2)。
static const _tabPages = [
AnalyticsPageName.home,
AnalyticsPageName.create,
AnalyticsPageName.petArchive,
AnalyticsPageName.petList,
AnalyticsPageName.services,
AnalyticsPageName.profile,
];
@@ -67,16 +114,44 @@ class _MainShellPageState extends State<MainShellPage> {
widget.pageViewTracker?.reportTab(_tabPages[3]);
}
void openPost(PostModel post) {
/// 帖子详情(T3-15:真实数据整页;Feed 卡片与详情共享
/// communityController,互动状态跨页一致)。
void openPost(String postId) {
Navigator.of(context).push(
MaterialPageRoute<void>(
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
builder: (context) =>
PostDetailPage(appState: widget.appState, postId: post.id),
builder: (context) => PostDetailPage(
controller: widget.communityController,
postId: postId,
currentUserId: widget.currentUserId,
analytics: widget.interactionAnalytics,
),
),
);
}
/// 发布页(T3-17:真实发布链路)。发布成功后回首页 Feed 并整体刷新,
/// 新帖按 `(published_at DESC, id DESC)` 落在首位(跨客户端同一序)。
Future<void> openCompose(PostEntryPoint entryPoint) async {
final published = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
settings: RouteSettings(name: AnalyticsPageName.postForm.pageName),
builder: (context) => PostComposePage(
controller: widget.communityController,
entryPoint: entryPoint,
analytics: widget.postAnalytics,
uploaderFactory: widget.mediaUploaderFactory,
),
),
);
if (!mounted || published != true) return;
selectTab(0);
unawaited(widget.communityController.refresh());
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已发布,去首页看看吧 🐾')));
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -91,20 +166,23 @@ class _MainShellPageState extends State<MainShellPage> {
final pages = [
HomePage(
appState: widget.appState,
onOpenPost: openPost,
communityController: widget.communityController,
feedAnalytics: widget.feedAnalytics,
isActive: currentIndex == 0,
onOpenServices: openServices,
onOpenCreate: () => selectTab(1),
onOpenCompose: () => openCompose(PostEntryPoint.feed),
onOpenPost: openPost,
),
CreatePage(
appState: widget.appState,
onPublished: (post) {
selectTab(0);
WidgetsBinding.instance.addPostFrameCallback(
(_) => openPost(post),
);
},
// T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。
onOpenCompose: () => openCompose(PostEntryPoint.createTab),
),
PetsPage(
controller: widget.petsController,
analytics: widget.petAnalytics,
healthAnalytics: widget.healthRecordAnalytics,
),
PetsPage(appState: widget.appState),
ServicesPage(
showPersonal: showPersonalServices,
locationWeather: widget.appState.locationWeather,
@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 照护提醒创建表单页(T2-14)。
///
/// - 四类提醒类型(驱虫/体检/用药/其他)必选;标题必填;到期日期必选
/// (今日取此刻、未来日期取当日 12:00,转 UTC——与记录表单同一约定)。
/// - 契约:创建恒为 pending(不收 status);title/dueAt 无编辑端点,
/// 改期路径为忽略后重建。
/// - 埋点:health_record_create_started/succeeded/failed
/// recordType=reminder)。
class CareReminderFormPage extends StatefulWidget {
const CareReminderFormPage({
required this.repository,
required this.petId,
super.key,
this.analytics,
this.entryPoint = HealthRecordEntryPoint.recordList,
});
final PetsRepository repository;
final String petId;
final HealthRecordAnalytics? analytics;
final HealthRecordEntryPoint entryPoint;
@override
State<CareReminderFormPage> createState() => _CareReminderFormPageState();
}
class _CareReminderFormPageState extends State<CareReminderFormPage> {
final _titleCtrl = TextEditingController();
CareReminderType? _type;
DateTime? _dueDate;
String? _typeError;
String? _titleError;
String? _dueError;
String? _formError;
bool _submitting = false;
bool _startedFired = false;
int _attemptSeq = 0;
late final DateTime _openedAt;
@override
void initState() {
super.initState();
_openedAt = DateTime.now();
}
@override
void dispose() {
_titleCtrl.dispose();
super.dispose();
}
void _markStarted() {
if (_startedFired) return;
_startedFired = true;
widget.analytics?.createStarted(
recordType: HealthRecordType.reminder,
entryPoint: widget.entryPoint,
);
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.createFailed(
recordType: HealthRecordType.reminder,
reason: reason,
attemptSeq: _attemptSeq,
errorCode: errorCode,
);
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
/// 到期时刻:今日取此刻,其余日期取当日 12:00(本地),提交前转 UTC。
DateTime _dueAt() {
final now = DateTime.now();
final date = _dueDate!;
final sameDay =
date.year == now.year && date.month == now.month && date.day == now.day;
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
return local.toUtc();
}
Future<void> _submit() async {
if (_submitting) return;
_attemptSeq++;
final typeError = _type == null ? '请选择提醒类型' : null;
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入提醒内容' : null;
final dueError = _dueDate == null ? '请选择到期日期' : null;
if (typeError != null || titleError != null || dueError != null) {
setState(() {
_typeError = typeError;
_titleError = titleError;
_dueError = dueError;
});
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final reminder = await widget.repository.createCareReminder(
widget.petId,
CreateCareReminderRequest(
reminderType: _type!,
title: _titleCtrl.text.trim(),
dueAt: _dueAt(),
),
);
widget.analytics?.createSucceeded(
recordType: HealthRecordType.reminder,
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
);
if (mounted) Navigator.of(context).pop(reminder);
} on PetAccessDeniedException {
if (!mounted) return;
_showFormError('你没有权限为该宠物添加提醒');
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on PetNotFoundException {
if (!mounted) return;
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
_trackFailed(HealthRecordFailureReason.notFound, 40401);
navigator.pop();
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('操作过于频繁,请稍后再试');
_trackFailed(HealthRecordFailureReason.rateLimited);
} on ApiBusinessException catch (error) {
if (!mounted) return;
final isParam = error.code == ApiCodes.paramError;
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
_trackFailed(
isParam
? HealthRecordFailureReason.validationError
: HealthRecordFailureReason.serverError,
error.code,
);
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
_trackFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('添加照护提醒'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
children: [
const Text(
'提醒类型',
style: TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final type in CareReminderType.values)
ChoiceChip(
label: Text(careReminderTypeLabel(type)),
selected: _type == type,
onSelected: _submitting
? null
: (_) {
_markStarted();
setState(() {
_type = type;
_typeError = null;
_formError = null;
});
},
),
],
),
if (_typeError != null) ...[
const SizedBox(height: 6),
Text(
_typeError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 18),
AppTextField(
label: '提醒内容(如:体内外驱虫)',
controller: _titleCtrl,
prefixIcon: Icons.notifications_outlined,
errorText: _titleError,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onChanged: (_) {
_markStarted();
if (_titleError != null || _formError != null) {
setState(() {
_titleError = null;
_formError = null;
});
}
},
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 14),
ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
tileColor: AppColors.surface,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: const Text('到期日期', style: TextStyle(fontSize: 14)),
subtitle: Text(
_dueDate == null ? '未选择' : dateToJson(_dueDate!),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.calendar_month_outlined,
color: AppColors.muted,
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await showDatePicker(
context: context,
initialDate: _dueDate ?? now,
firstDate: now,
lastDate: DateTime(now.year + 5),
);
if (value != null && mounted) {
_markStarted();
setState(() {
_dueDate = value;
_dueError = null;
});
}
},
),
if (_dueError != null) ...[
const SizedBox(height: 6),
Text(
_dueError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存提醒',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
}
+512
View File
@@ -0,0 +1,512 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _ListPhase { loading, ready, error }
/// 照护提醒页(T2-14):契约不分页,`due_at ASC`(待办最先到期在前);
/// `?status=` 过滤走服务端白名单视图;逾期待办有显性视觉标识。
///
/// - 完成 / 忽略:`PATCH /care-reminders/{id}` 状态流转
/// pending→completed 必带 completedAtpending→dismissed 禁带);
/// 42202 规则兜底、40902 并发流转抢先提示后重拉。
/// - 提醒完成/忽略**不埋事件**(06 §7 缺口 3 既定取舍:完成率从
/// care_reminders 事实表出数;M3+ 推送实验时再增补)。
/// - 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=reminder, source=pet_detail)`。
class CareRemindersPage extends StatefulWidget {
const CareRemindersPage({
required this.repository,
required this.petId,
required this.canWrite,
super.key,
this.analytics,
});
final PetsRepository repository;
final String petId;
/// owner/caregiver 可写;viewer 隐藏创建与完成/忽略入口。
final bool canWrite;
final HealthRecordAnalytics? analytics;
@override
State<CareRemindersPage> createState() => _CareRemindersPageState();
}
class _CareRemindersPageState extends State<CareRemindersPage> {
_ListPhase _phase = _ListPhase.loading;
List<CareReminder> _reminders = const [];
ApiException? _error;
CareReminderStatus? _filter;
bool _viewedFired = false;
bool _mutating = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_phase = _ListPhase.loading;
_error = null;
});
try {
final reminders = await widget.repository.listCareReminders(
widget.petId,
status: _filter,
);
if (!mounted) return;
setState(() {
_reminders = reminders;
_phase = _ListPhase.ready;
});
if (!_viewedFired) {
_viewedFired = true;
widget.analytics?.viewed(
recordType: HealthRecordType.reminder,
source: HealthRecordViewSource.petDetail,
);
}
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_phase = _ListPhase.error;
});
}
}
Future<void> _openCreate() async {
final created = await Navigator.of(context).push<CareReminder>(
fadePageRoute(
CareReminderFormPage(
repository: widget.repository,
petId: widget.petId,
analytics: widget.analytics,
),
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已添加提醒')));
// 排序键 due_at ASC 在服务端,重新拉取而非本地猜位置。
await _load();
}
}
/// 完成时刻:今日取此刻,补记历史日期取当日 12:00(本地),转 UTC。
DateTime _completedAt(DateTime date) {
final now = DateTime.now();
final sameDay =
date.year == now.year && date.month == now.month && date.day == now.day;
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
return local.toUtc();
}
Future<void> _complete(CareReminder reminder) async {
final date = await showDialog<DateTime>(
context: context,
builder: (context) => _CompleteDialog(title: reminder.title),
);
if (date == null || !mounted) return;
await _mutate(
reminder,
UpdateCareReminderRequest(
status: CareReminderStatus.completed,
// 契约:pending→completed 必带 completedAt(客户端提交,允许补记)。
completedAt: _completedAt(date),
),
successText: '已标记完成',
);
}
Future<void> _dismiss(CareReminder reminder) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('忽略这条提醒?'),
content: Text('${reminder.title}」将不再出现在待办中,且不可恢复为待办。'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('忽略'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _mutate(
reminder,
// 契约:pending→dismissed 禁带 completedAt。
const UpdateCareReminderRequest(status: CareReminderStatus.dismissed),
successText: '已忽略提醒',
);
}
Future<void> _mutate(
CareReminder reminder,
UpdateCareReminderRequest request, {
required String successText,
}) async {
if (_mutating) return;
setState(() => _mutating = true);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.repository.updateCareReminder(reminder.id, request);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successText)));
await _load();
} on CareReminderRuleException {
if (!mounted) return;
// 42202:状态机/completedAt 一致性兜底(前端结构上已按状态发字段)。
messenger.showSnackBar(
const SnackBar(content: Text('提醒状态不满足流转规则,已刷新,请重试')),
);
await _load();
} on PetVersionConflictException {
if (!mounted) return;
// 40902:并发流转抢先(条件更新守卫落空),处理方式与乐观锁一致。
messenger.showSnackBar(const SnackBar(content: Text('提醒已在其他设备被处理,已刷新')));
await _load();
} on PetRecordNotFoundException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('提醒不存在或已被删除,已刷新')));
await _load();
} on PetAccessDeniedException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该提醒')));
} on ApiBusinessException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
} on ApiNetworkException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _mutating = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('照护提醒'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
actions: [
if (widget.canWrite && _phase == _ListPhase.ready)
IconButton(
tooltip: '添加提醒',
onPressed: _openCreate,
icon: const Icon(Icons.add),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: _filterChips(),
),
Expanded(
child: switch (_phase) {
_ListPhase.loading => const Center(
child: CircularProgressIndicator(),
),
_ListPhase.error => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: petLoadErrorMessage(_error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('重试')),
],
),
),
),
_ListPhase.ready when _reminders.isEmpty => Center(
child: SingleChildScrollView(child: _emptyState()),
),
_ListPhase.ready => _list(),
},
),
],
),
);
}
/// 状态过滤(服务端白名单视图;「待办」即 ?status=pending 按 due_at 查询)。
Widget _filterChips() {
final options = <(String, CareReminderStatus?)>[
('全部', null),
('待办', CareReminderStatus.pending),
('已完成', CareReminderStatus.completed),
('已忽略', CareReminderStatus.dismissed),
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (label, status) in options)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: _filter == status,
onSelected: (_) {
if (_filter == status) return;
setState(() => _filter = status);
_load();
},
),
),
],
),
);
}
Widget _emptyState() {
// 过滤视图下的空态不给 CTA(05 §4.2 筛选后空态惯例)。
if (_filter != null) {
return EmptyStateIllustration(
icon: Icons.notifications_none_outlined,
title: '暂无「${careReminderStatusLabel(_filter!)}」提醒',
);
}
return EmptyStateIllustration(
icon: Icons.notifications_none_outlined,
title: '还没有照护提醒',
description: '驱虫、体检、用药……到点不忘每一件照护小事',
ctaLabel: widget.canWrite ? '添加第一条' : null,
onCtaPressed: widget.canWrite ? _openCreate : null,
);
}
Widget _list() {
final now = DateTime.now();
return RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: [
for (final reminder in _reminders) ...[
_ReminderTile(
reminder: reminder,
now: now,
// 完成/忽略仅对待办可用(终态不可迁;viewer 无写权限)。
onComplete:
widget.canWrite &&
reminder.status == CareReminderStatus.pending &&
!_mutating
? () => _complete(reminder)
: null,
onDismiss:
widget.canWrite &&
reminder.status == CareReminderStatus.pending &&
!_mutating
? () => _dismiss(reminder)
: null,
),
const SizedBox(height: 10),
],
],
),
);
}
}
/// 提醒条目:RecordTypeDot + 标题 + 时间副行 + 状态 TagPill(逾期红标),
/// 待办行附「标记完成 / 忽略」动作(图标 + 文字双通道)。
class _ReminderTile extends StatelessWidget {
const _ReminderTile({
required this.reminder,
required this.now,
this.onComplete,
this.onDismiss,
});
final CareReminder reminder;
final DateTime now;
final VoidCallback? onComplete;
final VoidCallback? onDismiss;
@override
Widget build(BuildContext context) {
final overdue = isReminderOverdue(reminder, now);
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
RecordTypeDot(
type: recordTypeForReminder(reminder.reminderType),
size: RecordTypeDotSize.md,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reminder.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${careReminderTypeLabel(reminder.reminderType)} · '
'${reminderDateLine(reminder)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
// 逾期副行同步警示色(与标签双位标识)。
color: overdue
? AppColors.errorDark
: AppColors.inkSoft,
fontSize: 12,
fontWeight: overdue
? FontWeight.w700
: FontWeight.w400,
),
),
],
),
),
const SizedBox(width: 8),
TagPill(
reminderStatusTag(reminder, now),
color: reminderStatusColor(reminder, now),
),
],
),
if (onComplete != null || onDismiss != null) ...[
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onDismiss,
icon: const Icon(Icons.close, size: 16),
style: TextButton.styleFrom(
foregroundColor: AppColors.inkSoft,
),
label: const Text('忽略'),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: onComplete,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('标记完成'),
),
],
),
],
],
),
),
);
}
}
/// 标记完成对话框:完成日期默认今天,可补记历史时刻(契约 completedAt
/// 由客户端提交)。确认返回所选日期。
class _CompleteDialog extends StatefulWidget {
const _CompleteDialog({required this.title});
final String title;
@override
State<_CompleteDialog> createState() => _CompleteDialogState();
}
class _CompleteDialogState extends State<_CompleteDialog> {
DateTime _date = DateTime.now();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('标记完成'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${widget.title}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: const Text('完成日期', style: TextStyle(fontSize: 14)),
subtitle: Text(
dateToJson(_date),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
onTap: () async {
final now = DateTime.now();
final value = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(1990),
lastDate: now,
);
if (value != null && mounted) {
setState(() => _date = value);
}
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_date),
child: const Text('确认完成'),
),
],
);
}
}
@@ -0,0 +1,351 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/money.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 健康事件编辑页(T2-14):顶层短路径 `PATCH /health-events/{id}`。
///
/// - 契约:仅 title / notes / amountCents 可编辑;eventType / occurredAt
/// 为条目身份,静态展示不可改;不支持清空回 null(清空输入视为不变更)。
/// - 差量提交:只发送改动字段 + version;无变更不发 PATCH 直接返回。
/// - 40902 版本冲突照 T2-12 模式:明确提示 + 自动取最新版本更新乐观锁
/// 基线(保留用户输入),用户核对后重新保存。契约无按 id 读取端点,
/// 最新版本经时间线分页检索取回。
/// - 埋点:health_record_edit_succeeded(fieldCount) / edit_failed
/// failureReason 含 conflictrecordType=health_event)。
class HealthEventEditPage extends StatefulWidget {
const HealthEventEditPage({
required this.repository,
required this.event,
super.key,
this.analytics,
});
final PetsRepository repository;
/// 编辑基线(含 version 乐观锁与差量比较基准)。
final HealthEvent event;
final HealthRecordAnalytics? analytics;
@override
State<HealthEventEditPage> createState() => _HealthEventEditPageState();
}
class _HealthEventEditPageState extends State<HealthEventEditPage> {
late final TextEditingController _titleCtrl;
late final TextEditingController _amountCtrl;
late final TextEditingController _notesCtrl;
/// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。
late HealthEvent _base;
String? _titleError;
String? _amountError;
String? _formError;
bool _submitting = false;
@override
void initState() {
super.initState();
_base = widget.event;
_titleCtrl = TextEditingController(text: _base.title);
_amountCtrl = TextEditingController(
text: _base.amountCents == null
? ''
: formatCentsAsYuan(_base.amountCents!),
);
_notesCtrl = TextEditingController(text: _base.notes ?? '');
}
@override
void dispose() {
_titleCtrl.dispose();
_amountCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.editFailed(
recordType: HealthRecordType.healthEvent,
reason: reason,
errorCode: errorCode,
);
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
/// 差量请求:只含改动字段(契约不支持清空回 null——清空输入视为不变更)。
/// 无实际变更返回 null。
UpdateHealthEventRequest? _buildDiff() {
final title = _titleCtrl.text.trim();
final notes = _notesCtrl.text.trim();
final amountText = _amountCtrl.text.trim();
final amountCents = amountText.isEmpty
? null
: parseYuanToCents(amountText);
final request = UpdateHealthEventRequest(
version: _base.version,
title: title != _base.title ? title : null,
notes: notes.isNotEmpty && notes != (_base.notes ?? '') ? notes : null,
amountCents: amountCents != null && amountCents != _base.amountCents
? amountCents
: null,
);
// 只剩 version 一个键 → 无实际变更。
return request.toJson().length == 1 ? null : request;
}
Future<void> _submit() async {
if (_submitting) return;
final titleError = _titleCtrl.text.trim().isEmpty ? '标题不能为空' : null;
final amountText = _amountCtrl.text.trim();
final amountError =
amountText.isNotEmpty && parseYuanToCents(amountText) == null
? '金额格式不正确,最多两位小数'
: null;
if (titleError != null || amountError != null) {
setState(() {
_titleError = titleError;
_amountError = amountError;
});
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
final request = _buildDiff();
if (request == null) {
Navigator.of(context).pop();
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final updated = await widget.repository.updateHealthEvent(
_base.id,
request,
);
widget.analytics?.editSucceeded(
recordType: HealthRecordType.healthEvent,
fieldCount: request.toJson().length - 1,
);
if (mounted) Navigator.of(context).pop(updated);
} on PetVersionConflictException {
_trackFailed(HealthRecordFailureReason.conflict, 40902);
await _handleVersionConflict();
} on PetRecordNotFoundException {
if (!mounted) return;
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
_trackFailed(HealthRecordFailureReason.notFound, 40402);
navigator.pop();
} on PetAccessDeniedException {
if (!mounted) return;
_showFormError('你没有权限修改该记录');
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('操作过于频繁,请稍后再试');
_trackFailed(HealthRecordFailureReason.rateLimited);
} on ApiBusinessException catch (error) {
if (!mounted) return;
final isParam = error.code == ApiCodes.paramError;
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
_trackFailed(
isParam
? HealthRecordFailureReason.validationError
: HealthRecordFailureReason.serverError,
error.code,
);
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
_trackFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
/// 40902:提示 + 刷新路径——契约无按 id 读取端点,经时间线分页检索
/// 取回最新版本更新乐观锁基线(保留用户输入),用户核对后重新保存。
Future<void> _handleVersionConflict() async {
try {
final fresh = await _fetchLatest();
if (!mounted) return;
if (fresh == null) {
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
navigator.pop();
return;
}
setState(() => _base = fresh);
_showFormError('记录已在其他设备被修改,已获取最新版本,请核对后重新保存');
} on ApiException {
if (!mounted) return;
_showFormError('记录已在其他设备被修改,请返回后刷新重试');
}
}
/// 按 occurred_at DESC 分页检索本记录(上限 10 页防御性截断)。
Future<HealthEvent?> _fetchLatest() async {
String? cursor;
for (var page = 0; page < 10; page++) {
final result = await widget.repository.listHealthEvents(
_base.petId,
limit: 50,
cursor: cursor,
);
for (final item in result.items) {
if (item.id == _base.id) return item;
}
if (!result.hasMore) return null;
cursor = result.nextCursor;
}
return null;
}
@override
Widget build(BuildContext context) {
final style = recordTypeStyles[recordTypeForHealthEvent(_base.eventType)]!;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('编辑健康事件'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
children: [
// 事件身份静态区:类型与发生时间不可编辑(契约不在请求体)。
Row(
children: [
RecordTypeDot(
type: recordTypeForHealthEvent(_base.eventType),
size: RecordTypeDotSize.md,
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
healthEventTypeLabel(_base.eventType),
style: TextStyle(
color: style.inkColor,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
formatOccurredAt(_base.occurredAt),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
],
),
const SizedBox(height: 18),
AppTextField(
label: '标题',
controller: _titleCtrl,
prefixIcon: Icons.title_outlined,
errorText: _titleError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
if (_titleError != null || _formError != null) {
setState(() {
_titleError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '金额(元)',
controller: _amountCtrl,
prefixIcon: Icons.payments_outlined,
errorText: _amountError,
helperText: '以元填写,最多两位小数;清空视为不变更',
enabled: !_submitting,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
textInputAction: TextInputAction.next,
onChanged: (_) {
if (_amountError != null || _formError != null) {
setState(() {
_amountError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '备注',
controller: _notesCtrl,
prefixIcon: Icons.sticky_note_2_outlined,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存修改',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
}
@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/money.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 健康事件录入表单页(T2-14)。
///
/// - 六类事件类型选择(05 §4.4 类型选择器形态:RecordTypeDot sm + 标签,
/// 图标 + 文字双通道);类型必选。
/// - 发生时间默认「现在」,可回选日期(非今日取当日 12:00,与体重表单
/// 同一约定);提交前转 UTC,ISO 8601 带 Z 上送。
/// - 金额可选:**UI 以元录入/展示,传输为整数分**(money.dart 换算,
/// 最多两位小数,非法拦截);后端提交小数 400/40000 兜底。
/// - 埋点:health_record_create_started/succeeded/failed
/// recordType=health_event)。
class HealthEventFormPage extends StatefulWidget {
const HealthEventFormPage({
required this.repository,
required this.petId,
super.key,
this.analytics,
this.entryPoint = HealthRecordEntryPoint.recordList,
});
final PetsRepository repository;
final String petId;
final HealthRecordAnalytics? analytics;
final HealthRecordEntryPoint entryPoint;
@override
State<HealthEventFormPage> createState() => _HealthEventFormPageState();
}
class _HealthEventFormPageState extends State<HealthEventFormPage> {
final _titleCtrl = TextEditingController();
final _amountCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
HealthEventType? _type;
DateTime _occurredDate = DateTime.now();
String? _typeError;
String? _titleError;
String? _amountError;
String? _formError;
bool _submitting = false;
bool _startedFired = false;
int _attemptSeq = 0;
late final DateTime _openedAt;
@override
void initState() {
super.initState();
_openedAt = DateTime.now();
}
@override
void dispose() {
_titleCtrl.dispose();
_amountCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
void _markStarted() {
if (_startedFired) return;
_startedFired = true;
widget.analytics?.createStarted(
recordType: HealthRecordType.healthEvent,
entryPoint: widget.entryPoint,
);
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.createFailed(
recordType: HealthRecordType.healthEvent,
reason: reason,
attemptSeq: _attemptSeq,
errorCode: errorCode,
);
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
/// 发生时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC
/// (与体重表单同一约定,规避无时区后缀的解析歧义)。
DateTime _occurredAt() {
final now = DateTime.now();
final sameDay =
_occurredDate.year == now.year &&
_occurredDate.month == now.month &&
_occurredDate.day == now.day;
final local = sameDay
? now
: DateTime(
_occurredDate.year,
_occurredDate.month,
_occurredDate.day,
12,
);
return local.toUtc();
}
Future<void> _submit() async {
if (_submitting) return;
_attemptSeq++;
final typeError = _type == null ? '请选择事件类型' : null;
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入标题' : null;
final amountText = _amountCtrl.text.trim();
final amountError =
amountText.isNotEmpty && parseYuanToCents(amountText) == null
? '金额格式不正确,最多两位小数'
: null;
if (typeError != null || titleError != null || amountError != null) {
setState(() {
_typeError = typeError;
_titleError = titleError;
_amountError = amountError;
});
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final notes = _notesCtrl.text.trim();
final record = await widget.repository.createHealthEvent(
widget.petId,
CreateHealthEventRequest(
eventType: _type!,
occurredAt: _occurredAt(),
title: _titleCtrl.text.trim(),
notes: notes.isEmpty ? null : notes,
// 元 → 整数分换算,DTO 层保持整数分(开发计划 §4.3)。
amountCents: amountText.isEmpty ? null : parseYuanToCents(amountText),
),
);
widget.analytics?.createSucceeded(
recordType: HealthRecordType.healthEvent,
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
);
if (mounted) Navigator.of(context).pop(record);
} on PetAccessDeniedException {
if (!mounted) return;
_showFormError('你没有权限为该宠物添加记录');
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on PetNotFoundException {
if (!mounted) return;
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
_trackFailed(HealthRecordFailureReason.notFound, 40401);
navigator.pop();
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('操作过于频繁,请稍后再试');
_trackFailed(HealthRecordFailureReason.rateLimited);
} on ApiBusinessException catch (error) {
if (!mounted) return;
final isParam = error.code == ApiCodes.paramError;
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
_trackFailed(
isParam
? HealthRecordFailureReason.validationError
: HealthRecordFailureReason.serverError,
error.code,
);
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
_trackFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('记录健康事件'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
children: [
const Text(
'事件类型',
style: TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_typeSelector(),
if (_typeError != null) ...[
const SizedBox(height: 6),
Text(
_typeError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 18),
AppTextField(
label: '标题(如:皮肤检查)',
controller: _titleCtrl,
prefixIcon: Icons.title_outlined,
errorText: _titleError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_titleError != null || _formError != null) {
setState(() {
_titleError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
tileColor: AppColors.surface,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: const Text('发生日期', style: TextStyle(fontSize: 14)),
subtitle: Text(
dateToJson(_occurredDate),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.calendar_month_outlined,
color: AppColors.muted,
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await showDatePicker(
context: context,
initialDate: _occurredDate,
firstDate: DateTime(1990),
lastDate: now,
);
if (value != null && mounted) {
_markStarted();
setState(() => _occurredDate = value);
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '金额(元,可选)',
controller: _amountCtrl,
prefixIcon: Icons.payments_outlined,
errorText: _amountError,
helperText: '以元填写,最多两位小数,如 128.50',
enabled: !_submitting,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_amountError != null || _formError != null) {
setState(() {
_amountError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '备注(可选)',
controller: _notesCtrl,
prefixIcon: Icons.sticky_note_2_outlined,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onChanged: (_) => _markStarted(),
onSubmitted: (_) => _submit(),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存记录',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
/// 六类类型选择器(05 §4.4RecordTypeDot sm 上、标签下;
/// 选中 surfaceTint 底 + primaryDark 标签,未选中 inkSoft)。
Widget _typeSelector() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final type in HealthEventType.values)
_TypeUnit(
type: type,
selected: _type == type,
enabled: !_submitting,
onTap: () {
_markStarted();
setState(() {
_type = type;
_typeError = null;
_formError = null;
});
},
),
],
);
}
}
class _TypeUnit extends StatelessWidget {
const _TypeUnit({
required this.type,
required this.selected,
required this.enabled,
required this.onTap,
});
final HealthEventType type;
final bool selected;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: selected ? AppColors.surfaceTint : AppColors.surface,
shape: RoundedRectangleBorder(
side: BorderSide(
color: selected ? AppColors.primaryStrong : AppColors.border,
),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
constraints: const BoxConstraints(minWidth: 64, minHeight: 52),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RecordTypeDot(
type: recordTypeForHealthEvent(type),
size: RecordTypeDotSize.sm,
),
const SizedBox(height: 4),
Text(
healthEventTypeLabel(type),
style: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
color: selected ? AppColors.primaryDark : AppColors.inkSoft,
),
),
],
),
),
),
);
}
}
+368
View File
@@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_event_edit_page.dart';
import 'package:patbond_flutter/features/pets/health_event_form_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/money.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _ListPhase { loading, ready, error }
/// 健康事件时间线页(T2-14):cursor 分页(occurred_at DESC
/// 「加载更多」追加,末页收起),按月分组(05 §4.2 组头),四态齐备。
///
/// - 六类事件经 [recordTypeForHealthEvent] 映射 RecordTypeDot
/// 类型标签 TagPill 双通道呈现;金额以元展示(传输为整数分)。
/// - 录入经 [HealthEventFormPage];成功后重拉首页(排序与月分组以
/// 服务端为准,不本地猜位置)。
/// - 编辑经 [HealthEventEditPage]canWrite 点条目进入);成功就地替换。
/// - 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=health_event, source=pet_detail)`。
class HealthEventsPage extends StatefulWidget {
const HealthEventsPage({
required this.repository,
required this.petId,
required this.canWrite,
super.key,
this.analytics,
this.pageSize,
});
final PetsRepository repository;
final String petId;
/// owner/caregiver 可写;viewer 隐藏录入/编辑入口(40300 语义前置)。
final bool canWrite;
final HealthRecordAnalytics? analytics;
/// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。
final int? pageSize;
@override
State<HealthEventsPage> createState() => _HealthEventsPageState();
}
class _HealthEventsPageState extends State<HealthEventsPage> {
_ListPhase _phase = _ListPhase.loading;
List<HealthEvent> _events = const [];
String? _nextCursor;
bool _hasMore = false;
bool _loadingMore = false;
ApiException? _error;
bool _viewedFired = false;
@override
void initState() {
super.initState();
_loadFirstPage();
}
Future<void> _loadFirstPage() async {
setState(() {
_phase = _ListPhase.loading;
_error = null;
});
try {
final page = await widget.repository.listHealthEvents(
widget.petId,
limit: widget.pageSize,
);
if (!mounted) return;
setState(() {
_events = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_phase = _ListPhase.ready;
});
if (!_viewedFired) {
_viewedFired = true;
widget.analytics?.viewed(
recordType: HealthRecordType.healthEvent,
source: HealthRecordViewSource.petDetail,
);
}
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_phase = _ListPhase.error;
});
}
}
Future<void> _loadMore() async {
if (_loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
try {
final page = await widget.repository.listHealthEvents(
widget.petId,
limit: widget.pageSize,
cursor: _nextCursor,
);
if (!mounted) return;
setState(() {
_events = [..._events, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
});
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
} finally {
if (mounted) setState(() => _loadingMore = false);
}
}
Future<void> _openCreate() async {
final created = await Navigator.of(context).push<HealthEvent>(
fadePageRoute(
HealthEventFormPage(
repository: widget.repository,
petId: widget.petId,
analytics: widget.analytics,
),
// record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已记录健康事件')));
// occurred_at DESC + 月分组:补录历史日期的位置由服务端定,
// 重拉首页而非本地猜位置。
await _loadFirstPage();
}
}
Future<void> _openEdit(HealthEvent event) async {
final updated = await Navigator.of(context).push<HealthEvent>(
// 编辑页不带路由名:record_form 专属创建漏斗到达段(T2-12 先例)。
fadePageRoute(
HealthEventEditPage(
repository: widget.repository,
event: event,
analytics: widget.analytics,
),
),
);
if (updated != null && mounted) {
setState(() {
// occurredAt 不可编辑 → 排序/分组位置不变,就地替换安全。
_events = [
for (final item in _events)
if (item.id == updated.id) updated else item,
];
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已保存修改')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('健康时间线'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
actions: [
if (widget.canWrite && _phase == _ListPhase.ready)
IconButton(
tooltip: '记录健康事件',
onPressed: _openCreate,
icon: const Icon(Icons.add),
),
],
),
body: switch (_phase) {
_ListPhase.loading => const Center(child: CircularProgressIndicator()),
_ListPhase.error => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: petLoadErrorMessage(_error)),
const SizedBox(height: 16),
FilledButton(
onPressed: _loadFirstPage,
child: const Text('重试'),
),
],
),
),
),
_ListPhase.ready when _events.isEmpty => Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.event_note_outlined,
title: '还没有健康记录',
description: '就医、驱虫、洗护……随手记下毛孩子的健康点滴',
ctaLabel: widget.canWrite ? '记录第一条' : null,
onCtaPressed: widget.canWrite ? _openCreate : null,
),
),
),
_ListPhase.ready => _list(),
},
);
}
/// 按月分组渲染:服务端 occurred_at DESC 保证同月相邻,
/// 月份变化处插组头(05 §4.2「2026 年 9 月」)。
Widget _list() {
final children = <Widget>[];
String? currentMonth;
for (final event in _events) {
final month = healthEventMonthHeader(event.occurredAt);
if (month != currentMonth) {
currentMonth = month;
children.add(
Padding(
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
child: Text(
month,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
);
}
children
..add(
_HealthEventTile(
event: event,
onTap: widget.canWrite ? () => _openEdit(event) : null,
),
)
..add(const SizedBox(height: 10));
}
if (_hasMore) {
children.add(
Center(
child: _loadingMore
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: TextButton(onPressed: _loadMore, child: const Text('加载更多')),
),
);
}
return RefreshIndicator(
onRefresh: _loadFirstPage,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: children,
),
);
}
}
/// 时间线条目(05 §3.3 形态):RecordTypeDot(六类映射) + 标题 +
/// 日期/备注副行;尾部类型 TagPill(双通道)与金额(元展示,
/// 15/w800 类型文字色)。
class _HealthEventTile extends StatelessWidget {
const _HealthEventTile({required this.event, this.onTap});
final HealthEvent event;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final recordType = recordTypeForHealthEvent(event.eventType);
final style = recordTypeStyles[recordType]!;
final meta = StringBuffer(formatOccurredAt(event.occurredAt));
if (event.notes != null && event.notes!.isNotEmpty) {
meta.write(' · ${event.notes}');
}
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
RecordTypeDot(type: recordType, size: RecordTypeDotSize.md),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
meta.toString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
TagPill(
healthEventTypeLabel(event.eventType),
color: style.baseColor,
),
if (event.amountCents != null) ...[
const SizedBox(height: 4),
Text(
'¥${formatCentsAsYuan(event.amountCents!)}',
style: TextStyle(
color: style.inkColor,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
],
],
),
],
),
),
),
);
}
}
@@ -0,0 +1,158 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
/// health_record 域埋点强类型封装(06 号规划 §1.4 字典 v2;后端白名单
/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期
/// 锁死,业务代码禁止手拼事件名与属性。
///
/// T2-13 挂接创建漏斗三事件 + viewedT2-14 补挂 edit_succeeded/failed
/// (健康事件编辑、疫苗标记完成/取消)。`health_record_deleted` 因 M2
/// 契约无删除端点暂无挂接点,留待删除交互落地。
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
enum HealthRecordType {
weight('weight'),
vaccine('vaccine'),
healthEvent('health_event'),
reminder('reminder');
const HealthRecordType(this.value);
final String value;
}
/// 记录创建入口(06 §1.4 entryPoint 枚举)。
enum HealthRecordEntryPoint {
petDetail('pet_detail'),
recordList('record_list'),
reminder('reminder');
const HealthRecordEntryPoint(this.value);
final String value;
}
/// 创建/编辑失败原因(06 §1.4 基底 + M2 验收新增三值;`conflict`
/// 仅编辑链路会出现——40902 乐观锁/条件更新守卫落空)。与 pet 域同款
/// 网络归并口径:断网/超时/5xx 均并入 network_errorserver_error
/// 保留给无法归类的兜底。
enum HealthRecordFailureReason {
validationError('validation_error'),
permissionDenied('permission_denied'),
notFound('not_found'),
rateLimited('rate_limited'),
networkError('network_error'),
serverError('server_error'),
conflict('conflict');
const HealthRecordFailureReason(this.value);
final String value;
}
/// viewed 的来源(06 §1.4 source 枚举)。
enum HealthRecordViewSource {
recordList('record_list'),
petDetail('pet_detail'),
reminder('reminder');
const HealthRecordViewSource(this.value);
final String value;
}
class HealthRecordAnalytics {
HealthRecordAnalytics(this._track);
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
final TrackEventFn _track;
/// 进入某类记录的创建表单并产生首次输入(每次进入记一次,表单层去重)。
void createStarted({
required HealthRecordType recordType,
required HealthRecordEntryPoint entryPoint,
}) {
_track('health_record_create_started', {
'recordType': recordType.value,
'entryPoint': entryPoint.value,
});
}
/// 创建接口成功响应(漏斗事件,北极星与 H1/H3/H4 的核心数据源)。
///
/// [photoCount] 无照片为 0(M2 不做媒体上传,恒 0,字段随字典保留)。
void createSucceeded({
required HealthRecordType recordType,
required int durationMs,
int photoCount = 0,
}) {
_track('health_record_create_succeeded', {
'recordType': recordType.value,
'durationMs': durationMs,
'photoCount': photoCount,
});
}
/// 创建失败:失败响应 / 超时 / 本地校验拦截。
///
/// [errorCode] 为业务错误码(本地校验/网络错误时缺席);
/// [httpStatus] 由五位业务码推导(`code ~/ 100`);
/// [attemptSeq] 为本次表单会话内第几次提交尝试(从 1 起)。
void createFailed({
required HealthRecordType recordType,
required HealthRecordFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('health_record_create_failed', {
'recordType': recordType.value,
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
/// 记录曝光。06 §1.4 定义在详情页可见;M2 体重/疫苗无独立详情页,
/// 以「列表页每次进入首个成功加载」为曝光时点(每次进入一次、
/// 不随滚动逐条上报,防事件洪水的意图不变)。
void viewed({
required HealthRecordType recordType,
required HealthRecordViewSource source,
}) {
_track('health_record_viewed', {
'recordType': recordType.value,
'source': source.value,
});
}
/// 编辑保存成功响应(06 §1.4:编辑不设 started)。
///
/// [fieldCount] 为本次变更字段数(差量 PATCH 的键数,不含 version)。
void editSucceeded({
required HealthRecordType recordType,
required int fieldCount,
}) {
_track('health_record_edit_succeeded', {
'recordType': recordType.value,
'fieldCount': fieldCount,
});
}
/// 编辑保存失败(失败原因含 `conflict`——40902 版本/状态守卫冲突,
/// M2 验收「并发冲突明确」场景的数据面)。属性集无 attemptSeq
/// (白名单对齐 06 §1.5)。
void editFailed({
required HealthRecordType recordType,
required HealthRecordFailureReason reason,
int? errorCode,
}) {
_track('health_record_edit_failed', {
'recordType': recordType.value,
'failureReason': reason.value,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
}
@@ -0,0 +1,190 @@
/// 体重 / 疫苗 / 健康事件 / 提醒展示与输入解析的纯函数集合
/// (列表 / 表单共用,可单测)。
library;
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
/// 非法(非数字、越界、三位小数)返回 null,由表单层给 errorText。
double? parseWeightKgInput(String raw) {
final text = raw.trim();
if (!RegExp(r'^\d{1,3}(\.\d{1,2})?$').hasMatch(text)) return null;
final value = double.parse(text);
if (value <= 0 || value > 500) return null;
return value;
}
/// 体重展示:去掉无意义尾零(4.35 → 4.35、5.00 → 5、4.50 → 4.5)。
String formatWeightKg(double weightKg) {
var text = weightKg.toStringAsFixed(2);
if (text.contains('.')) {
text = text.replaceFirst(RegExp(r'0+$'), '');
text = text.replaceFirst(RegExp(r'\.$'), '');
}
return text;
}
String vaccinationStatusLabel(VaccinationStatus status) => switch (status) {
VaccinationStatus.scheduled => '计划中',
VaccinationStatus.completed => '已完成',
VaccinationStatus.cancelled => '已取消',
};
/// 状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
Color vaccinationStatusColor(VaccinationStatus status) => switch (status) {
VaccinationStatus.scheduled => AppColors.accent,
VaccinationStatus.completed => AppColors.success,
VaccinationStatus.cancelled => AppColors.muted,
};
/// 剂次展示:doseLabel 优先,缺席回落「第 N 针」。
String vaccinationDoseLabel(Vaccination vaccination) =>
vaccination.doseLabel ?? '${vaccination.doseNo}';
/// 疫苗条目副行:按状态给日期语义(计划 / 接种 + 下次到期 / 取消)。
String vaccinationDateLine(
Vaccination vaccination,
) => switch (vaccination.status) {
VaccinationStatus.scheduled =>
'计划 ${vaccination.plannedOn == null ? '未定' : dateToJson(vaccination.plannedOn!)}',
VaccinationStatus.completed =>
'接种 ${vaccination.administeredOn == null ? '' : dateToJson(vaccination.administeredOn!)}'
'${vaccination.nextDueOn == null ? '' : ' · 下次 ${dateToJson(vaccination.nextDueOn!)}'}',
VaccinationStatus.cancelled => '已取消',
};
/// 摘要疫苗进度展示:契约 null 语义——无登记为 null(不是 0/0),
/// 调用方对 null 自行给空态文案。
String vaccinationProgressLabel(SummaryVaccinationProgress progress) =>
'${progress.completedDoses}/${progress.totalDoses}';
/// 疫苗状态-日期规则前端校验(契约 42201 规则的前置拦截;纯函数可单测):
/// scheduled 必有 plannedOncompleted 必有 administeredOn
/// nextDueOn 与 administeredOn 同时存在时须 nextDueOn ≥ administeredOn。
/// 通过返回 null,违反返回给用户的拦截文案。
String? vaccinationDateRuleError({
required VaccinationStatus status,
DateTime? plannedOn,
DateTime? administeredOn,
DateTime? nextDueOn,
}) {
if (status == VaccinationStatus.scheduled && plannedOn == null) {
return '请选择计划接种日期';
}
if (status == VaccinationStatus.completed && administeredOn == null) {
return '请选择接种日期';
}
if (administeredOn != null &&
nextDueOn != null &&
nextDueOn.isBefore(
DateTime(administeredOn.year, administeredOn.month, administeredOn.day),
)) {
return '下次接种日期不能早于接种日期';
}
return null;
}
// ---- 健康事件(T2-14----
/// 契约六类健康事件 → 视觉记录类型(RecordTypeDot 映射唯一出口)。
/// note(随手记)归入「其他」视觉族;其余五类各有专属图标。
RecordType recordTypeForHealthEvent(HealthEventType type) => switch (type) {
HealthEventType.medical => RecordType.medical,
HealthEventType.feeding => RecordType.feeding,
HealthEventType.deworming => RecordType.deworming,
HealthEventType.grooming => RecordType.grooming,
HealthEventType.measurement => RecordType.measurement,
HealthEventType.note => RecordType.other,
};
/// 六类事件中文文案(类型选择器 / 条目标签共用;
/// 图标 + 文字双通道,不单靠颜色区分)。
String healthEventTypeLabel(HealthEventType type) => switch (type) {
HealthEventType.medical => '就医',
HealthEventType.feeding => '喂养',
HealthEventType.deworming => '驱虫',
HealthEventType.grooming => '洗护',
HealthEventType.measurement => '测量',
HealthEventType.note => '随手记',
};
/// 事件发生时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
String formatOccurredAt(DateTime occurredAt) {
final local = occurredAt.toLocal();
final h = local.hour.toString().padLeft(2, '0');
final min = local.minute.toString().padLeft(2, '0');
return '${dateToJson(local)} $h:$min';
}
/// 时间线月分组组头(05 §4.2:「2026 年 9 月」),按本地时区归月。
String healthEventMonthHeader(DateTime occurredAt) {
final local = occurredAt.toLocal();
return '${local.year}${local.month}';
}
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
String tzOffsetQueryValue(Duration offset) {
final sign = offset.isNegative ? '-' : '+';
final abs = offset.abs();
final h = abs.inHours.toString().padLeft(2, '0');
final m = (abs.inMinutes % 60).toString().padLeft(2, '0');
return '$sign$h:$m';
}
// ---- 照护提醒(T2-14----
/// 四类提醒中文文案。
String careReminderTypeLabel(CareReminderType type) => switch (type) {
CareReminderType.deworming => '驱虫',
CareReminderType.checkup => '体检',
CareReminderType.medication => '用药',
CareReminderType.other => '其他',
};
/// 提醒类型 → 视觉记录类型(RecordTypeDot 复用):驱虫沿用驱虫族,
/// 体检/用药归就医族,其他归兜底族;类型文字由标签承载(双通道)。
RecordType recordTypeForReminder(CareReminderType type) => switch (type) {
CareReminderType.deworming => RecordType.deworming,
CareReminderType.checkup => RecordType.medical,
CareReminderType.medication => RecordType.medical,
CareReminderType.other => RecordType.other,
};
String careReminderStatusLabel(CareReminderStatus status) => switch (status) {
CareReminderStatus.pending => '待办',
CareReminderStatus.completed => '已完成',
CareReminderStatus.dismissed => '已忽略',
};
/// 逾期判定:待办且 dueAt 已过(工单硬项:逾期视觉标识)。
bool isReminderOverdue(CareReminder reminder, DateTime now) =>
reminder.status == CareReminderStatus.pending &&
reminder.dueAt.isBefore(now);
/// 提醒状态标签文案(逾期的待办以「已逾期」显性标识)。
String reminderStatusTag(CareReminder reminder, DateTime now) =>
isReminderOverdue(reminder, now)
? '已逾期'
: careReminderStatusLabel(reminder.status);
/// 提醒状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
Color reminderStatusColor(CareReminder reminder, DateTime now) =>
switch (reminder.status) {
CareReminderStatus.pending =>
isReminderOverdue(reminder, now) ? AppColors.error : AppColors.accent,
CareReminderStatus.completed => AppColors.success,
CareReminderStatus.dismissed => AppColors.muted,
};
/// 提醒条目副行:按状态给时间语义(到期 / 完成于 / 已忽略)。
String reminderDateLine(CareReminder reminder) => switch (reminder.status) {
CareReminderStatus.pending => '到期 ${formatOccurredAt(reminder.dueAt)}',
CareReminderStatus.completed =>
'完成于 ${reminder.completedAt == null ? '' : formatOccurredAt(reminder.completedAt!)}',
CareReminderStatus.dismissed =>
'已忽略 · 原到期 ${formatOccurredAt(reminder.dueAt)}',
};
+30
View File
@@ -0,0 +1,30 @@
/// 金额换算工具:契约传输整数分(amountCents),UI 展示以元。
/// DTO 层保持整数分不换算(开发计划 §4.3);本工具供 UI 层(T2-14)使用。
library;
/// 整数分 → 元展示字符串。整元不带小数("128"),
/// 非整元固定两位("128.50")。负数入参非法(契约金额非负)。
String formatCentsAsYuan(int cents) {
if (cents < 0) {
throw ArgumentError.value(cents, 'cents', '金额不得为负');
}
final yuan = cents ~/ 100;
final remainder = cents % 100;
if (remainder == 0) return '$yuan';
return '$yuan.${remainder.toString().padLeft(2, '0')}';
}
/// 元输入串 → 整数分。接受整数或最多两位小数("12"、"12.5"、"12.50");
/// 空白、非法字符、超两位小数、负数返回 null(表单层据此报「金额格式不正确」)。
int? parseYuanToCents(String input) {
final trimmed = input.trim();
final match = RegExp(r'^(\d+)(?:\.(\d{1,2}))?$').firstMatch(trimmed);
if (match == null) return null;
final yuan = int.parse(match.group(1)!);
final fractional = match.group(2);
final cents = switch (fractional) {
null => 0,
_ => int.parse(fractional.padRight(2, '0')),
};
return yuan * 100 + cents;
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
/// pet 域埋点强类型封装(06 号规划 §1.4/§4 字典 v2 三事件;沿用
/// 13 号规范 §3.1 惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性)。
///
/// 三事件只覆盖「建宠」漏斗:`page_viewed(pet_form)` 承接到达段(路由
/// 埋点自动采集),started 在表单**首次输入**触发、每次进入记一次;
/// 编辑不设事件(06 §1.4 自觉取舍:短表单漏斗价值低于事件成本)。
/// 建宠表单入口(06 §4pet_create_started.entryPoint 枚举)。
/// post_register_guide 预留:注册后引导流尚未落地,落地时启用。
enum PetCreateEntryPoint {
profileEmptyState('profile_empty_state'),
petList('pet_list'),
postRegisterGuide('post_register_guide');
const PetCreateEntryPoint(this.value);
final String value;
}
/// 建宠失败原因(06 §4 枚举;pet_limit_reached 因产品未设上限未纳入,
/// 若后续拍板设上限随字典增补)。客户端网络层不区分 5xx 与断网/超时
/// (均为 ApiNetworkException),两者并入 network_errorserver_error
/// 保留给无法归类的兜底。
enum PetCreateFailureReason {
validationError('validation_error'),
rateLimited('rate_limited'),
networkError('network_error'),
serverError('server_error');
const PetCreateFailureReason(this.value);
final String value;
}
class PetAnalytics {
PetAnalytics(this._track);
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
final TrackEventFn _track;
/// 进入建宠表单并产生首次输入(每次进入记一次,表单层负责去重)。
void createStarted({required PetCreateEntryPoint entryPoint}) {
_track('pet_create_started', {'entryPoint': entryPoint.value});
}
/// 建宠接口成功响应(漏斗事件)。
///
/// [durationMs] 为表单打开到成功的耗时;[petIndex] 为该用户第几只宠物
/// (H2 假设的直接数据源,与 pet.pets 事实表交叉验证)。
void createSucceeded({
required int durationMs,
required PetSpecies species,
required int petIndex,
}) {
_track('pet_create_succeeded', {
'durationMs': durationMs,
'species': species.name,
'petIndex': petIndex,
});
}
/// 建宠失败:失败响应 / 超时 / 本地校验拦截。
///
/// [errorCode] 为业务错误码(本地校验/网络错误时缺席);
/// [httpStatus] 由五位业务码推导(`code ~/ 100`);
/// [attemptSeq] 为本次表单会话内第几次提交尝试(从 1 起)。
void createFailed({
required PetCreateFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('pet_create_failed', {
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
}
+699
View File
@@ -0,0 +1,699 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
import 'package:patbond_flutter/features/pets/health_events_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/money.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
import 'package:patbond_flutter/features/pets/weight_records_page.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _DetailPhase { loading, ready, error, notFound }
enum _SummaryPhase { loading, ready, error }
enum _RemindersPhase { loading, ready, error }
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
///
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
/// 拉取最新详情(同步列表副本)。四态:loading / ready / error+重试 /
/// notFound40401 防枚举三态同响应 → 提示后返回列表并刷新)。
///
/// T2-13:数据卡行接 `GET /pets/{id}/summary` 实时聚合(最新体重 /
/// 疫苗进度 / 下一针,null 语义为空态文案而非 0/0),并作为体重历史、
/// 疫苗记录两页的导航入口;从记录页返回即重拉摘要。
class PetDetailPage extends StatefulWidget {
const PetDetailPage({
required this.controller,
required this.petId,
super.key,
this.analytics,
this.healthAnalytics,
});
final PetsController controller;
final String petId;
final PetAnalytics? analytics;
final HealthRecordAnalytics? healthAnalytics;
@override
State<PetDetailPage> createState() => _PetDetailPageState();
}
class _PetDetailPageState extends State<PetDetailPage> {
_DetailPhase _phase = _DetailPhase.loading;
Pet? _pet;
ApiException? _error;
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
PetSummary? _summary;
_RemindersPhase _remindersPhase = _RemindersPhase.loading;
List<CareReminder> _pendingReminders = const [];
@override
void initState() {
super.initState();
_pet = _fromController();
if (_pet != null) _phase = _DetailPhase.ready;
_load();
_loadSummary();
_loadPendingReminders();
}
Pet? _fromController() {
for (final pet in widget.controller.pets) {
if (pet.id == widget.petId) return pet;
}
return null;
}
Future<void> _load() async {
if (_pet == null) {
setState(() => _phase = _DetailPhase.loading);
}
try {
final pet = await widget.controller.getPet(widget.petId);
if (!mounted) return;
setState(() {
_pet = pet;
_phase = _DetailPhase.ready;
});
} on PetNotFoundException {
if (!mounted) return;
setState(() => _phase = _DetailPhase.notFound);
} on ApiException catch (error) {
if (!mounted) return;
if (_pet == null) {
setState(() {
_error = error;
_phase = _DetailPhase.error;
});
} else {
// 已有内存副本:刷新失败降级为瞬态提示,不打断阅读。
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
}
}
}
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
/// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度
/// 窗口随设备时区取边界,与用户直觉一致。
Future<void> _loadSummary() async {
setState(() => _summaryPhase = _SummaryPhase.loading);
try {
final summary = await widget.controller.repository.getPetSummary(
widget.petId,
tz: tzOffsetQueryValue(DateTime.now().timeZoneOffset),
);
if (!mounted) return;
setState(() {
_summary = summary;
_summaryPhase = _SummaryPhase.ready;
});
} on ApiException {
if (!mounted) return;
setState(() => _summaryPhase = _SummaryPhase.error);
}
}
/// 待办提醒(?status=pendingdue_at ASC):驱动「健康提醒」卡与
/// 提醒入口副行——demo 时代的硬编码提醒文案自此为真实数据取代。
/// 失败只降级为入口副行提示,不阻塞档案主链路。
Future<void> _loadPendingReminders() async {
setState(() => _remindersPhase = _RemindersPhase.loading);
try {
final reminders = await widget.controller.repository.listCareReminders(
widget.petId,
status: CareReminderStatus.pending,
);
if (!mounted) return;
setState(() {
_pendingReminders = reminders;
_remindersPhase = _RemindersPhase.ready;
});
} on ApiException {
if (!mounted) return;
setState(() => _remindersPhase = _RemindersPhase.error);
}
}
Future<void> _openEdit() async {
final pet = _pet;
if (pet == null) return;
final updated = await Navigator.of(context).push<Pet>(
// 编辑态不带路由名:pet_form 专属建宠漏斗到达段(06 §1.6),
// 编辑曝光不计入,避免「到达→动笔」分母虚高。
fadePageRoute(
PetFormPage.edit(
controller: widget.controller,
pet: pet,
analytics: widget.analytics,
),
),
);
if (updated != null && mounted) {
setState(() {
_pet = updated;
_phase = _DetailPhase.ready;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已保存修改')));
}
}
/// 仅 owner 可改档案(40300caregiver/viewer 改档案被拒 → 隐藏写入口)。
bool get _canEdit =>
_phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner;
/// 记录写入权限档 WRITEowner + caregiver);viewer 隐藏录入入口。
bool get _canWriteRecords => _pet?.myRole != PetRole.viewer;
Future<void> _openWeights(Pet pet) async {
await Navigator.of(context).push(
// 列表页页名不在字典 v2 枚举内(06 §5.2 验收 4:字典外不上报),
// 曝光由 health_record_viewed 承载。
fadePageRoute(
WeightRecordsPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 记录可能已变化:返回即重拉摘要(服务端实时聚合是唯一事实来源)。
if (mounted) await _loadSummary();
}
Future<void> _openVaccinations(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
VaccinationRecordsPage(
repository: widget.controller.repository,
petId: pet.id,
petSpecies: pet.species,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
if (mounted) await _loadSummary();
}
Future<void> _openTimeline(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
HealthEventsPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 事件可能已变化:返回即重拉摘要(月度花费实时聚合)。
if (mounted) await _loadSummary();
}
Future<void> _openReminders(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
CareRemindersPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 待办可能已变化(完成/忽略/新建):返回即重拉待办。
if (mounted) await _loadPendingReminders();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
actions: [
if (_canEdit)
IconButton(
tooltip: '编辑资料',
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined),
),
],
),
body: switch (_phase) {
_DetailPhase.loading => const Center(
child: CircularProgressIndicator(),
),
_DetailPhase.error => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: petLoadErrorMessage(_error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('重试')),
],
),
),
),
_DetailPhase.notFound => Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.search_off_rounded,
title: '宠物不存在或已被删除',
description: '档案可能已被移除,返回列表查看最新档案',
ctaLabel: '返回列表',
onCtaPressed: () {
widget.controller.refresh();
Navigator.of(context).pop();
},
),
),
),
_DetailPhase.ready => _content(_pet!),
},
);
}
Widget _content(Pet pet) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 30),
children: [
Column(
children: [
PetAvatar(
size: PetAvatarSize.xl,
showEditBadge: _canEdit,
onTap: _canEdit ? _openEdit : null,
semanticLabel: _canEdit ? '编辑宠物资料' : null,
),
const SizedBox(height: 12),
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
petMetaLine(pet),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
if (pet.status != PetStatus.active) ...[
const SizedBox(height: 8),
TagPill(
petStatusLabel(pet.status),
color: pet.status == PetStatus.lost
? AppColors.error
: AppColors.muted,
),
],
],
),
const SizedBox(height: 24),
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
_summarySection(pet),
const SizedBox(height: 16),
_recordsSection(pet),
const SizedBox(height: 24),
Text('基本资料', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
SectionCard(
child: Column(
children: [
_InfoRow(label: '物种', value: petSpeciesLabel(pet.species)),
const _RowDivider(),
_InfoRow(label: '品种', value: petBreedLabel(pet)),
const _RowDivider(),
_InfoRow(label: '性别', value: petSexLabel(pet.sex)),
const _RowDivider(),
_InfoRow(
label: '生日',
value: pet.birthDate == null
? '未填写'
: dateToJson(pet.birthDate!) +
(pet.birthDateEstimated ? '(估算)' : ''),
),
const _RowDivider(),
_InfoRow(label: '芯片号', value: pet.microchipNo ?? '未填写'),
const _RowDivider(),
_InfoRow(label: '性格', value: pet.personality ?? '未填写'),
const _RowDivider(),
_InfoRow(
label: '绝育日期',
value: pet.sterilizedOn == null
? '未填写'
: dateToJson(pet.sterilizedOn!),
),
],
),
),
if (_canEdit) ...[
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined, size: 17),
label: const Text('编辑资料'),
),
],
],
);
}
/// 记录导航区:健康时间线与照护提醒入口(T2-14)。待办提醒非空时
/// 上方渲染真实数据驱动的「健康提醒」卡(正典 alert-card 形态,
/// 取代 demo 硬编码文案),点卡与点入口同去提醒页。
Widget _recordsSection(Pet pet) {
final nearest = _pendingReminders.isEmpty ? null : _pendingReminders.first;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (nearest != null) ...[
_ReminderAlertCard(
reminder: nearest,
overdue: isReminderOverdue(nearest, DateTime.now()),
onTap: () => _openReminders(pet),
),
const SizedBox(height: 10),
],
SectionCard(
padding: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: const Icon(
Icons.event_note_outlined,
color: AppColors.inkSoft,
),
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
subtitle: const Text(
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.chevron_right,
color: AppColors.muted,
),
onTap: () => _openTimeline(pet),
),
const Divider(height: 1, thickness: 1, color: AppColors.border),
ListTile(
leading: const Icon(
Icons.notifications_outlined,
color: AppColors.inkSoft,
),
title: const Text('照护提醒', style: TextStyle(fontSize: 14)),
subtitle: Text(
switch (_remindersPhase) {
_RemindersPhase.loading => '加载中…',
_RemindersPhase.error => '提醒加载失败,点击查看',
_RemindersPhase.ready when _pendingReminders.isEmpty =>
'暂无待办提醒',
_RemindersPhase.ready => '${_pendingReminders.length} 条待办',
},
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
trailing: const Icon(
Icons.chevron_right,
color: AppColors.muted,
),
onTap: () => _openReminders(pet),
),
],
),
),
],
);
}
/// 数据卡行(05 §4.2 stat-row):四卡取数全部来自 summary 实时聚合,
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
Widget _summarySection(Pet pet) {
switch (_summaryPhase) {
case _SummaryPhase.loading:
return const SizedBox(
height: 86,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
case _SummaryPhase.error:
return Row(
children: [
const Expanded(
child: Text(
'健康数据加载失败',
style: TextStyle(color: AppColors.error, fontSize: 12),
),
),
TextButton(onPressed: _loadSummary, child: const Text('重试')),
],
);
case _SummaryPhase.ready:
final summary = _summary!;
final weight = summary.latestWeight;
final progress = summary.vaccinationProgress;
final next = summary.nextVaccination;
final expense = summary.monthlyExpense;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _SummaryCard(
icon: recordTypeStyles[RecordType.weight]!.icon,
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
value: weight == null
? '暂无记录'
: '${formatWeightKg(weight.weightKg)} kg',
emphasized: weight != null,
label: '最新体重',
onTap: () => _openWeights(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
value: progress == null
? '未登记'
: '${vaccinationProgressLabel(progress)}',
emphasized: progress != null,
label: '疫苗进度',
onTap: () => _openVaccinations(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
emphasized: next != null,
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
onTap: () => _openVaccinations(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
icon: Icons.payments_outlined,
iconColor: AppColors.primaryStrong,
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
value: '¥${formatCentsAsYuan(expense.amountCents)}',
emphasized: expense.amountCents > 0,
label: '本月花费',
onTap: () => _openTimeline(pet),
),
),
],
),
);
}
}
}
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.icon,
required this.iconColor,
required this.value,
required this.label,
required this.emphasized,
this.onTap,
});
final IconData icon;
final Color iconColor;
final String value;
final String label;
final bool emphasized;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: iconColor),
const SizedBox(height: 8),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: emphasized ? AppColors.ink : AppColors.inkSoft,
fontSize: emphasized ? 15 : 13,
fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 11),
),
],
),
),
),
);
}
}
/// 键值行(05 §4.3 P3 风格:字段名 12 inkSoft / 值 bodyMedium ink)。
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(
label,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
}
class _RowDivider extends StatelessWidget {
const _RowDivider();
@override
Widget build(BuildContext context) {
return const Divider(height: 1, thickness: 1, color: AppColors.border);
}
}
/// 「健康提醒」卡(正典 alert-cardsuccessSurface 底 + dot + 文字):
/// 数据源为最近到期的待办提醒(真实数据驱动,取代 demo 硬编码文案);
/// 逾期时文案切警示深色(双通道:前缀文字 + 颜色)。
class _ReminderAlertCard extends StatelessWidget {
const _ReminderAlertCard({
required this.reminder,
required this.overdue,
this.onTap,
});
final CareReminder reminder;
final bool overdue;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final textColor = overdue ? AppColors.errorDark : AppColors.successInk;
return Material(
color: AppColors.successSurface,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: textColor,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'健康提醒:${reminder.title}'
'${overdue ? '已逾期' : '${dateToJson(reminder.dueAt.toLocal())} 到期'}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, size: 18, color: textColor),
],
),
),
),
);
}
}
+56
View File
@@ -0,0 +1,56 @@
/// 宠物档案展示文案的纯函数集合(列表 / 详情 / 表单共用,可单测)。
library;
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
String petSpeciesLabel(PetSpecies species) => switch (species) {
PetSpecies.dog => '狗狗',
PetSpecies.cat => '猫咪',
PetSpecies.other => '其他',
};
String petSexLabel(PetSex sex) => switch (sex) {
PetSex.male => '男孩',
PetSex.female => '女孩',
PetSex.unknown => '性别未知',
};
String petStatusLabel(PetStatus status) => switch (status) {
PetStatus.active => '正常',
PetStatus.lost => '走失中',
PetStatus.deceased => '已离世',
PetStatus.archived => '已归档',
};
/// 品种展示:目录品种名 > 自定义品种名 > 未知(Pet 响应恰有其一非空,
/// 双空为兜底)。
String petBreedLabel(Pet pet) =>
pet.breedDisplayName ?? pet.customBreedName ?? '品种未知';
/// 年龄:满一岁按「N 岁」、不满一岁按「N 个月」、当月新生「未满月」;
/// 生日缺席或晚于今天为「年龄未知」。
String petAgeLabel(DateTime? birthDate, {DateTime? now}) {
if (birthDate == null) return '年龄未知';
final today = now ?? DateTime.now();
var months =
(today.year - birthDate.year) * 12 + today.month - birthDate.month;
if (today.day < birthDate.day) months--;
if (months < 0) return '年龄未知';
if (months == 0) return '未满月';
if (months < 12) return '$months 个月';
return '${months ~/ 12}';
}
/// 列表卡 / 详情头的元信息行:「柴犬 · 男孩 · 2 岁」。
String petMetaLine(Pet pet, {DateTime? now}) =>
'${petBreedLabel(pet)} · ${petSexLabel(pet.sex)} · '
'${petAgeLabel(pet.birthDate, now: now)}';
/// 加载失败的用户文案(一迭代三层错误模型:不可归属字段的加载错误
/// 走横幅;服务端原始 message 不上屏)。
String petLoadErrorMessage(ApiException? error) => switch (error) {
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '加载失败,请稍后重试',
};
+83
View File
@@ -0,0 +1,83 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
/// pets 域类型化业务异常(契约 v1.2.0 定型的 8 个新错误码,
/// 21 号收口报告 §1)。全部继承 [ApiBusinessException]
/// 既有按基类捕获的通用错误处理不受影响。
/// 40300:对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。
final class PetAccessDeniedException extends ApiBusinessException {
const PetAccessDeniedException({required super.message})
: super(code: ApiCodes.petAccessDenied);
}
/// 40401:宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。
final class PetNotFoundException extends ApiBusinessException {
const PetNotFoundException({required super.message})
: super(code: ApiCodes.petNotFound);
}
/// 40402:记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。
final class PetRecordNotFoundException extends ApiBusinessException {
const PetRecordNotFoundException({required super.message})
: super(code: ApiCodes.recordNotFound);
}
/// 40902:乐观锁版本冲突(version 过期;提醒的条件更新守卫落空同码)。
/// 客户端处理:刷新取新数据后重提。
final class PetVersionConflictException extends ApiBusinessException {
const PetVersionConflictException({required super.message})
: super(code: ApiCodes.versionConflict);
}
/// 40903:芯片号已被登记(跨用户唯一)。
final class MicrochipTakenException extends ApiBusinessException {
const MicrochipTakenException({required super.message})
: super(code: ApiCodes.microchipTaken);
}
/// 40904:同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在
/// (cancel 后同剂次可重新登记)。
final class VaccinationDoseExistsException extends ApiBusinessException {
const VaccinationDoseExistsException({required super.message})
: super(code: ApiCodes.vaccinationDoseExists);
}
/// 42201:疫苗状态机非法迁移或状态-日期规则违反(HTTP 422)。
final class VaccinationRuleException extends ApiBusinessException {
const VaccinationRuleException({required super.message})
: super(code: ApiCodes.vaccinationRuleViolation);
}
/// 42202:提醒状态机非法迁移或 completed-completedAt 一致性违反(HTTP 422)。
final class CareReminderRuleException extends ApiBusinessException {
const CareReminderRuleException({required super.message})
: super(code: ApiCodes.careReminderRuleViolation);
}
/// 把通用业务异常按 pets 域错误码升格为类型化异常;
/// 未覆盖的码(40000 参数错误等)原样返回,沿用通用处理。
ApiBusinessException mapPetBusinessException(ApiBusinessException error) {
return switch (error.code) {
ApiCodes.petAccessDenied => PetAccessDeniedException(
message: error.message,
),
ApiCodes.petNotFound => PetNotFoundException(message: error.message),
ApiCodes.recordNotFound => PetRecordNotFoundException(
message: error.message,
),
ApiCodes.versionConflict => PetVersionConflictException(
message: error.message,
),
ApiCodes.microchipTaken => MicrochipTakenException(message: error.message),
ApiCodes.vaccinationDoseExists => VaccinationDoseExistsException(
message: error.message,
),
ApiCodes.vaccinationRuleViolation => VaccinationRuleException(
message: error.message,
),
ApiCodes.careReminderRuleViolation => CareReminderRuleException(
message: error.message,
),
_ => error,
};
}
+714
View File
@@ -0,0 +1,714 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
/// 品种下拉里「自定义品种」哨兵值(与目录 breedId 互斥)。
const _customBreedSentinel = '__custom__';
/// 建宠 / 编辑宠物资料表单页(T2-12)。
///
/// - 字段对齐冻结契约:昵称*、物种*(创建后不可改)、性别*(契约必填)、
/// 品种(目录选择与自定义互斥、整体替换)、生日(+是否估算)、
/// 芯片号、性格;头像按 ADR-010 本地占位、不做上传。
/// - 错误分层沿用登录纵切:失焦+提交双校验走 errorTextonChanged 即清)、
/// 不可归属错误走 InlineErrorBanner、网络瞬态走 SnackBar+重试。
/// - 409/40902 版本冲突:提示「已被修改」并自动拉取最新版本(保留用户
/// 输入、更新乐观锁 version),用户核对后重新保存;40903 芯片号冲突
/// 为字段级报错。
/// - 埋点(仅创建模式):首次输入触发 pet_create_started;成功/失败
/// 经 [PetAnalytics] 强类型上报。表单页路由名 pet_form 由调用方在
/// push 时给定(创建态),page_viewed 走既有 RouteObserver。
class PetFormPage extends StatefulWidget {
const PetFormPage.create({
required this.controller,
super.key,
this.analytics,
this.entryPoint = PetCreateEntryPoint.petList,
}) : pet = null;
const PetFormPage.edit({
required this.controller,
required Pet this.pet,
super.key,
this.analytics,
}) : entryPoint = null;
final PetsController controller;
/// null 为创建模式。
final Pet? pet;
final PetAnalytics? analytics;
/// 创建模式的入口(pet_create_started.entryPoint)。
final PetCreateEntryPoint? entryPoint;
bool get isCreate => pet == null;
@override
State<PetFormPage> createState() => _PetFormPageState();
}
class _PetFormPageState extends State<PetFormPage> {
final _nameCtrl = TextEditingController();
final _customBreedCtrl = TextEditingController();
final _microchipCtrl = TextEditingController();
final _personalityCtrl = TextEditingController();
PetSpecies _species = PetSpecies.dog;
PetSex? _sex;
DateTime? _birthDate;
bool _birthDateEstimated = false;
/// 目录 breedId 或 [_customBreedSentinel]null 未选择。
String? _breedChoice;
List<Breed>? _breeds;
bool _breedsLoading = false;
bool _breedsFailed = false;
String? _nameError;
String? _sexError;
String? _breedError;
String? _microchipError;
String? _formError;
bool _submitting = false;
/// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。
Pet? _basePet;
bool _startedFired = false;
int _attemptSeq = 0;
late final DateTime _openedAt;
@override
void initState() {
super.initState();
_openedAt = DateTime.now();
final pet = widget.pet;
_basePet = pet;
if (pet != null) {
_nameCtrl.text = pet.name;
_species = pet.species;
_sex = pet.sex;
_birthDate = pet.birthDate;
_birthDateEstimated = pet.birthDateEstimated;
_microchipCtrl.text = pet.microchipNo ?? '';
_personalityCtrl.text = pet.personality ?? '';
if (pet.customBreedName != null) {
_breedChoice = _customBreedSentinel;
_customBreedCtrl.text = pet.customBreedName!;
} else {
_breedChoice = pet.breedId;
}
}
_loadBreeds();
}
@override
void dispose() {
_nameCtrl.dispose();
_customBreedCtrl.dispose();
_microchipCtrl.dispose();
_personalityCtrl.dispose();
super.dispose();
}
// ---- 品种目录(网络字典:加载 / 失败重试 / 空目录自定义兜底)----
Future<void> _loadBreeds() async {
setState(() {
_breedsLoading = true;
_breedsFailed = false;
});
try {
final breeds = await widget.controller.loadBreeds(_species);
if (!mounted) return;
setState(() {
_breeds = breeds;
_breedsLoading = false;
});
} on ApiException {
if (!mounted) return;
setState(() {
_breedsLoading = false;
_breedsFailed = true;
// 目录不可用不阻塞建档:回落到自定义品种输入。
_breedChoice ??= _customBreedSentinel;
});
}
}
// ---- 埋点(仅创建模式)----
void _markStarted() {
if (!widget.isCreate || _startedFired) return;
_startedFired = true;
widget.analytics?.createStarted(entryPoint: widget.entryPoint!);
}
void _trackCreateFailed(PetCreateFailureReason reason, [int? errorCode]) {
if (!widget.isCreate) return;
widget.analytics?.createFailed(
reason: reason,
attemptSeq: _attemptSeq,
errorCode: errorCode,
);
}
// ---- 校验(失焦 + 提交双校验,onChanged 即清)----
String? _validateName() => _nameCtrl.text.trim().isEmpty ? '请输入宠物昵称' : null;
String? _validateSex() => _sex == null ? '请选择性别' : null;
String? _validateBreed() {
if (_breedChoice == null) return '请选择品种';
if (_breedChoice == _customBreedSentinel &&
_customBreedCtrl.text.trim().isEmpty) {
return '请输入品种名称';
}
return null;
}
void _clearError(void Function() clear) {
setState(() {
clear();
_formError = null;
});
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
// ---- 提交 ----
Future<void> _submit() async {
if (_submitting) return;
_attemptSeq++;
final nameError = _validateName();
final sexError = _validateSex();
final breedError = _validateBreed();
if (nameError != null || sexError != null || breedError != null) {
setState(() {
_nameError = nameError;
_sexError = sexError;
_breedError = breedError;
});
_trackCreateFailed(PetCreateFailureReason.validationError);
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
if (widget.isCreate) {
await _create();
} else {
await _update();
}
} on MicrochipTakenException {
if (!mounted) return;
setState(() => _microchipError = '该芯片号已被登记,请核对后重试');
_trackCreateFailed(PetCreateFailureReason.validationError, 40903);
} on PetVersionConflictException {
await _handleVersionConflict();
} on PetNotFoundException {
if (!mounted) return;
// 编辑目标已不存在(防枚举三态同响应):返回列表并刷新。
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
widget.controller.refresh();
navigator.pop();
} on PetAccessDeniedException {
if (!mounted) return;
_showFormError('你没有权限修改该宠物的资料');
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('操作过于频繁,请稍后再试');
_trackCreateFailed(PetCreateFailureReason.rateLimited);
} on ApiBusinessException catch (error) {
if (!mounted) return;
_showFormError(
error.code == ApiCodes.paramError ? '请检查填写内容后重试' : '保存失败,请稍后重试',
);
_trackCreateFailed(
error.code == ApiCodes.paramError
? PetCreateFailureReason.validationError
: PetCreateFailureReason.serverError,
error.code,
);
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
_trackCreateFailed(PetCreateFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页,表单不再提示。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
bool get _isCustomBreed => _breedChoice == _customBreedSentinel;
Future<void> _create() async {
final request = CreatePetRequest(
name: _nameCtrl.text.trim(),
species: _species,
sex: _sex!,
breedId: _isCustomBreed ? null : _breedChoice,
customBreedName: _isCustomBreed ? _customBreedCtrl.text.trim() : null,
birthDate: _birthDate,
birthDateEstimated: _birthDate != null ? _birthDateEstimated : null,
microchipNo: _textOrNull(_microchipCtrl),
personality: _textOrNull(_personalityCtrl),
);
// petIndex:该用户第几只宠物(H2 假设数据源)。
final petIndex = widget.controller.pets.length + 1;
final pet = await widget.controller.createPet(request);
widget.analytics?.createSucceeded(
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
species: _species,
petIndex: petIndex,
);
if (mounted) Navigator.of(context).pop(pet);
}
Future<void> _update() async {
final request = _buildUpdateRequest();
if (request == null) {
// 无变更:直接返回,不发空 PATCH。
Navigator.of(context).pop();
return;
}
final pet = await widget.controller.updatePet(_basePet!.id, request);
if (mounted) Navigator.of(context).pop(pet);
}
/// 差量构造部分更新请求(缺席字段不发;契约不支持清空回 null,
/// 清空的输入视为未变更)。全部未变更返回 null。
UpdatePetRequest? _buildUpdateRequest() {
final base = _basePet!;
final name = _nameCtrl.text.trim();
final String? breedId = _isCustomBreed ? null : _breedChoice;
final String? customName = _isCustomBreed
? _customBreedCtrl.text.trim()
: null;
final breedChanged =
breedId != base.breedId || customName != base.customBreedName;
final microchip = _textOrNull(_microchipCtrl);
final personality = _textOrNull(_personalityCtrl);
final birthChanged =
_birthDate != null && !_sameDate(_birthDate, base.birthDate);
final estimatedChanged =
_birthDate != null && _birthDateEstimated != base.birthDateEstimated;
final request = UpdatePetRequest(
version: base.version,
name: name != base.name ? name : null,
sex: _sex != base.sex ? _sex : null,
// 品种对整体替换:任一半变化则整对发送。
breedId: breedChanged ? breedId : null,
customBreedName: breedChanged ? customName : null,
birthDate: birthChanged ? _birthDate : null,
birthDateEstimated: estimatedChanged ? _birthDateEstimated : null,
microchipNo: microchip != null && microchip != base.microchipNo
? microchip
: null,
personality: personality != null && personality != base.personality
? personality
: null,
);
// 只剩 version 一个键 → 无实际变更。
return request.toJson().length == 1 ? null : request;
}
/// 40902:提示 + 刷新路径——拉取最新版本更新乐观锁基线,
/// 保留用户输入,由用户核对后重新保存(T2-11 §7 定型路径)。
Future<void> _handleVersionConflict() async {
try {
final fresh = await widget.controller.getPet(_basePet!.id);
if (!mounted) return;
setState(() => _basePet = fresh);
_showFormError('资料已在其他设备被修改,已获取最新版本,请核对后重新保存');
} on ApiException {
if (!mounted) return;
_showFormError('资料已在其他设备被修改,请返回后刷新重试');
}
}
static String? _textOrNull(TextEditingController controller) {
final text = controller.text.trim();
return text.isEmpty ? null : text;
}
static bool _sameDate(DateTime? a, DateTime? b) {
if (a == null || b == null) return a == b;
return a.year == b.year && a.month == b.month && a.day == b.day;
}
// ---- UI ----
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: Text(widget.isCreate ? '添加宠物' : '编辑宠物资料'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 30),
children: [
// 头像本地占位:M2 不做上传(ADR-010 / D2-1)。
const Center(child: PetAvatar(size: PetAvatarSize.xl)),
const SizedBox(height: 18),
Focus(
onFocusChange: (hasFocus) {
if (!hasFocus && mounted) {
setState(() => _nameError = _validateName());
}
},
child: AppTextField(
label: '宠物昵称',
controller: _nameCtrl,
prefixIcon: Icons.pets_outlined,
errorText: _nameError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_nameError != null || _formError != null) {
_clearError(() => _nameError = null);
}
},
),
),
const SizedBox(height: 18),
_FieldLabel(widget.isCreate ? '物种' : '物种(创建后不可修改)'),
const SizedBox(height: 8),
if (widget.isCreate)
SegmentedButton<PetSpecies>(
segments: [
for (final species in PetSpecies.values)
ButtonSegment(
value: species,
label: Text(petSpeciesLabel(species)),
),
],
selected: {_species},
onSelectionChanged: _submitting
? null
: (value) {
_markStarted();
setState(() {
_species = value.first;
// 物种切换:品种目录随物种重载,已选品种作废。
_breedChoice = null;
_breedError = null;
_breeds = null;
});
_loadBreeds();
},
)
else
Text(
petSpeciesLabel(_species),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 18),
const _FieldLabel('性别'),
const SizedBox(height: 8),
SegmentedButton<PetSex>(
emptySelectionAllowed: true,
segments: const [
ButtonSegment(
value: PetSex.male,
icon: Icon(Icons.male),
label: Text('男孩'),
),
ButtonSegment(
value: PetSex.female,
icon: Icon(Icons.female),
label: Text('女孩'),
),
ButtonSegment(value: PetSex.unknown, label: Text('未知')),
],
selected: {?_sex},
onSelectionChanged: _submitting
? null
: (value) {
_markStarted();
setState(() {
_sex = value.isEmpty ? null : value.first;
_sexError = null;
_formError = null;
});
},
),
if (_sexError != null) ...[
const SizedBox(height: 6),
_FieldError(_sexError!),
],
const SizedBox(height: 18),
const _FieldLabel('品种'),
const SizedBox(height: 8),
..._breedSection(),
const SizedBox(height: 18),
_dateTile(),
if (_birthDate != null)
CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('生日为估算日期', style: TextStyle(fontSize: 13)),
value: _birthDateEstimated,
onChanged: _submitting
? null
: (value) {
_markStarted();
setState(() => _birthDateEstimated = value ?? false);
},
),
const SizedBox(height: 12),
AppTextField(
label: '芯片号(可选)',
controller: _microchipCtrl,
prefixIcon: Icons.qr_code_2_outlined,
errorText: _microchipError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_microchipError != null || _formError != null) {
_clearError(() => _microchipError = null);
}
},
),
const SizedBox(height: 12),
AppTextField(
label: '性格(可选,如:活泼)',
controller: _personalityCtrl,
prefixIcon: Icons.emoji_emotions_outlined,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onChanged: (_) => _markStarted(),
onSubmitted: (_) => _submit(),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: widget.isCreate ? '保存档案' : '保存修改',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
List<Widget> _breedSection() {
if (_breedsLoading) {
return const [
SizedBox(
height: 52,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
];
}
final widgets = <Widget>[];
if (_breedsFailed) {
widgets
..add(
Row(
children: [
const Expanded(
child: Text(
'品种目录加载失败,可先填写自定义品种',
style: TextStyle(color: AppColors.error, fontSize: 12),
),
),
TextButton(onPressed: _loadBreeds, child: const Text('重试')),
],
),
)
..add(const SizedBox(height: 8));
} else {
final breeds = _breeds ?? const <Breed>[];
final knownIds = breeds.map((breed) => breed.id).toSet();
final base = _basePet;
widgets
..add(
DropdownButtonFormField<String>(
initialValue: _breedChoice,
decoration: InputDecoration(
labelText: '品种',
errorText: _isCustomBreed ? null : _breedError,
),
items: [
// 编辑时目录中缺席的既有品种保底成项,避免下拉值失配。
if (base?.breedId != null && !knownIds.contains(base!.breedId))
DropdownMenuItem(
value: base.breedId,
child: Text(base.breedDisplayName ?? '当前品种'),
),
for (final breed in breeds)
DropdownMenuItem(
value: breed.id,
child: Text(breed.displayName),
),
const DropdownMenuItem(
value: _customBreedSentinel,
child: Text('自定义品种…'),
),
],
onChanged: _submitting
? null
: (value) {
_markStarted();
setState(() {
_breedChoice = value;
_breedError = null;
_formError = null;
});
},
),
)
..add(const SizedBox(height: 12));
}
if (_isCustomBreed) {
widgets.add(
Focus(
onFocusChange: (hasFocus) {
if (!hasFocus && mounted) {
setState(() => _breedError = _validateBreed());
}
},
child: AppTextField(
label: '品种名称',
controller: _customBreedCtrl,
prefixIcon: Icons.edit_note_outlined,
errorText: _breedError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_breedError != null || _formError != null) {
_clearError(() => _breedError = null);
}
},
),
),
);
} else if (_breedError != null && _breedsFailed) {
widgets.add(_FieldError(_breedError!));
}
return widgets;
}
Widget _dateTile() {
return ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
tileColor: AppColors.surface,
leading: const Icon(Icons.cake_outlined, color: AppColors.muted),
title: const Text('生日(可选)', style: TextStyle(fontSize: 14)),
subtitle: Text(
_birthDate == null ? '未填写' : dateToJson(_birthDate!),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.calendar_month_outlined,
color: AppColors.muted,
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await showDatePicker(
context: context,
initialDate: _birthDate ?? DateTime(now.year - 1, now.month),
firstDate: DateTime(1990),
lastDate: now,
);
if (value != null && mounted) {
_markStarted();
setState(() => _birthDate = value);
}
},
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
);
}
}
class _FieldError extends StatelessWidget {
const _FieldError(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(color: AppColors.error, fontSize: 12),
);
}
}
+829
View File
@@ -0,0 +1,829 @@
/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.2.0
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
/// 以便契约漂移在测试期暴露而非静默吞掉)。
library;
export 'package:patbond_flutter/core/models/cursor_page.dart';
/// 物种(创建即定,不可修改)。
enum PetSpecies {
dog,
cat,
other;
static PetSpecies fromJson(String value) =>
_enumFromJson(values, value, 'species');
}
/// 性别。
enum PetSex {
male,
female,
unknown;
static PetSex fromJson(String value) => _enumFromJson(values, value, 'sex');
}
/// 宠物状态(deleted 为内部软删态,接口永不返回)。
enum PetStatus {
active,
lost,
deceased,
archived;
static PetStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 调用者对宠物的权限角色(客户端据此显隐写入口)。
enum PetRole {
owner,
caregiver,
viewer;
static PetRole fromJson(String value) =>
_enumFromJson(values, value, 'myRole');
}
/// 体重记录来源。
enum WeightSource {
manual,
clinic,
device;
static WeightSource fromJson(String value) =>
_enumFromJson(values, value, 'source');
}
/// 疫苗记录状态。
enum VaccinationStatus {
scheduled,
completed,
cancelled;
static VaccinationStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 健康事件六类类型。
enum HealthEventType {
medical,
feeding,
deworming,
grooming,
measurement,
note;
static HealthEventType fromJson(String value) =>
_enumFromJson(values, value, 'eventType');
}
/// 照护提醒四类类型。
enum CareReminderType {
deworming,
checkup,
medication,
other;
static CareReminderType fromJson(String value) =>
_enumFromJson(values, value, 'reminderType');
}
/// 照护提醒状态。
enum CareReminderStatus {
pending,
completed,
dismissed;
static CareReminderStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 摘要「下次接种」的取值来源。
enum NextVaccinationSource {
planned,
nextDue;
static NextVaccinationSource fromJson(String value) =>
_enumFromJson(values, value, 'source');
}
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
for (final value in values) {
if (value.name == raw) return value;
}
throw FormatException('未知的 $field 取值:$raw');
}
/// 日期字段(契约 format: date)序列化为 YYYY-MM-DD。
String dateToJson(DateTime date) {
final y = date.year.toString().padLeft(4, '0');
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
DateTime? _dateOrNull(Object? value) =>
value == null ? null : DateTime.parse(value as String);
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
class Pet {
const Pet({
required this.id,
required this.name,
required this.species,
required this.breedId,
required this.breedDisplayName,
required this.customBreedName,
required this.sex,
required this.birthDate,
required this.birthDateEstimated,
required this.personality,
required this.microchipNo,
required this.sterilizedOn,
required this.status,
required this.myRole,
required this.createdAt,
required this.updatedAt,
required this.version,
});
factory Pet.fromJson(Map<String, dynamic> json) {
return Pet(
id: json['id'] as String,
name: json['name'] as String,
species: PetSpecies.fromJson(json['species'] as String),
breedId: json['breedId'] as String?,
breedDisplayName: json['breedDisplayName'] as String?,
customBreedName: json['customBreedName'] as String?,
sex: PetSex.fromJson(json['sex'] as String),
birthDate: _dateOrNull(json['birthDate']),
birthDateEstimated: json['birthDateEstimated'] as bool,
personality: json['personality'] as String?,
microchipNo: json['microchipNo'] as String?,
sterilizedOn: _dateOrNull(json['sterilizedOn']),
status: PetStatus.fromJson(json['status'] as String),
myRole: PetRole.fromJson(json['myRole'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
version: json['version'] as int,
);
}
final String id;
final String name;
final PetSpecies species;
final String? breedId;
final String? breedDisplayName;
final String? customBreedName;
final PetSex sex;
final DateTime? birthDate;
final bool birthDateEstimated;
final String? personality;
final String? microchipNo;
final DateTime? sterilizedOn;
final PetStatus status;
final PetRole myRole;
final DateTime createdAt;
final DateTime updatedAt;
final int version;
}
/// 创建宠物请求。breedId 与 customBreedName 二选一且互斥(400/40000 兜底在服务端)。
class CreatePetRequest {
const CreatePetRequest({
required this.name,
required this.species,
required this.sex,
this.breedId,
this.customBreedName,
this.birthDate,
this.birthDateEstimated,
this.personality,
this.microchipNo,
this.sterilizedOn,
});
final String name;
final PetSpecies species;
final PetSex sex;
final String? breedId;
final String? customBreedName;
final DateTime? birthDate;
final bool? birthDateEstimated;
final String? personality;
final String? microchipNo;
final DateTime? sterilizedOn;
Map<String, Object?> toJson() => {
'name': name,
'species': species.name,
'sex': sex.name,
if (breedId != null) 'breedId': breedId,
if (customBreedName != null) 'customBreedName': customBreedName,
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
if (personality != null) 'personality': personality,
if (microchipNo != null) 'microchipNo': microchipNo,
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
};
}
/// 更新宠物请求(部分更新:缺席字段不变;不支持清空回 null;
/// 品种对整体替换;species 不可改;version 乐观锁必填)。
class UpdatePetRequest {
const UpdatePetRequest({
required this.version,
this.name,
this.breedId,
this.customBreedName,
this.sex,
this.birthDate,
this.birthDateEstimated,
this.personality,
this.microchipNo,
this.sterilizedOn,
this.status,
});
final int version;
final String? name;
final String? breedId;
final String? customBreedName;
final PetSex? sex;
final DateTime? birthDate;
final bool? birthDateEstimated;
final String? personality;
final String? microchipNo;
final DateTime? sterilizedOn;
final PetStatus? status;
Map<String, Object?> toJson() => {
'version': version,
if (name != null) 'name': name,
if (breedId != null) 'breedId': breedId,
if (customBreedName != null) 'customBreedName': customBreedName,
if (sex != null) 'sex': sex!.name,
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
if (personality != null) 'personality': personality,
if (microchipNo != null) 'microchipNo': microchipNo,
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
if (status != null) 'status': status!.name,
};
}
/// 品种目录项(只读字典)。
class Breed {
const Breed({
required this.id,
required this.species,
required this.code,
required this.displayName,
});
factory Breed.fromJson(Map<String, dynamic> json) {
return Breed(
id: json['id'] as String,
species: PetSpecies.fromJson(json['species'] as String),
code: json['code'] as String,
displayName: json['displayName'] as String,
);
}
final String id;
final PetSpecies species;
final String code;
final String displayName;
}
/// 体重记录(append-only,无乐观锁)。
class WeightRecord {
const WeightRecord({
required this.id,
required this.petId,
required this.weightKg,
required this.measuredAt,
required this.source,
required this.note,
required this.createdAt,
});
factory WeightRecord.fromJson(Map<String, dynamic> json) {
return WeightRecord(
id: json['id'] as String,
petId: json['petId'] as String,
// 服务端 numeric(6,2),整数值也可能以 int 下发。
weightKg: (json['weightKg'] as num).toDouble(),
measuredAt: DateTime.parse(json['measuredAt'] as String),
source: WeightSource.fromJson(json['source'] as String),
note: json['note'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final String petId;
final double weightKg;
final DateTime measuredAt;
final WeightSource source;
final String? note;
final DateTime createdAt;
}
/// 添加体重记录请求。
class CreateWeightRequest {
const CreateWeightRequest({
required this.weightKg,
required this.measuredAt,
this.source,
this.note,
});
final double weightKg;
final DateTime measuredAt;
final WeightSource? source;
final String? note;
Map<String, Object?> toJson() => {
'weightKg': weightKg,
'measuredAt': measuredAt.toIso8601String(),
if (source != null) 'source': source!.name,
if (note != null) 'note': note,
};
}
/// 疫苗目录项(只读字典)。
class VaccineCatalogItem {
const VaccineCatalogItem({
required this.id,
required this.code,
required this.name,
required this.species,
required this.description,
});
factory VaccineCatalogItem.fromJson(Map<String, dynamic> json) {
return VaccineCatalogItem(
id: json['id'] as String,
code: json['code'] as String,
name: json['name'] as String,
species: PetSpecies.fromJson(json['species'] as String),
description: json['description'] as String?,
);
}
final String id;
final String code;
final String name;
final PetSpecies species;
final String? description;
}
/// 疫苗记录。vaccineName 由目录解出(列表页免二次查字典)。
class Vaccination {
const Vaccination({
required this.id,
required this.petId,
required this.vaccineId,
required this.vaccineName,
required this.seriesKey,
required this.doseNo,
required this.doseLabel,
required this.status,
required this.plannedOn,
required this.administeredOn,
required this.nextDueOn,
required this.manufacturer,
required this.batchNo,
required this.notes,
required this.createdAt,
required this.updatedAt,
required this.version,
});
factory Vaccination.fromJson(Map<String, dynamic> json) {
return Vaccination(
id: json['id'] as String,
petId: json['petId'] as String,
vaccineId: json['vaccineId'] as String,
vaccineName: json['vaccineName'] as String,
seriesKey: json['seriesKey'] as String,
doseNo: json['doseNo'] as int,
doseLabel: json['doseLabel'] as String?,
status: VaccinationStatus.fromJson(json['status'] as String),
plannedOn: _dateOrNull(json['plannedOn']),
administeredOn: _dateOrNull(json['administeredOn']),
nextDueOn: _dateOrNull(json['nextDueOn']),
manufacturer: json['manufacturer'] as String?,
batchNo: json['batchNo'] as String?,
notes: json['notes'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
version: json['version'] as int,
);
}
final String id;
final String petId;
final String vaccineId;
final String vaccineName;
final String seriesKey;
final int doseNo;
final String? doseLabel;
final VaccinationStatus status;
final DateTime? plannedOn;
final DateTime? administeredOn;
final DateTime? nextDueOn;
final String? manufacturer;
final String? batchNo;
final String? notes;
final DateTime createdAt;
final DateTime updatedAt;
final int version;
}
/// 创建疫苗记录请求(创建状态仅 scheduled / completed)。
class CreateVaccinationRequest {
const CreateVaccinationRequest({
required this.vaccineId,
required this.seriesKey,
required this.doseNo,
required this.status,
this.doseLabel,
this.plannedOn,
this.administeredOn,
this.nextDueOn,
this.manufacturer,
this.batchNo,
this.notes,
});
final String vaccineId;
final String seriesKey;
final int doseNo;
/// 契约允许 scheduled / completedcancelled 创建无业务意义,服务端 400)。
final VaccinationStatus status;
final String? doseLabel;
final DateTime? plannedOn;
final DateTime? administeredOn;
final DateTime? nextDueOn;
final String? manufacturer;
final String? batchNo;
final String? notes;
Map<String, Object?> toJson() => {
'vaccineId': vaccineId,
'seriesKey': seriesKey,
'doseNo': doseNo,
'status': status.name,
if (doseLabel != null) 'doseLabel': doseLabel,
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
if (manufacturer != null) 'manufacturer': manufacturer,
if (batchNo != null) 'batchNo': batchNo,
if (notes != null) 'notes': notes,
};
}
/// 更新疫苗记录请求(vaccineId / seriesKey / doseNo 不可改;version 必填)。
class UpdateVaccinationRequest {
const UpdateVaccinationRequest({
required this.version,
this.status,
this.plannedOn,
this.administeredOn,
this.nextDueOn,
this.doseLabel,
this.manufacturer,
this.batchNo,
this.notes,
});
final int version;
final VaccinationStatus? status;
final DateTime? plannedOn;
final DateTime? administeredOn;
final DateTime? nextDueOn;
final String? doseLabel;
final String? manufacturer;
final String? batchNo;
final String? notes;
Map<String, Object?> toJson() => {
'version': version,
if (status != null) 'status': status!.name,
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
if (doseLabel != null) 'doseLabel': doseLabel,
if (manufacturer != null) 'manufacturer': manufacturer,
if (batchNo != null) 'batchNo': batchNo,
if (notes != null) 'notes': notes,
};
}
/// 健康事件(六类;金额整数分传输,元/分换算留给 UI 层)。
class HealthEvent {
const HealthEvent({
required this.id,
required this.petId,
required this.eventType,
required this.occurredAt,
required this.title,
required this.notes,
required this.amountCents,
required this.createdByUserId,
required this.createdAt,
required this.updatedAt,
required this.version,
});
factory HealthEvent.fromJson(Map<String, dynamic> json) {
return HealthEvent(
id: json['id'] as String,
petId: json['petId'] as String,
eventType: HealthEventType.fromJson(json['eventType'] as String),
occurredAt: DateTime.parse(json['occurredAt'] as String),
title: json['title'] as String,
notes: json['notes'] as String?,
amountCents: json['amountCents'] as int?,
createdByUserId: json['createdByUserId'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
version: json['version'] as int,
);
}
final String id;
final String petId;
final HealthEventType eventType;
final DateTime occurredAt;
final String title;
final String? notes;
/// 金额(整数分,非负,可空)。DTO 层保持整数分不换算。
final int? amountCents;
final String createdByUserId;
final DateTime createdAt;
final DateTime updatedAt;
final int version;
}
/// 添加健康事件请求(createdByUserId 取自 token,不收请求体)。
class CreateHealthEventRequest {
const CreateHealthEventRequest({
required this.eventType,
required this.occurredAt,
required this.title,
this.notes,
this.amountCents,
});
final HealthEventType eventType;
final DateTime occurredAt;
final String title;
final String? notes;
/// 整数分;提交小数服务端 400/40000(不做静默截断)。
final int? amountCents;
Map<String, Object?> toJson() => {
'eventType': eventType.name,
'occurredAt': occurredAt.toIso8601String(),
'title': title,
if (notes != null) 'notes': notes,
if (amountCents != null) 'amountCents': amountCents,
};
}
/// 更新健康事件请求(仅 title / notes / amountCents 可编辑;version 必填)。
class UpdateHealthEventRequest {
const UpdateHealthEventRequest({
required this.version,
this.title,
this.notes,
this.amountCents,
});
final int version;
final String? title;
final String? notes;
final int? amountCents;
Map<String, Object?> toJson() => {
'version': version,
if (title != null) 'title': title,
if (notes != null) 'notes': notes,
if (amountCents != null) 'amountCents': amountCents,
};
}
/// 照护提醒(无 version 字段;completedAt 非空当且仅当 status=completed)。
class CareReminder {
const CareReminder({
required this.id,
required this.petId,
required this.reminderType,
required this.title,
required this.dueAt,
required this.status,
required this.completedAt,
required this.createdAt,
required this.updatedAt,
});
factory CareReminder.fromJson(Map<String, dynamic> json) {
return CareReminder(
id: json['id'] as String,
petId: json['petId'] as String,
reminderType: CareReminderType.fromJson(json['reminderType'] as String),
title: json['title'] as String,
dueAt: DateTime.parse(json['dueAt'] as String),
status: CareReminderStatus.fromJson(json['status'] as String),
completedAt: json['completedAt'] == null
? null
: DateTime.parse(json['completedAt'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
final String id;
final String petId;
final CareReminderType reminderType;
final String title;
final DateTime dueAt;
final CareReminderStatus status;
final DateTime? completedAt;
final DateTime createdAt;
final DateTime updatedAt;
}
/// 创建照护提醒请求(创建恒为 pending,不收 status)。
class CreateCareReminderRequest {
const CreateCareReminderRequest({
required this.reminderType,
required this.title,
required this.dueAt,
});
final CareReminderType reminderType;
final String title;
final DateTime dueAt;
Map<String, Object?> toJson() => {
'reminderType': reminderType.name,
'title': title,
'dueAt': dueAt.toIso8601String(),
};
}
/// 更新提醒状态请求(状态流转专用:仅 status + completedAt)。
class UpdateCareReminderRequest {
const UpdateCareReminderRequest({required this.status, this.completedAt});
final CareReminderStatus status;
/// status=completed 时必填;其余状态禁带(违反 422/42202)。
/// 由客户端提交(而非服务端 now()),允许补记实际完成时刻。
final DateTime? completedAt;
Map<String, Object?> toJson() => {
'status': status.name,
if (completedAt != null) 'completedAt': completedAt!.toIso8601String(),
};
}
/// 摘要:最新体重(无记录 → 整体 null)。
class SummaryLatestWeight {
const SummaryLatestWeight({required this.weightKg, required this.measuredAt});
factory SummaryLatestWeight.fromJson(Map<String, dynamic> json) {
return SummaryLatestWeight(
weightKg: (json['weightKg'] as num).toDouble(),
measuredAt: DateTime.parse(json['measuredAt'] as String),
);
}
final double weightKg;
final DateTime measuredAt;
}
/// 摘要:疫苗进度(totalDoses=0 → 整体 null,不是 0/0)。
class SummaryVaccinationProgress {
const SummaryVaccinationProgress({
required this.completedDoses,
required this.totalDoses,
});
factory SummaryVaccinationProgress.fromJson(Map<String, dynamic> json) {
return SummaryVaccinationProgress(
completedDoses: json['completedDoses'] as int,
totalDoses: json['totalDoses'] as int,
);
}
final int completedDoses;
final int totalDoses;
}
/// 摘要:下次接种(候选集空 → 整体 null;dueOn 可为过去日期——逾期针仍是下一针)。
class SummaryNextVaccination {
const SummaryNextVaccination({
required this.vaccinationId,
required this.vaccineId,
required this.vaccineName,
required this.doseNo,
required this.doseLabel,
required this.dueOn,
required this.source,
});
factory SummaryNextVaccination.fromJson(Map<String, dynamic> json) {
return SummaryNextVaccination(
vaccinationId: json['vaccinationId'] as String,
vaccineId: json['vaccineId'] as String,
vaccineName: json['vaccineName'] as String,
doseNo: json['doseNo'] as int,
doseLabel: json['doseLabel'] as String?,
dueOn: DateTime.parse(json['dueOn'] as String),
source: NextVaccinationSource.fromJson(json['source'] as String),
);
}
final String vaccinationId;
final String vaccineId;
final String vaccineName;
final int doseNo;
final String? doseLabel;
final DateTime dueOn;
final NextVaccinationSource source;
}
/// 摘要:当月花费(恒非 null;无支出 amountCents=0;月边界随 tz 移动)。
class SummaryMonthlyExpense {
const SummaryMonthlyExpense({
required this.month,
required this.timezone,
required this.amountCents,
});
factory SummaryMonthlyExpense.fromJson(Map<String, dynamic> json) {
return SummaryMonthlyExpense(
month: json['month'] as String,
timezone: json['timezone'] as String,
amountCents: json['amountCents'] as int,
);
}
/// ISO year-month(如 2026-09)。
final String month;
final String timezone;
final int amountCents;
}
/// 档案聚合摘要(四项聚合实时计算;前三项无记录时为 null,
/// monthlyExpense 恒非 null——契约 PetSummary null 语义)。
class PetSummary {
const PetSummary({
required this.petId,
required this.latestWeight,
required this.vaccinationProgress,
required this.nextVaccination,
required this.monthlyExpense,
});
factory PetSummary.fromJson(Map<String, dynamic> json) {
return PetSummary(
petId: json['petId'] as String,
latestWeight: json['latestWeight'] == null
? null
: SummaryLatestWeight.fromJson(
json['latestWeight'] as Map<String, dynamic>,
),
vaccinationProgress: json['vaccinationProgress'] == null
? null
: SummaryVaccinationProgress.fromJson(
json['vaccinationProgress'] as Map<String, dynamic>,
),
nextVaccination: json['nextVaccination'] == null
? null
: SummaryNextVaccination.fromJson(
json['nextVaccination'] as Map<String, dynamic>,
),
monthlyExpense: SummaryMonthlyExpense.fromJson(
json['monthlyExpense'] as Map<String, dynamic>,
),
);
}
final String petId;
final SummaryLatestWeight? latestWeight;
final SummaryVaccinationProgress? vaccinationProgress;
final SummaryNextVaccination? nextVaccination;
final SummaryMonthlyExpense monthlyExpense;
}
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 网络数据四态(第 9 节硬要求:loading / empty / error / retry
/// data 态含空列表判定,页面据 [PetsLoadPhase] + 数据渲染四态)。
enum PetsLoadPhase { initial, loading, ready, error }
/// pets feature 状态控制器(开发计划 §4.2 分层:
/// Page/Widget → PetsController → PetsRepository → ApiClient)。
///
/// 宠物档案状态自此从 `AppState` 拆出:本 feature 不读写 AppState 的
/// 宠物/疫苗 demo 数据;服务端是唯一事实来源,内存副本仅作展示缓存
/// (无本地持久化——档案数据以 [refresh] 为失效/刷新策略)。
///
/// 本单只承载列表 + 详情主链路状态;体重/疫苗/事件/提醒的页面级
/// 状态由 T2-12~14 按页面直接经 Repository 取数或扩展本控制器。
class PetsController extends ChangeNotifier {
PetsController({required this._repository});
final PetsRepository _repository;
/// 记录级页面(体重/疫苗/事件/提醒)直接经仓库取数(22 号报告 §7
/// 交接约定:页面级状态按页自建,不膨胀本控制器)。
PetsRepository get repository => _repository;
PetsLoadPhase _phase = PetsLoadPhase.initial;
List<Pet> _pets = const [];
ApiException? _lastError;
bool _disposed = false;
final Map<PetSpecies, List<Breed>> _breedsCache = {};
PetsLoadPhase get phase => _phase;
/// 当前用户可见宠物(服务端 created_at DESC 排序原样保留)。
List<Pet> get pets => _pets;
/// ready 且列表为空 → 空态(新用户建档引导)。
bool get isEmpty => _phase == PetsLoadPhase.ready && _pets.isEmpty;
/// 最近一次加载失败的类型化错误(error 态时非 null)。
ApiException? get lastError => _lastError;
/// 加载 / 重试宠物列表。错误不外抛,收敛为 error 态供页面渲染 + retry。
Future<void> refresh() async {
_phase = PetsLoadPhase.loading;
_lastError = null;
_notify();
try {
_pets = await _repository.listPets();
_phase = PetsLoadPhase.ready;
} on ApiException catch (error) {
_lastError = error;
_phase = PetsLoadPhase.error;
}
_notify();
}
/// 创建宠物:成功后就地插入列表头(服务端 created_at DESC,新建最前),
/// 失败按类型化异常外抛给表单层处理(40903 芯片号冲突等)。
Future<Pet> createPet(CreatePetRequest request) async {
final pet = await _repository.createPet(request);
_pets = [pet, ..._pets];
if (_phase != PetsLoadPhase.ready) _phase = PetsLoadPhase.ready;
_notify();
return pet;
}
/// 拉取单只宠物详情并同步列表内存副本。
Future<Pet> getPet(String petId) async {
final pet = await _repository.getPet(petId);
_replaceInList(pet);
return pet;
}
/// 更新宠物档案:成功后同步列表副本;40902 版本冲突等外抛,
/// 页面提示后应调用 [getPet] 刷新取新 version 重提。
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
final pet = await _repository.updatePet(petId, request);
_replaceInList(pet);
return pet;
}
void _replaceInList(Pet pet) {
final index = _pets.indexWhere((item) => item.id == pet.id);
if (index == -1) return;
_pets = [..._pets]..[index] = pet;
_notify();
}
/// 品种目录(只读字典):按物种缓存,会话内目录不变;
/// 失败按类型化异常外抛,供表单层内联重试。
Future<List<Breed>> loadBreeds(PetSpecies species) async {
final cached = _breedsCache[species];
if (cached != null) return cached;
final breeds = await _repository.listBreeds(species: species);
_breedsCache[species] = breeds;
return breeds;
}
/// 登出清空:回 initial 态并清缓存,避免上一账号档案跨会话泄漏;
/// 重新登录后主壳重建,列表页 initState 重新触发 [refresh]。
void reset() {
_phase = PetsLoadPhase.initial;
_pets = const [];
_lastError = null;
_breedsCache.clear();
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+237 -652
View File
@@ -1,723 +1,308 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/widgets/common.dart';
class PetsPage extends StatelessWidget {
const PetsPage({required this.appState, super.key});
/// 档案 Tab 落地页:宠物列表(T2-12 / 05 号规范 §4.1 P1)。
///
/// 真实数据经 [PetsController] 四态驱动:loading(居中转圈)、
/// empty(空态插画 + 建档 CTA)、error(横幅 + 重试)、ready(列表)。
/// 页面不直连 ApiClient,不读写 AppState demo 数据。
class PetsPage extends StatefulWidget {
const PetsPage({
required this.controller,
super.key,
this.analytics,
this.healthAnalytics,
});
final AppState appState;
final PetsController controller;
final PetAnalytics? analytics;
String ageLabel(String value) {
final birthday = DateTime.tryParse(value);
if (birthday == null) return '年龄未知';
final today = DateTime.now();
var age = today.year - birthday.year;
if (today.month < birthday.month ||
(today.month == birthday.month && today.day < birthday.day)) {
age--;
}
return '${age < 0 ? 0 : age}';
/// health_record 域埋点(T2-13,记录页面族透传)。
final HealthRecordAnalytics? healthAnalytics;
@override
State<PetsPage> createState() => _PetsPageState();
}
Future<void> editPet(BuildContext context) async {
final value = await showModalBottomSheet<PetProfile>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => EditPetSheet(pet: appState.pet),
class _PetsPageState extends State<PetsPage> {
@override
void initState() {
super.initState();
// 主壳挂载即预取(IndexedStack 各 Tab 同时构建);重登后控制器
// 已被 reset 回 initial,会重新拉取。
if (widget.controller.phase == PetsLoadPhase.initial) {
widget.controller.refresh();
}
}
Future<void> _openCreate(PetCreateEntryPoint entryPoint) async {
final created = await Navigator.of(context).push<Pet>(
fadePageRoute(
PetFormPage.create(
controller: widget.controller,
analytics: widget.analytics,
entryPoint: entryPoint,
),
// 建宠表单曝光由既有 RouteObserver 采集(06 §1.6 pet_form)。
settings: RouteSettings(name: AnalyticsPageName.petForm.pageName),
),
);
if (value != null) await appState.updatePet(value);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已为「${created.name}」建立档案 🐾')));
}
}
Future<void> editVaccines(BuildContext context) async {
final value = await showModalBottomSheet<VaccineRecord>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => VaccineSheet(record: appState.vaccines),
void _openDetail(Pet pet) {
Navigator.of(context).push(
fadePageRoute(
PetDetailPage(
controller: widget.controller,
petId: pet.id,
analytics: widget.analytics,
healthAnalytics: widget.healthAnalytics,
),
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
),
);
if (value != null) await appState.updateVaccines(value);
}
@override
Widget build(BuildContext context) {
final pet = appState.pet;
final vaccines = appState.vaccines;
final progress = vaccines.totalDoses == 0
? 0.0
: vaccines.completedDoses / vaccines.totalDoses;
return ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
final controller = widget.controller;
switch (controller.phase) {
case PetsLoadPhase.initial:
case PetsLoadPhase.loading:
return const Center(child: CircularProgressIndicator());
case PetsLoadPhase.error:
return _LoadErrorView(
message: petLoadErrorMessage(controller.lastError),
onRetry: controller.refresh,
);
case PetsLoadPhase.ready:
if (controller.isEmpty) {
return Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.pets,
title: '还没有宠物档案',
description: '添加毛孩子,开始记录 TA 的健康点滴',
ctaLabel: '添加宠物',
onCtaPressed: () =>
_openCreate(PetCreateEntryPoint.profileEmptyState),
),
),
);
}
return _petList(controller.pets);
}
},
);
}
return ListView(
Widget _petList(List<Pet> pets) {
return RefreshIndicator(
onRefresh: widget.controller.refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Column(
Row(
children: [
Stack(
children: [
InkWell(
onTap: () => editPet(context),
borderRadius: BorderRadius.circular(54),
child: RemoteImage(
url: pet.avatarUrl,
width: 104,
height: 104,
borderRadius: BorderRadius.circular(52),
),
),
Positioned(
right: 0,
bottom: 0,
child: CircleAvatar(
radius: 16,
backgroundColor: AppColors.primary,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: '编辑资料',
onPressed: () => editPet(context),
icon: const Icon(
Icons.edit,
color: Colors.white,
size: 15,
),
),
),
Text('我的宠物', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
TextButton.icon(
onPressed: () => _openCreate(PetCreateEntryPoint.petList),
icon: const Icon(Icons.add, size: 18),
label: const Text('添加'),
),
],
),
const SizedBox(height: 12),
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
'${pet.breed} · ${pet.gender == PetGender.male ? '男孩' : '女孩'} · ${ageLabel(pet.birthday)} · 活泼',
style: Theme.of(context).textTheme.bodySmall,
),
for (final pet in pets) ...[
_PetCard(pet: pet, onTap: () => _openDetail(pet)),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.settings_outlined, size: 17),
label: const Text('编辑资料'),
),
],
_AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)),
],
),
const SizedBox(height: 24),
Row(
children: [
Text('宠物数据', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
TextButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑'),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.monitor_weight_outlined,
color: AppColors.primary,
value: '${pet.weight.toStringAsFixed(1)}kg',
label: '体重',
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
icon: Icons.vaccines_outlined,
color: AppColors.success,
value: '${vaccines.completedDoses}/${vaccines.totalDoses}',
label: '疫苗进度',
onTap: () => editVaccines(context),
),
),
const SizedBox(width: 10),
const Expanded(
child: _StatCard(
icon: Icons.payments_outlined,
color: AppColors.warning,
value: '¥328',
label: '本月花费',
),
),
],
),
const SizedBox(height: 18),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('健康概览', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
Text('更新于今日', style: Theme.of(context).textTheme.bodySmall),
],
),
const SizedBox(height: 18),
Row(
children: [
SizedBox.square(
dimension: 62,
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 7,
backgroundColor: AppColors.border,
color: AppColors.success,
),
Center(
child: Text(
'${(progress * 100).round()}%',
style: const TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
progress >= 1 ? '疫苗接种完成' : '疫苗接种进行中',
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 5),
Text(
'下一针:${vaccines.reminderVaccine}\n预计 ${vaccines.reminderDate}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
tooltip: '管理疫苗',
onPressed: () => editVaccines(context),
icon: const Icon(Icons.edit_outlined),
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.successSurface,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.success.withAlpha(140)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.auto_awesome, color: AppColors.success),
SizedBox(width: 10),
Expanded(
child: Text(
'健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。',
style: TextStyle(color: AppColors.successInk, height: 1.5),
),
),
],
),
),
const SizedBox(height: 22),
Text('成长足迹', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
const _TimelineTile(
icon: Icons.medical_services_outlined,
title: '医疗 · 狂犬疫苗接种',
subtitle: '2025-06-12 · 瑞派宠物医院',
status: '已完成',
),
const _TimelineTile(
icon: Icons.restaurant_outlined,
title: '喂养 · 更换幼犬粮',
subtitle: '2025-05-20 · 体重增长稳定',
status: '已记录',
),
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.color,
required this.value,
required this.label,
this.onTap,
});
/// 加载失败态:InlineErrorBanner + 重试按钮(05 §4.2 错误三层模型)。
class _LoadErrorView extends StatelessWidget {
const _LoadErrorView({required this.message, required this.onRetry});
final IconData icon;
final Color color;
final String value;
final String label;
final VoidCallback? onTap;
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: message),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('重试')),
],
),
),
);
}
}
/// 宠物卡(05 §4.1):头像 lg + 名字 + 元信息 + chevron;非 active
/// 状态以 TagPill 标示(图文双通道由状态文案承担)。
class _PetCard extends StatelessWidget {
const _PetCard({required this.pet, required this.onTap});
final Pet pet;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 6),
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 2),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
),
),
);
}
}
class _TimelineTile extends StatelessWidget {
const _TimelineTile({
required this.icon,
required this.title,
required this.subtitle,
required this.status,
});
final IconData icon;
final String title;
final String subtitle;
final String status;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SectionCard(
padding: const EdgeInsets.all(14),
child: Row(
children: [
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)),
const PetAvatar(size: PetAvatarSize.lg),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w800),
pet.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 3),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
TagPill(status, color: AppColors.success),
],
),
),
);
}
}
class EditPetSheet extends StatefulWidget {
const EditPetSheet({required this.pet, super.key});
final PetProfile pet;
@override
State<EditPetSheet> createState() => _EditPetSheetState();
}
class _EditPetSheetState extends State<EditPetSheet> {
late final TextEditingController nameController;
late final TextEditingController weightController;
late String breed;
late PetGender gender;
late DateTime birthday;
late String avatar;
static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他'];
static const avatars = [
petAvatar,
'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85',
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85',
];
@override
void initState() {
super.initState();
nameController = TextEditingController(text: widget.pet.name);
weightController = TextEditingController(text: '${widget.pet.weight}');
breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他';
gender = widget.pet.gender;
birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15);
avatar = widget.pet.avatarUrl;
}
@override
void dispose() {
nameController.dispose();
weightController.dispose();
super.dispose();
}
String dateText(DateTime value) {
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetHandle(),
Row(
children: [
Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 14),
Center(
child: InkWell(
onTap: () {
final index = avatars.indexOf(avatar);
setState(
() => avatar = avatars[(index + 1) % avatars.length],
);
},
borderRadius: BorderRadius.circular(48),
child: Stack(
children: [
RemoteImage(
url: avatar,
width: 96,
height: 96,
borderRadius: BorderRadius.circular(48),
),
const Positioned(
right: 0,
bottom: 0,
child: CircleAvatar(
radius: 15,
child: Icon(Icons.edit, size: 15),
const SizedBox(height: 4),
Text(
petMetaLine(pet),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
),
const SizedBox(height: 18),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: '宠物昵称'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: breed,
decoration: const InputDecoration(labelText: '品种'),
items: breeds
.map(
(value) =>
DropdownMenuItem(value: value, child: Text(value)),
if (pet.status != PetStatus.active)
TagPill(
petStatusLabel(pet.status),
color: pet.status == PetStatus.lost
? AppColors.error
: AppColors.muted,
)
.toList(),
onChanged: (value) {
if (value != null) setState(() => breed = value);
},
),
const SizedBox(height: 12),
SegmentedButton<PetGender>(
segments: const [
ButtonSegment(
value: PetGender.male,
icon: Icon(Icons.male),
label: Text('男孩'),
),
ButtonSegment(
value: PetGender.female,
icon: Icon(Icons.female),
label: Text('女孩'),
),
],
selected: {gender},
onSelectionChanged: (value) =>
setState(() => gender = value.first),
),
const SizedBox(height: 12),
ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(18),
),
leading: const Icon(Icons.cake_outlined),
title: const Text('生日'),
subtitle: Text(dateText(birthday)),
trailing: const Icon(Icons.calendar_month_outlined),
onTap: () async {
final value = await showDatePicker(
context: context,
initialDate: birthday,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (value != null) setState(() => birthday = value);
},
),
const SizedBox(height: 12),
TextField(
controller: weightController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(labelText: '体重(kg'),
),
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () {
final name = nameController.text.trim();
final weight = double.tryParse(weightController.text);
if (name.isEmpty || weight == null || weight <= 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请填写有效的昵称和体重')));
return;
}
Navigator.pop(
context,
widget.pet.copyWith(
name: name,
breed: breed,
gender: gender,
birthday: dateText(birthday),
weight: weight,
avatarUrl: avatar,
),
);
},
icon: const Icon(Icons.save_outlined),
label: const Text('保存修改'),
),
else
const Icon(Icons.chevron_right, color: AppColors.muted),
],
),
),
),
);
}
}
class VaccineSheet extends StatefulWidget {
const VaccineSheet({required this.record, super.key});
/// 虚线「添加宠物」卡(05 §4.1border 色 1.5px dashedradius 24,高 64)。
class _AddPetCard extends StatelessWidget {
const _AddPetCard({required this.onTap});
final VaccineRecord record;
@override
State<VaccineSheet> createState() => _VaccineSheetState();
}
class _VaccineSheetState extends State<VaccineSheet> {
late List<VaccineItem> items;
late int totalDoses;
late final TextEditingController reminderController;
late final TextEditingController dateController;
int get completed =>
items.where((item) => item.status == VaccineStatus.completed).length;
@override
void initState() {
super.initState();
items = List<VaccineItem>.from(widget.record.items);
totalDoses = widget.record.totalDoses;
reminderController = TextEditingController(
text: widget.record.reminderVaccine,
);
dateController = TextEditingController(text: widget.record.reminderDate);
}
@override
void dispose() {
reminderController.dispose();
dateController.dispose();
super.dispose();
}
void changeTotal(int delta) {
final minimum = completed > items.length ? completed : items.length;
setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20));
}
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final progress = totalDoses == 0 ? 0.0 : completed / totalDoses;
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetHandle(),
Row(
children: [
const CircleAvatar(child: Icon(Icons.vaccines_outlined)),
const SizedBox(width: 10),
Text('疫苗接种管理', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 16),
SectionCard(
child: Column(
children: [
Row(
children: [
const Text('已接种 / 总规划'),
const Spacer(),
IconButton.filledTonal(
onPressed: () => changeTotal(-1),
icon: const Icon(Icons.remove),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
'$completed / $totalDoses',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
IconButton.filled(
onPressed: () => changeTotal(1),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(value: progress, minHeight: 9),
],
),
),
const SizedBox(height: 16),
Text('接种详情', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final done = item.status == VaccineStatus.completed;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: CheckboxListTile(
value: done,
title: Text(item.name),
subtitle: Text(
done ? '接种时间:${item.date}' : '计划接种:${item.date}',
),
secondary: Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
color: done ? AppColors.success : AppColors.muted,
),
onChanged: (_) {
final today = DateTime.now();
final date =
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
setState(() {
items[index] = item.copyWith(
status: done
? VaccineStatus.pending
: VaccineStatus.completed,
date: done ? '待定' : date,
);
});
},
),
);
}),
const SizedBox(height: 10),
TextField(
controller: reminderController,
decoration: const InputDecoration(labelText: '下一针提醒'),
),
const SizedBox(height: 12),
TextField(
controller: dateController,
decoration: const InputDecoration(labelText: '预计日期(YYYY-MM-DD'),
),
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () => Navigator.pop(
context,
VaccineRecord(
completedDoses: completed,
totalDoses: totalDoses,
items: items,
reminderVaccine: reminderController.text.trim(),
reminderDate: dateController.text.trim(),
),
),
icon: const Icon(Icons.save_outlined),
label: const Text('保存疫苗记录'),
),
],
),
),
);
}
}
class _SheetHandle extends StatelessWidget {
const _SheetHandle();
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 44,
height: 5,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
return CustomPaint(
painter: _DashedBorderPainter(
color: AppColors.border,
borderRadius: BorderRadius.circular(99),
strokeWidth: 1.5,
radius: AppRadius.xl,
),
child: SizedBox(
height: 64,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: const Center(
child: Text(
' 添加宠物',
style: TextStyle(
color: AppColors.primaryStrong,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
}
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter({
required this.color,
required this.strokeWidth,
required this.radius,
});
final Color color;
final double strokeWidth;
final double radius;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final path = Path()
..addRRect(
RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)),
);
const dashWidth = 6.0;
const dashGap = 4.0;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
canvas.drawPath(
metric.extractPath(distance, distance + dashWidth),
paint,
);
distance += dashWidth + dashGap;
}
}
}
@override
bool shouldRepaint(_DashedBorderPainter oldDelegate) =>
color != oldDelegate.color ||
strokeWidth != oldDelegate.strokeWidth ||
radius != oldDelegate.radius;
}
+312
View File
@@ -0,0 +1,312 @@
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:uuid/uuid.dart';
/// pets 域仓库接口(契约 v1.2.0 的 12 路径 / 18 操作全覆盖;
/// 页面依赖此抽象,widget 测试注入假实现)。
abstract class PetsRepository {
// ---- 宠物 CRUD ----
Future<List<Pet>> listPets();
Future<Pet> createPet(CreatePetRequest request);
Future<Pet> getPet(String petId);
Future<Pet> updatePet(String petId, UpdatePetRequest request);
// ---- 字典 ----
Future<List<Breed>> listBreeds({PetSpecies? species});
Future<List<VaccineCatalogItem>> listVaccineCatalog({PetSpecies? species});
// ---- 体重(cursor 分页)----
Future<CursorPage<WeightRecord>> listWeights(
String petId, {
int? limit,
String? cursor,
});
Future<WeightRecord> createWeight(String petId, CreateWeightRequest request);
// ---- 疫苗 ----
Future<List<Vaccination>> listVaccinations(String petId);
Future<Vaccination> createVaccination(
String petId,
CreateVaccinationRequest request,
);
Future<Vaccination> updateVaccination(
String vaccinationId,
UpdateVaccinationRequest request,
);
// ---- 健康事件(cursor 分页)----
Future<CursorPage<HealthEvent>> listHealthEvents(
String petId, {
int? limit,
String? cursor,
});
Future<HealthEvent> createHealthEvent(
String petId,
CreateHealthEventRequest request,
);
Future<HealthEvent> updateHealthEvent(
String eventId,
UpdateHealthEventRequest request,
);
// ---- 照护提醒 ----
Future<List<CareReminder>> listCareReminders(
String petId, {
CareReminderStatus? status,
});
Future<CareReminder> createCareReminder(
String petId,
CreateCareReminderRequest request,
);
Future<CareReminder> updateCareReminder(
String reminderId,
UpdateCareReminderRequest request,
);
// ---- 聚合摘要 ----
Future<PetSummary> getPetSummary(String petId, {String? tz});
}
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
/// 拦截 + 401/40101 单飞刷新重放);pets 域业务错误码升格为类型化异常。
///
/// 幂等:weights / vaccinations / health-events / care-reminders 四个 POST
/// 按契约携带可选 `Idempotency-Key`(每次逻辑提交换新键;token 刷新后的
/// 自动重放沿用同一个键,与 auth register 先例一致)。
class ApiPetsRepository implements PetsRepository {
ApiPetsRepository({required this._api, this._uuid = const Uuid()});
final ApiClient _api;
final Uuid _uuid;
Future<Object?> _request(
String path, {
String method = 'GET',
Object? body,
Map<String, Object?>? query,
bool idempotent = false,
}) async {
try {
return await _api.request(
path,
method: method,
body: body,
query: query,
headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null,
requiresAuth: true,
);
} on ApiBusinessException catch (error) {
throw mapPetBusinessException(error);
}
}
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
List<T> _asList<T>(Object? data, T Function(Map<String, dynamic>) fromJson) {
return (data! as List)
.map((item) => fromJson(item as Map<String, dynamic>))
.toList();
}
@override
Future<List<Pet>> listPets() async {
final data = await _request('/api/v1/pets');
return _asList(data, Pet.fromJson);
}
@override
Future<Pet> createPet(CreatePetRequest request) async {
final data = await _request(
'/api/v1/pets',
method: 'POST',
body: request.toJson(),
);
return Pet.fromJson(_asMap(data));
}
@override
Future<Pet> getPet(String petId) async {
final data = await _request('/api/v1/pets/$petId');
return Pet.fromJson(_asMap(data));
}
@override
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
final data = await _request(
'/api/v1/pets/$petId',
method: 'PATCH',
body: request.toJson(),
);
return Pet.fromJson(_asMap(data));
}
@override
Future<List<Breed>> listBreeds({PetSpecies? species}) async {
final data = await _request(
'/api/v1/breeds',
query: {if (species != null) 'species': species.name},
);
return _asList(data, Breed.fromJson);
}
@override
Future<List<VaccineCatalogItem>> listVaccineCatalog({
PetSpecies? species,
}) async {
final data = await _request(
'/api/v1/vaccine-catalog',
query: {if (species != null) 'species': species.name},
);
return _asList(data, VaccineCatalogItem.fromJson);
}
@override
Future<CursorPage<WeightRecord>> listWeights(
String petId, {
int? limit,
String? cursor,
}) async {
final data = await _request(
'/api/v1/pets/$petId/weights',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), WeightRecord.fromJson);
}
@override
Future<WeightRecord> createWeight(
String petId,
CreateWeightRequest request,
) async {
final data = await _request(
'/api/v1/pets/$petId/weights',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return WeightRecord.fromJson(_asMap(data));
}
@override
Future<List<Vaccination>> listVaccinations(String petId) async {
final data = await _request('/api/v1/pets/$petId/vaccinations');
return _asList(data, Vaccination.fromJson);
}
@override
Future<Vaccination> createVaccination(
String petId,
CreateVaccinationRequest request,
) async {
final data = await _request(
'/api/v1/pets/$petId/vaccinations',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return Vaccination.fromJson(_asMap(data));
}
@override
Future<Vaccination> updateVaccination(
String vaccinationId,
UpdateVaccinationRequest request,
) async {
final data = await _request(
'/api/v1/vaccinations/$vaccinationId',
method: 'PATCH',
body: request.toJson(),
);
return Vaccination.fromJson(_asMap(data));
}
@override
Future<CursorPage<HealthEvent>> listHealthEvents(
String petId, {
int? limit,
String? cursor,
}) async {
final data = await _request(
'/api/v1/pets/$petId/health-events',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), HealthEvent.fromJson);
}
@override
Future<HealthEvent> createHealthEvent(
String petId,
CreateHealthEventRequest request,
) async {
final data = await _request(
'/api/v1/pets/$petId/health-events',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return HealthEvent.fromJson(_asMap(data));
}
@override
Future<HealthEvent> updateHealthEvent(
String eventId,
UpdateHealthEventRequest request,
) async {
final data = await _request(
'/api/v1/health-events/$eventId',
method: 'PATCH',
body: request.toJson(),
);
return HealthEvent.fromJson(_asMap(data));
}
@override
Future<List<CareReminder>> listCareReminders(
String petId, {
CareReminderStatus? status,
}) async {
final data = await _request(
'/api/v1/pets/$petId/care-reminders',
query: {if (status != null) 'status': status.name},
);
return _asList(data, CareReminder.fromJson);
}
@override
Future<CareReminder> createCareReminder(
String petId,
CreateCareReminderRequest request,
) async {
final data = await _request(
'/api/v1/pets/$petId/care-reminders',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return CareReminder.fromJson(_asMap(data));
}
@override
Future<CareReminder> updateCareReminder(
String reminderId,
UpdateCareReminderRequest request,
) async {
final data = await _request(
'/api/v1/care-reminders/$reminderId',
method: 'PATCH',
body: request.toJson(),
);
return CareReminder.fromJson(_asMap(data));
}
@override
Future<PetSummary> getPetSummary(String petId, {String? tz}) async {
final data = await _request(
'/api/v1/pets/$petId/summary',
query: {'tz': ?tz},
);
return PetSummary.fromJson(_asMap(data));
}
}
@@ -0,0 +1,526 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 疫苗登记表单页(T2-13)。
///
/// - 目录:vaccine-catalog 按宠物物种过滤(契约:疫苗 species 须与宠物
/// 一致),目录网络面自带 loading / 失败重试 / 空目录提示。
/// - 系列/剂次:seriesKey 选中疫苗后以目录 code 自动预填(可改);
/// doseNo 默认 1。
/// - 状态机:创建仅 scheduled / completed;状态-日期规则由
/// [vaccinationDateRuleError] 前端拦截(scheduled 态不渲染接种日期字段,
/// 结构上杜绝「scheduled 带 administeredOn」),后端 42201 兜底提示;
/// 同系列同剂次重复登记 40904 兜底提示。
/// - 埋点:health_record_create_started/succeeded/failedrecordType=vaccine)。
class VaccinationFormPage extends StatefulWidget {
const VaccinationFormPage({
required this.repository,
required this.petId,
required this.petSpecies,
super.key,
this.analytics,
this.entryPoint = HealthRecordEntryPoint.recordList,
});
final PetsRepository repository;
final String petId;
final PetSpecies petSpecies;
final HealthRecordAnalytics? analytics;
final HealthRecordEntryPoint entryPoint;
@override
State<VaccinationFormPage> createState() => _VaccinationFormPageState();
}
class _VaccinationFormPageState extends State<VaccinationFormPage> {
final _seriesCtrl = TextEditingController();
final _doseNoCtrl = TextEditingController(text: '1');
final _doseLabelCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
List<VaccineCatalogItem>? _catalog;
bool _catalogLoading = false;
bool _catalogFailed = false;
String? _vaccineId;
/// seriesKey 是否仍处于「目录 code 自动预填」状态(用户一改即失效)。
bool _seriesAutoFilled = true;
VaccinationStatus _status = VaccinationStatus.scheduled;
DateTime? _plannedOn;
DateTime? _administeredOn;
DateTime? _nextDueOn;
String? _vaccineError;
String? _seriesError;
String? _doseNoError;
String? _dateError;
String? _formError;
bool _submitting = false;
bool _startedFired = false;
int _attemptSeq = 0;
late final DateTime _openedAt;
@override
void initState() {
super.initState();
_openedAt = DateTime.now();
_loadCatalog();
}
@override
void dispose() {
_seriesCtrl.dispose();
_doseNoCtrl.dispose();
_doseLabelCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
Future<void> _loadCatalog() async {
setState(() {
_catalogLoading = true;
_catalogFailed = false;
});
try {
final catalog = await widget.repository.listVaccineCatalog(
species: widget.petSpecies,
);
if (!mounted) return;
setState(() {
_catalog = catalog;
_catalogLoading = false;
});
} on ApiException {
if (!mounted) return;
setState(() {
_catalogLoading = false;
_catalogFailed = true;
});
}
}
void _markStarted() {
if (_startedFired) return;
_startedFired = true;
widget.analytics?.createStarted(
recordType: HealthRecordType.vaccine,
entryPoint: widget.entryPoint,
);
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.createFailed(
recordType: HealthRecordType.vaccine,
reason: reason,
attemptSeq: _attemptSeq,
errorCode: errorCode,
);
}
int? get _doseNo {
final value = int.tryParse(_doseNoCtrl.text.trim());
if (value == null || value < 1 || value > 32767) return null;
return value;
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
Future<void> _submit() async {
if (_submitting) return;
_attemptSeq++;
final vaccineError = _vaccineId == null ? '请选择疫苗' : null;
final seriesError = _seriesCtrl.text.trim().isEmpty ? '请输入系列名称' : null;
final doseNoError = _doseNo == null ? '剂次需为 1~32767 的整数' : null;
final dateError = vaccinationDateRuleError(
status: _status,
plannedOn: _plannedOn,
administeredOn: _administeredOn,
nextDueOn: _nextDueOn,
);
if (vaccineError != null ||
seriesError != null ||
doseNoError != null ||
dateError != null) {
setState(() {
_vaccineError = vaccineError;
_seriesError = seriesError;
_doseNoError = doseNoError;
_dateError = dateError;
});
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final scheduled = _status == VaccinationStatus.scheduled;
final doseLabel = _doseLabelCtrl.text.trim();
final notes = _notesCtrl.text.trim();
final record = await widget.repository.createVaccination(
widget.petId,
CreateVaccinationRequest(
vaccineId: _vaccineId!,
seriesKey: _seriesCtrl.text.trim(),
doseNo: _doseNo!,
status: _status,
// 状态-日期规则结构化落地:scheduled 只发 plannedOn
// completed 只发 administeredOn+可选 nextDueOn)。
plannedOn: scheduled ? _plannedOn : null,
administeredOn: scheduled ? null : _administeredOn,
nextDueOn: scheduled ? null : _nextDueOn,
doseLabel: doseLabel.isEmpty ? null : doseLabel,
notes: notes.isEmpty ? null : notes,
),
);
widget.analytics?.createSucceeded(
recordType: HealthRecordType.vaccine,
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
);
if (mounted) Navigator.of(context).pop(record);
} on VaccinationDoseExistsException {
if (!mounted) return;
_showFormError('该系列该剂次已有记录(40904);如登记有误,可取消原记录后重新登记');
_trackFailed(HealthRecordFailureReason.validationError, 40904);
} on VaccinationRuleException {
if (!mounted) return;
// 前端已拦截主路径,此处为后端状态-日期规则兜底(42201)。
_showFormError('接种状态与日期不符合规则,请核对后重试');
_trackFailed(HealthRecordFailureReason.validationError, 42201);
} on PetAccessDeniedException {
if (!mounted) return;
_showFormError('你没有权限为该宠物登记疫苗');
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on PetNotFoundException {
if (!mounted) return;
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
_trackFailed(HealthRecordFailureReason.notFound, 40401);
navigator.pop();
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('操作过于频繁,请稍后再试');
_trackFailed(HealthRecordFailureReason.rateLimited);
} on ApiBusinessException catch (error) {
if (!mounted) return;
final isParam = error.code == ApiCodes.paramError;
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
_trackFailed(
isParam
? HealthRecordFailureReason.validationError
: HealthRecordFailureReason.serverError,
error.code,
);
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
_trackFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('登记疫苗'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
children: [
..._catalogSection(),
const SizedBox(height: 14),
AppTextField(
label: '系列名称',
controller: _seriesCtrl,
prefixIcon: Icons.tag_outlined,
errorText: _seriesError,
helperText: '区分初免/加强等系列,同系列同剂次唯一',
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
_seriesAutoFilled = false;
if (_seriesError != null || _formError != null) {
setState(() {
_seriesError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: AppTextField(
label: '剂次',
controller: _doseNoCtrl,
prefixIcon: Icons.numbers_outlined,
errorText: _doseNoError,
enabled: !_submitting,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_doseNoError != null || _formError != null) {
setState(() {
_doseNoError = null;
_formError = null;
});
}
},
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: '剂次标签(可选)',
controller: _doseLabelCtrl,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) => _markStarted(),
),
),
],
),
const SizedBox(height: 18),
const Text(
'接种状态',
style: TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
SegmentedButton<VaccinationStatus>(
segments: const [
ButtonSegment(
value: VaccinationStatus.scheduled,
icon: Icon(Icons.event_outlined),
label: Text('计划接种'),
),
ButtonSegment(
value: VaccinationStatus.completed,
icon: Icon(Icons.check_circle_outline),
label: Text('已完成'),
),
],
selected: {_status},
onSelectionChanged: _submitting
? null
: (value) {
_markStarted();
setState(() {
_status = value.first;
_dateError = null;
_formError = null;
});
},
),
const SizedBox(height: 14),
if (_status == VaccinationStatus.scheduled)
_dateTile(
label: '计划接种日期',
value: _plannedOn,
allowFuture: true,
onPicked: (value) => setState(() {
_plannedOn = value;
_dateError = null;
}),
)
else ...[
_dateTile(
label: '接种日期',
value: _administeredOn,
allowFuture: false,
onPicked: (value) => setState(() {
_administeredOn = value;
_dateError = null;
}),
),
const SizedBox(height: 12),
_dateTile(
label: '下次接种日期(可选)',
value: _nextDueOn,
allowFuture: true,
onPicked: (value) => setState(() {
_nextDueOn = value;
_dateError = null;
}),
),
],
if (_dateError != null) ...[
const SizedBox(height: 6),
Text(
_dateError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 14),
AppTextField(
label: '备注(可选)',
controller: _notesCtrl,
prefixIcon: Icons.sticky_note_2_outlined,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onChanged: (_) => _markStarted(),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存登记',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
List<Widget> _catalogSection() {
if (_catalogLoading) {
return const [
SizedBox(
height: 52,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
];
}
if (_catalogFailed) {
return [
Row(
children: [
const Expanded(
child: Text(
'疫苗目录加载失败,请重试后选择',
style: TextStyle(color: AppColors.error, fontSize: 12),
),
),
TextButton(onPressed: _loadCatalog, child: const Text('重试')),
],
),
];
}
final catalog = _catalog ?? const <VaccineCatalogItem>[];
if (catalog.isEmpty) {
return const [
Text(
'该物种暂无可选疫苗目录',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
];
}
return [
DropdownButtonFormField<String>(
initialValue: _vaccineId,
decoration: InputDecoration(labelText: '疫苗', errorText: _vaccineError),
items: [
for (final item in catalog)
DropdownMenuItem(value: item.id, child: Text(item.name)),
],
onChanged: _submitting
? null
: (value) {
_markStarted();
setState(() {
_vaccineId = value;
_vaccineError = null;
_formError = null;
// seriesKey 以目录 code 预填(用户未改过才覆盖)。
if (_seriesAutoFilled && value != null) {
final item = catalog.firstWhere((c) => c.id == value);
_seriesCtrl.text = item.code;
}
});
},
),
];
}
Widget _dateTile({
required String label,
required DateTime? value,
required bool allowFuture,
required ValueChanged<DateTime> onPicked,
}) {
return ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
tileColor: AppColors.surface,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: Text(label, style: const TextStyle(fontSize: 14)),
subtitle: Text(
value == null ? '未选择' : dateToJson(value),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.calendar_month_outlined,
color: AppColors.muted,
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: value ?? now,
firstDate: DateTime(1990),
lastDate: allowFuture ? DateTime(now.year + 5) : now,
);
if (picked != null && mounted) {
_markStarted();
onPicked(picked);
}
},
);
}
}
@@ -0,0 +1,594 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _ListPhase { loading, ready, error }
/// 疫苗记录列表页(T2-13):契约不分页,服务端按
/// `series_key, dose_no, created_at, id` 排序,客户端按系列直接分组;
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
/// 四态齐备;登记经 [VaccinationFormPage]。
///
/// T2-14 收尾(25 号报告 §7 遗留①②):scheduled 行支持「标记完成 /
/// 取消登记」PATCH 流转;完成时可补录厂商/批号(契约可选字段);
/// 挂 `health_record_edit_succeeded/failed`recordType=vaccine)。
///
/// 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
class VaccinationRecordsPage extends StatefulWidget {
const VaccinationRecordsPage({
required this.repository,
required this.petId,
required this.petSpecies,
required this.canWrite,
super.key,
this.analytics,
});
final PetsRepository repository;
final String petId;
/// 疫苗目录按宠物物种过滤(契约:疫苗 species 须与宠物一致)。
final PetSpecies petSpecies;
/// owner/caregiver 可写;viewer 隐藏登记入口。
final bool canWrite;
final HealthRecordAnalytics? analytics;
@override
State<VaccinationRecordsPage> createState() => _VaccinationRecordsPageState();
}
class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
_ListPhase _phase = _ListPhase.loading;
List<Vaccination> _records = const [];
ApiException? _error;
bool _viewedFired = false;
bool _mutating = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_phase = _ListPhase.loading;
_error = null;
});
try {
final records = await widget.repository.listVaccinations(widget.petId);
if (!mounted) return;
setState(() {
_records = records;
_phase = _ListPhase.ready;
});
if (!_viewedFired) {
_viewedFired = true;
widget.analytics?.viewed(
recordType: HealthRecordType.vaccine,
source: HealthRecordViewSource.petDetail,
);
}
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_phase = _ListPhase.error;
});
}
}
Future<void> _openCreate() async {
final created = await Navigator.of(context).push<Vaccination>(
fadePageRoute(
VaccinationFormPage(
repository: widget.repository,
petId: widget.petId,
petSpecies: widget.petSpecies,
analytics: widget.analytics,
),
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已登记疫苗')));
// 排序键在服务端(series_key, dose_no),重新拉取而非本地猜位置。
await _load();
}
}
void _trackEditFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.editFailed(
recordType: HealthRecordType.vaccine,
reason: reason,
errorCode: errorCode,
);
}
/// 标记完成(25 号报告遗留①②):接种日期必填、下次接种/厂商/批号可选
/// (厂商/批号为契约可选字段的补录入口)。
Future<void> _markCompleted(Vaccination record) async {
final result = await showDialog<_CompleteVaccinationResult>(
context: context,
builder: (context) => _CompleteVaccinationDialog(record: record),
);
if (result == null || !mounted) return;
await _mutate(
record,
UpdateVaccinationRequest(
version: record.version,
status: VaccinationStatus.completed,
administeredOn: result.administeredOn,
nextDueOn: result.nextDueOn,
manufacturer: result.manufacturer,
batchNo: result.batchNo,
),
successText: '已标记完成',
);
}
Future<void> _cancelRegistration(Vaccination record) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('取消这条登记?'),
content: Text(
'${vaccinationDoseLabel(record)}」将标记为已取消;'
'取消后同系列同剂次可重新登记。',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('返回'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('取消登记'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _mutate(
record,
UpdateVaccinationRequest(
version: record.version,
status: VaccinationStatus.cancelled,
),
successText: '已取消登记',
);
}
Future<void> _mutate(
Vaccination record,
UpdateVaccinationRequest request, {
required String successText,
}) async {
if (_mutating) return;
setState(() => _mutating = true);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.repository.updateVaccination(record.id, request);
widget.analytics?.editSucceeded(
recordType: HealthRecordType.vaccine,
fieldCount: request.toJson().length - 1,
);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successText)));
await _load();
} on PetVersionConflictException {
if (!mounted) return;
// 40902:并发修改抢先——刷新取新 version 后由用户重试动作。
messenger.showSnackBar(
const SnackBar(content: Text('记录已在其他设备被修改,已刷新,请重试')),
);
_trackEditFailed(HealthRecordFailureReason.conflict, 40902);
await _load();
} on VaccinationRuleException {
if (!mounted) return;
// 42201:状态机/状态-日期规则兜底(前端已按规则拦截主路径)。
messenger.showSnackBar(
const SnackBar(content: Text('接种状态与日期不符合规则,请核对后重试')),
);
_trackEditFailed(HealthRecordFailureReason.validationError, 42201);
} on PetRecordNotFoundException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('记录不存在或已被删除,已刷新')));
_trackEditFailed(HealthRecordFailureReason.notFound, 40402);
await _load();
} on PetAccessDeniedException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该记录')));
_trackEditFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on ApiBusinessException catch (error) {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
_trackEditFailed(HealthRecordFailureReason.serverError, error.code);
} on ApiNetworkException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
_trackEditFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _mutating = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('疫苗记录'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
actions: [
if (widget.canWrite && _phase == _ListPhase.ready)
IconButton(
tooltip: '登记疫苗',
onPressed: _openCreate,
icon: const Icon(Icons.add),
),
],
),
body: switch (_phase) {
_ListPhase.loading => const Center(child: CircularProgressIndicator()),
_ListPhase.error => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: petLoadErrorMessage(_error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('重试')),
],
),
),
),
_ListPhase.ready when _records.isEmpty => Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.vaccines_outlined,
title: '还没有疫苗记录',
description: '登记接种计划与完成情况,不错过每一针',
ctaLabel: widget.canWrite ? '登记第一针' : null,
onCtaPressed: widget.canWrite ? _openCreate : null,
),
),
),
_ListPhase.ready => _list(),
},
);
}
/// 按系列分组渲染:服务端排序保证同系列相邻,系列变化处插组头
/// (疫苗名 · 系列键)。
Widget _list() {
final children = <Widget>[];
String? currentSeries;
for (final record in _records) {
final seriesId = '${record.vaccineId}/${record.seriesKey}';
if (seriesId != currentSeries) {
currentSeries = seriesId;
children.add(
Padding(
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
child: Text(
'${record.vaccineName} · ${record.seriesKey}',
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
);
}
children
..add(
_VaccinationTile(
record: record,
// 流转动作仅 scheduled 行可用(终态由服务端状态机守卫)。
onComplete:
widget.canWrite &&
record.status == VaccinationStatus.scheduled &&
!_mutating
? () => _markCompleted(record)
: null,
onCancel:
widget.canWrite &&
record.status == VaccinationStatus.scheduled &&
!_mutating
? () => _cancelRegistration(record)
: null,
),
)
..add(const SizedBox(height: 10));
}
return RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: children,
),
);
}
}
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
/// (图标+文字双通道,不单靠颜色区分);scheduled 行附
/// 「标记完成 / 取消登记」流转动作。
class _VaccinationTile extends StatelessWidget {
const _VaccinationTile({
required this.record,
this.onComplete,
this.onCancel,
});
final Vaccination record;
final VoidCallback? onComplete;
final VoidCallback? onCancel;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const RecordTypeDot(
type: RecordType.vaccine,
size: RecordTypeDotSize.md,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
vaccinationDoseLabel(record),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
vaccinationDateLine(record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
TagPill(
vaccinationStatusLabel(record.status),
color: vaccinationStatusColor(record.status),
),
],
),
if (onComplete != null || onCancel != null) ...[
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, size: 16),
style: TextButton.styleFrom(
foregroundColor: AppColors.inkSoft,
),
label: const Text('取消登记'),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: onComplete,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('标记完成'),
),
],
),
],
],
),
),
);
}
}
/// 标记完成对话框返回值。
class _CompleteVaccinationResult {
const _CompleteVaccinationResult({
required this.administeredOn,
this.nextDueOn,
this.manufacturer,
this.batchNo,
});
final DateTime administeredOn;
final DateTime? nextDueOn;
final String? manufacturer;
final String? batchNo;
}
/// 标记完成对话框:接种日期必填(默认今天)、下次接种可选;
/// 厂商/批号补录(契约可选字段,25 号报告遗留②的落地入口)。
/// 日期规则复用 [vaccinationDateRuleError]42201 前置拦截)。
class _CompleteVaccinationDialog extends StatefulWidget {
const _CompleteVaccinationDialog({required this.record});
final Vaccination record;
@override
State<_CompleteVaccinationDialog> createState() =>
_CompleteVaccinationDialogState();
}
class _CompleteVaccinationDialogState
extends State<_CompleteVaccinationDialog> {
final _manufacturerCtrl = TextEditingController();
final _batchNoCtrl = TextEditingController();
DateTime _administeredOn = DateTime.now();
DateTime? _nextDueOn;
String? _dateError;
@override
void initState() {
super.initState();
_manufacturerCtrl.text = widget.record.manufacturer ?? '';
_batchNoCtrl.text = widget.record.batchNo ?? '';
}
@override
void dispose() {
_manufacturerCtrl.dispose();
_batchNoCtrl.dispose();
super.dispose();
}
void _confirm() {
final error = vaccinationDateRuleError(
status: VaccinationStatus.completed,
administeredOn: _administeredOn,
nextDueOn: _nextDueOn,
);
if (error != null) {
setState(() => _dateError = error);
return;
}
final manufacturer = _manufacturerCtrl.text.trim();
final batchNo = _batchNoCtrl.text.trim();
Navigator.of(context).pop(
_CompleteVaccinationResult(
administeredOn: _administeredOn,
nextDueOn: _nextDueOn,
manufacturer: manufacturer.isEmpty ? null : manufacturer,
batchNo: batchNo.isEmpty ? null : batchNo,
),
);
}
Widget _dateTile({
required String label,
required DateTime? value,
required bool allowFuture,
required ValueChanged<DateTime> onPicked,
}) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: Text(label, style: const TextStyle(fontSize: 14)),
subtitle: Text(
value == null ? '未选择' : dateToJson(value),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: value ?? now,
firstDate: DateTime(1990),
lastDate: allowFuture ? DateTime(now.year + 5) : now,
);
if (picked != null && mounted) {
setState(() {
onPicked(picked);
_dateError = null;
});
}
},
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('标记完成'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${vaccinationDoseLabel(widget.record)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_dateTile(
label: '接种日期',
value: _administeredOn,
allowFuture: false,
onPicked: (value) => _administeredOn = value,
),
_dateTile(
label: '下次接种日期(可选)',
value: _nextDueOn,
allowFuture: true,
onPicked: (value) => _nextDueOn = value,
),
if (_dateError != null) ...[
const SizedBox(height: 4),
Text(
_dateError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 12),
AppTextField(
label: '厂商(可选)',
controller: _manufacturerCtrl,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
AppTextField(
label: '批号(可选)',
controller: _batchNoCtrl,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _confirm(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(onPressed: _confirm, child: const Text('确认完成')),
],
);
}
}
+288
View File
@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 体重录入表单页(T2-13)。
///
/// - `weightKg` 前端校验对齐契约:(0, 500]、最多两位小数;后端 40000 兜底。
/// - 称重时间默认「现在」,可回选日期(非今日取当日 12:00,保持
/// measured_at DESC 排序直觉);提交前转 UTCISO 8601 带 Z 上送。
/// - 错误分层沿用登录纵切:字段 errorText / InlineErrorBanner / SnackBar。
/// - 埋点:首次输入 health_record_create_started(recordType=weight)
/// 成功/失败按 06 §1.4 经 [HealthRecordAnalytics] 上报。
class WeightFormPage extends StatefulWidget {
const WeightFormPage({
required this.repository,
required this.petId,
super.key,
this.analytics,
this.entryPoint = HealthRecordEntryPoint.recordList,
});
final PetsRepository repository;
final String petId;
final HealthRecordAnalytics? analytics;
final HealthRecordEntryPoint entryPoint;
@override
State<WeightFormPage> createState() => _WeightFormPageState();
}
class _WeightFormPageState extends State<WeightFormPage> {
final _weightCtrl = TextEditingController();
final _noteCtrl = TextEditingController();
DateTime _measuredDate = DateTime.now();
String? _weightError;
String? _formError;
bool _submitting = false;
bool _startedFired = false;
int _attemptSeq = 0;
late final DateTime _openedAt;
@override
void initState() {
super.initState();
_openedAt = DateTime.now();
}
@override
void dispose() {
_weightCtrl.dispose();
_noteCtrl.dispose();
super.dispose();
}
void _markStarted() {
if (_startedFired) return;
_startedFired = true;
widget.analytics?.createStarted(
recordType: HealthRecordType.weight,
entryPoint: widget.entryPoint,
);
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.createFailed(
recordType: HealthRecordType.weight,
reason: reason,
attemptSeq: _attemptSeq,
errorCode: errorCode,
);
}
String? _validateWeight() {
final text = _weightCtrl.text.trim();
if (text.isEmpty) return '请输入体重';
if (parseWeightKgInput(text) == null) {
return '体重需大于 0 且不超过 500 公斤,最多两位小数';
}
return null;
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
/// 称重时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC。
DateTime _measuredAt() {
final now = DateTime.now();
final sameDay =
_measuredDate.year == now.year &&
_measuredDate.month == now.month &&
_measuredDate.day == now.day;
final local = sameDay
? now
: DateTime(
_measuredDate.year,
_measuredDate.month,
_measuredDate.day,
12,
);
return local.toUtc();
}
Future<void> _submit() async {
if (_submitting) return;
_attemptSeq++;
final weightError = _validateWeight();
if (weightError != null) {
setState(() => _weightError = weightError);
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final note = _noteCtrl.text.trim();
final record = await widget.repository.createWeight(
widget.petId,
CreateWeightRequest(
weightKg: parseWeightKgInput(_weightCtrl.text)!,
measuredAt: _measuredAt(),
note: note.isEmpty ? null : note,
),
);
widget.analytics?.createSucceeded(
recordType: HealthRecordType.weight,
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
);
if (mounted) Navigator.of(context).pop(record);
} on PetAccessDeniedException {
if (!mounted) return;
_showFormError('你没有权限为该宠物添加记录');
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on PetNotFoundException {
if (!mounted) return;
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
_trackFailed(HealthRecordFailureReason.notFound, 40401);
navigator.pop();
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('操作过于频繁,请稍后再试');
_trackFailed(HealthRecordFailureReason.rateLimited);
} on ApiBusinessException catch (error) {
if (!mounted) return;
final isParam = error.code == ApiCodes.paramError;
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
_trackFailed(
isParam
? HealthRecordFailureReason.validationError
: HealthRecordFailureReason.serverError,
error.code,
);
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
_trackFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('记录体重'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
children: [
AppTextField(
label: '体重(公斤)',
controller: _weightCtrl,
prefixIcon: Icons.monitor_weight_outlined,
errorText: _weightError,
helperText: '大于 0 且不超过 500,最多两位小数',
enabled: !_submitting,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_weightError != null || _formError != null) {
setState(() {
_weightError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
tileColor: AppColors.surface,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: const Text('称重日期', style: TextStyle(fontSize: 14)),
subtitle: Text(
dateToJson(_measuredDate),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.calendar_month_outlined,
color: AppColors.muted,
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await showDatePicker(
context: context,
initialDate: _measuredDate,
firstDate: DateTime(1990),
lastDate: now,
);
if (value != null && mounted) {
_markStarted();
setState(() => _measuredDate = value);
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '备注(可选,如:饭后称重)',
controller: _noteCtrl,
prefixIcon: Icons.sticky_note_2_outlined,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onChanged: (_) => _markStarted(),
onSubmitted: (_) => _submit(),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存记录',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
}
+294
View File
@@ -0,0 +1,294 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/features/pets/weight_form_page.dart';
enum _ListPhase { loading, ready, error }
/// 体重历史列表页(T2-13):cursor 分页(「加载更多」追加,末页收起),
/// 四态齐备;新记录经 [WeightFormPage] 录入后就地插入列表头。
///
/// 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=weight, source=pet_detail)`
/// (工单口径:列表曝光;不随滚动逐条上报)。
class WeightRecordsPage extends StatefulWidget {
const WeightRecordsPage({
required this.repository,
required this.petId,
required this.canWrite,
super.key,
this.analytics,
this.pageSize,
});
final PetsRepository repository;
final String petId;
/// owner/caregiver 可写;viewer 隐藏录入入口(40300 语义前置)。
final bool canWrite;
final HealthRecordAnalytics? analytics;
/// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。
final int? pageSize;
@override
State<WeightRecordsPage> createState() => _WeightRecordsPageState();
}
class _WeightRecordsPageState extends State<WeightRecordsPage> {
_ListPhase _phase = _ListPhase.loading;
List<WeightRecord> _records = const [];
String? _nextCursor;
bool _hasMore = false;
bool _loadingMore = false;
ApiException? _error;
bool _viewedFired = false;
@override
void initState() {
super.initState();
_loadFirstPage();
}
Future<void> _loadFirstPage() async {
setState(() {
_phase = _ListPhase.loading;
_error = null;
});
try {
final page = await widget.repository.listWeights(
widget.petId,
limit: widget.pageSize,
);
if (!mounted) return;
setState(() {
_records = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_phase = _ListPhase.ready;
});
if (!_viewedFired) {
_viewedFired = true;
widget.analytics?.viewed(
recordType: HealthRecordType.weight,
source: HealthRecordViewSource.petDetail,
);
}
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_phase = _ListPhase.error;
});
}
}
Future<void> _loadMore() async {
if (_loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
try {
final page = await widget.repository.listWeights(
widget.petId,
limit: widget.pageSize,
cursor: _nextCursor,
);
if (!mounted) return;
setState(() {
_records = [..._records, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
});
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
} finally {
if (mounted) setState(() => _loadingMore = false);
}
}
Future<void> _openCreate() async {
final created = await Navigator.of(context).push<WeightRecord>(
fadePageRoute(
WeightFormPage(
repository: widget.repository,
petId: widget.petId,
analytics: widget.analytics,
),
// record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
),
);
if (created != null && mounted) {
setState(() {
// 契约排序 measured_at DESC, id DESC:新记录通常最新,就地插头;
// 补录历史日期的位置以下次刷新为准(服务端是唯一事实来源)。
_records = [created, ..._records];
if (_phase != _ListPhase.ready) _phase = _ListPhase.ready;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已记录体重')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('体重记录'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
actions: [
if (widget.canWrite && _phase == _ListPhase.ready)
IconButton(
tooltip: '记录体重',
onPressed: _openCreate,
icon: const Icon(Icons.add),
),
],
),
body: switch (_phase) {
_ListPhase.loading => const Center(child: CircularProgressIndicator()),
_ListPhase.error => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: petLoadErrorMessage(_error)),
const SizedBox(height: 16),
FilledButton(
onPressed: _loadFirstPage,
child: const Text('重试'),
),
],
),
),
),
_ListPhase.ready when _records.isEmpty => Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.monitor_weight_outlined,
title: '还没有体重记录',
description: '定期称重,看见毛孩子的成长曲线',
ctaLabel: widget.canWrite ? '记录第一条' : null,
onCtaPressed: widget.canWrite ? _openCreate : null,
),
),
),
_ListPhase.ready => _list(),
},
);
}
Widget _list() {
return RefreshIndicator(
onRefresh: _loadFirstPage,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: [
for (final record in _records) ...[
_WeightTile(record: record),
const SizedBox(height: 10),
],
if (_hasMore)
Center(
child: _loadingMore
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: TextButton(onPressed: _loadMore, child: const Text('加载更多')),
),
],
),
);
}
}
/// 体重条目:RecordTypeDot(体重) + 大数值(primaryDark w800+
/// 日期/备注副行(05 §3.3 时间线条目形态的数值型变体)。
class _WeightTile extends StatelessWidget {
const _WeightTile({required this.record});
final WeightRecord record;
@override
Widget build(BuildContext context) {
final meta = StringBuffer(formatMeasuredAt(record.measuredAt));
if (record.note != null && record.note!.isNotEmpty) {
meta.write(' · ${record.note}');
}
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const RecordTypeDot(
type: RecordType.weight,
size: RecordTypeDotSize.md,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${formatWeightKg(record.weightKg)} kg',
style: const TextStyle(
color: AppColors.primaryDark,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
meta.toString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
}
/// 称重时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
String formatMeasuredAt(DateTime measuredAt) {
final local = measuredAt.toLocal();
final h = local.hour.toString().padLeft(2, '0');
final min = local.minute.toString().padLeft(2, '0');
return '${dateToJson(local)} $h:$min';
}
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -96,7 +96,10 @@ class ProfilePage extends StatelessWidget {
const _ProfileStat(value: '24', label: '关注我'),
const _ProfileStat(value: '1.8k', label: '获赞'),
_ProfileStat(
value: '${appState.posts.length}',
// 「我的资料」头部整体仍是 demo 家具(M5 范围):三项
// 统计同源 demo 常量;AppState.posts 随 T3-17 退役后
// 本项直读 demo 列表长度,不伪装真实数据。
value: '${initialPosts.length}',
label: '我的作品',
),
],
-79
View File
@@ -134,85 +134,6 @@ class PetProfile {
};
}
enum VaccineStatus { completed, pending }
class VaccineItem {
const VaccineItem({
required this.id,
required this.name,
required this.status,
required this.date,
});
final String id;
final String name;
final VaccineStatus status;
final String date;
VaccineItem copyWith({VaccineStatus? status, String? date}) {
return VaccineItem(
id: id,
name: name,
status: status ?? this.status,
date: date ?? this.date,
);
}
factory VaccineItem.fromJson(Map<String, dynamic> json) {
return VaccineItem(
id: json['id'] as String,
name: json['name'] as String,
status: json['status'] == 'completed'
? VaccineStatus.completed
: VaccineStatus.pending,
date: json['date'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'status': status.name,
'date': date,
};
}
class VaccineRecord {
const VaccineRecord({
required this.completedDoses,
required this.totalDoses,
required this.items,
required this.reminderVaccine,
required this.reminderDate,
});
final int completedDoses;
final int totalDoses;
final List<VaccineItem> items;
final String reminderVaccine;
final String reminderDate;
factory VaccineRecord.fromJson(Map<String, dynamic> json) {
return VaccineRecord(
completedDoses: json['completedDoses'] as int,
totalDoses: json['totalDoses'] as int,
items: (json['items'] as List)
.map((item) => VaccineItem.fromJson(item as Map<String, dynamic>))
.toList(),
reminderVaccine: json['reminderVaccine'] as String,
reminderDate: json['reminderDate'] as String,
);
}
Map<String, dynamic> toJson() => {
'completedDoses': completedDoses,
'totalDoses': totalDoses,
'items': items.map((item) => item.toJson()).toList(),
'reminderVaccine': reminderVaccine,
'reminderDate': reminderDate,
};
}
class CommentModel {
const CommentModel({
required this.id,
+7 -53
View File
@@ -7,13 +7,16 @@ import 'package:shared_preferences/shared_preferences.dart';
class AppState extends ChangeNotifier {
static const _petKey = 'patbond_pet';
static const _vaccinesKey = 'patbond_vaccines';
static const _postsKey = 'patbond_posts';
static const _locationWeatherKey = 'patbond_location_weather';
/// 首页问候卡 / 创作页 / 主壳头像仍消费的 demo 宠物(T2-12 起档案
/// Tab 已切独立 pets feature 真实数据;此 demo 随后续工单收敛)。
///
/// **demo 帖子列表已于 T3-17 退役**Feed / 详情页自 T3-14/15 起消费
/// community 真实数据,发布页自 T3-17 起走真实两步上传 + 建草稿/迁移
/// 发布,`posts` / `publishPost` / `updatePost` 与其
/// shared_preferences 持久化一并删除(03 号评估 §1.1 判定)。
PetProfile pet = initialPet;
VaccineRecord vaccines = initialVaccines;
List<PostModel> posts = List<PostModel>.from(initialPosts);
LocationWeather locationWeather = initialLocationWeather;
bool isReady = false;
@@ -21,23 +24,11 @@ class AppState extends ChangeNotifier {
try {
final preferences = await SharedPreferences.getInstance();
final savedPet = preferences.getString(_petKey);
final savedVaccines = preferences.getString(_vaccinesKey);
final savedPosts = preferences.getString(_postsKey);
final savedLocationWeather = preferences.getString(_locationWeatherKey);
if (savedPet != null) {
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
}
if (savedVaccines != null) {
vaccines = VaccineRecord.fromJson(
jsonDecode(savedVaccines) as Map<String, dynamic>,
);
}
if (savedPosts != null) {
posts = (jsonDecode(savedPosts) as List)
.map((item) => PostModel.fromJson(item as Map<String, dynamic>))
.toList();
}
if (savedLocationWeather != null) {
locationWeather = LocationWeather.fromJson(
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
@@ -46,8 +37,6 @@ class AppState extends ChangeNotifier {
} catch (error, stackTrace) {
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
pet = initialPet;
vaccines = initialVaccines;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather;
} finally {
isReady = true;
@@ -55,33 +44,6 @@ class AppState extends ChangeNotifier {
}
}
Future<void> updatePet(PetProfile value) async {
pet = value;
notifyListeners();
await _save(_petKey, value.toJson());
}
Future<void> updateVaccines(VaccineRecord value) async {
vaccines = value;
notifyListeners();
await _save(_vaccinesKey, value.toJson());
}
Future<void> updatePost(PostModel value) async {
final index = posts.indexWhere((post) => post.id == value.id);
if (index == -1) return;
posts[index] = value;
posts = List<PostModel>.from(posts);
notifyListeners();
await _savePosts();
}
Future<void> publishPost(PostModel value) async {
posts = [value, ...posts];
notifyListeners();
await _savePosts();
}
Future<void> updateLocationWeather(LocationWeather value) async {
locationWeather = value;
notifyListeners();
@@ -90,23 +52,15 @@ class AppState extends ChangeNotifier {
Future<void> resetDemoData() async {
pet = initialPet;
vaccines = initialVaccines;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather;
notifyListeners();
final preferences = await SharedPreferences.getInstance();
await Future.wait([
preferences.remove(_petKey),
preferences.remove(_vaccinesKey),
preferences.remove(_postsKey),
preferences.remove(_locationWeatherKey),
]);
}
Future<void> _savePosts() {
return _save(_postsKey, posts.map((post) => post.toJson()).toList());
}
Future<void> _save(String key, Object value) async {
try {
final preferences = await SharedPreferences.getInstance();
+34 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/network/signed_network_image.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
class RemoteImage extends StatelessWidget {
@@ -21,8 +22,10 @@ class RemoteImage extends StatelessWidget {
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: borderRadius,
child: Image.network(
url,
// 缓存 key 剥离预签名参数(T3-14):媒体 URL 每次响应现签,
// 按完整 URL 缓存会同图重复下载。
child: Image(
image: SignedNetworkImage(url),
width: width,
height: height,
fit: fit,
@@ -73,14 +76,41 @@ class SectionCard extends StatelessWidget {
}
}
/// 胶囊标签:底色为 [color] 的 8% 淡染(`withAlpha(20)`),文字用深变体。
///
/// DEBT-1 偿还(05 号规范 §5.3):原实现文字直接用 [color],在自身淡底
/// 上仅 1.672.55:1 不达 WCAG AA;现文字色按内置映射取深变体
/// primary→primaryDark 8.74:1、success→successInk 7.39:1、
/// accent→accentDark 7.07:1、error→errorDark 5.78:1、未命中→ink 兜底),
/// 或经 [inkColor] 显式指定。既有调用零参数变更、仅视觉变深。
class TagPill extends StatelessWidget {
const TagPill(this.label, {super.key, this.color = AppColors.primary});
const TagPill(
this.label, {
super.key,
this.color = AppColors.primary,
this.inkColor,
});
final String label;
/// 底色基色(淡染 8% 后作背景)。
final Color color;
/// 文字色;缺省按 [color] 查内置深变体映射。
final Color? inkColor;
/// 内置深变体映射(Color 重载 == 不能作 const map 键,用查表函数)。
static Color _defaultInkFor(Color color) {
if (color == AppColors.primary) return AppColors.primaryDark;
if (color == AppColors.success) return AppColors.successInk;
if (color == AppColors.accent) return AppColors.accentDark;
if (color == AppColors.error) return AppColors.errorDark;
return AppColors.ink;
}
@override
Widget build(BuildContext context) {
final effectiveInk = inkColor ?? _defaultInkFor(color);
return DecoratedBox(
decoration: BoxDecoration(
color: color.withAlpha(20),
@@ -91,7 +121,7 @@ class TagPill extends StatelessWidget {
child: Text(
label,
style: TextStyle(
color: color,
color: effectiveInk,
fontSize: 11,
fontWeight: FontWeight.w700,
),
@@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
+1
View File
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
)
@@ -5,11 +5,15 @@
import FlutterMacOS
import Foundation
import file_selector_macos
import flutter_image_compress_macos
import flutter_secure_storage_darwin
import package_info_plus
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+200 -1
View File
@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
crypto:
dependency: transitive
description:
@@ -90,7 +98,7 @@ packages:
source: hosted
version: "2.2.2"
fake_async:
dependency: transitive
dependency: "direct dev"
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
@@ -121,6 +129,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.dev"
source: hosted
version: "0.9.4+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
url: "https://pub.dev"
source: hosted
version: "0.9.5+1"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.dev"
source: hosted
version: "0.9.3+6"
fixnum:
dependency: transitive
description:
@@ -134,6 +174,59 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_image_compress:
dependency: "direct main"
description:
name: flutter_image_compress
sha256: "98a48c05a7add6869c6838270e862124a4d571f9dd5d0cf209ed03a71b20ea84"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
flutter_image_compress_common:
dependency: transitive
description:
name: flutter_image_compress_common
sha256: "76869e4d5f3d65f3431e7edff0b2d8ad1eea68b49c6a37772fdb1ae6016da9ed"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter_image_compress_macos:
dependency: transitive
description:
name: flutter_image_compress_macos
sha256: "0d2a842d2e544828fb32bda16dcfccb3149107df568898a4936d78232e486847"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_image_compress_ohos:
dependency: transitive
description:
name: flutter_image_compress_ohos
sha256: "1491bb7bcfdf59e3b127c263c116cfbf8ed3afade6e7fa691d9622e838ed7e48"
url: "https://pub.dev"
source: hosted
version: "0.0.3+1"
flutter_image_compress_platform_interface:
dependency: transitive
description:
name: flutter_image_compress_platform_interface
sha256: bbefb7967bda565004fdabdb3300dcbfb40c7ef8402675d23206e5bddc358178
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_image_compress_web:
dependency: transitive
description:
name: flutter_image_compress_web
sha256: "91cc58e091a1e09c7683d216d579e2b964119a8527fc43b64dfdb00f1acc94ec"
url: "https://pub.dev"
source: hosted
version: "0.1.5+1"
flutter_lints:
dependency: "direct dev"
description:
@@ -142,6 +235,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -200,6 +301,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
hooks:
dependency: transitive
description:
@@ -224,6 +330,75 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "1c0c38790306fda4ed774095620444333e56a2b6bc8fc98f3a35c9398781cf54"
url: "https://pub.dev"
source: hosted
version: "0.8.13+22"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae
url: "https://pub.dev"
source: hosted
version: "0.8.13+7"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
jni:
dependency: transitive
description:
@@ -424,6 +599,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
pub_semver:
dependency: transitive
description:
@@ -533,6 +716,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
term_glyph:
dependency: transitive
description:
@@ -589,6 +780,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
win32:
dependency: transitive
description:
+9
View File
@@ -39,10 +39,19 @@ dependencies:
flutter_secure_storage: ^11.0.0
uuid: ^4.6.0
package_info_plus: ^10.2.1
# T3-13 媒体上传:系统选择器多选(03 号评估 §4.1 选型)。
image_picker: ^1.2.0
# T3-13 媒体上传:原生编解码压缩(长边重采样 + JPEG 质量 + EXIF 方向矫正)。
flutter_image_compress: ^2.4.0
dev_dependencies:
flutter_test:
sdk: flutter
# T3-14 compose 真链路桌面实测入口(integration_test/,环境变量门控)。
integration_test:
sdk: flutter
# 定时冲刷/退避测试的假时钟驱动(flutter_test 传递依赖显式声明)。
fake_async: ^1.3.3
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
+104
View File
@@ -0,0 +1,104 @@
#!/bin/sh
# check-secrets.sh —— 凭证防泄漏检查(ADR-021,规范见 patbond-doc docs/development/git-workflow.md
#
# 规则单一来源:本地 pre-commit 与 CI 兜底跑的是同一个脚本、同一张规则表。
# 三仓(patbond-api / patbond-flutter / patbond-doc)各存一份同构副本,改规则时三仓同步。
#
# 用法:
# sh scripts/check-secrets.sh --staged # pre-commit:扫暂存区内容(经 scripts/hooks/pre-commit 调用)
# sh scripts/check-secrets.sh --all # CI 兜底 / 手动自查:扫全部已跟踪文件(缺省模式)
# sh scripts/check-secrets.sh <文件...> # 扫指定文件
#
# 拦下真实凭证时的第一动作:去云控制台轮换/禁用该密钥,然后才是清理提交。
set -u
mode="${1:---all}"
# 允许清单:行内出现任一形态即放行(${} 注入、占位值、明显示例值)
ALLOW='\$\{[^}]*\}|\{\{[^}]*\}\}|changeme|change[-_]me|your[-_][a-zA-Z0-9_-]+|<[a-zA-Z0-9 ,_.-]+>|placeholder|example|sample|dummy|fake|redacted|\*\*\*'
# 内容扫描跳过:本脚本与 hook 自身(含规则文本,非凭证)
SKIP_PATHS='(^|/)scripts/(check-secrets\.sh|hooks/pre-commit)$'
# 文件名黑名单:凭证载体文件本体禁止入库(.sample/.example 除外)
DENY_NAME='(^|/)\.env(\.[^/]+)?$|(^|/)credentials[^/]*$|[Aa]ccess[Kk]eys?[^/]*\.csv$|(^|/)rootkey\.csv$'
DENY_NAME_OK='\.(sample|example)$'
# 规则表:ID<TAB>大小写旗标(i=忽略大小写,-=敏感)<TAB>文件范围ERE(-=全部文件)<TAB>行模式ERE
RULES=$(cat <<'EOF'
AK-AWS - - AKIA[0-9A-Z]{16}
AK-QCLOUD - - AKID[0-9A-Za-z]{16,}
AK-ALIYUN - - LTAI[0-9A-Za-z]{12,}
MINIO-DEFAULT i - minio[-_.]?admin
PRIVATE-KEY - - ^[[:space:]]*-----BEGIN [A-Z ]*PRIVATE KEY-----[[:space:]]*$
KEY-ASSIGN i - (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
JWT-SECRET i - (jwt[-_.]?secret|signing[-_]?key|token[-_]?secret|hmac[-_]?(key|secret))["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
DB-PASSWORD i \.(ya?ml|properties|toml|conf|ini)(\.sample|\.example)?$ (password|passwd|pwd)["']?[[:space:]]*[:=][[:space:]]*["']?[^[:space:]"'$]{6,}
EOF
)
case "$mode" in
--staged)
files=$(git diff --cached --name-only --diff-filter=ACM)
src=index
;;
--all)
files=$(git ls-files)
src=worktree
;;
-*)
echo "用法: $0 [--staged|--all|<文件...>]" >&2
exit 2
;;
*)
files=$(printf '%s\n' "$@")
src=worktree
;;
esac
[ -n "$files" ] || exit 0
tmp=$(mktemp) || exit 2
viol=$(mktemp) || exit 2
trap 'rm -f "$tmp" "$viol"' EXIT
# 第一道:文件名黑名单
printf '%s\n' "$files" | grep -E "$DENY_NAME" | grep -vE "$DENY_NAME_OK" |
sed 's/^/[NAME-DENY] /' >>"$viol" || true
# 第二道:逐文件逐规则内容扫描(二进制文件经 grep -I 自然跳过)
IFS='
'
for f in $files; do
printf '%s' "$f" | grep -qE "$SKIP_PATHS" && continue
if [ "$src" = index ]; then
git show ":$f" >"$tmp" 2>/dev/null || continue
else
[ -f "$f" ] || continue
cat -- "$f" >"$tmp"
fi
printf '%s\n' "$RULES" | while IFS="$(printf '\t')" read -r id flag scope pat; do
[ -n "$id" ] || continue
if [ "$scope" != "-" ]; then
printf '%s' "$f" | grep -qE "$scope" || continue
fi
ci=""
[ "$flag" = "i" ] && ci="-i"
grep -InE $ci -e "$pat" "$tmp" 2>/dev/null | grep -viE "$ALLOW" |
sed "s|^|[$id] $f:|" >>"$viol" || true
done
done
if [ -s "$viol" ]; then
echo "凭证防泄漏检查未通过(ADR-021)——以下内容疑似真实凭证:" >&2
cat "$viol" >&2
cat >&2 <<'MSG'
处置:
1. 若是真实凭证:先去云控制台轮换/禁用该密钥,再从提交中移除;
2. 若是误报:改用 ${} 注入或占位值(changeme / your-xxx / <占位>),
或与团队确认后调整三仓同构的 scripts/check-secrets.sh 规则表。
敏感信息只允许存在于被 gitignore 的文件或 .sample 占位中(git-workflow.md)。
MSG
exit 1
fi
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# pre-commit —— 凭证防泄漏(ADR-021)。启用(每人每仓一次):
# git config core.hooksPath scripts/hooks
# 注意:core.hooksPath 会整体接管 hooks 目录;本仓无其他自定义 hook。
repo_root=$(git rev-parse --show-toplevel) || exit 1
exec sh "$repo_root/scripts/check-secrets.sh" --staged
@@ -0,0 +1,159 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
Map<String, dynamic> event(int seq) => {
'eventId': 'id-$seq',
'eventName': 'auth_login_succeeded',
'seq': seq,
};
void main() {
group('AnalyticsEventStore', () {
test('事件持久化后新实例冷启动可恢复(顺序保持旧 → 新)', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 45; i++) {
await store.add(event(i));
}
final reborn = AnalyticsEventStore();
await reborn.restore();
expect(reborn.length, 45);
expect(reborn.events.first['seq'], 0);
expect(reborn.events.last['seq'], 44);
// 45 条按容量 20 分段应为 3 段
final prefs = await SharedPreferences.getInstance();
final index =
jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!) as List;
expect(index.length, 3);
});
test('超过 500 条总上限时丢最旧整段', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 520; i++) {
await store.add(event(i));
}
// 第 501 条触发淘汰最旧一段(20 条),此后回落到上限内
expect(store.length, 500);
expect(store.events.first['seq'], 20);
expect(store.events.last['seq'], 519);
expect(store.droppedCount, 20);
});
test('损坏段丢弃不崩溃,其余段正常恢复且索引被清理', () async {
SharedPreferences.setMockInitialValues({
AnalyticsEventStore.segIndexKey: jsonEncode(['bad', 'good']),
'${AnalyticsEventStore.segKeyPrefix}bad': '{not-valid-json[[',
'${AnalyticsEventStore.segKeyPrefix}good': jsonEncode([event(7)]),
});
final store = AnalyticsEventStore();
await store.restore();
expect(store.length, 1);
expect(store.events.single['seq'], 7);
expect(store.droppedCount, 1);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('${AnalyticsEventStore.segKeyPrefix}bad'), isNull);
expect(jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!), [
'good',
]);
});
test('段索引本身损坏时清扫孤儿段、从空队列重建', () async {
SharedPreferences.setMockInitialValues({
AnalyticsEventStore.segIndexKey: 'garbage-not-json',
'${AnalyticsEventStore.segKeyPrefix}orphan': jsonEncode([event(1)]),
});
final store = AnalyticsEventStore();
await store.restore();
expect(store.length, 0);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getString('${AnalyticsEventStore.segKeyPrefix}orphan'),
isNull,
);
// 可继续正常写入
await store.add(event(2));
expect(store.length, 1);
});
test('takeBatch 封段:在途批次不受新事件影响,删段后新事件保留', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 5; i++) {
await store.add(event(i));
}
final batch = store.takeBatch(50);
// 模拟上传在途期间进入的新事件
await store.add(event(99));
expect(batch.events.length, 5);
await store.removeSegments(batch.segmentIds);
expect(store.length, 1);
expect(store.events.single['seq'], 99);
});
test('takeBatch 按段拼批不超过单批上限', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 60; i++) {
await store.add(event(i));
}
// 段容量 20、单批上限 50 → 每批最多 2 整段(40 条)
final first = store.takeBatch(50);
expect(first.events.length, 40);
await store.removeSegments(first.segmentIds);
final second = store.takeBatch(50);
expect(second.events.length, 20);
});
test('上传成功删段后 shared_preferences 无残留', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 20; i++) {
await store.add(event(i));
}
final batch = store.takeBatch(50);
await store.removeSegments(batch.segmentIds);
expect(store.length, 0);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getKeys().where(
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
),
isEmpty,
);
expect(
jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!),
isEmpty,
);
});
test('未调用 restore(持久化不可用)时降级为纯内存队列', () async {
final store = AnalyticsEventStore();
await store.add(event(1));
expect(store.length, 1);
final batch = store.takeBatch(50);
await store.removeSegments(batch.segmentIds);
expect(store.length, 0);
});
});
}
@@ -0,0 +1,209 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart';
/// 定时冲刷与失败退避测试(M3 T3-19,13 号 §3.4 第 4 触发点 +
/// iteration-3 06 号 §2.3):fakeAsync 驱动 Timer.periodic
/// 时钟注入照 SessionTracker 先例,假上传子类免起真实 HttpServer。
class _FakeUploadService extends AnalyticsService {
_FakeUploadService({super.now})
: super(
apiBaseUrl: 'http://unused',
getAccessToken: null,
getSessionId: () => 'session-x',
);
int uploadAttempts = 0;
bool failUploads = false;
final List<int> uploadedBatchSizes = [];
@override
Future<bool> uploadBatch(List<Map<String, dynamic>> events) async {
uploadAttempts++;
if (failUploads) {
throw Exception('simulated network failure');
}
uploadedBatchSizes.add(events.length);
return false;
}
}
void main() {
/// 逐秒推进假时间轴并同步注入时钟,保证定时器回调读到的 now 与
/// 已流逝时间一致(整段 elapse 会让回调读到未更新的旧时钟)。
(void Function(Duration), _FakeUploadService) setup(FakeAsync async) {
var clock = DateTime.utc(2026, 9, 8, 10);
final service = _FakeUploadService(now: () => clock);
void elapse(Duration duration) {
final target = clock.add(duration);
while (clock.isBefore(target)) {
clock = clock.add(const Duration(seconds: 1));
async.elapse(const Duration(seconds: 1));
}
}
return (elapse, service);
}
group('AnalyticsService 定时冲刷', () {
test('前台每 30 秒冲刷不满 20 条的队列', () {
fakeAsync((async) {
final (elapse, service) = setup(async);
service.startPeriodicFlush();
service.trackEvent('page_viewed', {'pageName': 'home'});
async.flushMicrotasks();
elapse(const Duration(seconds: 29));
expect(service.uploadAttempts, 0);
elapse(const Duration(seconds: 1));
expect(service.uploadedBatchSizes, [1]);
expect(service.pendingEvents, isEmpty);
service.stopPeriodicFlush();
});
});
test('队列为空时定时器不发起上传', () {
fakeAsync((async) {
final (elapse, service) = setup(async);
service.startPeriodicFlush();
elapse(const Duration(minutes: 2));
expect(service.uploadAttempts, 0);
service.stopPeriodicFlush();
});
});
test('stopPeriodicFlush 停止触发(退后台),start 恢复(回前台)', () {
fakeAsync((async) {
final (elapse, service) = setup(async);
service.trackEvent('page_viewed', {'pageName': 'home'});
async.flushMicrotasks();
service.startPeriodicFlush();
service.stopPeriodicFlush();
elapse(const Duration(minutes: 2));
expect(service.uploadAttempts, 0);
service.startPeriodicFlush();
elapse(const Duration(seconds: 30));
expect(service.uploadedBatchSizes, [1]);
service.stopPeriodicFlush();
});
});
test('startPeriodicFlush 幂等,不叠加多个定时器', () {
fakeAsync((async) {
final (elapse, service) = setup(async);
service.startPeriodicFlush();
service.startPeriodicFlush();
service.trackEvent('page_viewed', {'pageName': 'home'});
async.flushMicrotasks();
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 1);
service.stopPeriodicFlush();
});
});
});
group('AnalyticsService 失败退避', () {
test('上传失败按 30s→60s→120s 指数退避,期间定时冲刷跳过', () {
fakeAsync((async) {
final (elapse, service) = setup(async);
service.failUploads = true;
service.trackEvent('page_viewed', {'pageName': 'home'});
async.flushMicrotasks();
service.startPeriodicFlush();
// t=30s 首次尝试失败 → 退避 30s(下次可试 t=60s)。
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 1);
// t=60s 第二次失败 → 退避 60s(下次 t=120s);t=90s 被跳过。
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 2);
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 2);
// t=120s 第三次失败 → 退避 120st=150/180/210s 均跳过。
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 3);
elapse(const Duration(seconds: 90));
expect(service.uploadAttempts, 3);
// t=240s 第四次尝试;事件始终保留在队列。
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 4);
expect(service.pendingEvents.length, 1);
service.stopPeriodicFlush();
});
});
test('退避封顶 5 分钟', () {
fakeAsync((async) {
final (elapse, service) = setup(async);
service.failUploads = true;
service.trackEvent('page_viewed', {'pageName': 'home'});
async.flushMicrotasks();
service.startPeriodicFlush();
// 失败序列 t=30/60/120/240s(退避 30/60/120/240s),
// 第五次 t=480s240s×2=480s 超帽,封顶 300s。
elapse(const Duration(minutes: 8));
expect(service.uploadAttempts, 5);
// t=780s 前(480+300s 窗口内)不再尝试,到点第六次。
elapse(const Duration(seconds: 299));
expect(service.uploadAttempts, 5);
elapse(const Duration(seconds: 1));
expect(service.uploadAttempts, 6);
service.stopPeriodicFlush();
});
});
test('退避只挡定时冲刷,flushNow 显式触发不受限', () {
fakeAsync((async) {
final (elapse, service) = setup(async);
service.failUploads = true;
service.trackEvent('page_viewed', {'pageName': 'home'});
async.flushMicrotasks();
service.startPeriodicFlush();
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 1);
// 退避窗口内(t=45s)退后台显式冲刷仍然尝试。
elapse(const Duration(seconds: 15));
service.flushNow();
async.flushMicrotasks();
expect(service.uploadAttempts, 2);
service.stopPeriodicFlush();
});
});
test('上传成功即重置退避,恢复 30 秒节奏', () {
fakeAsync((async) {
final (elapse, service) = setup(async);
service.failUploads = true;
service.trackEvent('page_viewed', {'pageName': 'home'});
async.flushMicrotasks();
service.startPeriodicFlush();
// t=30/60s 两次失败后网络恢复,t=120s 第三次成功。
elapse(const Duration(seconds: 60));
expect(service.uploadAttempts, 2);
service.failUploads = false;
elapse(const Duration(seconds: 60));
expect(service.uploadedBatchSizes, [1]);
// 退避已重置:新事件在下一个 30 秒刻度即上传,无残留等待。
service.trackEvent('page_viewed', {'pageName': 'feed'});
async.flushMicrotasks();
elapse(const Duration(seconds: 30));
expect(service.uploadedBatchSizes, [1, 1]);
service.stopPeriodicFlush();
});
});
});
}
@@ -0,0 +1,208 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 服务级持久化队列联动测试(13 号规范 §3.3/§3.4):
/// 本地 HttpServer 模拟 /api/v1/events 的 202 / 400 响应。
void main() {
Future<(HttpServer, List<int>)> startServer(int statusCode) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final batchSizes = <int>[];
server.listen((request) async {
final body =
jsonDecode(await utf8.decoder.bind(request).join())
as Map<String, dynamic>;
batchSizes.add((body['events'] as List).length);
request.response.statusCode = statusCode;
await request.response.close();
});
return (server, batchSizes);
}
AnalyticsService buildService(String apiBaseUrl, AnalyticsEventStore store) {
return AnalyticsService(
apiBaseUrl: apiBaseUrl,
getAccessToken: null,
getSessionId: () => 'session-x',
store: store,
);
}
group('AnalyticsService 持久化队列', () {
test('满 20 条冲刷、202 受理后清空持久化段', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(202);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
final service = buildService('http://127.0.0.1:${server.port}', store);
for (var i = 0; i < 20; i++) {
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
}
expect(batchSizes, [20]);
expect(service.pendingEvents, isEmpty);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getKeys().where(
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
),
isEmpty,
);
});
test('flushNow 冲刷不满 20 条的持久化队列', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(202);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
final service = buildService('http://127.0.0.1:${server.port}', store);
await service.trackEvent('auth_login_succeeded');
await service.trackEvent('auth_logout');
await service.flushNow();
expect(batchSizes, [2]);
expect(service.pendingEvents, isEmpty);
expect(store.length, 0);
});
test('上传失败事件持久化,冷启动恢复后自动重传', () async {
SharedPreferences.setMockInitialValues({});
// 第一个进程:端口不可达,20 条触发冲刷失败,事件留在持久化队列
final storeA = AnalyticsEventStore();
await storeA.restore();
final serviceA = buildService('http://127.0.0.1:1', storeA);
for (var i = 0; i < 20; i++) {
await serviceA.trackEvent('auth_login_succeeded', {'attemptSeq': i});
}
expect(serviceA.pendingEvents.length, 20);
// 模拟冷启动:新实例 restore 恢复并冲刷到可用服务端
final (server, batchSizes) = await startServer(202);
addTearDown(() => server.close(force: true));
final storeB = AnalyticsEventStore();
final serviceB = buildService('http://127.0.0.1:${server.port}', storeB);
await serviceB.restore();
expect(batchSizes, [20]);
expect(serviceB.pendingEvents, isEmpty);
});
test('4xx 永久拒绝直接删段丢弃(毒丸防护),计入丢弃诊断数', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(400);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
final service = buildService('http://127.0.0.1:${server.port}', store);
for (var i = 0; i < 20; i++) {
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
}
expect(batchSizes, [20]);
expect(service.pendingEvents, isEmpty);
expect(store.droppedCount, 20);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getKeys().where(
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
),
isEmpty,
);
});
test('积压超过单批上限时按 50 条分批循环上传', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(202);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
// 直接预置 60 条积压(绕过 trackEvent 的满 20 冲刷触发)
for (var i = 0; i < 60; i++) {
await store.add({'eventId': 'id-$i', 'eventName': 'page_viewed'});
}
final service = buildService('http://127.0.0.1:${server.port}', store);
await service.flushNow();
// 段容量 20、单批上限 50 → 40 + 20 两批
expect(batchSizes, [40, 20]);
expect(service.pendingEvents, isEmpty);
});
test('429 不按毒丸丢弃:保段待退避重试(限流分支随后端落地)', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(429);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
final service = buildService('http://127.0.0.1:${server.port}', store);
for (var i = 0; i < 20; i++) {
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
}
expect(batchSizes, [20]);
expect(service.pendingEvents.length, 20);
expect(store.droppedCount, 0);
});
});
group('AnalyticsService anonymousId 持久化', () {
test('首次 restore 落盘生成的 anonymousId', () async {
SharedPreferences.setMockInitialValues({});
final service = buildService('http://unused', AnalyticsEventStore());
await service.restore();
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getString(AnalyticsService.anonymousIdKey),
service.anonymousId,
);
});
test('冷启动新实例沿用持久化 anonymousId,事件跨启动可归并', () async {
SharedPreferences.setMockInitialValues({});
final serviceA = buildService('http://unused', AnalyticsEventStore());
await serviceA.restore();
final firstLaunchId = serviceA.anonymousId;
// 模拟冷启动:新实例构造时是新的随机 v4,restore 后采用存储值。
final serviceB = buildService('http://unused', AnalyticsEventStore());
expect(serviceB.anonymousId, isNot(firstLaunchId));
await serviceB.restore();
expect(serviceB.anonymousId, firstLaunchId);
await serviceB.trackEvent('auth_login_succeeded');
expect(serviceB.pendingEvents.single['anonymousId'], firstLaunchId);
});
test('构造注入 anonymousId 的测试通道不被持久化覆盖', () async {
SharedPreferences.setMockInitialValues({
AnalyticsService.anonymousIdKey: 'anon-stored',
});
final service = AnalyticsService(
apiBaseUrl: 'http://unused',
getAccessToken: null,
getSessionId: () => 'session-x',
anonymousId: 'anon-injected',
store: AnalyticsEventStore(),
);
await service.restore();
expect(service.anonymousId, 'anon-injected');
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(AnalyticsService.anonymousIdKey), 'anon-stored');
});
});
}
@@ -122,5 +122,18 @@ void main() {
// 第 20 条触发上传,失败后批次应重回队列(M0 行为是整批清空)。
expect(service.pendingEvents.length, 20);
});
test('持久化不可用时 restore 降级临时 anonymousId 不崩溃', () async {
// 本文件从不 setMockInitialValuesSharedPreferences 走真实
// 平台通道并抛异常,restore 须吞掉并保留构造时的临时 id。
final service = buildService();
final ephemeralId = service.anonymousId;
await service.restore();
await service.trackEvent('auth_login_succeeded');
expect(service.anonymousId, ephemeralId);
expect(service.pendingEvents.single['anonymousId'], ephemeralId);
});
});
}
+27
View File
@@ -73,5 +73,32 @@ void main() {
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(tracker.sessionId, isNot(original));
});
test('前后台回调成对触发一次,生命周期级联不重复', () {
var leaveCount = 0;
var enterCount = 0;
final tracker = SessionTracker(
onLeaveForeground: () => leaveCount++,
onEnterForeground: () => enterCount++,
);
// 退后台级联:inactive → hidden → paused 只回调 leave 一次。
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
expect(leaveCount, 1);
expect(enterCount, 0);
// 回前台级联:hidden → inactive → resumed 只回调 enter 一次。
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(leaveCount, 1);
expect(enterCount, 1);
// 已在前台重复 resumed(冷启动首个 resumed 同形)不触发 enter。
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(enterCount, 1);
});
});
}
@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/signed_network_image.dart';
void main() {
group('presignedImageCacheKey', () {
test('剥离全部 X-Amz-* 签名参数(大小写不敏感)', () {
const url =
'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg'
'?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=cred'
'&X-Amz-Date=20260909T000000Z&X-Amz-Expires=3600'
'&X-Amz-SignedHeaders=host&x-amz-signature=deadbeef';
expect(
presignedImageCacheKey(url),
'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg',
);
});
test('保留非签名 query 参数', () {
const url = 'https://cdn.example.com/p.jpg?w=300&X-Amz-Signature=sig';
expect(
presignedImageCacheKey(url),
'https://cdn.example.com/p.jpg?w=300',
);
});
test('无签名参数原样返回', () {
expect(
presignedImageCacheKey('https://cdn.example.com/p.jpg?w=300&q=80'),
'https://cdn.example.com/p.jpg?w=300&q=80',
);
expect(
presignedImageCacheKey('https://cdn.example.com/p.jpg'),
'https://cdn.example.com/p.jpg',
);
});
test('非法 URL 不炸,退回原串', () {
expect(presignedImageCacheKey('::not a url::'), '::not a url::');
});
});
group('SignedNetworkImage', () {
test('同对象不同签名 → 判等(命中同一 ImageCache 条目)', () {
final first = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig1&X-Amz-Date=d1',
);
final second = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig2&X-Amz-Date=d2',
);
expect(first, second);
expect(first.hashCode, second.hashCode);
});
test('不同对象 → 不判等', () {
final first = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig',
);
final second = SignedNetworkImage(
'http://127.0.0.1:9000/m/b.jpg?X-Amz-Signature=sig',
);
expect(first, isNot(second));
});
test('scale 参与判等', () {
final first = SignedNetworkImage('http://h/m/a.jpg');
final second = SignedNetworkImage('http://h/m/a.jpg', scale: 2);
expect(first, isNot(second));
});
});
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/comment_tile.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
final now = DateTime.parse('2026-09-08T13:00:00.000Z');
Widget wrap(Widget child) => MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: Center(child: child)),
);
testWidgets('渲染作者名 / 内容 / 相对时间;无 onDelete 不显删除', (tester) async {
await tester.pumpWidget(
wrap(CommentTile(comment: sampleComment(), now: now)),
);
expect(find.text('毛毛的铲屎官'), findsOneWidget);
expect(find.text('好可爱!', findRichText: true), findsOneWidget);
expect(find.text('2 小时前'), findsOneWidget);
expect(find.text('删除'), findsNothing);
});
testWidgets('@ 回复以「回复 @昵称:」前缀呈现;降级作者「宠友」占位', (tester) async {
await tester.pumpWidget(
wrap(
CommentTile(
comment: sampleComment(
author: sampleAuthorJson(nickname: null, avatarUrl: null),
replyToUser: sampleAuthorJson(userId: 'u-2', nickname: '豆豆麻麻'),
),
now: now,
),
),
);
expect(find.text('宠友'), findsOneWidget);
expect(
find.textContaining('回复 @豆豆麻麻:', findRichText: true),
findsOneWidget,
);
});
testWidgets('onDelete 提供时显删除入口并回调;deleting 期间转圈锁定', (tester) async {
var deleted = 0;
await tester.pumpWidget(
wrap(
CommentTile(
comment: sampleComment(),
now: now,
onDelete: () => deleted++,
),
),
);
await tester.tap(find.text('删除'));
expect(deleted, 1);
await tester.pumpWidget(
wrap(
CommentTile(
comment: sampleComment(),
now: now,
deleting: true,
onDelete: () => deleted++,
),
),
);
expect(find.text('删除'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
void main() {
testWidgets('渲染插画 / 标题 / 说明 / CTA,点击 CTA 回调', (tester) async {
var taps = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: EmptyStateIllustration(
icon: Icons.pets,
title: '还没有宠物档案',
description: '添加毛孩子,开始记录 TA 的健康点滴',
ctaLabel: '添加宠物',
onCtaPressed: () => taps++,
),
),
),
);
expect(find.byIcon(Icons.pets), findsOneWidget);
expect(find.text('还没有宠物档案'), findsOneWidget);
expect(find.text('添加毛孩子,开始记录 TA 的健康点滴'), findsOneWidget);
await tester.tap(find.text('添加宠物'));
expect(taps, 1);
});
testWidgets('无 CTA / 无说明时不渲染对应区块', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: EmptyStateIllustration(
icon: Icons.event_note_outlined,
title: '暂无体重记录',
),
),
),
);
expect(find.text('暂无体重记录'), findsOneWidget);
expect(find.byType(FilledButton), findsNothing);
});
}
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/like_button.dart';
/// 宿主:模拟持有方(controller)的状态翻转——点按经 [onTap] 乐观翻转,
/// 外部(回滚/对账)经 [setActive] 直接改。
class _Host extends StatefulWidget {
const _Host({required this.initialActive, required this.initialCount});
final bool initialActive;
final int initialCount;
@override
State<_Host> createState() => _HostState();
}
class _HostState extends State<_Host> {
late bool active = widget.initialActive;
late int count = widget.initialCount;
void setExternal({required bool active, required int count}) {
setState(() {
this.active = active;
this.count = count;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: Center(
child: LikeButton(
variant: LikeButtonVariant.like,
active: active,
count: count,
onPressed: () => setState(() {
active = !active;
count += active ? 1 : -1;
}),
semanticLabel: '点赞',
),
),
),
);
}
}
void main() {
double scaleOf(WidgetTester tester) => tester
.widget<ScaleTransition>(
find.descendant(
of: find.byType(LikeButton),
matching: find.byType(ScaleTransition),
),
)
.scale
.value;
testWidgets('点按激活:240ms 弹性缩放动画(中途 >1,播完归位)', (tester) async {
await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6));
await tester.tap(find.byIcon(Icons.favorite_border));
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
expect(scaleOf(tester), greaterThan(1.0));
expect(find.byIcon(Icons.favorite), findsOneWidget);
expect(find.text('7'), findsOneWidget);
await tester.pumpAndSettle();
expect(scaleOf(tester), 1.0);
});
testWidgets('点按取消:仅颜色切换,无缩放动画', (tester) async {
await tester.pumpWidget(const _Host(initialActive: true, initialCount: 7));
await tester.tap(find.byIcon(Icons.favorite));
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
expect(scaleOf(tester), 1.0);
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
expect(find.text('6'), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('外部回滚:零动画直接跳变,计数与状态成对恢复', (tester) async {
await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6));
// 点按激活并播完动画(在途请求随后失败的场景)。
await tester.tap(find.byIcon(Icons.favorite_border));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.favorite), findsOneWidget);
// 外部(非点按)回滚:下一帧即恢复,无过渡动画。
tester
.state<_HostState>(find.byType(_Host))
.setExternal(active: false, count: 6);
await tester.pump();
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
expect(find.text('6'), findsOneWidget);
expect(scaleOf(tester), 1.0);
});
testWidgets('激活动画未播完时回滚:等播完再成对跳变(§4.3a 抖动抑制)', (tester) async {
await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6));
await tester.tap(find.byIcon(Icons.favorite_border));
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
expect(find.byIcon(Icons.favorite), findsOneWidget);
// 动画中途回滚:本帧仍显示激活视觉(等待播完),计数同持。
tester
.state<_HostState>(find.byType(_Host))
.setExternal(active: false, count: 6);
await tester.pump();
expect(find.byIcon(Icons.favorite), findsOneWidget);
expect(find.text('7'), findsOneWidget);
// 播完后成对跳回。
await tester.pumpAndSettle();
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
expect(find.text('6'), findsOneWidget);
});
testWidgets('对账静默:状态不变的计数变化直接替换,无动画', (tester) async {
await tester.pumpWidget(const _Host(initialActive: true, initialCount: 7));
tester
.state<_HostState>(find.byType(_Host))
.setExternal(active: true, count: 9);
await tester.pump();
expect(find.text('9'), findsOneWidget);
expect(scaleOf(tester), 1.0);
});
testWidgets('onPressed 为 null 时禁用但照常渲染激活态', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const Scaffold(
body: LikeButton(
variant: LikeButtonVariant.bookmark,
active: true,
count: 2,
),
),
),
);
expect(find.byIcon(Icons.bookmark), findsOneWidget);
expect(find.text('2'), findsOneWidget);
});
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
void main() {
Future<void> pump(WidgetTester tester, Widget child) {
return tester.pumpWidget(
MaterialApp(
home: Scaffold(body: Center(child: child)),
),
);
}
testWidgets('四档尺寸与规格一致(96/64/44/32', (tester) async {
expect(PetAvatarSize.xl.dimension, 96);
expect(PetAvatarSize.lg.dimension, 64);
expect(PetAvatarSize.md.dimension, 44);
expect(PetAvatarSize.sm.dimension, 32);
await pump(tester, const PetAvatar(size: PetAvatarSize.xl));
final size = tester.getSize(find.byType(PetAvatar));
expect(size.width, 96);
expect(size.height, 96);
});
testWidgets('无 url 渲染本地占位形态(ADR-010pets 图标)', (tester) async {
await pump(tester, const PetAvatar(size: PetAvatarSize.lg));
final icon = tester.widget<Icon>(find.byIcon(Icons.pets));
expect(icon.color, AppColors.muted);
expect(find.byType(Image), findsNothing);
});
testWidgets('xl 档编辑徽标为 primaryStrong 底白图标(无障碍修订)', (tester) async {
await pump(
tester,
const PetAvatar(size: PetAvatarSize.xl, showEditBadge: true),
);
final badgeIcon = tester.widget<Icon>(find.byIcon(Icons.edit));
expect(badgeIcon.color, Colors.white);
final badge = tester.widget<Container>(
find.ancestor(
of: find.byIcon(Icons.edit),
matching: find.byType(Container),
),
);
final decoration = badge.decoration as BoxDecoration;
expect(decoration.color, AppColors.primaryStrong);
});
testWidgets('sm/md 档不渲染编辑徽标', (tester) async {
await pump(
tester,
const PetAvatar(size: PetAvatarSize.sm, showEditBadge: true),
);
expect(find.byIcon(Icons.edit), findsNothing);
});
testWidgets('onTap 生效且禁用态不响应', (tester) async {
var taps = 0;
await pump(tester, PetAvatar(size: PetAvatarSize.lg, onTap: () => taps++));
await tester.tap(find.byType(PetAvatar));
expect(taps, 1);
await pump(
tester,
PetAvatar(size: PetAvatarSize.lg, enabled: false, onTap: () => taps++),
);
await tester.tap(find.byType(PetAvatar));
expect(taps, 1);
});
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/like_button.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/widgets/common.dart';
import '../../helpers/community_test_helpers.dart';
FeedCard cardFrom(Map<String, dynamic> overrides) =>
FeedCard.fromJson({...sampleFeedCardJson(), ...overrides});
Widget wrap(Widget child) => MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: SingleChildScrollView(child: child)),
);
void main() {
testWidgets('单图形态:通栏 4:3 出血,不走九宫格,预览 2 行', (tester) async {
final card = cardFrom({'mediaCount': 1});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.byType(AspectRatio), findsOneWidget);
expect(find.byType(PostMediaGrid), findsNothing);
final preview = tester.widget<Text>(find.text('晒了一下午太阳。'));
expect(preview.maxLines, 2);
});
testWidgets('多图形态:九宫格折叠封面 + 「+N」角标(契约只带封面与计数)', (tester) async {
final card = cardFrom({'mediaCount': 5});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.byType(PostMediaGrid), findsOneWidget);
expect(find.text('+4'), findsOneWidget);
});
testWidgets('纯文字形态:无媒体区,正文放宽 6 行、15/1.6', (tester) async {
final card = cardFrom({'coverImage': null, 'mediaCount': 0});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.byType(PostMediaGrid), findsNothing);
expect(find.byType(AspectRatio), findsNothing);
final preview = tester.widget<Text>(find.text('晒了一下午太阳。'));
expect(preview.maxLines, 6);
expect(preview.style?.fontSize, 15);
});
testWidgets('头部行:作者名 + 相对时间;求助帖追加 accent 标', (tester) async {
final card = cardFrom({
'category': 'help',
'publishedAt': DateTime.now()
.subtract(const Duration(hours: 2))
.toUtc()
.toIso8601String(),
});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.text('毛毛的铲屎官'), findsOneWidget);
expect(find.text('2 小时前'), findsOneWidget);
expect(find.widgetWithText(TagPill, '求助'), findsOneWidget);
});
testWidgets('降级作者:占位头像 + 「宠友」默认名', (tester) async {
final card = cardFrom({
'author': sampleAuthorJson(nickname: null, avatarUrl: null),
});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.text('宠友'), findsOneWidget);
expect(find.text('毛毛的铲屎官'), findsNothing);
});
testWidgets('操作行:计数与激活态展示;T3-14 纯展示点按无副作用', (tester) async {
final card = cardFrom({
'likedByMe': true,
'likeCount': 6,
'bookmarkedByMe': false,
'bookmarkCount': 2,
'commentCount': 3,
});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.text('6'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
// 点赞激活:实心 favorite + error 色(Colors.red 修订 D6)。
final likeIcon = tester.widget<Icon>(find.byIcon(Icons.favorite));
expect(likeIcon.color, AppColors.error);
// 收藏未激活:描边 + inkSoft。
final bookmarkIcon = tester.widget<Icon>(
find.byIcon(Icons.bookmark_border),
);
expect(bookmarkIcon.color, AppColors.inkSoft);
// 回调未接(null):点按不抛错、无状态变化。
await tester.tap(find.byType(LikeButton).first, warnIfMissed: false);
await tester.pump();
expect(find.byIcon(Icons.favorite), findsOneWidget);
});
}
+192
View File
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/core/widgets/upload_progress_overlay.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/widgets/common.dart';
import '../../helpers/media_test_helpers.dart';
Widget wrap(Widget child, {bool disableAnimations = false}) => MaterialApp(
theme: buildAppTheme(),
home: MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: Scaffold(body: SingleChildScrollView(child: child)),
),
);
List<String> urls(int count) => List.generate(
count,
(i) => 'https://minio.local/p$i.jpg?X-Amz-Signature=s',
);
void main() {
group('PostMediaGrid 列数规则(05 §3.2', () {
test('2、4 图 → 2 列;3、5–9 图 → 3 列', () {
expect(PostMediaGrid.columnsFor(2), 2);
expect(PostMediaGrid.columnsFor(4), 2);
expect(PostMediaGrid.columnsFor(3), 3);
for (var n = 5; n <= 9; n++) {
expect(PostMediaGrid.columnsFor(n), 3);
}
});
testWidgets('4 图渲染 4 格网格', (tester) async {
await tester.pumpWidget(wrap(PostMediaGrid(urls: urls(4))));
expect(find.byType(RemoteImage), findsNWidgets(4));
expect(find.byType(GridView), findsOneWidget);
});
testWidgets('超 9 图折叠:显 9 格,末格 ink 80% scrim + 「+N」', (tester) async {
await tester.pumpWidget(
wrap(PostMediaGrid(urls: urls(12), totalCount: 12)),
);
expect(find.byType(RemoteImage), findsNWidgets(9));
expect(find.text('+3'), findsOneWidget);
final scrim = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((w) => w.decoration)
.whereType<BoxDecoration>()
.where((d) => d.color == AppColors.ink.withAlpha(204));
expect(scrim, isNotEmpty);
});
testWidgets('单图折叠形态(Feed 封面 + 总数):4:3 单格 + 角标胶囊', (tester) async {
await tester.pumpWidget(
wrap(PostMediaGrid(urls: urls(1), totalCount: 3)),
);
expect(find.byType(GridView), findsNothing);
expect(find.byType(AspectRatio), findsOneWidget);
expect(find.text('+2'), findsOneWidget);
});
testWidgets('单图无折余不出角标', (tester) async {
await tester.pumpWidget(wrap(PostMediaGrid(urls: urls(1))));
expect(find.textContaining('+'), findsNothing);
});
});
group('FeedSkeleton05 §3.7', () {
testWidgets('骨架单元:头部圆 + 4:3 块 + 正文横条,呼吸动效运行', (tester) async {
await tester.pumpWidget(wrap(const FeedSkeleton()));
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(AspectRatio), findsOneWidget);
final fade = tester.widget<FadeTransition>(
find
.descendant(
of: find.byType(FeedSkeleton),
matching: find.byType(FadeTransition),
)
.first,
);
final controller = fade.opacity as AnimationController;
expect(controller.isAnimating, isTrue);
});
testWidgets('系统减弱动态效果:动画静止在 1.0(pumpAndSettle 可收敛)', (tester) async {
await tester.pumpWidget(
wrap(const FeedSkeleton(), disableAnimations: true),
);
await tester.pumpAndSettle();
final fade = tester.widget<FadeTransition>(
find
.descendant(
of: find.byType(FeedSkeleton),
matching: find.byType(FadeTransition),
)
.first,
);
expect(fade.opacity.value, 1.0);
});
});
group('PostMediaEditGrid 编辑态(05 §3.2', () {
MediaUploadItem item({
int localId = 1,
MediaItemPhase phase = MediaItemPhase.ready,
double progress = 0,
bool retryable = false,
}) => MediaUploadItem(
localId: localId,
phase: phase,
previewBytes: tinyPngBytes,
progress: progress,
assetId: phase == MediaItemPhase.ready ? 'a-$localId' : null,
retryable: retryable,
);
testWidgets('空列表只渲染「+」格;3 张图渲染 3 格 + 「+」', (tester) async {
await tester.pumpWidget(wrap(const PostMediaEditGrid(items: [])));
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsOneWidget);
expect(find.byType(Image), findsNothing);
await tester.pumpWidget(
wrap(
PostMediaEditGrid(
items: [item(localId: 1), item(localId: 2), item(localId: 3)],
),
),
);
await tester.pump();
expect(find.byType(Image), findsNWidgets(3));
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsOneWidget);
});
testWidgets('满 9 张(canAdd=false)隐藏「+」格', (tester) async {
await tester.pumpWidget(
wrap(
PostMediaEditGrid(
items: List.generate(9, (i) => item(localId: i + 1)),
canAdd: false,
),
),
);
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsNothing);
expect(find.byType(Image), findsNWidgets(9));
});
testWidgets('删除角标回传 localId;「+」格回调', (tester) async {
final removed = <int>[];
var addTaps = 0;
await tester.pumpWidget(
wrap(
PostMediaEditGrid(
items: [item(localId: 7), item(localId: 8)],
onAdd: () => addTaps++,
onRemove: removed.add,
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.close).first);
expect(removed, [7]);
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
expect(addTaps, 1);
});
testWidgets('上传中格叠进度覆盖层;可重试失败格整格点按重试,终态不响应', (tester) async {
final retried = <int>[];
await tester.pumpWidget(
wrap(
PostMediaEditGrid(
items: [
item(localId: 1, phase: MediaItemPhase.uploading, progress: 0.42),
item(localId: 2, phase: MediaItemPhase.failed, retryable: true),
item(localId: 3, phase: MediaItemPhase.failed),
],
onRetry: retried.add,
),
),
);
await tester.pump();
expect(find.byType(UploadProgressOverlay), findsNWidgets(3));
expect(find.text('42%'), findsOneWidget);
// 可重试格有「重试」通栏,终态格没有。
expect(find.text('重试'), findsOneWidget);
await tester.tap(find.text('重试'));
expect(retried, [2]);
});
});
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
void main() {
testWidgets('全类型映射齐备:图标 + 双色(05 §2 表 + T2-14 增补三型)', (tester) async {
// 映射表唯一出口:各类型均有图标、底色、图标色、文字色、文案。
expect(recordTypeStyles.length, RecordType.values.length);
expect(
recordTypeStyles[RecordType.weight]!.iconColor,
AppColors.primaryStrong,
);
expect(
recordTypeStyles[RecordType.weight]!.inkColor,
AppColors.primaryDark,
);
expect(
recordTypeStyles[RecordType.vaccine]!.inkColor,
AppColors.successInk,
);
expect(
recordTypeStyles[RecordType.deworming]!.inkColor,
AppColors.accentDark,
);
expect(recordTypeStyles[RecordType.medical]!.inkColor, AppColors.errorDark);
expect(recordTypeStyles[RecordType.other]!.inkColor, AppColors.inkSoft);
expect(recordTypeStyles[RecordType.medical]!.label, '就医');
// T2-14 增补三型:色族复用已审计色对,仅图标/文案区分。
expect(
recordTypeStyles[RecordType.feeding]!.inkColor,
AppColors.successInk,
);
expect(recordTypeStyles[RecordType.feeding]!.label, '喂养');
expect(
recordTypeStyles[RecordType.grooming]!.inkColor,
AppColors.accentDark,
);
expect(recordTypeStyles[RecordType.grooming]!.label, '洗护');
expect(
recordTypeStyles[RecordType.measurement]!.inkColor,
AppColors.primaryDark,
);
expect(recordTypeStyles[RecordType.measurement]!.label, '测量');
// 三型图标彼此不同(与既有五型也不重复),保证图标通道可辨。
final icons = {for (final style in recordTypeStyles.values) style.icon};
expect(icons.length, RecordType.values.length);
});
testWidgets('圆标渲染:尺寸档正确、图标为 dot 的 50%、底为基色 8% 淡染', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Center(
child: RecordTypeDot(
type: RecordType.vaccine,
size: RecordTypeDotSize.md,
),
),
),
),
);
final size = tester.getSize(find.byType(RecordTypeDot));
expect(size.width, 40);
expect(size.height, 40);
final icon = tester.widget<Icon>(find.byIcon(Icons.vaccines_outlined));
expect(icon.size, 20);
expect(icon.color, AppColors.successInk);
final container = tester.widget<Container>(
find.descendant(
of: find.byType(RecordTypeDot),
matching: find.byType(Container),
),
);
final decoration = container.decoration as BoxDecoration;
expect(decoration.color, AppColors.success.withAlpha(20));
expect(decoration.shape, BoxShape.circle);
});
testWidgets('三档尺寸 24/40/56', (tester) async {
expect(RecordTypeDotSize.sm.dimension, 24);
expect(RecordTypeDotSize.md.dimension, 40);
expect(RecordTypeDotSize.lg.dimension, 56);
});
}
@@ -0,0 +1,372 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
late FakeCommunityRepository repo;
late CommunityController controller;
setUp(() {
repo = FakeCommunityRepository();
controller = CommunityController(repository: repo);
});
tearDown(() => controller.dispose());
List<String> feedIds() => controller.feed.map((card) => card.id).toList();
group('Feed 四态与多页缓存', () {
test('首屏成功:initial → loading → ready,页数据落位', () async {
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
expect(controller.phase, FeedPhase.initial);
final pending = controller.refresh();
expect(controller.phase, FeedPhase.loading);
await pending;
expect(controller.phase, FeedPhase.ready);
expect(feedIds(), ['p-1']);
expect(controller.hasMore, isTrue);
expect(controller.isEmpty, isFalse);
});
test('首屏失败:error 态供 retry;重试成功恢复 ready', () async {
var fail = true;
repo.onFeed = (limit, cursor) async {
if (fail) throw const ApiNetworkException('断网');
return feedPage([sampleFeedCard()]);
};
await controller.refresh();
expect(controller.phase, FeedPhase.error);
expect(controller.lastError, isA<ApiNetworkException>());
expect(controller.feed, isEmpty);
fail = false;
await controller.refresh();
expect(controller.phase, FeedPhase.ready);
expect(controller.lastError, isNull);
expect(feedIds(), ['p-1']);
});
test('刷新失败保留旧列表:不清空不闪空态,错误走 refreshError', () async {
var fail = false;
repo.onFeed = (limit, cursor) async {
if (fail) throw const ApiNetworkException('超时');
return feedPage([sampleFeedCard()]);
};
await controller.refresh();
fail = true;
await controller.refresh();
expect(controller.phase, FeedPhase.ready);
expect(feedIds(), ['p-1']);
expect(controller.refreshError, isA<ApiNetworkException>());
expect(controller.lastError, isNull);
});
test('ready 且列表为空 → 空态', () async {
repo.onFeed = (limit, cursor) async => feedPage(const []);
await controller.refresh();
expect(controller.isEmpty, isTrue);
expect(controller.hasMore, isFalse);
});
});
group('加载更多与游标拼接', () {
test('loadMore 携带上一页 nextCursor,追加不替换;到底后不再发', () async {
repo.onFeed = (limit, cursor) async => switch (cursor) {
null => feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true),
'c1' => feedPage([sampleFeedCard(id: 'p-2')]),
_ => fail('意外游标:$cursor'),
};
await controller.refresh();
await controller.loadMore();
expect(repo.calls, ['feed:cursor=null', 'feed:cursor=c1']);
expect(feedIds(), ['p-1', 'p-2']);
expect(controller.hasMore, isFalse);
expect(controller.loadMorePhase, LoadMorePhase.idle);
// hasMore=false:不再发请求。
await controller.loadMore();
expect(repo.calls, hasLength(2));
});
test('loadMore 失败:error 态保留列表,重试成功恢复', () async {
var fail = true;
repo.onFeed = (limit, cursor) async {
if (cursor == null) {
return feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
}
if (fail) throw const ApiNetworkException('超时');
return feedPage([sampleFeedCard(id: 'p-2')]);
};
await controller.refresh();
await controller.loadMore();
expect(controller.loadMorePhase, LoadMorePhase.error);
expect(controller.loadMoreError, isA<ApiNetworkException>());
expect(feedIds(), ['p-1']);
fail = false;
await controller.loadMore();
expect(controller.loadMorePhase, LoadMorePhase.idle);
expect(feedIds(), ['p-1', 'p-2']);
});
test('在途 loadMore 与刷新竞态:旧代次尾页丢弃,不重复不错位', () async {
final tail = Completer<CursorPage<FeedCard>>();
repo.onFeed = (limit, cursor) async {
if (cursor == null) {
return feedPage(
[sampleFeedCard(id: 'p-fresh')],
nextCursor: 'c1',
hasMore: true,
);
}
return tail.future;
};
await controller.refresh();
final pending = controller.loadMore(); // 在途旧代次尾页。
await controller.refresh(); // 期间刷新:整体替换 + 代次 +1。
tail.complete(feedPage([sampleFeedCard(id: 'p-stale')]));
await pending;
expect(feedIds(), ['p-fresh']);
expect(controller.hasMore, isTrue); // 保持新代次首页的分页状态。
});
test('loading 中重复 loadMore 只发一请求(单飞)', () async {
final tail = Completer<CursorPage<FeedCard>>();
repo.onFeed = (limit, cursor) async => cursor == null
? feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true)
: tail.future;
await controller.refresh();
final first = controller.loadMore();
final second = controller.loadMore(); // loading 中:直接返回。
tail.complete(feedPage([sampleFeedCard(id: 'p-2')]));
await first;
await second;
expect(repo.calls.where((c) => c == 'feed:cursor=c1'), hasLength(1));
expect(feedIds(), ['p-1', 'p-2']);
});
});
group('ToggleSync 乐观更新(点赞 / 收藏同构)', () {
Future<void> loadOneCard({bool liked = false, int likeCount = 6}) async {
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard(likedByMe: liked, likeCount: likeCount)]);
await controller.refresh();
}
test('成功:同帧乐观翻转,权威计数覆盖乐观计数', () async {
await loadOneCard();
repo.onLikeToggle = (id, target) async =>
const LikeState(liked: true, likeCount: 10); // 吸收他人并发 +3。
controller.toggleLike('p-1');
// 同帧反馈:乐观 +1。
expect(controller.feed.single.likedByMe, isTrue);
expect(controller.feed.single.likeCount, 7);
await pumpEventQueue();
// 权威终态覆盖。
expect(controller.feed.single.likedByMe, isTrue);
expect(controller.feed.single.likeCount, 10);
expect(controller.toggleError, isNull);
});
test('失败:恢复链起点快照,错误走 toggleError 轻提示', () async {
await loadOneCard();
repo.onLikeToggle = (id, target) async =>
throw const ApiNetworkException('断网');
controller.toggleLike('p-1');
expect(controller.feed.single.likedByMe, isTrue);
await pumpEventQueue();
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 6);
expect(controller.toggleError, isA<ApiNetworkException>());
});
test('在途连点单飞:只发一请求,完成后按最终意图补发一次', () async {
await loadOneCard();
final completers = <Completer<LikeState>>[];
repo.onLikeToggle = (id, target) {
final completer = Completer<LikeState>();
completers.add(completer);
return completer.future;
};
controller.toggleLike('p-1'); // → true,在途。
controller.toggleLike('p-1'); // → false,只记意图不发请求。
expect(repo.calls.where((c) => c.startsWith('like')), hasLength(1));
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 6);
completers[0].complete(const LikeState(liked: true, likeCount: 7));
await pumpEventQueue();
// 确认态 true ≠ 最终意图 false → 补发 DELETE。
expect(repo.calls, contains('unlike:p-1'));
completers[1].complete(const LikeState(liked: false, likeCount: 6));
await pumpEventQueue();
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 6);
// 全程恰两个请求(中间抖动被合并)。
expect(
repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')),
hasLength(2),
);
});
test('补发目标与终态一致不再发:连点偶数次收敛回原意图', () async {
await loadOneCard();
final completers = <Completer<LikeState>>[];
repo.onLikeToggle = (id, target) {
final completer = Completer<LikeState>();
completers.add(completer);
return completer.future;
};
controller.toggleLike('p-1'); // → true,在途。
controller.toggleLike('p-1'); // → false。
controller.toggleLike('p-1'); // → true(最终意图与在途目标一致)。
completers.single.complete(const LikeState(liked: true, likeCount: 20));
await pumpEventQueue();
// 意图 == 确认态:不补发,权威计数覆盖。
expect(
repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')),
hasLength(1),
);
expect(controller.feed.single.likedByMe, isTrue);
expect(controller.feed.single.likeCount, 20);
});
test('代次守卫:刷新后到达的旧成功响应丢弃,不覆盖新数据', () async {
await loadOneCard();
final inFlight = Completer<LikeState>();
repo.onLikeToggle = (id, target) => inFlight.future;
controller.toggleLike('p-1');
// 期间刷新:列表被服务端数据整体替换(liked=false count=0)。
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard(likeCount: 0)]);
await controller.refresh();
inFlight.complete(const LikeState(liked: true, likeCount: 99));
await pumpEventQueue();
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 0);
});
test('代次守卫:刷新后到达的旧失败响应不回滚不提示', () async {
await loadOneCard();
final inFlight = Completer<LikeState>();
repo.onLikeToggle = (id, target) => inFlight.future;
controller.toggleLike('p-1');
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard(likeCount: 0)]);
await controller.refresh();
inFlight.completeError(const ApiNetworkException('超时'));
await pumpEventQueue();
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 0);
expect(controller.toggleError, isNull);
});
test('收藏同构:乐观翻转 + 权威终态覆盖', () async {
await loadOneCard();
repo.onBookmarkToggle = (id, target) async =>
const BookmarkState(bookmarked: true, bookmarkCount: 5);
controller.toggleBookmark('p-1');
expect(controller.feed.single.bookmarkedByMe, isTrue);
expect(controller.feed.single.bookmarkCount, 3);
await pumpEventQueue();
expect(controller.feed.single.bookmarkCount, 5);
});
test('对不在列表的 postId 点击作废:不发请求不崩溃', () async {
await loadOneCard();
controller.toggleLike('p-nonexistent');
await pumpEventQueue();
expect(repo.calls.where((c) => c.startsWith('like')), isEmpty);
});
});
group('详情副本与登出清态', () {
test('getPost:详情入缓存并回写 Feed 卡片互动字段', () async {
repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]);
await controller.refresh();
repo.onGetPost = (id) async =>
Post.fromJson(samplePostJson(likedByMe: true, likeCount: 42));
final post = await controller.getPost('p-1');
expect(post.likeCount, 42);
expect(controller.cachedPost('p-1')!.likedByMe, isTrue);
expect(controller.feed.single.likedByMe, isTrue);
expect(controller.feed.single.likeCount, 42);
});
test('详情副本存在时 toggle 同步详情与卡片两份内存', () async {
repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]);
await controller.refresh();
repo.onGetPost = (id) async => Post.fromJson(samplePostJson());
await controller.getPost('p-1');
repo.onLikeToggle = (id, target) async =>
const LikeState(liked: true, likeCount: 7);
controller.toggleLike('p-1');
expect(controller.cachedPost('p-1')!.likedByMe, isTrue);
expect(controller.feed.single.likedByMe, isTrue);
await pumpEventQueue();
expect(controller.cachedPost('p-1')!.likeCount, 7);
expect(controller.feed.single.likeCount, 7);
});
test('reset:清列表 / 游标 / 详情副本回 initial,在途响应作废', () async {
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
await controller.refresh();
final inFlight = Completer<LikeState>();
repo.onLikeToggle = (id, target) => inFlight.future;
controller.toggleLike('p-1');
controller.reset();
expect(controller.phase, FeedPhase.initial);
expect(controller.feed, isEmpty);
expect(controller.hasMore, isFalse);
expect(controller.cachedPost('p-1'), isNull);
expect(controller.toggleError, isNull);
// 登出后到达的在途响应作废,不写入任何状态。
inFlight.complete(const LikeState(liked: true, likeCount: 7));
await pumpEventQueue();
expect(controller.feed, isEmpty);
});
});
}
@@ -0,0 +1,126 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
void main() {
late List<(String, Map<String, dynamic>?)> events;
late CommunityInteractionAnalytics analytics;
setUp(() {
events = [];
analytics = CommunityInteractionAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
List<String> names() => events.map((e) => e.$1).toList();
List<Map<String, dynamic>?> props() => events.map((e) => e.$2).toList();
test('互动四事件:source 逐字段(22 号白名单键集)', () {
analytics.postLiked(source: InteractionSource.feed);
analytics.postUnliked(source: InteractionSource.postDetail);
analytics.postFavorited(source: InteractionSource.postDetail);
analytics.postUnfavorited(source: InteractionSource.feed);
expect(names(), [
'post_liked',
'post_unliked',
'post_favorited',
'post_unfavorited',
]);
expect(props(), [
{'source': 'feed'},
{'source': 'post_detail'},
{'source': 'post_detail'},
{'source': 'feed'},
]);
});
test('comment_create_succeededdurationMs/isReply/textLengthBucket', () {
analytics.commentCreateSucceeded(
durationMs: 4200,
isReply: false,
textLength: 12,
);
expect(names(), ['comment_create_succeeded']);
expect(props().single, {
'durationMs': 4200,
'isReply': false,
'textLengthBucket': 'short',
});
});
test('comment_create_failedhttpStatus 由五位业务码推导,网络错误缺席', () {
analytics.commentCreateFailed(
reason: CommentCreateFailureReason.validationError,
attemptSeq: 2,
errorCode: 40000,
);
analytics.commentCreateFailed(
reason: CommentCreateFailureReason.networkError,
attemptSeq: 3,
);
expect(names(), ['comment_create_failed', 'comment_create_failed']);
expect(props(), [
{
'failureReason': 'validation_error',
'attemptSeq': 2,
'errorCode': 40000,
'httpStatus': 400,
},
{'failureReason': 'network_error', 'attemptSeq': 3},
]);
});
test('关注对:user_followed / user_unfollowed', () {
analytics.userFollowed(source: InteractionSource.postDetail);
analytics.userUnfollowed(source: InteractionSource.postDetail);
expect(names(), ['user_followed', 'user_unfollowed']);
expect(props(), [
{'source': 'post_detail'},
{'source': 'post_detail'},
]);
});
test('textLengthBucketOf 分桶边界(06 §1.3 红线:不报精确字数)', () {
expect(textLengthBucketOf(0), 'empty');
expect(textLengthBucketOf(1), 'short');
expect(textLengthBucketOf(50), 'short');
expect(textLengthBucketOf(51), 'medium');
expect(textLengthBucketOf(500), 'medium');
expect(textLengthBucketOf(501), 'long');
});
test('失败原因映射:网络归并口径 + 防枚举族归 not_found + 会话失效 null', () {
expect(
commentCreateFailureReasonOf(const ApiNetworkException()),
CommentCreateFailureReason.networkError,
);
expect(
commentCreateFailureReasonOf(const ApiRateLimitException()),
CommentCreateFailureReason.rateLimited,
);
expect(
commentCreateFailureReasonOf(
const ApiBusinessException(code: ApiCodes.paramError, message: 'x'),
),
CommentCreateFailureReason.validationError,
);
expect(
commentCreateFailureReasonOf(
const ApiBusinessException(code: ApiCodes.postNotFound, message: 'x'),
),
CommentCreateFailureReason.notFound,
);
expect(
commentCreateFailureReasonOf(
const ApiBusinessException(code: 50000, message: 'x'),
),
CommentCreateFailureReason.serverError,
);
expect(
commentCreateFailureReasonOf(const SessionExpiredException()),
isNull,
);
});
}
@@ -0,0 +1,285 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
group('响应 DTO 逐字段映射(契约 v1.3.0)', () {
test('Post:完整形态含作者 / media / 互动计数 / publishedAt', () {
final post = Post.fromJson(samplePostJson());
expect(post.id, 'p-1');
expect(post.author.userId, 'u-1');
expect(post.author.nickname, '毛毛的铲屎官');
expect(post.petId, 'pet-1');
expect(post.category, PostCategory.general);
expect(post.title, '今天的豆豆');
expect(post.content, '晒了一下午太阳。');
expect(post.status, PostStatus.published);
expect(post.visibility, PostVisibility.public);
expect(post.media.single.assetId, 'a-1');
expect(post.media.single.isCover, isTrue);
expect(post.likeCount, 6);
expect(post.commentCount, 3);
expect(post.bookmarkCount, 2);
expect(post.likedByMe, isFalse);
expect(post.bookmarkedByMe, isFalse);
expect(post.publishedAt, isNotNull);
expect(post.version, 1);
});
test('Postdraft 态 publishedAt 为 null(仅 published 非空)', () {
final post = Post.fromJson(samplePostJson(status: 'draft'));
expect(post.status, PostStatus.draft);
expect(post.publishedAt, isNull);
});
test('FeedCard:卡片裁剪形态,纯文字帖 coverImage 为 null', () {
final withCover = FeedCard.fromJson(sampleFeedCardJson());
expect(withCover.coverImage!.url, contains('X-Amz-Signature'));
expect(withCover.mediaCount, 1);
expect(withCover.contentPreview, '晒了一下午太阳。');
expect(withCover.publishedAt, DateTime.parse('2026-09-08T10:05:00.000Z'));
final textOnly = FeedCard.fromJson(
sampleFeedCardJson()
..['coverImage'] = null
..['mediaCount'] = 0,
);
expect(textOnly.coverImage, isNull);
expect(textOnly.mediaCount, 0);
});
test('categoryai_creation(读侧预留)按线上 snake_case 解析', () {
final card = FeedCard.fromJson(
sampleFeedCardJson()..['category'] = 'ai_creation',
);
expect(card.category, PostCategory.aiCreation);
expect(PostCategory.aiCreation.wire, 'ai_creation');
});
test('AuthorSummarynickname 与 avatarUrl 同为 null 即降级/墓碑形态', () {
final normal = AuthorSummary.fromJson(sampleAuthorJson());
expect(normal.isDegraded, isFalse);
final degraded = AuthorSummary.fromJson(
sampleAuthorJson(nickname: null, avatarUrl: null),
);
expect(degraded.userId, 'u-1');
expect(degraded.isDegraded, isTrue);
// 仅缺头像不算降级(无头像 / 头像非 ready 也是 null)。
final noAvatar = AuthorSummary.fromJson(
sampleAuthorJson(avatarUrl: null),
);
expect(noAvatar.isDegraded, isFalse);
});
test('PostCommentreplyToUser 非回复为 null@ 回复含降级形态', () {
final plain = PostComment.fromJson(sampleCommentJson());
expect(plain.replyToUser, isNull);
expect(plain.postId, 'p-1');
final reply = PostComment.fromJson(
sampleCommentJson(
replyToUser: sampleAuthorJson(
userId: 'u-2',
nickname: null,
avatarUrl: null,
),
),
);
expect(reply.replyToUser!.userId, 'u-2');
expect(reply.replyToUser!.isDegraded, isTrue);
});
test('MediaUploadCredentialsrequiredHeaders 原样映射为字符串表', () {
final credentials = MediaUploadCredentials.fromJson(
sampleUploadCredentialsJson(),
);
expect(credentials.assetId, 'a-1');
expect(credentials.method, 'PUT');
expect(credentials.requiredHeaders, {'Content-Type': 'image/jpeg'});
expect(credentials.expiresAt, DateTime.parse('2026-09-08T10:10:00.000Z'));
});
test('MediaAssetready 态 url/readyAt 非空,uploading 态为 null', () {
final ready = MediaAsset.fromJson(sampleMediaAssetJson());
expect(ready.status, MediaAssetStatus.ready);
expect(ready.url, isNotNull);
expect(ready.readyAt, isNotNull);
expect(ready.byteSize, 204800);
final uploading = MediaAsset.fromJson(
sampleMediaAssetJson(status: 'uploading'),
);
expect(uploading.status, MediaAssetStatus.uploading);
expect(uploading.url, isNull);
expect(uploading.readyAt, isNull);
});
test('LikeState / BookmarkState / FollowState / FollowStats 终态解析', () {
final like = LikeState.fromJson({'liked': true, 'likeCount': 7});
expect(like.liked, isTrue);
expect(like.likeCount, 7);
final bookmark = BookmarkState.fromJson({
'bookmarked': false,
'bookmarkCount': 0,
});
expect(bookmark.bookmarked, isFalse);
expect(bookmark.bookmarkCount, 0);
final follow = FollowState.fromJson({
'following': true,
'followerCount': 12,
});
expect(follow.following, isTrue);
expect(follow.followerCount, 12);
final stats = FollowStats.fromJson({
'followerCount': 12,
'followingCount': 34,
'followedByMe': false,
});
expect(stats.followingCount, 34);
expect(stats.followedByMe, isFalse);
});
test('未知枚举取值抛 FormatException(契约漂移测试期暴露)', () {
expect(
() => Post.fromJson(samplePostJson()..['status'] = 'hidden'),
throwsFormatException,
);
expect(
() => FeedCard.fromJson(sampleFeedCardJson()..['category'] = 'topic'),
throwsFormatException,
);
expect(
() => MediaAsset.fromJson(sampleMediaAssetJson(status: 'deleted')),
throwsFormatException,
);
expect(
() => Post.fromJson(samplePostJson()..['visibility'] = 'private'),
throwsFormatException,
);
});
});
group('请求 DTO 序列化', () {
test('CreatePostRequest:可选字段缺席不出现,media 按项序列化', () {
const minimal = CreatePostRequest(content: '纯文字帖');
expect(minimal.toJson(), {'content': '纯文字帖'});
const full = CreatePostRequest(
content: '正文',
title: '标题',
category: PostCategory.help,
status: PostStatus.published,
petId: 'pet-1',
media: [
PostMediaAttachRequest(assetId: 'a-1', position: 0, isCover: true),
PostMediaAttachRequest(assetId: 'a-2', position: 1, caption: '第二张'),
],
);
expect(full.toJson(), {
'content': '正文',
'title': '标题',
'category': 'help',
'status': 'published',
'petId': 'pet-1',
'media': [
{'assetId': 'a-1', 'position': 0, 'isCover': true},
{'assetId': 'a-2', 'position': 1, 'caption': '第二张'},
],
});
});
test('UpdatePostRequestmedia 三态——缺席不动 / [] 清空 / 非空整组替换', () {
const absent = UpdatePostRequest(version: 2, title: '改标题');
expect(absent.toJson(), {'version': 2, 'title': '改标题'});
expect(absent.toJson().containsKey('media'), isFalse);
const clear = UpdatePostRequest(version: 2, media: []);
expect(clear.toJson(), {'version': 2, 'media': <Object?>[]});
const replace = UpdatePostRequest(
version: 2,
media: [PostMediaAttachRequest(assetId: 'a-3')],
);
expect(replace.toJson()['media'], [
{'assetId': 'a-3'},
]);
});
test('UpdatePostRequestpublish 即 status: published(唯一开放迁移)', () {
const publish = UpdatePostRequest(version: 1, publish: true);
expect(publish.toJson(), {'version': 1, 'status': 'published'});
const noPublish = UpdatePostRequest(version: 1, content: '改正文');
expect(noPublish.toJson().containsKey('status'), isFalse);
});
test('CreateMediaUploadRequestkind/purpose 按线上取值,sha256 可选', () {
const request = CreateMediaUploadRequest(
kind: MediaKind.image,
purpose: MediaPurpose.postImage,
mimeType: 'image/jpeg',
byteSize: 204800,
);
expect(request.toJson(), {
'kind': 'image',
'purpose': 'post_image',
'mimeType': 'image/jpeg',
'byteSize': 204800,
});
const withHash = CreateMediaUploadRequest(
kind: MediaKind.image,
purpose: MediaPurpose.postImage,
mimeType: 'image/webp',
byteSize: 1,
sha256:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
);
expect(withHash.toJson()['sha256'], hasLength(64));
});
test('CreateCommentRequestreplyToUserId 缺席不出现', () {
expect(const CreateCommentRequest(content: '好可爱!').toJson(), {
'content': '好可爱!',
});
expect(
const CreateCommentRequest(
content: '@回复',
replyToUserId: 'u-2',
).toJson(),
{'content': '@回复', 'replyToUserId': 'u-2'},
);
});
});
group('互动字段副本更新', () {
test('Post.copyWithInteraction 只动互动字段', () {
final post = Post.fromJson(samplePostJson());
final updated = post.copyWithInteraction(likedByMe: true, likeCount: 7);
expect(updated.likedByMe, isTrue);
expect(updated.likeCount, 7);
expect(updated.bookmarkCount, post.bookmarkCount);
expect(updated.content, post.content);
expect(updated.version, post.version);
});
test('FeedCard.copyWithInteraction 只动互动字段', () {
final card = sampleFeedCard();
final updated = card.copyWithInteraction(
bookmarkedByMe: true,
bookmarkCount: 3,
);
expect(updated.bookmarkedByMe, isTrue);
expect(updated.bookmarkCount, 3);
expect(updated.likeCount, card.likeCount);
expect(updated.contentPreview, card.contentPreview);
});
});
}
@@ -0,0 +1,455 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
late SessionManager session;
late FakeHttpAdapter adapter;
late FakeHttpAdapter mediaAdapter;
late ApiCommunityRepository repository;
Future<void> setUpWith(
Future<ResponseBody> Function(RequestOptions) handler,
) async {
session = SessionManager(store: InMemoryTokenStore());
await session.updateTokens(sampleTokens(access: 'community-access'));
final dio = buildPatbondDio(
session: session,
baseUrl: 'http://community.local',
);
adapter = FakeHttpAdapter(handler);
dio.httpClientAdapter = adapter;
final refresher = TokenRefresher(dio: dio, session: session);
// media 两步上传端点由 user 服务提供(13 号报告 §2),走独立客户端;
// 两 adapter 分开记录以断言线路不串。
final mediaDio = buildPatbondDio(
session: session,
baseUrl: 'http://user.local',
);
mediaAdapter = FakeHttpAdapter(handler);
mediaDio.httpClientAdapter = mediaAdapter;
repository = ApiCommunityRepository(
api: ApiClient(dio: dio, session: session, refresher: refresher),
mediaApi: ApiClient(
dio: mediaDio,
session: session,
refresher: refresher,
),
);
}
group('请求线路(路径 / 方法 / 鉴权 / 参数)', () {
test(
'createMediaUploadPOST /api/v1/media/uploads 走 user 服务客户端,携带 Bearer',
() async {
await setUpWith(
(options) async =>
jsonResponse(201, okEnvelope(sampleUploadCredentialsJson())),
);
final credentials = await repository.createMediaUpload(
const CreateMediaUploadRequest(
kind: MediaKind.image,
purpose: MediaPurpose.postImage,
mimeType: 'image/jpeg',
byteSize: 204800,
),
);
// 线路:media 端点不打 community 客户端。
expect(adapter.requests, isEmpty);
final request = mediaAdapter.requests.single;
expect(request.baseUrl, 'http://user.local');
expect(request.path, '/api/v1/media/uploads');
expect(request.method, 'POST');
expect(request.headers['Authorization'], 'Bearer community-access');
expect(request.data, {
'kind': 'image',
'purpose': 'post_image',
'mimeType': 'image/jpeg',
'byteSize': 204800,
});
expect(credentials.uploadUrl, contains('X-Amz-Signature'));
},
);
test('completeMediaUploadPOST /complete 无请求体(服务端幂等)', () async {
await setUpWith(
(options) async =>
jsonResponse(200, okEnvelope(sampleMediaAssetJson())),
);
final asset = await repository.completeMediaUpload('a-1');
expect(adapter.requests, isEmpty);
final request = mediaAdapter.requests.single;
expect(request.path, '/api/v1/media/uploads/a-1/complete');
expect(request.method, 'POST');
expect(asset.status, MediaAssetStatus.ready);
});
test('帖子 CRUDPOST / GET / PATCH / DELETE 线路', () async {
var call = 0;
await setUpWith((options) async {
call += 1;
return switch (call) {
1 => jsonResponse(201, okEnvelope(samplePostJson())),
2 => jsonResponse(200, okEnvelope(samplePostJson())),
3 => jsonResponse(200, okEnvelope(samplePostJson(version: 2))),
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
};
});
await repository.createPost(const CreatePostRequest(content: '正文'));
await repository.getPost('p-1');
final updated = await repository.updatePost(
'p-1',
const UpdatePostRequest(version: 1, publish: true),
);
await repository.deletePost('p-1');
expect(adapter.requests[0].path, '/api/v1/posts');
expect(adapter.requests[0].method, 'POST');
expect(adapter.requests[1].path, '/api/v1/posts/p-1');
expect(adapter.requests[1].method, 'GET');
expect(adapter.requests[2].method, 'PATCH');
expect(adapter.requests[2].data, {'version': 1, 'status': 'published'});
expect(adapter.requests[3].method, 'DELETE');
expect(updated.version, 2);
});
test('listMyPosts/api/v1/me/posts 分页 + status 过滤,缺省不传', () async {
await setUpWith(
(options) async => jsonResponse(
200,
okEnvelope(
cursorPageJson([samplePostJson()], nextCursor: 'c2', hasMore: true),
),
),
);
final page = await repository.listMyPosts(
limit: 20,
cursor: 'c1',
status: PostStatus.draft,
);
await repository.listMyPosts();
expect(adapter.requests[0].path, '/api/v1/me/posts');
expect(adapter.requests[0].queryParameters, {
'limit': 20,
'cursor': 'c1',
'status': 'draft',
});
expect(adapter.requests[1].queryParameters, isEmpty);
expect(page.items.single.id, 'p-1');
expect(page.nextCursor, 'c2');
expect(page.hasMore, isTrue);
});
test('getFeed/api/v1/feed 游标分页与 FeedCard 信封解析', () async {
await setUpWith(
(options) async => jsonResponse(
200,
okEnvelope(
cursorPageJson(
[sampleFeedCardJson()],
nextCursor: 'f2',
hasMore: true,
),
),
),
);
final page = await repository.getFeed(limit: 20, cursor: 'f1');
final request = adapter.requests.single;
expect(request.path, '/api/v1/feed');
expect(request.queryParameters, {'limit': 20, 'cursor': 'f1'});
expect(page.items.single.contentPreview, '晒了一下午太阳。');
expect(page.nextCursor, 'f2');
});
test('评论:列表分页、创建、顶层短路径删除', () async {
var call = 0;
await setUpWith((options) async {
call += 1;
return switch (call) {
1 => jsonResponse(
200,
okEnvelope(cursorPageJson([sampleCommentJson()])),
),
2 => jsonResponse(201, okEnvelope(sampleCommentJson())),
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
};
});
final page = await repository.listComments('p-1', limit: 20);
await repository.createComment(
'p-1',
const CreateCommentRequest(content: '好可爱!'),
);
await repository.deleteComment('c-1');
expect(adapter.requests[0].path, '/api/v1/posts/p-1/comments');
expect(adapter.requests[0].queryParameters, {'limit': 20});
expect(adapter.requests[1].method, 'POST');
expect(adapter.requests[1].data, {'content': '好可爱!'});
expect(adapter.requests[2].path, '/api/v1/comments/c-1');
expect(adapter.requests[2].method, 'DELETE');
expect(page.items.single.content, '好可爱!');
expect(page.hasMore, isFalse);
});
test('点赞/收藏:PUT 与 DELETE 同路径,返回权威终态', () async {
var call = 0;
await setUpWith((options) async {
call += 1;
return switch (call) {
1 => jsonResponse(200, okEnvelope({'liked': true, 'likeCount': 7})),
2 => jsonResponse(200, okEnvelope({'liked': false, 'likeCount': 6})),
3 => jsonResponse(
200,
okEnvelope({'bookmarked': true, 'bookmarkCount': 3}),
),
_ => jsonResponse(
200,
okEnvelope({'bookmarked': false, 'bookmarkCount': 2}),
),
};
});
final liked = await repository.likePost('p-1');
final unliked = await repository.unlikePost('p-1');
final bookmarked = await repository.bookmarkPost('p-1');
await repository.unbookmarkPost('p-1');
expect(adapter.requests[0].path, '/api/v1/posts/p-1/like');
expect(adapter.requests[0].method, 'PUT');
expect(adapter.requests[1].path, '/api/v1/posts/p-1/like');
expect(adapter.requests[1].method, 'DELETE');
expect(adapter.requests[2].path, '/api/v1/posts/p-1/bookmark');
expect(adapter.requests[2].method, 'PUT');
expect(adapter.requests[3].method, 'DELETE');
// PUT/DELETE 语义幂等:无 Idempotency-Key。
for (final request in adapter.requests) {
expect(request.headers.containsKey('Idempotency-Key'), isFalse);
}
expect(liked.liked, isTrue);
expect(liked.likeCount, 7);
expect(unliked.liked, isFalse);
expect(bookmarked.bookmarkCount, 3);
});
test('listMyBookmarks/api/v1/me/bookmarks,项形态 = FeedCard', () async {
await setUpWith(
(options) async => jsonResponse(
200,
okEnvelope(cursorPageJson([sampleFeedCardJson()])),
),
);
final page = await repository.listMyBookmarks(cursor: 'b1');
expect(adapter.requests.single.path, '/api/v1/me/bookmarks');
expect(adapter.requests.single.queryParameters, {'cursor': 'b1'});
expect(page.items.single.id, 'p-1');
});
test('关注:PUT / DELETE / follow-stats 线路与终态', () async {
var call = 0;
await setUpWith((options) async {
call += 1;
return switch (call) {
1 => jsonResponse(
200,
okEnvelope({'following': true, 'followerCount': 12}),
),
2 => jsonResponse(
200,
okEnvelope({'following': false, 'followerCount': 11}),
),
_ => jsonResponse(
200,
okEnvelope({
'followerCount': 11,
'followingCount': 34,
'followedByMe': false,
}),
),
};
});
final followed = await repository.followUser('u-2');
final unfollowed = await repository.unfollowUser('u-2');
final stats = await repository.getFollowStats('u-2');
expect(adapter.requests[0].path, '/api/v1/users/u-2/follow');
expect(adapter.requests[0].method, 'PUT');
expect(adapter.requests[1].method, 'DELETE');
expect(adapter.requests[2].path, '/api/v1/users/u-2/follow-stats');
expect(adapter.requests[2].method, 'GET');
expect(followed.following, isTrue);
expect(unfollowed.followerCount, 11);
expect(stats.followingCount, 34);
});
});
group('Idempotency-Keycommunity 域必带语义)', () {
test('createPost / createComment 必带键,每次逻辑提交换新键', () async {
var call = 0;
await setUpWith((options) async {
call += 1;
return call <= 2
? jsonResponse(201, okEnvelope(samplePostJson()))
: jsonResponse(201, okEnvelope(sampleCommentJson()));
});
await repository.createPost(const CreatePostRequest(content: ''));
await repository.createPost(const CreatePostRequest(content: ''));
await repository.createComment(
'p-1',
const CreateCommentRequest(content: ''),
);
final keys = adapter.requests
.map((r) => r.headers['Idempotency-Key'] as String?)
.toList();
expect(keys, everyElement(isNotNull));
expect(keys, everyElement(isNotEmpty));
// 每次逻辑提交换新键(契约:1~128 字符,建议 UUID)。
expect(keys.toSet().length, 3);
expect(keys.every((key) => key!.length <= 128), isTrue);
});
test('GET / PATCH / DELETE 不带 Idempotency-Key', () async {
var call = 0;
await setUpWith((options) async {
call += 1;
return switch (call) {
1 => jsonResponse(200, okEnvelope(samplePostJson())),
2 => jsonResponse(200, okEnvelope(samplePostJson(version: 2))),
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
};
});
await repository.getPost('p-1');
await repository.updatePost('p-1', const UpdatePostRequest(version: 1));
await repository.deletePost('p-1');
for (final request in adapter.requests) {
expect(request.headers.containsKey('Idempotency-Key'), isFalse);
}
});
test('40101:单飞刷新后重放,重放沿用同一 Idempotency-Key', () async {
await setUpWith((options) async {
if (options.path == '/api/v1/auth/refresh') {
return jsonResponse(
200,
okEnvelope(tokenDataJson(access: 'new-access')),
);
}
if (options.headers['Authorization'] == 'Bearer community-access') {
return jsonResponse(401, errorEnvelope(40101, 'token 过期'));
}
return jsonResponse(201, okEnvelope(samplePostJson()));
});
await repository.createPost(const CreatePostRequest(content: '正文'));
final postRequests = adapter.requests
.where((r) => r.path == '/api/v1/posts')
.toList();
expect(postRequests, hasLength(2));
expect(postRequests.last.headers['Authorization'], 'Bearer new-access');
// 刷新重放是同一逻辑提交:同键命中服务端首次结果,不重复建帖。
expect(
postRequests.first.headers['Idempotency-Key'],
postRequests.last.headers['Idempotency-Key'],
);
});
});
group('错误码 → 类型化异常映射(v1.3.0 新增 9 码 + 40902', () {
Future<void> expectMapped(int httpStatus, int code, Matcher matcher) async {
await setUpWith(
(options) async => jsonResponse(httpStatus, errorEnvelope(code)),
);
await expectLater(repository.getPost('p-x'), throwsA(matcher));
}
test('40301 → PostAccessDeniedException', () async {
await expectMapped(403, 40301, isA<PostAccessDeniedException>());
});
test('40403 → PostNotFoundException(防枚举合并)', () async {
await expectMapped(404, 40403, isA<PostNotFoundException>());
});
test('40404 → CommentNotFoundException', () async {
await expectMapped(404, 40404, isA<CommentNotFoundException>());
});
test('40405 → MediaAssetNotFoundException', () async {
await expectMapped(404, 40405, isA<MediaAssetNotFoundException>());
});
test('40406 → CommunityUserNotFoundException', () async {
await expectMapped(404, 40406, isA<CommunityUserNotFoundException>());
});
test('40902 → PostVersionConflictException(共码独立类型)', () async {
await expectMapped(409, 40902, isA<PostVersionConflictException>());
});
test('40905 → IdempotencyMismatchException', () async {
await expectMapped(409, 40905, isA<IdempotencyMismatchException>());
});
test('42203 → MediaNotReadyException', () async {
await expectMapped(422, 42203, isA<MediaNotReadyException>());
});
test('42204 → SelfFollowException', () async {
await expectMapped(422, 42204, isA<SelfFollowException>());
});
test('42205 → MediaUploadStateException', () async {
await expectMapped(422, 42205, isA<MediaUploadStateException>());
});
test('40401(pets 域码)不升格,保持通用 ApiBusinessException', () async {
await setUpWith(
(options) async => jsonResponse(404, errorEnvelope(40401, '宠物不存在')),
);
await expectLater(
repository.createPost(const CreatePostRequest(content: '带宠物')),
throwsA(
isA<ApiBusinessException>()
.having((e) => e.code, 'code', 40401)
.having((e) => e, 'type', isNot(isA<PostNotFoundException>())),
),
);
});
test('类型化异常仍可按基类 ApiBusinessException 捕获', () {
const error = SelfFollowException(message: '不能关注自己');
expect(error, isA<ApiBusinessException>());
expect(error.code, ApiCodes.selfFollow);
});
});
test('community 服务基地址常量存在且默认指向 :8084', () {
expect(patbondCommunityApiBaseUrl, 'http://127.0.0.1:8084');
});
}
@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
void main() {
late List<(String, Map<String, dynamic>?)> events;
late FeedAnalytics analytics;
setUp(() {
events = [];
analytics = FeedAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
test('feed_viewed:五个专有属性逐一上报(06 §1.4 白名单)', () {
analytics.feedViewed(
feedTab: FeedTab.home,
durationMs: 12345,
impressionCount: 7,
loadMoreCount: 2,
refreshCount: 1,
);
expect(events, hasLength(1));
expect(events.single.$1, 'feed_viewed');
expect(events.single.$2, {
'feedTab': 'home',
'durationMs': 12345,
'impressionCount': 7,
'loadMoreCount': 2,
'refreshCount': 1,
});
});
test('feed_load_failed:业务码带 errorCode 并推导 httpStatus', () {
analytics.feedLoadFailed(
feedTab: FeedTab.home,
loadType: FeedLoadType.loadMore,
reason: FeedLoadFailureReason.serverError,
errorCode: 40403,
);
expect(events.single.$1, 'feed_load_failed');
expect(events.single.$2, {
'feedTab': 'home',
'loadType': 'load_more',
'failureReason': 'server_error',
'errorCode': 40403,
'httpStatus': 404,
});
});
test('feed_load_failed:网络错误缺席 errorCode/httpStatus', () {
analytics.feedLoadFailedFrom(
const ApiNetworkException(),
feedTab: FeedTab.home,
loadType: FeedLoadType.refresh,
);
expect(events.single.$2, {
'feedTab': 'home',
'loadType': 'refresh',
'failureReason': 'network_error',
});
});
test('异常映射:限流/网络/业务兜底;会话失效不上报', () {
expect(
feedLoadFailureReasonOf(const ApiRateLimitException()),
FeedLoadFailureReason.rateLimited,
);
expect(
feedLoadFailureReasonOf(const ApiNetworkException()),
FeedLoadFailureReason.networkError,
);
expect(
feedLoadFailureReasonOf(
const ApiBusinessException(code: 40000, message: 'x'),
),
FeedLoadFailureReason.serverError,
);
expect(feedLoadFailureReasonOf(const SessionExpiredException()), isNull);
analytics.feedLoadFailedFrom(
const SessionExpiredException(),
feedTab: FeedTab.home,
loadType: FeedLoadType.refresh,
);
expect(events, isEmpty);
});
}
@@ -0,0 +1,77 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/features/community/feed_exposure.dart';
void main() {
test('曝光判定:≥50% 驻留满 500ms 记一次,段内按帖去重', () {
fakeAsync((async) {
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
segment.updateVisibility('p-1', 0.8);
async.elapse(const Duration(milliseconds: 600));
expect(segment.impressionCount, 1);
//
segment.updateVisibility('p-1', 0.9);
async.elapse(const Duration(milliseconds: 600));
expect(segment.impressionCount, 1);
});
});
test('快速滑过(<500ms 跌破阈值)不计曝光', () {
fakeAsync((async) {
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
segment.updateVisibility('p-1', 0.8);
async.elapse(const Duration(milliseconds: 300));
segment.updateVisibility('p-1', 0.2); //
async.elapse(const Duration(seconds: 1));
expect(segment.impressionCount, 0);
});
});
test('可见面积不足 50% 不起计时', () {
fakeAsync((async) {
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
segment.updateVisibility('p-1', 0.49);
async.elapse(const Duration(seconds: 1));
expect(segment.impressionCount, 0);
});
});
test('结算:冻结计数、取消在途驻留、幂等只出一份', () {
fakeAsync((async) {
final clock = async.getClock(DateTime(2026));
final segment = FeedViewSegment(now: clock.now);
segment.updateVisibility('p-1', 1);
async.elapse(const Duration(milliseconds: 600));
segment.updateVisibility('p-2', 1); // 500ms
async.elapse(const Duration(milliseconds: 100));
segment.recordRefresh();
segment.recordLoadMore();
segment.recordLoadMore();
final summary = segment.settle();
expect(summary, isNotNull);
expect(summary!.impressionCount, 1);
expect(summary.refreshCount, 1);
expect(summary.loadMoreCount, 2);
expect(summary.durationMs, 700);
//
expect(segment.settle(), isNull);
segment.updateVisibility('p-3', 1);
async.elapse(const Duration(seconds: 1));
expect(segment.impressionCount, 1);
});
});
test('durationMs 上限截断 30 分钟(防挂机污染 H8)', () {
fakeAsync((async) {
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
async.elapse(const Duration(minutes: 45));
expect(
segment.settle()!.durationMs,
const Duration(minutes: 30).inMilliseconds,
);
});
});
}
@@ -0,0 +1,112 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
/// T3-13 HttpServer MinIO
/// 200 403 线
void main() {
Future<HttpServer> startServer(
Future<void> Function(HttpRequest request) handler,
) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) async => handler(request));
return server;
}
test('200PUT 本体逐字节到达,requiredHeaders 原样携带,无鉴权头泄漏', () async {
late Map<String, String?> seenHeaders;
late List<int> seenBody;
late String seenMethod;
final server = await startServer((request) async {
seenMethod = request.method;
seenHeaders = {
'content-type': request.headers.value('content-type'),
'authorization': request.headers.value('authorization'),
'x-device-id': request.headers.value('x-device-id'),
};
seenBody = await request.fold<List<int>>(
[],
(all, chunk) => all..addAll(chunk),
);
request.response.statusCode = 200;
await request.response.close();
});
addTearDown(() => server.close(force: true));
final bytes = Uint8List.fromList(List.generate(4096, (i) => i % 251));
final progress = <(int, int)>[];
await DioMediaDirectUploadClient().put(
url:
'http://127.0.0.1:${server.port}/patbond-media/post_image/a-1?X-Amz-Signature=sig',
headers: const {'Content-Type': 'image/jpeg'},
bytes: bytes,
onProgress: (sent, total) => progress.add((sent, total)),
);
expect(seenMethod, 'PUT');
expect(seenBody, bytes);
// Content-Type
expect(seenHeaders['content-type'], 'image/jpeg');
// URL Bearer/
expect(seenHeaders['authorization'], isNull);
expect(seenHeaders['x-device-id'], isNull);
expect(progress.last.$1, progress.last.$2);
expect(progress.last.$2, bytes.length);
});
test('403(签名过期/被改动)→ isCredentialRejected 异常', () async {
final server = await startServer((request) async {
await request.drain<void>();
request.response.statusCode = 403;
await request.response.close();
});
addTearDown(() => server.close(force: true));
await expectLater(
DioMediaDirectUploadClient().put(
url: 'http://127.0.0.1:${server.port}/patbond-media/a-1',
headers: const {'Content-Type': 'image/jpeg'},
bytes: Uint8List(16),
),
throwsA(
isA<MediaDirectUploadException>()
.having((e) => e.statusCode, 'statusCode', 403)
.having(
(e) => e.isCredentialRejected,
'isCredentialRejected',
true,
),
),
);
});
test('传输中断(服务端半途断连)→ 网络型异常(statusCode null,可重试)', () async {
final server = await startServer((request) async {
// socket
await request.take(1).drain<void>();
final socket = await request.response.detachSocket(writeHeaders: false);
await socket.close();
socket.destroy();
});
addTearDown(() => server.close(force: true));
await expectLater(
DioMediaDirectUploadClient().put(
url: 'http://127.0.0.1:${server.port}/patbond-media/a-1',
headers: const {'Content-Type': 'image/jpeg'},
bytes: Uint8List.fromList(List.filled(1 << 20, 7)),
),
throwsA(
isA<MediaDirectUploadException>()
.having((e) => e.statusCode, 'statusCode', isNull)
.having(
(e) => e.isCredentialRejected,
'isCredentialRejected',
false,
),
),
);
});
}
@@ -0,0 +1,618 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
/// MediaUploader T3-13
/// createUploadconfirmready
/// / 403 / 42205
void main() {
late FakeCommunityRepository repository;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
int uploadSeq = 0;
MediaUploader build({
int maxConcurrentUploads = 2,
int maxByteSize = 10 * 1024 * 1024,
DateTime Function()? now,
PostAnalytics? analytics,
}) {
return MediaUploader(
repository: repository,
picker: picker,
compressor: compressor,
directUpload: direct,
analytics: analytics,
maxConcurrentUploads: maxConcurrentUploads,
maxByteSize: maxByteSize,
now: now,
);
}
setUp(() {
repository = FakeCommunityRepository();
picker = FakeMediaImagePicker([]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
uploadSeq = 0;
// confirm id ready asset
repository.onCreateMediaUpload = (request) async =>
credentials(assetId: 'a-${++uploadSeq}');
repository.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
});
group('happy path', () {
test('单图全程:阶段序列 + 进度 + ready assetId 交付', () async {
direct.manual = true;
final uploader = build();
final phases = <MediaItemPhase>[];
uploader.addListener(() {
if (uploader.items.isNotEmpty) phases.add(uploader.items.single.phase);
});
uploader.addImages([pickedImage()]);
await pumpEventQueue();
final call = direct.calls.single;
expect(uploader.items.single.phase, MediaItemPhase.uploading);
call.emitProgress(512, 1024);
expect(uploader.items.single.progress, 0.5);
// assetId
expect(uploader.items.single.assetId, isNull);
call.succeed();
await pumpEventQueue();
final item = uploader.items.single;
expect(item.phase, MediaItemPhase.ready);
expect(item.assetId, 'a-1');
expect(item.progress, 1);
expect(uploader.allReady, isTrue);
expect(phases.first, MediaItemPhase.queued);
expect(
phases,
containsAllInOrder([
MediaItemPhase.queued,
MediaItemPhase.compressing,
MediaItemPhase.uploading,
MediaItemPhase.confirming,
MediaItemPhase.ready,
]),
);
expect(repository.calls, ['createUpload:image/jpeg:64', 'complete:a-1']);
});
test('直传按凭据原样携带 requiredHeaders 与压缩产物字节', () async {
final uploader = build();
uploader.addImages([pickedImage(seed: 7, size: 128)]);
await pumpEventQueue();
final call = direct.calls.single;
expect(call.headers, {'Content-Type': 'image/jpeg'});
expect(call.url, contains('X-Amz-Signature'));
expect(call.bytes.length, 128);
// byteSize
expect(repository.calls.first, 'createUpload:image/jpeg:128');
});
test('压缩降质阶梯:80 超限降 60,登记与直传用 60 档产物', () async {
compressor.sizePerQuality = {80: 11 * 1024 * 1024, 60: 9 * 1024 * 1024};
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(compressor.qualities, [80, 60]);
expect(uploader.items.single.phase, MediaItemPhase.ready);
expect(direct.calls.single.bytes.length, 9 * 1024 * 1024);
});
});
group('多图并发与顺序保持', () {
test('并发上限 2:第三张等槽位;完成乱序不影响 position 语义', () async {
direct.manual = true;
final uploader = build();
uploader.addImages([
pickedImage(seed: 1),
pickedImage(seed: 2),
pickedImage(seed: 3),
]);
await pumpEventQueue();
//
expect(direct.calls, hasLength(2));
expect(uploader.items[2].phase, MediaItemPhase.queued);
//
direct.calls[1].succeed();
await pumpEventQueue();
expect(direct.calls, hasLength(3));
direct.calls[0].succeed();
direct.calls[2].succeed();
await pumpEventQueue();
expect(uploader.allReady, isTrue);
final attach = uploader.buildAttachRequests();
// position 0..2isCover
expect(attach.map((a) => a.position), [0, 1, 2]);
expect(attach.map((a) => a.isCover), [true, false, false]);
// 2 a-2 index 1
expect(attach[1].assetId, 'a-2');
expect(attach[0].assetId, 'a-1');
expect(attach[2].assetId, 'a-3');
});
test('单图失败不拖垮整批:另一张照常 ready', () async {
direct.manual = true;
final uploader = build();
uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]);
await pumpEventQueue();
direct.calls[0].fail(const MediaDirectUploadException(message: '断连'));
direct.calls[1].succeed();
await pumpEventQueue();
expect(uploader.items[0].phase, MediaItemPhase.failed);
expect(uploader.items[0].retryable, isTrue);
expect(uploader.items[1].phase, MediaItemPhase.ready);
expect(uploader.hasFailure, isTrue);
expect(uploader.allReady, isFalse);
});
});
group('弱网 / 失败语义', () {
test('直传网络中断 → failed 可重试;retry 复用压缩产物、换新 asset', () async {
direct.scriptedOutcomes.add(
const MediaDirectUploadException(message: '断连'),
);
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.phase, MediaItemPhase.failed);
expect(uploader.items.single.retryable, isTrue);
uploader.retry(uploader.items.single.localId);
await pumpEventQueue();
final item = uploader.items.single;
expect(item.phase, MediaItemPhase.ready);
// createUpload a-2 a-1
expect(item.assetId, 'a-2');
//
expect(compressor.qualities, [80]);
expect(repository.calls, [
'createUpload:image/jpeg:64',
'createUpload:image/jpeg:64',
'complete:a-2',
]);
});
test('凭据过期预检:PUT 前重新 createUpload 换新凭据', () async {
final clock = DateTime.utc(2026, 9, 9, 12);
var issued = 0;
repository.onCreateMediaUpload = (request) async {
issued++;
return credentials(
assetId: 'a-$issued',
// 30
expiresAt: issued == 1
? clock.add(const Duration(seconds: 10))
: clock.add(const Duration(minutes: 10)),
);
};
final uploader = build(now: () => clock);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(issued, 2);
expect(direct.calls.single.url, contains('/a-2?'));
expect(uploader.items.single.assetId, 'a-2');
});
test('换新凭据仍过期 → failed 可重试,不再无限重取', () async {
final clock = DateTime.utc(2026, 9, 9, 12);
repository.onCreateMediaUpload = (request) async =>
credentials(expiresAt: clock);
final uploader = build(now: () => clock);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.phase, MediaItemPhase.failed);
expect(uploader.items.single.retryable, isTrue);
expect(direct.calls, isEmpty);
});
test('存储侧 403(签名过期)→ 自动换新凭据重传一次后 ready', () async {
direct.scriptedOutcomes.add(
const MediaDirectUploadException(statusCode: 403, message: '签名过期'),
);
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(direct.calls, hasLength(2));
expect(uploader.items.single.phase, MediaItemPhase.ready);
expect(uploader.items.single.assetId, 'a-2');
expect(repository.calls.last, 'complete:a-2');
});
test('换新凭据后仍 403 → failed 可重试,不无限自动重传', () async {
direct.scriptedOutcomes.addAll([
const MediaDirectUploadException(statusCode: 403, message: '签名过期'),
const MediaDirectUploadException(statusCode: 403, message: '签名过期'),
]);
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(direct.calls, hasLength(2));
expect(uploader.items.single.phase, MediaItemPhase.failed);
expect(uploader.items.single.retryable, isTrue);
});
test('confirm 42205(对象未上传/内容不符)→ failed 可重试,重试换新 asset', () async {
var confirms = 0;
repository.onCompleteMediaUpload = (assetId) async {
if (++confirms == 1) {
throw const MediaUploadStateException(message: '上传未完成');
}
return readyAsset(assetId: assetId);
};
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.phase, MediaItemPhase.failed);
expect(uploader.items.single.retryable, isTrue);
expect(uploader.items.single.assetId, isNull);
uploader.retry(uploader.items.single.localId);
await pumpEventQueue();
expect(uploader.items.single.phase, MediaItemPhase.ready);
expect(uploader.items.single.assetId, 'a-2');
});
test('confirm 返回非 ready 状态(防御)→ failed 可重试', () async {
repository.onCompleteMediaUpload = (assetId) async =>
MediaAsset.fromJson({
...readyAssetJson(assetId: assetId),
'status': 'uploading',
'url': null,
'readyAt': null,
});
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.phase, MediaItemPhase.failed);
expect(uploader.items.single.retryable, isTrue);
});
test('createUpload 40000(参数拒绝)→ failed 终态不可重试', () async {
repository.onCreateMediaUpload = (request) async {
throw const ApiBusinessException(code: 40000, message: 'mime 不允许');
};
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
final item = uploader.items.single;
expect(item.phase, MediaItemPhase.failed);
expect(item.retryable, isFalse);
uploader.retry(item.localId);
await pumpEventQueue();
// retry no-op
expect(repository.calls, hasLength(1));
});
test('压缩阶梯到底仍超限 → failed 终态,未发起任何网络调用', () async {
compressor.sizePerQuality = {80: 12 * 1024 * 1024, 60: 11 * 1024 * 1024};
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
final item = uploader.items.single;
expect(item.phase, MediaItemPhase.failed);
expect(item.retryable, isFalse);
expect(repository.calls, isEmpty);
expect(direct.calls, isEmpty);
});
test('压缩异常 → failed 可重试', () async {
compressor.error = Exception('原生编解码失败');
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.phase, MediaItemPhase.failed);
expect(uploader.items.single.retryable, isTrue);
});
});
group('孤儿防护(未 confirm 的 asset 不得被引用)', () {
test('非全员 ready 时 buildAttachRequests 抛 StateError', () async {
direct.manual = true;
final uploader = build();
expect(uploader.buildAttachRequests, throwsStateError);
uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]);
await pumpEventQueue();
// uploading
expect(uploader.buildAttachRequests, throwsStateError);
direct.calls[0].succeed();
direct.calls[1].fail(const MediaDirectUploadException(message: '断连'));
await pumpEventQueue();
// ready failed
expect(uploader.readyCount, 1);
expect(uploader.buildAttachRequests, throwsStateError);
});
test('全生命周期快照 assetId 仅 ready 态非空', () async {
direct.manual = true;
final uploader = build();
final observed = <MediaItemPhase, String?>{};
uploader.addListener(() {
for (final item in uploader.items) {
observed[item.phase] = item.assetId;
}
});
uploader.addImages([pickedImage()]);
await pumpEventQueue();
direct.calls.single.succeed();
await pumpEventQueue();
expect(observed[MediaItemPhase.ready], 'a-1');
for (final MapEntry(:key, :value) in observed.entries) {
if (key != MediaItemPhase.ready) expect(value, isNull);
}
});
test('直传在途 remove → 结果作废,不发 confirm', () async {
direct.manual = true;
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
uploader.remove(uploader.items.single.localId);
expect(uploader.isEmpty, isTrue);
direct.calls.single.succeed();
await pumpEventQueue();
// asset
expect(repository.calls.where((c) => c.startsWith('complete')), isEmpty);
});
test('reset 后在途 confirm 结果作废', () async {
direct.manual = true;
final uploader = build();
uploader.addImages([pickedImage()]);
await pumpEventQueue();
uploader.reset();
direct.calls.single.succeed();
await pumpEventQueue();
expect(uploader.isEmpty, isTrue);
expect(repository.calls.where((c) => c.startsWith('complete')), isEmpty);
});
});
group('选图与容量', () {
test('pickAndAddpicking 态、limit=剩余槽位、产物入列', () async {
picker.results.addAll([pickedImage(seed: 1), pickedImage(seed: 2)]);
picker.gate = Completer<void>();
final uploader = build();
final picking = uploader.pickAndAdd();
expect(uploader.isPicking, isTrue);
picker.gate!.complete();
await picking;
await pumpEventQueue();
expect(uploader.isPicking, isFalse);
expect(picker.limits, [9]);
expect(uploader.items, hasLength(2));
expect(uploader.allReady, isTrue);
});
test('用户取消选择(空结果)→ 恢复空闲,无副作用', () async {
final uploader = build();
await uploader.pickAndAdd();
await pumpEventQueue();
expect(uploader.isPicking, isFalse);
expect(uploader.isEmpty, isTrue);
expect(repository.calls, isEmpty);
});
test('满 9 张后 pickAndAdd no-opaddImages 超量截断', () async {
final uploader = build();
uploader.addImages(List.generate(11, (i) => pickedImage(seed: i)));
await pumpEventQueue();
expect(uploader.items, hasLength(9));
expect(uploader.remainingSlots, 0);
await uploader.pickAndAdd();
expect(picker.limits, isEmpty);
});
test('overallProgress 汇总:ready 计 1、uploading 计进度', () async {
direct.manual = true;
final uploader = build();
uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]);
await pumpEventQueue();
direct.calls[0].succeed();
await pumpEventQueue();
direct.calls[1].emitProgress(1, 2);
expect(uploader.overallProgress, closeTo(0.75, 0.001));
});
});
// ---- T3-1722 v3 27~29----
group('媒体三段埋点', () {
late List<(String, Map<String, dynamic>?)> events;
late PostAnalytics analytics;
setUp(() {
events = [];
analytics = PostAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
List<Map<String, dynamic>?> named(String name) =>
events.where((event) => event.$1 == name).map((e) => e.$2).toList();
test('成功一图:started → succeeded 各一条,sizeBucket 同值 + durationMs', () async {
var clock = DateTime.utc(2026, 9, 10, 8);
//
repository.onCreateMediaUpload = (_) async => credentials(
assetId: 'a-1',
expiresAt: clock.add(const Duration(minutes: 10)),
);
final uploader = build(analytics: analytics, now: () => clock);
direct.manual = true;
uploader.addImages([pickedImage(size: 2048)]);
await pumpEventQueue();
// started 1200ms confirm
clock = clock.add(const Duration(milliseconds: 1200));
direct.calls.single.succeed();
await pumpEventQueue();
expect(events.map((e) => e.$1), [
'post_media_upload_started',
'post_media_upload_succeeded',
]);
expect(named('post_media_upload_started').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
});
expect(named('post_media_upload_succeeded').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'durationMs': 1200,
});
});
test('压缩后仍超限:started + failed(media_too_large, attemptSeq 1)', () async {
compressor.sizePerQuality = {80: 40, 60: 30};
final uploader = build(maxByteSize: 20, analytics: analytics);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.retryable, isFalse);
expect(named('post_media_upload_succeeded'), isEmpty);
expect(named('post_media_upload_failed').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'failureReason': 'media_too_large',
'attemptSeq': 1,
});
});
test('断连失败 → retryfailed(network_error) 后 attemptSeq 递增再成功', () async {
direct.scriptedOutcomes.add(
const MediaDirectUploadException(message: '断连'),
);
final uploader = build(analytics: analytics);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(named('post_media_upload_failed').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'failureReason': 'network_error',
'attemptSeq': 1,
});
uploader.retry(uploader.items.single.localId);
await pumpEventQueue();
expect(uploader.items.single.phase, MediaItemPhase.ready);
expect(named('post_media_upload_started'), hasLength(2));
expect(named('post_media_upload_succeeded'), hasLength(1));
});
test(
'createUpload 40000failed(unsupported_format) 带 errorCode/httpStatus',
() async {
repository.onCreateMediaUpload = (_) async =>
throw const ApiBusinessException(code: 40000, message: 'bad mime');
final uploader = build(analytics: analytics);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(named('post_media_upload_failed').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'failureReason': 'unsupported_format',
'attemptSeq': 1,
'errorCode': 40000,
'httpStatus': 400,
});
},
);
test('在途删格报 cancelledready 后删格不报', () async {
direct.manual = true;
final uploader = build(analytics: analytics);
uploader.addImages([pickedImage(seed: 1)]);
await pumpEventQueue();
uploader.remove(uploader.items.single.localId);
expect(named('post_media_upload_failed').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'failureReason': 'cancelled',
'attemptSeq': 1,
});
events.clear();
direct.manual = false;
uploader.addImages([pickedImage(seed: 2)]);
await pumpEventQueue();
expect(uploader.items.single.isReady, isTrue);
uploader.remove(uploader.items.single.localId);
expect(named('post_media_upload_failed'), isEmpty);
});
test('会话失效不上报失败(应用即将回登录页)', () async {
repository.onCreateMediaUpload = (_) async =>
throw const SessionExpiredException();
final uploader = build(analytics: analytics);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.isFailed, isTrue);
expect(named('post_media_upload_failed'), isEmpty);
expect(named('post_media_upload_started'), hasLength(1));
});
});
}
Map<String, dynamic> readyAssetJson({required String assetId}) => {
'id': assetId,
'kind': 'image',
'purpose': 'post_image',
'mimeType': 'image/jpeg',
'byteSize': 1024,
'widthPx': 1080,
'heightPx': 810,
'status': 'ready',
'url': 'http://minio.local/p.jpg?X-Amz-Signature=sig',
'readyAt': '2026-09-09T00:00:00.000Z',
'createdAt': '2026-09-09T00:00:00.000Z',
};
@@ -0,0 +1,193 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
/// post T3-17 22 v3 22~29
/// failureReason 线 ID /
void main() {
late List<(String, Map<String, dynamic>?)> events;
late PostAnalytics analytics;
setUp(() {
events = [];
analytics = PostAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
group('键集与白名单对齐(22 号 §1', () {
test('post_create_startedentryPoint', () {
analytics.postCreateStarted(entryPoint: PostEntryPoint.createTab);
expect(events.single.$1, 'post_create_started');
expect(events.single.$2, {'entryPoint': 'create_tab'});
});
test('post_draft_savedtrigger + mediaCount', () {
analytics.postDraftSaved(trigger: DraftSaveTrigger.onExit, mediaCount: 3);
expect(events.single.$1, 'post_draft_saved');
expect(events.single.$2, {'trigger': 'on_exit', 'mediaCount': 3});
});
test('post_publish_succeeded:五键齐 + 正文只出分桶', () {
analytics.postPublishSucceeded(
durationMs: 8200,
mediaCount: 2,
topicCount: 0,
textLength: 120,
fromDraft: true,
);
final props = events.single.$2!;
expect(
props.keys,
containsAll(<String>[
'durationMs',
'mediaCount',
'topicCount',
'textLengthBucket',
'fromDraft',
]),
);
expect(props['textLengthBucket'], 'medium');
expect(props['fromDraft'], isTrue);
// 线 1/2 postId
expect(props.containsKey('textLength'), isFalse);
expect(props.containsKey('postId'), isFalse);
});
test(
'post_publish_failedfailureReason/attemptSeq/errorCode/httpStatus',
() {
analytics.postPublishFailed(
reason: PostPublishFailureReason.mediaUploadIncomplete,
attemptSeq: 2,
errorCode: 42203,
);
expect(events.single.$2, {
'failureReason': 'media_upload_incomplete',
'attemptSeq': 2,
'errorCode': 42203,
'httpStatus': 422,
});
},
);
test('post_publish_failed:网络错误无 errorCode/httpStatus', () {
analytics.postPublishFailed(
reason: PostPublishFailureReason.networkError,
attemptSeq: 1,
);
expect(events.single.$2, {
'failureReason': 'network_error',
'attemptSeq': 1,
});
});
test('post_deleted:空属性集(单事件风格)', () {
analytics.postDeleted();
expect(events.single.$1, 'post_deleted');
expect(events.single.$2, isEmpty);
});
test('媒体三段:键集齐 + 只出桶不出字节数/文件名', () {
analytics.mediaUploadStarted(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
);
analytics.mediaUploadSucceeded(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
durationMs: 4300,
);
analytics.mediaUploadFailed(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
reason: MediaUploadFailureReason.cancelled,
attemptSeq: 1,
);
expect(events.map((event) => event.$1), [
'post_media_upload_started',
'post_media_upload_succeeded',
'post_media_upload_failed',
]);
expect(events[0].$2, {'mediaType': 'image', 'sizeBucket': 'mb_1_5'});
expect(events[1].$2, {
'mediaType': 'image',
'sizeBucket': 'mb_1_5',
'durationMs': 4300,
});
expect(events[2].$2, {
'mediaType': 'image',
'sizeBucket': 'mb_1_5',
'failureReason': 'cancelled',
'attemptSeq': 1,
});
for (final event in events) {
expect(event.$2!.containsKey('byteSize'), isFalse);
expect(event.$2!.containsKey('fileName'), isFalse);
}
});
});
group('分桶边界(06 §1.3 红线 4', () {
test('mediaSizeBucketOf 四档', () {
const mib = 1024 * 1024;
expect(mediaSizeBucketOf(0), 'lt_1mb');
expect(mediaSizeBucketOf(mib - 1), 'lt_1mb');
expect(mediaSizeBucketOf(mib), 'mb_1_5');
expect(mediaSizeBucketOf(5 * mib - 1), 'mb_1_5');
expect(mediaSizeBucketOf(5 * mib), 'mb_5_20');
expect(mediaSizeBucketOf(20 * mib - 1), 'mb_5_20');
expect(mediaSizeBucketOf(20 * mib), 'gte_20mb');
});
});
group('异常 → 发布失败原因', () {
test('42203 → media_upload_incomplete40905/40000 → validation_error', () {
expect(
postPublishFailureReasonOf(const MediaNotReadyException(message: 'x')),
PostPublishFailureReason.mediaUploadIncomplete,
);
expect(
postPublishFailureReasonOf(
const IdempotencyMismatchException(message: 'x'),
),
PostPublishFailureReason.validationError,
);
expect(
postPublishFailureReasonOf(
const ApiBusinessException(code: 40000, message: 'x'),
),
PostPublishFailureReason.validationError,
);
});
test('40403 → not_found40902 → server_error;限流/网络各自归位', () {
expect(
postPublishFailureReasonOf(const PostNotFoundException(message: 'x')),
PostPublishFailureReason.notFound,
);
expect(
postPublishFailureReasonOf(
const PostVersionConflictException(message: 'x'),
),
PostPublishFailureReason.serverError,
);
expect(
postPublishFailureReasonOf(const ApiRateLimitException()),
PostPublishFailureReason.rateLimited,
);
expect(
postPublishFailureReasonOf(const ApiNetworkException()),
PostPublishFailureReason.networkError,
);
});
test('会话失效不上报(null', () {
expect(
postPublishFailureReasonOf(const SessionExpiredException()),
isNull,
);
});
});
}
@@ -0,0 +1,571 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:patbond_flutter/features/community/post_compose_page.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
/// T3-17gating / 稿退
/// 40905 / 42203 / 稿
///
void main() {
late FakeCommunityRepository repository;
late CommunityController controller;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
late List<(String, Map<String, dynamic>?)> events;
late PostAnalytics analytics;
late List<CreatePostRequest> created;
late List<UpdatePostRequest> updated;
bool? popResult;
int uploadSeq = 0;
setUp(() {
repository = FakeCommunityRepository();
controller = CommunityController(repository: repository);
picker = FakeMediaImagePicker([
decodablePickedImage(seed: 1),
decodablePickedImage(seed: 2),
decodablePickedImage(seed: 3),
]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
events = [];
analytics = PostAnalytics(
(name, [props]) async => events.add((name, props)),
);
created = [];
updated = [];
popResult = null;
uploadSeq = 0;
repository.onCreateMediaUpload = (_) async =>
credentials(assetId: 'a-${++uploadSeq}');
repository.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
repository.onCreatePost = (request, _) async {
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
repository.onUpdatePost = (postId, request) async {
updated.add(request);
return Post.fromJson(
samplePostJson(id: postId, version: request.version + 1),
);
};
});
List<Map<String, dynamic>?> eventsNamed(String name) =>
events.where((event) => event.$1 == name).map((e) => e.$2).toList();
MediaUploader uploaderFactory(
CommunityRepository repo,
PostAnalytics? postAnalytics,
) => MediaUploader(
repository: repo,
picker: picker,
compressor: compressor,
directUpload: direct,
analytics: postAnalytics,
);
Future<void> pumpCompose(
WidgetTester tester, {
PostEntryPoint entryPoint = PostEntryPoint.createTab,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
popResult = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => PostComposePage(
controller: controller,
entryPoint: entryPoint,
analytics: analytics,
uploaderFactory: uploaderFactory,
),
),
);
},
child: const Text('打开发布页'),
),
),
),
),
),
);
await tester.tap(find.text('打开发布页'));
await tester.pumpAndSettle();
}
Finder publishButton() => find.widgetWithText(FilledButton, '发布');
bool publishEnabled(WidgetTester tester) =>
tester.widget<FilledButton>(publishButton()).onPressed != null;
Future<void> writeContent(
WidgetTester tester, [
String text = '带豆豆去了公园',
]) async {
await tester.enterText(find.byType(TextField), text);
await tester.pump();
}
/// [count]
Future<void> pickImages(WidgetTester tester, int count) async {
picker.results
..clear()
..addAll(List.generate(count, (i) => decodablePickedImage(seed: i + 1)));
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
await tester.pumpAndSettle();
}
group('gating05 §2.2/§2.3 + 后端 content 必填)', () {
testWidgets('空正文禁用;输入即启用;post_create_started 只报一次', (tester) async {
await pumpCompose(tester);
expect(publishEnabled(tester), isFalse);
await writeContent(tester, '');
expect(publishEnabled(tester), isTrue);
await writeContent(tester, '豆豆');
expect(eventsNamed('post_create_started'), hasLength(1));
expect(eventsNamed('post_create_started').single, {
'entryPoint': 'create_tab',
});
});
testWidgets('图片在途禁用发布,全部 ready 才放行(孤儿防护)', (tester) async {
direct.manual = true;
await pumpCompose(tester, entryPoint: PostEntryPoint.feed);
await writeContent(tester);
picker.results
..clear()
..addAll([decodablePickedImage()]);
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
await tester.pumpAndSettle();
expect(publishEnabled(tester), isFalse);
//
expect(find.textContaining('正在上传'), findsOneWidget);
direct.calls.single.succeed();
await tester.pumpAndSettle();
expect(publishEnabled(tester), isTrue);
expect(find.textContaining('正在上传'), findsNothing);
// entryPoint feed
expect(eventsNamed('post_create_started').single, {'entryPoint': 'feed'});
});
});
group('直接发布路径(建草稿 → PATCH 迁移)', () {
testWidgets('纯文字帖:createPost(draft) + PATCH publish → pop(true) + 漏斗事件', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester, '带豆豆去了公园');
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created.single.status, PostStatus.draft);
expect(created.single.content, '带豆豆去了公园');
expect(created.single.category, PostCategory.general);
expect(created.single.media, isNull);
expect(updated.single.publish, isTrue);
expect(updated.single.version, 1);
// 稿media PATCH
expect(updated.single.media, isNull);
expect(popResult, isTrue);
final succeeded = eventsNamed('post_publish_succeeded').single!;
expect(succeeded['mediaCount'], 0);
expect(succeeded['topicCount'], 0);
expect(succeeded['textLengthBucket'], 'short');
expect(succeeded['fromDraft'], isFalse);
expect(eventsNamed('post_publish_failed'), isEmpty);
});
testWidgets('求助类目 + 两图:position 0..1、首图封面、mediaCount 2', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.text('求助'));
await tester.pump();
await pickImages(tester, 2);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created.single.category, PostCategory.help);
final media = created.single.media!;
expect(media.map((item) => item.position), [0, 1]);
expect(media.map((item) => item.isCover), [true, false]);
expect(media.map((item) => item.assetId), ['a-1', 'a-2']);
expect(eventsNamed('post_publish_succeeded').single!['mediaCount'], 2);
//
expect(eventsNamed('post_media_upload_started'), hasLength(2));
expect(eventsNamed('post_media_upload_succeeded'), hasLength(2));
});
testWidgets('删格重排:3 图删中间 → position 重发号 0..1,弃掉的 asset 不被引用', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 3);
expect(find.byType(Image), findsNWidgets(3));
//
await tester.tap(find.byIcon(Icons.close).at(1));
await tester.pumpAndSettle();
expect(find.byType(Image), findsNWidgets(2));
await tester.tap(publishButton());
await tester.pumpAndSettle();
final media = created.single.media!;
expect(media.map((item) => item.position), [0, 1]);
expect(media.map((item) => item.assetId), ['a-1', 'a-3']);
expect(media.map((item) => item.isCover), [true, false]);
});
});
group('发布失败三语义', () {
testWidgets('网络失败:横幅可重试 + 同键重放(不重复建帖)', (tester) async {
var attempts = 0;
repository.onCreatePost = (request, _) async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException();
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsOneWidget);
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
// 稿
expect(find.text('草稿已保存,可稍后继续发布'), findsNothing);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'network_error',
'attemptSeq': 1,
});
expect(popResult, isNull);
await tester.tap(publishButton());
await tester.pumpAndSettle();
//
expect(repository.idempotencyKeys, hasLength(2));
expect(repository.idempotencyKeys[0], isNotNull);
expect(repository.idempotencyKeys[0], repository.idempotencyKeys[1]);
expect(popResult, isTrue);
});
testWidgets('迁移发布失败:明确「草稿已保存」,重试只补 PATCH', (tester) async {
var patches = 0;
repository.onUpdatePost = (postId, request) async {
patches += 1;
if (patches == 1) throw const ApiNetworkException();
updated.add(request);
return Post.fromJson(samplePostJson(id: postId));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('草稿已保存,可稍后继续发布'), findsOneWidget);
expect(created, hasLength(1));
await tester.tap(publishButton());
await tester.pumpAndSettle();
// 稿 PATCH
expect(created, hasLength(1));
expect(patches, 2);
expect(popResult, isTrue);
expect(eventsNamed('post_publish_failed').single!['attemptSeq'], 1);
expect(eventsNamed('post_publish_succeeded'), hasLength(1));
});
testWidgets('40905 同键异 hash:提示已重置提交标识,再点即换新键', (tester) async {
var attempts = 0;
repository.onCreatePost = (request, _) async {
attempts += 1;
if (attempts == 1) {
throw const IdempotencyMismatchException(message: 'mismatch');
}
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('提交内容与上次重试不一致,已重置提交标识,请再点一次「发布」'), findsOneWidget);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'validation_error',
'attemptSeq': 1,
'errorCode': 40905,
'httpStatus': 409,
});
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(repository.idempotencyKeys, hasLength(2));
expect(
repository.idempotencyKeys[0],
isNot(repository.idempotencyKeys[1]),
);
expect(popResult, isTrue);
});
testWidgets('42203 asset 未 ready:提示等图片就绪 + media_upload_incomplete', (
tester,
) async {
repository.onCreatePost = (_, _) async =>
throw const MediaNotReadyException(message: 'not ready');
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 1);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('有图片还没上传完成,请等图片就绪后再发布'), findsOneWidget);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'media_upload_incomplete',
'attemptSeq': 1,
'errorCode': 42203,
'httpStatus': 422,
});
expect(popResult, isNull);
});
});
group('存草稿退出路径', () {
testWidgets('「存草稿」按钮:createPost(draft) + post_draft_saved(manual)', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 1);
await tester.tap(find.widgetWithText(TextButton, '存草稿'));
await tester.pumpAndSettle();
expect(created.single.status, PostStatus.draft);
expect(updated, isEmpty);
expect(find.text('已保存草稿 ✓'), findsOneWidget);
expect(eventsNamed('post_draft_saved').single, {
'trigger': 'manual',
'mediaCount': 1,
});
});
testWidgets('「取消」有内容 → 保留草稿:on_exit 保存后离页', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
expect(find.text('保留草稿?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '保留'));
await tester.pumpAndSettle();
expect(created, hasLength(1));
expect(eventsNamed('post_draft_saved').single, {
'trigger': 'on_exit',
'mediaCount': 0,
});
expect(popResult, isFalse);
expect(find.byType(PostComposePage), findsNothing);
});
testWidgets('「不保留」:已落服务端的草稿软删 + post_deleted', (tester) async {
final deleted = <String>[];
repository.onDeletePost = (postId) async => deleted.add(postId);
await pumpCompose(tester);
await writeContent(tester);
// 稿使稿
await tester.tap(find.widgetWithText(TextButton, '存草稿'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '不保留'));
await tester.pumpAndSettle();
expect(deleted, ['p-new']);
expect(eventsNamed('post_deleted'), hasLength(1));
expect(popResult, isFalse);
});
testWidgets('「继续编辑」留在页内,不动服务端', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '继续编辑'));
await tester.pumpAndSettle();
expect(find.byType(PostComposePage), findsOneWidget);
expect(created, isEmpty);
expect(
repository.calls.where((c) => c.startsWith('deletePost')),
isEmpty,
);
});
testWidgets('空表单「取消」直接离页,无弹窗无请求', (tester) async {
await pumpCompose(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
expect(find.text('保留草稿?'), findsNothing);
expect(created, isEmpty);
expect(popResult, isFalse);
});
});
group('草稿恢复(最小实现:最新一条)', () {
setUp(() {
repository.onListMyPosts = (status) async =>
postPage([sampleDraft(version: 3, content: '上次没写完的草稿')]);
});
testWidgets('进页恢复:提示条 + 正文预填 + 已有图片说明', (tester) async {
await pumpCompose(tester);
expect(find.text('已恢复上次草稿'), findsOneWidget);
expect(find.text('上次没写完的草稿'), findsOneWidget);
expect(find.textContaining('草稿已含 1 张图片'), findsOneWidget);
// post_create_started
expect(eventsNamed('post_create_started'), isEmpty);
expect(repository.calls, contains('listMyPosts:draft'));
});
testWidgets('恢复后发布:不再建草稿,PATCH 带 version 与 fromDraft=true', (tester) async {
await pumpCompose(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created, isEmpty);
expect(updated.single.version, 3);
expect(updated.single.publish, isTrue);
// media
expect(updated.single.media, isNull);
final succeeded = eventsNamed('post_publish_succeeded').single!;
expect(succeeded['fromDraft'], isTrue);
expect(succeeded['mediaCount'], 1);
expect(popResult, isTrue);
});
testWidgets('恢复后重新选图:PATCH 整组替换', (tester) async {
await pumpCompose(tester);
await pickImages(tester, 1);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(updated.single.media!.map((item) => item.assetId), ['a-1']);
expect(eventsNamed('post_publish_succeeded').single!['mediaCount'], 1);
});
testWidgets('40902 乐观锁:自动取新 version 重提一次即成功', (tester) async {
var patches = 0;
repository.onUpdatePost = (postId, request) async {
patches += 1;
if (patches == 1) {
throw const PostVersionConflictException(message: 'stale');
}
updated.add(request);
return Post.fromJson(samplePostJson(id: postId));
};
repository.onGetPost = (postId) async => Post.fromJson(
samplePostJson(id: postId, status: 'draft', version: 9),
);
await pumpCompose(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(updated.single.version, 9);
expect(eventsNamed('post_publish_failed'), isEmpty);
expect(popResult, isTrue);
});
testWidgets('「清空」清掉恢复内容与提示条', (tester) async {
await pumpCompose(tester);
await tester.tap(find.widgetWithText(TextButton, '清空'));
await tester.pumpAndSettle();
expect(find.text('已恢复上次草稿'), findsNothing);
expect(find.text('上次没写完的草稿'), findsNothing);
expect(publishEnabled(tester), isFalse);
});
testWidgets('草稿恢复失败静默降级为新建(不打断发布)', (tester) async {
repository.onListMyPosts = (_) async => throw const ApiNetworkException();
await pumpCompose(tester);
expect(find.text('已恢复上次草稿'), findsNothing);
expect(find.byType(InlineErrorBanner), findsNothing);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created, hasLength(1));
expect(popResult, isTrue);
});
});
group('页面结构(05 §2.3', () {
testWidgets('AppBar 三件套 + 媒体编辑格 + 类目二选 + 位置占位', (tester) async {
await pumpCompose(tester);
expect(find.text('发布动态'), findsOneWidget);
expect(find.widgetWithText(TextButton, '取消'), findsOneWidget);
expect(find.widgetWithText(TextButton, '存草稿'), findsOneWidget);
expect(publishButton(), findsOneWidget);
expect(find.byType(PostMediaEditGrid), findsOneWidget);
expect(find.text('日常分享'), findsOneWidget);
expect(find.text('求助'), findsOneWidget);
// ai_creation M4
expect(find.textContaining('AI'), findsNothing);
await tester.drag(find.byType(ListView), const Offset(0, -240));
await tester.pump();
await tester.tap(find.text('添加位置(选填)'));
await tester.pump();
expect(find.text('位置功能即将上线'), findsOneWidget);
});
testWidgets('正文 1000 字上限计数器在位', (tester) async {
await pumpCompose(tester);
await writeContent(tester, '豆豆');
expect(find.text('2/1000'), findsOneWidget);
});
});
}
+467
View File
@@ -0,0 +1,467 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/home/home_page.dart';
import 'package:patbond_flutter/state/app_state.dart';
import '../../helpers/community_test_helpers.dart';
/// 便 id 便
FeedCard textCard(String id) => FeedCard.fromJson({
...sampleFeedCardJson(id: id),
'coverImage': null,
'mediaCount': 0,
'title': null,
'contentPreview': '动态内容 $id',
});
FeedCard degradedCard(String id) => FeedCard.fromJson({
...sampleFeedCardJson(id: id),
'coverImage': null,
'mediaCount': 0,
'title': null,
'contentPreview': '动态内容 $id',
'author': sampleAuthorJson(nickname: null, avatarUrl: null),
});
void main() {
late FakeCommunityRepository repository;
late CommunityController controller;
late List<(String, Map<String, dynamic>?)> events;
late FeedAnalytics analytics;
late int composeTaps;
late List<String> openedPosts;
setUp(() {
repository = FakeCommunityRepository();
events = [];
controller = CommunityController(
repository: repository,
interactionAnalytics: CommunityInteractionAnalytics(
(name, [props]) async => events.add((name, props)),
),
);
analytics = FeedAnalytics(
(name, [props]) async => events.add((name, props)),
);
composeTaps = 0;
openedPosts = [];
});
List<Map<String, dynamic>?> eventsNamed(String name) =>
events.where((e) => e.$1 == name).map((e) => e.$2).toList();
Future<void> pumpHome(WidgetTester tester, {bool isActive = true}) {
return tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: HomePage(
appState: AppState(),
communityController: controller,
feedAnalytics: analytics,
isActive: isActive,
onOpenServices: (_) {},
onOpenCompose: () => composeTaps++,
onOpenPost: openedPosts.add,
),
),
),
);
}
// story ListView first
Finder list() => find.byType(ListView).first;
testWidgets('四态 · loading:首载渲染 3 张骨架屏', (tester) async {
final completer = Completer<CursorPage<FeedCard>>();
repository.onFeed = (_, _) => completer.future;
await pumpHome(tester);
await tester.pump();
// 3 1
expect(find.byType(FeedSkeleton), findsAtLeastNWidgets(1));
completer.complete(feedPage(const []));
await tester.pumpAndSettle();
expect(find.byType(FeedSkeleton), findsNothing);
});
testWidgets('四态 · empty:空态插画 + 「发布第一条」CTA 进发布页', (tester) async {
repository.onFeed = (_, _) async => feedPage(const []);
await pumpHome(tester);
await tester.pumpAndSettle();
expect(find.text('还没有动态'), findsOneWidget);
await tester.drag(list(), const Offset(0, -300));
await tester.pump();
await tester.tap(find.text('发布第一条'));
expect(composeTaps, 1);
});
testWidgets('四态 · error:横幅 + 重试恢复 readyfeed_load_failed 上报', (tester) async {
var attempts = 0;
repository.onFeed = (_, _) async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException();
return feedPage([textCard('p-1')]);
};
await pumpHome(tester);
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsOneWidget);
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
expect(eventsNamed('feed_load_failed'), [
{
'feedTab': 'home',
'loadType': 'refresh',
'failureReason': 'network_error',
},
]);
await tester.drag(list(), const Offset(0, -200));
await tester.pump();
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsNothing);
expect(find.text('动态内容 p-1'), findsOneWidget);
});
testWidgets('四态 · ready:卡片列表 + 降级作者「宠友」占位', (tester) async {
repository.onFeed = (_, _) async =>
feedPage([textCard('p-1'), degradedCard('p-2')]);
await pumpHome(tester);
await tester.pumpAndSettle();
await tester.drag(list(), const Offset(0, -400));
await tester.pump();
expect(find.byType(PostCard), findsNWidgets(2));
expect(find.text('毛毛的铲屎官'), findsOneWidget);
expect(find.text('宠友'), findsOneWidget);
});
testWidgets('翻页:触底携游标加载下一页,不丢不重,到底显「没有更多了」', (tester) async {
final page2 = Completer<CursorPage<FeedCard>>();
repository.onFeed = (_, cursor) async {
if (cursor == null) {
return feedPage(
[textCard('p-1'), textCard('p-2')],
nextCursor: 'c1',
hasMore: true,
);
}
return page2.future;
};
await pumpHome(tester);
await tester.pumpAndSettle();
// loading
await tester.drag(list(), const Offset(0, -1000));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
page2.complete(feedPage([textCard('p-3')]));
await tester.pumpAndSettle();
await tester.drag(list(), const Offset(0, -600));
await tester.pump();
// keyset
expect(repository.calls.where((c) => c.startsWith('feed:')).toList(), [
'feed:cursor=null',
'feed:cursor=c1',
]);
expect(find.text('动态内容 p-1'), findsOneWidget);
expect(find.text('动态内容 p-2'), findsOneWidget);
expect(find.text('动态内容 p-3'), findsOneWidget);
expect(find.text('没有更多了'), findsOneWidget);
});
testWidgets('尾部失败:重试条 + feed_load_failed(load_more),点按重试补页', (tester) async {
var page2Attempts = 0;
repository.onFeed = (_, cursor) async {
if (cursor == null) {
return feedPage([textCard('p-1')], nextCursor: 'c1', hasMore: true);
}
page2Attempts += 1;
if (page2Attempts == 1) {
throw const ApiBusinessException(code: 40000, message: 'x');
}
return feedPage([textCard('p-2')]);
};
await pumpHome(tester);
await tester.pumpAndSettle();
await tester.drag(list(), const Offset(0, -1000));
await tester.pumpAndSettle();
expect(find.text('加载失败,点此重试'), findsOneWidget);
expect(eventsNamed('feed_load_failed'), [
{
'feedTab': 'home',
'loadType': 'load_more',
'failureReason': 'server_error',
'errorCode': 40000,
'httpStatus': 400,
},
]);
await tester.tap(find.text('加载失败,点此重试'));
await tester.pumpAndSettle();
await tester.drag(list(), const Offset(0, -400));
await tester.pump();
expect(find.text('动态内容 p-2'), findsOneWidget);
expect(find.text('加载失败,点此重试'), findsNothing);
});
testWidgets('下拉刷新:整体替换列表;刷新失败保留旧列表 + SnackBar', (tester) async {
var attempts = 0;
repository.onFeed = (_, _) async {
attempts += 1;
if (attempts == 1) return feedPage([textCard('p-1')]);
if (attempts == 2) throw const ApiNetworkException();
return feedPage([textCard('p-9')]);
};
await pumpHome(tester);
await tester.pumpAndSettle();
expect(find.text('动态内容 p-1'), findsOneWidget);
// SnackBar
await tester.fling(list(), const Offset(0, 400), 1000);
await tester.pumpAndSettle();
expect(find.text('动态内容 p-1'), findsOneWidget);
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
expect(eventsNamed('feed_load_failed').single?['loadType'], 'refresh');
//
await tester.fling(list(), const Offset(0, 400), 1000);
await tester.pumpAndSettle();
expect(find.text('动态内容 p-9'), findsOneWidget);
expect(find.text('动态内容 p-1'), findsNothing);
});
testWidgets('曝光结算 · 切走 Tab:可见 ≥500ms 的卡片计入,一段恰一条 feed_viewed', (
tester,
) async {
repository.onFeed = (_, _) async =>
feedPage([textCard('p-1'), textCard('p-2')]);
await pumpHome(tester);
await tester.pumpAndSettle();
await tester.drag(list(), const Offset(0, -1000));
// 500ms
// Tab
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
expect(eventsNamed('feed_viewed'), isEmpty);
await pumpHome(tester, isActive: false);
await tester.pump();
final viewed = eventsNamed('feed_viewed');
expect(viewed, hasLength(1));
expect(viewed.single?['feedTab'], 'home');
expect(viewed.single?['impressionCount'], 2);
//
expect(viewed.single?['refreshCount'], 0);
expect(viewed.single?['loadMoreCount'], 0);
// durationMs widget pump
// feed_exposure_test
expect(viewed.single?['durationMs'], greaterThanOrEqualTo(0));
// Tab
await pumpHome(tester);
await tester.pump();
await pumpHome(tester, isActive: false);
await tester.pump();
expect(eventsNamed('feed_viewed'), hasLength(2));
});
testWidgets('曝光结算 · 快速滑过不计;刷新/翻页计数入段', (tester) async {
final cards = List.generate(6, (i) => textCard('p-$i'));
repository.onFeed = (_, cursor) async {
if (cursor == null) {
return feedPage(cards, nextCursor: 'c1', hasMore: true);
}
return feedPage([textCard('p-6')]);
};
await pumpHome(tester);
await tester.pumpAndSettle();
// <500ms
await tester.drag(list(), const Offset(0, -2000));
await tester.pumpAndSettle();
//
await tester.drag(list(), const Offset(0, 2000));
await tester.pump();
await tester.fling(list(), const Offset(0, 400), 1000);
await tester.pumpAndSettle();
await pumpHome(tester, isActive: false);
await tester.pump();
final viewed = eventsNamed('feed_viewed').single!;
expect(viewed['refreshCount'], 1);
expect(viewed['loadMoreCount'], 1);
// 500ms
expect(viewed['impressionCount'], lessThan(cards.length));
});
testWidgets('曝光结算 · 退后台:结算一条,回前台开新段', (tester) async {
repository.onFeed = (_, _) async => feedPage([textCard('p-1')]);
await pumpHome(tester);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
// inactivehiddenpaused
expect(eventsNamed('feed_viewed'), hasLength(1));
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
expect(eventsNamed('feed_viewed'), hasLength(2));
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
});
testWidgets('曝光结算 · 切到服务分段:结算;切回开新段', (tester) async {
repository.onFeed = (_, _) async => feedPage([textCard('p-1')]);
await pumpHome(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('本地服务'));
await tester.pump();
expect(eventsNamed('feed_viewed'), hasLength(1));
await tester.tap(find.text('社区动态'));
await tester.pump();
await tester.tap(find.text('本地服务'));
await tester.pump();
expect(eventsNamed('feed_viewed'), hasLength(2));
});
testWidgets('T3-15 导航接通:整卡与评论钮点按回调 onOpenPost(占位提示移除)', (tester) async {
repository.onFeed = (_, _) async => feedPage([textCard('p-1')]);
await pumpHome(tester);
await tester.pumpAndSettle();
await tester.drag(list(), const Offset(0, -600));
await tester.pump();
await tester.tap(find.text('动态内容 p-1'));
await tester.pump();
expect(find.text('帖子详情正在接入真实数据,敬请期待'), findsNothing);
expect(openedPosts, ['p-1']);
await tester.tap(find.byIcon(Icons.chat_bubble_outline));
await tester.pump();
expect(openedPosts, ['p-1', 'p-1']);
});
testWidgets('T3-16 互动接线:点赞乐观翻转即时显示,成功报 post_liked(source=feed)', (
tester,
) async {
repository.onFeed = (_, _) async => feedPage([
FeedCard.fromJson({
...sampleFeedCardJson(likeCount: 6),
'coverImage': null,
'mediaCount': 0,
}),
]);
final like = Completer<LikeState>();
repository.onLikeToggle = (_, _) => like.future;
await pumpHome(tester);
await tester.pumpAndSettle();
await tester.drag(list(), const Offset(0, -800));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.favorite_border));
await tester.pump();
// +1
expect(find.text('7'), findsOneWidget);
expect(repository.calls, contains('like:p-1'));
like.complete(const LikeState(liked: true, likeCount: 7));
await tester.pumpAndSettle();
expect(eventsNamed('post_liked'), [
{'source': 'feed'},
]);
expect(find.text('7'), findsOneWidget);
});
testWidgets('T3-16 互动接线:点赞失败回滚 + SnackBar「操作失败,请重试」', (tester) async {
repository.onFeed = (_, _) async => feedPage([
FeedCard.fromJson({
...sampleFeedCardJson(likeCount: 6),
'coverImage': null,
'mediaCount': 0,
}),
]);
final like = Completer<LikeState>();
repository.onLikeToggle = (_, _) => like.future;
await pumpHome(tester);
await tester.pumpAndSettle();
await tester.drag(list(), const Offset(0, -800));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.favorite_border));
await tester.pump();
expect(find.text('7'), findsOneWidget);
like.completeError(const ApiNetworkException());
await tester.pumpAndSettle();
// + SnackBar post_liked
expect(find.text('6'), findsOneWidget);
expect(find.text('操作失败,请重试'), findsOneWidget);
expect(eventsNamed('post_liked'), isEmpty);
});
testWidgets('搜索词过滤已加载卡片;无命中显搜索空态', (tester) async {
repository.onFeed = (_, _) async =>
feedPage([textCard('p-1'), textCard('p-2')]);
await pumpHome(tester);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'p-1');
await tester.pump();
expect(find.byType(PostCard), findsOneWidget);
await tester.enterText(find.byType(TextField), '不存在的词');
await tester.pump();
expect(find.byType(PostCard), findsNothing);
expect(find.text('没有找到相关动态'), findsOneWidget);
});
}

Some files were not shown because too many files have changed in this diff Show More