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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00

151 lines
4.6 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:patbond_flutter/features/community/community_models.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';
/// media 上传链路测试件(T3-13):假选择器/压缩器/直传客户端,
/// 全部支持 Completer 控时序(helpers 既有先例)。
PickedMediaImage pickedImage({int seed = 1, int size = 64}) => PickedMediaImage(
bytes: Uint8List.fromList(List.filled(size, seed)),
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,
}) => MediaUploadCredentials(
assetId: assetId,
uploadUrl:
'http://minio.local/patbond-media/post_image/$assetId?X-Amz-Signature=sig',
method: 'PUT',
requiredHeaders: const {'Content-Type': 'image/jpeg'},
expiresAt: expiresAt ?? DateTime.now().add(const Duration(minutes: 10)),
);
MediaAsset readyAsset({String assetId = 'a-1'}) => MediaAsset(
id: assetId,
kind: MediaKind.image,
purpose: 'post_image',
mimeType: 'image/jpeg',
byteSize: 1024,
widthPx: 1080,
heightPx: 810,
status: MediaAssetStatus.ready,
url: 'http://minio.local/p.jpg?X-Amz-Signature=sig',
readyAt: DateTime.utc(2026, 9, 9),
createdAt: DateTime.utc(2026, 9, 9),
);
class FakeMediaImagePicker implements MediaImagePicker {
FakeMediaImagePicker(this.results);
final List<PickedMediaImage> results;
final List<int> limits = [];
/// 非 null 时 pickImages 挂起等待(picking 态观测用)。
Completer<void>? gate;
@override
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
limits.add(limit);
await gate?.future;
return results.take(limit).toList();
}
}
/// 假压缩器:默认原样透传出 image/jpeg[sizePerQuality] 指定各降质档
/// 的产物大小(超限阶梯测试用)。
class FakeMediaCompressor implements MediaImageCompressor {
final List<int> qualities = [];
Map<int, int>? sizePerQuality;
Exception? error;
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async {
qualities.add(quality);
if (error != null) throw error!;
final size = sizePerQuality?[quality];
return CompressedMediaImage(
bytes: size == null
? source.bytes
: Uint8List.fromList(List.filled(size, 0)),
mimeType: 'image/jpeg',
);
}
}
/// 一次直传调用的观测句柄(进度回调驱动 + 结局手动控制)。
class DirectUploadCall {
DirectUploadCall({
required this.url,
required this.headers,
required this.bytes,
required this.onProgress,
});
final String url;
final Map<String, String> headers;
final Uint8List bytes;
final void Function(int sent, int total)? onProgress;
final Completer<void> completer = Completer<void>();
void emitProgress(int sent, int total) => onProgress?.call(sent, total);
void succeed() => completer.complete();
void fail(MediaDirectUploadException error) => completer.completeError(error);
}
class FakeDirectUploadClient implements MediaDirectUploadClient {
final List<DirectUploadCall> calls = [];
/// 非 null 时每次 put 自动以该结果收尾(null = 自动成功);
/// 设为 manual 后由测试经 [calls] 手动驱动。
bool manual = false;
final List<MediaDirectUploadException?> scriptedOutcomes = [];
@override
Future<void> put({
required String url,
required Map<String, String> headers,
required Uint8List bytes,
void Function(int sent, int total)? onProgress,
}) {
final call = DirectUploadCall(
url: url,
headers: headers,
bytes: bytes,
onProgress: onProgress,
);
calls.add(call);
if (!manual) {
final outcome = scriptedOutcomes.isEmpty
? null
: scriptedOutcomes.removeAt(0);
if (outcome == null) {
call.succeed();
} else {
call.fail(outcome);
}
}
return call.completer.future;
}
}