Files
patbond-flutter/lib/core/widgets/post_media_grid.dart
T
lixi 8aac8c52cc
CI / flutter-gates (push) Successful in 2m41s
新增:首页 Feed 接入真实数据——四态/尾部三态/聚合曝光埋点,社区 demo 消亡第一页(T3-14)
- home_page Feed segment 改接 CommunityController:骨架屏首载、空态 CTA、
  错误横幅重试、下拉刷新(失败保留旧列表 + SnackBar)、触底游标翻页
  (失败态只走显式重试,不随滚动风暴自动重打)
- PostCard 三形态(单图通栏 4:3 / 多图折叠封面 +N 角标 / 纯文字 6 行)+
  PostMediaGrid(列数规则 + ink 80% scrim 精算)+ LikeButton(error 族修订
  Colors.red,T3-14 纯展示禁用态,ToggleSync 接线留 T3-15/16)+ FeedSkeleton
  (呼吸动效尊重减弱动态设置);降级作者统一「宠友」占位
- SignedNetworkImage:预签名 URL 缓存 key 剥离 X-Amz-* 签名参数,
  RemoteImage 全仓换用(同图不同签名命中同一缓存条目)
- feed 域埋点:feed_viewed 聚合曝光(浏览段开/结算于切 Tab、切分段、
  退后台、销毁;≥50% 可见 ≥500ms 段内按帖去重,postId 不上报)+
  feed_load_failed(refresh/load_more 双路,会话失效不报)
- integration_test/feed_live_test.dart:compose 六容器 Linux 桌面真链路
  实测入口(PATBOND_FEED_LIVE=1 门控,默认跳过)
- 测试 379 → 421(+42):四态/尾部三态/翻页不丢不重/降级作者/曝光结算
  时机/缓存 key/事件属性全覆盖;analyze 0,format 无 diff

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

165 lines
5.2 KiB
Dart
Raw 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 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.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:160% 档
/// 3.88:1 不达标弃用,§5.1 精算)。
/// - 单图折叠形态:Feed 卡片契约只带封面 + mediaCountFeedCard 裁剪),
/// [urls] 单元素而 [totalCount] > 1 时渲染 4:3 单格 + 右下「+N」角标
/// 胶囊(同 80% scrim 精算)。
///
/// 编辑态(「+」格 / 删除角标)随发布页工单(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;
},
);
}
}
/// 单图折叠形态: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;
}
}