新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
CI / flutter-gates (push) Successful in 3m9s
CI / flutter-gates (push) Successful in 3m9s
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目 (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay, 删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready) - 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿 已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、 网络失败同键重放不重复建帖;40902 自动取新 version 重提一次 - 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新 一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点) - 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典 v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq; pageName 枚举补 post_form - PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 + 进度覆盖层 + 失败整格重试;页级上传汇总条 - create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts / publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态 CTA 改为 push 真实发布页 - 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态 九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控) - flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过 (选图上传 → 发布 → Feed 置顶 → 第二客户端可见) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<MediaUploadItem> items;
|
||||
|
||||
/// 是否渲染「+」格(剩余槽位 > 0)。
|
||||
final bool canAdd;
|
||||
|
||||
final VoidCallback? onAdd;
|
||||
|
||||
/// 删格回调,参数为 [MediaUploadItem.localId]。
|
||||
final ValueChanged<int>? onRemove;
|
||||
|
||||
/// 重试回调(仅可重试失败格触发),参数为 localId。
|
||||
final ValueChanged<int>? onRetry;
|
||||
|
||||
static const _spacing = 4.0;
|
||||
static const _columns = 3;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cellCount = items.length + (canAdd ? 1 : 0);
|
||||
if (cellCount == 0) return const SizedBox.shrink();
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _columns,
|
||||
mainAxisSpacing: _spacing,
|
||||
crossAxisSpacing: _spacing,
|
||||
),
|
||||
itemCount: cellCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == items.length) return _AddCell(onTap: onAdd);
|
||||
return _EditCell(
|
||||
item: items[index],
|
||||
onRemove: onRemove == null
|
||||
? null
|
||||
: () => onRemove!(items[index].localId),
|
||||
onRetry: onRetry == null || !items[index].retryable
|
||||
? null
|
||||
: () => onRetry!(items[index].localId),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditCell extends StatelessWidget {
|
||||
const _EditCell({required this.item, this.onRemove, this.onRetry});
|
||||
|
||||
final MediaUploadItem item;
|
||||
final VoidCallback? onRemove;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Image.memory(
|
||||
item.previewBytes,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
// 选图字节无法解码时的兜底(RemoteImage 同款形态)。
|
||||
errorBuilder: (context, _, _) => const ColoredBox(
|
||||
color: AppColors.surfaceTint,
|
||||
child: Center(
|
||||
child: Icon(Icons.pets, color: AppColors.muted, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: UploadProgressOverlay(
|
||||
phase: item.phase,
|
||||
progress: item.progress,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: GestureDetector(
|
||||
onTap: onRemove,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
// 22 圆角标 + padding 撑到 32 触控热区。
|
||||
padding: EdgeInsets.all(5),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xCC3E2A1F),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: Center(
|
||||
child: Icon(Icons.close, size: 14, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddCell extends StatelessWidget {
|
||||
const _AddCell({this.onTap});
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: CustomPaint(
|
||||
painter: const _DashedBorderPainter(),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.add_photo_alternate_outlined,
|
||||
size: 24,
|
||||
color: AppColors.inkSoft,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「+」格的虚线圆角边框(Flutter 无内置虚线边框,按规范 1.5px 自绘)。
|
||||
class _DashedBorderPainter extends CustomPainter {
|
||||
const _DashedBorderPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = AppColors.border
|
||||
..strokeWidth = 1.5
|
||||
..style = PaintingStyle.stroke;
|
||||
final path = Path()
|
||||
..addRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(AppRadius.sm),
|
||||
),
|
||||
);
|
||||
const dash = 5.0;
|
||||
const gap = 4.0;
|
||||
for (final metric in path.computeMetrics()) {
|
||||
var distance = 0.0;
|
||||
while (distance < metric.length) {
|
||||
final next = distance + dash;
|
||||
canvas.drawPath(
|
||||
metric.extractPath(
|
||||
distance,
|
||||
next > metric.length ? metric.length : next,
|
||||
),
|
||||
paint,
|
||||
);
|
||||
distance = next + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DashedBorderPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// 单图折叠形态:4:3 圆角封面 + 右下「+N」胶囊角标。
|
||||
class _CollapsedCover extends StatelessWidget {
|
||||
const _CollapsedCover({
|
||||
|
||||
Reference in New Issue
Block a user