Compare commits

..

34 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
lixi 6fef0db009 新增:page_viewed 集中式路由埋点(NavigatorObserver)
CI / flutter-gates (push) Successful in 1m3s
- AnalyticsRouteObserver:didPush/didReplace/didPop 集中捕获,页面零侵入
- AnalyticsPageName 枚举:pageName 编译期锁死(字典 v2 九个初始值 + 客户端现存 create/pet_archive/services/post_detail),字典外路由不上报
- PageViewTracker:page_viewed 单一上报出口,维护 referrer 链(栈底/冷启动首页为 null)与去重(Tab 重复点选)
- 三类非路由曝光手动补点:主壳 Tab 切换(IndexedStack)、认证状态机切页(AnimatedSwitcher)、回栈到无名根路由(resolveRootPage)
- 既有 Navigator.push 挂 RouteSettings.name:login_page(register)、main_shell_page(post_detail);fade_route 签名扩展可选 settings
- M2 健康档案页面(pet_list/pet_detail/pet_form/record_form/record_detail)先留枚举定义,待功能落地接线

测试新增 8 例(analytics_route_observer_test):push 上报/push 两页 referrer 链正确 + pop 补报/根路由 resolveRootPage 补报/枚举外不报/dialog 不报/Tab 去重/referrer 连贯/reportTab。

验收对照(06 号报告 §5.2 六条):1✓ observer 注册、2✓ pageName 枚举含 pet_form、3✓ referrer 链、4✓ 字典外不报、5✓ 单测 push/pop/referrer、6✓ M1 存量四页接全。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 14:50:04 +08:00
lixi 4c2f839a01 修复:埋点接线与三处偏差(sessionId/eventId/设备信息)
- 生产接线修复:app.dart 组装时传 analytics 实例给 ApiAuthRepository(_buildRepository),修复 M1 遗留的「生产环境 _analytics 恒为 null、挂接点空转」问题
- sessionId 生命周期:新建 SessionTracker (WidgetsBindingObserver),冷启动生成 UUIDv7、后台超 30 分钟换新、未超阈值沿用原值,不再每事件随机生成
- eventId 改 UUIDv7:对齐 13 号规范(uuid 包已在依赖,直接用 v7()),保留插入时间局部性
- appVersion 动态注入:package_info_plus(新增依赖)异步读取后 setAppVersion,不再硬编码 '1.0.0+1'
- osVersion 动态读取:Platform.operatingSystemVersion 正则提取主版本(如 android-14),不再硬编码
- 队列顺手加固:上传失败批次重回队首而非整批丢弃(一行级缓解,分段持久化属 M2 第二波)

测试新增 9 例:session_tracker_test(5 例:冷启动/短后台/长后台/级联状态/连续幂等),analytics_service_test 补强 4 例(UUIDv7/sessionId 不再逐事件生成/appVersion 可注入/失败重回队列)。

验收对照(06 号报告 §5.1 六条):1✓ SessionTracker 新建、2✓ 三条语义、3✓ 同会话 sessionId 一致、4✓ sessionId 为 UUID 不持久化、5✓ 单测 3 例(实际 5 例)、6✓ 真机脚本(开发者手测)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 14:49:24 +08:00
lixi 3f8388e5d4 fix: 清零 flutter analyze 问题并修复隐私红线正则缺陷(CI 门禁)
CI / flutter-gates (push) Successful in 50s
- 修复真实缺陷:隐私红线 RegExp 使用 Dart 不支持的 (?i) 内联标志,构造即抛异常
  导致带 props 的事件被静默丢弃;改为 caseSensitive: false
