From 9892b65a19fc404b6a1fa4209ac7e5746986920f Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Thu, 10 Sep 2026 14:42:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E9=A1=B5=E7=9C=9F=E5=AE=9E=E5=8C=96=E2=80=94=E2=80=94=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E4=B9=9D=E5=AE=AB=E6=A0=BC=E7=BC=96=E8=BE=91=E6=80=81?= =?UTF-8?q?=20+=20=E5=BB=BA=E8=8D=89=E7=A8=BF/=E8=BF=81=E7=A7=BB=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E4=B8=A4=E6=AD=A5=20+=20=E5=8F=91=E5=B8=83=E6=BC=8F?= =?UTF-8?q?=E6=96=97=E4=B8=8E=E5=AA=92=E4=BD=93=E4=B8=89=E6=AE=B5=E5=9F=8B?= =?UTF-8?q?=E7=82=B9=EF=BC=8Ccreate=20=E9=A1=B5=20demo=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=B5=81=E9=80=80=E5=BD=B9=EF=BC=88T3-17=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- integration_test/publish_live_test.dart | 193 +++++ lib/analytics/analytics_page_name.dart | 5 +- lib/app/app.dart | 11 + lib/core/widgets/post_media_grid.dart | 205 ++++- lib/features/community/community_display.dart | 26 + .../community/community_repository.dart | 15 +- lib/features/community/media_uploader.dart | 117 ++- lib/features/community/post_analytics.dart | 239 ++++++ lib/features/community/post_compose_page.dart | 704 ++++++++++++++++++ lib/features/create/create_page.dart | 76 +- lib/features/home/home_page.dart | 11 +- lib/features/main/main_shell_page.dart | 42 +- lib/features/profile/profile_page.dart | 5 +- lib/state/app_state.dart | 35 +- test/core/widgets/post_media_grid_test.dart | 92 +++ .../community/media_uploader_test.dart | 148 ++++ .../community/post_analytics_test.dart | 193 +++++ .../community/post_compose_page_test.dart | 571 ++++++++++++++ test/features/home/home_page_test.dart | 10 +- .../main/main_shell_publish_test.dart | 87 +++ test/helpers/community_test_helpers.dart | 52 +- test/helpers/media_test_helpers.dart | 12 + .../smoke/detail_interactions_smoke_test.dart | 10 +- 23 files changed, 2760 insertions(+), 99 deletions(-) create mode 100644 integration_test/publish_live_test.dart create mode 100644 lib/features/community/post_analytics.dart create mode 100644 lib/features/community/post_compose_page.dart create mode 100644 test/features/community/post_analytics_test.dart create mode 100644 test/features/community/post_compose_page_test.dart create mode 100644 test/features/main/main_shell_publish_test.dart diff --git a/integration_test/publish_live_test.dart b/integration_test/publish_live_test.dart new file mode 100644 index 0000000..0ddd13a --- /dev/null +++ b/integration_test/publish_live_test.dart @@ -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 _values = {}; + + @override + Future read(String key) async => _values[key]; + + @override + Future write(String key, String value) async => _values[key] = value; + + @override + Future 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> pickImages({required int limit}) async => [ + PickedMediaImage(bytes: _pngBytes, name: 'live.png'), + ]; +} + +/// 桌面压缩替身:Linux 桌面无 flutter_image_compress 原生实现,原样透传 +/// (mime 落 image/png,在服务端白名单内)。其余环节全为生产实现。 +class _PassthroughCompressor implements MediaImageCompressor { + @override + Future 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 注释);埋点落库 +/// 取证走 curl(26 号报告 §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 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 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 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(publish).onPressed != null) break; + } + expect( + tester.widget(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(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(find.byType(PostCard)) + .toList(); + expect(readerCards.first.card.contentPreview, contains(marker)); + }, skip: !enabled); +} diff --git a/lib/analytics/analytics_page_name.dart b/lib/analytics/analytics_page_name.dart index 8be1e15..9452c45 100644 --- a/lib/analytics/analytics_page_name.dart +++ b/lib/analytics/analytics_page_name.dart @@ -19,7 +19,10 @@ enum AnalyticsPageName { create('create'), petArchive('pet_archive'), services('services'), - postDetail('post_detail'); + postDetail('post_detail'), + // —— 字典 v3 页面族(22 号报告 §2;本枚举登记 M3 已落地页)—— + /// 发布页(P3,T3-17 push 全屏页)。 + postForm('post_form'); const AnalyticsPageName(this.pageName); diff --git a/lib/app/app.dart b/lib/app/app.dart index 22b9575..fba1db6 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -16,6 +16,8 @@ import 'package:patbond_flutter/features/community/community_controller.dart'; import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; import 'package:patbond_flutter/features/community/community_repository.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; +import 'package:patbond_flutter/features/community/post_analytics.dart'; import 'package:patbond_flutter/features/main/main_shell_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_analytics.dart'; @@ -30,6 +32,7 @@ class App extends StatefulWidget { this.authRepository, this.petsRepository, this.communityRepository, + this.mediaUploaderFactory, }); /// 测试注入口;生产默认走安全存储 + 真实 API。 @@ -38,6 +41,9 @@ class App extends StatefulWidget { final PetsRepository? petsRepository; final CommunityRepository? communityRepository; + /// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。 + final MediaUploaderFactory? mediaUploaderFactory; + @override State createState() => _AppState(); } @@ -52,6 +58,7 @@ class _AppState extends State { late final HealthRecordAnalytics healthRecordAnalytics; late final FeedAnalytics feedAnalytics; late final CommunityInteractionAnalytics interactionAnalytics; + late final PostAnalytics postAnalytics; late final SessionTracker _sessionTracker; late final AnalyticsService _analytics; late final PageViewTracker _pageViewTracker; @@ -107,6 +114,8 @@ class _AppState extends State { petAnalytics = PetAnalytics(_analytics.trackEvent); healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent); feedAnalytics = FeedAnalytics(_analytics.trackEvent); + // T3-17:发布漏斗五事件 + 媒体上传三段(发布页与 MediaUploader 消费)。 + postAnalytics = PostAnalytics(_analytics.trackEvent); // 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件) sessionManager.addListener(_reportAuthStateChange); @@ -245,6 +254,8 @@ class _AppState extends State { healthRecordAnalytics: healthRecordAnalytics, feedAnalytics: feedAnalytics, interactionAnalytics: interactionAnalytics, + postAnalytics: postAnalytics, + mediaUploaderFactory: widget.mediaUploaderFactory, pageViewTracker: _pageViewTracker, onLogout: authRepository.logout, ); diff --git a/lib/core/widgets/post_media_grid.dart b/lib/core/widgets/post_media_grid.dart index 1943b4a..8dbc92a 100644 --- a/lib/core/widgets/post_media_grid.dart +++ b/lib/core/widgets/post_media_grid.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/upload_progress_overlay.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; import 'package:patbond_flutter/widgets/common.dart'; /// 图片九宫格展示态(05 号规范 §3.2)。 @@ -14,7 +16,8 @@ import 'package:patbond_flutter/widgets/common.dart'; /// [urls] 单元素而 [totalCount] > 1 时渲染 4:3 单格 + 右下「+N」角标 /// 胶囊(同 80% scrim 精算)。 /// -/// 编辑态(「+」格 / 删除角标)随发布页工单(T3-17)扩展。 +/// 编辑态(「+」格 / 删除角标 / 进度覆盖层)见同文件 [PostMediaEditGrid] +/// (T3-17 发布页选图区)。 class PostMediaGrid extends StatelessWidget { const PostMediaGrid({ 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 items; + + /// 是否渲染「+」格(剩余槽位 > 0)。 + final bool canAdd; + + final VoidCallback? onAdd; + + /// 删格回调,参数为 [MediaUploadItem.localId]。 + final ValueChanged? onRemove; + + /// 重试回调(仅可重试失败格触发),参数为 localId。 + final ValueChanged? onRetry; + + static const _spacing = 4.0; + static const _columns = 3; + + @override + Widget build(BuildContext context) { + final cellCount = items.length + (canAdd ? 1 : 0); + if (cellCount == 0) return const SizedBox.shrink(); + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _columns, + mainAxisSpacing: _spacing, + crossAxisSpacing: _spacing, + ), + itemCount: cellCount, + itemBuilder: (context, index) { + if (index == items.length) return _AddCell(onTap: onAdd); + return _EditCell( + item: items[index], + onRemove: onRemove == null + ? null + : () => onRemove!(items[index].localId), + onRetry: onRetry == null || !items[index].retryable + ? null + : () => onRetry!(items[index].localId), + ); + }, + ); + } +} + +class _EditCell extends StatelessWidget { + const _EditCell({required this.item, this.onRemove, this.onRetry}); + + final MediaUploadItem item; + final VoidCallback? onRemove; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Image.memory( + item.previewBytes, + fit: BoxFit.cover, + gaplessPlayback: true, + // 选图字节无法解码时的兜底(RemoteImage 同款形态)。 + errorBuilder: (context, _, _) => const ColoredBox( + color: AppColors.surfaceTint, + child: Center( + child: Icon(Icons.pets, color: AppColors.muted, size: 20), + ), + ), + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.sm), + child: UploadProgressOverlay( + phase: item.phase, + progress: item.progress, + onRetry: onRetry, + ), + ), + Positioned( + right: 0, + top: 0, + child: GestureDetector( + onTap: onRemove, + behavior: HitTestBehavior.opaque, + child: const Padding( + // 22 圆角标 + padding 撑到 32 触控热区。 + padding: EdgeInsets.all(5), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Color(0xCC3E2A1F), + ), + child: SizedBox( + width: 22, + height: 22, + child: Center( + child: Icon(Icons.close, size: 14, color: Colors.white), + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _AddCell extends StatelessWidget { + const _AddCell({this.onTap}); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: CustomPaint( + painter: const _DashedBorderPainter(), + child: const Center( + child: Icon( + Icons.add_photo_alternate_outlined, + size: 24, + color: AppColors.inkSoft, + ), + ), + ), + ); + } +} + +/// 「+」格的虚线圆角边框(Flutter 无内置虚线边框,按规范 1.5px 自绘)。 +class _DashedBorderPainter extends CustomPainter { + const _DashedBorderPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColors.border + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + final path = Path() + ..addRRect( + RRect.fromRectAndRadius( + Offset.zero & size, + const Radius.circular(AppRadius.sm), + ), + ); + const dash = 5.0; + const gap = 4.0; + for (final metric in path.computeMetrics()) { + var distance = 0.0; + while (distance < metric.length) { + final next = distance + dash; + canvas.drawPath( + metric.extractPath( + distance, + next > metric.length ? metric.length : next, + ), + paint, + ); + distance = next + gap; + } + } + } + + @override + bool shouldRepaint(_DashedBorderPainter oldDelegate) => false; +} + /// 单图折叠形态:4:3 圆角封面 + 右下「+N」胶囊角标。 class _CollapsedCover extends StatelessWidget { const _CollapsedCover({ diff --git a/lib/features/community/community_display.dart b/lib/features/community/community_display.dart index d44eb75..2256849 100644 --- a/lib/features/community/community_display.dart +++ b/lib/features/community/community_display.dart @@ -1,4 +1,5 @@ import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/community/community_exceptions.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; /// 降级作者([AuthorSummary.isDegraded],资料暂不可得或已注销)的 @@ -31,3 +32,28 @@ String feedLoadErrorMessage(ApiException? error) => switch (error) { 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 _ => '请求过于频繁,请稍后再试', + _ => '草稿保存失败,请重试', +}; diff --git a/lib/features/community/community_repository.dart b/lib/features/community/community_repository.dart index fc52449..e4be060 100644 --- a/lib/features/community/community_repository.dart +++ b/lib/features/community/community_repository.dart @@ -17,7 +17,9 @@ abstract class CommunityRepository { Future completeMediaUpload(String assetId); // ---- 帖子 CRUD / 发布 ---- - Future createPost(CreatePostRequest request); + /// [idempotencyKey]:调用方持键(T3-17 发布页「同键重放」——网络失败重试 + /// 沿用同键命中服务端首帖,不重复建帖;缺省则本层每次调用换新键)。 + Future createPost(CreatePostRequest request, {String? idempotencyKey}); Future getPost(String postId); Future updatePost(String postId, UpdatePostRequest request); Future deletePost(String postId); @@ -85,6 +87,7 @@ class ApiCommunityRepository implements CommunityRepository { Object? body, Map? query, bool idempotent = false, + String? idempotencyKey, bool media = false, }) async { try { @@ -93,7 +96,9 @@ class ApiCommunityRepository implements CommunityRepository { method: method, body: body, query: query, - headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null, + headers: idempotent + ? {'Idempotency-Key': idempotencyKey ?? _uuid.v4()} + : null, requiresAuth: true, ); } on ApiBusinessException catch (error) { @@ -132,12 +137,16 @@ class ApiCommunityRepository implements CommunityRepository { // ---- posts ---- @override - Future createPost(CreatePostRequest request) async { + Future createPost( + CreatePostRequest request, { + String? idempotencyKey, + }) async { final data = await _request( '/api/v1/posts', method: 'POST', body: request.toJson(), idempotent: true, + idempotencyKey: idempotencyKey, ); return Post.fromJson(_asMap(data)); } diff --git a/lib/features/community/media_uploader.dart b/lib/features/community/media_uploader.dart index fda0a04..82437c7 100644 --- a/lib/features/community/media_uploader.dart +++ b/lib/features/community/media_uploader.dart @@ -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_direct_upload.dart'; import 'package:patbond_flutter/features/community/media_picking.dart'; +import 'package:patbond_flutter/features/community/post_analytics.dart'; /// 单张图的上传阶段(05 号规范 §3.3 四视觉态的底层状态模型)。 /// @@ -76,6 +77,12 @@ class _UploadTask { bool retryable = false; bool cancelled = false; + /// 本图第几次上传尝试(媒体三段埋点 attemptSeq,从 1 起;retry 递增)。 + int attemptSeq = 1; + + /// 本次尝试的 started 时刻(succeeded 的 durationMs 口径)。 + DateTime? attemptStartedAt; + /// 压缩产物缓存(重试跳过重压缩)。 CompressedMediaImage? compressed; @@ -115,12 +122,15 @@ class _UploadTask { /// 对外可见的 [MediaUploadItem.assetId] 与 ready 态严格绑定(断言), /// [buildAttachRequests] 仅在全员 ready 时可用。 /// - 预签名凭据只存内存、用完即弃,不持久化(既有纪律)。 +/// - **媒体三段埋点**(T3-17):每次尝试恰一条 started,收敛为 +/// succeeded / failed 各一条;`sizeBucket` 统一取原图字节数。 class MediaUploader extends ChangeNotifier { MediaUploader({ required this._repository, MediaImagePicker? picker, MediaImageCompressor? compressor, MediaDirectUploadClient? directUpload, + this._analytics, this.maxImages = 9, this.maxConcurrentUploads = 2, this.maxByteSize = 10 * 1024 * 1024, @@ -135,6 +145,10 @@ class MediaUploader extends ChangeNotifier { final MediaImagePicker _picker; final MediaImageCompressor _compressor; final MediaDirectUploadClient _directUpload; + + /// 媒体上传三段埋点(T3-17 接入;未注入即不上报)。 + final PostAnalytics? _analytics; + final DateTime Function() _now; /// 九宫格上限(05 号规范 §3.2)。 @@ -244,24 +258,28 @@ class MediaUploader extends ChangeNotifier { task.errorMessage = null; task.retryable = false; task.progress = 0; + task.attemptSeq += 1; task.phase = MediaItemPhase.queued; notifyListeners(); unawaited(_run(task)); } /// 移除一张图(任意态可移除);在途请求结果一律作废,未 confirm 的 - /// 服务端 asset 弃引用(服务端超时清理兜底)。 + /// 服务端 asset 弃引用(服务端超时清理兜底)。在途任务被移除按 + /// `cancelled` 上报一条上传失败(06 §1.4「用户取消」口径)。 void remove(int localId) { final task = _taskOrNull(localId); if (task == null) return; + _reportCancelled(task); task.cancelled = true; _tasks.remove(task); notifyListeners(); } - /// 清空全部(发布成功/离开页面时调用)。 + /// 清空全部(发布成功/离开页面时调用);在途任务同 [remove] 记 cancelled。 void reset() { for (final task in _tasks) { + _reportCancelled(task); task.cancelled = true; } _tasks.clear(); @@ -282,6 +300,12 @@ class MediaUploader extends ChangeNotifier { await _acquireSlot(); try { if (task.cancelled) return; + // 一次尝试恰一条 started(含压缩段:压缩失败也在漏斗内可见)。 + task.attemptStartedAt = _now(); + _analytics?.mediaUploadStarted( + mediaType: MediaType.image, + byteSize: task.source.bytes.length, + ); final compressed = await _compress(task); if (compressed == null || task.cancelled) return; await _uploadAndConfirm(task, compressed); @@ -308,10 +332,20 @@ class MediaUploader extends ChangeNotifier { } } } catch (_) { - _fail(task, message: '图片处理失败', retryable: true); + _fail( + task, + message: '图片处理失败', + retryable: true, + reason: MediaUploadFailureReason.unsupportedFormat, + ); return null; } - _fail(task, message: '图片过大,压缩后仍超过 10 MB', retryable: false); + _fail( + task, + message: '图片过大,压缩后仍超过 10 MB', + retryable: false, + reason: MediaUploadFailureReason.mediaTooLarge, + ); return null; } @@ -335,7 +369,12 @@ class MediaUploader extends ChangeNotifier { while (true) { if (_credentialsExpired(credentials)) { if (renewed) { - _fail(task, message: '上传凭据已过期', retryable: true); + _fail( + task, + message: '上传凭据已过期', + retryable: true, + reason: MediaUploadFailureReason.serverError, + ); return; } renewed = true; @@ -380,6 +419,9 @@ class MediaUploader extends ChangeNotifier { task, message: error.statusCode == null ? '网络中断,上传失败' : '上传被存储服务拒绝', retryable: true, + reason: error.statusCode == null + ? MediaUploadFailureReason.networkError + : MediaUploadFailureReason.serverError, ); return; } @@ -398,12 +440,25 @@ class MediaUploader extends ChangeNotifier { } if (task.cancelled) return; if (asset.status != MediaAssetStatus.ready) { - _fail(task, message: '上传确认未通过', retryable: true); + _fail( + task, + message: '上传确认未通过', + retryable: true, + reason: MediaUploadFailureReason.serverError, + ); return; } task.readyAssetId = asset.id; task.progress = 1; _transition(task, MediaItemPhase.ready); + final startedAt = task.attemptStartedAt; + _analytics?.mediaUploadSucceeded( + mediaType: MediaType.image, + byteSize: task.source.bytes.length, + durationMs: startedAt == null + ? 0 + : _now().difference(startedAt).inMilliseconds, + ); } Future _createUpload( @@ -424,12 +479,22 @@ class MediaUploader extends ChangeNotifier { void _failFromApi(_UploadTask task, Exception error) { // 参数被服务端拒绝(40000:mime/byteSize 白名单外)重试无意义,终态。 - final retryable = - error is! ApiBusinessException || error.code != ApiCodes.paramError; + final isParamError = + error is ApiBusinessException && error.code == ApiCodes.paramError; _fail( task, 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 +502,36 @@ class MediaUploader extends ChangeNotifier { _UploadTask task, { required String message, required bool retryable, + required MediaUploadFailureReason? reason, + int? errorCode, }) { if (task.cancelled) return; task.phase = MediaItemPhase.failed; task.errorMessage = message; task.retryable = retryable; + if (reason != null) { + _analytics?.mediaUploadFailed( + mediaType: MediaType.image, + byteSize: task.source.bytes.length, + reason: reason, + attemptSeq: task.attemptSeq, + errorCode: errorCode, + ); + } notifyListeners(); } + /// 在途任务被删格/清空作废 → `cancelled`(已 ready / 已 failed 不报)。 + void _reportCancelled(_UploadTask task) { + if (task.cancelled || !task.snapshot().isBusy) return; + _analytics?.mediaUploadFailed( + mediaType: MediaType.image, + byteSize: task.source.bytes.length, + reason: MediaUploadFailureReason.cancelled, + attemptSeq: task.attemptSeq, + ); + } + void _transition(_UploadTask task, MediaItemPhase phase) { if (task.cancelled) return; task.phase = phase; @@ -469,3 +556,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, + ); diff --git a/lib/features/community/post_analytics.dart b/lib/features/community/post_analytics.dart new file mode 100644 index 0000000..c762277 --- /dev/null +++ b/lib/features/community/post_analytics.dart @@ -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 / feed;topic_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, + }); + } +} diff --git a/lib/features/community/post_compose_page.dart b/lib/features/community/post_compose_page.dart new file mode 100644 index 0000000..c6fc2e5 --- /dev/null +++ b/lib/features/community/post_compose_page.dart @@ -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'; + +/// 发布页(P3,05 号规范 §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 createState() => _PostComposePageState(); +} + +class _PostComposePageState extends State { + 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 _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; + + /// 发布 gating(05 §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 _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 _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? _mediaAttachOrNull() => + _uploader.isEmpty ? null : _uploader.buildAttachRequests(); + + // ---- 发布(建草稿 → 迁移发布)---- + + Future _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 _patchPublish(String signature) async { + final media = signature == _syncedMediaSignature + ? null // 缺席不动(服务端媒体与本地选图一致) + : _mediaAttachOrNull() ?? const []; + 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 _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 [], + ), + ); + _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 _discardDraft() async { + final draftId = _draftPostId; + if (draftId == null) return; + try { + await _controller.repository.deletePost(draftId); + widget.analytics?.postDeleted(); + } on ApiException { + // 删除失败不拦住离页(草稿留在服务端,下次进页可恢复)。 + } + } + + // ---- 离页 ---- + + Future _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 _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, + ), + ), + ], + ); + } +} diff --git a/lib/features/create/create_page.dart b/lib/features/create/create_page.dart index 8dcfaf4..5b500d4 100644 --- a/lib/features/create/create_page.dart +++ b/lib/features/create/create_page.dart @@ -7,15 +7,23 @@ import 'package:patbond_flutter/widgets/common.dart'; enum CreationMode { image, video } +/// 创作 Tab。 +/// +/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡) +/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页 +/// (`PostComposePage`,push 全屏)承担——本页顶部「发布动态」入口即其 +/// 入口(entryPoint=create_tab),`AppState.publishPost` demo 发布流退役。 class CreatePage extends StatefulWidget { const CreatePage({ required this.appState, - required this.onPublished, + required this.onOpenCompose, super.key, }); final AppState appState; - final ValueChanged onPublished; + + /// 真实发布页入口(主壳 push,发布成功后回首页 Feed 刷新)。 + final VoidCallback onOpenCompose; @override State createState() => _CreatePageState(); @@ -111,36 +119,13 @@ class _CreatePageState extends State { setState(() => tags = [...tags, value]); } + /// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media + /// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写 + /// `AppState.posts`)随 T3-17 退役,此处只余占位提示。 void publish() { - if (resultUrl == null || titleController.text.trim().isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('请先完成生成并填写标题'))); - return; - } - final post = PostModel( - id: 'post_user_${DateTime.now().millisecondsSinceEpoch}', - authorName: '萌宠新手(我)', - authorAvatar: userAvatar, - time: '刚刚', - breedTag: 'AI创作', - content: - '${titleController.text.trim()}\n\n${contentController.text.trim()}', - mainImage: resultUrl!, - likes: 1, - tags: [...tags, mode == CreationMode.image ? 'AI生图' : 'AI视频'], - comments: const [], - hasLiked: true, + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')), ); - widget.appState.publishPost(post); - setState(() { - uploaded = false; - resultUrl = null; - generationStep = 0; - titleController.clear(); - contentController.clear(); - }); - widget.onPublished(post); } @override @@ -148,6 +133,8 @@ class _CreatePageState extends State { return ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), children: [ + _ComposeEntryCard(onTap: widget.onOpenCompose), + const SizedBox(height: 18), SegmentedButton( segments: const [ ButtonSegment( @@ -402,6 +389,35 @@ class _CreatePageState extends State { } } +/// 真实发布入口(T3-17):本页其余部分是 AI 创作模拟(M4),社区发帖 +/// 走这里 push 的发布页。 +class _ComposeEntryCard extends StatelessWidget { + const _ComposeEntryCard({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SectionCard( + padding: EdgeInsets.zero, + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + leading: const CircleAvatar( + backgroundColor: AppColors.surfaceTint, + child: Icon(Icons.edit_outlined, color: AppColors.primary), + ), + title: const Text( + '发布动态', + style: TextStyle(fontWeight: FontWeight.w800), + ), + subtitle: const Text('写点文字、配上照片,分享给宠友'), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } +} + class _UploadCard extends StatelessWidget { const _UploadCard({ required this.uploaded, diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index 86a92af..7c4a0b6 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -24,7 +24,7 @@ class HomePage extends StatefulWidget { required this.appState, required this.communityController, required this.onOpenServices, - required this.onOpenCreate, + required this.onOpenCompose, super.key, this.onOpenPost, this.feedAnalytics, @@ -37,7 +37,10 @@ class HomePage extends StatefulWidget { final CommunityController communityController; final ValueChanged onOpenServices; - final VoidCallback onOpenCreate; + + /// 发布页入口(T3-17:story 环「发布」与空态 CTA 均 push 真实发布页, + /// entryPoint=feed;创作 Tab 的 AI 模拟不再是社区发帖入口)。 + final VoidCallback onOpenCompose; /// 帖子详情导航(T3-15 接通;主壳 push PostDetailPage)。 final ValueChanged? onOpenPost; @@ -370,7 +373,7 @@ class _HomePageState extends State with WidgetsBindingObserver { ), const SizedBox(height: 18), if (segment == HomeSegment.feed) ...[ - _StoryRow(onCreate: widget.onOpenCreate), + _StoryRow(onCreate: widget.onOpenCompose), const SizedBox(height: 18), _PromoCard(onTap: () => widget.onOpenServices(true)), const SizedBox(height: 18), @@ -459,7 +462,7 @@ class _HomePageState extends State with WidgetsBindingObserver { title: '还没有动态', description: '关注的毛孩子们还没发帖,去逛逛话题吧', ctaLabel: '发布第一条', - onCtaPressed: widget.onOpenCreate, + onCtaPressed: widget.onOpenCompose, ), ]; } diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index bc4f974..6bcf175 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/analytics/page_view_tracker.dart'; @@ -5,6 +7,9 @@ import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; +import 'package:patbond_flutter/features/community/post_analytics.dart'; +import 'package:patbond_flutter/features/community/post_compose_page.dart'; import 'package:patbond_flutter/features/create/create_page.dart'; import 'package:patbond_flutter/features/home/home_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; @@ -28,6 +33,8 @@ class MainShellPage extends StatefulWidget { this.healthRecordAnalytics, this.feedAnalytics, this.interactionAnalytics, + this.postAnalytics, + this.mediaUploaderFactory, this.pageViewTracker, this.onLogout, }); @@ -55,6 +62,12 @@ class MainShellPage extends StatefulWidget { /// 互动域埋点(T3-16 评论成败对 + 关注对;详情页消费)。 final CommunityInteractionAnalytics? interactionAnalytics; + /// post 域埋点(T3-17 发布漏斗五事件 + 媒体三段;发布页消费)。 + final PostAnalytics? postAnalytics; + + /// 发布页 [MediaUploader] 构造口(桌面实测 / 测试替换选图与压缩层)。 + final MediaUploaderFactory? mediaUploaderFactory; + /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 final PageViewTracker? pageViewTracker; @@ -117,6 +130,28 @@ class _MainShellPageState extends State { ); } + /// 发布页(T3-17:真实发布链路)。发布成功后回首页 Feed 并整体刷新, + /// 新帖按 `(published_at DESC, id DESC)` 落在首位(跨客户端同一序)。 + Future openCompose(PostEntryPoint entryPoint) async { + final published = await Navigator.of(context).push( + MaterialPageRoute( + settings: RouteSettings(name: AnalyticsPageName.postForm.pageName), + builder: (context) => PostComposePage( + controller: widget.communityController, + entryPoint: entryPoint, + analytics: widget.postAnalytics, + uploaderFactory: widget.mediaUploaderFactory, + ), + ), + ); + if (!mounted || published != true) return; + selectTab(0); + unawaited(widget.communityController.refresh()); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已发布,去首页看看吧 🐾'))); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -135,14 +170,13 @@ class _MainShellPageState extends State { feedAnalytics: widget.feedAnalytics, isActive: currentIndex == 0, onOpenServices: openServices, - onOpenCreate: () => selectTab(1), + onOpenCompose: () => openCompose(PostEntryPoint.feed), onOpenPost: openPost, ), CreatePage( appState: widget.appState, - // demo 详情页已退役(T3-15):demo 发布流不再导航详情, - // 回首页 Feed;发布页真实化随 T3-17 收编。 - onPublished: (_) => selectTab(0), + // T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。 + onOpenCompose: () => openCompose(PostEntryPoint.createTab), ), PetsPage( controller: widget.petsController, diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index 3b89bc1..feaa375 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -96,7 +96,10 @@ class ProfilePage extends StatelessWidget { const _ProfileStat(value: '24', label: '关注我'), const _ProfileStat(value: '1.8k', label: '获赞'), _ProfileStat( - value: '${appState.posts.length}', + // 「我的资料」头部整体仍是 demo 家具(M5 范围):三项 + // 统计同源 demo 常量;AppState.posts 随 T3-17 退役后 + // 本项直读 demo 列表长度,不伪装真实数据。 + value: '${initialPosts.length}', label: '我的作品', ), ], diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 3d4834b..443c3cc 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -7,13 +7,16 @@ import 'package:shared_preferences/shared_preferences.dart'; class AppState extends ChangeNotifier { static const _petKey = 'patbond_pet'; - static const _postsKey = 'patbond_posts'; static const _locationWeatherKey = 'patbond_location_weather'; /// 首页问候卡 / 创作页 / 主壳头像仍消费的 demo 宠物(T2-12 起档案 /// Tab 已切独立 pets feature 真实数据;此 demo 随后续工单收敛)。 + /// + /// **demo 帖子列表已于 T3-17 退役**:Feed / 详情页自 T3-14/15 起消费 + /// community 真实数据,发布页自 T3-17 起走真实两步上传 + 建草稿/迁移 + /// 发布,`posts` / `publishPost` / `updatePost` 与其 + /// shared_preferences 持久化一并删除(03 号评估 §1.1 判定)。 PetProfile pet = initialPet; - List posts = List.from(initialPosts); LocationWeather locationWeather = initialLocationWeather; bool isReady = false; @@ -21,17 +24,11 @@ class AppState extends ChangeNotifier { try { final preferences = await SharedPreferences.getInstance(); final savedPet = preferences.getString(_petKey); - final savedPosts = preferences.getString(_postsKey); final savedLocationWeather = preferences.getString(_locationWeatherKey); if (savedPet != null) { pet = PetProfile.fromJson(jsonDecode(savedPet) as Map); } - if (savedPosts != null) { - posts = (jsonDecode(savedPosts) as List) - .map((item) => PostModel.fromJson(item as Map)) - .toList(); - } if (savedLocationWeather != null) { locationWeather = LocationWeather.fromJson( jsonDecode(savedLocationWeather) as Map, @@ -40,7 +37,6 @@ class AppState extends ChangeNotifier { } catch (error, stackTrace) { debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace'); pet = initialPet; - posts = List.from(initialPosts); locationWeather = initialLocationWeather; } finally { isReady = true; @@ -48,21 +44,6 @@ class AppState extends ChangeNotifier { } } - Future updatePost(PostModel value) async { - final index = posts.indexWhere((post) => post.id == value.id); - if (index == -1) return; - posts[index] = value; - posts = List.from(posts); - notifyListeners(); - await _savePosts(); - } - - Future publishPost(PostModel value) async { - posts = [value, ...posts]; - notifyListeners(); - await _savePosts(); - } - Future updateLocationWeather(LocationWeather value) async { locationWeather = value; notifyListeners(); @@ -71,21 +52,15 @@ class AppState extends ChangeNotifier { Future resetDemoData() async { pet = initialPet; - posts = List.from(initialPosts); locationWeather = initialLocationWeather; notifyListeners(); final preferences = await SharedPreferences.getInstance(); await Future.wait([ preferences.remove(_petKey), - preferences.remove(_postsKey), preferences.remove(_locationWeatherKey), ]); } - Future _savePosts() { - return _save(_postsKey, posts.map((post) => post.toJson()).toList()); - } - Future _save(String key, Object value) async { try { final preferences = await SharedPreferences.getInstance(); diff --git a/test/core/widgets/post_media_grid_test.dart b/test/core/widgets/post_media_grid_test.dart index d034439..51dcc32 100644 --- a/test/core/widgets/post_media_grid_test.dart +++ b/test/core/widgets/post_media_grid_test.dart @@ -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/widgets/feed_skeleton.dart'; import 'package:patbond_flutter/core/widgets/post_media_grid.dart'; +import 'package:patbond_flutter/core/widgets/upload_progress_overlay.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; import 'package:patbond_flutter/widgets/common.dart'; +import '../../helpers/media_test_helpers.dart'; + Widget wrap(Widget child, {bool disableAnimations = false}) => MaterialApp( theme: buildAppTheme(), home: MediaQuery( @@ -97,4 +101,92 @@ void main() { 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 = []; + 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 = []; + 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]); + }); + }); } diff --git a/test/features/community/media_uploader_test.dart b/test/features/community/media_uploader_test.dart index 47a065d..7d4e862 100644 --- a/test/features/community/media_uploader_test.dart +++ b/test/features/community/media_uploader_test.dart @@ -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/media_direct_upload.dart'; import 'package:patbond_flutter/features/community/media_uploader.dart'; +import 'package:patbond_flutter/features/community/post_analytics.dart'; import '../../helpers/community_test_helpers.dart'; import '../../helpers/media_test_helpers.dart'; @@ -24,12 +25,14 @@ void main() { int maxConcurrentUploads = 2, int maxByteSize = 10 * 1024 * 1024, DateTime Function()? now, + PostAnalytics? analytics, }) { return MediaUploader( repository: repository, picker: picker, compressor: compressor, directUpload: direct, + analytics: analytics, maxConcurrentUploads: maxConcurrentUploads, maxByteSize: maxByteSize, now: now, @@ -453,6 +456,151 @@ void main() { expect(uploader.overallProgress, closeTo(0.75, 0.001)); }); }); + + // ---- 媒体上传三段埋点(T3-17;22 号白名单 v3 事件 27~29)---- + group('媒体三段埋点', () { + late List<(String, Map?)> events; + late PostAnalytics analytics; + + setUp(() { + events = []; + analytics = PostAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List?> 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('断连失败 → retry:failed(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 40000:failed(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('在途删格报 cancelled;ready 后删格不报', () 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 readyAssetJson({required String assetId}) => { diff --git a/test/features/community/post_analytics_test.dart b/test/features/community/post_analytics_test.dart new file mode 100644 index 0000000..85d016c --- /dev/null +++ b/test/features/community/post_analytics_test.dart @@ -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?)> events; + late PostAnalytics analytics; + + setUp(() { + events = []; + analytics = PostAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + group('键集与白名单对齐(22 号 §1)', () { + test('post_create_started:entryPoint', () { + analytics.postCreateStarted(entryPoint: PostEntryPoint.createTab); + expect(events.single.$1, 'post_create_started'); + expect(events.single.$2, {'entryPoint': 'create_tab'}); + }); + + test('post_draft_saved:trigger + 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([ + '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_failed:failureReason/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_incomplete;40905/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_found;40902 → 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, + ); + }); + }); +} diff --git a/test/features/community/post_compose_page_test.dart b/test/features/community/post_compose_page_test.dart new file mode 100644 index 0000000..1b1edd1 --- /dev/null +++ b/test/features/community/post_compose_page_test.dart @@ -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-17):gating、两条提交路径(直接发布 / 存草稿退出)、 +/// 三条失败语义(40905 / 42203 / 网络可重试同键)、删格重排、草稿恢复与 +/// 发布漏斗埋点断言。 +void main() { + late FakeCommunityRepository repository; + late CommunityController controller; + late FakeMediaImagePicker picker; + late FakeMediaCompressor compressor; + late FakeDirectUploadClient direct; + late List<(String, Map?)> events; + late PostAnalytics analytics; + late List created; + late List 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?> 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 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( + MaterialPageRoute( + 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(publishButton()).onPressed != null; + + Future writeContent( + WidgetTester tester, [ + String text = '带豆豆去了公园', + ]) async { + await tester.enterText(find.byType(TextField), text); + await tester.pump(); + } + + /// 选 [count] 张图并等上传完成。 + Future 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('gating(05 §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 = []; + 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); + }); + }); +} diff --git a/test/features/home/home_page_test.dart b/test/features/home/home_page_test.dart index 729b454..1d6e2e9 100644 --- a/test/features/home/home_page_test.dart +++ b/test/features/home/home_page_test.dart @@ -39,7 +39,7 @@ void main() { late CommunityController controller; late List<(String, Map?)> events; late FeedAnalytics analytics; - late int createTaps; + late int composeTaps; late List openedPosts; setUp(() { @@ -54,7 +54,7 @@ void main() { analytics = FeedAnalytics( (name, [props]) async => events.add((name, props)), ); - createTaps = 0; + composeTaps = 0; openedPosts = []; }); @@ -72,7 +72,7 @@ void main() { feedAnalytics: analytics, isActive: isActive, onOpenServices: (_) {}, - onOpenCreate: () => createTaps++, + onOpenCompose: () => composeTaps++, onOpenPost: openedPosts.add, ), ), @@ -98,7 +98,7 @@ void main() { expect(find.byType(FeedSkeleton), findsNothing); }); - testWidgets('四态 · empty:空态插画 + 「发布第一条」CTA 去创作', (tester) async { + testWidgets('四态 · empty:空态插画 + 「发布第一条」CTA 进发布页', (tester) async { repository.onFeed = (_, _) async => feedPage(const []); await pumpHome(tester); @@ -108,7 +108,7 @@ void main() { await tester.drag(list(), const Offset(0, -300)); await tester.pump(); await tester.tap(find.text('发布第一条')); - expect(createTaps, 1); + expect(composeTaps, 1); }); testWidgets('四态 · error:横幅 + 重试恢复 ready,feed_load_failed 上报', (tester) async { diff --git a/test/features/main/main_shell_publish_test.dart b/test/features/main/main_shell_publish_test.dart new file mode 100644 index 0000000..4575f19 --- /dev/null +++ b/test/features/main/main_shell_publish_test.dart @@ -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(find.byType(PostCard)).toList(); + expect(cards.first.card.id, 'p-new'); + expect(find.text('刚发布的动态'), findsWidgets); + }); +} diff --git a/test/helpers/community_test_helpers.dart b/test/helpers/community_test_helpers.dart index 713f353..38387e0 100644 --- a/test/helpers/community_test_helpers.dart +++ b/test/helpers/community_test_helpers.dart @@ -169,6 +169,21 @@ CursorPage commentPage( bool hasMore = false, }) => 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 >[], +}); + +CursorPage postPage(List items) => + CursorPage(items: items, nextCursor: null, hasMore: false); + /// 假仓库:controller 测试注入行为并记录调用(Completer 控时序)。 /// 未注入 handler 的方法一律 UnimplementedError(误触发即测试失败)。 class FakeCommunityRepository implements CommunityRepository { @@ -189,6 +204,14 @@ class FakeCommunityRepository implements CommunityRepository { Future Function(CreateMediaUploadRequest request)? onCreateMediaUpload; Future Function(String assetId)? onCompleteMediaUpload; + Future Function(CreatePostRequest request, String? idempotencyKey)? + onCreatePost; + Future Function(String postId, UpdatePostRequest request)? onUpdatePost; + Future Function(String postId)? onDeletePost; + Future> Function(PostStatus? status)? onListMyPosts; + + /// createPost 收到的幂等键顺序(同键重放断言用)。 + final List idempotencyKeys = []; @override Future> getFeed({int? limit, String? cursor}) { @@ -241,22 +264,39 @@ class FakeCommunityRepository implements CommunityRepository { } @override - Future createPost(CreatePostRequest request) => - throw UnimplementedError(); + Future createPost(CreatePostRequest request, {String? idempotencyKey}) { + calls.add('createPost:${request.status?.name}:${request.content}'); + idempotencyKeys.add(idempotencyKey); + return onCreatePost!(request, idempotencyKey); + } @override - Future updatePost(String postId, UpdatePostRequest request) => - throw UnimplementedError(); + Future updatePost(String postId, UpdatePostRequest request) { + calls.add( + 'updatePost:$postId:v${request.version}:publish=${request.publish}', + ); + return onUpdatePost!(postId, request); + } @override - Future deletePost(String postId) => throw UnimplementedError(); + Future deletePost(String postId) { + calls.add('deletePost:$postId'); + return onDeletePost!(postId); + } @override Future> listMyPosts({ int? limit, String? cursor, PostStatus? status, - }) => throw UnimplementedError(); + }) { + calls.add('listMyPosts:${status?.name}'); + return onListMyPosts == null + ? Future.value( + const CursorPage(items: [], nextCursor: null, hasMore: false), + ) + : onListMyPosts!(status); + } @override Future> listComments( diff --git a/test/helpers/media_test_helpers.dart b/test/helpers/media_test_helpers.dart index a93b78f..0158901 100644 --- a/test/helpers/media_test_helpers.dart +++ b/test/helpers/media_test_helpers.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; 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', ); +/// 1x1 真 PNG 字节(70B):widget 测试里九宫格格子要真解码出图, +/// 用可解码的最小合法图片避免 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({ String assetId = 'a-1', DateTime? expiresAt, diff --git a/test/smoke/detail_interactions_smoke_test.dart b/test/smoke/detail_interactions_smoke_test.dart index aeba99e..86ae478 100644 --- a/test/smoke/detail_interactions_smoke_test.dart +++ b/test/smoke/detail_interactions_smoke_test.dart @@ -170,9 +170,7 @@ void main() { expect(card().likedByMe, false); controller.dispose(); }, - skip: enabled - ? false - : '设 PATBOND_DETAIL_SMOKE=1 且后端六容器在本机运行时才执行', + skip: enabled ? false : '设 PATBOND_DETAIL_SMOKE=1 且后端六容器在本机运行时才执行', timeout: const Timeout(Duration(minutes: 3)), ); } @@ -193,8 +191,10 @@ class _SwitchableRepository implements CommunityRepository { target.completeMediaUpload(assetId); @override - Future createPost(CreatePostRequest request) => - target.createPost(request); + Future createPost( + CreatePostRequest request, { + String? idempotencyKey, + }) => target.createPost(request, idempotencyKey: idempotencyKey); @override Future getPost(String postId) => target.getPost(postId);