8aac8c52cc
CI / flutter-gates (push) Successful in 2m41s
- 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>
165 lines
5.2 KiB
Dart
165 lines
5.2 KiB
Dart
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:1;60% 档
|
||
/// 3.88:1 不达标弃用,§5.1 精算)。
|
||
/// - 单图折叠形态:Feed 卡片契约只带封面 + mediaCount(FeedCard 裁剪),
|
||
/// [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;
|
||
}
|
||
}
|