8 Commits

Author SHA1 Message Date
lixi 694543647f 新增:首页问候语真实化 + 刻意保留的 demo 占位显式登记(T3.5-10,ADR-022)
CI / flutter-gates (push) Failing after 1s
- 「下午好,豆豆」改真实展示名(`nickname ?? username`,与资料页共用同一个
  ProfileController:一次 /me 供两个消费点,改昵称后两处一起变)。资料未到手
  时退化为不称名的「下午好 👋」,不编造假名。
- 按 ADR-022 决策 D3.5-1 钉死范围,**其余首页 demo 一律不动**,并在代码里逐项
  标注为「刻意保留的 demo 占位」:天气/位置(需接外部服务)、圈子=话题
  (ADR-018 剪出)、促销卡(M5 服务域)、搜索与本地服务段。
  widget 测试同时钉住「保留项仍在」,避免后续以「顺手清理」越出拍板范围。
- 新增 compose 真链路桌面实测脚本 `integration_test/profile_avatar_live_test.dart`
  (默认跳过,`PATBOND_PROFILE_LIVE=1 ... -d linux` 开启):UI 注册 → 登录 →
  资料真实化 → 设昵称 → 传用户头像 → Feed 作者名同步 → 宠物头像,逐步截图。
  只有选图与压缩两层是桌面替身,createUpload / 预签名 PUT / confirm / PATCH
  全为生产实现。两处实测坑写进注释:Feed 作者名有服务端 60s 缓存滞后;
  1×1 的极小图能上传能下载但 Flutter 解码器拒绝,会被误判成「没传上」。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:48 +08:00
lixi eff3526840 新增:宠物头像接线——铅笔角标接上传、列表与详情展示真实头像(T3.5-09)
- `Pet.avatarUrl` 入模型;列表卡与详情头像改用真实预签名 URL(无图仍回退
  爪印占位——服务端对非 ready 的 asset 直接给 null,客户端只有一种占位)。
- 详情页铅笔角标自此有功能:接 `MediaUploader`(purpose=pet_avatar);已有
  头像时先给「更换 / 移除」二选一,移除即三态显式 null。
- 权限按 **WRITE 档**呈现(ADR-022 D3.5-3:owner + caregiver 可改头像,
  viewer 无入口),与资料编辑的 MANAGE 档刻意不同。因此纯头像 PATCH 只发
  `version` + `avatarAssetId`,**不夹带任何资料字段**——夹带会让服务端按更严
  的一半定档,caregiver 立刻 403。
- `UpdatePetRequest.avatarAssetId` 是该 schema 唯一的三态字段,其余字段保持
  M2 两态语义(它们的 CHECK 约束本就不允许空值,「清空」无意义)。
- 40902 冲突后重取档案让用户自行决定重来,不静默重放(头像是用户可见的
  覆盖操作,不该自动生效两次)。

测试 588 → 597(+9)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:25 +08:00
lixi a4a97c03c2 新增:资料页真实化 + 编辑页——/me 读写、PATCH 三态、获赞与作品统计(T3.5-08)
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码
demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。

- `PatchField<T>` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落
  显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」
  也编码成 null,用户只改昵称就会连头像一起被服务端清掉。
- `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username`
  **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值
  预填,避免把展示约定固化成真实昵称。
- `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级
  这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。
- 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口,
  未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。
- 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐;
  纯空白是校验失败而非隐式清空。
- `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet`
  (单图、预览确认后才交付 ready assetId,孤儿防护不变)。
- 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository`
  此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被
  触发;本单是第一个真实消费者,桌面实测即暴露。

测试 526 → 588(+62)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:00 +08:00
lixi 7d5c84d06d 修复:花费卡展示实际月份 + 四张数据卡补可点提示(M3.5-03)
CI / flutter-gates (push) Successful in 3m24s
卡片标签硬编码「本月花费」,而服务端 summary.monthlyExpense 本就返回 month
(ISO year-month,按 tz 归月)。用户看不到实际月份,所以无法自证「我这条记录
到底落在哪个月」,把正确的 ¥0 当成统计故障。

已核实不改后端聚合:用户记录落在 2026-04-09、当天是 2026-09-10,
「本月花费 ¥0」是正确行为。本单只让客户端把口径亮出来。

- health_record_display 新增纯函数 monthlyExpenseCardLabel(month, {now}):
  同年「9 月花费」(窄卡一行放得下,故不取「2026-09 花费」);跨年(服务端
  归月年份 ≠ 设备当前年份)「2026/12 花费」补年份消歧;串非法退回
  「本月花费」不崩不显示脏值。
- 可点提示:_SummaryCard 在 onTap 非空时右上角补 chevron_right。四张卡本都
  可点进明细页却无任何视觉提示(用户反馈不知道能点),提示形态沿用项目既有
  可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 chevron_right,不自创。
- 整卡包 MergeSemantics:读屏一次读全「¥128.50,9 月花费,按钮」而非两段
  孤立文字。没有用 excludeSemantics——那会连带丢掉 InkWell 的可激活性。

既有测试口径调整:pet_detail_page_test 两处断言改为实际月份,且样本
monthlyExpense.month 改用当月串使断言不随年份漂移(跨年格式由纯函数单测覆盖)。

新增 integration_test/client_ux_live_test.dart(环境变量门控,默认 skip,
沿用 M3 既有 live 测试形态):compose 六容器 + 真实 App 走完三项修复——
日期选择器全中文/品牌配色/手输录入/一键今天,以及花费卡实际月份 + chevron。
本机是 Wayland 会话、X11 import -window root 取不到根窗口,改为把整棵 App 包
一层 RepaintBoundary 后 toImage() 直出真实渲染像素落 build/ux-live/。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 18:00:21 +08:00
lixi 294fc4a781 修复:日期录入收口共享层——「今天」快捷键 + 品牌配色 datePickerTheme(M3.5-02)
用户实测已因日期选择器误录:Flutter 原生日历只给年份网格、月份必须靠 < >
逐月点,从 9 月回 4 月要点 5 次,他把当月(2026-09)的就医记录记成了
2026-04-09,进而误判「本月花费 ¥0」是聚合坏了。

新增 lib/core/widgets/app_date_picker.dart,全仓 7 处裸 showDatePicker 收口
(改造后 lib/ 下 showDatePicker 只出现在该文件内部一次):

- pickAppDate(...):calendar 首屏 + 保留头部铅笔切手输;initialDate 自动夹进
  [firstDate, lastDate] 防原生越界断言(调用方常传「当前值 ?? 今天」,而
  「到期日期」的 firstDate 就是今天,历史值可能已越界);返回值统一抹时分秒;
  中文文案一律交给 zh-CN 本地化,不硬编码,避免两处文案漂移。
- AppDateFieldTrailing(...):7 处日期行统一「今天 + 日历图标」。今天越界自动
  隐藏按钮、提交中禁用、触控 44×44、primaryStrong 白底 4.49:1。

入口模式取舍:不用 calendarOnly——它恰好会砍掉手输按钮,把「录一个已知日期」
这条唯一快路堵死;也不用 input 首屏——「记今天」这类高频场景敲 8 个数字更慢。
两条路都留着最省事。

「今天」为何放表单行而非弹窗内:原生 showDatePicker 无法注入自定义动作
(builder 只能包裹整个 Dialog,拿不到内部选中态;塞进 Column 还会因 Dialog
在无界高度下贪心布局而溢出)。放表单行反而更快——一键落值连弹窗都不用开,
把容易走错的月份导航整段绕开,且一次实现 7 处形态完全一致。

各调用点原有的 firstDate/lastDate 业务约束原样传入、一字未改(健康事件
不许未来、到期日不许补记过去、疫苗 allowFuture 双态、生日不许未来),
并由 widget 测试直接断言 DatePickerDialog.firstDate/lastDate 防后续悄悄放宽。

顺带修配色:此前从未定制 datePickerTheme,选中日直接吃 ColorScheme.fromSeed
由珊瑚橙派生的暗红棕,与品牌脱节。新增 _datePickerTheme 只复用 05 号规范
(iteration-2/05、iteration-3/05)已审计的色对,不新造色值:头部
surfaceTint + primaryDark 7.98:1(选中 chip 同款)、选中日/年 primaryStrong
实底白字 4.49:1、今日 primaryStrong 1.5px 描边、星期表头 inkSoft 6.59:1、
越界日 muted(DEBT-2 允许的禁用态用途)。headerHeadlineStyle 取 22px
(默认 32):中文「9月10日周四」在横屏侧栏头部 26px 起就折行。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 17:59:52 +08:00
lixi 6038901099 修复:Material 内置组件中文化——挂 zh-CN 三件套 delegate(M3.5-01)
用户 Linux 桌面实测反馈「日期选择器全英文」。根因是 MaterialApp 从一迭代
建起就没配 localizationsDelegates/supportedLocales/locale,pubspec 也没有
flutter_localizations;Flutter 在缺 delegate 时静默回退内置的
DefaultMaterialLocalizations(只有英文),于是业务自绘文案全中文、Material
内置组件全英文,同一弹窗里中英混排:

  Select date → 选择日期
  OK / Cancel → 确定 / 取消
  Enter Date → 输入日期
  Switch to input → 切换到输入模式
  Invalid format. / Out of range. → 格式无效。/ 超出范围。
  mm/dd/yyyy → yyyy/mm/dd(zh 顺序年在前)
  September 2026 → 2026年9月

处置:

- pubspec 加 flutter_localizations(sdk)与 intl(日期符号底座,显式直接
  依赖以锁定与 SDK 一致的版本)。
- 新增 lib/app/app_localization.dart 把 Global{Material,Cupertino,Widgets}
  三件套 delegate、supportedLocales、locale 收在一处常量:widget 测试若只
  pumpWidget(MaterialApp(home: ...)) 而不挂 delegate,测到的「中文」是假的,
  测试直接引用同一份常量,生产与测试不会各配一套而漂移。
- 单语言 zh-CN,不列 en:避免设备语言为英文时回退英文,再造混排。
- pets 域三份 widget 测试的 MaterialApp 同步挂上 delegate,
  tap(find.text('OK')) 相应改 '确定'——让测试与生产一致,而不是在英文兜底下测。

