9892b65a19
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>
368 lines
12 KiB
Dart
368 lines
12 KiB
Dart
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)。
|
||
///
|
||
/// - 列数规则:2、4 图 → 2 列;3、5–9 图 → 3 列;全部 1:1 cover、
|
||
/// 格间距 4、单格圆角 `sm`(12),`RemoteImage` 复用(loading tint 块 /
|
||
/// 失败图标兜底,缓存 key 已剥签名)。
|
||
/// - 超出折叠:最多显 9 格,[totalCount] 超过显示数时末格叠
|
||
/// `ink` 80% scrim + 白字「+N」20/w800(合成最亮白图 7.10:1;60% 档
|
||
/// 3.88:1 不达标弃用,§5.1 精算)。
|
||
/// - 单图折叠形态:Feed 卡片契约只带封面 + mediaCount(FeedCard 裁剪),
|
||
/// [urls] 单元素而 [totalCount] > 1 时渲染 4:3 单格 + 右下「+N」角标
|
||
/// 胶囊(同 80% scrim 精算)。
|
||
///
|
||
/// 编辑态(「+」格 / 删除角标 / 进度覆盖层)见同文件 [PostMediaEditGrid]
|
||
/// (T3-17 发布页选图区)。
|
||
class PostMediaGrid extends StatelessWidget {
|
||
const PostMediaGrid({
|
||
required this.urls,
|
||
super.key,
|
||
this.totalCount,
|
||
this.onCellTap,
|
||
}) : assert(urls.length > 0, 'PostMediaGrid 至少一张图');
|
||
|
||
/// 可展示的图片 URL(Feed 卡片仅封面一张;详情页全量)。
|
||
final List<String> urls;
|
||
|
||
/// 实际总张数(缺省 = urls.length);大于可展示数时渲染「+N」。
|
||
final int? totalCount;
|
||
|
||
/// 格子点按(全屏浏览入口,T3-15 详情页接线)。
|
||
final ValueChanged<int>? onCellTap;
|
||
|
||
static const _maxCells = 9;
|
||
static const _spacing = 4.0;
|
||
|
||
int get _effectiveTotal => totalCount ?? urls.length;
|
||
|
||
/// 列数规则(§3.2):2、4 → 2 列;其余 → 3 列(1 图不走网格)。
|
||
static int columnsFor(int cellCount) =>
|
||
(cellCount == 2 || cellCount == 4) ? 2 : 3;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (urls.length == 1) {
|
||
return _CollapsedCover(
|
||
url: urls.first,
|
||
hiddenCount: _effectiveTotal - 1,
|
||
onTap: onCellTap == null ? null : () => onCellTap!(0),
|
||
);
|
||
}
|
||
|
||
final cellCount = urls.length > _maxCells ? _maxCells : urls.length;
|
||
final overflow = _effectiveTotal - cellCount;
|
||
final columns = columnsFor(cellCount);
|
||
return GridView.builder(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: columns,
|
||
mainAxisSpacing: _spacing,
|
||
crossAxisSpacing: _spacing,
|
||
),
|
||
itemCount: cellCount,
|
||
itemBuilder: (context, index) {
|
||
final isOverflowCell = overflow > 0 && index == cellCount - 1;
|
||
Widget cell = RemoteImage(
|
||
url: urls[index],
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
);
|
||
if (isOverflowCell) {
|
||
cell = Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
cell,
|
||
DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
color: AppColors.ink.withAlpha(204),
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
),
|
||
child: Center(
|
||
child: Text(
|
||
'+$overflow',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
if (onCellTap != null) {
|
||
cell = InkWell(
|
||
onTap: () => onCellTap!(index),
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
child: cell,
|
||
);
|
||
}
|
||
return cell;
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 编辑态九宫格(05 号规范 §3.2 编辑态;T3-17 发布页选图区)。
|
||
///
|
||
/// 与展示态 [PostMediaGrid] 同文件成组,但**独立成类**:展示态以
|
||
/// 「至少一张图 + URL 列表」为前提(构造断言),编辑态的常态却是
|
||
/// 「零张图 + 一个『+』格」,共用一个构造签名只会让两边都别扭。
|
||
///
|
||
/// - 固定 3 列(九宫格语义),格间距 4、圆角 `sm`(12)、1:1 `cover`;
|
||
/// 缩略图直接渲染选图原始字节([MediaUploadItem.previewBytes],
|
||
/// 不落磁盘、不走网络;解码失败回落 surfaceTint 块 + pets 图标)。
|
||
/// - 每格叠 [UploadProgressOverlay](六态映射四视觉态);可重试失败格
|
||
/// 整格点按重试,终态失败不给重试通栏。
|
||
/// - 删除角标:右上 22 圆 `ink` 80% 实底 + 白 close 14,触控热区 32。
|
||
/// - 「+」格:虚线 1.5px 圆角 12 + `add_photo_alternate_outlined` 24
|
||
/// `inkSoft`;[canAdd] 为 false(满 9 张)时隐藏。
|
||
/// - 长按拖拽排序未做(05 §6 D9 可选项):删格即整组重排,position 由
|
||
/// [MediaUploader.buildAttachRequests] 按当前列表序重发号。
|
||
class PostMediaEditGrid extends StatelessWidget {
|
||
const PostMediaEditGrid({
|
||
required this.items,
|
||
super.key,
|
||
this.canAdd = true,
|
||
this.onAdd,
|
||
this.onRemove,
|
||
this.onRetry,
|
||
});
|
||
|
||
/// 当前上传项快照(顺序 = position 语义)。
|
||
final List<MediaUploadItem> items;
|
||
|
||
/// 是否渲染「+」格(剩余槽位 > 0)。
|
||
final bool canAdd;
|
||
|
||
final VoidCallback? onAdd;
|
||
|
||
/// 删格回调,参数为 [MediaUploadItem.localId]。
|
||
final ValueChanged<int>? onRemove;
|
||
|
||
/// 重试回调(仅可重试失败格触发),参数为 localId。
|
||
final ValueChanged<int>? onRetry;
|
||
|
||
static const _spacing = 4.0;
|
||
static const _columns = 3;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cellCount = items.length + (canAdd ? 1 : 0);
|
||
if (cellCount == 0) return const SizedBox.shrink();
|
||
return GridView.builder(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: _columns,
|
||
mainAxisSpacing: _spacing,
|
||
crossAxisSpacing: _spacing,
|
||
),
|
||
itemCount: cellCount,
|
||
itemBuilder: (context, index) {
|
||
if (index == items.length) return _AddCell(onTap: onAdd);
|
||
return _EditCell(
|
||
item: items[index],
|
||
onRemove: onRemove == null
|
||
? null
|
||
: () => onRemove!(items[index].localId),
|
||
onRetry: onRetry == null || !items[index].retryable
|
||
? null
|
||
: () => onRetry!(items[index].localId),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EditCell extends StatelessWidget {
|
||
const _EditCell({required this.item, this.onRemove, this.onRetry});
|
||
|
||
final MediaUploadItem item;
|
||
final VoidCallback? onRemove;
|
||
final VoidCallback? onRetry;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
child: Image.memory(
|
||
item.previewBytes,
|
||
fit: BoxFit.cover,
|
||
gaplessPlayback: true,
|
||
// 选图字节无法解码时的兜底(RemoteImage 同款形态)。
|
||
errorBuilder: (context, _, _) => const ColoredBox(
|
||
color: AppColors.surfaceTint,
|
||
child: Center(
|
||
child: Icon(Icons.pets, color: AppColors.muted, size: 20),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
child: UploadProgressOverlay(
|
||
phase: item.phase,
|
||
progress: item.progress,
|
||
onRetry: onRetry,
|
||
),
|
||
),
|
||
Positioned(
|
||
right: 0,
|
||
top: 0,
|
||
child: GestureDetector(
|
||
onTap: onRemove,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: const Padding(
|
||
// 22 圆角标 + padding 撑到 32 触控热区。
|
||
padding: EdgeInsets.all(5),
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Color(0xCC3E2A1F),
|
||
),
|
||
child: SizedBox(
|
||
width: 22,
|
||
height: 22,
|
||
child: Center(
|
||
child: Icon(Icons.close, size: 14, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AddCell extends StatelessWidget {
|
||
const _AddCell({this.onTap});
|
||
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
child: CustomPaint(
|
||
painter: const _DashedBorderPainter(),
|
||
child: const Center(
|
||
child: Icon(
|
||
Icons.add_photo_alternate_outlined,
|
||
size: 24,
|
||
color: AppColors.inkSoft,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 「+」格的虚线圆角边框(Flutter 无内置虚线边框,按规范 1.5px 自绘)。
|
||
class _DashedBorderPainter extends CustomPainter {
|
||
const _DashedBorderPainter();
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final paint = Paint()
|
||
..color = AppColors.border
|
||
..strokeWidth = 1.5
|
||
..style = PaintingStyle.stroke;
|
||
final path = Path()
|
||
..addRRect(
|
||
RRect.fromRectAndRadius(
|
||
Offset.zero & size,
|
||
const Radius.circular(AppRadius.sm),
|
||
),
|
||
);
|
||
const dash = 5.0;
|
||
const gap = 4.0;
|
||
for (final metric in path.computeMetrics()) {
|
||
var distance = 0.0;
|
||
while (distance < metric.length) {
|
||
final next = distance + dash;
|
||
canvas.drawPath(
|
||
metric.extractPath(
|
||
distance,
|
||
next > metric.length ? metric.length : next,
|
||
),
|
||
paint,
|
||
);
|
||
distance = next + gap;
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_DashedBorderPainter oldDelegate) => false;
|
||
}
|
||
|
||
/// 单图折叠形态:4:3 圆角封面 + 右下「+N」胶囊角标。
|
||
class _CollapsedCover extends StatelessWidget {
|
||
const _CollapsedCover({
|
||
required this.url,
|
||
required this.hiddenCount,
|
||
this.onTap,
|
||
});
|
||
|
||
final String url;
|
||
final int hiddenCount;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget cover = AspectRatio(
|
||
aspectRatio: 4 / 3,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
RemoteImage(
|
||
url: url,
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
),
|
||
if (hiddenCount > 0)
|
||
Positioned(
|
||
right: 8,
|
||
bottom: 8,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.ink.withAlpha(204),
|
||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||
),
|
||
child: Text(
|
||
'+$hiddenCount',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (onTap != null) {
|
||
cover = InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
child: cover,
|
||
);
|
||
}
|
||
return cover;
|
||
}
|
||
}
|