- 清理埋点模块未用 import/字段/变量(4 warning)
- 构造函数改用初始化形参(含 Dart 3.12 私有具名参数)
- E2E 手动脚本声明 ignore avoid_print + library 指令
- 门禁:format 0 changed / analyze No issues / flutter test 34 passed
2026-09-04 19:44:46 +08:00
lixi 45f94d2558 style: dart format analytics_service(CI 门禁要求)
CI / flutter-gates (push) Failing after 22s
2026-09-04 18:31:04 +08:00
lixi b0207c9972 chore: 新增 Flutter CI 门禁工作流(零 GitHub 依赖)
CI / flutter-gates (push) Failing after 5m2s
- format/analyze/test 三连,与本地门禁同一套命令
- Flutter SDK 从 flutter-io.cn 下载并缓存于 runner toolcache 卷,pub 走国内镜像
- checkout 手动克隆本 Gitea 实例,concurrency 取消旧 run
2026-09-04 18:20:40 +08:00
lixi 60d67a355b feat: 埋点采集模块落地——AnalyticsService + 登录/注册/退出三事件(M0 简化版,报告 13)
- AnalyticsService:trackEvent(name, props?)/identify(userId)/reset();隐私红线本地校验(password/token/phone 等模式拒绝);匿名 ID 持久化复用 session deviceId;sessionId 简化为每事件生成(M0);网络失败静默丢弃(无重试,按规范)
- 登录/注册成功挂接 auth_login_succeeded / auth_register_succeeded(durationMs);失败挂接 _failed(failureReason);退出挂接 auth_logout
- TODO(工单允许部分挂接):page_viewed(登录/注册/首页/个人中心各一次,M0 无路由埋点基础);health_record_action(M2 档案实现后挂接);auth_session_restore_*(Splash 恢复流程待完善);队列持久化到 shared_preferences 分段(当前内存队列 max 500,满 20 触发上传)
- 测试:flutter test → 34 passed(30→34,新增 analytics_service_test 4 例:字段/隐私拒绝/identify/reset);flutter analyze → 86 issues(与上一波同源);dart format → 1 changed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 17:14:20 +08:00
lixi 845e92fa7d fix: UserProfile.phone 改为可空,消除非空强转崩溃点
- 跨端契约核对发现(功能清单「跨端核对发现」项):服务端 phone 可返回 null
- 门禁:format 0 changed / analyze 0 issues / flutter test 30 passed
2026-09-04 14:55:40 +08:00
lixi da25804a06 fix: 登录页补充 42300 账号锁定错误码映射
- 对齐后端 T4 新增的登录锁定契约(openapi.yaml),锁定时展示明确横幅而非通用兜底
- 门禁:format 0 changed / analyze 0 issues / flutter test 30 passed
2026-09-04 12:17:25 +08:00
lixi 8d890c0424 feat: 落地登录纵切——网络层、认证会话与登录/注册/Splash 三页(ADR-003/ADR-004)
- 新增 lib/core/network/:ApiClient(dio 封装,错误信封→类型化异常,base URL 经 --dart-define=PATBOND_API_BASE_URL 注入)、AuthInterceptor(Bearer + 设备标识)、TokenRefresher(单飞刷新,401/40101 重放一次,仅 40102/再 401 清会话)
- 新增 lib/features/auth/:SessionManager(token 只进 flutter_secure_storage)、AuthRepository(login/register/refresh/logout/me,注册带 Idempotency-Key)、Splash(500ms 最短停留/5s 超时/错误态重试+改用账号登录)、登录页与注册页(照 12 号组装稿,错误三层映射,预留区不渲染)
- App 根组件改为认证状态机驱动(Splash↔登录↔主壳 300ms fade);个人中心退出登录接入真实 logout
- 顺带修复:FIX-1 促销卡渐变改 [primaryStrong, primary]、FIX-2 补 helperStyle: muted、README dart format 命令补 --output=none
- 新增依赖:dio ^5.11.1、flutter_secure_storage ^11.0.0、uuid ^4.6.0
- 门禁:dart format(0 changed)/ flutter analyze(No issues)/ flutter test(30 passed,其中新增 23:TokenRefresher 5 + AuthRepository 10 + 登录页 4 + 注册页 4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 11:52:51 +08:00
lixi ee77361b92 完善README 2026-09-04 10:50:25 +08:00
lixi af002edde3 feat: 迁移珊瑚橙主题体系并新增认证基础组件(ADR-005)
- app_theme.dart 重写为语义 token(AppColors/AppRadius),落地 primaryStrong/error 等 AA 对比度色
- 五个页面与公共组件的旧靛蓝硬编码色值全部替换为 token,仅换色不动布局
- 新增 BrandMark/AppTextField/PrimaryButton/InlineErrorBanner/AuthScaffold 及 6 个 widget 测试
- 门禁:dart format(0 changed)/ flutter analyze(0 issues)/ flutter test(7 passed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 10:34:18 +08:00
lixi 2601da395c update README.md 2026-07-21 17:42:54 +08:00
lixi b9d7c9f2ee 新增静态演示界面 2026-07-21 17:41:01 +08:00
164 changed files with 36089 additions and 142 deletions
+58
View File
@@ -0,0 +1,58 @@
# Gitea Actions 门禁:与 docs/development/git-workflow.md 的本地门禁同一套命令。
# 零外部 action / 零 GitHub 依赖(原因见 patbond-api 的 ci.yml 头注释)。
# Flutter SDK 从中国镜像(flutter-io.cn)下载并缓存在 runner 的 toolcache 卷中,
# 首跑约需下载 ~900MB,之后复用缓存秒级就绪。
# 前提:runner config.yaml 的 container.options 挂载了 toolcache 卷:
# options: "-v /var/run/docker.sock:/var/run/docker.sock -v gitea_toolcache:/opt/hostedtoolcache"
name: CI
on:
push:
branches: [dev]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
flutter-gates:
runs-on: ubuntu-latest
steps:
- name: Checkout (manual)
run: |
git init -q .
AUTH_URL=$(echo "${{ github.server_url }}" | sed "s#https://#https://oauth2:${{ github.token }}@#")
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
FLUTTER_HOME=/opt/hostedtoolcache/flutter-$FLUTTER_VERSION
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
echo "Downloading Flutter $FLUTTER_VERSION from flutter-io.cn ..."
curl -fsSL -o /tmp/flutter.tar.xz \
"https://storage.flutter-io.cn/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
mkdir -p /opt/hostedtoolcache
tar -xf /tmp/flutter.tar.xz -C /tmp
mv /tmp/flutter "$FLUTTER_HOME"
else
echo "Reusing cached Flutter at $FLUTTER_HOME"
fi
git config --global --add safe.directory "$FLUTTER_HOME"
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
echo "PUB_HOSTED_URL=https://pub.flutter-io.cn" >> "$GITHUB_ENV"
echo "FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn" >> "$GITHUB_ENV"
# 与本地门禁同一套命令(git-workflow.md
- name: Gates
run: |
flutter --version
flutter pub get
dart format --output=none --set-exit-if-changed lib test
flutter analyze
flutter test
+4
View File
@@ -12,6 +12,10 @@
.swiftpm/
migrate_working_dir/
# Keep Flutter application sources visible even if a global gitignore ignores lib/.
!/lib/
!/lib/**
# IntelliJ related
*.iml
*.ipr
+22 -44
View File
@@ -1,60 +1,38 @@
# Patbond Flutter
Patbond is a Flutter learning project. The current goal is to build the basic app frame for a social content app with a layout similar to Xiaohongshu.
Patbond
## Current Progress
可运行在 Android、iOS、Web 和桌面平台。
- Flutter project initialized.
- App entry extracted into `lib/app/app.dart`.
- Main shell page created with a bottom navigation bar.
- Home page connected as the first tab.
- Basic project notes added under `doc/`.
## 功能
## Project Structure
- 社区动态搜索、点赞、收藏与评论
- 模拟 AI 图片/视频创作和社区发布
- 宠物资料、疫苗记录及健康档案管理
- 医院、美容和个人服务搜索、筛选与模拟预约
- 使用 `shared_preferences` 持久化宠物、疫苗和帖子数据
```text
lib/
main.dart
app/
app.dart
features/
home/
home_page.dart
main/
main_shell_page.dart
doc/
android_development.md
common_app_structure.md
main_app_const.md
project_structure.md
```
AI 创作、预约、账号和部分菜单目前仍为本地演示交互。
## Run The App
Install dependencies:
## 运行
```bash
flutter clean
flutter pub get
```
Run in debug mode:
```bash
flutter run
```
Or open the project in VS Code, select a device, and press `F5`.
Web 调试:
## Development Plan
```bash
flutter run -d chrome
```
Near-term steps:
## 验证
1. Replace placeholder tabs with real feature pages.
2. Build a basic home feed page.
3. Add note card models and mock data.
4. Add theme configuration.
5. Add detail pages and navigation when the main frame is stable.
## Git Notes
Generated files such as `.dart_tool/`, `build/`, IDE workspace files, and local platform configuration should not be committed.
```bash
dart format --output=none --set-exit-if-changed lib test
flutter analyze
flutter test
flutter build apk --debug
```
+2 -1
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="patbond_flutter"
android:label="Patbond"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
+3
View File
@@ -1,5 +1,8 @@
allprojects {
repositories {
// Flutter Android engine artifacts. The mirror avoids very slow or
// interrupted downloads from storage.googleapis.com in China.
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
google()
+3 -2
View File
@@ -1,7 +1,8 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m
org.gradle.workers.max=2
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.parallel=false
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
+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;
}
+39
View File
@@ -0,0 +1,39 @@
/// page_viewed 的 pageName 编译期枚举(06 号报告 §5.2 字典 v2 + 03 号评估
/// §3.2 Tab 映射)。禁止自由路由字符串;带参数路由必须归一化,任何
/// UUID/ID 不得出现在 pageName 或 referrer 中(隐私红线第 5 条)。
///
/// 尚不存在的 M2 页面(petList/petDetail/petForm/recordForm/recordDetail
/// 先留枚举定义、不接线,待健康档案页面族落地时启用。
enum AnalyticsPageName {
// —— 字典 v2 初始集合(06 §5.2 验收 2)——
login('login'),
register('register'),
home('home'),
profile('profile'),
petList('pet_list'),
petDetail('pet_detail'),
petForm('pet_form'),
recordForm('record_form'),
recordDetail('record_detail'),
// —— 客户端现存页面/Tab 补充(03 §3.2 Tab 映射,需数据侧同步进字典)——
create('create'),
petArchive('pet_archive'),
services('services'),
postDetail('post_detail'),
// —— 字典 v3 页面族(22 号报告 §2;本枚举登记 M3 已落地页)——
/// 发布页(P3T3-17 push 全屏页)。
postForm('post_form');
const AnalyticsPageName(this.pageName);
/// 上报值,snake_case,与 RouteSettings.name 一致。
final String pageName;
static final Map<String, AnalyticsPageName> _byName = {
for (final page in values) page.pageName: page,
};
/// 路由名 → 枚举;不在枚举内返回 null(调用方不上报,06 §5.2 验收 4)。
static AnalyticsPageName? fromRouteName(String? name) =>
name == null ? null : _byName[name];
}
@@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
/// 集中式 page_viewed 路由埋点(03 号评估 §3.2NavigatorObserver 派生,
/// 页面零侵入;06 号报告 §5.2 验收 1/4)。
///
/// 只统计 [PageRoute]bottom sheet / dialog 是 PopupRoute,不计曝光);
/// 路由名不在 [AnalyticsPageName] 枚举内不上报(后端会整条 rejected,
/// 不白白消耗队列)。didPop 补报「回退后重新曝光的前一页」;弹回到
/// 无名根路由(认证状态机的 AnimatedSwitcher 宿主)时经 [resolveRootPage]
/// 解析当前根部页面。
class AnalyticsRouteObserver extends NavigatorObserver {
AnalyticsRouteObserver({required this.tracker, this.resolveRootPage});
final PageViewTracker tracker;
/// 根路由(无 RouteSettings.name)当前展示页的解析器,由 app.dart
/// 按认证状态与主壳当前 Tab 提供;返回 null 则不补报。
final AnalyticsPageName? Function()? resolveRootPage;
void _reportRoute(Route<dynamic>? route) {
if (route is! PageRoute) return;
final page = AnalyticsPageName.fromRouteName(route.settings.name);
if (page != null) tracker.report(page);
}
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
_reportRoute(route);
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
_reportRoute(newRoute);
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (previousRoute is! PageRoute) return;
final page = AnalyticsPageName.fromRouteName(previousRoute.settings.name);
if (page != null) {
tracker.report(page);
} else if (previousRoute.isFirst) {
final rootPage = resolveRootPage?.call();
if (rootPage != null) tracker.report(rootPage);
}
}
}
+296
View File
@@ -0,0 +1,296 @@
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';
/// 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(),
_store = store ?? AnalyticsEventStore(),
_now = now ?? DateTime.now;
/// 异步设置 appVersionapp.dart 启动时从 package_info_plus 读取后注入)。
void setAppVersion(String version) {
_appVersion = version;
}
// 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4)。
static const _flushThreshold = 20;
// 契约单批上限(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;
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
final String Function() getSessionId;
/// 前台定时冲刷周期(13 号 §3.4 第 4 触发点),构造参数化便于测试注入。
final Duration flushInterval;
String _anonymousId;
/// 构造显式注入 anonymousId 的测试通道不参与持久化采用/落盘。
final bool _anonymousIdInjected;
String _appVersion;
final String _osVersion;
String? _userId;
bool _flushing = false;
final AnalyticsEventStore _store;
/// 时钟注入口(照 SessionTracker 先例),退避判定测试免真实等待。
final DateTime Function() _now;
Timer? _flushTimer;
Duration? _backoffDelay;
DateTime? _retryNotBefore;
/// 待上报事件(测试断言用,生产代码不得直接操作)。
@visibleForTesting
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;
}
/// Clears userId on logout (anonymousId remains).
void reset() {
_userId = null;
}
/// Tracks event (never throws, never awaits network). Props are validated
/// for privacy red-line patterns locally before queuing.
Future<void> trackEvent(
String eventName, [
Map<String, dynamic>? props,
]) async {
try {
if (props != null && _containsForbiddenField(props)) {
debugPrint('Analytics: event $eventName rejected (forbidden field)');
return;
}
final event = {
'eventId': const Uuid().v7(),
'eventName': eventName,
'eventVersion': 1,
'anonymousId': _anonymousId,
if (_userId != null) 'userId': _userId,
'sessionId': getSessionId(),
'clientTs': DateTime.now().toUtc().toIso8601String(),
'appVersion': _appVersion,
'platform': _platformName(),
'osVersion': _osVersion,
if (props != null && props.isNotEmpty) 'props': props,
};
await _store.add(event);
if (_store.length >= _flushThreshold) {
await _flush();
}
} catch (error) {
debugPrint('Analytics track failed: $error');
}
}
/// 冷启动恢复:先采用持久化 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) return;
_flushing = true;
try {
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;
}
}
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'))
..headers.contentType = ContentType.json;
if (token != null) {
request.headers.add('Authorization', 'Bearer $token');
}
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) {
// Dart RegExp 不支持 (?i) 内联标志(原写法构造即抛异常,事件被静默丢弃)。
final pattern = RegExp(
r'password|token|secret|phone|mobile|email|credential|idfa|gaid',
caseSensitive: false,
);
return props.keys.any((key) => pattern.hasMatch(key));
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
/// trackEvent 形状的回调(生产传 `AnalyticsService.trackEvent`,测试传录制桩)。
typedef TrackEventFn =
Future<void> Function(String eventName, [Map<String, dynamic>? props]);
/// page_viewed 上报的单一出口:维护 referrer 链与去重。
///
/// 路由事件(AnalyticsRouteObserver)、主壳 Tab 切换、认证状态机切页
/// 三类曝光都经此上报,referrer(前一页 pageName)才能跨机制连贯;
/// 栈底/冷启动首页 referrer 为 null06 §5.2 验收 3)。
class PageViewTracker {
PageViewTracker(this._track);
final TrackEventFn _track;
String? _lastPageName;
AnalyticsPageName? _currentTab;
/// 主壳当前 Tab(供回栈补报解析根路由曝光页)。
AnalyticsPageName? get currentTab => _currentTab;
/// 上报一次页面曝光;与上一条相同的页面去重(Tab 重复点选等)。
void report(AnalyticsPageName page) {
if (page.pageName == _lastPageName) return;
final referrer = _lastPageName;
_lastPageName = page.pageName;
final props = <String, dynamic>{'pageName': page.pageName};
if (referrer != null) props['referrer'] = referrer;
_track('page_viewed', props);
}
/// 主壳 Tab 曝光:记录当前 Tab 后上报(IndexedStack 无路由事件,手动补点)。
void reportTab(AnalyticsPageName page) {
_currentTab = page;
report(page);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
/// sessionId 生命周期管理(13 号规范 §3.1、06 号报告 §5.1)。
///
/// 语义三条:冷启动生成新 sessionId`paused → resumed` 间隔超过
/// [timeout](默认 30 分钟)生成新 sessionId;未超过则沿用原值。
/// sessionId 不落任何持久化存储——会话本该跨冷启动失效(03 号评估 §3.1,
/// 纯内存方案,lastActiveAt 不持久化)。
///
/// 注意生命周期级联:前台恢复时状态机会依次经过
/// `paused → hidden → inactive → resumed`,因此只在**离开 resumed 的第一次
/// 变更**记录退后台时刻,后续级联状态不得覆盖,否则间隔永远趋近于零。
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();
/// 后台超时阈值;构造参数化便于测试注入。
final Duration timeout;
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
final VoidCallback? onLeaveForeground;
/// 回到前台时回调(app 装配层用于恢复 30 秒定时冲刷);
/// 冷启动首个 resumed 不触发(此前未离开过前台)。
final VoidCallback? onEnterForeground;
/// 时钟注入口,测试免真实等待。
final DateTime Function() _now;
String _sessionId;
DateTime? _leftForegroundAt;
AppLifecycleState _lastState = AppLifecycleState.resumed;
/// 当前会话标识(UUIDv7)。
String get sessionId => _sessionId;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final leftAt = _leftForegroundAt;
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;
}
}
+273 -4
View File
@@ -1,16 +1,285 @@
// import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/analytics/session_tracker.dart';
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
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 StatelessWidget{
const App({super.key});
class App extends StatefulWidget {
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();
}
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() {
super.initState();
appState = AppState()..load();
sessionManager =
widget.sessionManager ??
SessionManager(store: const SecureTokenStore());
_sessionTracker = SessionTracker(
onLeaveForeground: () {
// 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。
_analytics.stopPeriodicFlush();
_analytics.flushNow();
},
onEnterForeground: () => _analytics.startPeriodicFlush(),
);
WidgetsBinding.instance.addObserver(_sessionTracker);
_analytics = AnalyticsService(
apiBaseUrl: patbondUserApiBaseUrl,
getAccessToken: () => sessionManager.accessToken,
getSessionId: () => _sessionTracker.sessionId,
);
_initAppVersion();
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
_analytics.restore();
// 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。
_analytics.startPeriodicFlush();
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
_routeObserver = AnalyticsRouteObserver(
tracker: _pageViewTracker,
resolveRootPage: _resolveRootPage,
);
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);
}
/// 异步读取 appVersion 并注入 analytics(启动后台任务,不阻塞渲染)。
Future<void> _initAppVersion() async {
try {
final info = await PackageInfo.fromPlatform();
_analytics.setAppVersion('${info.version}+${info.buildNumber}');
} catch (error) {
// 读取失败回退 'unknown'AnalyticsService 构造默认值已兜底)。
}
}
/// 三服务分端口直连(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 = _ensureRefresher();
final api = ApiClient(
dio: dio,
session: sessionManager,
refresher: refresher,
);
return ApiAuthRepository(
api: api,
session: sessionManager,
refresher: refresher,
analytics: _analytics,
);
}
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) {
AuthStatus.unknown => null,
AuthStatus.unauthenticated => AnalyticsPageName.login,
AuthStatus.authenticated => _pageViewTracker.currentTab,
};
}
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,
AuthStatus.authenticated => _pageViewTracker.currentTab,
AuthStatus.unknown => null,
};
if (page != null) _pageViewTracker.report(page);
}
@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();
}
Widget _homeForStatus() {
switch (sessionManager.status) {
case AuthStatus.unknown:
return SplashPage(
key: const ValueKey('splash'),
authRepository: authRepository,
sessionManager: sessionManager,
);
case AuthStatus.unauthenticated:
return LoginPage(
key: const ValueKey('login'),
authRepository: authRepository,
);
case AuthStatus.authenticated:
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,
);
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Patbond',
debugShowCheckedModeBanner: false,
home: const MainShellPage(),
theme: buildAppTheme(),
navigatorObservers: [_routeObserver],
home: ListenableBuilder(
listenable: sessionManager,
builder: (context, _) {
// 认证状态切换统一 300ms 淡入淡出(Splash ↔ 登录 ↔ 主壳,
// BrandMark 同构保证品牌区过渡对位,组装稿 §7)。
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _homeForStatus(),
);
},
),
);
}
}
+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;
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
/// 统一页面切换:300ms 淡入淡出(12 号组装稿 §7 流程约束)。
///
/// [settings] 从 M2 起必传路由名(page_viewed 取数来源,03 号评估 §2.1),
/// 名字用 `AnalyticsPageName` 枚举的 pageName 值。
Route<T> fadePageRoute<T>(Widget page, {RouteSettings? settings}) {
return PageRouteBuilder<T>(
settings: settings,
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 300),
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
);
}
+177
View File
@@ -0,0 +1,177 @@
import 'package:dio/dio.dart';
import 'package:patbond_flutter/core/network/api_envelope.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';
/// API 基地址:`--dart-define=PATBOND_API_BASE_URL=...` 注入,默认本机后端。
const String patbondApiBaseUrl = String.fromEnvironment(
'PATBOND_API_BASE_URL',
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] 统一解析成
/// 类型化异常,不依赖 dio 的 4xx/5xx 抛错路径。
Dio buildPatbondDio({required SessionManager session, String? baseUrl}) {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl ?? patbondApiBaseUrl,
connectTimeout: const Duration(seconds: 5),
sendTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
contentType: Headers.jsonContentType,
validateStatus: (_) => true,
),
);
dio.interceptors.add(AuthInterceptor(session));
return dio;
}
/// 鉴权拦截器:对标记 [ApiClient.requiresAuthExtra] 的请求附加
/// `Authorization: Bearer`,并统一携带设备标识。
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._session);
final SessionManager _session;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final deviceId = _session.deviceId;
if (deviceId != null) {
options.headers['X-Device-Id'] = deviceId;
}
if (options.extra[ApiClient.requiresAuthExtra] == true) {
final token = _session.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options);
}
}
/// HTTP 客户端封装:错误信封 → 类型化异常,401/40101 → 刷新后重放一次。
class ApiClient {
ApiClient({
required this._dio,
required this._session,
required this._refresher,
});
/// RequestOptions.extra 标记:该请求需要 Bearer 鉴权。
static const requiresAuthExtra = 'patbond.requiresAuth';
final Dio _dio;
final SessionManager _session;
final TokenRefresher _refresher;
/// 发起请求并返回信封中的 `data`。
///
/// 鉴权请求遇 HTTP 401 或 code 40101 时:经单飞刷新后原样重放一次;
/// 重放仍失败则清会话并抛 [SessionExpiredException]。
Future<Object?> request(
String path, {
String method = 'POST',
Object? body,
Map<String, Object?>? query,
Map<String, Object?>? headers,
bool requiresAuth = false,
}) async {
var response = await _send(
path,
method: method,
body: body,
query: query,
headers: headers,
requiresAuth: requiresAuth,
);
if (requiresAuth && _isAuthFailure(response)) {
await _refresher.refresh();
response = await _send(
path,
method: method,
body: body,
query: query,
headers: headers,
requiresAuth: requiresAuth,
);
if (_isAuthFailure(response)) {
await _session.clearSession();
throw const SessionExpiredException('刷新后重放仍未通过鉴权');
}
}
return _unwrap(response);
}
Future<Response<Object?>> _send(
String path, {
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,
extra: {requiresAuthExtra: requiresAuth},
),
);
} on DioException catch (error) {
throw ApiNetworkException('请求失败:${error.type.name}');
}
}
bool _isAuthFailure(Response<Object?> response) {
if (response.statusCode == 401) return true;
return ApiEnvelope.tryParse(response.data)?.code ==
ApiCodes.accessTokenInvalid;
}
Object? _unwrap(Response<Object?> response) {
final status = response.statusCode ?? 0;
if (status == 429) {
throw const ApiRateLimitException();
}
final envelope = ApiEnvelope.tryParse(response.data);
if (envelope == null || status >= 500) {
throw ApiNetworkException('响应异常:HTTP $status');
}
if (envelope.code == ApiCodes.ok) {
return envelope.data;
}
throw ApiBusinessException(code: envelope.code, message: envelope.message);
}
}
+20
View File
@@ -0,0 +1,20 @@
/// 服务端统一响应信封 `{code, message, data}` 的解析结果。
class ApiEnvelope {
const ApiEnvelope({required this.code, required this.message, this.data});
final int code;
final String message;
final Object? data;
/// 从响应体解析信封;结构不符(非 JSON 对象 / 无 int code)返回 null。
static ApiEnvelope? tryParse(Object? body) {
if (body is! Map) return null;
final code = body['code'];
if (code is! int) return null;
return ApiEnvelope(
code: code,
message: body['message'] is String ? body['message'] as String : '',
data: body['data'],
);
}
}
+119
View File
@@ -0,0 +1,119 @@
/// 服务端错误信封 `{code, message, data}` 的业务码常量(接口契约冻结稿)。
abstract final class ApiCodes {
static const ok = 0;
/// 参数错误。
static const paramError = 40000;
/// 用户名或密码错误。
static const badCredentials = 40100;
/// access token 无效或过期。
static const accessTokenInvalid = 40101;
/// refresh token 已失效。
static const refreshTokenInvalid = 40102;
/// 用户名重复。
static const usernameTaken = 40900;
/// 手机号重复。
static const phoneTaken = 40901;
/// 登录失败次数过多,账号临时锁定(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 调用的类型化异常。页面按类型映射为三层错误呈现
/// (字段级 / 表单横幅 / SnackBar,见 12 号组装稿 §4),
/// 服务端原始 message 一律不直接透出给用户。
sealed class ApiException implements Exception {
const ApiException(this.message);
/// 服务端返回的原始 message(仅用于日志排查,不上屏)。
final String message;
@override
String toString() => '$runtimeType: $message';
}
/// 系统错误:超时、断网、5xx、响应无法解析。归瞬态层(SnackBar + 重试)。
final class ApiNetworkException extends ApiException {
const ApiNetworkException([super.message = '网络或服务不可用']);
}
/// 业务错误:错误信封 code != 040000/40100/40900/40901 等)。
/// 领域层可按错误码细分子类型(见 features/pets/pet_exceptions.dart)。
class ApiBusinessException extends ApiException {
const ApiBusinessException({required this.code, required String message})
: super(message);
final int code;
}
/// 限流(HTTP 429):映射为横幅「尝试次数过多,请稍后再试」。
final class ApiRateLimitException extends ApiException {
const ApiRateLimitException([super.message = '请求过于频繁']);
}
/// 会话已失效:refresh token 失效(40102)或刷新重放后仍 401。
/// 抛出前本地会话已被清除,应用会自动回到登录页。
final class SessionExpiredException extends ApiException {
const SessionExpiredException([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")';
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:dio/dio.dart';
import 'package:patbond_flutter/core/network/api_envelope.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
/// 单飞(single-flighttoken 刷新器。
///
/// 并发触发的多个 401/40101 只发出一次 `POST /auth/refresh`
/// 其余调用共享同一个进行中的刷新结果。
///
/// 清会话纪律(12 号组装稿 §7):只有服务端明确判定 refresh 失效
/// 40102 或 HTTP 401)才清除本地会话;网络失败、5xx 一律保留 token。
class TokenRefresher {
TokenRefresher({required this._dio, required this._session});
final Dio _dio;
final SessionManager _session;
Future<void>? _inflight;
/// 刷新并轮换 token。成功后新 token 已写入 [SessionManager]。
///
/// 抛出 [SessionExpiredException](会话已被清除)或
/// [ApiNetworkException]token 保留,可重试)。
Future<void> refresh() {
return _inflight ??= _doRefresh().whenComplete(() => _inflight = null);
}
Future<void> _doRefresh() async {
await _session.loadFromStorage();
final refreshToken = _session.refreshToken;
if (refreshToken == null) {
await _session.clearSession();
throw const SessionExpiredException('本地无 refresh token');
}
final Response<Object?> response;
try {
response = await _dio.post<Object?>(
'/api/v1/auth/refresh',
data: {'refreshToken': refreshToken},
);
} on DioException catch (error) {
throw ApiNetworkException('刷新请求失败:${error.type.name}');
}
final envelope = ApiEnvelope.tryParse(response.data);
if (response.statusCode == 401 ||
envelope?.code == ApiCodes.refreshTokenInvalid) {
await _session.clearSession();
throw const SessionExpiredException('refresh token 已失效');
}
if (envelope != null &&
envelope.code == ApiCodes.ok &&
envelope.data is Map<String, dynamic>) {
await _session.updateTokens(
AuthTokens.fromJson(envelope.data! as Map<String, dynamic>),
);
return;
}
throw ApiNetworkException(
'刷新响应异常:HTTP ${response.statusCode}code ${envelope?.code}',
);
}
}
+196
View File
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
/// 语义色 tokenADR-005:以 AI宠物_iOS_UI设计稿.html 珊瑚橙暖色系为品牌正典,
/// 详见 iteration-1-reports/04-ui-login-design-spec.md §1.1)。
abstract final class AppColors {
/// 品牌珊瑚橙:图标、装饰、渐变起点;不用于承载文字的实心填充(对比度不足)。
static const primary = Color(0xFFFF6F4C);
/// 加深珊瑚:实心按钮填充、可点击文字链接(白字约 4.5:1,WCAG AA)。
static const primaryStrong = Color(0xFFD6431A);
/// 浅色底上的强调文字、按钮反白替代。
static const primaryDark = Color(0xFF7A2E12);
/// amber:渐变终点、徽章、会员/促销 CTA。
static const accent = Color(0xFFFFB648);
/// amber 底上的文字。
static const accentDark = Color(0xFF7A4B0A);
/// 页面背景(奶油底)。
static const canvas = Color(0xFFFFF7ED);
/// 卡片、输入框背景。
static const surface = Color(0xFFFFFFFF);
/// peach:图标底、占位块、选中指示。
static const surfaceTint = Color(0xFFFFE8D6);
/// 主文字。
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);
/// 描边、分隔线。
static const border = Color(0xFFF0DCC8);
/// sage:成功提示的图标/描边。
static const success = Color(0xFF7FA88A);
/// 成功提示底上的文字。
static const successInk = Color(0xFF3F5744);
/// 成功提示底色。
static const successSurface = Color(0xFFE8F0E8);
static const warning = Color(0xFFF59E0B);
/// 校验错误文字、错误描边、失败提示(白底上约 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,
end: Alignment.bottomRight,
colors: [primary, accent],
);
}
/// 圆角 token(设计规范 §1.3)。
abstract final class AppRadius {
/// 小按钮、内嵌 CTA、错误横幅。
static const sm = 12.0;
/// 主按钮、SnackBar、菜单组。
static const md = 16.0;
/// 输入框、feed 卡。
static const lg = 18.0;
/// 大卡片。
static const xl = 24.0;
/// 芯片、徽章。
static const pill = 999.0;
}
ThemeData buildAppTheme() {
final scheme = ColorScheme.fromSeed(
seedColor: AppColors.primary,
brightness: Brightness.light,
surface: AppColors.surface,
).copyWith(error: AppColors.error, onError: Colors.white);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: AppColors.canvas,
fontFamilyFallback: const [
'Noto Sans CJK SC',
'Noto Sans SC',
'PingFang SC',
'Microsoft YaHei',
'sans-serif',
],
textTheme: const TextTheme(
headlineSmall: TextStyle(
color: AppColors.ink,
fontSize: 22,
fontWeight: FontWeight.w800,
),
titleLarge: TextStyle(
color: AppColors.ink,
fontSize: 18,
fontWeight: FontWeight.w800,
),
titleMedium: TextStyle(
color: AppColors.ink,
fontSize: 15,
fontWeight: FontWeight.w700,
),
bodyMedium: TextStyle(color: AppColors.ink, fontSize: 14, height: 1.5),
bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4),
),
cardTheme: const CardThemeData(
color: AppColors.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
side: BorderSide(color: AppColors.border),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.primaryStrong,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.ink.withAlpha(31),
disabledForegroundColor: AppColors.ink.withAlpha(97),
minimumSize: const Size(64, 52),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
inputDecorationTheme: const InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.error, width: 1.5),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.error, width: 1.5),
),
errorStyle: TextStyle(color: AppColors.error, fontSize: 12),
helperStyle: TextStyle(color: AppColors.muted, fontSize: 12),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surface,
indicatorColor: AppColors.surfaceTint,
height: 72,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
return TextStyle(
fontSize: 11,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w700
: FontWeight.w500,
color: states.contains(WidgetState.selected)
? AppColors.primaryStrong
: AppColors.muted,
);
}),
),
);
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 表单输入框封装(设计规范 §2.2 / §4.1)。
///
/// 密码模式([obscurable] 为 true)内置可见性切换按钮,点击目标 ≥44×44。
/// 提交中禁用时整体 60% 不透明度。
class AppTextField extends StatefulWidget {
const AppTextField({
required this.label,
super.key,
this.controller,
this.prefixIcon,
this.prefixText,
this.errorText,
this.helperText,
this.enabled = true,
this.obscurable = false,
this.keyboardType,
this.textInputAction,
this.autofillHints,
this.onChanged,
this.onSubmitted,
});
final String label;
final TextEditingController? controller;
final IconData? prefixIcon;
/// 固定前缀文本(如手机号的 '+86 '),仅展示与拼接语义,不进输入值。
final String? prefixText;
final String? errorText;
final String? helperText;
final bool enabled;
/// 是否为密码模式:默认遮蔽输入,并显示可见性切换按钮。
final bool obscurable;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final Iterable<String>? autofillHints;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
@override
State<AppTextField> createState() => _AppTextFieldState();
}
class _AppTextFieldState extends State<AppTextField> {
bool obscured = true;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: widget.enabled ? 1 : 0.6,
child: TextFormField(
controller: widget.controller,
enabled: widget.enabled,
obscureText: widget.obscurable && obscured,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
autofillHints: widget.autofillHints,
onChanged: widget.onChanged,
onFieldSubmitted: widget.onSubmitted,
decoration: InputDecoration(
labelText: widget.label,
errorText: widget.errorText,
helperText: widget.helperText,
prefixText: widget.prefixText,
prefixIcon: widget.prefixIcon == null
? null
: Icon(widget.prefixIcon, color: AppColors.muted),
suffixIcon: widget.obscurable
? IconButton(
tooltip: obscured ? '显示密码' : '隐藏密码',
constraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
onPressed: widget.enabled
? () => setState(() => obscured = !obscured)
: null,
icon: Icon(
obscured ? Icons.visibility_off : Icons.visibility,
color: AppColors.muted,
),
)
: null,
),
),
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
/// 认证页统一框架:SafeArea + 滚动 + 水平 padding 24 + 键盘避让(设计规范 §6.2)。
///
/// 登录 / 注册 / 找回密码共用。[appBar] 供注册页传入透明返回栏;
/// 键盘弹出时内容可滚动,防止溢出。
class AuthScaffold extends StatelessWidget {
const AuthScaffold({required this.child, super.key, this.appBar});
final Widget child;
final PreferredSizeWidget? appBar;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: appBar,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: child,
),
);
},
),
),
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 品牌区:logo + 字标 + slogan(设计规范 §2.2 / §5.2)。
///
/// Splash 与登录页共用,保证两处渲染一致,是淡入过渡成立的前提。
class BrandMark extends StatelessWidget {
const BrandMark({super.key, this.size = 72, this.slogan = '和毛孩子在一起的每一天'});
/// logo 图形边长。
final double size;
/// 传 null 时不渲染 slogan 行。
final String? slogan;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: size,
height: size,
decoration: const BoxDecoration(
gradient: AppColors.brandGradient,
borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
),
child: Icon(Icons.pets, size: size * 0.5, color: Colors.white),
),
const SizedBox(height: 12),
const Text(
'Patbond',
style: TextStyle(
color: AppColors.primary,
fontSize: 32,
fontWeight: FontWeight.w700,
),
),
if (slogan != null) ...[
const SizedBox(height: 4),
Text(
slogan!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 14),
),
],
],
);
}
}
+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),
),
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 表单级错误横幅(设计规范 §4.2)。
///
/// 用于无法归属单一字段的业务错误(如 401「用户名或密码错误」),
/// 停留在表单上下文里供用户对照修改,不用 SnackBar。
class InlineErrorBanner extends StatelessWidget {
const InlineErrorBanner({required this.message, super.key});
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withAlpha(20),
borderRadius: const BorderRadius.all(Radius.circular(AppRadius.sm)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, size: 18, color: AppColors.error),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(
color: AppColors.error,
fontSize: 13,
height: 1.4,
),
),
),
],
),
);
}
}
+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;
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 主操作按钮:全宽 × 52 高,`primaryStrong` 填充(设计规范 §2.2 / §4.3 / §4.4)。
///
/// [isLoading] 为 true 时文字替换为 20×20 白色转圈,按钮尺寸不变、不可再点,
/// 填充保持 `primaryStrong`loading 不是禁用态,不置灰)。
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
required this.label,
super.key,
this.onPressed,
this.isLoading = false,
});
final String label;
/// 传 null 进入禁用态(主题 disabled 置灰)。
final VoidCallback? onPressed;
final bool isLoading;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 52,
child: FilledButton(
onPressed: isLoading ? null : onPressed,
style: isLoading
? FilledButton.styleFrom(
disabledBackgroundColor: AppColors.primaryStrong,
)
: null,
child: isLoading
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Text(label),
),
);
}
}
+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);
}
}
+245
View File
@@ -0,0 +1,245 @@
import 'package:patbond_flutter/models/models.dart';
const petAvatar =
'https://images.unsplash.com/photo-1552053831-71594a27632d?auto=format&fit=crop&w=600&q=85';
const userAvatar =
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=300&q=80';
const generatedPetImage =
'https://images.unsplash.com/photo-1605568427561-40dd23c2acea?auto=format&fit=crop&w=1200&q=85';
const locationWeatherOptions = [
LocationWeather(
city: '北京',
district: '朝阳区',
temperature: 28,
condition: WeatherCondition.sunny,
conditionText: '',
highTemperature: 31,
lowTemperature: 21,
humidity: 42,
),
LocationWeather(
city: '上海',
district: '浦东新区',
temperature: 25,
condition: WeatherCondition.cloudy,
conditionText: '多云',
highTemperature: 28,
lowTemperature: 22,
humidity: 68,
),
LocationWeather(
city: '深圳',
district: '南山区',
temperature: 32,
condition: WeatherCondition.rainy,
conditionText: '阵雨',
highTemperature: 34,
lowTemperature: 27,
humidity: 81,
),
LocationWeather(
city: '成都',
district: '高新区',
temperature: 23,
condition: WeatherCondition.overcast,
conditionText: '',
highTemperature: 26,
lowTemperature: 19,
humidity: 72,
),
LocationWeather(
city: '杭州',
district: '西湖区',
temperature: 27,
condition: WeatherCondition.cloudy,
conditionText: '多云',
highTemperature: 30,
lowTemperature: 22,
humidity: 64,
),
LocationWeather(
city: '哈尔滨',
district: '道里区',
temperature: 6,
condition: WeatherCondition.snow,
conditionText: '小雪',
highTemperature: 8,
lowTemperature: 1,
humidity: 73,
),
];
final initialLocationWeather = locationWeatherOptions.first;
const initialPet = PetProfile(
name: '豆豆',
breed: '柴犬',
gender: PetGender.male,
birthday: '2024-05-15',
weight: 5.2,
tags: ['已绝育', '疫苗齐全', '定期驱虫', '芯片植入'],
avatarUrl: petAvatar,
);
const initialPosts = [
PostModel(
id: 'post1',
authorName: '豆豆的妈妈',
authorAvatar: userAvatar,
time: '2小时前',
breedTag: '柴犬',
content: '用 AI 把豆豆生成了治愈系插画,太可爱了吧!大家也快去试试创作功能!✨',
mainImage: generatedPetImage,
likes: 128,
tags: ['AI创作', '柴犬', 'Patbond'],
comments: [
CommentModel(
id: 'c1',
authorName: 'CorgiLover99',
authorAvatar:
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80',
content: '这也太像了吧!求教程怎么生成的?',
time: '1小时前',
),
CommentModel(
id: 'c2',
authorName: 'MiloDaCat',
authorAvatar:
'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=200&q=80',
content: '好可爱的画风,豆豆真上镜!',
time: '45分钟前',
),
],
),
PostModel(
id: 'post2',
authorName: 'Momo と猫',
authorAvatar:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=200&q=80',
time: '5小时前',
breedTag: '求助专区',
content: '猫咪最近总是挠耳朵,有懂的朋友吗?',
mainImage:
'https://images.unsplash.com/photo-1574158622682-e40e69881006?auto=format&fit=crop&w=1200&q=85',
likes: 12,
tags: ['猫咪健康', '求助'],
comments: [
CommentModel(
id: 'c3',
authorName: '猫奴小七',
authorAvatar:
'https://images.unsplash.com/photo-1527980965255-d3b416303d12?auto=format&fit=crop&w=200&q=80',
content: '建议先去医院检查耳道,不要自行用药哦。',
time: '4小时前',
),
],
),
];
const serviceProviders = [
ServiceProviderModel(
id: 'h1',
kind: ProviderKind.hospital,
name: '安宠动物医院(高新旗舰店)',
distance: '1.2km',
rating: 4.9,
description: '猫科内科 · 骨外科 · 预防医学 · 24H急诊',
tags: ['猫专科认证', '24H急诊'],
price: 299,
image:
'https://images.unsplash.com/photo-1628009368231-7bb7cfcb0def?auto=format&fit=crop&w=900&q=85',
verified: true,
),
ServiceProviderModel(
id: 'h2',
kind: ProviderKind.hospital,
name: '瑞派宠物医院(南山分院)',
distance: '3.5km',
rating: 4.7,
description: '异宠专科 · 牙科 · 皮肤科',
tags: ['异宠专家坐诊'],
price: 399,
image:
'https://images.unsplash.com/photo-1581888227599-779811939961?auto=format&fit=crop&w=900&q=85',
),
ServiceProviderModel(
id: 'g1',
kind: ProviderKind.grooming,
name: 'PawSpa 高级宠物沙龙',
distance: '1.6km',
rating: 4.9,
description: '资深美容师主理 · 环境静音 · 猫咪免应激',
tags: ['日系精修', '猫咪免应激'],
price: 158,
image:
'https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&w=900&q=85',
verified: true,
),
ServiceProviderModel(
id: 'p1',
kind: ProviderKind.personal,
name: '阳光遛狗员-阿健',
distance: '0.8km',
rating: 4.9,
description: '资深铲屎官,提供耐心、安全的上门遛狗服务。',
tags: ['实时定位', '自带清洁包', '只接中小型犬'],
price: 45,
image:
'https://images.unsplash.com/photo-1601758228041-f3b2795255f1?auto=format&fit=crop&w=900&q=85',
verified: true,
),
ServiceProviderModel(
id: 'p2',
kind: ProviderKind.personal,
name: '喵汪小保姆-莉莉',
distance: '3.5km',
rating: 4.7,
description: '周末全天可接单,可接大型犬并提供喂食服务。',
tags: ['可接大型犬', '体能充沛'],
price: 60,
image:
'https://images.unsplash.com/photo-1558788353-f76d92427f16?auto=format&fit=crop&w=900&q=85',
),
];
const creationStyles = [
CreationStyle(
id: 'healing',
title: '治愈动画',
subtitle: '梦幻动漫感',
image: generatedPetImage,
),
CreationStyle(
id: '3d',
title: '3D 卡通',
subtitle: '经典渲染',
image:
'https://images.unsplash.com/photo-1558929996-da64ba858215?auto=format&fit=crop&w=700&q=85',
),
CreationStyle(
id: 'comic',
title: '漫画风',
subtitle: '帅气网格',
image:
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=700&q=85',
),
CreationStyle(
id: 'watercolor',
title: '水彩',
subtitle: '柔和手绘质感',
image:
'https://images.unsplash.com/photo-1537151608828-ea2b11777ee8?auto=format&fit=crop&w=700&q=85',
),
];
const serviceCategories = [
('投喂', 'restaurant'),
('遛狗', 'walk'),
('洗澡', 'bath'),
('美容', 'cut'),
('寄养', 'home'),
('运输', 'truck'),
('领养', 'favorite'),
('医院', 'hospital'),
];
+58
View File
@@ -0,0 +1,58 @@
/// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。
class AuthTokens {
const AuthTokens({
required this.userId,
required this.tokenType,
required this.accessToken,
required this.accessTokenExpiresAt,
required this.refreshToken,
required this.refreshTokenExpiresAt,
});
factory AuthTokens.fromJson(Map<String, dynamic> json) {
return AuthTokens(
userId: json['userId'] as String,
tokenType: json['tokenType'] as String,
accessToken: json['accessToken'] as String,
accessTokenExpiresAt: DateTime.parse(
json['accessTokenExpiresAt'] as String,
),
refreshToken: json['refreshToken'] as String,
refreshTokenExpiresAt: DateTime.parse(
json['refreshTokenExpiresAt'] as String,
),
);
}
final String userId;
final String tokenType;
final String accessToken;
final DateTime accessTokenExpiresAt;
final String refreshToken;
final DateTime refreshTokenExpiresAt;
}
/// `GET /api/v1/me` 的用户资料。
class UserProfile {
const UserProfile({
required this.userId,
required this.username,
required this.phone,
required this.createdAt,
});
factory UserProfile.fromJson(Map<String, dynamic> json) {
return UserProfile(
userId: json['userId'] as String,
username: json['username'] as String,
// 服务端可返回 null(历史数据或未来第三方注册),不得非空强转。
phone: json['phone'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String userId;
final String username;
final String? phone;
final DateTime createdAt;
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:patbond_flutter/analytics/analytics_service.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/auth_models.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:uuid/uuid.dart';
/// Splash 会话恢复的裁决结果。状态切换由 Splash 在最短停留时间后执行。
enum SessionRestoreResult { authenticated, noSession }
/// 认证仓库接口(页面依赖此抽象,widget 测试注入假实现)。
abstract class AuthRepository {
Future<void> login({required String username, required String password});
Future<void> register({
required String username,
required String phone,
required String password,
});
/// 撤销当前会话并清除本地凭证。服务端调用失败也保证本地清除。
Future<void> logout();
/// 启动恢复:本地有 refresh token 则静默刷新。
/// 网络失败抛 [ApiNetworkException]token 保留);
/// refresh 失效抛 [SessionExpiredException](会话已清)。
Future<SessionRestoreResult> restoreSession();
Future<UserProfile> me();
}
class ApiAuthRepository implements AuthRepository {
ApiAuthRepository({
required this._api,
required this._session,
required this._refresher,
this._analytics,
this._uuid = const Uuid(),
});
final ApiClient _api;
final SessionManager _session;
final TokenRefresher _refresher;
final AnalyticsService? _analytics;
final Uuid _uuid;
@override
Future<void> login({
required String username,
required String password,
}) async {
final startTime = DateTime.now();
try {
final data = await _api.request(
'/api/v1/auth/login',
body: {'username': username, 'password': password},
);
await _session.saveSession(
AuthTokens.fromJson(data! as Map<String, dynamic>),
);
_analytics?.identify(_session.userId!);
_analytics?.trackEvent('auth_login_succeeded', {
'identifierType': 'username',
'durationMs': DateTime.now().difference(startTime).inMilliseconds,
});
} catch (error) {
_analytics?.trackEvent('auth_login_failed', {
'identifierType': 'username',
'failureReason': error.toString(),
});
rethrow;
}
}
@override
Future<void> register({
required String username,
required String phone,
required String password,
}) async {
final startTime = DateTime.now();
try {
final data = await _api.request(
'/api/v1/auth/register',
body: {'username': username, 'phone': phone, 'password': password},
// 每次提交一个幂等键;token 刷新后的自动重放沿用同一个键。
headers: {'Idempotency-Key': _uuid.v4()},
);
await _session.saveSession(
AuthTokens.fromJson(data! as Map<String, dynamic>),
);
_analytics?.identify(_session.userId!);
_analytics?.trackEvent('auth_register_succeeded', {
'durationMs': DateTime.now().difference(startTime).inMilliseconds,
});
} catch (error) {
_analytics?.trackEvent('auth_register_failed', {
'failureReason': error.toString(),
});
rethrow;
}
}
@override
Future<void> logout() async {
final refreshToken = _session.refreshToken;
try {
await _api.request(
'/api/v1/auth/logout',
body: {'refreshToken': refreshToken},
requiresAuth: true,
);
} on ApiException {
// 服务端撤销失败不阻塞本地登出;refresh 侧最终会自然过期。
} finally {
await _session.clearSession();
_analytics?.reset();
_analytics?.trackEvent('auth_logout', {'serverRevoked': true});
}
}
@override
Future<SessionRestoreResult> restoreSession() async {
await _session.loadFromStorage();
if (_session.refreshToken == null) {
return SessionRestoreResult.noSession;
}
await _refresher.refresh();
return SessionRestoreResult.authenticated;
}
@override
Future<UserProfile> me() async {
final data = await _api.request(
'/api/v1/me',
method: 'GET',
requiresAuth: true,
);
return UserProfile.fromJson(data! as Map<String, dynamic>);
}
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.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/auth_scaffold.dart';
import 'package:patbond_flutter/core/widgets/brand_mark.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/auth/auth_repository.dart';
import 'package:patbond_flutter/features/auth/register_page.dart';
enum _Field { account, password }
/// 登录页(12 号组装稿 §5)。登录成功后由认证状态机切入主壳。
class LoginPage extends StatefulWidget {
const LoginPage({required this.authRepository, super.key});
final AuthRepository authRepository;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _accountCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
String? _accountError;
String? _passwordError;
String? _formError;
bool _submitting = false;
@override
void dispose() {
_accountCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
String? _validateAccount() =>
_accountCtrl.text.trim().isEmpty ? '请输入用户名或手机号' : null;
String? _validatePassword() =>
_passwordCtrl.text.trim().isEmpty ? '请输入密码' : null;
void _validateAccountOnBlur() {
if (!mounted) return;
setState(() => _accountError = _validateAccount());
}
void _validatePasswordOnBlur() {
if (!mounted) return;
setState(() => _passwordError = _validatePassword());
}
/// 字段变更即清除本字段错误与表单级横幅。
void _clearErrors({required _Field field}) {
if (_formError == null &&
(field == _Field.account ? _accountError : _passwordError) == null) {
return;
}
setState(() {
_formError = null;
if (field == _Field.account) _accountError = null;
if (field == _Field.password) _passwordError = null;
});
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
void _showNetworkSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
}
Future<void> _submit() async {
if (_submitting) return;
final accountError = _validateAccount();
final passwordError = _validatePassword();
if (accountError != null || passwordError != null) {
setState(() {
_accountError = accountError;
_passwordError = passwordError;
});
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
await widget.authRepository.login(
username: _accountCtrl.text.trim(),
password: _passwordCtrl.text.trim(),
);
TextInput.finishAutofillContext();
// 认证状态机随 saveSession 切换,根路由 300ms fade 进主壳。
} on ApiBusinessException catch (error) {
if (!mounted) return;
_showFormError(switch (error.code) {
ApiCodes.badCredentials => '用户名或密码错误',
ApiCodes.loginLocked => '尝试次数过多,账号已临时锁定,请稍后再试',
_ => '登录失败,请稍后重试',
});
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('尝试次数过多,请稍后再试');
} on ApiNetworkException {
if (!mounted) return;
_showNetworkSnackBar();
} on SessionExpiredException {
// 登录请求不带会话,理论不可达;静默留在登录页。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _goRegister() {
Navigator.of(context).push(
fadePageRoute<void>(
RegisterPage(authRepository: widget.authRepository),
settings: RouteSettings(name: AnalyticsPageName.register.pageName),
),
);
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
child: AutofillGroup(
child: Column(
// AuthScaffold 内禁用 Spacer,垂直居中用 mainAxisAlignment(§4 总则)。
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 48),
const BrandMark(),
const SizedBox(height: 48),
Focus(
onFocusChange: (hasFocus) {
if (!hasFocus) _validateAccountOnBlur();
},
child: AppTextField(
label: '用户名 / 手机号',
controller: _accountCtrl,
prefixIcon: Icons.person_outline_rounded,
errorText: _accountError,
enabled: !_submitting,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
onChanged: (_) => _clearErrors(field: _Field.account),
),
),
const SizedBox(height: 16),
Focus(
onFocusChange: (hasFocus) {
if (!hasFocus) _validatePasswordOnBlur();
},
child: AppTextField(
label: '密码',
controller: _passwordCtrl,
prefixIcon: Icons.lock_outline_rounded,
obscurable: true,
errorText: _passwordError,
enabled: !_submitting,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onChanged: (_) => _clearErrors(field: _Field.password),
onSubmitted: (_) => _submit(),
),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '登录',
isLoading: _submitting,
onPressed: _submit,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'还没有账号?',
style: TextStyle(color: AppColors.muted, fontSize: 14),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
onPressed: _submitting ? null : _goRegister,
child: const Text('立即注册'),
),
],
),
const SizedBox(height: 24),
// 协议行:v1 协议页未就绪,整行不渲染(组装稿 §5)。
// 预留区(短信验证码/第三方登录):不渲染任何占位(ADR-004)。
],
),
),
);
}
}
+319
View File
@@ -0,0 +1,319 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.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/auth_scaffold.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/auth/auth_repository.dart';
enum _Field { username, phone, password, confirm }
/// 注册页(12 号组装稿 §6)。字段按 ADR-004:用户名 + 手机号 + 密码 + 确认密码;
/// 短信验证码行与第三方登录预留区一律不渲染。注册成功即建立会话直接进首页。
class RegisterPage extends StatefulWidget {
const RegisterPage({required this.authRepository, super.key});
final AuthRepository authRepository;
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
static final _usernamePattern = RegExp(r'^[A-Za-z][A-Za-z0-9_]{2,19}$');
static final _phonePattern = RegExp(r'^1\d{10}$');
final _usernameCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _confirmCtrl = TextEditingController();
String? _usernameError;
String? _phoneError;
String? _passwordError;
String? _confirmError;
String? _formError;
bool _submitting = false;
@override
void dispose() {
_usernameCtrl.dispose();
_phoneCtrl.dispose();
_passwordCtrl.dispose();
_confirmCtrl.dispose();
super.dispose();
}
String? _validateUsernameValue() {
final value = _usernameCtrl.text.trim();
if (value.isEmpty) return '请输入用户名';
if (!_usernamePattern.hasMatch(value)) {
return '用户名需 3–20 位,字母开头,可含数字和下划线';
}
return null;
}
String? _validatePhoneValue() {
final value = _phoneCtrl.text.trim();
if (value.isEmpty) return '请输入手机号';
if (!_phonePattern.hasMatch(value)) return '请输入正确的 11 位手机号';
return null;
}
String? _validatePasswordValue() {
final value = _passwordCtrl.text;
final lengthOk = value.length >= 8 && value.length <= 32;
final hasLetter = value.contains(RegExp('[A-Za-z]'));
final hasDigit = value.contains(RegExp('[0-9]'));
if (!lengthOk || !hasLetter || !hasDigit) {
return '密码需 8–32 位,且同时包含字母和数字';
}
return null;
}
String? _validateConfirmValue() =>
_confirmCtrl.text == _passwordCtrl.text ? null : '两次输入的密码不一致';
void _validateOnBlur(_Field field) {
if (!mounted) return;
setState(() {
switch (field) {
case _Field.username:
_usernameError = _validateUsernameValue();
case _Field.phone:
_phoneError = _validatePhoneValue();
case _Field.password:
_passwordError = _validatePasswordValue();
case _Field.confirm:
_confirmError = _validateConfirmValue();
}
});
}
/// 字段变更清除本字段错误与横幅;密码变更时若确认密码已有值,
/// 同步重校验一致性(组装稿 §6)。
void _clearError(_Field field) {
setState(() {
_formError = null;
switch (field) {
case _Field.username:
_usernameError = null;
case _Field.phone:
_phoneError = null;
case _Field.password:
_passwordError = null;
if (_confirmCtrl.text.isNotEmpty) {
_confirmError = _validateConfirmValue();
}
case _Field.confirm:
_confirmError = null;
}
});
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
Future<void> _submit() async {
if (_submitting) return;
final usernameError = _validateUsernameValue();
final phoneError = _validatePhoneValue();
final passwordError = _validatePasswordValue();
final confirmError = _validateConfirmValue();
if (usernameError != null ||
phoneError != null ||
passwordError != null ||
confirmError != null) {
setState(() {
_usernameError = usernameError;
_phoneError = phoneError;
_passwordError = passwordError;
_confirmError = confirmError;
});
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
await widget.authRepository.register(
username: _usernameCtrl.text.trim(),
phone: '+86${_phoneCtrl.text.trim()}',
password: _passwordCtrl.text,
);
TextInput.finishAutofillContext();
if (!mounted) return;
// 会话已建立,根路由切主壳;弹掉注册页露出新首页,不回登录页。
Navigator.of(context).popUntil((route) => route.isFirst);
} on ApiBusinessException catch (error) {
if (!mounted) return;
switch (error.code) {
case ApiCodes.usernameTaken:
setState(() => _usernameError = '该用户名已被使用');
case ApiCodes.phoneTaken:
setState(() => _phoneError = '该手机号已注册,可直接登录');
default:
_showFormError('注册失败,请稍后重试');
}
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('尝试次数过多,请稍后再试');
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
} on SessionExpiredException {
// 注册请求不带会话,理论不可达。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Widget _blurValidated(_Field field, Widget child) {
return Focus(
onFocusChange: (hasFocus) {
if (!hasFocus) _validateOnBlur(field);
},
child: child,
);
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: AppColors.ink,
),
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text('创建账号', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
Text(
'加入 Patbond,记录毛孩子的每一天',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 32),
_blurValidated(
_Field.username,
AppTextField(
label: '用户名',
controller: _usernameCtrl,
prefixIcon: Icons.person_outline_rounded,
errorText: _usernameError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.newUsername],
onChanged: (_) => _clearError(_Field.username),
),
),
const SizedBox(height: 16),
_blurValidated(
_Field.phone,
AppTextField(
label: '手机号',
controller: _phoneCtrl,
prefixIcon: Icons.phone_iphone_rounded,
prefixText: '+86 ',
errorText: _phoneError,
enabled: !_submitting,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.telephoneNumber],
onChanged: (_) => _clearError(_Field.phone),
),
),
const SizedBox(height: 16),
_blurValidated(
_Field.password,
AppTextField(
label: '密码',
controller: _passwordCtrl,
prefixIcon: Icons.lock_outline_rounded,
obscurable: true,
errorText: _passwordError,
helperText: '密码 832 位,需包含字母和数字',
enabled: !_submitting,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.newPassword],
onChanged: (_) => _clearError(_Field.password),
),
),
const SizedBox(height: 16),
_blurValidated(
_Field.confirm,
AppTextField(
label: '确认密码',
controller: _confirmCtrl,
prefixIcon: Icons.lock_outline_rounded,
obscurable: true,
errorText: _confirmError,
enabled: !_submitting,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.newPassword],
onChanged: (_) => _clearError(_Field.confirm),
onSubmitted: (_) => _submit(),
),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 32),
PrimaryButton(
label: '注册',
isLoading: _submitting,
onPressed: _submit,
),
const SizedBox(height: 16),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'已有账号?',
style: TextStyle(color: AppColors.muted, fontSize: 14),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
onPressed: _submitting
? null
: () => Navigator.of(context).pop(),
child: const Text('直接登录'),
),
],
),
),
const SizedBox(height: 24),
],
),
),
);
}
}
+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:uuid/uuid.dart';
/// 认证状态机:unknown(启动恢复中,停留 Splash)→ authenticated / unauthenticated。
enum AuthStatus { unknown, authenticated, unauthenticated }
/// token 持久化抽象。生产实现走系统安全存储(Keychain / Keystore),
/// 测试注入内存实现。token 绝不写入 SharedPreferences(开发计划 §4.2)。
abstract class TokenStore {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> delete(String key);
}
/// 基于 flutter_secure_storage 的生产实现。
class SecureTokenStore implements TokenStore {
const SecureTokenStore([this._storage = const FlutterSecureStorage()]);
final FlutterSecureStorage _storage;
@override
Future<String?> read(String key) => _storage.read(key: key);
@override
Future<void> write(String key, String value) =>
_storage.write(key: key, value: value);
@override
Future<void> delete(String key) => _storage.delete(key: key);
}
/// 会话管理:token 的内存副本 + 安全存储持久化 + 认证状态广播。
///
/// 状态变更只通过 [saveSession](登录/注册成功)、[markAuthenticated] /
/// [markUnauthenticated]Splash 恢复裁决)与 [clearSession](登出/会话失效)。
/// TokenRefresher 轮换 token 用 [updateTokens],不改状态——
/// Splash 需要在最短停留时间后才切页(12 号组装稿 §7)。
class SessionManager extends ChangeNotifier {
SessionManager({required this._store, this._uuid = const Uuid()});
static const _accessTokenKey = 'patbond_access_token';
static const _refreshTokenKey = 'patbond_refresh_token';
static const _accessExpiresKey = 'patbond_access_token_expires_at';
static const _refreshExpiresKey = 'patbond_refresh_token_expires_at';
static const _userIdKey = 'patbond_user_id';
static const _deviceIdKey = 'patbond_device_id';
final TokenStore _store;
final Uuid _uuid;
AuthStatus _status = AuthStatus.unknown;
String? _accessToken;
String? _refreshToken;
String? _userId;
String? _deviceId;
bool _loaded = false;
AuthStatus get status => _status;
String? get accessToken => _accessToken;
String? get refreshToken => _refreshToken;
String? get userId => _userId;
/// 设备标识:首次生成 UUID 并持久化,跨会话稳定。
String? get deviceId => _deviceId;
/// 从安全存储载入 token 到内存(幂等)。存储读取失败按无会话处理,
/// 不阻塞启动。
Future<void> loadFromStorage() async {
if (_loaded) return;
_accessToken = await _readOrNull(_accessTokenKey);
_refreshToken = await _readOrNull(_refreshTokenKey);
_userId = await _readOrNull(_userIdKey);
_deviceId = await _readOrNull(_deviceIdKey);
if (_deviceId == null) {
_deviceId = _uuid.v4();
await _writeQuietly(_deviceIdKey, _deviceId!);
}
_loaded = true;
}
/// 登录/注册成功:持久化整套 token 并进入已认证态。
Future<void> saveSession(AuthTokens tokens) async {
await updateTokens(tokens);
_status = AuthStatus.authenticated;
notifyListeners();
}
/// 仅写入/轮换 token,不改变认证状态(刷新流程用)。
Future<void> updateTokens(AuthTokens tokens) async {
_accessToken = tokens.accessToken;
_refreshToken = tokens.refreshToken;
_userId = tokens.userId;
_loaded = true;
await _writeQuietly(_accessTokenKey, tokens.accessToken);
await _writeQuietly(_refreshTokenKey, tokens.refreshToken);
await _writeQuietly(
_accessExpiresKey,
tokens.accessTokenExpiresAt.toIso8601String(),
);
await _writeQuietly(
_refreshExpiresKey,
tokens.refreshTokenExpiresAt.toIso8601String(),
);
await _writeQuietly(_userIdKey, tokens.userId);
}
/// Splash 恢复成功后的显式切态。
void markAuthenticated() {
_status = AuthStatus.authenticated;
notifyListeners();
}
/// 无本地会话或用户选择改用账号登录。
void markUnauthenticated() {
_status = AuthStatus.unauthenticated;
notifyListeners();
}
/// 清除会话(登出 / refresh 失效)。设备标识保留。
Future<void> clearSession() async {
_accessToken = null;
_refreshToken = null;
_userId = null;
_status = AuthStatus.unauthenticated;
notifyListeners();
for (final key in const [
_accessTokenKey,
_refreshTokenKey,
_accessExpiresKey,
_refreshExpiresKey,
_userIdKey,
]) {
try {
await _store.delete(key);
} catch (error) {
debugPrint('清除会话存储失败($key):$error');
}
}
}
Future<String?> _readOrNull(String key) async {
try {
return await _store.read(key);
} catch (error) {
debugPrint('读取安全存储失败($key):$error');
return null;
}
}
Future<void> _writeQuietly(String key, String value) async {
try {
await _store.write(key, value);
} catch (error) {
debugPrint('写入安全存储失败($key):$error');
}
}
}
+147
View File
@@ -0,0 +1,147 @@
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/brand_mark.dart';
import 'package:patbond_flutter/features/auth/auth_repository.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
enum _SplashState { checking, failed }
/// 启动 Splash(12 号组装稿 §7):静默恢复会话。
///
/// 流程约束:最短停留 500ms;刷新超时 5sspinner 等待超过 300ms 才出现;
/// 网络失败不清除本地 refresh token(只有服务端明确 401/40102 才清)。
class SplashPage extends StatefulWidget {
const SplashPage({
required this.authRepository,
required this.sessionManager,
super.key,
});
final AuthRepository authRepository;
final SessionManager sessionManager;
@override
State<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
_SplashState _state = _SplashState.checking;
bool _showSpinner = false;
Timer? _spinnerTimer;
@override
void initState() {
super.initState();
_restore();
}
@override
void dispose() {
_spinnerTimer?.cancel();
super.dispose();
}
Future<void> _restore() async {
_spinnerTimer?.cancel();
_spinnerTimer = Timer(const Duration(milliseconds: 300), () {
if (mounted) setState(() => _showSpinner = true);
});
final minStay = Future<void>.delayed(const Duration(milliseconds: 500));
try {
final result = await widget.authRepository.restoreSession().timeout(
const Duration(seconds: 5),
);
await minStay;
if (!mounted) return;
switch (result) {
case SessionRestoreResult.authenticated:
widget.sessionManager.markAuthenticated();
case SessionRestoreResult.noSession:
widget.sessionManager.markUnauthenticated();
}
} on SessionExpiredException {
// refresh 已被服务端判失效,会话已清、状态机已切换,无需处理。
} catch (_) {
// 网络失败 / 超时:token 保留,转错误态供重试。
await minStay;
if (!mounted) return;
setState(() {
_state = _SplashState.failed;
_showSpinner = false;
});
}
}
void _retryRefresh() {
setState(() {
_state = _SplashState.checking;
_showSpinner = false;
});
_restore();
}
Future<void> _clearCredentialsAndGoLogin() async {
// 逃生通道:放弃本地凭证,改用账号登录。
await widget.sessionManager.clearSession();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.canvas,
body: Center(
child: _state == _SplashState.checking
? Column(
mainAxisSize: MainAxisSize.min,
children: [
const BrandMark(),
const SizedBox(height: 32),
// 占位保高度,spinner 出现时不跳动。
SizedBox.square(
dimension: 20,
child: _showSpinner
? const CircularProgressIndicator(
strokeWidth: 2.5,
color: AppColors.primary,
)
: null,
),
],
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BrandMark(),
const SizedBox(height: 24),
const Text(
'网络连接失败,无法恢复登录',
style: TextStyle(color: AppColors.ink, fontSize: 14),
),
const SizedBox(height: 16),
OutlinedButton(
style: OutlinedButton.styleFrom(
minimumSize: const Size(120, 44),
foregroundColor: AppColors.primaryStrong,
side: const BorderSide(color: AppColors.border),
),
onPressed: _retryRefresh,
child: const Text('重试'),
),
const SizedBox(height: 8),
TextButton(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
minimumSize: const Size(44, 44),
),
onPressed: _clearCredentialsAndGoLogin,
child: const Text('改用账号登录'),
),
],
),
),
);
}
}
@@ -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;
}
+563
View File
@@ -0,0 +1,563 @@
import 'package:flutter/material.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/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.onOpenCompose,
super.key,
});
final AppState appState;
/// 真实发布页入口(主壳 push,发布成功后回首页 Feed 刷新)。
final VoidCallback onOpenCompose;
@override
State<CreatePage> createState() => _CreatePageState();
}
class _CreatePageState extends State<CreatePage> {
final titleController = TextEditingController();
final contentController = TextEditingController();
CreationMode mode = CreationMode.image;
CreationStyle selectedStyle = creationStyles.first;
String selectedModel = 'Patbond-V1';
String duration = '5 秒';
String resolution = '1080P';
bool upscaling = true;
bool uploaded = false;
bool uploading = false;
bool generating = false;
int generationStep = 0;
String? resultUrl;
List<String> tags = ['可爱修勾', 'AI宠物'];
@override
void dispose() {
titleController.dispose();
contentController.dispose();
super.dispose();
}
Future<void> simulateUpload() async {
setState(() => uploading = true);
await Future<void>.delayed(const Duration(milliseconds: 700));
if (!mounted) return;
setState(() {
uploading = false;
uploaded = true;
resultUrl = null;
});
}
Future<void> generate() async {
if (!uploaded) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先选择一张宠物照片')));
return;
}
setState(() {
generating = true;
generationStep = 1;
});
for (var step = 2; step <= 4; step++) {
await Future<void>.delayed(const Duration(milliseconds: 650));
if (!mounted) return;
setState(() => generationStep = step);
}
await Future<void>.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
setState(() {
generating = false;
resultUrl = selectedStyle.image;
titleController.text = mode == CreationMode.image
? '豆豆的${selectedStyle.title}冒险'
: '豆豆的 AI 萌宠短片';
contentController.text = '刚刚用 Patbond 创作了新作品,快来看看豆豆的新造型吧!✨';
});
}
Future<void> addTag() async {
final controller = TextEditingController();
final value = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('添加话题'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: '输入话题名称'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('添加'),
),
],
),
);
controller.dispose();
if (value == null || value.isEmpty || tags.contains(value)) return;
setState(() => tags = [...tags, value]);
}
/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media
/// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写
/// `AppState.posts`)随 T3-17 退役,此处只余占位提示。
void publish() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')),
);
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
_ComposeEntryCard(onTap: widget.onOpenCompose),
const SizedBox(height: 18),
SegmentedButton<CreationMode>(
segments: const [
ButtonSegment(
value: CreationMode.image,
icon: Icon(Icons.image_outlined),
label: Text('AI 图片'),
),
ButtonSegment(
value: CreationMode.video,
icon: Icon(Icons.movie_creation_outlined),
label: Text('AI 视频'),
),
],
selected: {mode},
showSelectedIcon: false,
onSelectionChanged: (value) => setState(() {
mode = value.first;
resultUrl = null;
}),
),
const SizedBox(height: 16),
_UploadCard(
uploaded: uploaded,
uploading: uploading,
imageUrl: widget.appState.pet.avatarUrl,
onTap: simulateUpload,
onRemove: () => setState(() {
uploaded = false;
resultUrl = null;
}),
),
const SizedBox(height: 16),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('生成设置', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 14),
DropdownButtonFormField<String>(
initialValue: selectedModel,
decoration: const InputDecoration(labelText: '创作模型'),
items: ['Patbond-V1', 'Pet-Art Pro', 'Cute Motion']
.map(
(model) =>
DropdownMenuItem(value: model, child: Text(model)),
)
.toList(),
onChanged: (value) {
if (value != null) setState(() => selectedModel = value);
},
),
if (mode == CreationMode.video) ...[
const SizedBox(height: 12),
_ChoiceRow(
title: '视频时长',
values: const ['5 秒', '10 秒', '15 秒'],
selected: duration,
onChanged: (value) => setState(() => duration = value),
),
],
const SizedBox(height: 12),
_ChoiceRow(
title: '分辨率',
values: const ['720P', '1080P', '2K'],
selected: resolution,
onChanged: (value) => setState(() => resolution = value),
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('高清增强'),
subtitle: const Text('提升毛发与眼睛细节'),
value: upscaling,
onChanged: (value) => setState(() => upscaling = value),
),
],
),
),
const SizedBox(height: 18),
Text('热门风格', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
SizedBox(
height: 150,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: creationStyles.length,
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final style = creationStyles[index];
final selected = style.id == selectedStyle.id;
return InkWell(
onTap: () => setState(() {
selectedStyle = style;
resultUrl = null;
}),
borderRadius: BorderRadius.circular(20),
child: Container(
width: 125,
decoration: BoxDecoration(
border: Border.all(
color: selected ? AppColors.primary : AppColors.border,
width: selected ? 2 : 1,
),
borderRadius: BorderRadius.circular(20),
),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
url: style.image,
borderRadius: BorderRadius.circular(18),
),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
AppColors.ink.withAlpha(204),
],
),
),
),
Positioned(
left: 12,
right: 12,
bottom: 10,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
style.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
Text(
style.subtitle,
style: const TextStyle(
color: Colors.white70,
fontSize: 10,
),
),
],
),
),
],
),
),
);
},
),
),
const SizedBox(height: 18),
if (generating) _GenerationProgress(step: generationStep),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: AppColors.ink,
),
onPressed: generating ? null : generate,
icon: generating
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.auto_awesome),
label: Text(generating ? '正在生成…' : '开始生成'),
),
if (resultUrl != null) ...[
const SizedBox(height: 18),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.check_circle, color: AppColors.success),
const SizedBox(width: 8),
Text(
'生成完成',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 14),
AspectRatio(
aspectRatio: 16 / 10,
child: RemoteImage(
url: resultUrl!,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(height: 14),
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: '发布标题(必填)'),
),
const SizedBox(height: 12),
TextField(
controller: contentController,
maxLines: 3,
decoration: const InputDecoration(labelText: '分享正文'),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
...tags.map(
(tag) => InputChip(
label: Text('#$tag'),
onDeleted: () => setState(
() =>
tags = tags.where((item) => item != tag).toList(),
),
),
),
ActionChip(
avatar: const Icon(Icons.add, size: 16),
label: const Text('话题'),
onPressed: addTag,
),
],
),
const SizedBox(height: 14),
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.location_on_outlined),
title: Text('北京市 · 朝阳区'),
trailing: Icon(Icons.chevron_right),
),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
onPressed: publish,
icon: const Icon(Icons.send_rounded),
label: const Text('发布到社区'),
),
],
),
),
],
],
);
}
}
/// 真实发布入口(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,
required this.uploading,
required this.imageUrl,
required this.onTap,
required this.onRemove,
});
final bool uploaded;
final bool uploading;
final String imageUrl;
final VoidCallback onTap;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return SectionCard(
child: SizedBox(
height: 190,
width: double.infinity,
child: uploading
? const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('正在读取宠物照片…'),
],
)
: uploaded
? Stack(
fit: StackFit.expand,
children: [
RemoteImage(
url: imageUrl,
borderRadius: BorderRadius.circular(18),
),
Positioned(
right: 8,
top: 8,
child: IconButton.filled(
tooltip: '移除照片',
onPressed: onRemove,
icon: const Icon(Icons.close),
),
),
],
)
: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 28,
child: Icon(Icons.add_photo_alternate_outlined),
),
SizedBox(height: 12),
Text(
'选择宠物照片',
style: TextStyle(fontWeight: FontWeight.w800),
),
SizedBox(height: 4),
Text('演示模式会读取豆豆的档案头像'),
],
),
),
),
);
}
}
class _ChoiceRow extends StatelessWidget {
const _ChoiceRow({
required this.title,
required this.values,
required this.selected,
required this.onChanged,
});
final String title;
final List<String> values;
final String selected;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 7),
Wrap(
spacing: 8,
children: values
.map(
(value) => ChoiceChip(
label: Text(value),
selected: value == selected,
onSelected: (_) => onChanged(value),
),
)
.toList(),
),
],
);
}
}
class _GenerationProgress extends StatelessWidget {
const _GenerationProgress({required this.step});
final int step;
@override
Widget build(BuildContext context) {
const labels = ['分析宠物特征', '加载风格模型', '生成画面细节', '高清增强与合成'];
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: SectionCard(
child: Column(
children: [
LinearProgressIndicator(value: step / labels.length),
const SizedBox(height: 12),
for (var index = 0; index < labels.length; index++)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
index < step ? Icons.check_circle : Icons.circle_outlined,
color: index < step ? AppColors.success : AppColors.muted,
size: 20,
),
title: Text(labels[index]),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+266 -61
View File
@@ -1,8 +1,78 @@
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/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class MainShellPage extends StatefulWidget {
const MainShellPage({super.key});
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;
/// 退出登录:调 logout 接口并清会话,认证状态机自动回登录页。
final Future<void> Function()? onLogout;
@override
State<MainShellPage> createState() => _MainShellPageState();
@@ -10,72 +80,207 @@ class MainShellPage extends StatefulWidget {
class _MainShellPageState extends State<MainShellPage> {
int currentIndex = 0;
bool showPersonalServices = false;
final pages = const [
HomePage(),
Center(child: Text('发现')),
Center(child: Text('发布')),
Center(child: Text('消息')),
Center(child: Text('我的')),
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
/// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表;档案 Tab 自 T2-12
/// 起为真实宠物列表,页名由 pet_archive 改报 pet_list06 §5.2 字典 v2)。
static const _tabPages = [
AnalyticsPageName.home,
AnalyticsPageName.create,
AnalyticsPageName.petList,
AnalyticsPageName.services,
AnalyticsPageName.profile,
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: pages[currentIndex],
bottomNavigationBar: NavigationBarTheme(
data: NavigationBarThemeData(
backgroundColor: Colors.white,
indicatorColor: Colors.red.shade50,
labelTextStyle: WidgetStateProperty.all(
const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(color: Colors.red);
}
return const IconThemeData(color: Colors.grey);
}),
),
child: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) {
setState(() {
currentIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: '发现',
),
NavigationDestination(
icon: Icon(Icons.add_circle_outline),
selectedIcon: Icon(Icons.add_circle),
label: '发布',
),
NavigationDestination(
icon: Icon(Icons.notifications_outlined),
selectedIcon: Icon(Icons.notifications),
label: '消息',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
void initState() {
super.initState();
// 初始 Tab 曝光补一次(03 号评估 §3.2 手动补点 1)。
widget.pageViewTracker?.reportTab(_tabPages[currentIndex]);
}
void selectTab(int index) {
setState(() => currentIndex = index);
widget.pageViewTracker?.reportTab(_tabPages[index]);
}
void openServices(bool personal) {
setState(() {
showPersonalServices = personal;
currentIndex = 3;
});
widget.pageViewTracker?.reportTab(_tabPages[3]);
}
/// 帖子详情(T3-15:真实数据整页;Feed 卡片与详情共享
/// communityController,互动状态跨页一致)。
void openPost(String postId) {
Navigator.of(context).push(
MaterialPageRoute<void>(
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
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(
animation: widget.appState,
builder: (context, _) {
if (!widget.appState.isReady) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final pages = [
HomePage(
appState: widget.appState,
communityController: widget.communityController,
feedAnalytics: widget.feedAnalytics,
isActive: currentIndex == 0,
onOpenServices: openServices,
onOpenCompose: () => openCompose(PostEntryPoint.feed),
onOpenPost: openPost,
),
CreatePage(
appState: widget.appState,
// T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。
onOpenCompose: () => openCompose(PostEntryPoint.createTab),
),
PetsPage(
controller: widget.petsController,
analytics: widget.petAnalytics,
healthAnalytics: widget.healthRecordAnalytics,
),
ServicesPage(
showPersonal: showPersonalServices,
locationWeather: widget.appState.locationWeather,
),
ProfilePage(appState: widget.appState, onLogout: widget.onLogout),
];
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white.withAlpha(245),
surfaceTintColor: Colors.transparent,
scrolledUnderElevation: 1,
centerTitle: true,
title: Text(
titles[currentIndex],
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
leadingWidth: 118,
leading: InkWell(
onTap: () => selectTab(2),
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.only(left: 14),
child: Row(
children: [
RemoteImage(
url: widget.appState.pet.avatarUrl,
width: 36,
height: 36,
borderRadius: BorderRadius.circular(18),
),
const SizedBox(width: 7),
const Expanded(
child: Text(
'Patbond',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.primary,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
),
actions: [
IconButton.filledTonal(
tooltip: '通知',
onPressed: () {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('暂无新通知 🐾')));
},
icon: const Icon(Icons.notifications_outlined, size: 21),
),
const SizedBox(width: 10),
],
),
body: SafeArea(
top: false,
child: IndexedStack(index: currentIndex, children: pages),
),
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: selectTab,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_rounded),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: Icon(Icons.auto_awesome),
label: '创作',
),
NavigationDestination(
icon: Icon(Icons.pets_outlined),
selectedIcon: Icon(Icons.pets),
label: '档案',
),
NavigationDestination(
icon: Icon(Icons.storefront_outlined),
selectedIcon: Icon(Icons.storefront),
label: '服务',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
),
);
},
);
}
}
@@ -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.355.00 54.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.22026 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 @@
/// amountCentsUI
/// 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 + caregiverviewer
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]
///
/// 40300viewer 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);
}
/// 40902version
///
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
/// version40903
///
/// - 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),
);
// petIndexH2
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;
}
/// nulldueOn
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();
}
}
+308
View File
@@ -0,0 +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/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';
/// Tab T2-12 / 05 §4.1 P1
///
/// [PetsController] loading
/// empty + CTAerror + ready
/// ApiClient AppState demo
class PetsPage extends StatefulWidget {
const PetsPage({
required this.controller,
super.key,
this.analytics,
this.healthAnalytics,
});
final PetsController controller;
final PetAnalytics? analytics;
/// health_record T2-13
final HealthRecordAnalytics? healthAnalytics;
@override
State<PetsPage> createState() => _PetsPageState();
}
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 (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已为「${created.name}」建立档案 🐾')));
}
}
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),
),
);
}
@override
Widget build(BuildContext context) {
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);
}
},
);
}
Widget _petList(List<Pet> pets) {
return RefreshIndicator(
onRefresh: widget.controller.refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Row(
children: [
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),
for (final pet in pets) ...[
_PetCard(pet: pet, onTap: () => _openDetail(pet)),
const SizedBox(height: 10),
],
_AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)),
],
),
);
}
}
/// InlineErrorBanner + 05 §4.2
class _LoadErrorView extends StatelessWidget {
const _LoadErrorView({required this.message, required this.onRetry});
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(AppRadius.xl),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const PetAvatar(size: PetAvatarSize.lg),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
petMetaLine(pet),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
if (pet.status != PetStatus.active)
TagPill(
petStatusLabel(pet.status),
color: pet.status == PetStatus.lost
? AppColors.error
: AppColors.muted,
)
else
const Icon(Icons.chevron_right, color: AppColors.muted),
],
),
),
),
);
}
}
/// 线05 §4.1border 1.5px dashedradius 24 64
class _AddPetCard extends StatelessWidget {
const _AddPetCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _DashedBorderPainter(
color: AppColors.border,
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-13cursor
/// [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_form06 §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';
}
+986
View File
@@ -0,0 +1,986 @@
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/comment_tile.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.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_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_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// T3-15 demo 退
/// + + + +
/// ToggleSync 线Feed [CommunityController]
/// + / /
///
///
///
/// - ****[PostMediaGrid]
/// 05 D11
/// 03 E InteractiveViewer photo_view
/// - 40403// SnackBar Feed
///
/// - @ **** UI replyToUserId
/// replyToUser
class PostDetailPage extends StatefulWidget {
const PostDetailPage({
required this.controller,
required this.postId,
super.key,
this.currentUserId,
this.analytics,
this.now,
});
/// Tab app.dart Feed
final CommunityController controller;
final String postId;
/// id UI
///
final String? currentUserId;
/// + / controller
final CommunityInteractionAnalytics? analytics;
///
final DateTime? now;
@override
State<PostDetailPage> createState() => _PostDetailPageState();
}
enum _DetailPhase { loading, error, ready }
class _PostDetailPageState extends State<PostDetailPage> {
final _commentController = TextEditingController();
final _commentFocus = FocusNode();
_DetailPhase _phase = _DetailPhase.loading;
ApiException? _loadError;
/// 03 §2 DESC
_DetailPhase _commentsPhase = _DetailPhase.loading;
ApiException? _commentsError;
List<PostComment> _comments = const [];
String? _commentsCursor;
bool _commentsHasMore = false;
LoadMorePhase _commentsLoadMore = LoadMorePhase.idle;
bool _sending = false;
final Set<String> _deletingIds = {};
/// comment_create_succeeded
/// durationMs started
DateTime? _composeStartedAt;
/// comment_create_failed.attemptSeq
/// 1
int _attemptSeq = 0;
///
FollowStats? _followStats;
bool _following = false;
/// 40403 Feed
bool _leaving = false;
CommunityController get _controller => widget.controller;
Post? get _post => _controller.cachedPost(widget.postId);
@override
void initState() {
super.initState();
_commentController.addListener(_onComposeChanged);
if (_post != null) {
_phase = _DetailPhase.ready;
_ensureFollowStats(_post!);
}
unawaited(_loadPost());
unawaited(_loadComments());
}
@override
void dispose() {
_commentController.dispose();
_commentFocus.dispose();
super.dispose();
}
// ---- ----
Future<void> _loadPost() async {
if (_post == null && _phase != _DetailPhase.loading) {
setState(() {
_phase = _DetailPhase.loading;
_loadError = null;
});
}
try {
final post = await _controller.getPost(widget.postId);
if (!mounted) return;
setState(() => _phase = _DetailPhase.ready);
_ensureFollowStats(post);
} on PostNotFoundException {
_handleGone();
} on ApiException catch (error) {
if (!mounted) return;
// error
if (_post == null) {
setState(() {
_phase = _DetailPhase.error;
_loadError = error;
});
}
}
}
/// 40403// Feed
///
void _handleGone() {
if (_leaving || !mounted) return;
_leaving = true;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('帖子不存在或已被删除')));
unawaited(_controller.refresh());
Navigator.of(context).pop();
}
// ---- ----
Future<void> _loadComments() async {
setState(() {
_commentsPhase = _DetailPhase.loading;
_commentsError = null;
});
try {
final page = await _controller.repository.listComments(widget.postId);
if (!mounted) return;
setState(() {
_comments = page.items;
_commentsCursor = page.nextCursor;
_commentsHasMore = page.hasMore;
_commentsPhase = _DetailPhase.ready;
_commentsLoadMore = LoadMorePhase.idle;
});
} on PostNotFoundException {
_handleGone();
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_commentsPhase = _DetailPhase.error;
_commentsError = error;
});
}
}
Future<void> _loadMoreComments() async {
if (_commentsPhase != _DetailPhase.ready ||
!_commentsHasMore ||
_commentsLoadMore == LoadMorePhase.loading) {
return;
}
setState(() => _commentsLoadMore = LoadMorePhase.loading);
try {
final page = await _controller.repository.listComments(
widget.postId,
cursor: _commentsCursor,
);
if (!mounted) return;
setState(() {
_comments = [..._comments, ...page.items];
_commentsCursor = page.nextCursor;
_commentsHasMore = page.hasMore;
_commentsLoadMore = LoadMorePhase.idle;
});
} on ApiException {
if (!mounted) return;
setState(() => _commentsLoadMore = LoadMorePhase.error);
}
}
bool _onScrollNotification(ScrollNotification notification) {
if (notification is ScrollUpdateNotification ||
notification is ScrollEndNotification) {
// 400 Feed
if (notification.metrics.extentAfter < 400 &&
_commentsLoadMore == LoadMorePhase.idle) {
unawaited(_loadMoreComments());
}
}
return false;
}
// ---- / ----
void _onComposeChanged() {
final text = _commentController.text;
if (text.isEmpty) {
// = durationMs attemptSeq
_composeStartedAt = null;
_attemptSeq = 0;
} else {
_composeStartedAt ??= DateTime.now();
}
setState(() {}); //
}
Future<void> _submitComment() async {
final text = _commentController.text.trim();
if (text.isEmpty || _sending) return;
final startedAt = _composeStartedAt ?? DateTime.now();
_attemptSeq += 1;
setState(() => _sending = true);
try {
final comment = await _controller.repository.createComment(
widget.postId,
CreateCommentRequest(content: text),
);
if (!mounted) return;
widget.analytics?.commentCreateSucceeded(
durationMs: DateTime.now().difference(startedAt).inMilliseconds,
isReply: false,
textLength: text.length,
);
setState(() {
_comments = [comment, ..._comments];
_sending = false;
});
_controller.adjustCommentCount(widget.postId, 1);
_commentController.clear();
_commentFocus.unfocus();
} on ApiException catch (error) {
if (!mounted) return;
final reason = commentCreateFailureReasonOf(error);
if (reason != null) {
widget.analytics?.commentCreateFailed(
reason: reason,
attemptSeq: _attemptSeq,
errorCode: error is ApiBusinessException ? error.code : null,
);
}
setState(() => _sending = false);
if (error is PostNotFoundException) {
_handleGone();
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(_commentErrorMessage(error))));
}
}
String _commentErrorMessage(ApiException error) => switch (error) {
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
ApiBusinessException(code: ApiCodes.paramError) => '评论内容不合规,请修改后重试',
_ => '评论发送失败,请稍后重试',
};
Future<void> _deleteComment(PostComment comment) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('删除这条评论?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _deletingIds.add(comment.id));
try {
await _controller.repository.deleteComment(comment.id);
if (!mounted) return;
_removeCommentLocally(comment.id);
} on CommentNotFoundException {
// getPost
if (!mounted) return;
_removeCommentLocally(comment.id);
} on ApiException catch (error) {
if (!mounted) return;
setState(() => _deletingIds.remove(comment.id));
final message = switch (error) {
PostAccessDeniedException _ => '没有权限删除这条评论',
ApiNetworkException _ => '网络异常,请检查网络后重试',
_ => '删除失败,请稍后重试',
};
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
void _removeCommentLocally(String commentId) {
setState(() {
_deletingIds.remove(commentId);
_comments = _comments.where((c) => c.id != commentId).toList();
});
_controller.adjustCommentCount(widget.postId, -1);
}
// ---- ----
void _ensureFollowStats(Post post) {
if (_followStats != null) return;
final authorId = post.author.userId;
if (authorId == widget.currentUserId) return; //
unawaited(() async {
try {
final stats = await _controller.repository.getFollowStats(authorId);
if (!mounted) return;
setState(() {
_followStats = stats;
_following = stats.followedByMe;
});
} on ApiException {
//
}
}());
}
Future<void> _toggleFollow(String authorId) async {
final target = !_following;
if (!target) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('不再关注 TA'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('不再关注'),
),
],
),
);
if (confirmed != true || !mounted) return;
}
setState(() => _following = target); // §4.5
try {
final state = target
? await _controller.repository.followUser(authorId)
: await _controller.repository.unfollowUser(authorId);
if (!mounted) return;
target
? widget.analytics?.userFollowed(source: InteractionSource.postDetail)
: widget.analytics?.userUnfollowed(
source: InteractionSource.postDetail,
);
setState(() => _following = state.following); //
} on ApiException {
if (!mounted) return;
// + SnackBar§4.5
setState(() => _following = !target);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
}
}
// ---- ----
void _openGallery(List<PostMediaItem> media, int initialIndex) {
Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (context) => _MediaGalleryPage(
urls: [for (final item in media) item.url],
initialIndex: initialIndex,
),
),
);
}
// ---- ----
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
// / SnackBar§3.5
if (_controller.toggleError != null) {
_controller.clearToggleError();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
});
}
final post = _post;
return Scaffold(
appBar: AppBar(
title: const Text(
'社区动态',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
centerTitle: true,
actions: [
IconButton(
tooltip: '分享',
onPressed: () => ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('分享功能即将上线'))),
icon: const Icon(Icons.ios_share_outlined),
),
],
),
body: post != null ? _content(post) : _placeholder(),
bottomSheet: post != null ? _commentInputBar() : null,
);
},
);
}
/// loading / error
Widget _placeholder() {
if (_phase == _DetailPhase.error) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
InlineErrorBanner(message: feedLoadErrorMessage(_loadError)),
const SizedBox(height: 16),
FilledButton(onPressed: _loadPost, child: const Text('重试')),
],
),
);
}
return const Center(child: CircularProgressIndicator());
}
Widget _content(Post post) {
return NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: ListView(
padding: const EdgeInsets.only(bottom: 100),
children: [
_mediaSection(post),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_authorCard(post),
const SizedBox(height: 14),
_bodyCard(post),
const SizedBox(height: 14),
_actionRow(post),
const SizedBox(height: 22),
Text(
'评论 (${post.commentCount})',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
..._commentsSection(),
],
),
),
],
),
);
}
/// [0.75w, 1.33w]
///PostMediaGrid 2/42 3/593 + 9
Widget _mediaSection(Post post) {
final media = [...post.media]
..sort((a, b) => a.position.compareTo(b.position));
if (media.isEmpty) return const SizedBox.shrink();
if (media.length == 1) {
final item = media.first;
var ratio = 4 / 3;
final width = item.widthPx;
final height = item.heightPx;
if (width != null && height != null && width > 0 && height > 0) {
// [×0.75, ×1.33]05 §2.2 [1/1.33, 1/0.75]
ratio = (width / height).clamp(1 / 1.33, 1 / 0.75);
}
return GestureDetector(
onTap: () => _openGallery(media, 0),
child: AspectRatio(
aspectRatio: ratio,
child: RemoteImage(url: item.url),
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: PostMediaGrid(
urls: [for (final item in media) item.url],
onCellTap: (index) => _openGallery(media, index),
),
);
}
Widget _authorCard(Post post) {
final author = post.author;
final isSelf = author.userId == widget.currentUserId;
return SectionCard(
child: Row(
children: [
PetAvatar(size: PetAvatarSize.md, url: author.avatarUrl),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authorDisplayName(author),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(
feedRelativeTime(
post.publishedAt ?? post.createdAt,
now: widget.now,
),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
if (!isSelf && _followStats != null) _followButton(author.userId),
],
),
);
}
/// 05 §2.2 tonal Outlined
/// +
Widget _followButton(String authorId) {
if (_following) {
return OutlinedButton(
onPressed: () => _toggleFollow(authorId),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.inkSoft,
side: const BorderSide(color: AppColors.border),
),
child: const Text('已关注'),
);
}
return FilledButton.tonal(
onPressed: () => _toggleFollow(authorId),
child: const Text(' 关注'),
);
}
Widget _bodyCard(Post post) {
return SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (post.title != null) ...[
Text(post.title!, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
],
if (post.category == PostCategory.help) ...[
const TagPill('求助', color: AppColors.accent),
const SizedBox(height: 8),
],
Text(
post.content,
style: const TextStyle(
color: AppColors.ink,
fontSize: 14,
height: 1.6,
),
),
const SizedBox(height: 12),
Text(
'发布于 ${feedRelativeTime(post.publishedAt ?? post.createdAt, now: widget.now)}',
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
],
),
);
}
/// Feed ToggleSync
/// demo FilledButton.tonalIcon
Widget _actionRow(Post post) {
return Row(
children: [
LikeButton(
variant: LikeButtonVariant.like,
active: post.likedByMe,
count: post.likeCount,
onPressed: () => _controller.toggleLike(
widget.postId,
source: InteractionSource.postDetail,
),
semanticLabel: '点赞',
),
const SizedBox(width: 4),
//
Semantics(
label: '评论',
button: true,
child: InkWell(
onTap: _commentFocus.requestFocus,
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(
'${post.commentCount}',
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
),
const SizedBox(width: 4),
LikeButton(
variant: LikeButtonVariant.bookmark,
active: post.bookmarkedByMe,
count: post.bookmarkCount,
onPressed: () => _controller.toggleBookmark(
widget.postId,
source: InteractionSource.postDetail,
),
semanticLabel: '收藏',
),
],
);
}
List<Widget> _commentsSection() {
switch (_commentsPhase) {
case _DetailPhase.loading:
return const [
_CommentSkeleton(),
SizedBox(height: 12),
_CommentSkeleton(),
];
case _DetailPhase.error:
return [
Center(
child: Column(
children: [
Text(
feedLoadErrorMessage(_commentsError),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
TextButton(
onPressed: _loadComments,
child: const Text('加载失败,点此重试'),
),
],
),
),
];
case _DetailPhase.ready:
if (_comments.isEmpty) {
return const [
Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
// muted DEBT-2
Icon(
Icons.chat_bubble_outline,
size: 36,
color: AppColors.muted,
),
SizedBox(height: 8),
Text(
'还没有评论,来抢沙发',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
],
),
),
];
}
return [
for (final comment in _comments)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: CommentTile(
comment: comment,
now: widget.now,
deleting: _deletingIds.contains(comment.id),
// 40301/40404
onDelete:
widget.currentUserId != null &&
comment.author.userId == widget.currentUserId
? () => _deleteComment(comment)
: null,
),
),
..._commentsTail(),
];
}
}
List<Widget> _commentsTail() {
switch (_commentsLoadMore) {
case LoadMorePhase.loading:
return const [
Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: AppColors.primary,
),
),
),
),
];
case LoadMorePhase.error:
return [
Center(
child: TextButton(
onPressed: _loadMoreComments,
child: const Text('加载失败,点此重试'),
),
),
];
case LoadMorePhase.idle:
return const [];
}
}
/// 05 §2.2surface + border 1px 线 +
/// isDense + filled 18
Widget _commentInputBar() {
final canSend = _commentController.text.trim().isNotEmpty && !_sending;
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: TextField(
controller: _commentController,
focusNode: _commentFocus,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submitComment(),
decoration: const InputDecoration(
hintText: '写下你的评论…',
isDense: true,
),
),
),
const SizedBox(width: 8),
IconButton.filled(
tooltip: '发送评论',
onPressed: canSend ? _submitComment : null,
icon: _sending
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.send_rounded),
),
],
),
),
);
}
}
/// 05 §3.732 + 16 72
class _CommentSkeleton extends StatelessWidget {
const _CommentSkeleton();
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceTint,
),
),
const SizedBox(width: 10),
Expanded(
child: Container(
height: 72,
decoration: BoxDecoration(
color: AppColors.surfaceTint,
borderRadius: BorderRadius.circular(16),
),
),
),
],
);
}
}
/// 05 §2.203 E
/// InteractiveViewer + n/Nink +
/// InteractiveViewer
/// photo_view
class _MediaGalleryPage extends StatefulWidget {
const _MediaGalleryPage({required this.urls, required this.initialIndex});
final List<String> urls;
final int initialIndex;
@override
State<_MediaGalleryPage> createState() => _MediaGalleryPageState();
}
class _MediaGalleryPageState extends State<_MediaGalleryPage> {
late final PageController _pageController = PageController(
initialPage: widget.initialIndex,
);
late int _index = widget.initialIndex;
final _viewerController = TransformationController();
@override
void dispose() {
_pageController.dispose();
_viewerController.dispose();
super.dispose();
}
void _toggleZoom(TapDownDetails details) {
if (_viewerController.value != Matrix4.identity()) {
_viewerController.value = Matrix4.identity();
return;
}
final position = details.localPosition;
// 2.5
_viewerController.value = Matrix4.identity()
..translateByDouble(-position.dx * 1.5, -position.dy * 1.5, 0, 1)
..scaleByDouble(2.5, 2.5, 1, 1);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PageView.builder(
controller: _pageController,
itemCount: widget.urls.length,
onPageChanged: (index) {
_viewerController.value = Matrix4.identity();
setState(() => _index = index);
},
itemBuilder: (context, index) {
return GestureDetector(
onDoubleTapDown: index == _index ? _toggleZoom : null,
onDoubleTap: () {}, // onDoubleTapDown
child: InteractiveViewer(
transformationController: index == _index
? _viewerController
: null,
maxScale: 4,
child: Center(
child: RemoteImage(
url: widget.urls[index],
fit: BoxFit.contain,
),
),
),
);
},
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
IconButton(
tooltip: '关闭',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white),
),
const Spacer(),
if (widget.urls.length > 1)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.ink,
borderRadius: BorderRadius.circular(AppRadius.pill),
),
child: Text(
'${_index + 1}/${widget.urls.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
],
),
);
}
}
+176
View File
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class ProfilePage extends StatelessWidget {
const ProfilePage({required this.appState, super.key, this.onLogout});
final AppState appState;
/// 退线
final Future<void> Function()? onLogout;
static const menuItems = [
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
(Icons.badge_outlined, '宠物健康卡包', '电子接种证与体检报告'),
(Icons.location_on_outlined, '地址与定位管理', '管理家庭住址与常用医院'),
(Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'),
];
void showDemoMessage(BuildContext context, String name) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$name」功能为演示入口')));
}
Future<void> reset(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('恢复演示数据'),
content: const Text('宠物资料、疫苗记录以及新增帖子都会恢复为初始状态。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认恢复'),
),
],
),
);
if (confirmed == true) {
await appState.resetDemoData();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('演示数据已恢复')));
}
}
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.ink,
borderRadius: BorderRadius.circular(30),
),
child: Column(
children: [
RemoteImage(
url: userAvatar,
width: 82,
height: 82,
borderRadius: BorderRadius.circular(41),
),
const SizedBox(height: 12),
const Text(
'萌宠新手(豆豆家长)',
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
const Text(
'Patbond 社区创作达人',
style: TextStyle(color: AppColors.accent, fontSize: 12),
),
const SizedBox(height: 20),
const Divider(color: Colors.white24),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const _ProfileStat(value: '24', label: '关注我'),
const _ProfileStat(value: '1.8k', label: '获赞'),
_ProfileStat(
// demo M5
// demo AppState.posts T3-17 退
// demo
value: '${initialPosts.length}',
label: '我的作品',
),
],
),
],
),
),
const SizedBox(height: 18),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
children: menuItems.map((item) {
return ListTile(
leading: CircleAvatar(
backgroundColor: AppColors.surfaceTint,
child: Icon(item.$1, color: AppColors.primary),
),
title: Text(
item.$2,
style: const TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Text(item.$3),
trailing: const Icon(Icons.chevron_right),
onTap: () => showDemoMessage(context, item.$2),
);
}).toList(),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () => reset(context),
icon: const Icon(Icons.restart_alt),
label: const Text('恢复演示数据'),
),
const SizedBox(height: 10),
TextButton(
onPressed: onLogout ?? () => showDemoMessage(context, '退出登录'),
child: const Text('切换账号或退出登录'),
),
],
);
}
}
class _ProfileStat extends StatelessWidget {
const _ProfileStat({required this.value, required this.label});
final String value;
final String label;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
);
}
}
+291
View File
@@ -0,0 +1,291 @@
import 'package:flutter/material.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/widgets/common.dart';
class ServicesPage extends StatefulWidget {
const ServicesPage({
required this.showPersonal,
required this.locationWeather,
super.key,
});
final bool showPersonal;
final LocationWeather locationWeather;
@override
State<ServicesPage> createState() => _ServicesPageState();
}
class _ServicesPageState extends State<ServicesPage> {
late bool personal;
String query = '';
String filter = '全部';
@override
void initState() {
super.initState();
personal = widget.showPersonal;
}
@override
void didUpdateWidget(covariant ServicesPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.showPersonal != widget.showPersonal) {
personal = widget.showPersonal;
}
}
List<ServiceProviderModel> get providers {
final keyword = query.trim().toLowerCase();
var list = serviceProviders.where((provider) {
final matchesType = personal
? provider.kind == ProviderKind.personal
: provider.kind != ProviderKind.personal;
final matchesQuery =
keyword.isEmpty ||
provider.name.toLowerCase().contains(keyword) ||
provider.description.toLowerCase().contains(keyword) ||
provider.tags.any((tag) => tag.toLowerCase().contains(keyword));
return matchesType && matchesQuery;
}).toList();
if (!personal) {
if (filter == '距离优先') {
list.sort(
(a, b) => double.parse(
a.distance.replaceAll('km', ''),
).compareTo(double.parse(b.distance.replaceAll('km', ''))),
);
} else if (filter == '24H急诊') {
list = list.where((item) => item.tags.contains('24H急诊')).toList();
} else if (filter == '美容洗护') {
list = list
.where((item) => item.kind == ProviderKind.grooming)
.toList();
}
}
return list;
}
Future<void> book(ServiceProviderModel provider) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认预约'),
content: Text('预约 ${provider.name}\n服务价格:¥${provider.price}'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认'),
),
],
),
);
if (confirmed == true && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已提交「${provider.name}」预约(演示)')));
}
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: false,
icon: Icon(Icons.apartment_outlined),
label: Text('专业机构'),
),
ButtonSegment(
value: true,
icon: Icon(Icons.person_pin_circle_outlined),
label: Text('个人服务'),
),
],
selected: {personal},
showSelectedIcon: false,
onSelectionChanged: (value) => setState(() {
personal = value.first;
filter = '全部';
}),
),
const SizedBox(height: 10),
Row(
children: [
const Icon(
Icons.near_me_outlined,
size: 17,
color: AppColors.primary,
),
const SizedBox(width: 5),
Text(
'正在展示 ${widget.locationWeather.displayArea} 附近的服务',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 14),
TextField(
onChanged: (value) => setState(() => query = value),
decoration: InputDecoration(
hintText: personal ? '搜索遛狗、寄养或上门喂养…' : '搜索宠物医院、美容或服务…',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
tooltip: '筛选',
onPressed: () => ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('可使用下方快捷筛选条件'))),
icon: const Icon(Icons.tune),
),
),
),
if (!personal) ...[
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: ['全部', '距离优先', '24H急诊', '美容洗护']
.map(
(value) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(value),
selected: filter == value,
onSelected: (_) => setState(() => filter = value),
),
),
)
.toList(),
),
),
],
const SizedBox(height: 16),
if (providers.isEmpty)
const EmptyState(message: '没有找到符合条件的服务')
else
...providers.map(
(provider) => Padding(
padding: const EdgeInsets.only(bottom: 14),
child: _ServiceCard(
provider: provider,
onBook: () => book(provider),
),
),
),
],
);
}
}
class _ServiceCard extends StatelessWidget {
const _ServiceCard({required this.provider, required this.onBook});
final ServiceProviderModel provider;
final VoidCallback onBook;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: RemoteImage(url: provider.image),
),
if (provider.verified)
const Positioned(
left: 12,
top: 12,
child: TagPill('认证服务', color: AppColors.success),
),
Positioned(
right: 12,
bottom: 10,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withAlpha(235),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 9,
vertical: 5,
),
child: Text(
'${provider.rating}',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
provider.name,
style: Theme.of(context).textTheme.titleMedium,
),
),
Text(
provider.distance,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 6),
Text(provider.description),
const SizedBox(height: 10),
Wrap(
spacing: 7,
runSpacing: 7,
children: provider.tags.map(TagPill.new).toList(),
),
const SizedBox(height: 14),
Row(
children: [
Text(
'¥${provider.price}',
style: const TextStyle(
color: AppColors.primary,
fontSize: 20,
fontWeight: FontWeight.w900,
),
),
const Text('', style: TextStyle(color: AppColors.muted)),
const Spacer(),
FilledButton.icon(
onPressed: onBook,
icon: const Icon(Icons.calendar_month_outlined, size: 18),
label: const Text('立即预约'),
),
],
),
],
),
),
],
),
);
}
}
+297
View File
@@ -0,0 +1,297 @@
enum PetGender { male, female }
enum WeatherCondition { sunny, cloudy, rainy, overcast, snow }
class LocationWeather {
const LocationWeather({
required this.city,
required this.district,
required this.temperature,
required this.condition,
required this.conditionText,
required this.highTemperature,
required this.lowTemperature,
required this.humidity,
});
final String city;
final String district;
final int temperature;
final WeatherCondition condition;
final String conditionText;
final int highTemperature;
final int lowTemperature;
final int humidity;
String get displayArea => district.isEmpty ? city : '$city · $district';
String get petAdvice {
if (condition == WeatherCondition.rainy) {
return '今天有雨,外出记得带雨具并及时擦干脚掌。';
}
if (condition == WeatherCondition.snow) {
return '天气寒冷,短毛宠物外出记得做好保暖。';
}
if (temperature >= 30) {
return '今天较热,建议清晨或傍晚带毛孩子出门。';
}
if (temperature <= 8) {
return '气温偏低,散步时间不宜过长,注意保暖。';
}
if (condition == WeatherCondition.sunny) {
return '天气不错,适合和毛孩子一起去户外散步。';
}
return '体感舒适,适合安排一次轻松的户外活动。';
}
factory LocationWeather.fromJson(Map<String, dynamic> json) {
return LocationWeather(
city: json['city'] as String,
district: json['district'] as String? ?? '',
temperature: json['temperature'] as int,
condition: WeatherCondition.values.firstWhere(
(value) => value.name == json['condition'],
orElse: () => WeatherCondition.sunny,
),
conditionText: json['conditionText'] as String,
highTemperature: json['highTemperature'] as int,
lowTemperature: json['lowTemperature'] as int,
humidity: json['humidity'] as int,
);
}
Map<String, dynamic> toJson() => {
'city': city,
'district': district,
'temperature': temperature,
'condition': condition.name,
'conditionText': conditionText,
'highTemperature': highTemperature,
'lowTemperature': lowTemperature,
'humidity': humidity,
};
}
class PetProfile {
const PetProfile({
required this.name,
required this.breed,
required this.gender,
required this.birthday,
required this.weight,
required this.tags,
required this.avatarUrl,
});
final String name;
final String breed;
final PetGender gender;
final String birthday;
final double weight;
final List<String> tags;
final String avatarUrl;
PetProfile copyWith({
String? name,
String? breed,
PetGender? gender,
String? birthday,
double? weight,
List<String>? tags,
String? avatarUrl,
}) {
return PetProfile(
name: name ?? this.name,
breed: breed ?? this.breed,
gender: gender ?? this.gender,
birthday: birthday ?? this.birthday,
weight: weight ?? this.weight,
tags: tags ?? this.tags,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
factory PetProfile.fromJson(Map<String, dynamic> json) {
return PetProfile(
name: json['name'] as String,
breed: json['breed'] as String,
gender: json['gender'] == 'female' ? PetGender.female : PetGender.male,
birthday: json['birthday'] as String,
weight: (json['weight'] as num).toDouble(),
tags: List<String>.from(json['tags'] as List),
avatarUrl: json['avatarUrl'] as String,
);
}
Map<String, dynamic> toJson() => {
'name': name,
'breed': breed,
'gender': gender.name,
'birthday': birthday,
'weight': weight,
'tags': tags,
'avatarUrl': avatarUrl,
};
}
class CommentModel {
const CommentModel({
required this.id,
required this.authorName,
required this.authorAvatar,
required this.content,
required this.time,
});
final String id;
final String authorName;
final String authorAvatar;
final String content;
final String time;
factory CommentModel.fromJson(Map<String, dynamic> json) {
return CommentModel(
id: json['id'] as String,
authorName: json['authorName'] as String,
authorAvatar: json['authorAvatar'] as String,
content: json['content'] as String,
time: json['time'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'authorName': authorName,
'authorAvatar': authorAvatar,
'content': content,
'time': time,
};
}
class PostModel {
const PostModel({
required this.id,
required this.authorName,
required this.authorAvatar,
required this.time,
required this.breedTag,
required this.content,
required this.mainImage,
required this.likes,
required this.tags,
required this.comments,
this.hasLiked = false,
this.hasBookmarked = false,
});
final String id;
final String authorName;
final String authorAvatar;
final String time;
final String breedTag;
final String content;
final String mainImage;
final int likes;
final List<String> tags;
final List<CommentModel> comments;
final bool hasLiked;
final bool hasBookmarked;
PostModel copyWith({
int? likes,
List<CommentModel>? comments,
bool? hasLiked,
bool? hasBookmarked,
}) {
return PostModel(
id: id,
authorName: authorName,
authorAvatar: authorAvatar,
time: time,
breedTag: breedTag,
content: content,
mainImage: mainImage,
likes: likes ?? this.likes,
tags: tags,
comments: comments ?? this.comments,
hasLiked: hasLiked ?? this.hasLiked,
hasBookmarked: hasBookmarked ?? this.hasBookmarked,
);
}
factory PostModel.fromJson(Map<String, dynamic> json) {
return PostModel(
id: json['id'] as String,
authorName: json['authorName'] as String,
authorAvatar: json['authorAvatar'] as String,
time: json['time'] as String,
breedTag: json['breedTag'] as String,
content: json['content'] as String,
mainImage: json['mainImage'] as String,
likes: json['likes'] as int,
tags: List<String>.from(json['tags'] as List),
comments: (json['comments'] as List)
.map((item) => CommentModel.fromJson(item as Map<String, dynamic>))
.toList(),
hasLiked: json['hasLiked'] as bool? ?? false,
hasBookmarked: json['hasBookmarked'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'authorName': authorName,
'authorAvatar': authorAvatar,
'time': time,
'breedTag': breedTag,
'content': content,
'mainImage': mainImage,
'likes': likes,
'tags': tags,
'comments': comments.map((comment) => comment.toJson()).toList(),
'hasLiked': hasLiked,
'hasBookmarked': hasBookmarked,
};
}
enum ProviderKind { hospital, grooming, personal }
class ServiceProviderModel {
const ServiceProviderModel({
required this.id,
required this.kind,
required this.name,
required this.distance,
required this.rating,
required this.description,
required this.tags,
required this.price,
required this.image,
this.verified = false,
});
final String id;
final ProviderKind kind;
final String name;
final String distance;
final double rating;
final String description;
final List<String> tags;
final int price;
final String image;
final bool verified;
}
class CreationStyle {
const CreationStyle({
required this.id,
required this.title,
required this.subtitle,
required this.image,
});
final String id;
final String title;
final String subtitle;
final String image;
}
+72
View File
@@ -0,0 +1,72 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppState extends ChangeNotifier {
static const _petKey = 'patbond_pet';
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;
LocationWeather locationWeather = initialLocationWeather;
bool isReady = false;
Future<void> load() async {
try {
final preferences = await SharedPreferences.getInstance();
final savedPet = preferences.getString(_petKey);
final savedLocationWeather = preferences.getString(_locationWeatherKey);
if (savedPet != null) {
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
}
if (savedLocationWeather != null) {
locationWeather = LocationWeather.fromJson(
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
);
}
} catch (error, stackTrace) {
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
pet = initialPet;
locationWeather = initialLocationWeather;
} finally {
isReady = true;
notifyListeners();
}
}
Future<void> updateLocationWeather(LocationWeather value) async {
locationWeather = value;
notifyListeners();
await _save(_locationWeatherKey, value.toJson());
}
Future<void> resetDemoData() async {
pet = initialPet;
locationWeather = initialLocationWeather;
notifyListeners();
final preferences = await SharedPreferences.getInstance();
await Future.wait([
preferences.remove(_petKey),
preferences.remove(_locationWeatherKey),
]);
}
Future<void> _save(String key, Object value) async {
try {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(key, jsonEncode(value));
} catch (error, stackTrace) {
debugPrint('保存本地数据失败:$error\n$stackTrace');
}
}
}
+156
View File
@@ -0,0 +1,156 @@
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 {
const RemoteImage({
required this.url,
super.key,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius = BorderRadius.zero,
});
final String url;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius borderRadius;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: borderRadius,
// key T3-14 URL
// URL
child: Image(
image: SignedNetworkImage(url),
width: width,
height: height,
fit: fit,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: AppColors.surfaceTint,
child: SizedBox(
width: width,
height: height,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return ColoredBox(
color: AppColors.surfaceTint,
child: SizedBox(
width: width,
height: height,
child: const Center(
child: Icon(Icons.pets, color: AppColors.muted, size: 34),
),
),
);
},
),
);
}
}
class SectionCard extends StatelessWidget {
const SectionCard({required this.child, super.key, this.padding});
final Widget child;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: padding ?? const EdgeInsets.all(18),
child: child,
),
);
}
}
/// [color] 8% `withAlpha(20)`
///
/// DEBT-1 05 §5.3 [color]
/// 1.672.55:1 WCAG AA
/// primaryprimaryDark 8.74:1successsuccessInk 7.39:1
/// accentaccentDark 7.07:1errorerrorDark 5.78:1ink
/// [inkColor]
class TagPill extends StatelessWidget {
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),
borderRadius: BorderRadius.circular(99),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
label,
style: TextStyle(
color: effectiveInk,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState({required this.message, super.key});
final String message;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
const Icon(
Icons.search_off_rounded,
size: 42,
color: AppColors.muted,
),
const SizedBox(height: 12),
Text(message, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}
@@ -6,6 +6,14 @@
#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);
}
+3
View File
@@ -3,9 +3,12 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
@@ -5,6 +5,16 @@
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"))
}
+606 -2
View File
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -33,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -41,6 +57,22 @@ 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:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@@ -49,19 +81,152 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
fake_async:
dio:
dependency: "direct main"
description:
name: dio
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
url: "https://pub.dev"
source: hosted
version: "5.11.1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
fake_async:
dependency: "direct dev"
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
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:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
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:
@@ -70,11 +235,194 @@ 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:
name: flutter_secure_storage
sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0
url: "https://pub.dev"
source: hosted
version: "0.4.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
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:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
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:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker:
dependency: transitive
description:
@@ -107,6 +455,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -131,6 +487,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
url: "https://pub.dev"
source: hosted
version: "10.2.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "4.1.0"
path:
dependency: transitive
description:
@@ -139,6 +535,150 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
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:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
url: "https://pub.dev"
source: hosted
version: "2.4.27"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -176,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:
@@ -192,6 +740,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
@@ -208,6 +772,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
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:
name: win32
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "6.4.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.44.0"
+15 -1
View File
@@ -1,5 +1,5 @@
name: patbond_flutter
description: "A new Flutter project."
description: "Patbond pet community and health companion."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -34,10 +34,24 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
shared_preferences: ^2.5.4
dio: ^5.11.1
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-1913 §3.4 4 +
/// iteration-3 06 §2.3fakeAsync 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=120st=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;
// v4restore
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');
});
});
}
@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
void main() {
group('AnalyticsRouteObserver', () {
testWidgets('push 路由报 page_viewed', (tester) async {
final events = <Map<String, dynamic>>[];
final tracker = PageViewTracker((name, [props]) async {
events.add({'event': name, ...?props});
});
final observer = AnalyticsRouteObserver(tracker: tracker);
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [observer],
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
settings: RouteSettings(
name: AnalyticsPageName.login.pageName,
),
builder: (_) => const Placeholder(),
),
),
child: const Text('Go'),
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pumpAndSettle();
expect(events, hasLength(1));
expect(events.first['event'], 'page_viewed');
expect(events.first['pageName'], 'login');
});
testWidgets('push 两页 referrer 链正确,pop 返回补报前一页', (tester) async {
final events = <Map<String, dynamic>>[];
final tracker = PageViewTracker((name, [props]) async {
events.add({'event': name, ...?props});
});
final observer = AnalyticsRouteObserver(tracker: tracker);
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [observer],
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
settings: RouteSettings(
name: AnalyticsPageName.login.pageName,
),
builder: (loginContext) => ElevatedButton(
onPressed: () => Navigator.of(loginContext).push(
MaterialPageRoute<void>(
settings: RouteSettings(
name: AnalyticsPageName.register.pageName,
),
builder: (registerContext) => ElevatedButton(
onPressed: () => Navigator.of(registerContext).pop(),
child: const Text('Back'),
),
),
),
child: const Text('Next'),
),
),
),
child: const Text('Go'),
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pumpAndSettle();
await tester.tap(find.text('Next'));
await tester.pumpAndSettle();
// push referrer 06 §5.2 5
expect(events.map((e) => e['pageName']), ['login', 'register']);
expect(events[0].containsKey('referrer'), isFalse);
expect(events[1]['referrer'], 'login');
// pop didPop referrer
await tester.tap(find.text('Back'));
await tester.pumpAndSettle();
expect(events, hasLength(3));
expect(events[2]['pageName'], 'login');
expect(events[2]['referrer'], 'register');
});
testWidgets('pop 回无名根路由经 resolveRootPage 补报', (tester) async {
final events = <Map<String, dynamic>>[];
final tracker = PageViewTracker((name, [props]) async {
events.add({'event': name, ...?props});
});
final observer = AnalyticsRouteObserver(
tracker: tracker,
resolveRootPage: () => AnalyticsPageName.home,
);
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [observer],
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
settings: RouteSettings(
name: AnalyticsPageName.profile.pageName,
),
builder: (innerContext) => ElevatedButton(
onPressed: () => Navigator.of(innerContext).pop(),
child: const Text('Back'),
),
),
),
child: const Text('Go'),
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pumpAndSettle();
await tester.tap(find.text('Back'));
await tester.pumpAndSettle();
expect(events.map((e) => e['pageName']), ['profile', 'home']);
expect(events[1]['referrer'], 'profile');
});
testWidgets('未在枚举的路由名不上报', (tester) async {
final events = <Map<String, dynamic>>[];
final tracker = PageViewTracker((name, [props]) async {
events.add({'event': name, ...?props});
});
final observer = AnalyticsRouteObserver(tracker: tracker);
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [observer],
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
settings: const RouteSettings(name: '/debug/unknown'),
builder: (_) => const Placeholder(),
),
),
child: const Text('Go'),
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pumpAndSettle();
expect(events, isEmpty);
});
testWidgets('dialog 不上报(PopupRoute 不是 PageRoute', (tester) async {
final events = <Map<String, dynamic>>[];
final tracker = PageViewTracker((name, [props]) async {
events.add({'event': name, ...?props});
});
final observer = AnalyticsRouteObserver(tracker: tracker);
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [observer],
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showDialog<void>(
context: context,
builder: (_) => const AlertDialog(title: Text('Dialog')),
),
child: const Text('Show'),
),
),
),
);
await tester.tap(find.text('Show'));
await tester.pumpAndSettle();
expect(events, isEmpty);
});
});
group('PageViewTracker', () {
test('连续相同页面去重(Tab 重复点选)', () {
final events = <Map<String, dynamic>>[];
final tracker = PageViewTracker((name, [props]) async {
events.add({'event': name, ...?props});
});
tracker.report(AnalyticsPageName.home);
tracker.report(AnalyticsPageName.home);
tracker.report(AnalyticsPageName.profile);
tracker.report(AnalyticsPageName.home);
expect(events, hasLength(3));
expect(events.map((e) => e['pageName']), ['home', 'profile', 'home']);
});
test('referrer 链跨机制连贯(首页无 referrer)', () {
final events = <Map<String, dynamic>>[];
final tracker = PageViewTracker((name, [props]) async {
events.add({'event': name, ...?props});
});
tracker.report(AnalyticsPageName.home);
tracker.report(AnalyticsPageName.login);
tracker.report(AnalyticsPageName.profile);
expect(events[0].containsKey('referrer'), isFalse);
expect(events[1]['referrer'], 'home');
expect(events[2]['referrer'], 'login');
});
test('reportTab 记录当前 Tab', () {
final events = <Map<String, dynamic>>[];
final tracker = PageViewTracker((name, [props]) async {
events.add({'event': name, ...?props});
});
tracker.reportTab(AnalyticsPageName.petArchive);
expect(tracker.currentTab, AnalyticsPageName.petArchive);
expect(events.single['pageName'], 'pet_archive');
});
});
}

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