32 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
158 changed files with 33229 additions and 1355 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
+2 -1
View File
@@ -17,6 +17,7 @@ AI 创作、预约、账号和部分菜单目前仍为本地演示交互。
## 运行 ## 运行
```bash ```bash
flutter clean
flutter pub get flutter pub get
flutter run flutter run
``` ```
@@ -30,7 +31,7 @@ flutter run -d chrome
## 验证 ## 验证
```bash ```bash
dart format --set-exit-if-changed lib test dart format --output=none --set-exit-if-changed lib test
flutter analyze flutter analyze
flutter test flutter test
flutter build apk --debug flutter build apk --debug
+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;
}
}
+250 -2
View File
@@ -1,10 +1,48 @@
import 'package:flutter/material.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/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/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'; import 'package:patbond_flutter/state/app_state.dart';
class App extends StatefulWidget { class App extends StatefulWidget {
const App({super.key}); 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 @override
State<App> createState() => _AppState(); State<App> createState() => _AppState();
@@ -12,26 +50,236 @@ class App extends StatefulWidget {
class _AppState extends State<App> { class _AppState extends State<App> {
late final AppState appState; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
appState = AppState()..load(); 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 @override
void dispose() { void dispose() {
_analytics.stopPeriodicFlush();
sessionManager.removeListener(_reportAuthStateChange);
WidgetsBinding.instance.removeObserver(_sessionTracker);
appState.dispose(); appState.dispose();
petsController.dispose();
communityController.dispose();
if (widget.sessionManager == null) sessionManager.dispose();
super.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Patbond', title: 'Patbond',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildAppTheme(), theme: buildAppTheme(),
home: MainShellPage(appState: appState), 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}',
);
}
}
+116 -19
View File
@@ -1,21 +1,95 @@
import 'package:flutter/material.dart'; 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 { abstract final class AppColors {
static const primary = Color(0xFF4F46E5); /// 品牌珊瑚橙:图标、装饰、渐变起点;不用于承载文字的实心填充(对比度不足)。
static const canvas = Color(0xFFF8FAFC); static const primary = Color(0xFFFF6F4C);
static const ink = Color(0xFF0F172A);
static const muted = Color(0xFF64748B); /// 加深珊瑚:实心按钮填充、可点击文字链接(白字约 4.5:1,WCAG AA)。
static const border = Color(0xFFE2E8F0); static const primaryStrong = Color(0xFFD6431A);
static const success = Color(0xFF10B981);
/// 浅色底上的强调文字、按钮反白替代。
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); 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() { ThemeData buildAppTheme() {
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(
seedColor: AppColors.primary, seedColor: AppColors.primary,
brightness: Brightness.light, brightness: Brightness.light,
surface: Colors.white, surface: AppColors.surface,
); ).copyWith(error: AppColors.error, onError: Colors.white);
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
@@ -48,40 +122,63 @@ ThemeData buildAppTheme() {
bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4), bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4),
), ),
cardTheme: const CardThemeData( cardTheme: const CardThemeData(
color: Colors.white, color: AppColors.surface,
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(24)), borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
side: BorderSide(color: Color(0xFFF1F5F9)), 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( inputDecorationTheme: const InputDecorationTheme(
filled: true, filled: true,
fillColor: Colors.white, fillColor: AppColors.surface,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(18)), borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.border), borderSide: BorderSide(color: AppColors.border),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(18)), borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.border), borderSide: BorderSide(color: AppColors.border),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(18)), borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.primary, width: 1.5), 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( snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
), ),
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.white, backgroundColor: AppColors.surface,
indicatorColor: const Color(0xFFE0E7FF), indicatorColor: AppColors.surfaceTint,
height: 72, height: 72,
labelTextStyle: WidgetStateProperty.resolveWith((states) { labelTextStyle: WidgetStateProperty.resolveWith((states) {
return TextStyle( return TextStyle(
@@ -90,7 +187,7 @@ ThemeData buildAppTheme() {
? FontWeight.w700 ? FontWeight.w700
: FontWeight.w500, : FontWeight.w500,
color: states.contains(WidgetState.selected) color: states.contains(WidgetState.selected)
? AppColors.primary ? AppColors.primaryStrong
: AppColors.muted, : 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 11.251+
/// 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;
/// URLnull 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.1home `_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
///
/// - 24 2 359 3 1:1 cover
/// 4 `sm`(12)`RemoteImage` loading tint /
/// key
/// - 9 [totalCount]
/// `ink` 80% scrim + +N20/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 至少一张图');
/// URLFeed
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.224 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);
}
}
-27
View File
@@ -82,33 +82,6 @@ const initialPet = PetProfile(
avatarUrl: petAvatar, avatarUrl: petAvatar,
); );
const initialVaccines = VaccineRecord(
completedDoses: 2,
totalDoses: 3,
items: [
VaccineItem(
id: 'v1',
name: '犬五联 第1针',
status: VaccineStatus.completed,
date: '2025-05-12',
),
VaccineItem(
id: 'v2',
name: '狂犬疫苗',
status: VaccineStatus.completed,
date: '2025-06-15',
),
VaccineItem(
id: 'v3',
name: '犬五联 加强针',
status: VaccineStatus.pending,
date: '待定',
),
],
reminderVaccine: '犬五联 加强针',
reminderDate: '2026-08-15',
);
const initialPosts = [ const initialPosts = [
PostModel( PostModel(
id: 'post1', id: 'post1',
+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 }
/// Splash12 稿 §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();
}
/// nextCursorkeyset
/// [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);
}
/// 40902version
/// 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 imagevideo/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 GETTTL 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/40000isCover 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` draftpublished
/// 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 hexM3
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;
/// PUTenum
final String method;
final Map<String, String> requiredHeaders;
final DateTime expiresAt;
}
/// media assetconfirm url ready
/// GETTTL 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
///
/// FeedTab 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 GPS03 §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-1303 §4.3 稿
///
/// 8060 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 线线 1postId /
/// 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 imagevideo
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.3T3-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_creationM4 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 / sheetM3 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-op15 §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.3surfaceTint 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;
}
+52 -33
View File
@@ -7,15 +7,23 @@ import 'package:patbond_flutter/widgets/common.dart';
enum CreationMode { image, video } 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 { class CreatePage extends StatefulWidget {
const CreatePage({ const CreatePage({
required this.appState, required this.appState,
required this.onPublished, required this.onOpenCompose,
super.key, super.key,
}); });
final AppState appState; final AppState appState;
final ValueChanged<PostModel> onPublished;
/// push Feed
final VoidCallback onOpenCompose;
@override @override
State<CreatePage> createState() => _CreatePageState(); State<CreatePage> createState() => _CreatePageState();
@@ -111,36 +119,13 @@ class _CreatePageState extends State<CreatePage> {
setState(() => tags = [...tags, value]); setState(() => tags = [...tags, value]);
} }
/// AI M4AI media
/// assetdemo
/// `AppState.posts` T3-17 退
void publish() { void publish() {
if (resultUrl == null || titleController.text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of( const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')),
context,
).showSnackBar(const SnackBar(content: Text('请先完成生成并填写标题')));
return;
}
final post = PostModel(
id: 'post_user_${DateTime.now().millisecondsSinceEpoch}',
authorName: '萌宠新手(我)',
authorAvatar: userAvatar,
time: '刚刚',
breedTag: 'AI创作',
content:
'${titleController.text.trim()}\n\n${contentController.text.trim()}',
mainImage: resultUrl!,
likes: 1,
tags: [...tags, mode == CreationMode.image ? 'AI生图' : 'AI视频'],
comments: const [],
hasLiked: true,
); );
widget.appState.publishPost(post);
setState(() {
uploaded = false;
resultUrl = null;
generationStep = 0;
titleController.clear();
contentController.clear();
});
widget.onPublished(post);
} }
@override @override
@@ -148,6 +133,8 @@ class _CreatePageState extends State<CreatePage> {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [ children: [
_ComposeEntryCard(onTap: widget.onOpenCompose),
const SizedBox(height: 18),
SegmentedButton<CreationMode>( SegmentedButton<CreationMode>(
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
@@ -262,10 +249,13 @@ class _CreatePageState extends State<CreatePage> {
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
gradient: const LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Colors.transparent, Color(0xCC0F172A)], colors: [
Colors.transparent,
AppColors.ink.withAlpha(204),
],
), ),
), ),
), ),
@@ -286,7 +276,7 @@ class _CreatePageState extends State<CreatePage> {
Text( Text(
style.subtitle, style.subtitle,
style: const TextStyle( style: const TextStyle(
color: Color(0xFFE2E8F0), color: Colors.white70,
fontSize: 10, fontSize: 10,
), ),
), ),
@@ -399,6 +389,35 @@ class _CreatePageState extends State<CreatePage> {
} }
} }
/// T3-17 AI M4
/// push
class _ComposeEntryCard extends StatelessWidget {
const _ComposeEntryCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SectionCard(
padding: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: const CircleAvatar(
backgroundColor: AppColors.surfaceTint,
child: Icon(Icons.edit_outlined, color: AppColors.primary),
),
title: const Text(
'发布动态',
style: TextStyle(fontWeight: FontWeight.w800),
),
subtitle: const Text('写点文字、配上照片,分享给宠友'),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _UploadCard extends StatelessWidget { class _UploadCard extends StatelessWidget {
const _UploadCard({ const _UploadCard({
required this.uploaded, required this.uploaded,
+477 -224
View File
@@ -1,42 +1,252 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/data/demo_data.dart'; import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_display.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/community/feed_exposure.dart';
import 'package:patbond_flutter/models/models.dart'; import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart'; import 'package:patbond_flutter/widgets/common.dart';
enum HomeSegment { feed, services } enum HomeSegment { feed, services }
/// TabFeed T3-14 [CommunityController]
/// + + ///
/// `_StoryRow` demo 03 §1.1
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({ const HomePage({
required this.appState, required this.appState,
required this.onOpenPost, required this.communityController,
required this.onOpenServices, required this.onOpenServices,
required this.onOpenCreate, required this.onOpenCompose,
super.key, super.key,
this.onOpenPost,
this.feedAnalytics,
this.isActive = true,
}); });
final AppState appState; final AppState appState;
final ValueChanged<PostModel> onOpenPost;
/// Feed Tab app.dart
final CommunityController communityController;
final ValueChanged<bool> onOpenServices; final ValueChanged<bool> onOpenServices;
final VoidCallback onOpenCreate;
/// T3-17story CTA push
/// entryPoint=feed Tab AI
final VoidCallback onOpenCompose;
/// T3-15 push PostDetailPage
final ValueChanged<String>? onOpenPost;
/// feed feed_viewed + feed_load_failed
final FeedAnalytics? feedAnalytics;
/// Tab TabIndexedStack Tab
/// /
final bool isActive;
@override @override
State<HomePage> createState() => _HomePageState(); State<HomePage> createState() => _HomePageState();
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
HomeSegment segment = HomeSegment.feed; HomeSegment segment = HomeSegment.feed;
String query = ''; String query = '';
String sort = '综合'; String sort = '综合';
List<PostModel> get filteredPosts { /// Feed Feed null
FeedViewSegment? _viewSegment;
/// postId GlobalKey
final Map<String, GlobalKey> _cardKeys = {};
final GlobalKey _listKey = GlobalKey();
AppLifecycleState _lastLifecycle = AppLifecycleState.resumed;
CommunityController get _feed => widget.communityController;
bool get _feedSurfaceVisible =>
widget.isActive && segment == HomeSegment.feed;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// pets reset initial
// refreshCount
if (_feed.phase == FeedPhase.initial) {
_refreshFeed(userInitiated: false);
}
if (_feedSurfaceVisible) _startSegment();
}
@override
void didUpdateWidget(HomePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
widget.isActive ? _maybeStartSegment() : _settleSegment();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// 退06 §1.2feed_viewed
// resumed inactive/hidden/paused
// Feed
if (state == AppLifecycleState.resumed) {
_lastLifecycle = state;
_maybeStartSegment();
return;
}
if (_lastLifecycle == AppLifecycleState.resumed) {
_settleSegment();
}
_lastLifecycle = state;
}
@override
void dispose() {
_settleSegment();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// ---- / ----
void _startSegment() {
_viewSegment ??= FeedViewSegment();
_scheduleVisibilityScan();
}
void _maybeStartSegment() {
if (_feedSurfaceVisible && _lastLifecycle == AppLifecycleState.resumed) {
_startSegment();
}
}
void _settleSegment() {
final summary = _viewSegment?.settle();
_viewSegment = null;
if (summary != null && widget.feedAnalytics != null) {
summary.report(widget.feedAnalytics!);
}
}
void _onSegmentChanged(HomeSegment value) {
if (value == segment) return;
setState(() => segment = value);
value == HomeSegment.feed ? _maybeStartSegment() : _settleSegment();
}
// ---- ----
bool _scanScheduled = false;
/// RenderBox
/// post-frame
void _scheduleVisibilityScan() {
if (_scanScheduled) return;
_scanScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_scanScheduled = false;
_scanVisibility();
});
}
/// RenderBox
/// 50% 500ms [FeedViewSegment]
void _scanVisibility() {
final viewSegment = _viewSegment;
if (viewSegment == null || !mounted) return;
final listBox = _listKey.currentContext?.findRenderObject() as RenderBox?;
if (listBox == null || !listBox.attached) return;
final viewportTop = listBox.localToGlobal(Offset.zero).dy;
final viewportBottom = viewportTop + listBox.size.height;
for (final entry in _cardKeys.entries) {
final box = entry.value.currentContext?.findRenderObject() as RenderBox?;
var fraction = 0.0;
if (box != null && box.attached && box.hasSize && box.size.height > 0) {
final top = box.localToGlobal(Offset.zero).dy;
final bottom = top + box.size.height;
final visible =
bottom.clamp(viewportTop, viewportBottom) -
top.clamp(viewportTop, viewportBottom);
fraction = visible / box.size.height;
}
viewSegment.updateVisibility(entry.key, fraction);
}
}
bool _onScrollNotification(ScrollNotification notification) {
if (segment != HomeSegment.feed) return false;
if (notification is ScrollUpdateNotification ||
notification is ScrollEndNotification) {
_scheduleVisibilityScan();
// 400
//
if (notification.metrics.extentAfter < 400 &&
_feed.loadMorePhase == LoadMorePhase.idle) {
_loadMoreFeed();
}
}
return false;
}
// ---- ----
Future<void> _refreshFeed({required bool userInitiated}) async {
if (userInitiated) _viewSegment?.recordRefresh();
await _feed.refresh();
final error = _feed.refreshError ?? _feed.lastError;
if (error != null) {
widget.feedAnalytics?.feedLoadFailedFrom(
error,
feedTab: FeedTab.home,
loadType: FeedLoadType.refresh,
);
}
// SnackBar error
if (_feed.refreshError != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(feedLoadErrorMessage(_feed.refreshError))),
);
}
}
Future<void> _loadMoreFeed() async {
if (_feed.phase != FeedPhase.ready ||
!_feed.hasMore ||
_feed.loadMorePhase == LoadMorePhase.loading) {
return;
}
_viewSegment?.recordLoadMore();
await _feed.loadMore();
final error = _feed.loadMoreError;
if (error != null) {
widget.feedAnalytics?.feedLoadFailedFrom(
error,
feedTab: FeedTab.home,
loadType: FeedLoadType.loadMore,
);
}
}
/// demo
/// v1.3.0
List<FeedCard> get _visibleCards {
final keyword = query.trim().toLowerCase(); final keyword = query.trim().toLowerCase();
if (keyword.isEmpty) return widget.appState.posts; if (keyword.isEmpty) return _feed.feed;
return widget.appState.posts.where((post) { return _feed.feed.where((card) {
return post.content.toLowerCase().contains(keyword) || return card.contentPreview.toLowerCase().contains(keyword) ||
post.authorName.toLowerCase().contains(keyword) || (card.title?.toLowerCase().contains(keyword) ?? false) ||
post.tags.any((tag) => tag.toLowerCase().contains(keyword)); authorDisplayName(card.author).toLowerCase().contains(keyword);
}).toList(); }).toList();
} }
@@ -95,113 +305,250 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RefreshIndicator( return ListenableBuilder(
onRefresh: () async => listenable: _feed,
Future<void>.delayed(const Duration(milliseconds: 500)), builder: (context, _) {
child: ListView( // / SnackBar§3.5
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), // controller toggleError
children: [ if (_feed.toggleError != null) {
_WeatherStatusBar( _feed.clearToggleError();
weather: widget.appState.locationWeather, WidgetsBinding.instance.addPostFrameCallback((_) {
onAreaTap: selectArea, if (!mounted) return;
onWeatherTap: showWeatherDetails, ScaffoldMessenger.of(
), context,
const SizedBox(height: 12), ).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
_PetGreetingCard( });
greeting: greeting, }
petName: widget.appState.pet.name, // ready
petAvatar: widget.appState.pet.avatarUrl, if (_viewSegment != null && _feed.phase == FeedPhase.ready) {
advice: widget.appState.locationWeather.petAdvice, _scheduleVisibilityScan();
), }
const SizedBox(height: 14), return NotificationListener<ScrollNotification>(
TextField( onNotification: _onScrollNotification,
onChanged: (value) => setState(() => query = value), child: RefreshIndicator(
decoration: const InputDecoration( onRefresh: () => segment == HomeSegment.feed
hintText: '搜索动态、医院、美容或遛狗服务…', ? _refreshFeed(userInitiated: true)
prefixIcon: Icon(Icons.search), : Future<void>.delayed(const Duration(milliseconds: 500)),
), child: ListView(
), key: _listKey,
const SizedBox(height: 14), padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
SegmentedButton<HomeSegment>( children: [
segments: const [ _WeatherStatusBar(
ButtonSegment( weather: widget.appState.locationWeather,
value: HomeSegment.feed, onAreaTap: selectArea,
icon: Icon(Icons.forum_outlined), onWeatherTap: showWeatherDetails,
label: Text('社区动态'), ),
), const SizedBox(height: 12),
ButtonSegment( _PetGreetingCard(
value: HomeSegment.services, greeting: greeting,
icon: Icon(Icons.storefront_outlined), petName: widget.appState.pet.name,
label: Text('本地服务'), petAvatar: widget.appState.pet.avatarUrl,
), advice: widget.appState.locationWeather.petAdvice,
], ),
selected: {segment}, const SizedBox(height: 14),
showSelectedIcon: false, TextField(
onSelectionChanged: (value) { onChanged: (value) => setState(() => query = value),
setState(() => segment = value.first); decoration: const InputDecoration(
}, hintText: '搜索动态、医院、美容或遛狗服务…',
), prefixIcon: Icon(Icons.search),
const SizedBox(height: 18), ),
if (segment == HomeSegment.feed) ...[ ),
_StoryRow(onCreate: widget.onOpenCreate), const SizedBox(height: 14),
const SizedBox(height: 18), SegmentedButton<HomeSegment>(
_PromoCard(onTap: () => widget.onOpenServices(true)), segments: const [
const SizedBox(height: 18), ButtonSegment(
if (filteredPosts.isEmpty) value: HomeSegment.feed,
const EmptyState(message: '没有找到相关动态') icon: Icon(Icons.forum_outlined),
else label: Text('社区动态'),
...filteredPosts.map( ),
(post) => Padding( ButtonSegment(
padding: const EdgeInsets.only(bottom: 16), value: HomeSegment.services,
child: _PostCard( icon: Icon(Icons.storefront_outlined),
post: post, label: Text('本地服务'),
onTap: () => widget.onOpenPost(post), ),
onLike: () => widget.appState.updatePost( ],
post.copyWith( selected: {segment},
hasLiked: !post.hasLiked, showSelectedIcon: false,
likes: post.hasLiked ? post.likes - 1 : post.likes + 1, onSelectionChanged: (value) => _onSegmentChanged(value.first),
),
const SizedBox(height: 18),
if (segment == HomeSegment.feed) ...[
_StoryRow(onCreate: widget.onOpenCompose),
const SizedBox(height: 18),
_PromoCard(onTap: () => widget.onOpenServices(true)),
const SizedBox(height: 18),
..._feedSection(),
] else ...[
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
const SizedBox(height: 18),
Row(
children: [
Text(
'附近推荐',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
for (final item in ['综合', '距离', '评分'])
Padding(
padding: const EdgeInsets.only(left: 6),
child: ChoiceChip(
label: Text(item),
selected: sort == item,
onSelected: (_) => setState(() => sort = item),
),
),
],
),
const SizedBox(height: 12),
if (filteredProviders.isEmpty)
const EmptyState(message: '没有找到相关服务')
else
...filteredProviders.map(
(provider) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _ProviderCompactCard(
provider: provider,
onTap: () => widget.onOpenServices(
provider.kind == ProviderKind.personal,
),
),
), ),
), ),
), ],
),
),
] else ...[
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
const SizedBox(height: 18),
Row(
children: [
Text('附近推荐', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
for (final item in ['综合', '距离', '评分'])
Padding(
padding: const EdgeInsets.only(left: 6),
child: ChoiceChip(
label: Text(item),
selected: sort == item,
onSelected: (_) => setState(() => sort = item),
),
),
], ],
), ),
const SizedBox(height: 12), ),
if (filteredProviders.isEmpty) );
const EmptyState(message: '没有找到相关服务') },
else );
...filteredProviders.map( }
(provider) => Padding(
padding: const EdgeInsets.only(bottom: 12), // ---- Feed ----
child: _ProviderCompactCard(
provider: provider, List<Widget> _feedSection() {
onTap: () => widget.onOpenServices( switch (_feed.phase) {
provider.kind == ProviderKind.personal, case FeedPhase.initial:
), case FeedPhase.loading:
), return const [
FeedSkeleton(),
SizedBox(height: 16),
FeedSkeleton(),
SizedBox(height: 16),
FeedSkeleton(),
];
case FeedPhase.error:
return [
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
InlineErrorBanner(
message: feedLoadErrorMessage(_feed.lastError),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _refreshFeed(userInitiated: true),
child: const Text('重试'),
),
],
),
),
];
case FeedPhase.ready:
if (_feed.feed.isEmpty) {
return [
EmptyStateIllustration(
icon: Icons.forum_outlined,
title: '还没有动态',
description: '关注的毛孩子们还没发帖,去逛逛话题吧',
ctaLabel: '发布第一条',
onCtaPressed: widget.onOpenCompose,
),
];
}
final cards = _visibleCards;
_pruneCardKeys(cards);
if (cards.isEmpty) {
return const [EmptyState(message: '没有找到相关动态')];
}
return [
for (final card in cards)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: KeyedSubtree(
key: _cardKeys.putIfAbsent(card.id, GlobalKey.new),
// / ToggleSyncsource=feed
// T3-15 T3-14
child: PostCard(
card: card,
onTap: () => widget.onOpenPost?.call(card.id),
onCommentTap: () => widget.onOpenPost?.call(card.id),
onLikeTap: () => _feed.toggleLike(card.id),
onBookmarkTap: () => _feed.toggleBookmark(card.id),
), ),
), ),
], ),
], //
), if (query.trim().isEmpty) _feedTail(),
); ];
}
}
/// key
void _pruneCardKeys(List<FeedCard> cards) {
final alive = {for (final card in cards) card.id};
_cardKeys.removeWhere((id, _) {
if (alive.contains(id)) return false;
_viewSegment?.updateVisibility(id, 0);
return true;
});
}
/// / /
Widget _feedTail() {
switch (_feed.loadMorePhase) {
case LoadMorePhase.loading:
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: AppColors.primary,
),
),
),
);
case LoadMorePhase.error:
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
Text(
feedLoadErrorMessage(_feed.loadMoreError),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
TextButton(
onPressed: _loadMoreFeed,
child: const Text('加载失败,点此重试'),
),
],
),
);
case LoadMorePhase.idle:
if (_feed.hasMore) return const SizedBox(height: 24);
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'没有更多了',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
),
);
}
} }
} }
@@ -314,15 +661,19 @@ class _PetGreetingCard extends StatelessWidget {
gradient: const LinearGradient( gradient: const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFFECFDF5), Color(0xFFFFF7ED)], colors: [AppColors.surfaceTint, AppColors.canvas],
), ),
), ),
child: Stack( child: Stack(
children: [ children: [
const Positioned( Positioned(
left: 160, left: 160,
top: 15, top: 15,
child: Icon(Icons.pets, size: 28, color: Color(0x224F46E5)), child: Icon(
Icons.pets,
size: 28,
color: AppColors.primary.withAlpha(34),
),
), ),
Positioned( Positioned(
right: -5, right: -5,
@@ -355,7 +706,7 @@ class _PetGreetingCard extends StatelessWidget {
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF475569), color: AppColors.primaryDark,
fontSize: 12, fontSize: 12,
height: 1.45, height: 1.45,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -497,7 +848,7 @@ class _WeatherDetailsSheet extends StatelessWidget {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEEF2FF), color: AppColors.surfaceTint,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
child: Row( child: Row(
@@ -527,10 +878,10 @@ IconData _weatherIcon(WeatherCondition condition) {
Color _weatherColor(WeatherCondition condition) { Color _weatherColor(WeatherCondition condition) {
return switch (condition) { return switch (condition) {
WeatherCondition.sunny => const Color(0xFFF59E0B), WeatherCondition.sunny => AppColors.warning,
WeatherCondition.cloudy => const Color(0xFF64748B), WeatherCondition.cloudy => AppColors.muted,
WeatherCondition.rainy => const Color(0xFF0284C7), WeatherCondition.rainy => const Color(0xFF0284C7),
WeatherCondition.overcast => const Color(0xFF475569), WeatherCondition.overcast => AppColors.ink,
WeatherCondition.snow => const Color(0xFF0891B2), WeatherCondition.snow => const Color(0xFF0891B2),
}; };
} }
@@ -577,9 +928,7 @@ class _StoryRow extends StatelessWidget {
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
decoration: const BoxDecoration( decoration: const BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: LinearGradient( gradient: AppColors.brandGradient,
colors: [AppColors.primary, Color(0xFF8B5CF6)],
),
), ),
child: index == 0 child: index == 0
? const CircleAvatar( ? const CircleAvatar(
@@ -617,8 +966,9 @@ class _PromoCard extends StatelessWidget {
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
// 4.5:1 brandGradient FIX-1
gradient: const LinearGradient( gradient: const LinearGradient(
colors: [AppColors.primary, Color(0xFF7C3AED)], colors: [AppColors.primaryStrong, AppColors.primary],
), ),
borderRadius: BorderRadius.circular(26), borderRadius: BorderRadius.circular(26),
), ),
@@ -639,7 +989,7 @@ class _PromoCard extends StatelessWidget {
SizedBox(height: 5), SizedBox(height: 5),
Text( Text(
'遛狗 / 洗澡 / 寄养均可使用', '遛狗 / 洗澡 / 寄养均可使用',
style: TextStyle(color: Color(0xFFE0E7FF), fontSize: 12), style: TextStyle(color: Colors.white, fontSize: 12),
), ),
], ],
), ),
@@ -647,7 +997,7 @@ class _PromoCard extends StatelessWidget {
FilledButton( FilledButton(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: AppColors.primary, foregroundColor: AppColors.primaryStrong,
), ),
onPressed: onTap, onPressed: onTap,
child: const Text('去使用'), child: const Text('去使用'),
@@ -658,103 +1008,6 @@ class _PromoCard extends StatelessWidget {
} }
} }
class _PostCard extends StatelessWidget {
const _PostCard({
required this.post,
required this.onTap,
required this.onLike,
});
final PostModel post;
final VoidCallback onTap;
final VoidCallback onLike;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
RemoteImage(
url: post.authorAvatar,
width: 38,
height: 38,
borderRadius: BorderRadius.circular(19),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.authorName,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(
'${post.time} · ${post.breedTag}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const Icon(Icons.more_horiz, color: AppColors.muted),
],
),
),
AspectRatio(
aspectRatio: 4 / 3,
child: RemoteImage(url: post.mainImage),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
children: [
ActionChip(
avatar: Icon(
post.hasLiked
? Icons.favorite
: Icons.favorite_border,
size: 17,
color: post.hasLiked ? Colors.red : AppColors.primary,
),
label: Text('${post.likes}'),
onPressed: onLike,
),
const SizedBox(width: 8),
Chip(
avatar: const Icon(Icons.chat_bubble_outline, size: 16),
label: Text('${post.comments.length}'),
),
const Spacer(),
const Icon(Icons.share_outlined, color: AppColors.muted),
],
),
],
),
),
],
),
),
);
}
}
class _CategoryGrid extends StatelessWidget { class _CategoryGrid extends StatelessWidget {
const _CategoryGrid({required this.onTap}); const _CategoryGrid({required this.onTap});
@@ -789,7 +1042,7 @@ class _CategoryGrid extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CircleAvatar( CircleAvatar(
backgroundColor: const Color(0xFFEEF2FF), backgroundColor: AppColors.surfaceTint,
child: Icon(item.$2, color: AppColors.primary), child: Icon(item.$2, color: AppColors.primary),
), ),
const SizedBox(height: 7), const SizedBox(height: 7),
+125 -15
View File
@@ -1,20 +1,79 @@
import 'dart:async';
import 'package:flutter/material.dart'; 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/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/create/create_page.dart';
import 'package:patbond_flutter/features/home/home_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/pets/pets_page.dart';
import 'package:patbond_flutter/features/post/post_detail_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/profile/profile_page.dart';
import 'package:patbond_flutter/features/services/services_page.dart'; import 'package:patbond_flutter/features/services/services_page.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart'; import 'package:patbond_flutter/widgets/common.dart';
class MainShellPage extends StatefulWidget { class MainShellPage extends StatefulWidget {
const MainShellPage({required this.appState, 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; 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 @override
State<MainShellPage> createState() => _MainShellPageState(); State<MainShellPage> createState() => _MainShellPageState();
} }
@@ -25,8 +84,26 @@ class _MainShellPageState extends State<MainShellPage> {
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料']; 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
void initState() {
super.initState();
// Tab 03 §3.2 1
widget.pageViewTracker?.reportTab(_tabPages[currentIndex]);
}
void selectTab(int index) { void selectTab(int index) {
setState(() => currentIndex = index); setState(() => currentIndex = index);
widget.pageViewTracker?.reportTab(_tabPages[index]);
} }
void openServices(bool personal) { void openServices(bool personal) {
@@ -34,17 +111,47 @@ class _MainShellPageState extends State<MainShellPage> {
showPersonalServices = personal; showPersonalServices = personal;
currentIndex = 3; currentIndex = 3;
}); });
widget.pageViewTracker?.reportTab(_tabPages[3]);
} }
void openPost(PostModel post) { /// T3-15Feed
/// communityController
void openPost(String postId) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (context) => settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
PostDetailPage(appState: widget.appState, postId: post.id), builder: (context) => PostDetailPage(
controller: widget.communityController,
postId: postId,
currentUserId: widget.currentUserId,
analytics: widget.interactionAnalytics,
),
), ),
); );
} }
/// T3-17 Feed
/// `(published_at DESC, id DESC)`
Future<void> openCompose(PostEntryPoint entryPoint) async {
final published = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
settings: RouteSettings(name: AnalyticsPageName.postForm.pageName),
builder: (context) => PostComposePage(
controller: widget.communityController,
entryPoint: entryPoint,
analytics: widget.postAnalytics,
uploaderFactory: widget.mediaUploaderFactory,
),
),
);
if (!mounted || published != true) return;
selectTab(0);
unawaited(widget.communityController.refresh());
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已发布,去首页看看吧 🐾')));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
@@ -59,25 +166,28 @@ class _MainShellPageState extends State<MainShellPage> {
final pages = [ final pages = [
HomePage( HomePage(
appState: widget.appState, appState: widget.appState,
onOpenPost: openPost, communityController: widget.communityController,
feedAnalytics: widget.feedAnalytics,
isActive: currentIndex == 0,
onOpenServices: openServices, onOpenServices: openServices,
onOpenCreate: () => selectTab(1), onOpenCompose: () => openCompose(PostEntryPoint.feed),
onOpenPost: openPost,
), ),
CreatePage( CreatePage(
appState: widget.appState, appState: widget.appState,
onPublished: (post) { // T3-17 pushAI M4
selectTab(0); onOpenCompose: () => openCompose(PostEntryPoint.createTab),
WidgetsBinding.instance.addPostFrameCallback( ),
(_) => openPost(post), PetsPage(
); controller: widget.petsController,
}, analytics: widget.petAnalytics,
healthAnalytics: widget.healthRecordAnalytics,
), ),
PetsPage(appState: widget.appState),
ServicesPage( ServicesPage(
showPersonal: showPersonalServices, showPersonal: showPersonalServices,
locationWeather: widget.appState.locationWeather, locationWeather: widget.appState.locationWeather,
), ),
ProfilePage(appState: widget.appState), ProfilePage(appState: widget.appState, onLogout: widget.onLogout),
]; ];
return Scaffold( return Scaffold(
@@ -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 statustitle/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}`
/// pendingcompleted completedAtpendingdismissed
/// 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,
// pendingcompleted 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,
// pendingdismissed 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() {
// CTA05 §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
/// UTCISO 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-14cursor 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_form06 §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.22026 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] 0M2 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();
}
}
+261 -676
View File
@@ -1,288 +1,224 @@
import 'package:flutter/material.dart'; 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/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/models/models.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/state/app_state.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'; import 'package:patbond_flutter/widgets/common.dart';
class PetsPage extends StatelessWidget { /// Tab T2-12 / 05 §4.1 P1
const PetsPage({required this.appState, super.key}); ///
/// [PetsController] loading
/// empty + CTAerror + ready
/// ApiClient AppState demo
class PetsPage extends StatefulWidget {
const PetsPage({
required this.controller,
super.key,
this.analytics,
this.healthAnalytics,
});
final AppState appState; final PetsController controller;
final PetAnalytics? analytics;
String ageLabel(String value) { /// health_record T2-13
final birthday = DateTime.tryParse(value); final HealthRecordAnalytics? healthAnalytics;
if (birthday == null) return '年龄未知';
final today = DateTime.now(); @override
var age = today.year - birthday.year; State<PetsPage> createState() => _PetsPageState();
if (today.month < birthday.month || }
(today.month == birthday.month && today.day < birthday.day)) {
age--; class _PetsPageState extends State<PetsPage> {
@override
void initState() {
super.initState();
// IndexedStack Tab
// reset initial
if (widget.controller.phase == PetsLoadPhase.initial) {
widget.controller.refresh();
} }
return '${age < 0 ? 0 : age}';
} }
Future<void> editPet(BuildContext context) async { Future<void> _openCreate(PetCreateEntryPoint entryPoint) async {
final value = await showModalBottomSheet<PetProfile>( final created = await Navigator.of(context).push<Pet>(
context: context, fadePageRoute(
isScrollControlled: true, PetFormPage.create(
useSafeArea: true, controller: widget.controller,
builder: (context) => EditPetSheet(pet: appState.pet), analytics: widget.analytics,
entryPoint: entryPoint,
),
// RouteObserver 06 §1.6 pet_form
settings: RouteSettings(name: AnalyticsPageName.petForm.pageName),
),
); );
if (value != null) await appState.updatePet(value); if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已为「${created.name}」建立档案 🐾')));
}
} }
Future<void> editVaccines(BuildContext context) async { void _openDetail(Pet pet) {
final value = await showModalBottomSheet<VaccineRecord>( Navigator.of(context).push(
context: context, fadePageRoute(
isScrollControlled: true, PetDetailPage(
useSafeArea: true, controller: widget.controller,
builder: (context) => VaccineSheet(record: appState.vaccines), petId: pet.id,
analytics: widget.analytics,
healthAnalytics: widget.healthAnalytics,
),
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
),
); );
if (value != null) await appState.updateVaccines(value);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pet = appState.pet; return ListenableBuilder(
final vaccines = appState.vaccines; listenable: widget.controller,
final progress = vaccines.totalDoses == 0 builder: (context, _) {
? 0.0 final controller = widget.controller;
: vaccines.completedDoses / vaccines.totalDoses; switch (controller.phase) {
case PetsLoadPhase.initial:
case PetsLoadPhase.loading:
return const Center(child: CircularProgressIndicator());
case PetsLoadPhase.error:
return _LoadErrorView(
message: petLoadErrorMessage(controller.lastError),
onRetry: controller.refresh,
);
case PetsLoadPhase.ready:
if (controller.isEmpty) {
return Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.pets,
title: '还没有宠物档案',
description: '添加毛孩子,开始记录 TA 的健康点滴',
ctaLabel: '添加宠物',
onCtaPressed: () =>
_openCreate(PetCreateEntryPoint.profileEmptyState),
),
),
);
}
return _petList(controller.pets);
}
},
);
}
return ListView( Widget _petList(List<Pet> pets) {
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30), return RefreshIndicator(
children: [ onRefresh: widget.controller.refresh,
Column( child: ListView(
children: [ padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
Stack( children: [
children: [ Row(
InkWell( children: [
onTap: () => editPet(context), Text('我的宠物', style: Theme.of(context).textTheme.titleLarge),
borderRadius: BorderRadius.circular(54), const Spacer(),
child: RemoteImage( TextButton.icon(
url: pet.avatarUrl, onPressed: () => _openCreate(PetCreateEntryPoint.petList),
width: 104, icon: const Icon(Icons.add, size: 18),
height: 104, label: const Text('添加'),
borderRadius: BorderRadius.circular(52), ),
), ],
), ),
Positioned( const SizedBox(height: 12),
right: 0, for (final pet in pets) ...[
bottom: 0, _PetCard(pet: pet, onTap: () => _openDetail(pet)),
child: CircleAvatar(
radius: 16,
backgroundColor: AppColors.primary,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: '编辑资料',
onPressed: () => editPet(context),
icon: const Icon(
Icons.edit,
color: Colors.white,
size: 15,
),
),
),
),
],
),
const SizedBox(height: 12),
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
'${pet.breed} · ${pet.gender == PetGender.male ? '男孩' : '女孩'} · ${ageLabel(pet.birthday)} · 活泼',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10), const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.settings_outlined, size: 17),
label: const Text('编辑资料'),
),
], ],
), _AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)),
const SizedBox(height: 24), ],
Row( ),
children: [
Text('宠物数据', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
TextButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑'),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.monitor_weight_outlined,
color: AppColors.primary,
value: '${pet.weight.toStringAsFixed(1)}kg',
label: '体重',
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
icon: Icons.vaccines_outlined,
color: AppColors.success,
value: '${vaccines.completedDoses}/${vaccines.totalDoses}',
label: '疫苗进度',
onTap: () => editVaccines(context),
),
),
const SizedBox(width: 10),
const Expanded(
child: _StatCard(
icon: Icons.payments_outlined,
color: AppColors.warning,
value: '¥328',
label: '本月花费',
),
),
],
),
const SizedBox(height: 18),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('健康概览', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
Text('更新于今日', style: Theme.of(context).textTheme.bodySmall),
],
),
const SizedBox(height: 18),
Row(
children: [
SizedBox.square(
dimension: 62,
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 7,
backgroundColor: const Color(0xFFE2E8F0),
color: AppColors.success,
),
Center(
child: Text(
'${(progress * 100).round()}%',
style: const TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
progress >= 1 ? '疫苗接种完成' : '疫苗接种进行中',
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 5),
Text(
'下一针:${vaccines.reminderVaccine}\n预计 ${vaccines.reminderDate}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
tooltip: '管理疫苗',
onPressed: () => editVaccines(context),
icon: const Icon(Icons.edit_outlined),
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFECFDF5),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFA7F3D0)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.auto_awesome, color: AppColors.success),
SizedBox(width: 10),
Expanded(
child: Text(
'健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。',
style: TextStyle(color: Color(0xFF047857), height: 1.5),
),
),
],
),
),
const SizedBox(height: 22),
Text('成长足迹', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
const _TimelineTile(
icon: Icons.medical_services_outlined,
title: '医疗 · 狂犬疫苗接种',
subtitle: '2025-06-12 · 瑞派宠物医院',
status: '已完成',
),
const _TimelineTile(
icon: Icons.restaurant_outlined,
title: '喂养 · 更换幼犬粮',
subtitle: '2025-05-20 · 体重增长稳定',
status: '已记录',
),
],
); );
} }
} }
class _StatCard extends StatelessWidget { /// InlineErrorBanner + 05 §4.2
const _StatCard({ class _LoadErrorView extends StatelessWidget {
required this.icon, const _LoadErrorView({required this.message, required this.onRetry});
required this.color,
required this.value,
required this.label,
this.onTap,
});
final IconData icon; final String message;
final Color color; final Future<void> Function() onRetry;
final String value;
final String label; @override
final VoidCallback? onTap; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(AppRadius.xl),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), padding: const EdgeInsets.all(14),
child: Column( child: Row(
children: [ children: [
Icon(icon, color: color), const PetAvatar(size: PetAvatarSize.lg),
const SizedBox(height: 6), const SizedBox(width: 12),
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)), Expanded(
const SizedBox(height: 2), child: Column(
Text(label, style: Theme.of(context).textTheme.bodySmall), 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),
], ],
), ),
), ),
@@ -291,433 +227,82 @@ class _StatCard extends StatelessWidget {
} }
} }
class _TimelineTile extends StatelessWidget { /// 线05 §4.1border 1.5px dashedradius 24 64
const _TimelineTile({ class _AddPetCard extends StatelessWidget {
required this.icon, const _AddPetCard({required this.onTap});
required this.title,
required this.subtitle, final VoidCallback onTap;
required this.status,
@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 IconData icon; final Color color;
final String title; final double strokeWidth;
final String subtitle; final double radius;
final String status;
@override @override
Widget build(BuildContext context) { void paint(Canvas canvas, Size size) {
return Padding( final paint = Paint()
padding: const EdgeInsets.only(bottom: 10), ..color = color
child: SectionCard( ..strokeWidth = strokeWidth
child: Row( ..style = PaintingStyle.stroke;
children: [ final path = Path()
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)), ..addRRect(
const SizedBox(width: 12), RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)),
Expanded( );
child: Column( const dashWidth = 6.0;
crossAxisAlignment: CrossAxisAlignment.start, const dashGap = 4.0;
children: [ for (final metric in path.computeMetrics()) {
Text( var distance = 0.0;
title, while (distance < metric.length) {
style: const TextStyle(fontWeight: FontWeight.w800), canvas.drawPath(
), metric.extractPath(distance, distance + dashWidth),
const SizedBox(height: 3), paint,
Text(subtitle, style: Theme.of(context).textTheme.bodySmall), );
], distance += dashWidth + dashGap;
), }
), }
TagPill(status, color: AppColors.success),
],
),
),
);
}
}
class EditPetSheet extends StatefulWidget {
const EditPetSheet({required this.pet, super.key});
final PetProfile pet;
@override
State<EditPetSheet> createState() => _EditPetSheetState();
}
class _EditPetSheetState extends State<EditPetSheet> {
late final TextEditingController nameController;
late final TextEditingController weightController;
late String breed;
late PetGender gender;
late DateTime birthday;
late String avatar;
static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他'];
static const avatars = [
petAvatar,
'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85',
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85',
];
@override
void initState() {
super.initState();
nameController = TextEditingController(text: widget.pet.name);
weightController = TextEditingController(text: '${widget.pet.weight}');
breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他';
gender = widget.pet.gender;
birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15);
avatar = widget.pet.avatarUrl;
}
@override
void dispose() {
nameController.dispose();
weightController.dispose();
super.dispose();
}
String dateText(DateTime value) {
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetHandle(),
Row(
children: [
Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 14),
Center(
child: InkWell(
onTap: () {
final index = avatars.indexOf(avatar);
setState(
() => avatar = avatars[(index + 1) % avatars.length],
);
},
borderRadius: BorderRadius.circular(48),
child: Stack(
children: [
RemoteImage(
url: avatar,
width: 96,
height: 96,
borderRadius: BorderRadius.circular(48),
),
const Positioned(
right: 0,
bottom: 0,
child: CircleAvatar(
radius: 15,
child: Icon(Icons.edit, size: 15),
),
),
],
),
),
),
const SizedBox(height: 18),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: '宠物昵称'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: breed,
decoration: const InputDecoration(labelText: '品种'),
items: breeds
.map(
(value) =>
DropdownMenuItem(value: value, child: Text(value)),
)
.toList(),
onChanged: (value) {
if (value != null) setState(() => breed = value);
},
),
const SizedBox(height: 12),
SegmentedButton<PetGender>(
segments: const [
ButtonSegment(
value: PetGender.male,
icon: Icon(Icons.male),
label: Text('男孩'),
),
ButtonSegment(
value: PetGender.female,
icon: Icon(Icons.female),
label: Text('女孩'),
),
],
selected: {gender},
onSelectionChanged: (value) =>
setState(() => gender = value.first),
),
const SizedBox(height: 12),
ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(18),
),
leading: const Icon(Icons.cake_outlined),
title: const Text('生日'),
subtitle: Text(dateText(birthday)),
trailing: const Icon(Icons.calendar_month_outlined),
onTap: () async {
final value = await showDatePicker(
context: context,
initialDate: birthday,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (value != null) setState(() => birthday = value);
},
),
const SizedBox(height: 12),
TextField(
controller: weightController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(labelText: '体重(kg'),
),
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () {
final name = nameController.text.trim();
final weight = double.tryParse(weightController.text);
if (name.isEmpty || weight == null || weight <= 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请填写有效的昵称和体重')));
return;
}
Navigator.pop(
context,
widget.pet.copyWith(
name: name,
breed: breed,
gender: gender,
birthday: dateText(birthday),
weight: weight,
avatarUrl: avatar,
),
);
},
icon: const Icon(Icons.save_outlined),
label: const Text('保存修改'),
),
],
),
),
);
}
}
class VaccineSheet extends StatefulWidget {
const VaccineSheet({required this.record, super.key});
final VaccineRecord record;
@override
State<VaccineSheet> createState() => _VaccineSheetState();
}
class _VaccineSheetState extends State<VaccineSheet> {
late List<VaccineItem> items;
late int totalDoses;
late final TextEditingController reminderController;
late final TextEditingController dateController;
int get completed =>
items.where((item) => item.status == VaccineStatus.completed).length;
@override
void initState() {
super.initState();
items = List<VaccineItem>.from(widget.record.items);
totalDoses = widget.record.totalDoses;
reminderController = TextEditingController(
text: widget.record.reminderVaccine,
);
dateController = TextEditingController(text: widget.record.reminderDate);
}
@override
void dispose() {
reminderController.dispose();
dateController.dispose();
super.dispose();
}
void changeTotal(int delta) {
final minimum = completed > items.length ? completed : items.length;
setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20));
}
@override
Widget build(BuildContext context) {
final progress = totalDoses == 0 ? 0.0 : completed / totalDoses;
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetHandle(),
Row(
children: [
const CircleAvatar(child: Icon(Icons.vaccines_outlined)),
const SizedBox(width: 10),
Text('疫苗接种管理', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 16),
SectionCard(
child: Column(
children: [
Row(
children: [
const Text('已接种 / 总规划'),
const Spacer(),
IconButton.filledTonal(
onPressed: () => changeTotal(-1),
icon: const Icon(Icons.remove),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
'$completed / $totalDoses',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
IconButton.filled(
onPressed: () => changeTotal(1),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(value: progress, minHeight: 9),
],
),
),
const SizedBox(height: 16),
Text('接种详情', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final done = item.status == VaccineStatus.completed;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: CheckboxListTile(
value: done,
title: Text(item.name),
subtitle: Text(
done ? '接种时间:${item.date}' : '计划接种:${item.date}',
),
secondary: Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
color: done ? AppColors.success : AppColors.muted,
),
onChanged: (_) {
final today = DateTime.now();
final date =
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
setState(() {
items[index] = item.copyWith(
status: done
? VaccineStatus.pending
: VaccineStatus.completed,
date: done ? '待定' : date,
);
});
},
),
);
}),
const SizedBox(height: 10),
TextField(
controller: reminderController,
decoration: const InputDecoration(labelText: '下一针提醒'),
),
const SizedBox(height: 12),
TextField(
controller: dateController,
decoration: const InputDecoration(labelText: '预计日期(YYYY-MM-DD'),
),
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () => Navigator.pop(
context,
VaccineRecord(
completedDoses: completed,
totalDoses: totalDoses,
items: items,
reminderVaccine: reminderController.text.trim(),
reminderDate: dateController.text.trim(),
),
),
icon: const Icon(Icons.save_outlined),
label: const Text('保存疫苗记录'),
),
],
),
),
);
}
}
class _SheetHandle extends StatelessWidget {
const _SheetHandle();
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 44,
height: 5,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(99),
),
),
);
} }
@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';
}
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -5,10 +5,13 @@ import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart'; import 'package:patbond_flutter/widgets/common.dart';
class ProfilePage extends StatelessWidget { class ProfilePage extends StatelessWidget {
const ProfilePage({required this.appState, super.key}); const ProfilePage({required this.appState, super.key, this.onLogout});
final AppState appState; final AppState appState;
/// 退线
final Future<void> Function()? onLogout;
static const menuItems = [ static const menuItems = [
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'), (Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'), (Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
@@ -82,10 +85,10 @@ class ProfilePage extends StatelessWidget {
const SizedBox(height: 5), const SizedBox(height: 5),
const Text( const Text(
'Patbond 社区创作达人', 'Patbond 社区创作达人',
style: TextStyle(color: Color(0xFFA5B4FC), fontSize: 12), style: TextStyle(color: AppColors.accent, fontSize: 12),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Divider(color: Color(0xFF334155)), const Divider(color: Colors.white24),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
@@ -93,7 +96,10 @@ class ProfilePage extends StatelessWidget {
const _ProfileStat(value: '24', label: '关注我'), const _ProfileStat(value: '24', label: '关注我'),
const _ProfileStat(value: '1.8k', label: '获赞'), const _ProfileStat(value: '1.8k', label: '获赞'),
_ProfileStat( _ProfileStat(
value: '${appState.posts.length}', // demo M5
// demo AppState.posts T3-17 退
// demo
value: '${initialPosts.length}',
label: '我的作品', label: '我的作品',
), ),
], ],
@@ -108,7 +114,7 @@ class ProfilePage extends StatelessWidget {
children: menuItems.map((item) { children: menuItems.map((item) {
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: const Color(0xFFEEF2FF), backgroundColor: AppColors.surfaceTint,
child: Icon(item.$1, color: AppColors.primary), child: Icon(item.$1, color: AppColors.primary),
), ),
title: Text( title: Text(
@@ -133,7 +139,7 @@ class ProfilePage extends StatelessWidget {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
TextButton( TextButton(
onPressed: () => showDemoMessage(context, '退出登录'), onPressed: onLogout ?? () => showDemoMessage(context, '退出登录'),
child: const Text('切换账号或退出登录'), child: const Text('切换账号或退出登录'),
), ),
], ],
@@ -162,7 +168,7 @@ class _ProfileStat extends StatelessWidget {
const SizedBox(height: 3), const SizedBox(height: 3),
Text( Text(
label, label,
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 11), style: const TextStyle(color: Colors.white70, fontSize: 11),
), ),
], ],
); );
-79
View File
@@ -134,85 +134,6 @@ class PetProfile {
}; };
} }
enum VaccineStatus { completed, pending }
class VaccineItem {
const VaccineItem({
required this.id,
required this.name,
required this.status,
required this.date,
});
final String id;
final String name;
final VaccineStatus status;
final String date;
VaccineItem copyWith({VaccineStatus? status, String? date}) {
return VaccineItem(
id: id,
name: name,
status: status ?? this.status,
date: date ?? this.date,
);
}
factory VaccineItem.fromJson(Map<String, dynamic> json) {
return VaccineItem(
id: json['id'] as String,
name: json['name'] as String,
status: json['status'] == 'completed'
? VaccineStatus.completed
: VaccineStatus.pending,
date: json['date'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'status': status.name,
'date': date,
};
}
class VaccineRecord {
const VaccineRecord({
required this.completedDoses,
required this.totalDoses,
required this.items,
required this.reminderVaccine,
required this.reminderDate,
});
final int completedDoses;
final int totalDoses;
final List<VaccineItem> items;
final String reminderVaccine;
final String reminderDate;
factory VaccineRecord.fromJson(Map<String, dynamic> json) {
return VaccineRecord(
completedDoses: json['completedDoses'] as int,
totalDoses: json['totalDoses'] as int,
items: (json['items'] as List)
.map((item) => VaccineItem.fromJson(item as Map<String, dynamic>))
.toList(),
reminderVaccine: json['reminderVaccine'] as String,
reminderDate: json['reminderDate'] as String,
);
}
Map<String, dynamic> toJson() => {
'completedDoses': completedDoses,
'totalDoses': totalDoses,
'items': items.map((item) => item.toJson()).toList(),
'reminderVaccine': reminderVaccine,
'reminderDate': reminderDate,
};
}
class CommentModel { class CommentModel {
const CommentModel({ const CommentModel({
required this.id, required this.id,
+7 -53
View File
@@ -7,13 +7,16 @@ import 'package:shared_preferences/shared_preferences.dart';
class AppState extends ChangeNotifier { class AppState extends ChangeNotifier {
static const _petKey = 'patbond_pet'; static const _petKey = 'patbond_pet';
static const _vaccinesKey = 'patbond_vaccines';
static const _postsKey = 'patbond_posts';
static const _locationWeatherKey = 'patbond_location_weather'; 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; PetProfile pet = initialPet;
VaccineRecord vaccines = initialVaccines;
List<PostModel> posts = List<PostModel>.from(initialPosts);
LocationWeather locationWeather = initialLocationWeather; LocationWeather locationWeather = initialLocationWeather;
bool isReady = false; bool isReady = false;
@@ -21,23 +24,11 @@ class AppState extends ChangeNotifier {
try { try {
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
final savedPet = preferences.getString(_petKey); final savedPet = preferences.getString(_petKey);
final savedVaccines = preferences.getString(_vaccinesKey);
final savedPosts = preferences.getString(_postsKey);
final savedLocationWeather = preferences.getString(_locationWeatherKey); final savedLocationWeather = preferences.getString(_locationWeatherKey);
if (savedPet != null) { if (savedPet != null) {
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>); pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
} }
if (savedVaccines != null) {
vaccines = VaccineRecord.fromJson(
jsonDecode(savedVaccines) as Map<String, dynamic>,
);
}
if (savedPosts != null) {
posts = (jsonDecode(savedPosts) as List)
.map((item) => PostModel.fromJson(item as Map<String, dynamic>))
.toList();
}
if (savedLocationWeather != null) { if (savedLocationWeather != null) {
locationWeather = LocationWeather.fromJson( locationWeather = LocationWeather.fromJson(
jsonDecode(savedLocationWeather) as Map<String, dynamic>, jsonDecode(savedLocationWeather) as Map<String, dynamic>,
@@ -46,8 +37,6 @@ class AppState extends ChangeNotifier {
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace'); debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
pet = initialPet; pet = initialPet;
vaccines = initialVaccines;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather; locationWeather = initialLocationWeather;
} finally { } finally {
isReady = true; isReady = true;
@@ -55,33 +44,6 @@ class AppState extends ChangeNotifier {
} }
} }
Future<void> updatePet(PetProfile value) async {
pet = value;
notifyListeners();
await _save(_petKey, value.toJson());
}
Future<void> updateVaccines(VaccineRecord value) async {
vaccines = value;
notifyListeners();
await _save(_vaccinesKey, value.toJson());
}
Future<void> updatePost(PostModel value) async {
final index = posts.indexWhere((post) => post.id == value.id);
if (index == -1) return;
posts[index] = value;
posts = List<PostModel>.from(posts);
notifyListeners();
await _savePosts();
}
Future<void> publishPost(PostModel value) async {
posts = [value, ...posts];
notifyListeners();
await _savePosts();
}
Future<void> updateLocationWeather(LocationWeather value) async { Future<void> updateLocationWeather(LocationWeather value) async {
locationWeather = value; locationWeather = value;
notifyListeners(); notifyListeners();
@@ -90,23 +52,15 @@ class AppState extends ChangeNotifier {
Future<void> resetDemoData() async { Future<void> resetDemoData() async {
pet = initialPet; pet = initialPet;
vaccines = initialVaccines;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather; locationWeather = initialLocationWeather;
notifyListeners(); notifyListeners();
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
await Future.wait([ await Future.wait([
preferences.remove(_petKey), preferences.remove(_petKey),
preferences.remove(_vaccinesKey),
preferences.remove(_postsKey),
preferences.remove(_locationWeatherKey), preferences.remove(_locationWeatherKey),
]); ]);
} }
Future<void> _savePosts() {
return _save(_postsKey, posts.map((post) => post.toJson()).toList());
}
Future<void> _save(String key, Object value) async { Future<void> _save(String key, Object value) async {
try { try {
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
+36 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/network/signed_network_image.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart';
class RemoteImage extends StatelessWidget { class RemoteImage extends StatelessWidget {
@@ -21,15 +22,17 @@ class RemoteImage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return ClipRRect(
borderRadius: borderRadius, borderRadius: borderRadius,
child: Image.network( // key T3-14 URL
url, // URL
child: Image(
image: SignedNetworkImage(url),
width: width, width: width,
height: height, height: height,
fit: fit, fit: fit,
loadingBuilder: (context, child, progress) { loadingBuilder: (context, child, progress) {
if (progress == null) return child; if (progress == null) return child;
return ColoredBox( return ColoredBox(
color: const Color(0xFFF1F5F9), color: AppColors.surfaceTint,
child: SizedBox( child: SizedBox(
width: width, width: width,
height: height, height: height,
@@ -41,7 +44,7 @@ class RemoteImage extends StatelessWidget {
}, },
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return ColoredBox( return ColoredBox(
color: const Color(0xFFF1F5F9), color: AppColors.surfaceTint,
child: SizedBox( child: SizedBox(
width: width, width: width,
height: height, height: height,
@@ -73,14 +76,41 @@ class SectionCard extends StatelessWidget {
} }
} }
/// [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 { class TagPill extends StatelessWidget {
const TagPill(this.label, {super.key, this.color = AppColors.primary}); const TagPill(
this.label, {
super.key,
this.color = AppColors.primary,
this.inkColor,
});
final String label; final String label;
/// 8%
final Color color; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final effectiveInk = inkColor ?? _defaultInkFor(color);
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withAlpha(20), color: color.withAlpha(20),
@@ -91,7 +121,7 @@ class TagPill extends StatelessWidget {
child: Text( child: Text(
label, label,
style: TextStyle( style: TextStyle(
color: color, color: effectiveInk,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h" #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) { 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 list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
@@ -5,8 +5,16 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import file_selector_macos
import flutter_image_compress_macos
import flutter_secure_storage_darwin
import package_info_plus
import shared_preferences_foundation import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 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")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }
+472 -1
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -33,6 +41,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -49,8 +81,24 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" 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 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: description:
name: fake_async name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
@@ -65,6 +113,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: file:
dependency: transitive dependency: transitive
description: description:
@@ -73,11 +129,104 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -86,6 +235,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -96,6 +301,128 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -128,6 +455,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -152,6 +487,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@@ -160,6 +535,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -200,6 +599,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -293,6 +716,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" 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: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -309,6 +740,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -333,6 +780,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -341,6 +804,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks: sdks:
dart: ">=3.12.2 <4.0.0" dart: ">=3.12.2 <4.0.0"
flutter: ">=3.44.0" flutter: ">=3.44.0"
+13
View File
@@ -35,10 +35,23 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
shared_preferences: ^2.5.4 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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 # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # 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');
});
});
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart';
void main() {
final uuidV7 = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
);
AnalyticsService buildService({
String Function()? getSessionId,
String? anonymousId,
}) {
var counter = 0;
return AnalyticsService(
apiBaseUrl: 'http://test',
getAccessToken: null,
getSessionId: getSessionId ?? () => 'session-${counter++}',
anonymousId: anonymousId,
);
}
group('AnalyticsService', () {
test('tracks event with required fields', () async {
final service = buildService(getSessionId: () => 'session-a');
await service.trackEvent('auth_login_succeeded', {
'identifierType': 'username',
'durationMs': 123,
});
final event = service.pendingEvents.single;
expect(event['eventName'], 'auth_login_succeeded');
expect(event['eventVersion'], 1);
expect(event['sessionId'], 'session-a');
expect(event['anonymousId'], isNotEmpty);
expect(event['clientTs'], isNotEmpty);
expect(event['appVersion'], isNotEmpty);
expect(event['osVersion'], isNotEmpty);
expect(event['props'], {'identifierType': 'username', 'durationMs': 123});
});
test('eventId 为 UUIDv7 且逐事件唯一', () async {
final service = buildService();
await service.trackEvent('auth_login_succeeded');
await service.trackEvent('auth_logout');
final ids = service.pendingEvents
.map((event) => event['eventId'] as String)
.toList();
expect(ids[0], matches(uuidV7));
expect(ids[1], matches(uuidV7));
expect(ids[0], isNot(ids[1]));
});
test('同一 tracker 下多事件 sessionId 相同,不再每事件生成', () async {
final service = buildService(getSessionId: () => 'tracker-session');
await service.trackEvent('auth_login_succeeded');
await service.trackEvent('page_viewed', {'pageName': 'home'});
await service.trackEvent('auth_logout');
final sessionIds = service.pendingEvents
.map((event) => event['sessionId'])
.toSet();
expect(sessionIds, {'tracker-session'});
});
test('appVersion 可注入更新(不再硬编码)', () async {
final service = buildService();
service.setAppVersion('2.3.4+56');
await service.trackEvent('auth_login_succeeded');
expect(service.pendingEvents.single['appVersion'], '2.3.4+56');
});
test('rejects events with forbidden field patterns', () async {
final service = buildService();
// Forbidden field triggers local rejection (no throw, silent drop).
await service.trackEvent('auth_login_succeeded', {
'userPassword': 'leak', // Forbidden pattern
});
expect(service.pendingEvents, isEmpty);
});
test('identify sets userId on subsequent events', () async {
final service = buildService();
service.identify('user-123');
await service.trackEvent('auth_login_succeeded');
expect(service.pendingEvents.single['userId'], 'user-123');
});
test('reset clears userId but keeps anonymousId', () async {
final service = buildService(anonymousId: 'anon-123');
service.identify('user-123');
service.reset();
await service.trackEvent('auth_logout');
final event = service.pendingEvents.single;
expect(event.containsKey('userId'), isFalse);
expect(event['anonymousId'], 'anon-123');
});
test('上传失败批次重回队列而非整批丢弃', () async {
// apiBaseUrl 20 flush
final service = AnalyticsService(
apiBaseUrl: 'http://127.0.0.1:1',
getAccessToken: null,
getSessionId: () => 's',
);
for (var i = 0; i < 20; i++) {
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
}
// 20 M0
expect(service.pendingEvents.length, 20);
});
test('持久化不可用时 restore 降级临时 anonymousId 不崩溃', () async {
// setMockInitialValuesSharedPreferences
// restore id
final service = buildService();
final ephemeralId = service.anonymousId;
await service.restore();
await service.trackEvent('auth_login_succeeded');
expect(service.anonymousId, ephemeralId);
expect(service.pendingEvents.single['anonymousId'], ephemeralId);
});
});
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/session_tracker.dart';
void main() {
final uuidV7 = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
);
group('SessionTracker', () {
test('冷启动生成 UUIDv7 格式的 sessionId', () {
final tracker = SessionTracker();
expect(tracker.sessionId, matches(uuidV7));
});
test('短后台(≤30 分钟)resume 后沿用原 sessionId', () {
var clock = DateTime.utc(2026, 9, 7, 10);
final tracker = SessionTracker(now: () => clock);
final original = tracker.sessionId;
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
clock = clock.add(const Duration(minutes: 30));
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(tracker.sessionId, original);
});
test('长后台(>30 分钟)resume 后生成新 sessionId', () {
var clock = DateTime.utc(2026, 9, 7, 10);
final tracker = SessionTracker(now: () => clock);
final original = tracker.sessionId;
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
clock = clock.add(const Duration(minutes: 31));
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(tracker.sessionId, isNot(original));
expect(tracker.sessionId, matches(uuidV7));
});
test('真实生命周期级联下退后台时刻不被 inactive 覆盖', () {
// paused hidden inactive resumed
// inactive 31 0
var clock = DateTime.utc(2026, 9, 7, 10);
final tracker = SessionTracker(now: () => clock);
final original = tracker.sessionId;
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
clock = clock.add(const Duration(minutes: 31));
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(tracker.sessionId, isNot(original));
});
test('连续多次短后台幂等,仅超阈值才换新', () {
var clock = DateTime.utc(2026, 9, 7, 10);
final tracker = SessionTracker(now: () => clock);
final original = tracker.sessionId;
for (var i = 0; i < 3; i++) {
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
clock = clock.add(const Duration(minutes: 10));
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(tracker.sessionId, original);
}
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
clock = clock.add(const Duration(minutes: 35));
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(tracker.sessionId, isNot(original));
});
test('前后台回调成对触发一次,生命周期级联不重复', () {
var leaveCount = 0;
var enterCount = 0;
final tracker = SessionTracker(
onLeaveForeground: () => leaveCount++,
onEnterForeground: () => enterCount++,
);
// 退inactive hidden paused leave
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
expect(leaveCount, 1);
expect(enterCount, 0);
// hidden inactive resumed enter
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(leaveCount, 1);
expect(enterCount, 1);
// resumed resumed enter
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(enterCount, 1);
});
});
}
@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/signed_network_image.dart';
void main() {
group('presignedImageCacheKey', () {
test('剥离全部 X-Amz-* 签名参数(大小写不敏感)', () {
const url =
'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg'
'?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=cred'
'&X-Amz-Date=20260909T000000Z&X-Amz-Expires=3600'
'&X-Amz-SignedHeaders=host&x-amz-signature=deadbeef';
expect(
presignedImageCacheKey(url),
'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg',
);
});
test('保留非签名 query 参数', () {
const url = 'https://cdn.example.com/p.jpg?w=300&X-Amz-Signature=sig';
expect(
presignedImageCacheKey(url),
'https://cdn.example.com/p.jpg?w=300',
);
});
test('无签名参数原样返回', () {
expect(
presignedImageCacheKey('https://cdn.example.com/p.jpg?w=300&q=80'),
'https://cdn.example.com/p.jpg?w=300&q=80',
);
expect(
presignedImageCacheKey('https://cdn.example.com/p.jpg'),
'https://cdn.example.com/p.jpg',
);
});
test('非法 URL 不炸,退回原串', () {
expect(presignedImageCacheKey('::not a url::'), '::not a url::');
});
});
group('SignedNetworkImage', () {
test('同对象不同签名 → 判等(命中同一 ImageCache 条目)', () {
final first = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig1&X-Amz-Date=d1',
);
final second = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig2&X-Amz-Date=d2',
);
expect(first, second);
expect(first.hashCode, second.hashCode);
});
test('不同对象 → 不判等', () {
final first = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig',
);
final second = SignedNetworkImage(
'http://127.0.0.1:9000/m/b.jpg?X-Amz-Signature=sig',
);
expect(first, isNot(second));
});
test('scale 参与判等', () {
final first = SignedNetworkImage('http://h/m/a.jpg');
final second = SignedNetworkImage('http://h/m/a.jpg', scale: 2);
expect(first, isNot(second));
});
});
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import '../../helpers/auth_test_helpers.dart';
void main() {
late SessionManager session;
late Dio dio;
late FakeHttpAdapter adapter;
late TokenRefresher refresher;
Future<void> setUpWith(
Future<ResponseBody> Function(RequestOptions) handler, {
bool seedTokens = true,
}) async {
session = SessionManager(store: InMemoryTokenStore());
if (seedTokens) {
await session.updateTokens(
sampleTokens(access: 'old-access', refresh: 'old-refresh'),
);
}
dio = Dio(BaseOptions(validateStatus: (_) => true));
adapter = FakeHttpAdapter(handler);
dio.httpClientAdapter = adapter;
refresher = TokenRefresher(dio: dio, session: session);
}
test('并发触发只发出一次刷新请求(单飞),并轮换 token', () async {
await setUpWith((options) async {
await Future<void>.delayed(const Duration(milliseconds: 20));
return jsonResponse(
200,
okEnvelope(tokenDataJson(access: 'new-access', refresh: 'new-refresh')),
);
});
await Future.wait([refresher.refresh(), refresher.refresh()]);
expect(adapter.requests, hasLength(1));
expect(adapter.requests.single.path, '/api/v1/auth/refresh');
expect(adapter.requests.single.data, {'refreshToken': 'old-refresh'});
expect(session.accessToken, 'new-access');
expect(session.refreshToken, 'new-refresh');
});
test('40102 清除会话并抛 SessionExpiredException', () async {
await setUpWith(
(options) async =>
jsonResponse(401, errorEnvelope(40102, 'refresh token 已失效')),
);
await expectLater(
refresher.refresh(),
throwsA(isA<SessionExpiredException>()),
);
expect(session.accessToken, isNull);
expect(session.refreshToken, isNull);
expect(session.status, AuthStatus.unauthenticated);
});
test('网络失败不清除本地 token,抛 ApiNetworkException', () async {
await setUpWith((options) async {
throw DioException(
requestOptions: options,
type: DioExceptionType.connectionTimeout,
);
});
await expectLater(refresher.refresh(), throwsA(isA<ApiNetworkException>()));
expect(session.refreshToken, 'old-refresh');
expect(session.accessToken, 'old-access');
});
test('上一次刷新结束后单飞复位,可再次刷新', () async {
var calls = 0;
await setUpWith((options) async {
calls += 1;
if (calls == 1) {
throw DioException(
requestOptions: options,
type: DioExceptionType.connectionError,
);
}
return jsonResponse(
200,
okEnvelope(tokenDataJson(access: 'a2', refresh: 'r2')),
);
});
await expectLater(refresher.refresh(), throwsA(isA<ApiNetworkException>()));
await refresher.refresh();
expect(calls, 2);
expect(session.refreshToken, 'r2');
});
test('本地无 refresh token 时直接判会话失效', () async {
await setUpWith(
(options) async => jsonResponse(200, okEnvelope(tokenDataJson())),
seedTokens: false,
);
await expectLater(
refresher.refresh(),
throwsA(isA<SessionExpiredException>()),
);
expect(adapter.requests, isEmpty);
});
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
Widget wrap(Widget child) {
return MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: Center(child: child)),
);
}
void main() {
testWidgets('errorText 展示在输入框下方', (tester) async {
await tester.pumpWidget(
wrap(const AppTextField(label: '密码', errorText: '请输入密码')),
);
expect(find.text('请输入密码'), findsOneWidget);
});
testWidgets('errorText 为 null 时不显示错误', (tester) async {
await tester.pumpWidget(wrap(const AppTextField(label: '密码')));
expect(find.text('请输入密码'), findsNothing);
});
testWidgets('密码模式默认遮蔽,点击切换按钮后可见', (tester) async {
await tester.pumpWidget(
wrap(const AppTextField(label: '密码', obscurable: true)),
);
expect(find.byIcon(Icons.visibility_off), findsOneWidget);
var editable = tester.widget<EditableText>(find.byType(EditableText));
expect(editable.obscureText, isTrue);
await tester.tap(find.byIcon(Icons.visibility_off));
await tester.pump();
expect(find.byIcon(Icons.visibility), findsOneWidget);
editable = tester.widget<EditableText>(find.byType(EditableText));
expect(editable.obscureText, isFalse);
});
}

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