新增 test/app/app_localization_test.dart 守住这一行不被回删:断言根
MaterialApp 实际挂上三件套 + locale zh-CN,并从运行期 MaterialLocalizations
取回中文文案。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 17:59:21 +08:00
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
71 changed files with 9160 additions and 234 deletions
+243
View File
@@ -0,0 +1,243 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show ImageByteFormat;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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/theme/app_theme.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.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);
}
/// M3.5-01/02/03 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再:
/// PATBOND_UX_LIVE=1 flutter test integration_test/client_ux_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面 GTK 渲染管线 + 真实 HTTP)走用户实测那条路:
/// 注册 → UI 登录 → 档案页建档 → 打开生日选择器(校验全中文 + 品牌配色 +
/// 手输可用 + 一键今天)→ 保存进详情页(校验花费卡展示实际月份 + chevron)。
/// 逐步截图落到 `build/ux-live/`:本机是 Wayland 会话,X11 的
/// `import -window root` 取不到根窗口,改为把整棵 App 包一层
/// [RepaintBoundary] 后 `toImage()` 直出真实渲染像素(含 Overlay 里的弹窗)。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_UX_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
const petBase = 'http://127.0.0.1:8083';
final shotDir = Directory('build/ux-live');
const rootKey = ValueKey<String>('ux-live-root');
Future<void> shot(WidgetTester tester, String name) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(rootKey),
);
await tester.runAsync(() async {
if (!shotDir.existsSync()) shotDir.createSync(recursive: true);
final image = await boundary.toImage(pixelRatio: 1.5);
final bytes = await image.toByteData(format: ImageByteFormat.png);
final path = '${shotDir.path}/$name.png';
File(path).writeAsBytesSync(bytes!.buffer.asUint8List());
debugPrint('[shot] $name$path (${bytes.lengthInBytes} bytes)');
});
}
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 25),
}) 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> pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 25),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isEmpty) return;
}
fail('等待消失超时:$finder');
}
/// 直连服务端下单个 JSON 请求(种子数据走真实 HTTP,不绕 DB)。
Future<Map<String, dynamic>> postJson(
String url,
Map<String, Object?> body, {
String? bearer,
}) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
request.headers.contentType = ContentType.json;
if (bearer != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer');
}
request.add(utf8.encode(jsonEncode(body)));
final response = await request.close();
final text = await response.transform(utf8.decoder).join();
client.close();
expect(response.statusCode, anyOf(200, 201), reason: 'POST $url 失败:$text');
final envelope = jsonDecode(text) as Map<String, dynamic>;
expect(envelope['code'], 0, reason: 'POST $url 业务码非 0$text');
return envelope['data'] as Map<String, dynamic>;
}
testWidgets('桌面真链路:日期选择器中文/品牌色/手输/今天 + 花费卡实际月份', (tester) async {
// ---- 注册一次性账号 + 种子数据(宠物 + 当月一笔就医支出)----
final seed = DateTime.now().millisecondsSinceEpoch;
final username = 'uxlive$seed';
final password = 'Live1234!$seed';
await postJson('$authBase/api/v1/auth/register', {
'username': username,
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
'password': password,
});
final tokens = await postJson('$authBase/api/v1/auth/login', {
'username': username,
'password': password,
});
final accessToken = tokens['accessToken'] as String;
final pet = await postJson('$petBase/api/v1/pets', {
'name': '实测豆豆',
'species': 'dog',
'sex': 'male',
'customBreedName': '中华田园犬',
}, bearer: accessToken);
final petId = pet['id'] as String;
// 当月一笔 128.50 元就医支出:花费卡应显示「N 月花费 ¥128.50」——
// 用户那次误录(记到 4 月)看到 ¥0 正是因为记录不在当月窗口内。
await postJson('$petBase/api/v1/pets/$petId/health-events', {
'eventType': 'medical',
'occurredAt': DateTime.now().toUtc().toIso8601String(),
'title': '皮肤检查',
'amountCents': 12850,
}, bearer: accessToken);
// ---- 启动真实 App ----
await tester.pumpWidget(
RepaintBoundary(
key: rootKey,
child: 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('登录'));
// ---- 档案 Tab → 建档表单 ----
await pumpUntil(tester, find.text('档案'));
await tester.tap(find.text('档案'));
// 列表已有种子宠物 → 走虚线「+ 添加宠物」卡(空态 CTA 文案不同)。
await pumpUntil(tester, find.textContaining('添加宠物'));
await tester.tap(find.textContaining('添加宠物').first);
await pumpUntil(tester, find.byType(PetFormPage));
await shot(tester, '01-pet-form');
// ---- 生日日期选择器:全中文 + 品牌配色 ----
await tester.tap(find.widgetWithText(ListTile, '生日(可选)'));
await pumpUntil(tester, find.byType(DatePickerDialog));
await tester.pump(const Duration(milliseconds: 600));
await shot(tester, '02-date-picker-zh');
expect(find.text('选择日期'), findsOneWidget, reason: '标题非中文');
expect(find.text('确定'), findsOneWidget);
expect(find.text('取消'), findsOneWidget);
expect(find.text('Select date'), findsNothing);
expect(find.text('OK'), findsNothing);
final inkColors = tester
.widgetList<Ink>(find.byType(Ink))
.map((ink) => ink.decoration)
.whereType<ShapeDecoration>()
.map((d) => d.color)
.toList();
expect(
inkColors,
contains(AppColors.primaryStrong),
reason: '选中日仍是 fromSeed 派生的暗红棕,未取品牌 primaryStrong',
);
// ---- 手输模式:中文标签 + 敲入已知日期 ----
await tester.tap(find.byIcon(Icons.edit_outlined));
await pumpUntil(tester, find.text('输入日期'));
await shot(tester, '03-date-input-zh');
final field = find.descendant(
of: find.byType(DatePickerDialog),
matching: find.byType(TextField),
);
await tester.enterText(field, '2024/03/15');
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('确定'));
await pumpUntil(tester, find.text('2024-03-15'));
await shot(tester, '04-date-typed');
// ---- 「今天」快捷键:一键落今天,不开弹窗 ----
final todayText =
'${DateTime.now().year}-'
'${DateTime.now().month.toString().padLeft(2, '0')}-'
'${DateTime.now().day.toString().padLeft(2, '0')}';
await tester.tap(find.widgetWithText(TextButton, '今天'));
await pumpUntil(tester, find.text(todayText));
expect(find.byType(DatePickerDialog), findsNothing, reason: '「今天」不该开弹窗');
await shot(tester, '05-today-shortcut');
// ---- 退出建档表单(本单不验建档链路,宠物与支出已由 API 种下)----
await tester.tap(find.byIcon(Icons.arrow_back));
await pumpUntilGone(tester, find.byType(PetFormPage));
// ---- 详情页:花费卡展示实际月份 + chevron 可点提示 ----
await pumpUntil(tester, find.text('实测豆豆'));
await shot(tester, '06-pets-list');
await tester.tap(find.text('实测豆豆'));
await pumpUntil(tester, find.byType(PetDetailPage));
final monthLabel = '${DateTime.now().month} 月花费';
await pumpUntil(tester, find.text(monthLabel));
await tester.pump(const Duration(milliseconds: 600));
await shot(tester, '07-pet-detail-expense-card');
expect(find.text('本月花费'), findsNothing, reason: '仍是硬编码「本月花费」');
// 当月真有支出 → 卡片给出金额(月份口径自证:记录落在当月才计入)。
expect(find.text('¥128.50'), findsOneWidget);
expect(
find.ancestor(
of: find.text(monthLabel),
matching: find.widgetWithIcon(Card, Icons.chevron_right),
),
findsOneWidget,
reason: '花费卡缺 chevron 可点提示',
);
}, skip: !enabled);
}
@@ -0,0 +1,471 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' show ImageByteFormat;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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/pet_avatar.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';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.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);
}
/// 固定测试图:16×16 棋盘 PNG(87 字节)。
///
/// **刻意不用 1×1 的极小图**1×1 的 PNG/JPEG 服务端照收、curl 也能原样取回,
/// 但走 `NetworkImage` 的解码路径会抛「Codec failed to produce an image」,
/// UI 于是落到 `RemoteImage` 的兜底占位——本机实测踩过这一格,很容易被误判
/// 成「头像根本没传上去」。用一张最小的**多像素**图即可走通到真实像素。
final Uint8List _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAHklEQVR42mOo0RV6Fc'
'FDPMlAkmogyTBqw6gNQ8YGAKJxBJBXv4+TAAAAAElFTkSuQmCC',
);
/// 桌面替身:选图直接给上面那张 JPEGLinux 无 image_picker 原生实现)。
class _DesktopPicker implements MediaImagePicker {
int calls = 0;
@override
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
calls += 1;
return [PickedMediaImage(bytes: _pngBytes, name: 'avatar.png')];
}
}
/// 桌面替身:压缩原样透传(Linux 无 flutter_image_compress 原生实现)。
class _PassthroughCompressor implements MediaImageCompressor {
const _PassthroughCompressor();
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
}
/// M3.5-08/09/10 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再:
/// PATBOND_PROFILE_LIVE=1 \
/// flutter test integration_test/profile_avatar_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面 GTK 渲染 + 真实 HTTP + 真实 MinIO)走一遍:
/// UI 注册 → 首页问候语显示用户名 → 资料页显示真实用户名与零值统计 →
/// 设昵称 → 首页问候语与资料页同步变昵称 → Feed 作者名同步(验证服务端
/// `/internal/users/profiles` 的 SQL 回退链)→ 传用户头像 → 宠物详情传头像。
///
/// 只有**选图与压缩两层**是桌面替身;createUpload / 预签名 PUT 直传 /
/// confirm / PATCH 全为生产实现。逐步截图落到 `build/profile-live/`。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_PROFILE_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
const petBase = 'http://127.0.0.1:8083';
const communityBase = 'http://127.0.0.1:8084';
final shotDir = Directory('build/profile-live');
const rootKey = ValueKey<String>('profile-live-root');
Future<void> shot(WidgetTester tester, String name) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(rootKey),
);
await tester.runAsync(() async {
if (!shotDir.existsSync()) shotDir.createSync(recursive: true);
final image = await boundary.toImage(pixelRatio: 1.5);
final bytes = await image.toByteData(format: ImageByteFormat.png);
File(
'${shotDir.path}/$name.png',
).writeAsBytesSync(bytes!.buffer.asUint8List());
debugPrint('[shot] $name (${bytes.lengthInBytes} bytes)');
});
}
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> pumpUntilGone(
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().isEmpty) return;
}
fail('等待消失超时:$finder');
}
/// 让出**真实时间**`pump(Duration)` 只推进一帧(把动画时钟往前拨,
/// 并不真的等 5 秒),等网络/等服务端缓存过期必须走 [WidgetTester.runAsync]。
Future<void> settleReal(
WidgetTester tester, {
Duration wait = const Duration(seconds: 2),
}) async {
await tester.runAsync(() => Future<void>.delayed(wait));
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
/// 在指定滚动容器里向下滚到目标可见(资料页/列表页的底部入口都需要)。
Future<void> scrollTo(
WidgetTester tester,
Finder list,
Finder target, {
int maxDrags = 10,
}) async {
for (var i = 0; i < maxDrags && target.evaluate().isEmpty; i++) {
await tester.drag(list, const Offset(0, -300));
await tester.pump(const Duration(milliseconds: 300));
}
}
Future<Map<String, dynamic>> postJson(
String url,
Map<String, Object?> body, {
String? bearer,
}) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
request.headers.contentType = ContentType.json;
request.headers.set('Idempotency-Key', DateTime.now().toIso8601String());
if (bearer != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer');
}
request.add(utf8.encode(jsonEncode(body)));
final response = await request.close();
final text = await response.transform(utf8.decoder).join();
client.close();
expect(response.statusCode, anyOf(200, 201), reason: 'POST $url$text');
final envelope = jsonDecode(text) as Map<String, dynamic>;
expect(envelope['code'], 0, reason: 'POST $url 业务码非 0$text');
return envelope['data'] as Map<String, dynamic>;
}
String greetingPrefix() {
final hour = DateTime.now().hour;
if (hour < 6) return '夜深了';
if (hour < 11) return '早上好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
return '晚上好';
}
testWidgets('桌面真链路:注册 → 资料真实化 → 设昵称 → 传头像 → Feed 作者名 → 宠物头像', (tester) async {
final picker = _DesktopPicker();
final seed = DateTime.now().millisecondsSinceEpoch;
final username = 'plive$seed';
final password = 'Live1234!$seed';
final phone = '+86137${(seed % 100000000).toString().padLeft(8, '0')}';
// 昵称与帖文都带 seed:Feed 是全局的,同名会让断言认错卡片。
final nickname = '实测柴${seed % 1000000}';
final postContent = '资料页实测帖 $seed';
await tester.pumpWidget(
RepaintBoundary(
key: rootKey,
child: App(
sessionManager: SessionManager(store: _InMemoryTokenStore()),
avatarUploaderFactory: (repository, purpose) => MediaUploader(
repository: repository,
purpose: purpose,
maxImages: 1,
maxConcurrentUploads: 1,
picker: picker,
compressor: const _PassthroughCompressor(),
),
),
),
);
// ---- 步骤 1:UI 注册(注册面不收昵称,ADR-022 决策 D3.5-5----
await pumpUntil(tester, find.text('立即注册'));
await tester.tap(find.text('立即注册'));
await pumpUntil(tester, find.text('创建账号'));
// live binding 下首个 enterText 常被丢(焦点还没建立),故每格「点入 →
// 输入 → 验值 → 必要时重输」,本机实测踩过这一格。
for (final entry in [
(0, username),
(1, phone.substring(3)),
(2, password),
(3, password),
]) {
final field = find.byType(TextField).at(entry.$1);
for (var attempt = 0; attempt < 3; attempt++) {
await tester.tap(field);
await tester.pump(const Duration(milliseconds: 200));
await tester.enterText(field, entry.$2);
await tester.pump(const Duration(milliseconds: 300));
if (tester.widget<TextField>(field).controller?.text == entry.$2) break;
}
expect(
tester.widget<TextField>(field).controller?.text,
entry.$2,
reason: '注册表单第 ${entry.$1} 格未收到输入',
);
}
await shot(tester, '01-register');
await tester.tap(find.widgetWithText(FilledButton, '注册'));
// ---- 步骤 2:主壳挂载 → 等 /me 到手(资料页与首页共用这一次请求)----
await pumpUntil(tester, find.text('我的'));
await pumpUntil(tester, find.text('${greetingPrefix()}$username 👋'));
await shot(tester, '02-home-greeting-username');
// 借真实会话种一篇帖与一只宠物(UI 发帖/建档已有各自实测覆盖)。
final tokens = await tester.runAsync(
() => postJson('$authBase/api/v1/auth/login', {
'username': username,
'password': password,
}),
);
final accessToken = tokens!['accessToken'] as String;
await tester.runAsync(
() => postJson('$communityBase/api/v1/posts', {
'category': 'general',
'content': postContent,
'status': 'published',
}, bearer: accessToken),
);
final pet = await tester.runAsync(
() => postJson('$petBase/api/v1/pets', {
'name': '实测豆豆',
'species': 'dog',
'sex': 'male',
'customBreedName': '中华田园犬',
}, bearer: accessToken),
);
// ---- 步骤 2.5:登出 → UI 登录 ----
// 覆盖登录链路,同时让三个控制器重新预取:登出即 reset 是「跨账号不
// 泄漏」的既有纪律(app.dart),重登后各 Tab 从 initial 重新拉取,
// 于是上面用 API 种下的帖与宠物才会进入内存副本。
await tester.tap(find.text('我的'));
await pumpUntil(tester, find.text('@$username'));
final profileList = find
.ancestor(of: find.text('@$username'), matching: find.byType(ListView))
.first;
await scrollTo(tester, profileList, find.text('切换账号或退出登录'));
await pumpUntil(tester, find.text('切换账号或退出登录'));
await tester.tap(find.text('切换账号或退出登录'));
await pumpUntil(tester, find.text('立即注册'));
for (final entry in [(0, username), (1, password)]) {
final field = find.byType(TextField).at(entry.$1);
for (var attempt = 0; attempt < 3; attempt++) {
await tester.tap(field);
await tester.pump(const Duration(milliseconds: 200));
await tester.enterText(field, entry.$2);
await tester.pump(const Duration(milliseconds: 300));
if (tester.widget<TextField>(field).controller?.text == entry.$2) break;
}
}
await tester.tap(find.widgetWithText(FilledButton, '登录'));
await pumpUntil(tester, find.text('我的'));
await pumpUntil(tester, find.text('${greetingPrefix()}$username 👋'));
// ---- 步骤 3:资料页展示真实用户名 + 零值统计(不是 demo 的 24/1.8k/2----
await tester.tap(find.text('我的'));
await pumpUntil(tester, find.text('@$username'));
await shot(tester, '03-profile-real-username');
expect(find.text('萌宠新手(豆豆家长)'), findsNothing);
expect(find.text('1.8k'), findsNothing);
expect(find.text('获赞'), findsOneWidget);
// 统计来自服务端:刚发了 1 帖、没人赞 → 作品 1 / 获赞 0。
expect(
find.descendant(
of: find
.ancestor(of: find.text('我的作品'), matching: find.byType(Column))
.first,
matching: find.text('1'),
),
findsOneWidget,
reason: '作品数应为服务端聚合的 1',
);
// ---- 步骤 4:设昵称 ----
await tester.tap(find.widgetWithText(OutlinedButton, '编辑资料'));
await pumpUntil(tester, find.byType(ProfileEditPage));
expect(
find.text('未设置,当前展示为用户名「$username'),
findsOneWidget,
reason: '编辑页不该把 username 预填进昵称框',
);
await shot(tester, '04-profile-edit-empty');
await tester.enterText(find.byType(TextField).first, nickname);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.widgetWithText(FilledButton, '保存'));
await pumpUntilGone(tester, find.byType(ProfileEditPage));
await pumpUntil(tester, find.text(nickname));
await shot(tester, '05-profile-nickname-set');
// ---- 步骤 5:上传用户头像(真实两步上传 + PATCH)----
await tester.tap(find.widgetWithText(OutlinedButton, '编辑资料'));
await pumpUntil(tester, find.byType(ProfileEditPage));
await tester.tap(find.widgetWithText(TextButton, '更换头像'));
await pumpUntil(tester, find.text('使用这张'));
await shot(tester, '06-avatar-sheet-ready');
await tester.tap(find.text('使用这张'));
await pumpUntil(tester, find.text('已选择\n新头像'));
await tester.tap(find.widgetWithText(FilledButton, '保存'));
await pumpUntilGone(tester, find.byType(ProfileEditPage));
await pumpUntil(tester, find.text('资料已更新'));
await settleReal(tester);
await shot(tester, '07-profile-avatar-uploaded');
expect(picker.calls, 1);
// ---- 步骤 6:首页问候语 + Feed 作者名同步为昵称 ----
await tester.tap(find.text('首页'));
await pumpUntil(tester, find.text('${greetingPrefix()}$nickname 👋'));
await shot(tester, '08-home-greeting-nickname');
// Feed 首屏是在设昵称之前拉的,故先下拉刷新——作者名要变必须重取,
// 这一步同时验证服务端 `/internal/users/profiles` 的 SQL 回退链
// (客户端对 FeedCard.author.nickname 不做任何拼装)。
final feedList = find.byType(ListView).first;
// ⚠️ 服务端有意的滞后:community 的 AuthorProfileGateway 把
// `/internal/users/profiles` 的结果放在 60s TTL 的进程内缓存里
// `patbond.author-profile.cache-ttl`M3 T3-05)。首屏 Feed 是设昵称
// 之前拉的,那次已把「作者名 = username」写进缓存,所以立刻下拉刷新
// 仍是旧名——这不是客户端 bug,客户端对作者名不做任何拼装。
// 故先等过 TTL,再刷新验证同步。
await settleReal(tester, wait: const Duration(seconds: 65));
var authorSynced = false;
for (var round = 0; round < 3 && !authorSynced; round++) {
// 逐段 moveBy + 抬手才会触发 RefreshIndicatorfling 的速度语义被当成
// 普通滚动,本机实测过);随后必须循环 pump 让真实 HTTP 有时间完成。
final pull = await tester.startGesture(
tester.getTopLeft(feedList) + const Offset(200, 120),
);
for (var i = 0; i < 6; i++) {
await pull.moveBy(const Offset(0, 60));
await tester.pump(const Duration(milliseconds: 80));
}
await pull.up();
await settleReal(tester, wait: const Duration(seconds: 3));
for (var i = 0; i < 8 && find.text(postContent).evaluate().isEmpty; i++) {
await tester.drag(feedList, const Offset(0, -240));
await tester.pump(const Duration(milliseconds: 400));
}
if (find.text(postContent).evaluate().isNotEmpty) {
final card = find.ancestor(
of: find.text(postContent),
matching: find.byType(PostCard),
);
debugPrint(
'[live] 第 $round 轮我的卡片文本:'
'${tester.widgetList<Text>(find.descendant(of: card, matching: find.byType(Text))).map((t) => t.data).join(' | ')}',
);
authorSynced = find
.descendant(of: card, matching: find.text(nickname))
.evaluate()
.isNotEmpty;
}
if (!authorSynced) {
await tester.drag(feedList, const Offset(0, 2000));
await tester.pump(const Duration(milliseconds: 400));
}
}
expect(
authorSynced,
isTrue,
reason: 'Feed 作者名未随昵称变化(服务端 /internal 回退链或刷新未生效)',
);
await shot(tester, '09-feed-author-nickname');
// ---- 步骤 7:宠物详情传头像(WRITE 档,纯头像 PATCH----
await tester.tap(find.text('档案'));
await pumpUntil(tester, find.text('实测豆豆'));
await shot(tester, '10a-pets-list-avatar-placeholder');
await tester.tap(find.text('实测豆豆'));
await pumpUntil(tester, find.byType(PetDetailPage));
await shot(tester, '10-pet-detail-placeholder-avatar');
expect(
tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.any((a) => a.url == null),
isTrue,
reason: '上传前应是爪印占位',
);
await tester.tap(find.byType(PetAvatar).first);
await pumpUntil(tester, find.text('使用这张'));
await tester.tap(find.text('使用这张'));
await pumpUntil(tester, find.text('头像已更新'));
await settleReal(tester, wait: const Duration(seconds: 6));
await shot(tester, '11-pet-avatar-uploaded');
final detailAvatarUrl = tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.map((a) => a.url)
.whereType<String>()
.firstOrNull;
debugPrint('[live] 详情头像 URL$detailAvatarUrl');
final imageStatus = await tester.runAsync(() async {
final client = HttpClient();
final response = await (await client.getUrl(
Uri.parse(detailAvatarUrl!),
)).close();
final bytes = await response.fold<int>(0, (n, chunk) => n + chunk.length);
client.close();
return '${response.statusCode} / $bytes bytes';
});
debugPrint('[live] 应用进程内直取头像:$imageStatus');
expect(imageStatus, startsWith('200'), reason: '预签名头像 URL 应可直取');
// 真的画出了像素:详情头像下必须有 Image,且不是 RemoteImage 的兜底
// 占位(占位是 Icon(Icons.pets),画不出图时才出现)。
final detailAvatar = find.byWidgetPredicate(
(widget) => widget is PetAvatar && widget.showEditBadge,
);
expect(
find.descendant(of: detailAvatar, matching: find.byType(Image)),
findsOneWidget,
);
expect(
find.descendant(of: detailAvatar, matching: find.byIcon(Icons.pets)),
findsNothing,
reason: '仍是爪印占位说明图没画出来(后端对了但 UI 没显示)',
);
expect(
tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.any((a) => (a.url ?? '').contains('pet_avatar')),
isTrue,
reason: '详情页头像应换成 pet_avatar 前缀的预签名 URL',
);
debugPrint('[live] petId=${pet!['id']} username=$username');
}, 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);
}
+4 -1
View File
@@ -19,7 +19,10 @@ enum AnalyticsPageName {
create('create'), create('create'),
petArchive('pet_archive'), petArchive('pet_archive'),
services('services'), services('services'),
postDetail('post_detail'); postDetail('post_detail'),
// —— 字典 v3 页面族(22 号报告 §2;本枚举登记 M3 已落地页)——
/// 发布页(P3T3-17 push 全屏页)。
postForm('post_form');
const AnalyticsPageName(this.pageName); const AnalyticsPageName(this.pageName);
+59 -2
View File
@@ -5,9 +5,11 @@ import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart'; import 'package:patbond_flutter/analytics/analytics_service.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/analytics/session_tracker.dart'; import 'package:patbond_flutter/analytics/session_tracker.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/core/network/api_client.dart'; import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/token_refresher.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/core/widgets/avatar_upload_sheet.dart';
import 'package:patbond_flutter/features/auth/auth_repository.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/login_page.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:patbond_flutter/features/auth/session_manager.dart';
@@ -16,11 +18,14 @@ 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_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/community_repository.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/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/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_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_controller.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart'; import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/features/profile/profile_controller.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 {
@@ -30,6 +35,8 @@ class App extends StatefulWidget {
this.authRepository, this.authRepository,
this.petsRepository, this.petsRepository,
this.communityRepository, this.communityRepository,
this.mediaUploaderFactory,
this.avatarUploaderFactory,
}); });
/// 测试注入口;生产默认走安全存储 + 真实 API。 /// 测试注入口;生产默认走安全存储 + 真实 API。
@@ -38,6 +45,14 @@ class App extends StatefulWidget {
final PetsRepository? petsRepository; final PetsRepository? petsRepository;
final CommunityRepository? communityRepository; final CommunityRepository? communityRepository;
/// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。
final MediaUploaderFactory? mediaUploaderFactory;
/// 头像上传器构造口(T3.5-08/09)。生产为 null → 走
/// [defaultAvatarUploader];桌面实测与集成测试在此替换选图/压缩层
/// Linux 桌面无 image_picker / 压缩原生实现),网络三段仍是生产实现。
final AvatarUploaderFactory? avatarUploaderFactory;
@override @override
State<App> createState() => _AppState(); State<App> createState() => _AppState();
} }
@@ -48,10 +63,13 @@ class _AppState extends State<App> {
late final AuthRepository authRepository; late final AuthRepository authRepository;
late final PetsController petsController; late final PetsController petsController;
late final CommunityController communityController; late final CommunityController communityController;
late final ProfileController profileController;
late final AvatarUploaderBuilder _avatarUploaderBuilder;
late final PetAnalytics petAnalytics; late final PetAnalytics petAnalytics;
late final HealthRecordAnalytics healthRecordAnalytics; late final HealthRecordAnalytics healthRecordAnalytics;
late final FeedAnalytics feedAnalytics; late final FeedAnalytics feedAnalytics;
late final CommunityInteractionAnalytics interactionAnalytics; late final CommunityInteractionAnalytics interactionAnalytics;
late final PostAnalytics postAnalytics;
late final SessionTracker _sessionTracker; late final SessionTracker _sessionTracker;
late final AnalyticsService _analytics; late final AnalyticsService _analytics;
late final PageViewTracker _pageViewTracker; late final PageViewTracker _pageViewTracker;
@@ -100,13 +118,29 @@ class _AppState extends State<App> {
// T3-14Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起 // T3-14Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起
// 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。 // 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。
interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent); interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent);
final communityRepository =
widget.communityRepository ?? _buildCommunityRepository();
communityController = CommunityController( communityController = CommunityController(
repository: widget.communityRepository ?? _buildCommunityRepository(), repository: communityRepository,
interactionAnalytics: interactionAnalytics, interactionAnalytics: interactionAnalytics,
); );
// T3.5-08/10:资料与统计单例,资料 Tab 与首页问候语共用(一次 /me
// 供两个消费点,改昵称后两处同时变)。
profileController = ProfileController(
authRepository: authRepository,
communityRepository: communityRepository,
);
// 头像上传:purpose 由调用页给定(user_avatar / pet_avatar)。仓库复用
// community 仓库——media 两步上传端点由 user 服务提供,已在
// _buildCommunityRepository 里单独接线(mediaApi)。
final avatarFactory = widget.avatarUploaderFactory ?? defaultAvatarUploader;
_avatarUploaderBuilder = (purpose) =>
avatarFactory(communityRepository, purpose);
petAnalytics = PetAnalytics(_analytics.trackEvent); petAnalytics = PetAnalytics(_analytics.trackEvent);
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent); healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
feedAnalytics = FeedAnalytics(_analytics.trackEvent); feedAnalytics = FeedAnalytics(_analytics.trackEvent);
// T3-17:发布漏斗五事件 + 媒体上传三段(发布页与 MediaUploader 消费)。
postAnalytics = PostAnalytics(_analytics.trackEvent);
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件) // 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
sessionManager.addListener(_reportAuthStateChange); sessionManager.addListener(_reportAuthStateChange);
@@ -139,8 +173,19 @@ class _AppState extends State<App> {
session: sessionManager, session: sessionManager,
refresher: refresher, refresher: refresher,
); );
// `/api/v1/me` 由 user 服务(:8082)提供,auth:8081)上没有该路由,
// 故单独接一条 user 线路(与 community 仓库的 mediaApi 同构)。
final userApi = ApiClient(
dio: buildPatbondDio(
session: sessionManager,
baseUrl: patbondUserApiBaseUrl,
),
session: sessionManager,
refresher: refresher,
);
return ApiAuthRepository( return ApiAuthRepository(
api: api, api: api,
userApi: userApi,
session: sessionManager, session: sessionManager,
refresher: refresher, refresher: refresher,
analytics: _analytics, analytics: _analytics,
@@ -195,10 +240,12 @@ class _AppState extends State<App> {
} }
void _reportAuthStateChange() { void _reportAuthStateChange() {
// 登出即清宠物档案社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。 // 登出即清宠物档案社区 Feed 与本人资料内存副本(跨账号不泄漏;
// 重登后重新拉取)。
if (sessionManager.status == AuthStatus.unauthenticated) { if (sessionManager.status == AuthStatus.unauthenticated) {
petsController.reset(); petsController.reset();
communityController.reset(); communityController.reset();
profileController.reset();
} }
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2) // 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
final page = switch (sessionManager.status) { final page = switch (sessionManager.status) {
@@ -217,6 +264,7 @@ class _AppState extends State<App> {
appState.dispose(); appState.dispose();
petsController.dispose(); petsController.dispose();
communityController.dispose(); communityController.dispose();
profileController.dispose();
if (widget.sessionManager == null) sessionManager.dispose(); if (widget.sessionManager == null) sessionManager.dispose();
super.dispose(); super.dispose();
} }
@@ -240,11 +288,15 @@ class _AppState extends State<App> {
appState: appState, appState: appState,
petsController: petsController, petsController: petsController,
communityController: communityController, communityController: communityController,
profileController: profileController,
currentUserId: sessionManager.userId, currentUserId: sessionManager.userId,
petAnalytics: petAnalytics, petAnalytics: petAnalytics,
healthRecordAnalytics: healthRecordAnalytics, healthRecordAnalytics: healthRecordAnalytics,
feedAnalytics: feedAnalytics, feedAnalytics: feedAnalytics,
interactionAnalytics: interactionAnalytics, interactionAnalytics: interactionAnalytics,
postAnalytics: postAnalytics,
mediaUploaderFactory: widget.mediaUploaderFactory,
avatarUploaderBuilder: _avatarUploaderBuilder,
pageViewTracker: _pageViewTracker, pageViewTracker: _pageViewTracker,
onLogout: authRepository.logout, onLogout: authRepository.logout,
); );
@@ -257,6 +309,11 @@ class _AppState extends State<App> {
title: 'Patbond', title: 'Patbond',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01Material 内置组件中文化(此前未配 delegate,日期选择器等
// 全部回退英文兜底)。单语言 zh-CN,不随设备语言回退英文。
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
navigatorObservers: [_routeObserver], navigatorObservers: [_routeObserver],
home: ListenableBuilder( home: ListenableBuilder(
listenable: sessionManager, listenable: sessionManager,
+31
View File
@@ -0,0 +1,31 @@
/// 应用本地化配置(M3.5-01)。
///
/// 根因备忘:M3 之前 [MaterialApp] 未配任何 `localizationsDelegates`
/// Flutter 回退到内置的 `DefaultMaterialLocalizations`(仅英文),导致
/// 一切 Material 内置组件(日期选择器标题「Select date」、确定/取消
/// 「OK」/「Cancel」、模式切换 tooltip「Switch to input」、格式报错
/// 「Invalid format.」)全英文,而业务自绘文案全中文——同一弹窗内中英混排。
/// 挂上三件套后分别为「选择日期 / 确定 / 取消 / 切换到输入模式 / 格式无效。」。
///
/// 这里把三件套 delegate 与 locale 收在一处,供 `App` 与 widget 测试共用:
/// 测试若只 `pumpWidget(MaterialApp(home: ...))` 而不带 delegate,看到的仍是
/// 英文兜底,与真机不一致;需要断言中文渲染的测试请一并挂上本文件的常量。
library;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
/// Material / Cupertino / Widgets 三件套本地化 delegate。
const List<LocalizationsDelegate<Object>> appLocalizationsDelegates =
<LocalizationsDelegate<Object>>[
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// 简体中文(中国大陆)。M3.5 仅单语言,暂不做语言切换。
const Locale appLocale = Locale('zh', 'CN');
/// 支持语言列表。仅 zh-CN——避免设备语言为英文时回退到英文,
/// 造成同一界面内业务中文 + 组件英文的混排。
const List<Locale> appSupportedLocales = <Locale>[appLocale];
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/foundation.dart';
/// PATCH 请求的**三态字段**:契约 v1.4.0 对「昵称 / 头像」两类天生可选的
/// 字段定型为三态语义(`UpdateMeRequest` 与 `UpdatePetRequest.avatarAssetId`):
///
/// | 态 | JSON 表现 | 服务端语义 |
/// | --- | --- | --- |
/// | [PatchField.absent] | **键不出现** | 不改 |
/// | [PatchField.clear] | 键出现且值为 `null` | 清空 |
/// | [PatchField.value] | 键出现且有值 | 设置 |
///
/// Dart 的 `String?` 只有两态(有值 / null),无法区分「不改」与「清空」——
/// 若把「不改」也编码为 `null`,未改动的字段会被服务端当成清空指令执行
/// (用户只改昵称,头像就被顺手删了)。故三态必须由类型承载,
/// 不能靠 `T?` 加约定。
///
/// 序列化一律经 [writeTo]:它是「absent 不落键」这条纪律的唯一实现处,
/// 各请求 DTO 不自行拼 map,避免某处漏写 `isPresent` 判断。
@immutable
class PatchField<T extends Object> {
/// 不改:键不出现在 JSON 里。
const PatchField.absent() : _present = false, _value = null;
/// 清空:键出现且值为 `null`。
const PatchField.clear() : _present = true, _value = null;
/// 设置为 [value]。
const PatchField.value(T value) : _present = true, _value = value;
final bool _present;
final T? _value;
/// 本次 PATCH 是否触及该字段(决定键是否落进 JSON)。
bool get isPresent => _present;
/// 是否为「显式清空」(present 且值为 null)。
bool get isClear => _present && _value == null;
/// present 且有值时的值;absent 与 clear 均为 null(两者不可由此区分)。
T? get valueOrNull => _value;
/// 按三态把自己写进 [json]absent 不落键;clear 落 `null`
/// 有值时落 [encode] 的产物(缺省原样写入)。
void writeTo(
Map<String, Object?> json,
String key, {
Object? Function(T value)? encode,
}) {
if (!_present) return;
final value = _value;
json[key] = value == null ? null : (encode?.call(value) ?? value);
}
@override
bool operator ==(Object other) =>
other is PatchField<T> &&
other._present == _present &&
other._value == _value;
@override
int get hashCode => Object.hash(_present, _value);
@override
String toString() => _present ? 'PatchField($_value)' : 'PatchField.absent()';
}
+100
View File
@@ -176,6 +176,7 @@ ThemeData buildAppTheme() {
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)), borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
), ),
), ),
datePickerTheme: _datePickerTheme,
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
indicatorColor: AppColors.surfaceTint, indicatorColor: AppColors.surfaceTint,
@@ -194,3 +195,102 @@ ThemeData buildAppTheme() {
), ),
); );
} }
/// 日期选择器主题(M3.5-01)。
///
/// 根因备忘:此前未定制,`showDatePicker` 完全走 `ColorScheme.fromSeed`
/// 由珊瑚橙 `#FF6F4C` 派生出的 M3 调和色(选中日为暗红棕实底),
/// 与全 app 品牌色脱节。这里只复用 05 号规范(iteration-2/05、iteration-3/05
/// 已审计过的色对,不新造色值:
///
/// | 位置 | 色对 | 对比度 | 来源 |
/// | --- | --- | --- | --- |
/// | 头部(帮助文字 + 标题 + 模式切换图标) | `surfaceTint` 底 + `primaryDark` | 7.98:1 | 选中 chip 同款(iteration-2/05 §3 D7 |
/// | 选中日 / 选中年 | `primaryStrong` 底 + 白字 | 4.49:1 | FAB / 头像徽标同款(同 §2) |
/// | 今日(未选中)描边与文字 | 白底 + `primaryStrong` | 4.49:1 | 同上 |
/// | 未选中日 / 年 | 白底 + `ink` | ≥12:1 | 正文主色 |
/// | 星期表头 | 白底 + `inkSoft` | 6.59:1 | 承载信息的次级文字(§3 DEBT-2) |
/// | 越界不可选日 | 白底 + `muted` | 3.36:1 | 禁用态,DEBT-2 允许的 `muted` 用途 |
/// | 确定 / 取消按钮 | 白底 + `primaryStrong` 文字 | 4.49:1 | 可点击文字链接(§1.1 token 注释) |
final DatePickerThemeData _datePickerTheme = DatePickerThemeData(
backgroundColor: AppColors.surface,
elevation: 0,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
side: BorderSide(color: AppColors.border),
),
dividerColor: AppColors.border,
headerBackgroundColor: AppColors.surfaceTint,
headerForegroundColor: AppColors.primaryDark,
headerHelpStyle: const TextStyle(
color: AppColors.primaryDark,
fontSize: 12,
fontWeight: FontWeight.w600,
),
headerHeadlineStyle: const TextStyle(
color: AppColors.primaryDark,
// 22(默认 32 偏大):中文 `formatMediumDate` 是「9月10日周四」5~6 字,
// 横屏侧栏头部宽度下 26 起就会折行,22 一行放得下。
fontSize: 22,
fontWeight: FontWeight.w800,
),
// 年份下拉 / 上下月箭头一行:与头部同族深色,白底 8.74:1。
subHeaderForegroundColor: AppColors.primaryDark,
weekdayStyle: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
fontWeight: FontWeight.w600,
),
dayStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
dayForegroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return AppColors.muted;
if (states.contains(WidgetState.selected)) return Colors.white;
return AppColors.ink;
}),
dayBackgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return AppColors.primaryStrong;
return null;
}),
dayOverlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return Colors.white24;
return AppColors.primary.withAlpha(31);
}),
todayForegroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return AppColors.muted;
if (states.contains(WidgetState.selected)) return Colors.white;
return AppColors.primaryStrong;
}),
todayBackgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return AppColors.primaryStrong;
return null;
}),
// 今日始终带描边(选中态由实底承载,未选中态靠 1.5px 描边定位):
// `primaryStrong` 白底 4.49:1,超过非文字元素 3:1 门槛。
todayBorder: const BorderSide(color: AppColors.primaryStrong, width: 1.5),
yearStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
yearForegroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return AppColors.muted;
if (states.contains(WidgetState.selected)) return Colors.white;
return AppColors.ink;
}),
yearBackgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return AppColors.primaryStrong;
return null;
}),
yearOverlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return Colors.white24;
return AppColors.primary.withAlpha(31);
}),
cancelButtonStyle: TextButton.styleFrom(
foregroundColor: AppColors.inkSoft,
minimumSize: const Size(64, 44),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
confirmButtonStyle: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
minimumSize: const Size(64, 44),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
);
+144
View File
@@ -0,0 +1,144 @@
/// 日期录入共享层(M3.5-02)。
///
/// 根因备忘:全仓 7 处 `showDatePicker` 各写一遍裸调用,用户实测暴露两个问题:
///
/// 1. **月份只能靠 `< >` 逐月切**。Flutter 原生日历有年份网格、没有月份网格,
/// 从 9 月回到 4 月要点 5 次箭头。已实际导致误录——用户把当月(2026-09)
/// 的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是统计坏了。
/// 2. **键盘输入模式在英文兜底下不可用**。原生 calendar 模式本就带一枚
/// 切换到手输的铅笔按钮,但未配本地化时按钮 tooltip 是
/// `Switch to input`、报错是 `Invalid format.`,中文用户看不懂也不敢用。
/// M3.5-01 挂上 zh-CN delegate 后变成「切换到输入模式」/「格式无效。」/
/// 「超出范围。」,输入框提示也从 `mm/dd/yyyy` 变成中文习惯的
/// `yyyy/mm/dd`,这条路才真正走通。
///
/// 本文件的处置:
///
/// - [pickAppDate] 收口 7 处调用:统一 calendar 首屏 + **保留**手输切换
/// (明确不用 `calendarOnly`——那恰好会砍掉手输这条快路),并把
/// `initialDate` 夹进 `[firstDate, lastDate]` 防越界断言。各调用点原有的
/// 业务约束(如健康事件 `lastDate: now` 不许未来)由调用方原样传入,不改。
/// - [AppDateFieldTrailing] 给 7 处日期行统一挂「今天」快捷键:绝大多数录入
/// 就是「记今天的事」,一键落值比开弹窗再找今天更快,也把「月份导航」
/// 这条容易走错的路整段绕开。原生 `showDatePicker` 无法注入自定义动作
/// `builder` 只能包裹整个 Dialog,拿不到它的内部选中态),所以快捷键
/// 放在调用方表单行而非弹窗内。
library;
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 抹掉时分秒,只留年月日(日期选择器返回值与业务日期字段的统一口径)。
DateTime dateOnly(DateTime value) =>
DateTime(value.year, value.month, value.day);
/// 今天(本地时区,仅日期)。
DateTime today() => dateOnly(DateTime.now());
/// 判断 [date] 是否落在 `[firstDate, lastDate]` 闭区间内(按日粒度)。
bool isDateSelectable({
required DateTime date,
required DateTime firstDate,
required DateTime lastDate,
}) {
final day = dateOnly(date);
return !day.isBefore(dateOnly(firstDate)) && !day.isAfter(dateOnly(lastDate));
}
/// 全仓统一的日期选择入口。
///
/// [initialDate] 会被夹进 `[firstDate, lastDate]`——调用方常传「当前值 ?? 今天」,
/// 而部分表单的 `firstDate` 就是今天(如提醒到期日),历史值可能已越界。
///
/// 返回 `null` 表示用户取消;返回值已抹掉时分秒。
Future<DateTime?> pickAppDate({
required BuildContext context,
required DateTime initialDate,
required DateTime firstDate,
required DateTime lastDate,
}) async {
final first = dateOnly(firstDate);
final last = dateOnly(lastDate);
var initial = dateOnly(initialDate);
if (initial.isBefore(first)) initial = first;
if (initial.isAfter(last)) initial = last;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: first,
lastDate: last,
// calendar 首屏 + 头部铅笔按钮切手输:日期不确定时翻日历,日期已知时
// 一行敲完。不用 calendarOnly(砍掉手输)、不用 input 首屏(多数录入
// 是「今天/最近几天」,日历一眼可点,手输反而更慢)。
initialEntryMode: DatePickerEntryMode.calendar,
// 帮助文字/确定/取消/手输提示与格式报错全部交给 zh-CN 本地化
// appLocalizationsDelegates),不在此硬编码中文,避免两处文案漂移。
);
return picked == null ? null : dateOnly(picked);
}
/// 日期行尾部(7 处日期 [ListTile] 统一形态):「今天」快捷键 + 日历图标。
///
/// - 「今天」直接把字段落到今天,不开弹窗;今天越界(业务不允许)时按钮
/// 自动隐藏,只留日历图标。
/// - 触控目标 44×44(项目最小触控口径),文字 `primaryStrong` 白底 4.49:1。
class AppDateFieldTrailing extends StatelessWidget {
const AppDateFieldTrailing({
required this.firstDate,
required this.lastDate,
required this.onToday,
super.key,
this.enabled = true,
});
/// 业务允许的最早日期(与 [pickAppDate] 同一约束)。
final DateTime firstDate;
/// 业务允许的最晚日期。
final DateTime lastDate;
/// 点「今天」的回调,入参为今天(仅日期)。
final ValueChanged<DateTime> onToday;
/// 表单提交中等禁用态。
final bool enabled;
@override
Widget build(BuildContext context) {
final now = today();
final showToday = isDateSelectable(
date: now,
firstDate: firstDate,
lastDate: lastDate,
);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showToday)
Tooltip(
message: '设为今天',
child: TextButton(
onPressed: enabled ? () => onToday(now) : null,
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
disabledForegroundColor: AppColors.muted,
minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
child: const Text('今天'),
),
),
const Icon(
Icons.calendar_month_outlined,
size: 20,
color: AppColors.muted,
),
],
);
}
}
+271
View File
@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.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';
/// 页面侧的构造口:调用页只知道「用途」,仓库与选图/压缩层的装配由
/// `app.dart` 在装配处完成。
///
/// 缺省为 null 时页面**不渲染头像上传入口**:意为「本次构建未装配上传能力」,
/// 而不是「有入口但点了没反应」。生产装配恒注入(见 `app.dart`)。
typedef AvatarUploaderBuilder = MediaUploader Function(MediaPurpose purpose);
/// App 级注入口(沿 `MediaUploaderFactory` 先例:工厂拿到已装配的仓库)。
///
/// 生产缺省 [defaultAvatarUploader]**桌面实测与集成测试**在此替换选图与
/// 压缩两层——Linux 桌面既无 image_picker 也无 flutter_image_compress 的
/// 原生实现,而 createUpload / 预签名 PUT 直传 / confirm 三段仍走生产实现。
typedef AvatarUploaderFactory =
MediaUploader Function(
CommunityRepository repository,
MediaPurpose purpose,
);
/// 生产缺省头像上传器:单图、单并发(头像没有批量语义)。
MediaUploader defaultAvatarUploader(
CommunityRepository repository,
MediaPurpose purpose,
) => MediaUploader(
repository: repository,
purpose: purpose,
maxImages: 1,
maxConcurrentUploads: 1,
);
/// 弹出头像上传流程,返回**ready 的 assetId**;用户取消或未走到 ready
/// 即 null(孤儿防护:未 confirm 的 asset 绝不外露,见 [MediaUploader])。
Future<String?> showAvatarUploadSheet(
BuildContext context, {
required AvatarUploaderBuilder builder,
required MediaPurpose purpose,
String title = '更换头像',
}) {
return showModalBottomSheet<String>(
context: context,
useSafeArea: true,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
AvatarUploadSheet(uploader: builder(purpose), title: title),
);
}
/// 头像上传 sheet:复用发布页的 [MediaUploader] 六态编排(queued /
/// compressing / uploading / confirming / ready / failed),单图上限。
///
/// 与九宫格的差异只在呈现:这里一次只有一张图,故用整幅预览 + 线性进度
/// 条,而不是格内 [UploadProgressOverlay];状态机、凭据过期换新、失败可
/// 重试、孤儿防护全部沿用编排器,本组件不复制任何上传逻辑。
///
/// **ready 后仍需用户点「使用这张」**:上传成功不等于用户满意这张图,
/// 自动关闭会剥夺预览确认的机会(头像是长期可见的身份标识)。
class AvatarUploadSheet extends StatefulWidget {
const AvatarUploadSheet({
required this.uploader,
required this.title,
super.key,
});
/// 本次会话专属的上传器(purpose 与 maxImages=1 由构造口设定);
/// 关闭 sheet 即 dispose,在途请求经 [MediaUploader.reset] 作废。
final MediaUploader uploader;
final String title;
@override
State<AvatarUploadSheet> createState() => _AvatarUploadSheetState();
}
class _AvatarUploadSheetState extends State<AvatarUploadSheet> {
MediaUploader get _uploader => widget.uploader;
@override
void initState() {
super.initState();
_uploader.addListener(_onChanged);
// 打开即拉起选择器:sheet 的唯一目的就是选一张图,多一次「选择图片」
// 点击是纯摩擦。用户在系统选择器里取消后回落到空态(可再次选择)。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _uploader.pickAndAdd();
});
}
void _onChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_uploader.removeListener(_onChanged);
// 在途任务按 cancelled 收敛(未 confirm 的服务端 asset 弃引用,
// 由服务端超时清理兜底)。
_uploader
..reset()
..dispose();
super.dispose();
}
Future<void> _reselect() async {
// 换图前先清空:maxImages=1 时不清空则 remainingSlots=0,选择器不会拉起。
_uploader.reset();
await _uploader.pickAndAdd();
}
@override
Widget build(BuildContext context) {
final items = _uploader.items;
final item = items.isEmpty ? null : items.first;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
if (item != null) _preview(item),
if (item != null) const SizedBox(height: 14),
..._body(item),
],
),
);
}
/// 预览:原图字节直接解码(不落盘),圆形裁切以贴合头像最终形态。
Widget _preview(MediaUploadItem item) {
return Center(
child: ClipOval(
child: Image.memory(
item.previewBytes,
width: 132,
height: 132,
fit: BoxFit.cover,
errorBuilder: (context, error, stack) => Container(
width: 132,
height: 132,
color: AppColors.surfaceTint,
child: const Icon(Icons.image_outlined, color: AppColors.muted),
),
),
),
);
}
List<Widget> _body(MediaUploadItem? item) {
if (item == null) {
// 选择器拉起中 / 用户取消后的空态。
return _uploader.isPicking
? const [_BusyLine(label: '正在打开相册…')]
: [
const Text(
'还没有选择图片',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _reselect,
icon: const Icon(Icons.photo_library_outlined, size: 18),
label: const Text('选择图片'),
),
];
}
switch (item.phase) {
case MediaItemPhase.queued:
case MediaItemPhase.compressing:
return const [_BusyLine(label: '正在处理图片…')];
case MediaItemPhase.uploading:
return [
_ProgressLine(progress: item.progress),
const SizedBox(height: 10),
Text(
'上传中 ${(item.progress * 100).round()}%',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
];
case MediaItemPhase.confirming:
return const [
_ProgressLine(progress: 1),
SizedBox(height: 10),
Text(
'正在确认…',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
];
case MediaItemPhase.ready:
return [
FilledButton(
onPressed: () => Navigator.of(context).pop(item.assetId),
child: const Text('使用这张'),
),
const SizedBox(height: 8),
TextButton(onPressed: _reselect, child: const Text('重新选择')),
];
case MediaItemPhase.failed:
return [
Text(
item.errorMessage ?? '上传失败',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.errorDark, fontSize: 13),
),
const SizedBox(height: 12),
// 不可重试(如压缩后仍超 10 MB)只给「重新选择」——重试同一张
// 必然再失败,给重试钮是误导。
if (item.retryable)
FilledButton(
onPressed: () => _uploader.retry(item.localId),
child: const Text('重试'),
),
if (item.retryable) const SizedBox(height: 8),
TextButton(onPressed: _reselect, child: const Text('重新选择')),
];
}
}
}
class _BusyLine extends StatelessWidget {
const _BusyLine({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 10),
Text(
label,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
],
);
}
}
class _ProgressLine extends StatelessWidget {
const _ProgressLine({required this.progress});
final double progress;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(AppRadius.pill)),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor: AppColors.surfaceTint,
color: AppColors.primary,
),
);
}
}
+7 -4
View File
@@ -28,9 +28,12 @@ enum PetAvatarSize {
/// 头像实现。圆形裁切 + 3px `surface` 白描边 + 轻投影(正典 /// 头像实现。圆形裁切 + 3px `surface` 白描边 + 轻投影(正典
/// `.patbond-avatar` 规格),可选右下编辑徽标与品牌渐变环。 /// `.patbond-avatar` 规格),可选右下编辑徽标与品牌渐变环。
/// ///
/// M2 首版不做头像上传(ADR-010 / D2-1 裁决)[url] 为 null 时渲染 /// M2 首版不做头像上传(ADR-010 / D2-1 裁决)T3.5-09 起 [url] 由服务端
/// 本地占位形态(`surfaceTint` 底 + `Icons.pets`);有图时经 /// `Pet.avatarUrl`(预签名 GET)驱动,[url] 为 null 时仍渲染本地占位形态
/// [RemoteImage] 加载(loading / 失败兜底由其内置)。 /// `surfaceTint` 底 + `Icons.pets`)——签一个必然 404 的 URL 比返回 null
/// 更糟,故服务端对非 ready 的 asset 直接给 null,客户端只有一种占位逻辑。
/// 有图时经 [RemoteImage] 加载(缓存 key 已剥签名参数;loading / 失败兜底
/// 由其内置)。
class PetAvatar extends StatelessWidget { class PetAvatar extends StatelessWidget {
const PetAvatar({ const PetAvatar({
required this.size, required this.size,
@@ -45,7 +48,7 @@ class PetAvatar extends StatelessWidget {
final PetAvatarSize size; final PetAvatarSize size;
/// 头像图 URL;null 为本地占位形态(M2 默认)。 /// 头像图 URL;null 为本地占位形态(无头像 / asset 非 ready / 存储未配置)。
final String? url; final String? url;
/// 可点击时整体 [InkWell] 圆形 ripple。 /// 可点击时整体 [InkWell] 圆形 ripple。
+204 -1
View File
@@ -1,5 +1,7 @@
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/upload_progress_overlay.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/widgets/common.dart'; import 'package:patbond_flutter/widgets/common.dart';
/// 图片九宫格展示态(05 号规范 §3.2)。 /// 图片九宫格展示态(05 号规范 §3.2)。
@@ -14,7 +16,8 @@ import 'package:patbond_flutter/widgets/common.dart';
/// [urls] 单元素而 [totalCount] > 1 时渲染 4:3 单格 + 右下「+N」角标 /// [urls] 单元素而 [totalCount] > 1 时渲染 4:3 单格 + 右下「+N」角标
/// 胶囊(同 80% scrim 精算)。 /// 胶囊(同 80% scrim 精算)。
/// ///
/// 编辑态(「+」格 / 删除角标)随发布页工单(T3-17)扩展。 /// 编辑态(「+」格 / 删除角标 / 进度覆盖层)见同文件 [PostMediaEditGrid]
/// (T3-17 发布页选图区)。
class PostMediaGrid extends StatelessWidget { class PostMediaGrid extends StatelessWidget {
const PostMediaGrid({ const PostMediaGrid({
required this.urls, required this.urls,
@@ -106,6 +109,206 @@ class PostMediaGrid extends StatelessWidget {
} }
} }
/// 编辑态九宫格(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」胶囊角标。 /// 单图折叠形态:4:3 圆角封面 + 右下「+N」胶囊角标。
class _CollapsedCover extends StatelessWidget { class _CollapsedCover extends StatelessWidget {
const _CollapsedCover({ const _CollapsedCover({
+68 -1
View File
@@ -1,4 +1,10 @@
/// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。 /// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。
library;
import 'package:patbond_flutter/core/models/patch_field.dart';
export 'package:patbond_flutter/core/models/patch_field.dart';
class AuthTokens { class AuthTokens {
const AuthTokens({ const AuthTokens({
required this.userId, required this.userId,
@@ -32,11 +38,16 @@ class AuthTokens {
final DateTime refreshTokenExpiresAt; final DateTime refreshTokenExpiresAt;
} }
/// `GET /api/v1/me` 的用户资料。 /// `GET` / `PATCH /api/v1/me` 的用户资料(契约 v1.4.0 恰好这 6 个字段)
///
/// **不含 `avatarAssetId`**:客户端对头像 asset 只写不读,「有头像」等价于
/// [avatarUrl] 非 null。
class UserProfile { class UserProfile {
const UserProfile({ const UserProfile({
required this.userId, required this.userId,
required this.username, required this.username,
required this.nickname,
required this.avatarUrl,
required this.phone, required this.phone,
required this.createdAt, required this.createdAt,
}); });
@@ -45,6 +56,11 @@ class UserProfile {
return UserProfile( return UserProfile(
userId: json['userId'] as String, userId: json['userId'] as String,
username: json['username'] as String, username: json['username'] as String,
// DB 原值:服务端**刻意不做 username 回退**v1.4.0 定型,理由见
// [displayName]),未设置即 null。
nickname: json['nickname'] as String?,
// 时效性预签名 GET URL,每次响应现签;不得持久化、过期即重取。
avatarUrl: json['avatarUrl'] as String?,
// 服务端可返回 null(历史数据或未来第三方注册),不得非空强转。 // 服务端可返回 null(历史数据或未来第三方注册),不得非空强转。
phone: json['phone'] as String?, phone: json['phone'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: DateTime.parse(json['createdAt'] as String),
@@ -53,6 +69,57 @@ class UserProfile {
final String userId; final String userId;
final String username; final String username;
/// 昵称 DB 原值,未设置为 null。**编辑态预填只能用它**,不能用
/// [displayName]——否则保存时会把展示回退值固化成真实昵称。
final String? nickname;
/// 头像预签名 GET URL(会过期,禁止入本地存储);无头像 / asset 非
/// ready / 对象存储未配置三种情况均为 null。
final String? avatarUrl;
final String? phone; final String? phone;
final DateTime createdAt; final DateTime createdAt;
/// 本人视角的展示名:`nickname ?? username`。
///
/// **回退刻意做在客户端展示层**:服务端 `/me` 返回 DB 原值不回退(契约
/// v1.4.0 §Me),因为 `/me` 是本人编辑态——若服务端回退,编辑页会把
/// `llx` 预填进昵称框,用户误以为设过昵称,下次保存即把这个展示约定
/// **固化成真实数据**`/internal/users/profiles` 的 SQL 回退链从此再不
/// 触发。他人视角(Feed 作者名)的回退由服务端 SQL 层承担,客户端不重复。
String get displayName => nickname ?? username;
/// 是否已有头像(响应不回显 avatarAssetId,判定只看 URL 是否现签成功)。
bool get hasAvatar => avatarUrl != null;
}
/// `PATCH /api/v1/me` 请求体(契约 v1.4.0**三态语义**
/// 键缺省 = 不改 / 显式 `null` = 清空 / 给值 = 设置)。
///
/// 两字段都 absent 即**空 patch**——服务端答 400/40000 而非静默 200
/// 故调用前应以 [isEmpty] 短路(无变更不发请求)。
///
/// 昵称的服务端校验:btrim 后长度按**码点**计 1~32;纯空白或空串是
/// 400/40000 而**不是**隐式清空(清空只走 [PatchField.clear] 一条路)。
class UpdateMeRequest {
const UpdateMeRequest({
this.nickname = const PatchField<String>.absent(),
this.avatarAssetId = const PatchField<String>.absent(),
});
final PatchField<String> nickname;
/// 头像 asset(两步上传产物,`purpose` 须为 `user_avatar`)。
final PatchField<String> avatarAssetId;
/// 本次 patch 未触及任何字段(发出去必得 400/40000)。
bool get isEmpty => !nickname.isPresent && !avatarAssetId.isPresent;
Map<String, Object?> toJson() {
final json = <String, Object?>{};
nickname.writeTo(json, 'nickname');
avatarAssetId.writeTo(json, 'avatarAssetId');
return json;
}
} }
+32 -3
View File
@@ -28,18 +28,33 @@ abstract class AuthRepository {
Future<SessionRestoreResult> restoreSession(); Future<SessionRestoreResult> restoreSession();
Future<UserProfile> me(); Future<UserProfile> me();
/// 本人资料部分更新(昵称 / 头像,三态语义见 [UpdateMeRequest])。
/// 成功返回**更新后的全量 [UserProfile]**(服务端回显,含现签 avatarUrl)。
Future<UserProfile> updateMe(UpdateMeRequest request);
} }
/// 基于 [ApiClient] 的实现。
///
/// **端口线路**(ADR-002 无网关,分端口直连):注册/登录/登出/刷新走 auth
/// 服务(:8081);`GET` 与 `PATCH /api/v1/me` 由 **user 服务**:8082
/// `MeController`)提供,故经 [userApi] 直连——auth 上没有 `/api/v1/me`
/// 路由,走主客户端会得到 404。未提供 [userApi] 时回落主客户端(既有测试
/// 桩场景)。同一线路分离手法与 `ApiCommunityRepository` 的 `mediaApi` 一致。
class ApiAuthRepository implements AuthRepository { class ApiAuthRepository implements AuthRepository {
ApiAuthRepository({ ApiAuthRepository({
required this._api, required ApiClient api,
required this._session, required this._session,
required this._refresher, required this._refresher,
ApiClient? userApi,
this._analytics, this._analytics,
this._uuid = const Uuid(), this._uuid = const Uuid(),
}); }) : _api = api,
// 缺省回落主客户端:既有测试桩只注入一个 ApiClient。
_userApi = userApi ?? api;
final ApiClient _api; final ApiClient _api;
final ApiClient _userApi;
final SessionManager _session; final SessionManager _session;
final TokenRefresher _refresher; final TokenRefresher _refresher;
final AnalyticsService? _analytics; final AnalyticsService? _analytics;
@@ -132,11 +147,25 @@ class ApiAuthRepository implements AuthRepository {
@override @override
Future<UserProfile> me() async { Future<UserProfile> me() async {
final data = await _api.request( final data = await _userApi.request(
'/api/v1/me', '/api/v1/me',
method: 'GET', method: 'GET',
requiresAuth: true, requiresAuth: true,
); );
return UserProfile.fromJson(data! as Map<String, dynamic>); return UserProfile.fromJson(data! as Map<String, dynamic>);
} }
@override
Future<UserProfile> updateMe(UpdateMeRequest request) async {
// 无乐观锁、无幂等键(契约定型:唯一合法写者 + 列级选择性 UPDATE,
// 同 body 重放天然幂等)。三态由 request.toJson 表达——「不改」的字段
// 根本不出现在 body 里,绝不发成 null。
final data = await _userApi.request(
'/api/v1/me',
method: 'PATCH',
body: request.toJson(),
requiresAuth: true,
);
return UserProfile.fromJson(data! as Map<String, dynamic>);
}
} }
@@ -1,4 +1,5 @@
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/community_models.dart';
/// 降级作者([AuthorSummary.isDegraded],资料暂不可得或已注销)的 /// 降级作者([AuthorSummary.isDegraded],资料暂不可得或已注销)的
@@ -31,3 +32,28 @@ String feedLoadErrorMessage(ApiException? error) => switch (error) {
ApiRateLimitException _ => '请求过于频繁,请稍后再试', 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 _ => '请求过于频繁,请稍后再试',
_ => '草稿保存失败,请重试',
};
+29 -2
View File
@@ -60,9 +60,14 @@ enum MediaKind {
_enumFromJson(values, value, 'kind'); _enumFromJson(values, value, 'kind');
} }
/// 上传用途白名单(M3 定型仅 post_image决定 objectKey 前缀)。 /// 上传用途白名单(契约 v1.4.0 三值;决定 objectKey 前缀)。
///
/// **用途即引用侧的类型检查**:引用时服务端校验 purpose 相符,故帖图不能
/// 当头像、两种头像也互不通用(不符者 404/40405)。
enum MediaPurpose { enum MediaPurpose {
postImage('post_image'); postImage('post_image'),
userAvatar('user_avatar'),
petAvatar('pet_avatar');
const MediaPurpose(this.wire); const MediaPurpose(this.wire);
@@ -628,3 +633,25 @@ class FollowStats {
final int followingCount; final int followingCount;
final bool followedByMe; final bool followedByMe;
} }
/// 我的社区数字(`GET /api/v1/me/community-stats`,契约 v1.4.0 新增)。
///
/// 两数同一集合:本人的、已发布的、未软删的帖(草稿 / 软删 / hidden /
/// archived 均不计;自己赞自己**计入**,与帖子详情的 likeCount 同口径)。
/// 空数据为 `0` 而非 null,且该端点**永不 404**——任何已认证用户都有 stats。
class CommunityStats {
const CommunityStats({
required this.receivedLikeCount,
required this.publishedPostCount,
});
factory CommunityStats.fromJson(Map<String, dynamic> json) {
return CommunityStats(
receivedLikeCount: json['receivedLikeCount'] as int,
publishedPostCount: json['publishedPostCount'] as int,
);
}
final int receivedLikeCount;
final int publishedPostCount;
}
@@ -17,7 +17,9 @@ abstract class CommunityRepository {
Future<MediaAsset> completeMediaUpload(String assetId); Future<MediaAsset> completeMediaUpload(String assetId);
// ---- 帖子 CRUD / 发布 ---- // ---- 帖子 CRUD / 发布 ----
Future<Post> createPost(CreatePostRequest request); /// [idempotencyKey]:调用方持键(T3-17 发布页「同键重放」——网络失败重试
/// 沿用同键命中服务端首帖,不重复建帖;缺省则本层每次调用换新键)。
Future<Post> createPost(CreatePostRequest request, {String? idempotencyKey});
Future<Post> getPost(String postId); Future<Post> getPost(String postId);
Future<Post> updatePost(String postId, UpdatePostRequest request); Future<Post> updatePost(String postId, UpdatePostRequest request);
Future<void> deletePost(String postId); Future<void> deletePost(String postId);
@@ -53,6 +55,9 @@ abstract class CommunityRepository {
Future<FollowState> followUser(String userId); Future<FollowState> followUser(String userId);
Future<FollowState> unfollowUser(String userId); Future<FollowState> unfollowUser(String userId);
Future<FollowStats> getFollowStats(String userId); Future<FollowStats> getFollowStats(String userId);
// ---- 我的社区数字(主体恒为调用者,路径上无 userId)----
Future<CommunityStats> getMyCommunityStats();
} }
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token /// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
@@ -85,6 +90,7 @@ class ApiCommunityRepository implements CommunityRepository {
Object? body, Object? body,
Map<String, Object?>? query, Map<String, Object?>? query,
bool idempotent = false, bool idempotent = false,
String? idempotencyKey,
bool media = false, bool media = false,
}) async { }) async {
try { try {
@@ -93,7 +99,9 @@ class ApiCommunityRepository implements CommunityRepository {
method: method, method: method,
body: body, body: body,
query: query, query: query,
headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null, headers: idempotent
? {'Idempotency-Key': idempotencyKey ?? _uuid.v4()}
: null,
requiresAuth: true, requiresAuth: true,
); );
} on ApiBusinessException catch (error) { } on ApiBusinessException catch (error) {
@@ -132,12 +140,16 @@ class ApiCommunityRepository implements CommunityRepository {
// ---- posts ---- // ---- posts ----
@override @override
Future<Post> createPost(CreatePostRequest request) async { Future<Post> createPost(
CreatePostRequest request, {
String? idempotencyKey,
}) async {
final data = await _request( final data = await _request(
'/api/v1/posts', '/api/v1/posts',
method: 'POST', method: 'POST',
body: request.toJson(), body: request.toJson(),
idempotent: true, idempotent: true,
idempotencyKey: idempotencyKey,
); );
return Post.fromJson(_asMap(data)); return Post.fromJson(_asMap(data));
} }
@@ -292,4 +304,12 @@ class ApiCommunityRepository implements CommunityRepository {
final data = await _request('/api/v1/users/$userId/follow-stats'); final data = await _request('/api/v1/users/$userId/follow-stats');
return FollowStats.fromJson(_asMap(data)); return FollowStats.fromJson(_asMap(data));
} }
// ---- 我的社区数字 ----
@override
Future<CommunityStats> getMyCommunityStats() async {
final data = await _request('/api/v1/me/community-stats');
return CommunityStats.fromJson(_asMap(data));
}
} }
+117 -11
View File
@@ -7,6 +7,7 @@ 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_compression.dart';
import 'package:patbond_flutter/features/community/media_direct_upload.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/media_picking.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
/// 单张图的上传阶段(05 号规范 §3.3 四视觉态的底层状态模型)。 /// 单张图的上传阶段(05 号规范 §3.3 四视觉态的底层状态模型)。
/// ///
@@ -76,6 +77,12 @@ class _UploadTask {
bool retryable = false; bool retryable = false;
bool cancelled = false; bool cancelled = false;
/// 本图第几次上传尝试(媒体三段埋点 attemptSeq,从 1 起;retry 递增)。
int attemptSeq = 1;
/// 本次尝试的 started 时刻(succeeded 的 durationMs 口径)。
DateTime? attemptStartedAt;
/// 压缩产物缓存(重试跳过重压缩)。 /// 压缩产物缓存(重试跳过重压缩)。
CompressedMediaImage? compressed; CompressedMediaImage? compressed;
@@ -115,12 +122,16 @@ class _UploadTask {
/// 对外可见的 [MediaUploadItem.assetId] 与 ready 态严格绑定(断言), /// 对外可见的 [MediaUploadItem.assetId] 与 ready 态严格绑定(断言),
/// [buildAttachRequests] 仅在全员 ready 时可用。 /// [buildAttachRequests] 仅在全员 ready 时可用。
/// - 预签名凭据只存内存、用完即弃,不持久化(既有纪律)。 /// - 预签名凭据只存内存、用完即弃,不持久化(既有纪律)。
/// - **媒体三段埋点**T3-17):每次尝试恰一条 started,收敛为
/// succeeded / failed 各一条;`sizeBucket` 统一取原图字节数。
class MediaUploader extends ChangeNotifier { class MediaUploader extends ChangeNotifier {
MediaUploader({ MediaUploader({
required this._repository, required this._repository,
MediaImagePicker? picker, MediaImagePicker? picker,
MediaImageCompressor? compressor, MediaImageCompressor? compressor,
MediaDirectUploadClient? directUpload, MediaDirectUploadClient? directUpload,
this._analytics,
this.purpose = MediaPurpose.postImage,
this.maxImages = 9, this.maxImages = 9,
this.maxConcurrentUploads = 2, this.maxConcurrentUploads = 2,
this.maxByteSize = 10 * 1024 * 1024, this.maxByteSize = 10 * 1024 * 1024,
@@ -135,9 +146,19 @@ class MediaUploader extends ChangeNotifier {
final MediaImagePicker _picker; final MediaImagePicker _picker;
final MediaImageCompressor _compressor; final MediaImageCompressor _compressor;
final MediaDirectUploadClient _directUpload; final MediaDirectUploadClient _directUpload;
/// 媒体上传三段埋点(T3-17 接入;未注入即不上报)。
final PostAnalytics? _analytics;
final DateTime Function() _now; final DateTime Function() _now;
/// 九宫格上限(05 号规范 §3.2)。 /// 上传用途(决定服务端 objectKey 前缀,也是引用侧的类型检查依据):
/// 发布页 `post_image`、资料页头像 `user_avatar`、宠物头像 `pet_avatar`。
/// 用途不符的 asset 在引用时被答 404/40405,所以这个值必须由调用方
/// 按场景显式给对(M3.5-08/09 起本类不再只服务发布页)。
final MediaPurpose purpose;
/// 九宫格上限(05 号规范 §3.2);头像场景传 1。
final int maxImages; final int maxImages;
final int maxConcurrentUploads; final int maxConcurrentUploads;
@@ -244,24 +265,28 @@ class MediaUploader extends ChangeNotifier {
task.errorMessage = null; task.errorMessage = null;
task.retryable = false; task.retryable = false;
task.progress = 0; task.progress = 0;
task.attemptSeq += 1;
task.phase = MediaItemPhase.queued; task.phase = MediaItemPhase.queued;
notifyListeners(); notifyListeners();
unawaited(_run(task)); unawaited(_run(task));
} }
/// 移除一张图(任意态可移除);在途请求结果一律作废,未 confirm 的 /// 移除一张图(任意态可移除);在途请求结果一律作废,未 confirm 的
/// 服务端 asset 弃引用(服务端超时清理兜底)。 /// 服务端 asset 弃引用(服务端超时清理兜底)。在途任务被移除按
/// `cancelled` 上报一条上传失败(06 §1.4「用户取消」口径)。
void remove(int localId) { void remove(int localId) {
final task = _taskOrNull(localId); final task = _taskOrNull(localId);
if (task == null) return; if (task == null) return;
_reportCancelled(task);
task.cancelled = true; task.cancelled = true;
_tasks.remove(task); _tasks.remove(task);
notifyListeners(); notifyListeners();
} }
/// 清空全部(发布成功/离开页面时调用)。 /// 清空全部(发布成功/离开页面时调用);在途任务同 [remove] 记 cancelled
void reset() { void reset() {
for (final task in _tasks) { for (final task in _tasks) {
_reportCancelled(task);
task.cancelled = true; task.cancelled = true;
} }
_tasks.clear(); _tasks.clear();
@@ -282,6 +307,12 @@ class MediaUploader extends ChangeNotifier {
await _acquireSlot(); await _acquireSlot();
try { try {
if (task.cancelled) return; if (task.cancelled) return;
// 一次尝试恰一条 started(含压缩段:压缩失败也在漏斗内可见)。
task.attemptStartedAt = _now();
_analytics?.mediaUploadStarted(
mediaType: MediaType.image,
byteSize: task.source.bytes.length,
);
final compressed = await _compress(task); final compressed = await _compress(task);
if (compressed == null || task.cancelled) return; if (compressed == null || task.cancelled) return;
await _uploadAndConfirm(task, compressed); await _uploadAndConfirm(task, compressed);
@@ -308,10 +339,20 @@ class MediaUploader extends ChangeNotifier {
} }
} }
} catch (_) { } catch (_) {
_fail(task, message: '图片处理失败', retryable: true); _fail(
task,
message: '图片处理失败',
retryable: true,
reason: MediaUploadFailureReason.unsupportedFormat,
);
return null; return null;
} }
_fail(task, message: '图片过大,压缩后仍超过 10 MB', retryable: false); _fail(
task,
message: '图片过大,压缩后仍超过 10 MB',
retryable: false,
reason: MediaUploadFailureReason.mediaTooLarge,
);
return null; return null;
} }
@@ -335,7 +376,12 @@ class MediaUploader extends ChangeNotifier {
while (true) { while (true) {
if (_credentialsExpired(credentials)) { if (_credentialsExpired(credentials)) {
if (renewed) { if (renewed) {
_fail(task, message: '上传凭据已过期', retryable: true); _fail(
task,
message: '上传凭据已过期',
retryable: true,
reason: MediaUploadFailureReason.serverError,
);
return; return;
} }
renewed = true; renewed = true;
@@ -380,6 +426,9 @@ class MediaUploader extends ChangeNotifier {
task, task,
message: error.statusCode == null ? '网络中断,上传失败' : '上传被存储服务拒绝', message: error.statusCode == null ? '网络中断,上传失败' : '上传被存储服务拒绝',
retryable: true, retryable: true,
reason: error.statusCode == null
? MediaUploadFailureReason.networkError
: MediaUploadFailureReason.serverError,
); );
return; return;
} }
@@ -398,12 +447,25 @@ class MediaUploader extends ChangeNotifier {
} }
if (task.cancelled) return; if (task.cancelled) return;
if (asset.status != MediaAssetStatus.ready) { if (asset.status != MediaAssetStatus.ready) {
_fail(task, message: '上传确认未通过', retryable: true); _fail(
task,
message: '上传确认未通过',
retryable: true,
reason: MediaUploadFailureReason.serverError,
);
return; return;
} }
task.readyAssetId = asset.id; task.readyAssetId = asset.id;
task.progress = 1; task.progress = 1;
_transition(task, MediaItemPhase.ready); _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( Future<MediaUploadCredentials> _createUpload(
@@ -412,7 +474,7 @@ class MediaUploader extends ChangeNotifier {
return _repository.createMediaUpload( return _repository.createMediaUpload(
CreateMediaUploadRequest( CreateMediaUploadRequest(
kind: MediaKind.image, kind: MediaKind.image,
purpose: MediaPurpose.postImage, purpose: purpose,
mimeType: compressed.mimeType, mimeType: compressed.mimeType,
byteSize: compressed.byteSize, byteSize: compressed.byteSize,
), ),
@@ -424,12 +486,22 @@ class MediaUploader extends ChangeNotifier {
void _failFromApi(_UploadTask task, Exception error) { void _failFromApi(_UploadTask task, Exception error) {
// 参数被服务端拒绝(40000mime/byteSize 白名单外)重试无意义,终态。 // 参数被服务端拒绝(40000mime/byteSize 白名单外)重试无意义,终态。
final retryable = final isParamError =
error is! ApiBusinessException || error.code != ApiCodes.paramError; error is ApiBusinessException && error.code == ApiCodes.paramError;
_fail( _fail(
task, task,
message: error is ApiBusinessException ? error.message : '网络异常,请重试', message: error is ApiBusinessException ? error.message : '网络异常,请重试',
retryable: retryable, 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,
); );
} }
@@ -437,14 +509,36 @@ class MediaUploader extends ChangeNotifier {
_UploadTask task, { _UploadTask task, {
required String message, required String message,
required bool retryable, required bool retryable,
required MediaUploadFailureReason? reason,
int? errorCode,
}) { }) {
if (task.cancelled) return; if (task.cancelled) return;
task.phase = MediaItemPhase.failed; task.phase = MediaItemPhase.failed;
task.errorMessage = message; task.errorMessage = message;
task.retryable = retryable; task.retryable = retryable;
if (reason != null) {
_analytics?.mediaUploadFailed(
mediaType: MediaType.image,
byteSize: task.source.bytes.length,
reason: reason,
attemptSeq: task.attemptSeq,
errorCode: errorCode,
);
}
notifyListeners(); 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) { void _transition(_UploadTask task, MediaItemPhase phase) {
if (task.cancelled) return; if (task.cancelled) return;
task.phase = phase; task.phase = phase;
@@ -469,3 +563,15 @@ class MediaUploader extends ChangeNotifier {
} }
} }
} }
/// [MediaUploader] 的构造口(发布页每次进入建一个,退出即 dispose)。
///
/// 生产缺省即 `MediaUploader(repository: ..., analytics: ...)`;注入点为
/// **测试与桌面实测专用**——Linux 桌面既无 image_picker 也无
/// flutter_image_compress 的原生实现,桌面真链路只替换选图与压缩两层,
/// 其余(createUpload / 直传 PUT / confirm)全为生产实现。
typedef MediaUploaderFactory =
MediaUploader Function(
CommunityRepository repository,
PostAnalytics? analytics,
);
+239
View File
@@ -0,0 +1,239 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'
show textLengthBucketOf;
/// post 域埋点强类型封装(06 号规划 §1.4 发布漏斗五事件 + 媒体上传三段;
/// 后端白名单随 api dev@`8089c06` 就绪,22 号报告 §1 键集逐一对齐)。
/// 沿 pet/feed/互动域惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性。
///
/// 隐私纪律(06 §1.3 红线):正文只出分桶不出字数(红线 1);postId /
/// assetId 等内容 ID 一律不进 props(红线 2);媒体只报 [MediaType] 与
/// [mediaSizeBucketOf] 分桶,文件名/路径/URL 禁止(红线 4)。
///
/// **不得上报**22 号 §1 末段锁死为 unknown):`post_impression`、
/// `post_viewed`、`post_like_failed` 等——本文件不提供其封装。
/// 发帖入口(06 §1.4 `post_create_started.entryPoint`)。
/// M3 接 create_tab / feedtopic_detail / pet_detail 随后续页面启用。
enum PostEntryPoint {
createTab('create_tab'),
feed('feed'),
topicDetail('topic_detail'),
petDetail('pet_detail');
const PostEntryPoint(this.value);
final String value;
}
/// 草稿保存触发方式(06 §1.4:**自动保存不埋**,防高频)。
enum DraftSaveTrigger {
/// 「存草稿」按钮显式保存。
manual('manual'),
/// 离开发布页时经「保留草稿?」确认保存。
onExit('on_exit');
const DraftSaveTrigger(this.value);
final String value;
}
/// 发布失败原因(06 §1.4 枚举;`content_rejected` 待拍板未启用,
/// `not_found` 取 §1.4「失败枚举基底」的复用条——草稿已被别处删除)。
enum PostPublishFailureReason {
validationError('validation_error'),
mediaUploadIncomplete('media_upload_incomplete'),
notFound('not_found'),
rateLimited('rate_limited'),
networkError('network_error'),
serverError('server_error');
const PostPublishFailureReason(this.value);
final String value;
}
/// 媒体类型(M3 仅 image;video 随视频能力启用)。
enum MediaType {
image('image'),
video('video');
const MediaType(this.value);
final String value;
}
/// 单文件上传失败原因(06 §1.4 媒体漏斗枚举)。
enum MediaUploadFailureReason {
mediaTooLarge('media_too_large'),
unsupportedFormat('unsupported_format'),
networkError('network_error'),
serverError('server_error'),
/// 用户在上传途中删格 / 离开发布页作废在途任务。
cancelled('cancelled');
const MediaUploadFailureReason(this.value);
final String value;
}
/// 类型化异常 → 发布失败原因;会话失效返回 null(应用即将回登录页,
/// 不作为发布失败上报,feed / 互动域同款口径)。
///
/// 映射取舍(26 号报告 §3 有对照表):42203 恰为
/// [PostPublishFailureReason.mediaUploadIncomplete]40905(同键异
/// payload)归 validation_error——提交内容与幂等键不一致属提交侧问题,
/// 非服务端故障;40902(乐观锁,自动刷新 version 重提仍失败)归
/// server_error 兜底。
PostPublishFailureReason? postPublishFailureReasonOf(ApiException error) =>
switch (error) {
ApiNetworkException _ => PostPublishFailureReason.networkError,
ApiRateLimitException _ => PostPublishFailureReason.rateLimited,
SessionExpiredException _ => null,
ApiBusinessException(:final code) => switch (code) {
ApiCodes.paramError || ApiCodes.idempotencyKeyMismatch =>
PostPublishFailureReason.validationError,
ApiCodes.mediaNotReady =>
PostPublishFailureReason.mediaUploadIncomplete,
ApiCodes.postNotFound ||
ApiCodes.mediaNotFound => PostPublishFailureReason.notFound,
_ => PostPublishFailureReason.serverError,
},
};
/// 媒体大小分桶(06 §1.3 红线 4:不报精确字节数)。
/// `lt_1mb` / `mb_1_5` / `mb_5_20` / `gte_20mb`,以 MiB 为界
/// (与 MediaUploader 的 10 MiB 上限同一进制)。
String mediaSizeBucketOf(int byteSize) {
const mib = 1024 * 1024;
if (byteSize < mib) return 'lt_1mb';
if (byteSize < 5 * mib) return 'mb_1_5';
if (byteSize < 20 * mib) return 'mb_5_20';
return 'gte_20mb';
}
/// 发布漏斗 + 媒体上传三段埋点(22 号白名单 v3 事件 22~29)。
class PostAnalytics {
PostAnalytics(this._track);
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
final TrackEventFn _track;
// ---- 发布漏斗 ----
/// 进入发布页并产生**首次输入**(首个字符或首次选媒体),每次进入记一次。
/// 草稿恢复不算输入(非用户动作,不上报)。
void postCreateStarted({required PostEntryPoint entryPoint}) {
_track('post_create_started', {'entryPoint': entryPoint.value});
}
/// 草稿保存**成功响应后**;仅显式保存与离开时保存(自动保存不埋)。
void postDraftSaved({
required DraftSaveTrigger trigger,
required int mediaCount,
}) {
_track('post_draft_saved', {
'trigger': trigger.value,
'mediaCount': mediaCount,
});
}
/// 发布成功响应后(漏斗事件,H5/H6 核心数据源)。
///
/// [durationMs]`post_create_started` → 发布成功;[textLength] 经
/// [textLengthBucketOf] 分桶后上报,精确字数不出端;[fromDraft] 指
/// 「本次发布基于先前保存/恢复的草稿」(发布内部的建草稿→迁移两步
/// 不算,见 26 号报告 §3)。
void postPublishSucceeded({
required int durationMs,
required int mediaCount,
required int topicCount,
required int textLength,
required bool fromDraft,
}) {
_track('post_publish_succeeded', {
'durationMs': durationMs,
'mediaCount': mediaCount,
'topicCount': topicCount,
'textLengthBucket': textLengthBucketOf(textLength),
'fromDraft': fromDraft,
});
}
/// 发布失败 / 超时 / 本地校验拦截。
///
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位业务码
/// 推导(`code ~/ 100`pet 域同款);[attemptSeq] 为本次发布会话内第几次
/// 尝试(从 1 起,发布成功或离开发布页后重置)。
void postPublishFailed({
required PostPublishFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('post_publish_failed', {
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
/// 删帖成功响应后(单事件风格,无专有属性;失败靠服务端错误率观测)。
/// M3 触点:发布页「不保留草稿」删除服务端草稿。
void postDeleted() {
_track('post_deleted', const {});
}
// ---- 媒体上传三段(逐文件)----
/// 单个文件开始上传(一次尝试恰一条;重试各记一条)。
///
/// [byteSize] 取**选图原文件**字节数,保证同一次尝试三段事件的
/// `sizeBucket` 一致(压缩产物大小不另开一套桶)。
void mediaUploadStarted({
required MediaType mediaType,
required int byteSize,
}) {
_track('post_media_upload_started', {
'mediaType': mediaType.value,
'sizeBucket': mediaSizeBucketOf(byteSize),
});
}
/// 单文件上传成功(confirm 返回 ready 后)。
/// [durationMs]:本次尝试 started → ready。
void mediaUploadSucceeded({
required MediaType mediaType,
required int byteSize,
required int durationMs,
}) {
_track('post_media_upload_succeeded', {
'mediaType': mediaType.value,
'sizeBucket': mediaSizeBucketOf(byteSize),
'durationMs': durationMs,
});
}
/// 单文件失败 / 超时 / 用户取消。
void mediaUploadFailed({
required MediaType mediaType,
required int byteSize,
required MediaUploadFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('post_media_upload_failed', {
'mediaType': mediaType.value,
'sizeBucket': mediaSizeBucketOf(byteSize),
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
}
@@ -0,0 +1,704 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_display.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:uuid/uuid.dart';
/// 发布页(P305 号规范 §2.3;T3-17 真实数据整页落地)。
///
/// 结构:媒体选择区([PostMediaEditGrid] 组装 [MediaUploader])→ 正文
/// → 类目(general / help)→ 位置占位;AppBar 三件套「取消 / 发布动态 /
/// 发布」,另置「存草稿」。
///
/// 两条提交路径(15 号后端语义 §2.4):
///
/// - **直接发布** = `createPost(status: draft)` 建草稿 → `PATCH
/// {status: published}` 迁移发布。两步而非「一步建 published」是为了让
/// 「发布失败但草稿已保存」成为事实而不是话术:迁移这一步失败时草稿
/// 已在服务端,用户内容不会丢。
/// - **存草稿退出** = 同一个 `createPost(status: draft)`(或对已有草稿
/// `PATCH`),随后离页。
///
/// 幂等纪律:建草稿的 `Idempotency-Key` 由**本页持有**——网络失败重试
/// 沿用同键(服务端命中首帖,不重复建帖);表单一经改动即换新键
/// (避免「同键异 payload」的 40905 常态化)。
///
/// 草稿管理最小实现:进页拉取「我的草稿」最新一条并恢复(05 §2.3 的
/// 「已恢复上次草稿」提示条),完整草稿列表页留待(26 号报告 §7)。
class PostComposePage extends StatefulWidget {
const PostComposePage({
required this.controller,
super.key,
this.entryPoint = PostEntryPoint.createTab,
this.analytics,
this.uploaderFactory,
this.now,
});
/// Tab 级单例(app.dart 装配):发布成功后由调用方触发 Feed 刷新。
final CommunityController controller;
/// 入口归因(`post_create_started.entryPoint`)。
final PostEntryPoint entryPoint;
/// post 域埋点(发布漏斗五事件 + 媒体三段,媒体段经 [MediaUploader])。
final PostAnalytics? analytics;
/// [MediaUploader] 构造口(测试 / 桌面实测替换选图与压缩层)。
final MediaUploaderFactory? uploaderFactory;
/// 时钟注入口(durationMs 断言用;缺省取当前时间)。
final DateTime Function()? now;
@override
State<PostComposePage> createState() => _PostComposePageState();
}
class _PostComposePageState extends State<PostComposePage> {
static const _maxContentLength = 1000;
final _contentController = TextEditingController();
final _uuid = const Uuid();
late final MediaUploader _uploader;
PostCategory _category = PostCategory.general;
/// 服务端草稿标识与乐观锁版本(建草稿成功或恢复草稿后非空)。
String? _draftPostId;
int? _draftVersion;
/// 恢复来的草稿既有媒体(uploader 只持本地选图,服务端媒体只读呈现)。
List<PostMediaItem> _draftMedia = const [];
/// 「已恢复上次草稿」提示条可见性。
bool _restoredBannerVisible = false;
/// 草稿恢复期间的输入监听抑制(恢复不是「首次输入」,不发 started)。
bool _restoring = false;
/// 本页是否基于先前保存/恢复的草稿发布(`fromDraft` 口径)。
bool _fromDraft = false;
/// 上一次同步到服务端的 ready assetId 签名(media 三态判定:
/// 与当前一致即 PATCH 缺席不动,不一致才整组替换)。
String? _syncedMediaSignature;
/// 建草稿幂等键(同键重放;表单改动即置 null 换新键)。
String? _idempotencyKey;
bool _publishing = false;
bool _savingDraft = false;
/// 发布失败横幅(页内停留供对照,不用 SnackBar)。
String? _publishErrorMessage;
/// 「草稿已保存」的伴随提示(发布失败时告知内容未丢)。
bool _draftPreservedHint = false;
/// 「已保存草稿 ✓」提示(保存动作后显示)。
bool _draftSavedHint = false;
/// 首次输入已上报 started。
bool _started = false;
DateTime? _startedAt;
/// 本页发布尝试序号(attemptSeq,从 1 起)。
int _publishAttemptSeq = 0;
CommunityController get _controller => widget.controller;
DateTime _nowValue() => (widget.now ?? DateTime.now)();
@override
void initState() {
super.initState();
_uploader = (widget.uploaderFactory ?? _defaultUploaderFactory)(
_controller.repository,
widget.analytics,
);
_uploader.addListener(_onUploaderChanged);
_contentController.addListener(_onContentChanged);
unawaited(_restoreLatestDraft());
}
static MediaUploader _defaultUploaderFactory(
CommunityRepository repository,
PostAnalytics? analytics,
) => MediaUploader(repository: repository, analytics: analytics);
@override
void dispose() {
_uploader.removeListener(_onUploaderChanged);
// 在途上传作废(未 confirm 的 asset 弃引用,服务端超时清理兜底)。
_uploader.reset();
_uploader.dispose();
_contentController.dispose();
super.dispose();
}
// ---- 输入与状态 ----
String get _content => _contentController.text.trim();
bool get _isEmptyForm =>
_content.isEmpty && _uploader.isEmpty && _draftMedia.isEmpty;
/// 发布 gating05 §2.2/§2.3 + 后端 content 必填):正文非空、
/// 在场媒体全部 ready、无在途提交。
bool get _canPublish =>
_content.isNotEmpty &&
(_uploader.isEmpty || _uploader.allReady) &&
!_publishing &&
!_savingDraft;
void _onContentChanged() {
if (_restoring) return;
_markDirty();
_reportStartedOnce();
setState(() {});
}
void _onUploaderChanged() {
if (_uploader.items.isNotEmpty) _reportStartedOnce();
_markDirty();
setState(() {});
}
/// 表单一经改动即弃用旧幂等键(下次提交换新键,杜绝 40905 常态化)。
void _markDirty() {
_idempotencyKey = null;
_draftSavedHint = false;
}
void _reportStartedOnce() {
if (_started) return;
if (_content.isEmpty && _uploader.isEmpty) return;
_started = true;
_startedAt = _nowValue();
widget.analytics?.postCreateStarted(entryPoint: widget.entryPoint);
}
// ---- 草稿恢复(最小实现:最新一条)----
Future<void> _restoreLatestDraft() async {
try {
final page = await _controller.repository.listMyPosts(
limit: 1,
status: PostStatus.draft,
);
if (!mounted || page.items.isEmpty) return;
final draft = page.items.first;
_restoring = true;
_contentController.text = draft.content;
_restoring = false;
setState(() {
_draftPostId = draft.id;
_draftVersion = draft.version;
_draftMedia = draft.media;
// ai_creation(M4 预留读侧值)不在发布页可选集内,回落 general。
_category = draft.category == PostCategory.aiCreation
? PostCategory.general
: draft.category;
_restoredBannerVisible = true;
_fromDraft = true;
_syncedMediaSignature = _mediaSignature();
});
} on ApiException {
// 草稿恢复失败静默降级为「新建」,不阻塞发布(不打扰)。
}
}
void _clearRestoredDraft() {
_restoring = true;
_contentController.clear();
_restoring = false;
setState(() {
_draftMedia = const [];
_restoredBannerVisible = false;
_idempotencyKey = null;
});
}
// ---- 媒体 ----
Future<void> _pickImages() async {
await _uploader.pickAndAdd();
if (!mounted) return;
if (_uploader.remainingSlots <= 0) {
_showSnackBar('最多可选 ${_uploader.maxImages} 张图片');
}
}
String _mediaSignature() => _uploader.items
.where((item) => item.isReady)
.map((item) => item.assetId)
.join(',');
/// 当前选图的挂接请求(全 ready 才可取;封面取首张)。
List<PostMediaAttachRequest>? _mediaAttachOrNull() =>
_uploader.isEmpty ? null : _uploader.buildAttachRequests();
// ---- 发布(建草稿 → 迁移发布)----
Future<void> _publish() async {
if (!_canPublish) return;
FocusScope.of(context).unfocus();
_publishAttemptSeq += 1;
setState(() {
_publishing = true;
_publishErrorMessage = null;
_draftPreservedHint = false;
});
final signature = _mediaSignature();
final fromDraft = _fromDraft && _draftPostId != null;
try {
if (_draftPostId == null) {
final key = _idempotencyKey ??= _uuid.v4();
final draft = await _controller.repository.createPost(
CreatePostRequest(
content: _content,
category: _category,
status: PostStatus.draft,
media: _mediaAttachOrNull(),
),
idempotencyKey: key,
);
_draftPostId = draft.id;
_draftVersion = draft.version;
_syncedMediaSignature = signature;
}
await _patchPublish(signature);
if (!mounted) return;
widget.analytics?.postPublishSucceeded(
durationMs: _elapsedSinceStart(),
mediaCount: _uploader.items.length + _keptDraftMediaCount(signature),
// 话题(TopicChip / 话题选择 sheet)无契约端点,M3 恒 0。
topicCount: 0,
textLength: _content.length,
fromDraft: fromDraft,
);
_uploader.reset();
Navigator.of(context).pop(true);
} on ApiException catch (error) {
if (!mounted) return;
_handlePublishError(error);
} finally {
if (mounted) setState(() => _publishing = false);
}
}
/// PATCH 迁移发布;乐观锁过期(40902,别处改过草稿)自动刷新 version
/// 重提一次。已发布帖重复提交为幂等 no-op(15 号 §2.4),弱网重放安全。
Future<void> _patchPublish(String signature) async {
final media = signature == _syncedMediaSignature
? null // 缺席不动(服务端媒体与本地选图一致)
: _mediaAttachOrNull() ?? const <PostMediaAttachRequest>[];
UpdatePostRequest request(int version) => UpdatePostRequest(
version: version,
content: _content,
category: _category,
publish: true,
media: media,
);
try {
await _controller.repository.updatePost(
_draftPostId!,
request(_draftVersion!),
);
} on PostVersionConflictException {
final latest = await _controller.repository.getPost(_draftPostId!);
_draftVersion = latest.version;
await _controller.repository.updatePost(
_draftPostId!,
request(latest.version),
);
}
_syncedMediaSignature = signature;
}
/// 恢复草稿的服务端既有媒体在本次发布中被保留的张数(mediaCount 口径)。
int _keptDraftMediaCount(String signature) =>
signature == _syncedMediaSignature && _uploader.isEmpty
? _draftMedia.length
: 0;
int _elapsedSinceStart() {
final startedAt = _startedAt;
if (startedAt == null) return 0;
return _nowValue().difference(startedAt).inMilliseconds;
}
void _handlePublishError(ApiException error) {
final reason = postPublishFailureReasonOf(error);
if (reason != null) {
widget.analytics?.postPublishFailed(
reason: reason,
attemptSeq: _publishAttemptSeq,
errorCode: error is ApiBusinessException ? error.code : null,
);
}
if (error is IdempotencyMismatchException) {
// 同键异 payload:弃用旧键,下次提交换新键即可成功。
_idempotencyKey = null;
}
if (error is PostNotFoundException) {
// 草稿在别处被删:解除关联,重试走全新建草稿。
_draftPostId = null;
_draftVersion = null;
_fromDraft = false;
}
setState(() {
_publishErrorMessage = postPublishErrorMessage(error);
// 草稿已在服务端 → 明确告知内容未丢(本单核心提示语义)。
_draftPreservedHint = _draftPostId != null;
});
}
// ---- 存草稿 ----
Future<bool> _saveDraft(DraftSaveTrigger trigger) async {
if (_isEmptyForm) return true;
if (_content.isEmpty) {
_showSnackBar('请先写点什么再保存草稿');
return false;
}
if (_uploader.hasBusyItem) {
_showSnackBar('图片还在上传中,请稍候再保存草稿');
return false;
}
if (_uploader.hasFailure) {
_showSnackBar('有图片上传失败,请重试或删除后再保存草稿');
return false;
}
setState(() {
_savingDraft = true;
_publishErrorMessage = null;
});
final signature = _mediaSignature();
try {
if (_draftPostId == null) {
final key = _idempotencyKey ??= _uuid.v4();
final draft = await _controller.repository.createPost(
CreatePostRequest(
content: _content,
category: _category,
status: PostStatus.draft,
media: _mediaAttachOrNull(),
),
idempotencyKey: key,
);
_draftPostId = draft.id;
_draftVersion = draft.version;
} else {
final updated = await _controller.repository.updatePost(
_draftPostId!,
UpdatePostRequest(
version: _draftVersion!,
content: _content,
category: _category,
media: signature == _syncedMediaSignature
? null
: _mediaAttachOrNull() ?? const <PostMediaAttachRequest>[],
),
);
_draftVersion = updated.version;
}
_syncedMediaSignature = signature;
_fromDraft = true;
widget.analytics?.postDraftSaved(
trigger: trigger,
mediaCount: _uploader.items.length + _keptDraftMediaCount(signature),
);
if (mounted) setState(() => _draftSavedHint = true);
return true;
} on ApiException catch (error) {
if (mounted) _showSnackBar(draftSaveErrorMessage(error));
return false;
} finally {
if (mounted) setState(() => _savingDraft = false);
}
}
/// 「不保留」:已落服务端的草稿一并软删(`post_deleted` 触点)。
Future<void> _discardDraft() async {
final draftId = _draftPostId;
if (draftId == null) return;
try {
await _controller.repository.deletePost(draftId);
widget.analytics?.postDeleted();
} on ApiException {
// 删除失败不拦住离页(草稿留在服务端,下次进页可恢复)。
}
}
// ---- 离页 ----
Future<void> _onCancel() async {
if (_publishing || _savingDraft) return;
if (_isEmptyForm) {
Navigator.of(context).pop(false);
return;
}
final choice = await showDialog<_ExitChoice>(
context: context,
builder: (context) => AlertDialog(
title: const Text('保留草稿?'),
content: const Text('保留后下次进入发布页可继续编辑。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, _ExitChoice.keepEditing),
child: const Text('继续编辑'),
),
TextButton(
onPressed: () => Navigator.pop(context, _ExitChoice.discard),
child: const Text('不保留'),
),
FilledButton(
onPressed: () => Navigator.pop(context, _ExitChoice.keep),
child: const Text('保留'),
),
],
),
);
if (!mounted || choice == null || choice == _ExitChoice.keepEditing) return;
if (choice == _ExitChoice.discard) {
await _discardDraft();
if (!mounted) return;
Navigator.of(context).pop(false);
return;
}
final saved = await _saveDraft(DraftSaveTrigger.onExit);
if (!mounted || !saved) return;
Navigator.of(context).pop(false);
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// ---- 渲染 ----
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PopScope(
canPop: _isEmptyForm && !_publishing && !_savingDraft,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_onCancel());
},
child: Scaffold(
appBar: AppBar(
leadingWidth: 76,
leading: Center(
child: TextButton(
onPressed: _publishing ? null : () => unawaited(_onCancel()),
style: TextButton.styleFrom(foregroundColor: AppColors.ink),
child: const Text('取消'),
),
),
title: const Text('发布动态'),
actions: [
TextButton(
onPressed: _publishing || _savingDraft
? null
: () => unawaited(_saveDraft(DraftSaveTrigger.manual)),
child: const Text('存草稿'),
),
const SizedBox(width: 4),
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 20),
),
onPressed: _canPublish ? () => unawaited(_publish()) : null,
child: _publishing
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('发布'),
),
const SizedBox(width: 12),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
if (_restoredBannerVisible) ...[
_RestoredDraftBanner(onClear: _clearRestoredDraft),
const SizedBox(height: 12),
],
if (_publishErrorMessage != null) ...[
InlineErrorBanner(message: _publishErrorMessage!),
if (_draftPreservedHint) ...[
const SizedBox(height: 6),
const Text(
'草稿已保存,可稍后继续发布',
style: TextStyle(fontSize: 12, color: AppColors.inkSoft),
),
],
const SizedBox(height: 12),
],
if (_draftSavedHint) ...[
const Text(
'已保存草稿 ✓',
style: TextStyle(fontSize: 12, color: AppColors.inkSoft),
),
const SizedBox(height: 12),
],
..._mediaSection(),
if (_uploader.hasBusyItem) ...[
const SizedBox(height: 8),
_UploadSummaryBar(
progress: _uploader.overallProgress,
readyCount: _uploader.readyCount,
total: _uploader.items.length,
),
],
const SizedBox(height: 16),
TextField(
controller: _contentController,
minLines: 6,
maxLines: null,
maxLength: _maxContentLength,
keyboardType: TextInputType.multiline,
decoration: const InputDecoration(
hintText: '分享毛孩子的日常,或向宠友求助…',
alignLabelWithHint: true,
),
),
const SizedBox(height: 12),
Text('分类', style: theme.textTheme.bodySmall),
const SizedBox(height: 7),
Wrap(
spacing: 8,
children: [
for (final entry in const [
(PostCategory.general, '日常分享'),
(PostCategory.help, '求助'),
])
ChoiceChip(
label: Text(entry.$2),
selected: _category == entry.$1,
onSelected: (_) {
_markDirty();
setState(() => _category = entry.$1);
},
),
],
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.location_on_outlined),
title: const Text('添加位置(选填)'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showSnackBar('位置功能即将上线'),
),
],
),
),
);
}
List<Widget> _mediaSection() {
final showDraftMedia = _uploader.isEmpty && _draftMedia.isNotEmpty;
return [
PostMediaEditGrid(
items: _uploader.items,
canAdd: _uploader.remainingSlots > 0,
onAdd: _uploader.isPicking ? null : () => unawaited(_pickImages()),
onRemove: _uploader.remove,
onRetry: _uploader.retry,
),
if (showDraftMedia) ...[
const SizedBox(height: 8),
Text(
'草稿已含 ${_draftMedia.length} 张图片(发布时保留;重新选图将整组替换)',
style: const TextStyle(fontSize: 12, color: AppColors.inkSoft),
),
],
];
}
}
enum _ExitChoice { keep, discard, keepEditing }
/// 「已恢复上次草稿」提示条(05 §2.3:surfaceTint 底、圆角 sm12)。
class _RestoredDraftBanner extends StatelessWidget {
const _RestoredDraftBanner({required this.onClear});
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
decoration: const BoxDecoration(
color: AppColors.surfaceTint,
borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)),
),
child: Row(
children: [
const Expanded(
child: Text(
'已恢复上次草稿',
style: TextStyle(fontSize: 12, color: AppColors.primaryDark),
),
),
TextButton(onPressed: onClear, child: const Text('清空')),
],
),
);
}
}
/// 页级上传汇总条(05 §3.3 末段:线性进度 + 「正在上传 n/N」)。
class _UploadSummaryBar extends StatelessWidget {
const _UploadSummaryBar({
required this.progress,
required this.readyCount,
required this.total,
});
final double progress;
final int readyCount;
final int total;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
'正在上传 $readyCount/$total',
style: const TextStyle(fontSize: 12, color: AppColors.inkSoft),
),
const SizedBox(width: 10),
Expanded(
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
color: AppColors.primaryStrong,
backgroundColor: AppColors.surfaceTint,
),
),
],
);
}
}
+46 -30
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 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media
/// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写
/// `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(
@@ -402,6 +389,35 @@ class _CreatePageState extends State<CreatePage> {
} }
} }
/// 真实发布入口(T3-17):本页其余部分是 AI 创作模拟(M4),社区发帖
/// 走这里 push 的发布页。
class _ComposeEntryCard extends StatelessWidget {
const _ComposeEntryCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SectionCard(
padding: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: const CircleAvatar(
backgroundColor: AppColors.surfaceTint,
child: Icon(Icons.edit_outlined, color: AppColors.primary),
),
title: const Text(
'发布动态',
style: TextStyle(fontWeight: FontWeight.w800),
),
subtitle: const Text('写点文字、配上照片,分享给宠友'),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _UploadCard extends StatelessWidget { class _UploadCard extends StatelessWidget {
const _UploadCard({ const _UploadCard({
required this.uploaded, required this.uploaded,
+59 -10
View File
@@ -10,6 +10,7 @@ import 'package:patbond_flutter/features/community/community_display.dart';
import 'package:patbond_flutter/features/community/community_models.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_analytics.dart';
import 'package:patbond_flutter/features/community/feed_exposure.dart'; import 'package:patbond_flutter/features/community/feed_exposure.dart';
import 'package:patbond_flutter/features/profile/profile_controller.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';
@@ -17,15 +18,27 @@ import 'package:patbond_flutter/widgets/common.dart';
enum HomeSegment { feed, services } enum HomeSegment { feed, services }
/// TabFeed T3-14 [CommunityController] /// TabFeed T3-14 [CommunityController]
/// + + /// /// + + T3.5-10
/// `_StoryRow` demo 03 §1.1 ///
/// ** demo **ADR-022 D3.5-1
///
/// - ****[_WeatherStatusBar] / [_AreaPickerSheet] /
/// [_WeatherDetailsSheet] API key
///
/// - **story / / **[_StoryRow]
/// ADR-018 M3
/// - ** ¥20**[_PromoCard] M5
/// /
/// - ****
/// demo M5
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({ const HomePage({
required this.appState, required this.appState,
required this.communityController, required this.communityController,
required this.onOpenServices, required this.onOpenServices,
required this.onOpenCreate, required this.onOpenCompose,
super.key, super.key,
this.profileController,
this.onOpenPost, this.onOpenPost,
this.feedAnalytics, this.feedAnalytics,
this.isActive = true, this.isActive = true,
@@ -36,8 +49,16 @@ class HomePage extends StatefulWidget {
/// Feed Tab app.dart /// Feed Tab app.dart
final CommunityController communityController; final CommunityController communityController;
/// T3.5-10****
/// `/me`
///
final ProfileController? profileController;
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 /// T3-15 push PostDetailPage
final ValueChanged<String>? onOpenPost; final ValueChanged<String>? onOpenPost;
@@ -76,6 +97,8 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
// /
widget.profileController?.addListener(_onProfileChanged);
// pets reset initial // pets reset initial
// refreshCount // refreshCount
if (_feed.phase == FeedPhase.initial) { if (_feed.phase == FeedPhase.initial) {
@@ -84,6 +107,10 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
if (_feedSurfaceVisible) _startSegment(); if (_feedSurfaceVisible) _startSegment();
} }
void _onProfileChanged() {
if (mounted) setState(() {});
}
@override @override
void didUpdateWidget(HomePage oldWidget) { void didUpdateWidget(HomePage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -111,6 +138,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_settleSegment(); _settleSegment();
widget.profileController?.removeListener(_onProfileChanged);
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@@ -338,7 +366,10 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
const SizedBox(height: 12), const SizedBox(height: 12),
_PetGreetingCard( _PetGreetingCard(
greeting: greeting, greeting: greeting,
petName: widget.appState.pet.name, // T3.5-10`nickname ?? username`
// demo
// null
displayName: widget.profileController?.displayName,
petAvatar: widget.appState.pet.avatarUrl, petAvatar: widget.appState.pet.avatarUrl,
advice: widget.appState.locationWeather.petAdvice, advice: widget.appState.locationWeather.petAdvice,
), ),
@@ -370,7 +401,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
if (segment == HomeSegment.feed) ...[ if (segment == HomeSegment.feed) ...[
_StoryRow(onCreate: widget.onOpenCreate), _StoryRow(onCreate: widget.onOpenCompose),
const SizedBox(height: 18), const SizedBox(height: 18),
_PromoCard(onTap: () => widget.onOpenServices(true)), _PromoCard(onTap: () => widget.onOpenServices(true)),
const SizedBox(height: 18), const SizedBox(height: 18),
@@ -459,7 +490,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
title: '还没有动态', title: '还没有动态',
description: '关注的毛孩子们还没发帖,去逛逛话题吧', description: '关注的毛孩子们还没发帖,去逛逛话题吧',
ctaLabel: '发布第一条', ctaLabel: '发布第一条',
onCtaPressed: widget.onOpenCreate, onCtaPressed: widget.onOpenCompose,
), ),
]; ];
} }
@@ -549,6 +580,8 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
} }
} }
/// ** demo **ADR-022 D3.5-1
/// API key demo
class _WeatherStatusBar extends StatelessWidget { class _WeatherStatusBar extends StatelessWidget {
const _WeatherStatusBar({ const _WeatherStatusBar({
required this.weather, required this.weather,
@@ -638,14 +671,23 @@ class _WeatherStatusBar extends StatelessWidget {
class _PetGreetingCard extends StatelessWidget { class _PetGreetingCard extends StatelessWidget {
const _PetGreetingCard({ const _PetGreetingCard({
required this.greeting, required this.greeting,
required this.petName, required this.displayName,
required this.petAvatar, required this.petAvatar,
required this.advice, required this.advice,
}); });
final String greeting; final String greeting;
final String petName;
/// null 退
final String? displayName;
/// ** demo ** demo
///
/// ADR-022
final String petAvatar; final String petAvatar;
/// ** demo ** demo
/// ADR-022 D3.5-1
final String advice; final String advice;
@override @override
@@ -688,7 +730,9 @@ class _PetGreetingCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'$greeting$petName 👋', displayName == null
? '$greeting 👋'
: '$greeting$displayName 👋',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
@@ -883,6 +927,9 @@ Color _weatherColor(WeatherCondition condition) {
}; };
} }
/// ** demo **ADR-022 D3.5-1 / /
/// ****ADR-018 M3
/// T3-17 push
class _StoryRow extends StatelessWidget { class _StoryRow extends StatelessWidget {
const _StoryRow({required this.onCreate}); const _StoryRow({required this.onCreate});
@@ -953,6 +1000,8 @@ class _StoryRow extends StatelessWidget {
} }
} }
/// ** demo **ADR-022 D3.5-1使
/// **M5 **
class _PromoCard extends StatelessWidget { class _PromoCard extends StatelessWidget {
const _PromoCard({required this.onTap}); const _PromoCard({required this.onTap});
+56 -5
View File
@@ -1,10 +1,16 @@
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/analytics_page_name.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.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/core/widgets/avatar_upload_sheet.dart';
import 'package:patbond_flutter/features/community/community_controller.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_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/feed_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/health_record_analytics.dart';
@@ -12,6 +18,7 @@ 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_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_controller.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/state/app_state.dart'; import 'package:patbond_flutter/state/app_state.dart';
@@ -22,12 +29,16 @@ class MainShellPage extends StatefulWidget {
required this.appState, required this.appState,
required this.petsController, required this.petsController,
required this.communityController, required this.communityController,
required this.profileController,
super.key, super.key,
this.currentUserId, this.currentUserId,
this.petAnalytics, this.petAnalytics,
this.healthRecordAnalytics, this.healthRecordAnalytics,
this.feedAnalytics, this.feedAnalytics,
this.interactionAnalytics, this.interactionAnalytics,
this.postAnalytics,
this.mediaUploaderFactory,
this.avatarUploaderBuilder,
this.pageViewTracker, this.pageViewTracker,
this.onLogout, this.onLogout,
}); });
@@ -40,6 +51,9 @@ class MainShellPage extends StatefulWidget {
/// T3-12 Feed segment T3-14 线 /// T3-12 Feed segment T3-14 线
final CommunityController communityController; final CommunityController communityController;
/// T3.5-08 Tab
final ProfileController profileController;
/// id / UI /// id / UI
final String? currentUserId; final String? currentUserId;
@@ -55,6 +69,15 @@ class MainShellPage extends StatefulWidget {
/// T3-16 + /// T3-16 +
final CommunityInteractionAnalytics? interactionAnalytics; final CommunityInteractionAnalytics? interactionAnalytics;
/// post T3-17 +
final PostAnalytics? postAnalytics;
/// [MediaUploader] /
final MediaUploaderFactory? mediaUploaderFactory;
/// T3.5-08/09
final AvatarUploaderBuilder? avatarUploaderBuilder;
/// Tab IndexedStack 03 §3.2 /// Tab IndexedStack 03 §3.2
final PageViewTracker? pageViewTracker; final PageViewTracker? pageViewTracker;
@@ -117,6 +140,28 @@ class _MainShellPageState extends State<MainShellPage> {
); );
} }
/// 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(
@@ -132,28 +177,34 @@ class _MainShellPageState extends State<MainShellPage> {
HomePage( HomePage(
appState: widget.appState, appState: widget.appState,
communityController: widget.communityController, communityController: widget.communityController,
profileController: widget.profileController,
feedAnalytics: widget.feedAnalytics, feedAnalytics: widget.feedAnalytics,
isActive: currentIndex == 0, isActive: currentIndex == 0,
onOpenServices: openServices, onOpenServices: openServices,
onOpenCreate: () => selectTab(1), onOpenCompose: () => openCompose(PostEntryPoint.feed),
onOpenPost: openPost, onOpenPost: openPost,
), ),
CreatePage( CreatePage(
appState: widget.appState, appState: widget.appState,
// demo 退T3-15demo // T3-17 pushAI M4
// Feed T3-17 onOpenCompose: () => openCompose(PostEntryPoint.createTab),
onPublished: (_) => selectTab(0),
), ),
PetsPage( PetsPage(
controller: widget.petsController, controller: widget.petsController,
analytics: widget.petAnalytics, analytics: widget.petAnalytics,
healthAnalytics: widget.healthRecordAnalytics, healthAnalytics: widget.healthRecordAnalytics,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
), ),
ServicesPage( ServicesPage(
showPersonal: showPersonalServices, showPersonal: showPersonalServices,
locationWeather: widget.appState.locationWeather, locationWeather: widget.appState.locationWeather,
), ),
ProfilePage(appState: widget.appState, onLogout: widget.onLogout), ProfilePage(
appState: widget.appState,
controller: widget.profileController,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
onLogout: widget.onLogout,
),
]; ];
return Scaffold( return Scaffold(
+13 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart';
@@ -265,14 +266,22 @@ class _CareReminderFormPageState extends State<CareReminderFormPage> {
_dueDate == null ? '未选择' : dateToJson(_dueDate!), _dueDate == null ? '未选择' : dateToJson(_dueDate!),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime.now(),
color: AppColors.muted, lastDate: DateTime(DateTime.now().year + 5),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() {
_dueDate = value;
_dueError = null;
});
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _dueDate ?? now, initialDate: _dueDate ?? now,
firstDate: now, firstDate: now,
+7 -1
View File
@@ -3,6 +3,7 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
@@ -484,7 +485,7 @@ class _CompleteDialogState extends State<_CompleteDialog> {
), ),
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _date, initialDate: _date,
firstDate: DateTime(1990), firstDate: DateTime(1990),
@@ -494,6 +495,11 @@ class _CompleteDialogState extends State<_CompleteDialog> {
setState(() => _date = value); setState(() => _date = value);
} }
}, },
trailing: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime.now(),
onToday: (value) => setState(() => _date = value),
),
), ),
], ],
), ),
+10 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart';
@@ -269,14 +270,19 @@ class _HealthEventFormPageState extends State<HealthEventFormPage> {
dateToJson(_occurredDate), dateToJson(_occurredDate),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime(1990),
color: AppColors.muted, lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _occurredDate = value);
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _occurredDate, initialDate: _occurredDate,
firstDate: DateTime(1990), firstDate: DateTime(1990),
@@ -125,6 +125,27 @@ String healthEventMonthHeader(DateTime occurredAt) {
return '${local.year}${local.month}'; return '${local.year}${local.month}';
} }
/// M3.5-03
///
///
/// 2026-04 ¥0
/// `summary.monthlyExpense.month` ISO year-month `2026-09`
/// `tz`
///
/// [month] `2026-09` 4~5 9
/// 2026-09
/// 1 12 退
String monthlyExpenseCardLabel(String month, {DateTime? now}) {
final match = RegExp(r'^(\d{4})-(\d{2})$').firstMatch(month);
if (match == null) return '本月花费';
final year = int.parse(match.group(1)!);
final monthNo = int.parse(match.group(2)!);
if (monthNo < 1 || monthNo > 12) return '本月花费';
final currentYear = (now ?? DateTime.now()).year;
if (year != currentYear) return '$year/$monthNo 花费';
return '$monthNo 月花费';
}
/// summary `tz` `+08:00` /// summary `tz` `+08:00`
/// Flutter IANA /// Flutter IANA
String tzOffsetQueryValue(Duration offset) { String tzOffsetQueryValue(Duration offset) {
+170 -32
View File
@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/avatar_upload_sheet.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/pets/care_reminders_page.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_events_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
@@ -43,6 +45,7 @@ class PetDetailPage extends StatefulWidget {
super.key, super.key,
this.analytics, this.analytics,
this.healthAnalytics, this.healthAnalytics,
this.avatarUploaderBuilder,
}); });
final PetsController controller; final PetsController controller;
@@ -50,6 +53,10 @@ class PetDetailPage extends StatefulWidget {
final PetAnalytics? analytics; final PetAnalytics? analytics;
final HealthRecordAnalytics? healthAnalytics; final HealthRecordAnalytics? healthAnalytics;
/// T3.5-09null
/// `app.dart`
final AvatarUploaderBuilder? avatarUploaderBuilder;
@override @override
State<PetDetailPage> createState() => _PetDetailPageState(); State<PetDetailPage> createState() => _PetDetailPageState();
} }
@@ -186,6 +193,109 @@ class _PetDetailPageState extends State<PetDetailPage> {
/// WRITEowner + caregiverviewer /// WRITEowner + caregiverviewer
bool get _canWriteRecords => _pet?.myRole != PetRole.viewer; bool get _canWriteRecords => _pet?.myRole != PetRole.viewer;
/// **WRITE**ADR-022 D3.5-3
/// /owner + caregiver viewer
///
/// [_canEdit]MANAGE owner****
/// avatarAssetId WRITE
/// MANAGE PATCH
bool get _canEditAvatar =>
_phase == _DetailPhase.ready &&
_pet?.myRole != PetRole.viewer &&
widget.avatarUploaderBuilder != null;
/// /
Future<void> _onAvatarTap() async {
final pet = _pet;
if (pet == null || !_canEditAvatar) return;
if (pet.avatarUrl == null) {
await _uploadAvatar();
return;
}
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('更换头像'),
onTap: () => Navigator.pop(context, 'replace'),
),
ListTile(
leading: const Icon(
Icons.delete_outline,
color: AppColors.errorDark,
),
title: const Text(
'移除头像',
style: TextStyle(color: AppColors.errorDark),
),
onTap: () => Navigator.pop(context, 'clear'),
),
],
),
),
);
if (!mounted || action == null) return;
if (action == 'replace') {
await _uploadAvatar();
} else {
// null= JSON
await _patchAvatar(const PatchField<String>.clear(), '已移除头像');
}
}
Future<void> _uploadAvatar() async {
final builder = widget.avatarUploaderBuilder;
if (builder == null) return;
final assetId = await showAvatarUploadSheet(
context,
builder: builder,
// 404/40405
purpose: MediaPurpose.petAvatar,
);
if (assetId == null || !mounted) return;
await _patchAvatar(PatchField<String>.value(assetId), '头像已更新');
}
/// PATCH `version` + `avatarAssetId`**
/// ** WRITE MANAGEcaregiver
/// 403
Future<void> _patchAvatar(PatchField<String> intent, String okMessage) async {
final pet = _pet;
if (pet == null) return;
try {
final updated = await widget.controller.updatePet(
pet.id,
UpdatePetRequest(version: pet.version, avatarAssetId: intent),
);
if (!mounted) return;
setState(() {
_pet = updated;
_phase = _DetailPhase.ready;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(okMessage)));
} on PetVersionConflictException catch (error) {
// 40902 version
//
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petAvatarSaveErrorMessage(error))));
await _load();
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petAvatarSaveErrorMessage(error))));
}
}
Future<void> _openWeights(Pet pet) async { Future<void> _openWeights(Pet pet) async {
await Navigator.of(context).push( await Navigator.of(context).push(
// v2 06 §5.2 4 // v2 06 §5.2 4
@@ -306,11 +416,14 @@ class _PetDetailPageState extends State<PetDetailPage> {
children: [ children: [
Column( Column(
children: [ children: [
// T3.5-09 URL退
// MediaUploader pet_avatarWRITE
PetAvatar( PetAvatar(
size: PetAvatarSize.xl, size: PetAvatarSize.xl,
showEditBadge: _canEdit, url: pet.avatarUrl,
onTap: _canEdit ? _openEdit : null, showEditBadge: _canEditAvatar,
semanticLabel: _canEdit ? '编辑宠物资料' : null, onTap: _canEditAvatar ? _onAvatarTap : null,
semanticLabel: _canEditAvatar ? '更换宠物头像' : null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall), Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
@@ -532,7 +645,9 @@ class _PetDetailPageState extends State<PetDetailPage> {
// monthlyExpense null // monthlyExpense null
value: '¥${formatCentsAsYuan(expense.amountCents)}', value: '¥${formatCentsAsYuan(expense.amountCents)}',
emphasized: expense.amountCents > 0, emphasized: expense.amountCents > 0,
label: '本月花费', // M3.5-03
//
label: monthlyExpenseCardLabel(expense.month),
onTap: () => _openTimeline(pet), onTap: () => _openTimeline(pet),
), ),
), ),
@@ -545,6 +660,13 @@ class _PetDetailPageState extends State<PetDetailPage> {
/// stat-card + 15/w800 + 12 inkSoft /// stat-card + 15/w800 + 12 inkSoft
/// inkSoft /// inkSoft
///
/// M3.5-03[onTap] `chevron_right`
/// 沿
/// / `chevron_right`
/// [MergeSemantics] + InkWell button
/// ¥09 tap
/// InkWell `excludeSemantics`
class _SummaryCard extends StatelessWidget { class _SummaryCard extends StatelessWidget {
const _SummaryCard({ const _SummaryCard({
required this.icon, required this.icon,
@@ -564,35 +686,51 @@ class _SummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return MergeSemantics(
child: InkWell( child: Card(
onTap: onTap, child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.xl), onTap: onTap,
child: Padding( borderRadius: BorderRadius.circular(AppRadius.xl),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), child: Padding(
child: Column( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Icon(icon, size: 18, color: iconColor), children: [
const SizedBox(height: 8), Row(
Text( children: [
value, Icon(icon, size: 18, color: iconColor),
maxLines: 1, const Spacer(),
overflow: TextOverflow.ellipsis, if (onTap != null)
style: TextStyle( const Icon(
color: emphasized ? AppColors.ink : AppColors.inkSoft, Icons.chevron_right,
fontSize: emphasized ? 15 : 13, size: 16,
fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600, color: AppColors.muted,
),
],
), ),
), const SizedBox(height: 8),
const SizedBox(height: 4), Text(
Text( value,
label, maxLines: 1,
maxLines: 1, overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, style: TextStyle(
style: const TextStyle(color: AppColors.inkSoft, fontSize: 11), 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,
),
),
],
),
), ),
), ),
), ),
+18
View File
@@ -54,3 +54,21 @@ String petLoadErrorMessage(ApiException? error) => switch (error) {
ApiRateLimitException _ => '请求过于频繁,请稍后再试', ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '加载失败,请稍后重试', _ => '加载失败,请稍后重试',
}; };
/// T3.5-09 v1.4.0
/// `PATCH /pets/{petId}`
///
/// - 404/40405asset / / / ****
/// 40401
/// - 422/42203 `pet_avatar` asset uploading/failed
/// - 403/40300viewer myRole
/// - 409/40902
String petAvatarSaveErrorMessage(ApiException? error) => switch (error) {
ApiBusinessException(code: ApiCodes.mediaNotFound) => '头像已失效,请重新上传',
ApiBusinessException(code: ApiCodes.mediaNotReady) => '头像还没上传完,请稍后重试',
ApiBusinessException(code: ApiCodes.petAccessDenied) => '你没有修改该宠物头像的权限',
ApiBusinessException(code: ApiCodes.versionConflict) => '档案已被更新,请重新操作',
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '头像保存失败,请稍后重试',
};
+10 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
@@ -659,14 +660,19 @@ class _PetFormPageState extends State<PetFormPage> {
_birthDate == null ? '未填写' : dateToJson(_birthDate!), _birthDate == null ? '未填写' : dateToJson(_birthDate!),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime(1990),
color: AppColors.muted, lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _birthDate = value);
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _birthDate ?? DateTime(now.year - 1, now.month), initialDate: _birthDate ?? DateTime(now.year - 1, now.month),
firstDate: DateTime(1990), firstDate: DateTime(1990),
+45 -16
View File
@@ -1,8 +1,11 @@
/// pets / 稿 openapi.yaml v1.2.0 /// pets / 稿 openapi.yaml v1.4.0
/// [FormatException] /// [FormatException]
/// 便 /// 便
library; library;
import 'package:patbond_flutter/core/models/patch_field.dart';
export 'package:patbond_flutter/core/models/patch_field.dart';
export 'package:patbond_flutter/core/models/cursor_page.dart'; export 'package:patbond_flutter/core/models/cursor_page.dart';
/// ///
@@ -143,6 +146,7 @@ class Pet {
required this.microchipNo, required this.microchipNo,
required this.sterilizedOn, required this.sterilizedOn,
required this.status, required this.status,
required this.avatarUrl,
required this.myRole, required this.myRole,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
@@ -164,6 +168,9 @@ class Pet {
microchipNo: json['microchipNo'] as String?, microchipNo: json['microchipNo'] as String?,
sterilizedOn: _dateOrNull(json['sterilizedOn']), sterilizedOn: _dateOrNull(json['sterilizedOn']),
status: PetStatus.fromJson(json['status'] as String), status: PetStatus.fromJson(json['status'] as String),
// v1.4.0 GET URL
// ** avatarAssetId**
avatarUrl: json['avatarUrl'] as String?,
myRole: PetRole.fromJson(json['myRole'] as String), myRole: PetRole.fromJson(json['myRole'] as String),
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String),
@@ -184,6 +191,11 @@ class Pet {
final String? microchipNo; final String? microchipNo;
final DateTime? sterilizedOn; final DateTime? sterilizedOn;
final PetStatus status; final PetStatus status;
/// GET URL / asset ready / null
/// null assetId
final String? avatarUrl;
final PetRole myRole; final PetRole myRole;
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
@@ -230,8 +242,17 @@ class CreatePetRequest {
}; };
} }
/// null /// species
/// species version /// version
///
/// **** [avatarAssetId] 沿 M2
/// null null CHECK
/// [avatarAssetId] schema **** v1.4.0
///
///
/// ****ADR-022 + `requiredLevel`
/// [avatarAssetId] WRITEowner + caregiver
/// MANAGE owner
class UpdatePetRequest { class UpdatePetRequest {
const UpdatePetRequest({ const UpdatePetRequest({
required this.version, required this.version,
@@ -245,6 +266,7 @@ class UpdatePetRequest {
this.microchipNo, this.microchipNo,
this.sterilizedOn, this.sterilizedOn,
this.status, this.status,
this.avatarAssetId = const PatchField<String>.absent(),
}); });
final int version; final int version;
@@ -259,19 +281,26 @@ class UpdatePetRequest {
final DateTime? sterilizedOn; final DateTime? sterilizedOn;
final PetStatus? status; final PetStatus? status;
Map<String, Object?> toJson() => { /// asset`purpose` `pet_avatar`
'version': version, final PatchField<String> avatarAssetId;
if (name != null) 'name': name,
if (breedId != null) 'breedId': breedId, Map<String, Object?> toJson() {
if (customBreedName != null) 'customBreedName': customBreedName, final json = <String, Object?>{
if (sex != null) 'sex': sex!.name, 'version': version,
if (birthDate != null) 'birthDate': dateToJson(birthDate!), if (name != null) 'name': name,
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated, if (breedId != null) 'breedId': breedId,
if (personality != null) 'personality': personality, if (customBreedName != null) 'customBreedName': customBreedName,
if (microchipNo != null) 'microchipNo': microchipNo, if (sex != null) 'sex': sex!.name,
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!), if (birthDate != null) 'birthDate': dateToJson(birthDate!),
if (status != null) 'status': status!.name, 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,
};
avatarAssetId.writeTo(json, 'avatarAssetId');
return json;
}
} }
/// ///
+8 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.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/core/widgets/avatar_upload_sheet.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
@@ -25,6 +26,7 @@ class PetsPage extends StatefulWidget {
super.key, super.key,
this.analytics, this.analytics,
this.healthAnalytics, this.healthAnalytics,
this.avatarUploaderBuilder,
}); });
final PetsController controller; final PetsController controller;
@@ -33,6 +35,9 @@ class PetsPage extends StatefulWidget {
/// health_record T2-13 /// health_record T2-13
final HealthRecordAnalytics? healthAnalytics; final HealthRecordAnalytics? healthAnalytics;
/// T3.5-09
final AvatarUploaderBuilder? avatarUploaderBuilder;
@override @override
State<PetsPage> createState() => _PetsPageState(); State<PetsPage> createState() => _PetsPageState();
} }
@@ -75,6 +80,7 @@ class _PetsPageState extends State<PetsPage> {
petId: pet.id, petId: pet.id,
analytics: widget.analytics, analytics: widget.analytics,
healthAnalytics: widget.healthAnalytics, healthAnalytics: widget.healthAnalytics,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
), ),
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName), settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
), ),
@@ -189,7 +195,8 @@ class _PetCard extends StatelessWidget {
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
child: Row( child: Row(
children: [ children: [
const PetAvatar(size: PetAvatarSize.lg), // T3.5-09 URL退
PetAvatar(size: PetAvatarSize.lg, url: pet.avatarUrl),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
+12 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart';
@@ -503,14 +504,21 @@ class _VaccinationFormPageState extends State<VaccinationFormPage> {
value == null ? '未选择' : dateToJson(value), value == null ? '未选择' : dateToJson(value),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime(1990),
color: AppColors.muted, lastDate: allowFuture
? DateTime(DateTime.now().year + 5)
: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
onPicked(value);
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final picked = await showDatePicker( final picked = await pickAppDate(
context: context, context: context,
initialDate: value ?? now, initialDate: value ?? now,
firstDate: DateTime(1990), firstDate: DateTime(1990),
@@ -3,6 +3,7 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.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/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
@@ -514,9 +515,19 @@ class _CompleteVaccinationDialogState
value == null ? '未选择' : dateToJson(value), value == null ? '未选择' : dateToJson(value),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: allowFuture
? DateTime(DateTime.now().year + 5)
: DateTime.now(),
onToday: (picked) => setState(() {
onPicked(picked);
_dateError = null;
}),
),
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final picked = await showDatePicker( final picked = await pickAppDate(
context: context, context: context,
initialDate: value ?? now, initialDate: value ?? now,
firstDate: DateTime(1990), firstDate: DateTime(1990),
+10 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart';
@@ -241,14 +242,19 @@ class _WeightFormPageState extends State<WeightFormPage> {
dateToJson(_measuredDate), dateToJson(_measuredDate),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime(1990),
color: AppColors.muted, lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _measuredDate = value);
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _measuredDate, initialDate: _measuredDate,
firstDate: DateTime(1990), firstDate: DateTime(1990),
@@ -0,0 +1,137 @@
import 'package:flutter/foundation.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/auth_repository.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
/// 沿 pets/community
enum ProfileLoadPhase { initial, loading, ready, error }
/// `/me` ****
/// error
enum ProfileStatsPhase { loading, ready, error }
/// T3.5-08
///
///
/// - `GET /api/v1/me` / error
/// - `GET /api/v1/me/community-stats` /
/// - `GET /api/v1/users/{me}/follow-stats` /
///
/// T3.5-10 [displayName]
/// `/me`
class ProfileController extends ChangeNotifier {
ProfileController({
required AuthRepository authRepository,
required CommunityRepository communityRepository,
}) : _auth = authRepository,
_community = communityRepository;
final AuthRepository _auth;
final CommunityRepository _community;
ProfileLoadPhase _phase = ProfileLoadPhase.initial;
ProfileStatsPhase _statsPhase = ProfileStatsPhase.loading;
UserProfile? _profile;
CommunityStats? _communityStats;
FollowStats? _followStats;
ApiException? _lastError;
bool _disposed = false;
ProfileLoadPhase get phase => _phase;
ProfileStatsPhase get statsPhase => _statsPhase;
/// ready null
UserProfile? get profile => _profile;
CommunityStats? get communityStats => _communityStats;
FollowStats? get followStats => _followStats;
ApiException? get lastError => _lastError;
/// `nickname ?? username` null
String? get displayName => _profile?.displayName;
/// / error +
///
/// ****`_phase` ready
///
Future<void> refresh() async {
if (_profile == null) {
_phase = ProfileLoadPhase.loading;
}
_lastError = null;
_notify();
try {
_profile = await _auth.me();
_phase = ProfileLoadPhase.ready;
} on ApiException catch (error) {
_lastError = error;
if (_profile == null) {
_phase = ProfileLoadPhase.error;
_notify();
return;
}
}
_notify();
await refreshStats();
}
///
Future<void> refreshStats() async {
final userId = _profile?.userId;
if (userId == null) return;
_statsPhase = ProfileStatsPhase.loading;
_notify();
try {
// error
// +
//
final community = await _community.getMyCommunityStats();
final follow = await _community.getFollowStats(userId);
_communityStats = community;
_followStats = follow;
_statsPhase = ProfileStatsPhase.ready;
} on ApiException {
_statsPhase = ProfileStatsPhase.error;
}
_notify();
}
/// [UpdateMeRequest]
///
/// ** patch ** patch 400/40000
/// 200
/// `avatarUrl`
///
Future<UserProfile?> save(UpdateMeRequest request) async {
if (request.isEmpty) return _profile;
final updated = await _auth.updateMe(request);
_profile = updated;
_phase = ProfileLoadPhase.ready;
_notify();
return updated;
}
/// initial /
/// 沿 `PetsController.reset`
void reset() {
_phase = ProfileLoadPhase.initial;
_statsPhase = ProfileStatsPhase.loading;
_profile = null;
_communityStats = null;
_followStats = null;
_lastError = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
/// /沿 `petLoadErrorMessage`
/// message
String profileLoadErrorMessage(ApiException? error) => switch (error) {
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '加载失败,请稍后重试',
};
/// v1.4.0 `PATCH /api/v1/me`
///
/// | | | |
/// | --- | --- | --- |
/// | 40000 | / patch UUID | |
/// | 40405 | asset /// | |
/// | 42203 | asset uploading/failed | |
///
/// 40000 / patch /
/// assetId 40000
///
String profileSaveErrorMessage(ApiException? error) => switch (error) {
ApiBusinessException(code: ApiCodes.paramError) => '填写内容不符合要求,请检查后重试',
ApiBusinessException(code: ApiCodes.mediaNotFound) => '头像已失效,请重新上传',
ApiBusinessException(code: ApiCodes.mediaNotReady) => '头像还没上传完,请稍后重试',
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '保存失败,请稍后重试',
};
/// `ck_users_nickname`btrim + 1~32
///
/// ****`String.characters` `runes`
/// `char_length` UTF-16 `String.length` 32 emoji
/// UTF-16 64
///
/// null errorText
String? validateNickname(String raw) {
// btrim btrim trim
final trimmed = raw.trim();
if (trimmed.isEmpty) {
// 400/40000
return '昵称不能为空,如需清除请用下方「清除昵称」';
}
if (trimmed.runes.length > 32) return '昵称最多 32 个字';
return null;
}
+335
View File
@@ -0,0 +1,335 @@
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/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.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_models.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_display.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// T3.5-08 +
///
/// **PATCH ** v1.4.0 `UpdateMeRequest`
///
/// | | |
/// | --- | --- |
/// | | `nickname` **** |
/// | | `nickname: "新值"` |
/// | | `nickname: null` |
/// | | `avatarAssetId` **** |
/// | | `avatarAssetId: "<ready assetId>"` |
/// | | `avatarAssetId: null` |
///
/// JSON ** vs null**
/// null
/// ****[PatchField]
///
class ProfileEditPage extends StatefulWidget {
const ProfileEditPage({
required this.controller,
super.key,
this.avatarUploaderBuilder,
});
final ProfileController controller;
/// null
/// `app.dart`
final AvatarUploaderBuilder? avatarUploaderBuilder;
@override
State<ProfileEditPage> createState() => _ProfileEditPageState();
}
class _ProfileEditPageState extends State<ProfileEditPage> {
late final TextEditingController _nickname;
///
late final UserProfile _initial;
/// null
/// null
bool _nicknameCleared = false;
/// absent / clear / assetId
PatchField<String> _avatarIntent = const PatchField<String>.absent();
///
String? _pendingAvatarUrl;
String? _nicknameError;
bool _saving = false;
ApiException? _saveError;
@override
void initState() {
super.initState();
_initial = widget.controller.profile!;
// ** DB ** displayName退 username
//
// /me 退
_nickname = TextEditingController(text: _initial.nickname ?? '');
_nickname.addListener(_onNicknameChanged);
}
@override
void dispose() {
_nickname.dispose();
super.dispose();
}
void _onNicknameChanged() {
// `_hasChanges`
//
//
setState(() {
if (_nicknameCleared && _nickname.text.isNotEmpty) {
_nicknameCleared = false;
}
_nicknameError = null;
});
}
/// > >
PatchField<String> get _nicknamePatch {
if (_nicknameCleared) {
// patch
//
return _initial.nickname == null
? const PatchField<String>.absent()
: const PatchField<String>.clear();
}
final trimmed = _nickname.text.trim();
if (trimmed.isEmpty) return const PatchField<String>.absent();
if (trimmed == _initial.nickname) return const PatchField<String>.absent();
return PatchField<String>.value(trimmed);
}
UpdateMeRequest get _request =>
UpdateMeRequest(nickname: _nicknamePatch, avatarAssetId: _avatarIntent);
bool get _hasChanges => !_request.isEmpty;
/// > >
String? get _shownAvatarUrl {
if (_avatarIntent.isClear) return null;
return _pendingAvatarUrl ?? _initial.avatarUrl;
}
bool get _hasAvatarNow => _shownAvatarUrl != null;
Future<void> _pickAvatar() async {
final builder = widget.avatarUploaderBuilder;
if (builder == null) return;
final assetId = await showAvatarUploadSheet(
context,
builder: builder,
purpose: MediaPurpose.userAvatar,
);
if (assetId == null || !mounted) return;
setState(() {
_avatarIntent = PatchField<String>.value(assetId);
// 沿访 URL complete url
// /me avatarUrl
//
_pendingAvatarUrl = null;
});
}
void _clearAvatar() {
setState(() {
_avatarIntent = const PatchField<String>.clear();
_pendingAvatarUrl = null;
});
}
void _clearNickname() {
setState(() {
_nicknameCleared = true;
_nicknameError = null;
_nickname.clear();
});
}
Future<void> _save() async {
//
final raw = _nickname.text;
if (!_nicknameCleared && raw.trim().isNotEmpty) {
final error = validateNickname(raw);
if (error != null) {
setState(() => _nicknameError = error);
return;
}
}
final request = _request;
if (request.isEmpty) {
// patch 400/40000 200
Navigator.of(context).pop(false);
return;
}
setState(() {
_saving = true;
_saveError = null;
});
try {
await widget.controller.save(request);
if (!mounted) return;
Navigator.of(context).pop(true);
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_saving = false;
_saveError = error;
});
}
}
@override
Widget build(BuildContext context) {
final canUploadAvatar = widget.avatarUploaderBuilder != null;
return Scaffold(
appBar: AppBar(title: const Text('编辑资料')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 30),
children: [
if (_saveError != null) ...[
InlineErrorBanner(message: profileSaveErrorMessage(_saveError)),
const SizedBox(height: 16),
],
if (canUploadAvatar) ...[
Center(
child: Column(
children: [
_AvatarPreview(
url: _shownAvatarUrl,
pendingAssetId: _avatarIntent.valueOrNull,
onTap: _saving ? null : _pickAvatar,
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: _saving ? null : _pickAvatar,
icon: const Icon(Icons.photo_camera_outlined, size: 18),
label: const Text('更换头像'),
),
if (_hasAvatarNow)
TextButton(
onPressed: _saving ? null : _clearAvatar,
child: const Text(
'清除头像',
style: TextStyle(color: AppColors.errorDark),
),
),
if (_avatarIntent.isClear)
const Text(
'保存后将移除头像',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
],
),
),
const SizedBox(height: 20),
],
AppTextField(
label: '昵称',
controller: _nickname,
enabled: !_saving,
errorText: _nicknameError,
// 退
//
helperText: _initial.nickname == null
? '未设置,当前展示为用户名「${_initial.username}'
: '1~32 个字',
textInputAction: TextInputAction.done,
onSubmitted: (_) => _saving ? null : _save(),
),
if (_initial.nickname != null)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _saving ? null : _clearNickname,
child: const Text(
'清除昵称',
style: TextStyle(color: AppColors.errorDark),
),
),
),
if (_nicknameCleared)
const Text(
'保存后将清除昵称,展示名回退为用户名',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
const SizedBox(height: 24),
PrimaryButton(
label: '保存',
isLoading: _saving,
//
onPressed: _hasChanges ? _save : null,
),
],
),
);
}
}
/// URL [RemoteImage] key
/// URL
class _AvatarPreview extends StatelessWidget {
const _AvatarPreview({
required this.url,
required this.pendingAssetId,
this.onTap,
});
final String? url;
final String? pendingAssetId;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final Widget content;
if (url != null) {
content = RemoteImage(
url: url!,
width: 96,
height: 96,
borderRadius: BorderRadius.circular(48),
);
} else if (pendingAssetId != null) {
content = const ColoredBox(
color: AppColors.surfaceTint,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, color: AppColors.primary, size: 24),
SizedBox(height: 4),
Text(
'已选择\n新头像',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.primaryDark, fontSize: 10),
),
],
),
),
);
} else {
content = const ColoredBox(
color: AppColors.surfaceTint,
child: Icon(Icons.person_outline, color: AppColors.muted, size: 40),
);
}
return Semantics(
label: '更换头像',
button: onTap != null,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(width: 96, height: 96, child: ClipOval(child: content)),
),
);
}
}
+243 -31
View File
@@ -1,17 +1,49 @@
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/data/demo_data.dart'; import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_display.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.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 ProfilePage extends StatelessWidget { /// TabT3.5-08
const ProfilePage({required this.appState, super.key, this.onLogout}); ///
/// / /
/// `GET /me`+ `GET /me/community-stats` / +
/// `GET /users/{me}/follow-stats` / M3
/// /24 / 1.8k / 2退
///
/// loading/ error + / ready****
/// `/me/community-stats` 404
/// 0 ready 0
/// [ProfileStatsPhase]
class ProfilePage extends StatefulWidget {
const ProfilePage({
required this.appState,
required this.controller,
super.key,
this.avatarUploaderBuilder,
this.onLogout,
});
final AppState appState; final AppState appState;
/// Tab `app.dart`
final ProfileController controller;
///
final AvatarUploaderBuilder? avatarUploaderBuilder;
/// 退线 /// 退线
final Future<void> Function()? onLogout; final Future<void> Function()? onLogout;
/// demo ADR-022
/// M5
/// 稿`/me/bookmarks``/me/posts`
/// 05
static const menuItems = [ static const menuItems = [
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'), (Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'), (Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
@@ -20,18 +52,63 @@ class ProfilePage extends StatelessWidget {
(Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'), (Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'),
]; ];
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
ProfileController get _controller => widget.controller;
@override
void initState() {
super.initState();
// IndexedStack Tab 沿 pets/home
// reset initial
//
// ****`refresh()` notify loading
// initState
// initState Flutterbuild setState
// pets/home initState
if (_controller.phase == ProfileLoadPhase.initial) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _controller.phase == ProfileLoadPhase.initial) {
_controller.refresh();
}
});
}
}
void showDemoMessage(BuildContext context, String name) { void showDemoMessage(BuildContext context, String name) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('$name」功能为演示入口'))); ).showSnackBar(SnackBar(content: Text('$name」功能为演示入口')));
} }
Future<void> _openEdit() async {
if (_controller.profile == null) return;
final saved = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => ProfileEditPage(
controller: _controller,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
),
),
);
if (saved == true && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('资料已更新')));
}
}
/// demo AppState /
///
Future<void> reset(BuildContext context) async { Future<void> reset(BuildContext context) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('恢复演示数据'), title: const Text('恢复演示数据'),
content: const Text('宠物资料、疫苗记录以及新增帖子都会恢复为初始状态'), content: const Text('首页天气与本地服务的演示内容会恢复为初始状态(不影响账号资料与宠物档案)'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
@@ -45,7 +122,7 @@ class ProfilePage extends StatelessWidget {
), ),
); );
if (confirmed == true) { if (confirmed == true) {
await appState.resetDemoData(); await widget.appState.resetDemoData();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
@@ -56,6 +133,43 @@ class ProfilePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
switch (_controller.phase) {
case ProfileLoadPhase.initial:
case ProfileLoadPhase.loading:
return const Center(child: CircularProgressIndicator());
case ProfileLoadPhase.error:
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(
message: profileLoadErrorMessage(_controller.lastError),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _controller.refresh,
child: const Text('重试'),
),
],
),
),
);
case ProfileLoadPhase.ready:
return RefreshIndicator(
onRefresh: _controller.refresh,
child: _content(_controller.profile!),
);
}
},
);
}
Widget _content(UserProfile profile) {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30), padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [ children: [
@@ -67,40 +181,38 @@ class ProfilePage extends StatelessWidget {
), ),
child: Column( child: Column(
children: [ children: [
RemoteImage( _ProfileAvatar(url: profile.avatarUrl, onTap: _openEdit),
url: userAvatar,
width: 82,
height: 82,
borderRadius: BorderRadius.circular(41),
),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( Text(
'萌宠新手(豆豆家长)', // 退`nickname ?? username` /me
style: TextStyle( // DB 退 UserProfile.displayName
profile.displayName,
style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
const Text( Text(
'Patbond 社区创作达人', '@${profile.username}',
style: TextStyle(color: AppColors.accent, fontSize: 12), style: const TextStyle(color: AppColors.accent, fontSize: 12),
), ),
const SizedBox(height: 20), const SizedBox(height: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
visualDensity: VisualDensity.compact,
),
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑资料'),
),
const SizedBox(height: 16),
const Divider(color: Colors.white24), const Divider(color: Colors.white24),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( _statsRow(),
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const _ProfileStat(value: '24', label: '关注我'),
const _ProfileStat(value: '1.8k', label: '获赞'),
_ProfileStat(
value: '${appState.posts.length}',
label: '我的作品',
),
],
),
], ],
), ),
), ),
@@ -108,7 +220,7 @@ class ProfilePage extends StatelessWidget {
SectionCard( SectionCard(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Column( child: Column(
children: menuItems.map((item) { children: ProfilePage.menuItems.map((item) {
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: AppColors.surfaceTint, backgroundColor: AppColors.surfaceTint,
@@ -136,12 +248,112 @@ class ProfilePage extends StatelessWidget {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
TextButton( TextButton(
onPressed: onLogout ?? () => showDemoMessage(context, '退出登录'), onPressed: widget.onLogout ?? () => showDemoMessage(context, '退出登录'),
child: const Text('切换账号或退出登录'), child: const Text('切换账号或退出登录'),
), ),
], ],
); );
} }
/// loading + / error + / ready
///
/// ** 1.8k ** likeCount
///
Widget _statsRow() {
switch (_controller.statsPhase) {
case ProfileStatsPhase.loading:
return const SizedBox(
height: 46,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
case ProfileStatsPhase.error:
return SizedBox(
height: 46,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'统计加载失败',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
TextButton(
onPressed: _controller.refreshStats,
child: const Text(
'重试',
style: TextStyle(color: AppColors.accent),
),
),
],
),
);
case ProfileStatsPhase.ready:
final follow = _controller.followStats;
final community = _controller.communityStats;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_ProfileStat(value: '${follow?.followerCount ?? 0}', label: '关注我'),
_ProfileStat(value: '${follow?.followingCount ?? 0}', label: '我关注'),
_ProfileStat(
value: '${community?.receivedLikeCount ?? 0}',
label: '获赞',
),
_ProfileStat(
value: '${community?.publishedPostCount ?? 0}',
label: '我的作品',
),
],
);
}
}
}
/// URL [RemoteImage] key URL
///
class _ProfileAvatar extends StatelessWidget {
const _ProfileAvatar({required this.url, required this.onTap});
final String? url;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
label: '编辑资料',
button: true,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: 82,
height: 82,
child: ClipOval(
child: url == null
? const ColoredBox(
color: AppColors.surfaceTint,
child: Icon(
Icons.person_outline,
color: AppColors.muted,
size: 36,
),
)
: RemoteImage(
url: url!,
width: 82,
height: 82,
borderRadius: BorderRadius.circular(41),
),
),
),
),
);
}
} }
class _ProfileStat extends StatelessWidget { class _ProfileStat extends StatelessWidget {
+5 -30
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 _postsKey = 'patbond_posts';
static const _locationWeatherKey = 'patbond_location_weather'; static const _locationWeatherKey = 'patbond_location_weather';
/// / / demo T2-12 /// / / demo T2-12
/// Tab pets feature demo /// 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;
List<PostModel> posts = List<PostModel>.from(initialPosts);
LocationWeather locationWeather = initialLocationWeather; LocationWeather locationWeather = initialLocationWeather;
bool isReady = false; bool isReady = false;
@@ -21,17 +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 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 (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>,
@@ -40,7 +37,6 @@ class AppState extends ChangeNotifier {
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace'); debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
pet = initialPet; pet = initialPet;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather; locationWeather = initialLocationWeather;
} finally { } finally {
isReady = true; isReady = true;
@@ -48,21 +44,6 @@ class AppState extends ChangeNotifier {
} }
} }
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();
@@ -71,21 +52,15 @@ class AppState extends ChangeNotifier {
Future<void> resetDemoData() async { Future<void> resetDemoData() async {
pet = initialPet; pet = initialPet;
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(_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();
+13
View File
@@ -235,6 +235,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -399,6 +404,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni: jni:
dependency: transitive dependency: transitive
description: description:
+7
View File
@@ -43,6 +43,13 @@ dependencies:
image_picker: ^1.2.0 image_picker: ^1.2.0
# T3-13 媒体上传:原生编解码压缩(长边重采样 + JPEG 质量 + EXIF 方向矫正)。 # T3-13 媒体上传:原生编解码压缩(长边重采样 + JPEG 质量 + EXIF 方向矫正)。
flutter_image_compress: ^2.4.0 flutter_image_compress: ^2.4.0
# M3.5-01 中文本地化:Material/Cupertino/Widgets 内置组件文案(日期选择器
# 标题、确定/取消、输入模式提示与格式错误)由 Global*Localizations 提供。
flutter_localizations:
sdk: flutter
# flutter_localizations 的日期符号/数字格式底座;显式直接依赖以锁定与 SDK
# 一致的版本(避免间接依赖升级悄悄改变格式化行为)。
intl: ^0.20.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/auth_test_helpers.dart';
import '../helpers/community_test_helpers.dart';
import '../helpers/pet_test_helpers.dart';
/// M3.5-01 [MaterialApp] zh-CN
///
/// delegate
/// Material 退
void main() {
testWidgets('MaterialApp 挂三件套 delegate + 单语言 zh-CN', (tester) async {
SharedPreferences.setMockInitialValues({});
final session = SessionManager(store: InMemoryTokenStore())
..markAuthenticated();
await tester.pumpWidget(
App(
sessionManager: session,
authRepository: FakeAuthRepository(),
petsRepository: FakePetsRepository(),
communityRepository: FakeCommunityRepository()
..onFeed = (_, _) async => feedPage(const []),
),
);
await tester.pumpAndSettle();
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.localizationsDelegates, appLocalizationsDelegates);
expect(app.supportedLocales, appSupportedLocales);
expect(app.locale, const Locale('zh', 'CN'));
// Material Default*
final context = tester.element(find.byType(NavigationBar));
final l10n = MaterialLocalizations.of(context);
expect(l10n.okButtonLabel, '确定');
expect(l10n.cancelButtonLabel, '取消');
expect(l10n.datePickerHelpText, '选择日期');
expect(l10n.dateInputLabel, '输入日期');
expect(l10n.inputDateModeButtonLabel, '切换到输入模式');
expect(l10n.invalidDateFormatLabel, '格式无效。');
expect(l10n.formatMonthYear(DateTime(2026, 9, 10)), '2026年9月');
});
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/models/patch_field.dart';
/// PATCH JSON v1.4.0 `UpdateMeRequest` /
/// `UpdatePetRequest.avatarAssetId`
///
/// 线**absent ** absent `null`
/// null
void main() {
test('absent 不落键 / clear 落 null / value 落值', () {
final json = <String, Object?>{};
const PatchField<String>.absent().writeTo(json, 'a');
expect(json.containsKey('a'), isFalse, reason: 'absent 必须连键都不出现');
const PatchField<String>.clear().writeTo(json, 'b');
expect(json.containsKey('b'), isTrue);
expect(json['b'], isNull);
const PatchField<String>.value('x').writeTo(json, 'c');
expect(json['c'], 'x');
});
test('absent 与 clear 的 valueOrNull 同为 null,只能靠 isPresent 区分', () {
const absent = PatchField<String>.absent();
const clear = PatchField<String>.clear();
expect(absent.valueOrNull, isNull);
expect(clear.valueOrNull, isNull);
expect(absent.isPresent, isFalse);
expect(clear.isPresent, isTrue);
expect(absent.isClear, isFalse);
expect(clear.isClear, isTrue);
expect(const PatchField<String>.value('x').isClear, isFalse);
});
test('encode 只作用于有值态(clear 不调 encode,避免 null 解引用)', () {
final json = <String, Object?>{};
var encodeCalls = 0;
const PatchField<int>.clear().writeTo(
json,
'n',
encode: (value) {
encodeCalls += 1;
return value.toString();
},
);
expect(json['n'], isNull);
expect(encodeCalls, 0);
const PatchField<int>.value(7).writeTo(
json,
'm',
encode: (value) {
encodeCalls += 1;
return value.toString();
},
);
expect(json['m'], '7');
expect(encodeCalls, 1);
});
test('值相等即相等(absent 与 clear 不相等)', () {
expect(
const PatchField<String>.value('a'),
const PatchField<String>.value('a'),
);
expect(
const PatchField<String>.absent(),
isNot(const PatchField<String>.clear()),
);
});
}
+385
View File
@@ -0,0 +1,385 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_date_picker.dart';
/// M3.5-01/02
///
///
void main() {
/// app + zh-CN delegate宿 delegate
/// Material 退
Widget host(Widget child) => MaterialApp(
theme: buildAppTheme(),
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: Scaffold(body: child),
);
group('dateOnly / today / isDateSelectable', () {
test('dateOnly 抹掉时分秒', () {
expect(
dateOnly(DateTime(2026, 4, 9, 23, 59, 58, 777)),
DateTime(2026, 4, 9),
);
});
test('today 为本地当天零点', () {
final now = DateTime.now();
expect(today(), DateTime(now.year, now.month, now.day));
});
test('isDateSelectable 闭区间按日粒度判定', () {
final first = DateTime(2026, 9, 1);
final last = DateTime(2026, 9, 30, 8, 30);
expect(
isDateSelectable(
date: DateTime(2026, 9, 1),
firstDate: first,
lastDate: last,
),
isTrue,
);
// lastDate
expect(
isDateSelectable(
date: DateTime(2026, 9, 30, 23, 0),
firstDate: first,
lastDate: last,
),
isTrue,
);
expect(
isDateSelectable(
date: DateTime(2026, 8, 31),
firstDate: first,
lastDate: last,
),
isFalse,
);
expect(
isDateSelectable(
date: DateTime(2026, 10, 1),
firstDate: first,
lastDate: last,
),
isFalse,
);
});
});
group('pickAppDate · 中文本地化实际渲染', () {
testWidgets('日历模式:标题/确定/取消全中文(此前为 Select date / OK / Cancel', (
tester,
) async {
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () => pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
),
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
expect(find.text('选择日期'), findsOneWidget);
expect(find.text('确定'), findsOneWidget);
expect(find.text('取消'), findsOneWidget);
expect(find.text('Select date'), findsNothing);
expect(find.text('OK'), findsNothing);
expect(find.text('Cancel'), findsNothing);
});
testWidgets('保留手输切换按钮(不用 calendarOnly),切换后提示与标签为中文', (tester) async {
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () => pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
),
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
// calendarOnly
//
final toggle = find.byIcon(Icons.edit_outlined);
expect(toggle, findsOneWidget);
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(find.text('输入日期'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
final field = tester.widget<TextField>(find.byType(TextField));
// / zh-CN mm/dd/yyyy
expect(field.decoration!.labelText, isNot(contains('Date')));
expect(field.decoration!.hintText, isNot('mm/dd/yyyy'));
});
testWidgets('手输模式敲入已知日期即可返回(无需逐月点箭头)', (tester) async {
DateTime? picked;
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () async {
picked = await pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
);
},
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit_outlined));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '2026/04/09');
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(picked, DateTime(2026, 4, 9));
});
testWidgets('取消返回 null;确认返回值已抹掉时分秒', (tester) async {
DateTime? picked;
var calls = 0;
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () async {
calls++;
picked = await pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10, 15, 30),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
);
},
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(calls, 1);
expect(picked, isNull);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(picked, DateTime(2026, 9, 10));
});
testWidgets('initialDate 越界自动夹进 [firstDate, lastDate](不触发原生断言)', (
tester,
) async {
DateTime? picked;
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () async {
// firstDatefirstDate
//
picked = await pickAppDate(
context: context,
initialDate: DateTime(2020, 1, 1),
firstDate: DateTime(2026, 9, 1),
lastDate: DateTime(2026, 9, 30),
);
},
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(picked, DateTime(2026, 9, 1));
});
});
group('AppDateFieldTrailing · 「今天」快捷键', () {
testWidgets('点「今天」直接回调今天,不开弹窗', (tester) async {
DateTime? got;
await tester.pumpWidget(
host(
AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime(2100),
onToday: (value) => got = value,
),
),
);
expect(find.text('今天'), findsOneWidget);
expect(find.byIcon(Icons.calendar_month_outlined), findsOneWidget);
await tester.tap(find.text('今天'));
await tester.pumpAndSettle();
expect(got, today());
//
expect(find.text('选择日期'), findsNothing);
});
testWidgets('今天越界时隐藏按钮,只留日历图标', (tester) async {
await tester.pumpWidget(
host(
AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime(1999, 12, 31),
onToday: (_) {},
),
),
);
expect(find.text('今天'), findsNothing);
expect(find.byIcon(Icons.calendar_month_outlined), findsOneWidget);
});
testWidgets('enabled=false(提交中)按钮禁用', (tester) async {
var calls = 0;
await tester.pumpWidget(
host(
AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime(2100),
enabled: false,
onToday: (_) => calls++,
),
),
);
expect(
tester.widget<TextButton>(find.byType(TextButton)).onPressed,
isNull,
);
await tester.tap(find.text('今天'), warnIfMissed: false);
await tester.pumpAndSettle();
expect(calls, 0);
});
testWidgets('触控目标不小于 44×44', (tester) async {
await tester.pumpWidget(
host(
AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime(2100),
onToday: (_) {},
),
),
);
final size = tester.getSize(find.byType(TextButton));
expect(size.width, greaterThanOrEqualTo(44));
expect(size.height, greaterThanOrEqualTo(44));
});
});
group('datePickerTheme · 品牌配色(不再是 fromSeed 的暗红棕)', () {
test('选中日/选中年为 primaryStrong 实底白字,今日 primaryStrong 描边', () {
final theme = buildAppTheme().datePickerTheme;
const selected = <WidgetState>{WidgetState.selected};
expect(
theme.dayBackgroundColor!.resolve(selected),
AppColors.primaryStrong,
);
expect(theme.dayForegroundColor!.resolve(selected), Colors.white);
expect(
theme.yearBackgroundColor!.resolve(selected),
AppColors.primaryStrong,
);
expect(theme.yearForegroundColor!.resolve(selected), Colors.white);
expect(theme.todayBorder!.color, AppColors.primaryStrong);
expect(
theme.todayForegroundColor!.resolve(const {}),
AppColors.primaryStrong,
);
// 沿 chipsurfaceTint + primaryDark 7.98:1
expect(theme.headerBackgroundColor, AppColors.surfaceTint);
expect(theme.headerForegroundColor, AppColors.primaryDark);
expect(theme.backgroundColor, AppColors.surface);
// mutedDEBT-2 muted
expect(
theme.dayForegroundColor!.resolve(const {WidgetState.disabled}),
AppColors.muted,
);
});
testWidgets('实际渲染:选中日圆底取 primaryStrong', (tester) async {
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () => pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
),
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
// Ink(decoration: ShapeDecoration)
// datePickerTheme.dayBackgroundColor / todayBackgroundColor
final inkColors = tester
.widgetList<Ink>(find.byType(Ink))
.map((ink) => ink.decoration)
.whereType<ShapeDecoration>()
.map((d) => d.color)
.toList();
expect(
inkColors,
contains(AppColors.primaryStrong),
reason: '选中日未使用品牌 primaryStrong 实底(仍是 fromSeed 派生色)',
);
});
});
}
@@ -0,0 +1,162 @@
import 'dart:async';
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/avatar_upload_sheet.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
/// sheet MediaUploader
///
void main() {
late FakeCommunityRepository community;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
setUp(() {
community = FakeCommunityRepository();
picker = FakeMediaImagePicker([decodablePickedImage()]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
community.onCreateMediaUpload = (_) async => credentials();
community.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
});
MediaUploader build(MediaPurpose purpose) => MediaUploader(
repository: community,
purpose: purpose,
picker: picker,
compressor: compressor,
directUpload: direct,
maxImages: 1,
maxConcurrentUploads: 1,
);
/// sheet pop
Future<List<String?>> pumpSheet(
WidgetTester tester, {
MediaPurpose purpose = MediaPurpose.userAvatar,
}) async {
final results = <String?>[];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
results.add(
await showAvatarUploadSheet(
context,
builder: build,
purpose: purpose,
),
);
},
child: const Text('打开'),
),
),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pump();
return results;
}
testWidgets('打开即自动拉起选择器(无需多点一次「选择图片」)', (tester) async {
picker.gate = Completer<void>();
await pumpSheet(tester);
await tester.pump();
expect(find.text('正在打开相册…'), findsOneWidget);
expect(picker.limits, [1], reason: 'maxImages=1 → 只取一张');
picker.gate!.complete();
await tester.pumpAndSettle();
});
testWidgets('上传中:线性进度 + 百分比;ready 后给预览与「使用这张」', (tester) async {
direct.manual = true;
final results = await pumpSheet(tester);
await tester.pumpAndSettle();
expect(find.byType(LinearProgressIndicator), findsOneWidget);
final call = direct.calls.single;
call.emitProgress(30, 100);
await tester.pump();
expect(find.text('上传中 30%'), findsOneWidget);
call.succeed();
await tester.pumpAndSettle();
expect(find.text('使用这张'), findsOneWidget);
expect(find.byType(Image), findsOneWidget, reason: 'ready 态给整幅预览');
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
expect(results.single, 'a-1', reason: '只交付 ready 态的 assetId(孤儿防护)');
});
testWidgets('purpose 透传到 createUpload(宠物头像 = pet_avatar', (tester) async {
await pumpSheet(tester, purpose: MediaPurpose.petAvatar);
await tester.pumpAndSettle();
expect(community.lastMediaUploadRequest!.purpose, MediaPurpose.petAvatar);
expect(community.lastMediaUploadRequest!.purpose.wire, 'pet_avatar');
});
testWidgets('可重试失败:给「重试」+「重新选择」,重试成功后可确认', (tester) async {
direct.scriptedOutcomes.add(
const MediaDirectUploadException(message: '断网'),
);
final results = await pumpSheet(tester);
await tester.pumpAndSettle();
expect(find.text('网络中断,上传失败'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
expect(find.text('重新选择'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('使用这张'), findsOneWidget);
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
expect(results.single, 'a-1');
});
testWidgets('不可重试失败(压缩后仍超限):只给「重新选择」,不给「重试」', (tester) async {
compressor.sizePerQuality = {80: 20 * 1024 * 1024, 60: 15 * 1024 * 1024};
await pumpSheet(tester);
await tester.pumpAndSettle();
expect(find.text('图片过大,压缩后仍超过 10 MB'), findsOneWidget);
expect(find.text('重试'), findsNothing, reason: '重试同一张必然再失败,给重试钮是误导');
expect(find.text('重新选择'), findsOneWidget);
});
testWidgets('用户在系统选择器里取消 → 空态可再次选择,pop 结果为 null', (tester) async {
picker = FakeMediaImagePicker(const []);
final results = await pumpSheet(tester);
await tester.pumpAndSettle();
expect(find.text('还没有选择图片'), findsOneWidget);
expect(find.text('选择图片'), findsOneWidget);
// sheet ready assetId
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(results.single, isNull);
});
}
@@ -3,8 +3,12 @@ import 'package:flutter_test/flutter_test.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/feed_skeleton.dart'; import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart'; import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/core/widgets/upload_progress_overlay.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/widgets/common.dart'; import 'package:patbond_flutter/widgets/common.dart';
import '../../helpers/media_test_helpers.dart';
Widget wrap(Widget child, {bool disableAnimations = false}) => MaterialApp( Widget wrap(Widget child, {bool disableAnimations = false}) => MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
home: MediaQuery( home: MediaQuery(
@@ -97,4 +101,92 @@ void main() {
expect(fade.opacity.value, 1.0); expect(fade.opacity.value, 1.0);
}); });
}); });
group('PostMediaEditGrid 编辑态(05 §3.2', () {
MediaUploadItem item({
int localId = 1,
MediaItemPhase phase = MediaItemPhase.ready,
double progress = 0,
bool retryable = false,
}) => MediaUploadItem(
localId: localId,
phase: phase,
previewBytes: tinyPngBytes,
progress: progress,
assetId: phase == MediaItemPhase.ready ? 'a-$localId' : null,
retryable: retryable,
);
testWidgets('空列表只渲染「+」格;3 张图渲染 3 格 + 「+」', (tester) async {
await tester.pumpWidget(wrap(const PostMediaEditGrid(items: [])));
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsOneWidget);
expect(find.byType(Image), findsNothing);
await tester.pumpWidget(
wrap(
PostMediaEditGrid(
items: [item(localId: 1), item(localId: 2), item(localId: 3)],
),
),
);
await tester.pump();
expect(find.byType(Image), findsNWidgets(3));
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsOneWidget);
});
testWidgets('满 9 张(canAdd=false)隐藏「+」格', (tester) async {
await tester.pumpWidget(
wrap(
PostMediaEditGrid(
items: List.generate(9, (i) => item(localId: i + 1)),
canAdd: false,
),
),
);
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsNothing);
expect(find.byType(Image), findsNWidgets(9));
});
testWidgets('删除角标回传 localId;「+」格回调', (tester) async {
final removed = <int>[];
var addTaps = 0;
await tester.pumpWidget(
wrap(
PostMediaEditGrid(
items: [item(localId: 7), item(localId: 8)],
onAdd: () => addTaps++,
onRemove: removed.add,
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.close).first);
expect(removed, [7]);
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
expect(addTaps, 1);
});
testWidgets('上传中格叠进度覆盖层;可重试失败格整格点按重试,终态不响应', (tester) async {
final retried = <int>[];
await tester.pumpWidget(
wrap(
PostMediaEditGrid(
items: [
item(localId: 1, phase: MediaItemPhase.uploading, progress: 0.42),
item(localId: 2, phase: MediaItemPhase.failed, retryable: true),
item(localId: 3, phase: MediaItemPhase.failed),
],
onRetry: retried.add,
),
),
);
await tester.pump();
expect(find.byType(UploadProgressOverlay), findsNWidgets(3));
expect(find.text('42%'), findsOneWidget);
//
expect(find.text('重试'), findsOneWidget);
await tester.tap(find.text('重试'));
expect(retried, [2]);
});
});
} }
@@ -6,6 +6,7 @@ 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_models.dart';
import 'package:patbond_flutter/features/community/media_direct_upload.dart'; import 'package:patbond_flutter/features/community/media_direct_upload.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart'; import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import '../../helpers/community_test_helpers.dart'; import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart'; import '../../helpers/media_test_helpers.dart';
@@ -24,12 +25,14 @@ void main() {
int maxConcurrentUploads = 2, int maxConcurrentUploads = 2,
int maxByteSize = 10 * 1024 * 1024, int maxByteSize = 10 * 1024 * 1024,
DateTime Function()? now, DateTime Function()? now,
PostAnalytics? analytics,
}) { }) {
return MediaUploader( return MediaUploader(
repository: repository, repository: repository,
picker: picker, picker: picker,
compressor: compressor, compressor: compressor,
directUpload: direct, directUpload: direct,
analytics: analytics,
maxConcurrentUploads: maxConcurrentUploads, maxConcurrentUploads: maxConcurrentUploads,
maxByteSize: maxByteSize, maxByteSize: maxByteSize,
now: now, now: now,
@@ -453,6 +456,151 @@ void main() {
expect(uploader.overallProgress, closeTo(0.75, 0.001)); expect(uploader.overallProgress, closeTo(0.75, 0.001));
}); });
}); });
// ---- T3-1722 v3 27~29----
group('媒体三段埋点', () {
late List<(String, Map<String, dynamic>?)> events;
late PostAnalytics analytics;
setUp(() {
events = [];
analytics = PostAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
List<Map<String, dynamic>?> named(String name) =>
events.where((event) => event.$1 == name).map((e) => e.$2).toList();
test('成功一图:started → succeeded 各一条,sizeBucket 同值 + durationMs', () async {
var clock = DateTime.utc(2026, 9, 10, 8);
//
repository.onCreateMediaUpload = (_) async => credentials(
assetId: 'a-1',
expiresAt: clock.add(const Duration(minutes: 10)),
);
final uploader = build(analytics: analytics, now: () => clock);
direct.manual = true;
uploader.addImages([pickedImage(size: 2048)]);
await pumpEventQueue();
// started 1200ms confirm
clock = clock.add(const Duration(milliseconds: 1200));
direct.calls.single.succeed();
await pumpEventQueue();
expect(events.map((e) => e.$1), [
'post_media_upload_started',
'post_media_upload_succeeded',
]);
expect(named('post_media_upload_started').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
});
expect(named('post_media_upload_succeeded').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'durationMs': 1200,
});
});
test('压缩后仍超限:started + failed(media_too_large, attemptSeq 1)', () async {
compressor.sizePerQuality = {80: 40, 60: 30};
final uploader = build(maxByteSize: 20, analytics: analytics);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.retryable, isFalse);
expect(named('post_media_upload_succeeded'), isEmpty);
expect(named('post_media_upload_failed').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'failureReason': 'media_too_large',
'attemptSeq': 1,
});
});
test('断连失败 → retryfailed(network_error) 后 attemptSeq 递增再成功', () async {
direct.scriptedOutcomes.add(
const MediaDirectUploadException(message: '断连'),
);
final uploader = build(analytics: analytics);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(named('post_media_upload_failed').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'failureReason': 'network_error',
'attemptSeq': 1,
});
uploader.retry(uploader.items.single.localId);
await pumpEventQueue();
expect(uploader.items.single.phase, MediaItemPhase.ready);
expect(named('post_media_upload_started'), hasLength(2));
expect(named('post_media_upload_succeeded'), hasLength(1));
});
test(
'createUpload 40000failed(unsupported_format) 带 errorCode/httpStatus',
() async {
repository.onCreateMediaUpload = (_) async =>
throw const ApiBusinessException(code: 40000, message: 'bad mime');
final uploader = build(analytics: analytics);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(named('post_media_upload_failed').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'failureReason': 'unsupported_format',
'attemptSeq': 1,
'errorCode': 40000,
'httpStatus': 400,
});
},
);
test('在途删格报 cancelledready 后删格不报', () async {
direct.manual = true;
final uploader = build(analytics: analytics);
uploader.addImages([pickedImage(seed: 1)]);
await pumpEventQueue();
uploader.remove(uploader.items.single.localId);
expect(named('post_media_upload_failed').single, {
'mediaType': 'image',
'sizeBucket': 'lt_1mb',
'failureReason': 'cancelled',
'attemptSeq': 1,
});
events.clear();
direct.manual = false;
uploader.addImages([pickedImage(seed: 2)]);
await pumpEventQueue();
expect(uploader.items.single.isReady, isTrue);
uploader.remove(uploader.items.single.localId);
expect(named('post_media_upload_failed'), isEmpty);
});
test('会话失效不上报失败(应用即将回登录页)', () async {
repository.onCreateMediaUpload = (_) async =>
throw const SessionExpiredException();
final uploader = build(analytics: analytics);
uploader.addImages([pickedImage()]);
await pumpEventQueue();
expect(uploader.items.single.isFailed, isTrue);
expect(named('post_media_upload_failed'), isEmpty);
expect(named('post_media_upload_started'), hasLength(1));
});
});
} }
Map<String, dynamic> readyAssetJson({required String assetId}) => { Map<String, dynamic> readyAssetJson({required String assetId}) => {
@@ -0,0 +1,193 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
/// post T3-17 22 v3 22~29
/// failureReason 线 ID /
void main() {
late List<(String, Map<String, dynamic>?)> events;
late PostAnalytics analytics;
setUp(() {
events = [];
analytics = PostAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
group('键集与白名单对齐(22 号 §1', () {
test('post_create_startedentryPoint', () {
analytics.postCreateStarted(entryPoint: PostEntryPoint.createTab);
expect(events.single.$1, 'post_create_started');
expect(events.single.$2, {'entryPoint': 'create_tab'});
});
test('post_draft_savedtrigger + mediaCount', () {
analytics.postDraftSaved(trigger: DraftSaveTrigger.onExit, mediaCount: 3);
expect(events.single.$1, 'post_draft_saved');
expect(events.single.$2, {'trigger': 'on_exit', 'mediaCount': 3});
});
test('post_publish_succeeded:五键齐 + 正文只出分桶', () {
analytics.postPublishSucceeded(
durationMs: 8200,
mediaCount: 2,
topicCount: 0,
textLength: 120,
fromDraft: true,
);
final props = events.single.$2!;
expect(
props.keys,
containsAll(<String>[
'durationMs',
'mediaCount',
'topicCount',
'textLengthBucket',
'fromDraft',
]),
);
expect(props['textLengthBucket'], 'medium');
expect(props['fromDraft'], isTrue);
// 线 1/2 postId
expect(props.containsKey('textLength'), isFalse);
expect(props.containsKey('postId'), isFalse);
});
test(
'post_publish_failedfailureReason/attemptSeq/errorCode/httpStatus',
() {
analytics.postPublishFailed(
reason: PostPublishFailureReason.mediaUploadIncomplete,
attemptSeq: 2,
errorCode: 42203,
);
expect(events.single.$2, {
'failureReason': 'media_upload_incomplete',
'attemptSeq': 2,
'errorCode': 42203,
'httpStatus': 422,
});
},
);
test('post_publish_failed:网络错误无 errorCode/httpStatus', () {
analytics.postPublishFailed(
reason: PostPublishFailureReason.networkError,
attemptSeq: 1,
);
expect(events.single.$2, {
'failureReason': 'network_error',
'attemptSeq': 1,
});
});
test('post_deleted:空属性集(单事件风格)', () {
analytics.postDeleted();
expect(events.single.$1, 'post_deleted');
expect(events.single.$2, isEmpty);
});
test('媒体三段:键集齐 + 只出桶不出字节数/文件名', () {
analytics.mediaUploadStarted(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
);
analytics.mediaUploadSucceeded(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
durationMs: 4300,
);
analytics.mediaUploadFailed(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
reason: MediaUploadFailureReason.cancelled,
attemptSeq: 1,
);
expect(events.map((event) => event.$1), [
'post_media_upload_started',
'post_media_upload_succeeded',
'post_media_upload_failed',
]);
expect(events[0].$2, {'mediaType': 'image', 'sizeBucket': 'mb_1_5'});
expect(events[1].$2, {
'mediaType': 'image',
'sizeBucket': 'mb_1_5',
'durationMs': 4300,
});
expect(events[2].$2, {
'mediaType': 'image',
'sizeBucket': 'mb_1_5',
'failureReason': 'cancelled',
'attemptSeq': 1,
});
for (final event in events) {
expect(event.$2!.containsKey('byteSize'), isFalse);
expect(event.$2!.containsKey('fileName'), isFalse);
}
});
});
group('分桶边界(06 §1.3 红线 4', () {
test('mediaSizeBucketOf 四档', () {
const mib = 1024 * 1024;
expect(mediaSizeBucketOf(0), 'lt_1mb');
expect(mediaSizeBucketOf(mib - 1), 'lt_1mb');
expect(mediaSizeBucketOf(mib), 'mb_1_5');
expect(mediaSizeBucketOf(5 * mib - 1), 'mb_1_5');
expect(mediaSizeBucketOf(5 * mib), 'mb_5_20');
expect(mediaSizeBucketOf(20 * mib - 1), 'mb_5_20');
expect(mediaSizeBucketOf(20 * mib), 'gte_20mb');
});
});
group('异常 → 发布失败原因', () {
test('42203 → media_upload_incomplete40905/40000 → validation_error', () {
expect(
postPublishFailureReasonOf(const MediaNotReadyException(message: 'x')),
PostPublishFailureReason.mediaUploadIncomplete,
);
expect(
postPublishFailureReasonOf(
const IdempotencyMismatchException(message: 'x'),
),
PostPublishFailureReason.validationError,
);
expect(
postPublishFailureReasonOf(
const ApiBusinessException(code: 40000, message: 'x'),
),
PostPublishFailureReason.validationError,
);
});
test('40403 → not_found40902 → server_error;限流/网络各自归位', () {
expect(
postPublishFailureReasonOf(const PostNotFoundException(message: 'x')),
PostPublishFailureReason.notFound,
);
expect(
postPublishFailureReasonOf(
const PostVersionConflictException(message: 'x'),
),
PostPublishFailureReason.serverError,
);
expect(
postPublishFailureReasonOf(const ApiRateLimitException()),
PostPublishFailureReason.rateLimited,
);
expect(
postPublishFailureReasonOf(const ApiNetworkException()),
PostPublishFailureReason.networkError,
);
});
test('会话失效不上报(null', () {
expect(
postPublishFailureReasonOf(const SessionExpiredException()),
isNull,
);
});
});
}
@@ -0,0 +1,571 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:patbond_flutter/features/community/post_compose_page.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
/// T3-17gating / 稿退
/// 40905 / 42203 / 稿
///
void main() {
late FakeCommunityRepository repository;
late CommunityController controller;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
late List<(String, Map<String, dynamic>?)> events;
late PostAnalytics analytics;
late List<CreatePostRequest> created;
late List<UpdatePostRequest> updated;
bool? popResult;
int uploadSeq = 0;
setUp(() {
repository = FakeCommunityRepository();
controller = CommunityController(repository: repository);
picker = FakeMediaImagePicker([
decodablePickedImage(seed: 1),
decodablePickedImage(seed: 2),
decodablePickedImage(seed: 3),
]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
events = [];
analytics = PostAnalytics(
(name, [props]) async => events.add((name, props)),
);
created = [];
updated = [];
popResult = null;
uploadSeq = 0;
repository.onCreateMediaUpload = (_) async =>
credentials(assetId: 'a-${++uploadSeq}');
repository.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
repository.onCreatePost = (request, _) async {
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
repository.onUpdatePost = (postId, request) async {
updated.add(request);
return Post.fromJson(
samplePostJson(id: postId, version: request.version + 1),
);
};
});
List<Map<String, dynamic>?> eventsNamed(String name) =>
events.where((event) => event.$1 == name).map((e) => e.$2).toList();
MediaUploader uploaderFactory(
CommunityRepository repo,
PostAnalytics? postAnalytics,
) => MediaUploader(
repository: repo,
picker: picker,
compressor: compressor,
directUpload: direct,
analytics: postAnalytics,
);
Future<void> pumpCompose(
WidgetTester tester, {
PostEntryPoint entryPoint = PostEntryPoint.createTab,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
popResult = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => PostComposePage(
controller: controller,
entryPoint: entryPoint,
analytics: analytics,
uploaderFactory: uploaderFactory,
),
),
);
},
child: const Text('打开发布页'),
),
),
),
),
),
);
await tester.tap(find.text('打开发布页'));
await tester.pumpAndSettle();
}
Finder publishButton() => find.widgetWithText(FilledButton, '发布');
bool publishEnabled(WidgetTester tester) =>
tester.widget<FilledButton>(publishButton()).onPressed != null;
Future<void> writeContent(
WidgetTester tester, [
String text = '带豆豆去了公园',
]) async {
await tester.enterText(find.byType(TextField), text);
await tester.pump();
}
/// [count]
Future<void> pickImages(WidgetTester tester, int count) async {
picker.results
..clear()
..addAll(List.generate(count, (i) => decodablePickedImage(seed: i + 1)));
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
await tester.pumpAndSettle();
}
group('gating05 §2.2/§2.3 + 后端 content 必填)', () {
testWidgets('空正文禁用;输入即启用;post_create_started 只报一次', (tester) async {
await pumpCompose(tester);
expect(publishEnabled(tester), isFalse);
await writeContent(tester, '');
expect(publishEnabled(tester), isTrue);
await writeContent(tester, '豆豆');
expect(eventsNamed('post_create_started'), hasLength(1));
expect(eventsNamed('post_create_started').single, {
'entryPoint': 'create_tab',
});
});
testWidgets('图片在途禁用发布,全部 ready 才放行(孤儿防护)', (tester) async {
direct.manual = true;
await pumpCompose(tester, entryPoint: PostEntryPoint.feed);
await writeContent(tester);
picker.results
..clear()
..addAll([decodablePickedImage()]);
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
await tester.pumpAndSettle();
expect(publishEnabled(tester), isFalse);
//
expect(find.textContaining('正在上传'), findsOneWidget);
direct.calls.single.succeed();
await tester.pumpAndSettle();
expect(publishEnabled(tester), isTrue);
expect(find.textContaining('正在上传'), findsNothing);
// entryPoint feed
expect(eventsNamed('post_create_started').single, {'entryPoint': 'feed'});
});
});
group('直接发布路径(建草稿 → PATCH 迁移)', () {
testWidgets('纯文字帖:createPost(draft) + PATCH publish → pop(true) + 漏斗事件', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester, '带豆豆去了公园');
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created.single.status, PostStatus.draft);
expect(created.single.content, '带豆豆去了公园');
expect(created.single.category, PostCategory.general);
expect(created.single.media, isNull);
expect(updated.single.publish, isTrue);
expect(updated.single.version, 1);
// 稿media PATCH
expect(updated.single.media, isNull);
expect(popResult, isTrue);
final succeeded = eventsNamed('post_publish_succeeded').single!;
expect(succeeded['mediaCount'], 0);
expect(succeeded['topicCount'], 0);
expect(succeeded['textLengthBucket'], 'short');
expect(succeeded['fromDraft'], isFalse);
expect(eventsNamed('post_publish_failed'), isEmpty);
});
testWidgets('求助类目 + 两图:position 0..1、首图封面、mediaCount 2', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.text('求助'));
await tester.pump();
await pickImages(tester, 2);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created.single.category, PostCategory.help);
final media = created.single.media!;
expect(media.map((item) => item.position), [0, 1]);
expect(media.map((item) => item.isCover), [true, false]);
expect(media.map((item) => item.assetId), ['a-1', 'a-2']);
expect(eventsNamed('post_publish_succeeded').single!['mediaCount'], 2);
//
expect(eventsNamed('post_media_upload_started'), hasLength(2));
expect(eventsNamed('post_media_upload_succeeded'), hasLength(2));
});
testWidgets('删格重排:3 图删中间 → position 重发号 0..1,弃掉的 asset 不被引用', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 3);
expect(find.byType(Image), findsNWidgets(3));
//
await tester.tap(find.byIcon(Icons.close).at(1));
await tester.pumpAndSettle();
expect(find.byType(Image), findsNWidgets(2));
await tester.tap(publishButton());
await tester.pumpAndSettle();
final media = created.single.media!;
expect(media.map((item) => item.position), [0, 1]);
expect(media.map((item) => item.assetId), ['a-1', 'a-3']);
expect(media.map((item) => item.isCover), [true, false]);
});
});
group('发布失败三语义', () {
testWidgets('网络失败:横幅可重试 + 同键重放(不重复建帖)', (tester) async {
var attempts = 0;
repository.onCreatePost = (request, _) async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException();
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsOneWidget);
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
// 稿
expect(find.text('草稿已保存,可稍后继续发布'), findsNothing);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'network_error',
'attemptSeq': 1,
});
expect(popResult, isNull);
await tester.tap(publishButton());
await tester.pumpAndSettle();
//
expect(repository.idempotencyKeys, hasLength(2));
expect(repository.idempotencyKeys[0], isNotNull);
expect(repository.idempotencyKeys[0], repository.idempotencyKeys[1]);
expect(popResult, isTrue);
});
testWidgets('迁移发布失败:明确「草稿已保存」,重试只补 PATCH', (tester) async {
var patches = 0;
repository.onUpdatePost = (postId, request) async {
patches += 1;
if (patches == 1) throw const ApiNetworkException();
updated.add(request);
return Post.fromJson(samplePostJson(id: postId));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('草稿已保存,可稍后继续发布'), findsOneWidget);
expect(created, hasLength(1));
await tester.tap(publishButton());
await tester.pumpAndSettle();
// 稿 PATCH
expect(created, hasLength(1));
expect(patches, 2);
expect(popResult, isTrue);
expect(eventsNamed('post_publish_failed').single!['attemptSeq'], 1);
expect(eventsNamed('post_publish_succeeded'), hasLength(1));
});
testWidgets('40905 同键异 hash:提示已重置提交标识,再点即换新键', (tester) async {
var attempts = 0;
repository.onCreatePost = (request, _) async {
attempts += 1;
if (attempts == 1) {
throw const IdempotencyMismatchException(message: 'mismatch');
}
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('提交内容与上次重试不一致,已重置提交标识,请再点一次「发布」'), findsOneWidget);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'validation_error',
'attemptSeq': 1,
'errorCode': 40905,
'httpStatus': 409,
});
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(repository.idempotencyKeys, hasLength(2));
expect(
repository.idempotencyKeys[0],
isNot(repository.idempotencyKeys[1]),
);
expect(popResult, isTrue);
});
testWidgets('42203 asset 未 ready:提示等图片就绪 + media_upload_incomplete', (
tester,
) async {
repository.onCreatePost = (_, _) async =>
throw const MediaNotReadyException(message: 'not ready');
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 1);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('有图片还没上传完成,请等图片就绪后再发布'), findsOneWidget);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'media_upload_incomplete',
'attemptSeq': 1,
'errorCode': 42203,
'httpStatus': 422,
});
expect(popResult, isNull);
});
});
group('存草稿退出路径', () {
testWidgets('「存草稿」按钮:createPost(draft) + post_draft_saved(manual)', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 1);
await tester.tap(find.widgetWithText(TextButton, '存草稿'));
await tester.pumpAndSettle();
expect(created.single.status, PostStatus.draft);
expect(updated, isEmpty);
expect(find.text('已保存草稿 ✓'), findsOneWidget);
expect(eventsNamed('post_draft_saved').single, {
'trigger': 'manual',
'mediaCount': 1,
});
});
testWidgets('「取消」有内容 → 保留草稿:on_exit 保存后离页', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
expect(find.text('保留草稿?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '保留'));
await tester.pumpAndSettle();
expect(created, hasLength(1));
expect(eventsNamed('post_draft_saved').single, {
'trigger': 'on_exit',
'mediaCount': 0,
});
expect(popResult, isFalse);
expect(find.byType(PostComposePage), findsNothing);
});
testWidgets('「不保留」:已落服务端的草稿软删 + post_deleted', (tester) async {
final deleted = <String>[];
repository.onDeletePost = (postId) async => deleted.add(postId);
await pumpCompose(tester);
await writeContent(tester);
// 稿使稿
await tester.tap(find.widgetWithText(TextButton, '存草稿'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '不保留'));
await tester.pumpAndSettle();
expect(deleted, ['p-new']);
expect(eventsNamed('post_deleted'), hasLength(1));
expect(popResult, isFalse);
});
testWidgets('「继续编辑」留在页内,不动服务端', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '继续编辑'));
await tester.pumpAndSettle();
expect(find.byType(PostComposePage), findsOneWidget);
expect(created, isEmpty);
expect(
repository.calls.where((c) => c.startsWith('deletePost')),
isEmpty,
);
});
testWidgets('空表单「取消」直接离页,无弹窗无请求', (tester) async {
await pumpCompose(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
expect(find.text('保留草稿?'), findsNothing);
expect(created, isEmpty);
expect(popResult, isFalse);
});
});
group('草稿恢复(最小实现:最新一条)', () {
setUp(() {
repository.onListMyPosts = (status) async =>
postPage([sampleDraft(version: 3, content: '上次没写完的草稿')]);
});
testWidgets('进页恢复:提示条 + 正文预填 + 已有图片说明', (tester) async {
await pumpCompose(tester);
expect(find.text('已恢复上次草稿'), findsOneWidget);
expect(find.text('上次没写完的草稿'), findsOneWidget);
expect(find.textContaining('草稿已含 1 张图片'), findsOneWidget);
// post_create_started
expect(eventsNamed('post_create_started'), isEmpty);
expect(repository.calls, contains('listMyPosts:draft'));
});
testWidgets('恢复后发布:不再建草稿,PATCH 带 version 与 fromDraft=true', (tester) async {
await pumpCompose(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created, isEmpty);
expect(updated.single.version, 3);
expect(updated.single.publish, isTrue);
// media
expect(updated.single.media, isNull);
final succeeded = eventsNamed('post_publish_succeeded').single!;
expect(succeeded['fromDraft'], isTrue);
expect(succeeded['mediaCount'], 1);
expect(popResult, isTrue);
});
testWidgets('恢复后重新选图:PATCH 整组替换', (tester) async {
await pumpCompose(tester);
await pickImages(tester, 1);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(updated.single.media!.map((item) => item.assetId), ['a-1']);
expect(eventsNamed('post_publish_succeeded').single!['mediaCount'], 1);
});
testWidgets('40902 乐观锁:自动取新 version 重提一次即成功', (tester) async {
var patches = 0;
repository.onUpdatePost = (postId, request) async {
patches += 1;
if (patches == 1) {
throw const PostVersionConflictException(message: 'stale');
}
updated.add(request);
return Post.fromJson(samplePostJson(id: postId));
};
repository.onGetPost = (postId) async => Post.fromJson(
samplePostJson(id: postId, status: 'draft', version: 9),
);
await pumpCompose(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(updated.single.version, 9);
expect(eventsNamed('post_publish_failed'), isEmpty);
expect(popResult, isTrue);
});
testWidgets('「清空」清掉恢复内容与提示条', (tester) async {
await pumpCompose(tester);
await tester.tap(find.widgetWithText(TextButton, '清空'));
await tester.pumpAndSettle();
expect(find.text('已恢复上次草稿'), findsNothing);
expect(find.text('上次没写完的草稿'), findsNothing);
expect(publishEnabled(tester), isFalse);
});
testWidgets('草稿恢复失败静默降级为新建(不打断发布)', (tester) async {
repository.onListMyPosts = (_) async => throw const ApiNetworkException();
await pumpCompose(tester);
expect(find.text('已恢复上次草稿'), findsNothing);
expect(find.byType(InlineErrorBanner), findsNothing);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created, hasLength(1));
expect(popResult, isTrue);
});
});
group('页面结构(05 §2.3', () {
testWidgets('AppBar 三件套 + 媒体编辑格 + 类目二选 + 位置占位', (tester) async {
await pumpCompose(tester);
expect(find.text('发布动态'), findsOneWidget);
expect(find.widgetWithText(TextButton, '取消'), findsOneWidget);
expect(find.widgetWithText(TextButton, '存草稿'), findsOneWidget);
expect(publishButton(), findsOneWidget);
expect(find.byType(PostMediaEditGrid), findsOneWidget);
expect(find.text('日常分享'), findsOneWidget);
expect(find.text('求助'), findsOneWidget);
// ai_creation M4
expect(find.textContaining('AI'), findsNothing);
await tester.drag(find.byType(ListView), const Offset(0, -240));
await tester.pump();
await tester.tap(find.text('添加位置(选填)'));
await tester.pump();
expect(find.text('位置功能即将上线'), findsOneWidget);
});
testWidgets('正文 1000 字上限计数器在位', (tester) async {
await pumpCompose(tester);
await writeContent(tester, '豆豆');
expect(find.text('2/1000'), findsOneWidget);
});
});
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/home/home_page.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/state/app_state.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/community_test_helpers.dart';
/// T3.5-10
///
/// ADR-022 D3.5-1**** / / /
/// demo
/// ADR
void main() {
late FakeCommunityRepository community;
late CommunityController feed;
setUp(() {
community = FakeCommunityRepository();
community.onFeed = (_, _) async => feedPage(const []);
feed = CommunityController(repository: community);
});
Future<ProfileController> pumpHome(
WidgetTester tester, {
required FakeAuthRepository auth,
}) async {
tester.view.physicalSize = const Size(700, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final profile = ProfileController(
authRepository: auth,
communityRepository: community,
);
addTearDown(profile.dispose);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: HomePage(
appState: AppState(),
communityController: feed,
profileController: profile,
onOpenServices: (_) {},
onOpenCompose: () {},
),
),
),
);
return profile;
}
///
String greetingPrefix([DateTime? now]) {
final hour = (now ?? DateTime.now()).hour;
if (hour < 6) return '夜深了';
if (hour < 11) return '早上好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
return '晚上好';
}
testWidgets('有昵称:问候语用昵称,demo 宠物名「豆豆」不再出现', (tester) async {
final profile = await pumpHome(
tester,
auth: FakeAuthRepository(
meHandler: () async => buildProfile(username: 'llx', nickname: '小柴'),
),
);
await profile.refresh();
await tester.pumpAndSettle();
expect(find.text('${greetingPrefix()},小柴 👋'), findsOneWidget);
expect(find.textContaining('豆豆'), findsNothing);
});
testWidgets('无昵称:问候语回退 username(与资料页同规则)', (tester) async {
final profile = await pumpHome(
tester,
auth: FakeAuthRepository(
meHandler: () async => buildProfile(username: 'llx'),
),
);
await profile.refresh();
await tester.pumpAndSettle();
expect(find.text('${greetingPrefix()}llx 👋'), findsOneWidget);
});
testWidgets('资料未到手:只问候不称名(不编造假名)', (tester) async {
await pumpHome(
tester,
auth: FakeAuthRepository(
meHandler: () async => throw const ApiNetworkException('断网'),
),
);
await tester.pumpAndSettle();
expect(find.text('${greetingPrefix()} 👋'), findsOneWidget);
// 退 demo
expect(find.textContaining('${greetingPrefix()}'), findsNothing);
});
testWidgets('改昵称后首页同步(与资料页共用同一控制器,无需手动刷新)', (tester) async {
final auth = FakeAuthRepository(
meHandler: () async => buildProfile(username: 'llx'),
updateMeHandler: (_) async =>
buildProfile(username: 'llx', nickname: '小柴'),
);
final profile = await pumpHome(tester, auth: auth);
await profile.refresh();
await tester.pumpAndSettle();
expect(find.text('${greetingPrefix()}llx 👋'), findsOneWidget);
await profile.save(
const UpdateMeRequest(nickname: PatchField<String>.value('小柴')),
);
await tester.pumpAndSettle();
expect(find.text('${greetingPrefix()},小柴 👋'), findsOneWidget);
});
testWidgets('刻意保留的 demo 占位仍在(ADR-022 钉死的范围)', (tester) async {
final profile = await pumpHome(
tester,
auth: FakeAuthRepository(meHandler: () async => buildProfile()),
);
await profile.refresh();
await tester.pumpAndSettle();
// /
expect(find.text('北京 · 朝阳区'), findsOneWidget);
expect(find.text('28°C 晴'), findsOneWidget);
// = ADR-018
expect(find.text('柴犬圈'), findsOneWidget);
// M5
expect(find.text('新用户首单立减 ¥20'), findsOneWidget);
});
}
+5 -5
View File
@@ -39,7 +39,7 @@ void main() {
late CommunityController controller; late CommunityController controller;
late List<(String, Map<String, dynamic>?)> events; late List<(String, Map<String, dynamic>?)> events;
late FeedAnalytics analytics; late FeedAnalytics analytics;
late int createTaps; late int composeTaps;
late List<String> openedPosts; late List<String> openedPosts;
setUp(() { setUp(() {
@@ -54,7 +54,7 @@ void main() {
analytics = FeedAnalytics( analytics = FeedAnalytics(
(name, [props]) async => events.add((name, props)), (name, [props]) async => events.add((name, props)),
); );
createTaps = 0; composeTaps = 0;
openedPosts = []; openedPosts = [];
}); });
@@ -72,7 +72,7 @@ void main() {
feedAnalytics: analytics, feedAnalytics: analytics,
isActive: isActive, isActive: isActive,
onOpenServices: (_) {}, onOpenServices: (_) {},
onOpenCreate: () => createTaps++, onOpenCompose: () => composeTaps++,
onOpenPost: openedPosts.add, onOpenPost: openedPosts.add,
), ),
), ),
@@ -98,7 +98,7 @@ void main() {
expect(find.byType(FeedSkeleton), findsNothing); expect(find.byType(FeedSkeleton), findsNothing);
}); });
testWidgets('四态 · empty:空态插画 + 「发布第一条」CTA 去创作', (tester) async { testWidgets('四态 · empty:空态插画 + 「发布第一条」CTA 进发布页', (tester) async {
repository.onFeed = (_, _) async => feedPage(const []); repository.onFeed = (_, _) async => feedPage(const []);
await pumpHome(tester); await pumpHome(tester);
@@ -108,7 +108,7 @@ void main() {
await tester.drag(list(), const Offset(0, -300)); await tester.drag(list(), const Offset(0, -300));
await tester.pump(); await tester.pump();
await tester.tap(find.text('发布第一条')); await tester.tap(find.text('发布第一条'));
expect(createTaps, 1); expect(composeTaps, 1);
}); });
testWidgets('四态 · error:横幅 + 重试恢复 readyfeed_load_failed 上报', (tester) async { testWidgets('四态 · error:横幅 + 重试恢复 readyfeed_load_failed 上报', (tester) async {
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_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/community_models.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
import '../../helpers/pet_test_helpers.dart';
/// T3-17 Tab
/// Feed M3
/// compose 26 §5
void main() {
testWidgets('创作 Tab「发布动态」→ 发布 → 回首页 Feed 刷新,新帖置顶', (tester) async {
SharedPreferences.setMockInitialValues({});
final session = SessionManager(store: InMemoryTokenStore())
..markAuthenticated();
final repository = FakeCommunityRepository();
var published = false;
repository.onFeed = (_, _) async => feedPage(
published
? [
FeedCard.fromJson({
...sampleFeedCardJson(id: 'p-new'),
'contentPreview': '刚发布的动态',
}),
sampleFeedCard(id: 'p-old'),
]
: [sampleFeedCard(id: 'p-old')],
);
repository.onCreatePost = (request, _) async =>
Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
repository.onUpdatePost = (postId, request) async {
published = true;
return Post.fromJson(samplePostJson(id: postId, version: 2));
};
await tester.pumpWidget(
App(
sessionManager: session,
authRepository: FakeAuthRepository(),
petsRepository: FakePetsRepository(),
communityRepository: repository,
mediaUploaderFactory: (repo, analytics) => MediaUploader(
repository: repo,
picker: FakeMediaImagePicker([decodablePickedImage()]),
compressor: FakeMediaCompressor(),
directUpload: FakeDirectUploadClient(),
analytics: analytics,
),
),
);
await tester.pumpAndSettle();
// Tab entryPoint=create_tab
await tester.tap(find.text('创作'));
await tester.pumpAndSettle();
await tester.tap(find.text('发布动态'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextButton, '存草稿'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, '刚发布的动态');
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '发布'));
await tester.pumpAndSettle();
// Feed+ +
expect(find.widgetWithText(TextButton, '存草稿'), findsNothing);
expect(find.text('已发布,去首页看看吧 🐾'), findsOneWidget);
expect(
repository.calls.where((call) => call.startsWith('feed:')),
hasLength(2),
);
// story /
await tester.drag(find.byType(ListView).first, const Offset(0, -320));
await tester.pumpAndSettle();
final cards = tester.widgetList<PostCard>(find.byType(PostCard)).toList();
expect(cards.first.card.id, 'p-new');
expect(find.text('刚发布的动态'), findsWidgets);
});
}
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/features/pets/care_reminder_form_page.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_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
@@ -34,6 +36,10 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01 zh-CN delegate
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: Builder( home: Builder(
builder: (context) => Scaffold( builder: (context) => Scaffold(
body: Center( body: Center(
@@ -67,7 +73,7 @@ void main() {
); );
await tester.tap(find.text('到期日期')); await tester.tap(find.text('到期日期'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('OK')); await tester.tap(find.text('确定'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@@ -124,4 +130,36 @@ void main() {
expect(failed[1]!['failureReason'], 'network_error'); expect(failed[1]!['failureReason'], 'network_error');
expect(failed[1]!['attemptSeq'], 2); expect(failed[1]!['attemptSeq'], 2);
}); });
group('M3.5-02 · 到期日期录入(firstDate 就是今天的那一格)', () {
testWidgets('未选日期时也有「今天」快捷键;一键落今天并清掉必填校验错', (tester) async {
await pumpForm(tester);
//
await tester.tap(find.text('保存提醒'));
await tester.pumpAndSettle();
expect(find.text('未选择'), findsOneWidget);
await tester.tap(find.text('今天'));
await tester.pumpAndSettle();
expect(find.text(dateToJson(DateTime.now())), findsOneWidget);
expect(find.text('未选择'), findsNothing);
expect(find.text('选择日期'), findsNothing);
});
testWidgets('业务约束不变:firstDate 今天(不许补记过去)、lastDate 五年后', (tester) async {
await pumpForm(tester);
await tester.tap(find.widgetWithText(ListTile, '到期日期'));
await tester.pumpAndSettle();
final dialog = tester.widget<DatePickerDialog>(
find.byType(DatePickerDialog),
);
expect(dialog.firstDate, dateOnly(DateTime.now()));
expect(dialog.lastDate, DateTime(DateTime.now().year + 5));
expect(find.text('选择日期'), findsOneWidget);
});
});
} }
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/app/app_localization.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/empty_state_illustration.dart';
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart'; import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
@@ -39,6 +40,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01 zh-CN delegate
//
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: const Scaffold(body: Text('详情基底')), home: const Scaffold(body: Text('详情基底')),
), ),
); );
@@ -206,7 +212,7 @@ void main() {
); );
await tester.tap(find.text('到期日期')); await tester.tap(find.text('到期日期'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('OK')); await tester.tap(find.text('确定'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('保存提醒')); await tester.tap(find.text('保存提醒'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -1,7 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.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/app_date_picker.dart';
import 'package:patbond_flutter/features/pets/health_event_form_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_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
@@ -34,6 +38,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01 zh-CN delegate
// 西
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: Builder( home: Builder(
builder: (context) => Scaffold( builder: (context) => Scaffold(
body: Center( body: Center(
@@ -192,4 +201,77 @@ void main() {
expect(failed[2]!['failureReason'], 'network_error'); expect(failed[2]!['failureReason'], 'network_error');
expect(failed[2]!['attemptSeq'], 3); expect(failed[2]!['attemptSeq'], 3);
}); });
group('M3.5-02 · 日期录入(用户误录 2026-04 的那一格)', () {
testWidgets('点「今天」直接落今天,不开弹窗;同时算首次输入触发 started', (tester) async {
await pumpForm(tester);
final expected = dateToJson(DateTime.now());
//
await tester.tap(find.widgetWithText(ListTile, '发生日期'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit_outlined));
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(
of: find.byType(DatePickerDialog),
matching: find.byType(TextField),
),
'2026/04/09',
);
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(find.text('2026-04-09'), findsOneWidget);
//
await tester.tap(find.text('今天'));
await tester.pumpAndSettle();
expect(find.text(expected), findsOneWidget);
expect(find.text('选择日期'), findsNothing);
expect(eventsOf('health_record_create_started'), hasLength(1));
});
testWidgets('选择器为中文,且业务约束不变(firstDate 1990 / lastDate 今天,不许未来)', (
tester,
) async {
await pumpForm(tester);
await tester.tap(find.widgetWithText(ListTile, '发生日期'));
await tester.pumpAndSettle();
expect(find.text('选择日期'), findsOneWidget);
expect(find.text('确定'), findsOneWidget);
expect(find.text('取消'), findsOneWidget);
// calendarOnly
expect(find.byIcon(Icons.edit_outlined), findsOneWidget);
final dialog = tester.widget<DatePickerDialog>(
find.byType(DatePickerDialog),
);
expect(dialog.firstDate, DateTime(1990));
expect(dialog.lastDate, dateOnly(DateTime.now()));
});
testWidgets('提交中禁用「今天」快捷键', (tester) async {
final gate = Completer<HealthEvent>();
repository.createHealthEventHandler = (petId, req) => gate.future;
await pumpForm(tester);
await fillValid(tester);
await tester.tap(find.text('保存记录'));
await tester.pump();
expect(
tester
.widget<TextButton>(find.widgetWithText(TextButton, '今天'))
.onPressed,
isNull,
);
gate.complete(buildHealthEvent('he-1'));
await tester.pumpAndSettle();
});
});
} }
@@ -277,4 +277,30 @@ void main() {
expect(reminderStatusColor(dismissed, now), AppColors.muted); expect(reminderStatusColor(dismissed, now), AppColors.muted);
}); });
}); });
group('M3.5-03 · monthlyExpenseCardLabel(花费卡展示实际月份)', () {
test('同年只给月份,卡片一行放得下(不再硬编码「本月花费」)', () {
final now = DateTime(2026, 9, 10);
expect(monthlyExpenseCardLabel('2026-09', now: now), '9 月花费');
// 4 4
expect(monthlyExpenseCardLabel('2026-04', now: now), '4 月花费');
expect(monthlyExpenseCardLabel('2026-12', now: now), '12 月花费');
expect(monthlyExpenseCardLabel('2026-01', now: now), '1 月花费');
});
test('跨年(服务端归月年份 ≠ 设备当前年份)补年份消歧', () {
// 2027-01 tz 2026-12
final now = DateTime(2027, 1, 1);
expect(monthlyExpenseCardLabel('2026-12', now: now), '2026/12 花费');
});
test('月份串非法退回「本月花费」(不崩、不显示脏值)', () {
final now = DateTime(2026, 9, 10);
expect(monthlyExpenseCardLabel('', now: now), '本月花费');
expect(monthlyExpenseCardLabel('2026-9', now: now), '本月花费');
expect(monthlyExpenseCardLabel('2026-13', now: now), '本月花费');
expect(monthlyExpenseCardLabel('2026-00', now: now), '本月花费');
expect(monthlyExpenseCardLabel('2026-09-01', now: now), '本月花费');
});
});
} }
@@ -0,0 +1,323 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/media_uploader.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_exceptions.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/pets_page.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
import '../../helpers/pet_test_helpers.dart';
/// T3.5-09 线
///
///
/// 1. ** WRITE **owner + caregiver viewer
/// MANAGE owner
/// 2. ** PATCH ** MANAGE
/// caregiver 403
void main() {
const signedAvatar =
'https://minio.local/patbond-media/pet_avatar/2026/09/a-1'
'?X-Amz-Signature=sig';
late FakePetsRepository pets;
late PetsController controller;
late FakeCommunityRepository community;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
setUp(() {
pets = FakePetsRepository();
controller = PetsController(repository: pets);
community = FakeCommunityRepository();
picker = FakeMediaImagePicker([decodablePickedImage()]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
community.onCreateMediaUpload = (_) async => credentials();
community.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
});
MediaUploader buildUploader(MediaPurpose purpose) => MediaUploader(
repository: community,
purpose: purpose,
picker: picker,
compressor: compressor,
directUpload: direct,
maxImages: 1,
maxConcurrentUploads: 1,
);
Future<void> pumpDetail(
WidgetTester tester, {
bool withUploader = true,
}) async {
tester.view.physicalSize = const Size(700, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const Scaffold(body: Text('列表基底')),
),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
builder: (_) => PetDetailPage(
controller: controller,
petId: 'p-1',
avatarUploaderBuilder: withUploader ? buildUploader : null,
),
),
),
);
await tester.pump();
}
group('契约字段', () {
test('Pet.avatarUrl 解析(无头像为 null;不外露 avatarAssetId', () {
expect(buildPet('p-1').avatarUrl, isNull);
expect(
buildPet('p-1', overrides: {'avatarUrl': signedAvatar}).avatarUrl,
signedAvatar,
);
});
test('UpdatePetRequest:只带头像时载荷仅 version + avatarAssetId', () {
const request = UpdatePetRequest(
version: 3,
avatarAssetId: PatchField<String>.value('a-1'),
);
expect(request.toJson(), {'version': 3, 'avatarAssetId': 'a-1'});
});
test('UpdatePetRequest:清除头像为显式 null;缺省则键不出现', () {
expect(
const UpdatePetRequest(
version: 3,
avatarAssetId: PatchField<String>.clear(),
).toJson(),
{'version': 3, 'avatarAssetId': null},
);
final absent = const UpdatePetRequest(version: 3, name: '豆豆').toJson();
expect(absent, {'version': 3, 'name': '豆豆'});
expect(
absent.containsKey('avatarAssetId'),
isFalse,
reason: '改名请求不该顺手把头像清掉',
);
});
test('头像写入失败文案分层(40405 / 42203 与 40401 区分开)', () {
expect(
petAvatarSaveErrorMessage(
const ApiBusinessException(
code: ApiCodes.mediaNotFound,
message: 'x',
),
),
'头像已失效,请重新上传',
);
expect(
petAvatarSaveErrorMessage(
const ApiBusinessException(
code: ApiCodes.mediaNotReady,
message: 'x',
),
),
'头像还没上传完,请稍后重试',
);
expect(
petAvatarSaveErrorMessage(
const ApiBusinessException(
code: ApiCodes.petAccessDenied,
message: 'x',
),
),
'你没有修改该宠物头像的权限',
);
});
});
group('权限呈现(WRITE 档)', () {
testWidgets('owner:头像可点 + 铅笔角标', (tester) async {
pets.getPetHandler = (_) async => buildPet('p-1');
await pumpDetail(tester);
await tester.pumpAndSettle();
final avatar = tester.widget<PetAvatar>(find.byType(PetAvatar));
expect(avatar.showEditBadge, isTrue);
expect(avatar.onTap, isNotNull);
expect(avatar.semanticLabel, '更换宠物头像');
});
testWidgets('caregiver:头像入口在(WRITE 档),但资料编辑入口不在(MANAGE 档)', (tester) async {
pets.getPetHandler = (_) async =>
buildPet('p-1', overrides: {'myRole': 'caregiver'});
await pumpDetail(tester);
await tester.pumpAndSettle();
final avatar = tester.widget<PetAvatar>(find.byType(PetAvatar));
expect(avatar.showEditBadge, isTrue, reason: 'ADR-022:头像属 WRITE 档');
expect(avatar.onTap, isNotNull);
expect(find.text('编辑资料'), findsNothing, reason: '改档案仍是 MANAGE 档');
});
testWidgets('viewer:头像不可点、无角标', (tester) async {
pets.getPetHandler = (_) async =>
buildPet('p-1', overrides: {'myRole': 'viewer'});
await pumpDetail(tester);
await tester.pumpAndSettle();
final avatar = tester.widget<PetAvatar>(find.byType(PetAvatar));
expect(avatar.showEditBadge, isFalse);
expect(avatar.onTap, isNull);
});
testWidgets('未装配上传能力:owner 也不渲染头像入口', (tester) async {
pets.getPetHandler = (_) async => buildPet('p-1');
await pumpDetail(tester, withUploader: false);
await tester.pumpAndSettle();
final avatar = tester.widget<PetAvatar>(find.byType(PetAvatar));
expect(avatar.showEditBadge, isFalse);
expect(avatar.onTap, isNull);
});
});
group('上传与清除', () {
testWidgets('无头像 → 点头像直接上传 → PATCH 只带 version + avatarAssetId', (
tester,
) async {
pets.getPetHandler = (_) async => buildPet('p-1', version: 3);
UpdatePetRequest? captured;
pets.updatePetHandler = (petId, request) async {
captured = request;
return buildPet(
'p-1',
version: 4,
overrides: {'avatarUrl': signedAvatar},
);
};
await pumpDetail(tester);
await tester.pumpAndSettle();
await tester.tap(find.byType(PetAvatar));
await tester.pumpAndSettle();
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
expect(community.lastMediaUploadRequest!.purpose, MediaPurpose.petAvatar);
expect(captured!.toJson(), {'version': 3, 'avatarAssetId': 'a-1'});
expect(find.text('头像已更新'), findsOneWidget);
// URL
expect(
tester.widget<PetAvatar>(find.byType(PetAvatar)).url,
signedAvatar,
);
});
testWidgets('已有头像 → 点头像给「更换 / 移除」;移除发显式 null', (tester) async {
pets.getPetHandler = (_) async =>
buildPet('p-1', version: 5, overrides: {'avatarUrl': signedAvatar});
UpdatePetRequest? captured;
pets.updatePetHandler = (petId, request) async {
captured = request;
return buildPet('p-1', version: 6);
};
await pumpDetail(tester);
await tester.pumpAndSettle();
await tester.tap(find.byType(PetAvatar));
await tester.pumpAndSettle();
expect(find.text('更换头像'), findsOneWidget);
expect(find.text('移除头像'), findsOneWidget);
await tester.tap(find.text('移除头像'));
await tester.pumpAndSettle();
final payload = captured!.toJson();
expect(payload.containsKey('avatarAssetId'), isTrue);
expect(payload['avatarAssetId'], isNull);
expect(payload['version'], 5);
expect(payload.keys.length, 2, reason: '不得夹带任何资料字段(否则抬到 MANAGE 档)');
expect(find.text('已移除头像'), findsOneWidget);
expect(tester.widget<PetAvatar>(find.byType(PetAvatar)).url, isNull);
});
testWidgets('40902 版本冲突:提示 + 自动重取档案(不静默重放)', (tester) async {
var getCalls = 0;
pets.getPetHandler = (_) async {
getCalls += 1;
return buildPet('p-1', version: getCalls == 1 ? 3 : 9);
};
pets.updatePetHandler = (petId, request) async =>
throw const PetVersionConflictException(message: 'stale');
await pumpDetail(tester);
await tester.pumpAndSettle();
await tester.tap(find.byType(PetAvatar));
await tester.pumpAndSettle();
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
expect(find.text('档案已被更新,请重新操作'), findsOneWidget);
expect(getCalls, 2, reason: '冲突后重取档案拿新 version');
});
testWidgets('42203 头像未就绪:SnackBar 提示,不改动头像', (tester) async {
pets.getPetHandler = (_) async => buildPet('p-1', version: 3);
pets.updatePetHandler = (petId, request) async =>
throw const ApiBusinessException(
code: ApiCodes.mediaNotReady,
message: 'uploading',
);
await pumpDetail(tester);
await tester.pumpAndSettle();
await tester.tap(find.byType(PetAvatar));
await tester.pumpAndSettle();
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
expect(find.text('头像还没上传完,请稍后重试'), findsOneWidget);
expect(tester.widget<PetAvatar>(find.byType(PetAvatar)).url, isNull);
});
});
group('列表展示', () {
testWidgets('列表卡展示真实头像;无头像回退占位', (tester) async {
pets.listPetsHandler = () async => [
buildPet('p-1', name: '豆豆', overrides: {'avatarUrl': signedAvatar}),
buildPet('p-2', name: '花花'),
];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: PetsPage(controller: controller)),
),
);
await tester.pumpAndSettle();
final avatars = tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.toList();
expect(avatars.length, 2);
expect(avatars[0].url, signedAvatar);
expect(avatars[1].url, isNull, reason: '无头像回退爪印占位(不显示破图)');
});
});
}
+61 -6
View File
@@ -188,12 +188,24 @@ void main() {
}); });
group('T2-13 · 数据卡行接 summary', () { group('T2-13 · 数据卡行接 summary', () {
testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 月花费(tz 透传)', (tester) async { testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 月花费(tz 透传)', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetHandler = (petId) async => buildPet('p-1');
String? capturedTz; String? capturedTz;
// M3.5-03
// monthlyExpenseCardLabel
final now = DateTime.now();
final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}';
repository.getPetSummaryHandler = (petId, tz) async { repository.getPetSummaryHandler = (petId, tz) async {
capturedTz = tz; capturedTz = tz;
return buildSummary(); return buildSummary(
overrides: {
'monthlyExpense': {
'month': thisMonth,
'timezone': 'Asia/Shanghai',
'amountCents': 12850,
},
},
);
}; };
await pumpDetail(tester); await pumpDetail(tester);
@@ -208,12 +220,45 @@ void main() {
expect(find.text('下一针·狂犬疫苗'), findsOneWidget); expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
// T2-14 monthlyExpense12850 // T2-14 monthlyExpense12850
expect(find.text('¥128.50'), findsOneWidget); expect(find.text('¥128.50'), findsOneWidget);
expect(find.text('本月花费'), findsOneWidget); // M3.5-03便
expect(find.text('${now.month} 月花费'), findsOneWidget);
expect(find.text('本月花费'), findsNothing);
// T2-13 tz // T2-13 tz
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset)); expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$'))); expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$')));
}); });
testWidgets('M3.5-03 · 四张数据卡均有 chevron 可点提示', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
final now = DateTime.now();
final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}';
repository.getPetSummaryHandler = (petId, tz) async => buildSummary(
overrides: {
'monthlyExpense': {
'month': thisMonth,
'timezone': 'Asia/Shanghai',
'amountCents': 0,
},
},
);
await pumpDetail(tester);
await tester.pumpAndSettle();
// stat onTap
// chevron_right /
for (final label in ['最新体重', '疫苗进度', '下一针·狂犬疫苗', '${now.month} 月花费']) {
expect(
find.ancestor(
of: find.text(label),
matching: find.widgetWithIcon(Card, Icons.chevron_right),
),
findsOneWidget,
reason: '$label」卡缺可点提示',
);
}
});
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async { testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetHandler = (petId) async => buildPet('p-1');
repository.getPetSummaryHandler = (petId, tz) async => buildSummary( repository.getPetSummaryHandler = (petId, tz) async => buildSummary(
@@ -302,12 +347,22 @@ void main() {
}); });
group('T2-14 · 时间线入口', () { group('T2-14 · 时间线入口', () {
testWidgets('月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async { testWidgets('月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetHandler = (petId) async => buildPet('p-1');
var summaryCalls = 0; var summaryCalls = 0;
final now = DateTime.now();
final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}';
repository.getPetSummaryHandler = (petId, tz) async { repository.getPetSummaryHandler = (petId, tz) async {
summaryCalls++; summaryCalls++;
return buildSummary(); return buildSummary(
overrides: {
'monthlyExpense': {
'month': thisMonth,
'timezone': 'Asia/Shanghai',
'amountCents': 12850,
},
},
);
}; };
repository.listHealthEventsHandler = (petId, limit, cursor) async => repository.listHealthEventsHandler = (petId, limit, cursor) async =>
const CursorPage(items: [], nextCursor: null, hasMore: false); const CursorPage(items: [], nextCursor: null, hasMore: false);
@@ -315,7 +370,7 @@ void main() {
await pumpDetail(tester); await pumpDetail(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('月花费')); await tester.tap(find.text('${now.month} 月花费'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(HealthEventsPage), findsOneWidget); expect(find.byType(HealthEventsPage), findsOneWidget);
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
@@ -36,6 +37,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01 zh-CN delegate
//
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: const Scaffold(body: Text('列表基底')), home: const Scaffold(body: Text('列表基底')),
), ),
); );
@@ -65,7 +71,7 @@ void main() {
Future<void> pickDate(WidgetTester tester, String tileLabel) async { Future<void> pickDate(WidgetTester tester, String tileLabel) async {
await tester.tap(find.text(tileLabel)); await tester.tap(find.text(tileLabel));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('OK')); await tester.tap(find.text('确定'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/app/app_localization.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/empty_state_illustration.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
@@ -39,6 +40,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01 zh-CN delegate
//
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: const Scaffold(body: Text('详情基底')), home: const Scaffold(body: Text('详情基底')),
), ),
); );
@@ -166,7 +172,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('计划接种日期')); await tester.tap(find.text('计划接种日期'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('OK')); await tester.tap(find.text('确定'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('保存登记')); await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -0,0 +1,200 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
late FakeCommunityRepository community;
setUp(() {
community = FakeCommunityRepository();
});
ProfileController build(FakeAuthRepository auth) =>
ProfileController(authRepository: auth, communityRepository: community);
test('四态:initial → loading → ready(资料 + 两块统计齐备)', () async {
final gate = Completer<UserProfile>();
final controller = build(FakeAuthRepository(meHandler: () => gate.future));
community.onGetMyCommunityStats = () async =>
CommunityStats.fromJson(sampleCommunityStatsJson());
community.onGetFollowStats = (_) async =>
FollowStats.fromJson(sampleFollowStatsJson());
expect(controller.phase, ProfileLoadPhase.initial);
final pending = controller.refresh();
expect(controller.phase, ProfileLoadPhase.loading);
gate.complete(buildProfile(nickname: '小柴'));
await pending;
expect(controller.phase, ProfileLoadPhase.ready);
expect(controller.statsPhase, ProfileStatsPhase.ready);
expect(controller.displayName, '小柴');
expect(controller.communityStats!.receivedLikeCount, 128);
expect(controller.followStats!.followerCount, 24);
});
test('/me 失败且无副本 → error 态 + 可重试;不外抛', () async {
var attempts = 0;
final auth = FakeAuthRepository(
meHandler: () async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException('断网');
return buildProfile(nickname: '小柴');
},
);
final controller = build(auth);
await controller.refresh();
expect(controller.phase, ProfileLoadPhase.error);
expect(controller.lastError, isA<ApiNetworkException>());
expect(controller.profile, isNull);
await controller.refresh();
expect(controller.phase, ProfileLoadPhase.ready);
expect(controller.displayName, '小柴');
});
test('已有副本时刷新失败保留副本(不打断阅读,停在 ready)', () async {
var attempts = 0;
final controller = build(
FakeAuthRepository(
meHandler: () async {
attempts += 1;
if (attempts == 1) return buildProfile(nickname: '小柴');
throw const ApiNetworkException('断网');
},
),
);
await controller.refresh();
await controller.refresh();
expect(controller.phase, ProfileLoadPhase.ready);
expect(controller.displayName, '小柴');
expect(controller.lastError, isA<ApiNetworkException>());
});
test('统计失败只降级统计块,资料仍 ready;refreshStats 可单独重试', () async {
var statsAttempts = 0;
community.onGetMyCommunityStats = () async {
statsAttempts += 1;
if (statsAttempts == 1) throw const ApiNetworkException('断网');
return CommunityStats.fromJson(sampleCommunityStatsJson());
};
community.onGetFollowStats = (_) async =>
FollowStats.fromJson(sampleFollowStatsJson());
final controller = build(FakeAuthRepository());
await controller.refresh();
expect(controller.phase, ProfileLoadPhase.ready);
expect(controller.statsPhase, ProfileStatsPhase.error);
expect(controller.communityStats, isNull);
await controller.refreshStats();
expect(controller.statsPhase, ProfileStatsPhase.ready);
expect(controller.phase, ProfileLoadPhase.ready);
});
test('空数据:两个数字为 0(不是 null、不是 404)', () async {
community.onGetMyCommunityStats = () async => CommunityStats.fromJson(
sampleCommunityStatsJson(receivedLikeCount: 0, publishedPostCount: 0),
);
community.onGetFollowStats = (_) async => FollowStats.fromJson(
sampleFollowStatsJson(followerCount: 0, followingCount: 0),
);
final controller = build(FakeAuthRepository());
await controller.refresh();
expect(controller.communityStats!.receivedLikeCount, 0);
expect(controller.communityStats!.publishedPostCount, 0);
});
test('save:空 patch 直接短路,不发请求(服务端对空 patch 答 400)', () async {
final auth = FakeAuthRepository(
updateMeHandler: (_) async => buildProfile(nickname: '不该被调用'),
);
final controller = build(auth);
await controller.refresh();
await controller.save(const UpdateMeRequest());
expect(auth.updateMePayloads, isEmpty);
});
test('save 成功即以服务端回显替换副本(含 avatarUrl 现签值)', () async {
final auth = FakeAuthRepository(
updateMeHandler: (_) async => buildProfile(
nickname: '小柴',
avatarUrl: 'https://m/a.jpg?X-Amz-Signature=new',
),
);
final controller = build(auth);
await controller.refresh();
expect(controller.displayName, 'llx');
await controller.save(
const UpdateMeRequest(nickname: PatchField<String>.value('小柴')),
);
expect(controller.displayName, '小柴');
expect(controller.profile!.avatarUrl, contains('X-Amz-Signature=new'));
expect(auth.updateMePayloads.single, {'nickname': '小柴'});
});
test('save 失败按类型化异常外抛(供编辑页分层呈现),副本不动', () async {
final auth = FakeAuthRepository(
updateMeHandler: (_) async => throw const ApiBusinessException(
code: ApiCodes.mediaNotReady,
message: 'not ready',
),
);
final controller = build(auth);
await controller.refresh();
await expectLater(
controller.save(
const UpdateMeRequest(avatarAssetId: PatchField<String>.value('a-1')),
),
throwsA(
isA<ApiBusinessException>().having(
(e) => e.code,
'code',
ApiCodes.mediaNotReady,
),
),
);
expect(controller.displayName, 'llx');
});
test('reset:登出清空资料与统计,回 initial(跨账号不泄漏)', () async {
final controller = build(FakeAuthRepository());
await controller.refresh();
expect(controller.profile, isNotNull);
controller.reset();
expect(controller.phase, ProfileLoadPhase.initial);
expect(controller.profile, isNull);
expect(controller.displayName, isNull);
expect(controller.communityStats, isNull);
expect(controller.followStats, isNull);
});
test('follow-stats 用的是本人 userId(路径主体正确)', () async {
final controller = build(
FakeAuthRepository(meHandler: () async => buildProfile(userId: 'u-42')),
);
await controller.refresh();
expect(community.calls, contains('followStats:u-42'));
expect(community.calls, contains('communityStats'));
});
}
@@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
/// T3.5-08 **PATCH **
///
/// JSON
/// 线
/// AppTextField TextFormField
Finder get nicknameField => find.byType(TextFormField);
void main() {
late FakeCommunityRepository community;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
setUp(() {
community = FakeCommunityRepository();
community.onGetMyCommunityStats = () async =>
CommunityStats.fromJson(sampleCommunityStatsJson());
community.onGetFollowStats = (_) async =>
FollowStats.fromJson(sampleFollowStatsJson());
picker = FakeMediaImagePicker([decodablePickedImage()]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
community.onCreateMediaUpload = (_) async => credentials();
community.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
});
MediaUploader buildUploader(MediaPurpose purpose) => MediaUploader(
repository: community,
purpose: purpose,
picker: picker,
compressor: compressor,
directUpload: direct,
maxImages: 1,
maxConcurrentUploads: 1,
);
/// push
Future<FakeAuthRepository> pumpEdit(
WidgetTester tester, {
String? nickname,
String? avatarUrl,
bool withUploader = true,
ApiException? saveError,
}) async {
tester.view.physicalSize = const Size(700, 1800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final auth = FakeAuthRepository(
meHandler: () async => buildProfile(
username: 'llx',
nickname: nickname,
avatarUrl: avatarUrl,
),
updateMeHandler: (_) async {
if (saveError != null) throw saveError;
return buildProfile(username: 'llx', nickname: nickname);
},
);
final controller = ProfileController(
authRepository: auth,
communityRepository: community,
);
addTearDown(controller.dispose);
await controller.refresh();
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: ProfileEditPage(
controller: controller,
avatarUploaderBuilder: withUploader ? buildUploader : null,
),
),
);
await tester.pumpAndSettle();
return auth;
}
group('三态载荷', () {
testWidgets('只改昵称 → 载荷只有 nicknameavatarAssetId 键不出现', (tester) async {
final auth = await pumpEdit(
tester,
avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s',
);
await tester.enterText(nicknameField, '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
final payload = auth.updateMePayloads.single;
expect(payload, {'nickname': '小柴'});
expect(
payload.containsKey('avatarAssetId'),
isFalse,
reason: '发成 avatarAssetId: null 会把用户已有头像一起清掉',
);
});
testWidgets('点「清除昵称」 → nickname 显式 null,头像键不出现', (tester) async {
final auth = await pumpEdit(
tester,
nickname: '小柴',
avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s',
);
await tester.tap(find.text('清除昵称'));
await tester.pumpAndSettle();
expect(find.text('保存后将清除昵称,展示名回退为用户名'), findsOneWidget);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
final payload = auth.updateMePayloads.single;
expect(payload.containsKey('nickname'), isTrue);
expect(payload['nickname'], isNull);
expect(payload.containsKey('avatarAssetId'), isFalse);
});
testWidgets('点「清除头像」 → avatarAssetId 显式 nullnickname 键不出现', (
tester,
) async {
final auth = await pumpEdit(
tester,
nickname: '小柴',
avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s',
);
await tester.tap(find.text('清除头像'));
await tester.pumpAndSettle();
expect(find.text('保存后将移除头像'), findsOneWidget);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
final payload = auth.updateMePayloads.single;
expect(payload.containsKey('avatarAssetId'), isTrue);
expect(payload['avatarAssetId'], isNull);
expect(
payload.containsKey('nickname'),
isFalse,
reason: '昵称没碰过就不该出现在载荷里(更不该是 null)',
);
});
testWidgets('输入与原昵称相同 → 视为未改,保存钮禁用、不发请求', (tester) async {
final auth = await pumpEdit(tester, nickname: '小柴');
//
expect(find.text('小柴'), findsOneWidget);
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '保存'),
);
expect(button.onPressed, isNull);
expect(auth.updateMePayloads, isEmpty);
});
testWidgets('无昵称时预填空串(不预填 username,避免把回退固化成真昵称)', (tester) async {
await pumpEdit(tester);
final field = tester.widget<TextFormField>(nicknameField);
expect(field.controller!.text, '');
expect(find.text('未设置,当前展示为用户名「llx」'), findsOneWidget);
// 西
expect(find.text('清除昵称'), findsNothing);
});
});
group('校验与失败语义', () {
testWidgets('33 码点昵称:字段级 errorText,不发请求', (tester) async {
final auth = await pumpEdit(tester);
await tester.enterText(nicknameField, '' * 33);
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.text('昵称最多 32 个字'), findsOneWidget);
expect(auth.updateMePayloads, isEmpty);
});
testWidgets('纯空格昵称:不是隐式清空,提示走显式清除入口', (tester) async {
final auth = await pumpEdit(tester, nickname: '小柴');
await tester.enterText(nicknameField, ' ');
await tester.pump();
//
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '保存'),
);
expect(button.onPressed, isNull);
expect(auth.updateMePayloads, isEmpty);
});
testWidgets('42203 头像未就绪 → 横幅提示,页面留在编辑态可重试', (tester) async {
await pumpEdit(
tester,
saveError: const ApiBusinessException(
code: ApiCodes.mediaNotReady,
message: 'asset uploading',
),
);
await tester.enterText(nicknameField, '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsOneWidget);
expect(find.text('头像还没上传完,请稍后重试'), findsOneWidget);
expect(find.byType(ProfileEditPage), findsOneWidget);
});
testWidgets('40405 头像 asset 失效 → 提示重新上传', (tester) async {
await pumpEdit(
tester,
saveError: const ApiBusinessException(
code: ApiCodes.mediaNotFound,
message: 'gone',
),
);
await tester.enterText(nicknameField, '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.text('头像已失效,请重新上传'), findsOneWidget);
});
});
group('头像上传接线', () {
testWidgets('上传全链路:purpose=user_avatarready 后确认 → 载荷带新 assetId', (
tester,
) async {
final auth = await pumpEdit(tester);
await tester.tap(find.text('更换头像'));
await tester.pumpAndSettle();
// sheet + 使
expect(find.text('使用这张'), findsOneWidget);
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
// user_avatar
final request = community.lastMediaUploadRequest!;
expect(request.purpose, MediaPurpose.userAvatar);
expect(request.purpose.wire, 'user_avatar');
expect(find.text('已选择\n新头像'), findsOneWidget);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
final payload = auth.updateMePayloads.single;
expect(payload, {'avatarAssetId': 'a-1'});
expect(payload.containsKey('nickname'), isFalse);
});
testWidgets('上传后再改昵称 → 两键齐出(一次 PATCH 改两样)', (tester) async {
final auth = await pumpEdit(tester);
await tester.tap(find.text('更换头像'));
await tester.pumpAndSettle();
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
await tester.enterText(nicknameField, '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(auth.updateMePayloads.single, {
'nickname': '小柴',
'avatarAssetId': 'a-1',
});
});
});
}
@@ -0,0 +1,153 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/profile/profile_display.dart';
import '../../helpers/auth_test_helpers.dart';
void main() {
group('展示名回退 nickname ?? username', () {
test('有昵称:展示昵称,nickname 字段保留 DB 原值', () {
final profile = buildProfile(username: 'llx', nickname: '小柴');
expect(profile.displayName, '小柴');
expect(profile.nickname, '小柴');
});
test('无昵称:展示 username,但 nickname 仍为 null(不被回退值污染)', () {
final profile = buildProfile(username: 'llx');
expect(profile.displayName, 'llx');
expect(
profile.nickname,
isNull,
reason: '回退只在展示层发生;nickname 必须保持 null,否则编辑页预填后会被固化成真昵称',
);
});
test('hasAvatar 只看 avatarUrl(响应不外露 avatarAssetId', () {
expect(buildProfile().hasAvatar, isFalse);
expect(
buildProfile(avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s').hasAvatar,
isTrue,
);
});
});
group('UpdateMeRequest 三态载荷', () {
test('什么都没碰 → isEmpty 且 JSON 为空对象', () {
const request = UpdateMeRequest();
expect(request.isEmpty, isTrue);
expect(request.toJson(), isEmpty);
});
test('只改昵称:avatarAssetId 键**不出现**(不改,而不是清空)', () {
const request = UpdateMeRequest(nickname: PatchField<String>.value('小柴'));
final json = request.toJson();
expect(json, {'nickname': '小柴'});
expect(
json.containsKey('avatarAssetId'),
isFalse,
reason: '发成 avatarAssetId: null 会把用户头像一起清掉',
);
});
test('只清昵称:nickname 显式 null,头像键仍不出现', () {
const request = UpdateMeRequest(nickname: PatchField<String>.clear());
final json = request.toJson();
expect(json.containsKey('nickname'), isTrue);
expect(json['nickname'], isNull);
expect(json.containsKey('avatarAssetId'), isFalse);
});
test('一次改两样:两键齐出', () {
const request = UpdateMeRequest(
nickname: PatchField<String>.value('小柴'),
avatarAssetId: PatchField<String>.value('asset-9'),
);
expect(request.toJson(), {'nickname': '小柴', 'avatarAssetId': 'asset-9'});
expect(request.isEmpty, isFalse);
});
test('只清头像:nickname 键不出现', () {
const request = UpdateMeRequest(
avatarAssetId: PatchField<String>.clear(),
);
final json = request.toJson();
expect(json.containsKey('nickname'), isFalse);
expect(json['avatarAssetId'], isNull);
});
});
group('昵称校验与 ck_users_nickname 对齐', () {
test('1 码点通过;32 CJK 通过;33 拒', () {
expect(validateNickname(''), isNull);
expect(validateNickname('' * 32), isNull);
expect(validateNickname('' * 33), isNotNull);
});
test('32 个 emojiUTF-16 长度 64)通过——长度按码点计而非 String.length', () {
const emoji = '🐕';
final name = emoji * 32;
expect(name.length, 64, reason: '前提:UTF-16 长度确为 64');
expect(
validateNickname(name),
isNull,
reason: '按 String.length 校验会误拒数据库能存的 32 emoji 昵称',
);
expect(validateNickname(emoji * 33), isNotNull);
});
test('btrim 对齐:前后空格不计入长度', () {
expect(validateNickname(' 豆豆 '), isNull);
expect(validateNickname(' ${'' * 32} '), isNull);
});
test('纯空白不是隐式清空,而是校验失败并指向显式清除入口', () {
final message = validateNickname(' ');
expect(message, isNotNull);
expect(message, contains('清除昵称'));
});
});
group('错误文案分层', () {
test('保存失败按 PATCH /me 错误谱分层', () {
expect(
profileSaveErrorMessage(
const ApiBusinessException(code: ApiCodes.paramError, message: 'x'),
),
'填写内容不符合要求,请检查后重试',
);
expect(
profileSaveErrorMessage(
const ApiBusinessException(
code: ApiCodes.mediaNotFound,
message: 'x',
),
),
'头像已失效,请重新上传',
);
expect(
profileSaveErrorMessage(
const ApiBusinessException(
code: ApiCodes.mediaNotReady,
message: 'x',
),
),
'头像还没上传完,请稍后重试',
);
expect(
profileSaveErrorMessage(const ApiNetworkException()),
'网络异常,请检查网络后重试',
);
});
test('加载失败文案不透出服务端 message', () {
expect(
profileLoadErrorMessage(
const ApiNetworkException('Connection refused'),
),
'网络异常,请检查网络后重试',
);
expect(profileLoadErrorMessage(null), '加载失败,请稍后重试');
});
});
}
@@ -0,0 +1,207 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.dart';
import 'package:patbond_flutter/features/profile/profile_page.dart';
import 'package:patbond_flutter/state/app_state.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
late FakeCommunityRepository community;
setUp(() {
community = FakeCommunityRepository();
community.onGetMyCommunityStats = () async =>
CommunityStats.fromJson(sampleCommunityStatsJson());
community.onGetFollowStats = (_) async =>
FollowStats.fromJson(sampleFollowStatsJson());
});
Future<ProfileController> pumpProfile(
WidgetTester tester, {
required FakeAuthRepository auth,
}) async {
// + +
tester.view.physicalSize = const Size(700, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final controller = ProfileController(
authRepository: auth,
communityRepository: community,
);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: ProfilePage(appState: AppState(), controller: controller),
),
),
);
return controller;
}
testWidgets('loading 态:居中转圈,无 demo 文案', (tester) async {
final gate = Completer<UserProfile>();
await pumpProfile(
tester,
auth: FakeAuthRepository(meHandler: () => gate.future),
);
//
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('萌宠新手(豆豆家长)'), findsNothing);
expect(find.text('Patbond 社区创作达人'), findsNothing);
gate.complete(buildProfile());
await tester.pumpAndSettle();
});
testWidgets('ready + 有昵称:展示昵称与 @username,四个数字来自服务端', (tester) async {
await pumpProfile(
tester,
auth: FakeAuthRepository(
meHandler: () async => buildProfile(username: 'llx', nickname: '小柴'),
),
);
await tester.pumpAndSettle();
expect(find.text('小柴'), findsOneWidget);
expect(find.text('@llx'), findsOneWidget);
// 24 / 7 / 128 / 12 helpers
expect(find.text('24'), findsOneWidget);
expect(find.text('7'), findsOneWidget);
expect(find.text('128'), findsOneWidget);
expect(find.text('12'), findsOneWidget);
expect(find.text('获赞'), findsOneWidget);
expect(find.text('我的作品'), findsOneWidget);
// demo 退
expect(find.text('1.8k'), findsNothing);
expect(find.text('萌宠新手(豆豆家长)'), findsNothing);
});
testWidgets('ready + 无昵称:展示名回退 username(回退在客户端展示层)', (tester) async {
await pumpProfile(
tester,
auth: FakeAuthRepository(
meHandler: () async => buildProfile(username: 'llx'),
),
);
await tester.pumpAndSettle();
// @username llx
expect(find.text('llx'), findsOneWidget);
expect(find.text('@llx'), findsOneWidget);
});
testWidgets('空数据:四个数字全 0(不是空白、不是错误态)', (tester) async {
community.onGetMyCommunityStats = () async => CommunityStats.fromJson(
sampleCommunityStatsJson(receivedLikeCount: 0, publishedPostCount: 0),
);
community.onGetFollowStats = (_) async => FollowStats.fromJson(
sampleFollowStatsJson(followerCount: 0, followingCount: 0),
);
await pumpProfile(tester, auth: FakeAuthRepository());
await tester.pumpAndSettle();
expect(find.text('0'), findsNWidgets(4));
expect(find.text('统计加载失败'), findsNothing);
});
testWidgets('error 态:横幅 + 重试;重试成功进 ready', (tester) async {
var attempts = 0;
await pumpProfile(
tester,
auth: FakeAuthRepository(
meHandler: () async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException('断网');
return buildProfile(nickname: '小柴');
},
),
);
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsOneWidget);
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('小柴'), findsOneWidget);
expect(find.byType(InlineErrorBanner), findsNothing);
});
testWidgets('统计块独立降级:资料照常显示,统计给「加载失败 + 重试」', (tester) async {
var statsAttempts = 0;
community.onGetMyCommunityStats = () async {
statsAttempts += 1;
if (statsAttempts == 1) throw const ApiNetworkException('断网');
return CommunityStats.fromJson(sampleCommunityStatsJson());
};
await pumpProfile(
tester,
auth: FakeAuthRepository(
meHandler: () async => buildProfile(nickname: '小柴'),
),
);
await tester.pumpAndSettle();
expect(find.text('小柴'), findsOneWidget, reason: '统计失败不该拖垮整页');
expect(find.text('统计加载失败'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('128'), findsOneWidget);
expect(find.text('统计加载失败'), findsNothing);
});
testWidgets('点「编辑资料」进编辑页;保存成功回资料页并提示', (tester) async {
final auth = FakeAuthRepository(
meHandler: () async => buildProfile(username: 'llx'),
updateMeHandler: (_) async =>
buildProfile(username: 'llx', nickname: '小柴'),
);
await pumpProfile(tester, auth: auth);
await tester.pumpAndSettle();
await tester.tap(find.text('编辑资料'));
await tester.pumpAndSettle();
expect(find.byType(ProfileEditPage), findsOneWidget);
await tester.enterText(find.byType(TextFormField), '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.byType(ProfileEditPage), findsNothing);
expect(find.text('资料已更新'), findsOneWidget);
//
expect(find.text('小柴'), findsOneWidget);
});
testWidgets('未装配上传能力时编辑页不渲染头像入口', (tester) async {
await pumpProfile(
tester,
auth: FakeAuthRepository(meHandler: () async => buildProfile()),
);
await tester.pumpAndSettle();
await tester.tap(find.text('编辑资料'));
await tester.pumpAndSettle();
expect(find.text('更换头像'), findsNothing);
expect(find.byType(TextFormField), findsOneWidget, reason: '昵称输入仍在');
});
}
+51 -1
View File
@@ -26,15 +26,23 @@ class FakeAuthRepository implements AuthRepository {
this.loginHandler, this.loginHandler,
this.registerHandler, this.registerHandler,
this.restoreHandler, this.restoreHandler,
this.meHandler,
this.updateMeHandler,
}); });
final Future<void> Function()? loginHandler; final Future<void> Function()? loginHandler;
final Future<void> Function()? registerHandler; final Future<void> Function()? registerHandler;
final Future<SessionRestoreResult> Function()? restoreHandler; final Future<SessionRestoreResult> Function()? restoreHandler;
final Future<UserProfile> Function()? meHandler;
final Future<UserProfile> Function(UpdateMeRequest)? updateMeHandler;
int loginCalls = 0; int loginCalls = 0;
int registerCalls = 0; int registerCalls = 0;
int logoutCalls = 0; int logoutCalls = 0;
int meCalls = 0;
/// PATCH ** map **
final List<Map<String, Object?>> updateMePayloads = [];
@override @override
Future<void> login({required String username, required String password}) { Future<void> login({required String username, required String password}) {
@@ -64,9 +72,51 @@ class FakeAuthRepository implements AuthRepository {
} }
@override @override
Future<UserProfile> me() async => throw UnimplementedError(); Future<UserProfile> me() async {
meCalls += 1;
// 退 username
//
return meHandler?.call() ?? Future.value(buildProfile());
}
@override
Future<UserProfile> updateMe(UpdateMeRequest request) async {
updateMePayloads.add(request.toJson());
if (updateMeHandler == null) throw UnimplementedError();
return updateMeHandler!(request);
}
} }
/// `/me` v1.4.0
Map<String, dynamic> sampleMeJson({
String userId = 'user-1',
String username = 'llx',
String? nickname,
String? avatarUrl,
String? phone = '+8613800138000',
}) => {
'userId': userId,
'username': username,
'nickname': nickname,
'avatarUrl': avatarUrl,
'phone': phone,
'createdAt': '2026-09-01T10:00:00+08:00',
};
UserProfile buildProfile({
String userId = 'user-1',
String username = 'llx',
String? nickname,
String? avatarUrl,
}) => UserProfile.fromJson(
sampleMeJson(
userId: userId,
username: username,
nickname: nickname,
avatarUrl: avatarUrl,
),
);
/// mock dio HttpClientAdapter /// mock dio HttpClientAdapter
class FakeHttpAdapter implements HttpClientAdapter { class FakeHttpAdapter implements HttpClientAdapter {
FakeHttpAdapter(this.handler); FakeHttpAdapter(this.handler);
+86 -7
View File
@@ -169,6 +169,21 @@ CursorPage<PostComment> commentPage(
bool hasMore = false, bool hasMore = false,
}) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore); }) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore);
/// 稿T3-17 稿media 稿
Post sampleDraft({
String id = 'draft-1',
int version = 3,
String content = '草稿正文',
bool withMedia = true,
}) => Post.fromJson({
...samplePostJson(id: id, status: 'draft', version: version),
'content': content,
if (!withMedia) 'media': const <Map<String, dynamic>>[],
});
CursorPage<Post> postPage(List<Post> items) =>
CursorPage(items: items, nextCursor: null, hasMore: false);
/// controller Completer /// controller Completer
/// handler UnimplementedError /// handler UnimplementedError
class FakeCommunityRepository implements CommunityRepository { class FakeCommunityRepository implements CommunityRepository {
@@ -186,9 +201,21 @@ class FakeCommunityRepository implements CommunityRepository {
Future<void> Function(String commentId)? onDeleteComment; Future<void> Function(String commentId)? onDeleteComment;
Future<FollowState> Function(String userId, bool target)? onFollowToggle; Future<FollowState> Function(String userId, bool target)? onFollowToggle;
Future<FollowStats> Function(String userId)? onGetFollowStats; Future<FollowStats> Function(String userId)? onGetFollowStats;
Future<CommunityStats> Function()? onGetMyCommunityStats;
Future<MediaUploadCredentials> Function(CreateMediaUploadRequest request)? Future<MediaUploadCredentials> Function(CreateMediaUploadRequest request)?
onCreateMediaUpload; onCreateMediaUpload;
Future<MediaAsset> Function(String assetId)? onCompleteMediaUpload; Future<MediaAsset> Function(String assetId)? onCompleteMediaUpload;
Future<Post> Function(CreatePostRequest request, String? idempotencyKey)?
onCreatePost;
Future<Post> Function(String postId, UpdatePostRequest request)? onUpdatePost;
Future<void> Function(String postId)? onDeletePost;
Future<CursorPage<Post>> Function(PostStatus? status)? onListMyPosts;
/// createPost
final List<String?> idempotencyKeys = [];
/// createMediaUpload purpose
CreateMediaUploadRequest? lastMediaUploadRequest;
@override @override
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) { Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) {
@@ -231,6 +258,7 @@ class FakeCommunityRepository implements CommunityRepository {
CreateMediaUploadRequest request, CreateMediaUploadRequest request,
) { ) {
calls.add('createUpload:${request.mimeType}:${request.byteSize}'); calls.add('createUpload:${request.mimeType}:${request.byteSize}');
lastMediaUploadRequest = request;
return onCreateMediaUpload!(request); return onCreateMediaUpload!(request);
} }
@@ -241,22 +269,39 @@ class FakeCommunityRepository implements CommunityRepository {
} }
@override @override
Future<Post> createPost(CreatePostRequest request) => Future<Post> createPost(CreatePostRequest request, {String? idempotencyKey}) {
throw UnimplementedError(); calls.add('createPost:${request.status?.name}:${request.content}');
idempotencyKeys.add(idempotencyKey);
return onCreatePost!(request, idempotencyKey);
}
@override @override
Future<Post> updatePost(String postId, UpdatePostRequest request) => Future<Post> updatePost(String postId, UpdatePostRequest request) {
throw UnimplementedError(); calls.add(
'updatePost:$postId:v${request.version}:publish=${request.publish}',
);
return onUpdatePost!(postId, request);
}
@override @override
Future<void> deletePost(String postId) => throw UnimplementedError(); Future<void> deletePost(String postId) {
calls.add('deletePost:$postId');
return onDeletePost!(postId);
}
@override @override
Future<CursorPage<Post>> listMyPosts({ Future<CursorPage<Post>> listMyPosts({
int? limit, int? limit,
String? cursor, String? cursor,
PostStatus? status, PostStatus? status,
}) => throw UnimplementedError(); }) {
calls.add('listMyPosts:${status?.name}');
return onListMyPosts == null
? Future.value(
const CursorPage(items: [], nextCursor: null, hasMore: false),
)
: onListMyPosts!(status);
}
@override @override
Future<CursorPage<PostComment>> listComments( Future<CursorPage<PostComment>> listComments(
@@ -298,10 +343,44 @@ class FakeCommunityRepository implements CommunityRepository {
@override @override
Future<FollowStats> getFollowStats(String userId) { Future<FollowStats> getFollowStats(String userId) {
calls.add('followStats:$userId'); calls.add('followStats:$userId');
return onGetFollowStats!(userId); return onGetFollowStats?.call(userId) ??
Future.value(FollowStats.fromJson(sampleFollowStatsJson()));
}
@override
Future<CommunityStats> getMyCommunityStats() {
calls.add('communityStats');
return onGetMyCommunityStats?.call() ??
// 0 null 404
Future.value(
CommunityStats.fromJson(
sampleCommunityStatsJson(
receivedLikeCount: 0,
publishedPostCount: 0,
),
),
);
} }
@override @override
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor}) => Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor}) =>
throw UnimplementedError(); throw UnimplementedError();
} }
Map<String, dynamic> sampleCommunityStatsJson({
int receivedLikeCount = 128,
int publishedPostCount = 12,
}) => {
'receivedLikeCount': receivedLikeCount,
'publishedPostCount': publishedPostCount,
};
Map<String, dynamic> sampleFollowStatsJson({
int followerCount = 24,
int followingCount = 7,
bool followedByMe = false,
}) => {
'followerCount': followerCount,
'followingCount': followingCount,
'followedByMe': followedByMe,
};
+12
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/community_models.dart';
@@ -14,6 +15,17 @@ PickedMediaImage pickedImage({int seed = 1, int size = 64}) => PickedMediaImage(
name: 'img-$seed.jpg', name: 'img-$seed.jpg',
); );
/// 1x1 PNG 70Bwidget ,
/// Image.memory errorBuilder
final Uint8List tinyPngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF'
'AAH/q842iQAAAABJRU5ErkJggg==',
);
/// widget
PickedMediaImage decodablePickedImage({int seed = 1}) =>
PickedMediaImage(bytes: tinyPngBytes, name: 'img-$seed.png');
MediaUploadCredentials credentials({ MediaUploadCredentials credentials({
String assetId = 'a-1', String assetId = 'a-1',
DateTime? expiresAt, DateTime? expiresAt,
+2 -1
View File
@@ -1,4 +1,4 @@
/// pets openapi.yaml v1.2.0 schema JSON /// pets openapi.yaml v1.4.0 schema JSON
/// ///
library; library;
@@ -27,6 +27,7 @@ Map<String, dynamic> samplePetJson({
'microchipNo': null, 'microchipNo': null,
'sterilizedOn': null, 'sterilizedOn': null,
'status': 'active', 'status': 'active',
'avatarUrl': null,
'myRole': 'owner', 'myRole': 'owner',
'createdAt': '2026-09-01T10:00:00+08:00', 'createdAt': '2026-09-01T10:00:00+08:00',
'updatedAt': '2026-09-02T10:00:00+08:00', 'updatedAt': '2026-09-02T10:00:00+08:00',
@@ -170,9 +170,7 @@ void main() {
expect(card().likedByMe, false); expect(card().likedByMe, false);
controller.dispose(); controller.dispose();
}, },
skip: enabled skip: enabled ? false : '设 PATBOND_DETAIL_SMOKE=1 且后端六容器在本机运行时才执行',
? false
: '设 PATBOND_DETAIL_SMOKE=1 且后端六容器在本机运行时才执行',
timeout: const Timeout(Duration(minutes: 3)), timeout: const Timeout(Duration(minutes: 3)),
); );
} }
@@ -193,8 +191,10 @@ class _SwitchableRepository implements CommunityRepository {
target.completeMediaUpload(assetId); target.completeMediaUpload(assetId);
@override @override
Future<Post> createPost(CreatePostRequest request) => Future<Post> createPost(
target.createPost(request); CreatePostRequest request, {
String? idempotencyKey,
}) => target.createPost(request, idempotencyKey: idempotencyKey);
@override @override
Future<Post> getPost(String postId) => target.getPost(postId); Future<Post> getPost(String postId) => target.getPost(postId);
@@ -252,6 +252,9 @@ class _SwitchableRepository implements CommunityRepository {
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor}) => Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor}) =>
target.listMyBookmarks(limit: limit, cursor: cursor); target.listMyBookmarks(limit: limit, cursor: cursor);
@override
Future<CommunityStats> getMyCommunityStats() => target.getMyCommunityStats();
@override @override
Future<FollowState> followUser(String userId) => target.followUser(userId); Future<FollowState> followUser(String userId) => target.followUser(userId);
File diff suppressed because it is too large Load Diff