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 urls; /// 实际总张数(缺省 = urls.length);大于可展示数时渲染「+N」。 final int? totalCount; /// 格子点按(全屏浏览入口,T3-15 详情页接线)。 final ValueChanged? 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 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({ 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; } }