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>
241 lines
7.5 KiB
Dart
241 lines
7.5 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||
import 'package:patbond_flutter/core/widgets/like_button.dart';
|
||
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
|
||
import 'package:patbond_flutter/features/community/community_display.dart';
|
||
import 'package:patbond_flutter/features/community/community_models.dart';
|
||
import 'package:patbond_flutter/widgets/common.dart';
|
||
|
||
/// 帖子卡三形态(05 号规范 §2.1/§3.1):home 私有 `_PostCard` 的升级
|
||
/// 迁移,按媒体形态分支——
|
||
///
|
||
/// | 形态 | 媒体区 |
|
||
/// | --- | --- |
|
||
/// | 单图(mediaCount ≤ 1 且有封面) | 通栏出血 4:3 |
|
||
/// | 多图(mediaCount > 1) | [PostMediaGrid] 折叠封面(FeedCard 契约只带封面 + 计数),水平 padding 14 |
|
||
/// | 纯文字(无封面) | 无媒体区,正文放宽 ≤6 行、15/1.6 |
|
||
///
|
||
/// 头部行:作者头像 32 + 名字 14/w700 + 时间 12 `inkSoft`;求助帖
|
||
/// 元信息尾追加 `TagPill(accent)`。降级作者([AuthorSummary.isDegraded])
|
||
/// 渲染占位头像 + 「宠友」。操作行:[LikeButton](点赞 / 收藏)+ 评论
|
||
/// 计数 + 分享,各钮触控 44;互动回调传 null 即纯展示(T3-14 形态,
|
||
/// ToggleSync 接线属 T3-15/16)。
|
||
class PostCard extends StatelessWidget {
|
||
const PostCard({
|
||
required this.card,
|
||
super.key,
|
||
this.onTap,
|
||
this.onLikeTap,
|
||
this.onCommentTap,
|
||
this.onBookmarkTap,
|
||
this.onShareTap,
|
||
this.now,
|
||
});
|
||
|
||
final FeedCard card;
|
||
|
||
/// 整卡点按(帖子详情入口)。
|
||
final VoidCallback? onTap;
|
||
|
||
final VoidCallback? onLikeTap;
|
||
final VoidCallback? onCommentTap;
|
||
final VoidCallback? onBookmarkTap;
|
||
final VoidCallback? onShareTap;
|
||
|
||
/// 相对时间的参考时钟(测试注入;缺省取当前时间)。
|
||
final DateTime? now;
|
||
|
||
bool get _isTextOnly => card.coverImage == null;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
clipBehavior: Clip.antiAlias,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_header(context),
|
||
if (card.coverImage != null)
|
||
card.mediaCount > 1
|
||
? Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||
child: PostMediaGrid(
|
||
urls: [card.coverImage!.url],
|
||
totalCount: card.mediaCount,
|
||
),
|
||
)
|
||
: AspectRatio(
|
||
aspectRatio: 4 / 3,
|
||
child: RemoteImage(url: card.coverImage!.url),
|
||
),
|
||
_body(context),
|
||
_actionRow(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _header(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Row(
|
||
children: [
|
||
PetAvatar(size: PetAvatarSize.sm, url: card.author.avatarUrl),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
authorDisplayName(card.author),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
feedRelativeTime(card.publishedAt, now: now),
|
||
style: const TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
if (card.category == PostCategory.help) ...[
|
||
const SizedBox(width: 6),
|
||
const TagPill('求助', color: AppColors.accent),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _body(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (card.title != null) ...[
|
||
Text(
|
||
card.title!,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
const SizedBox(height: 6),
|
||
],
|
||
Text(
|
||
card.contentPreview,
|
||
// 纯文字帖放宽至 6 行并升字号补偿视觉重量(§2.1)。
|
||
maxLines: _isTextOnly ? 6 : 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: _isTextOnly
|
||
? const TextStyle(
|
||
color: AppColors.ink,
|
||
fontSize: 15,
|
||
height: 1.6,
|
||
)
|
||
: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _actionRow() {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||
child: Row(
|
||
children: [
|
||
LikeButton(
|
||
variant: LikeButtonVariant.like,
|
||
active: card.likedByMe,
|
||
count: card.likeCount,
|
||
onPressed: onLikeTap,
|
||
semanticLabel: '点赞',
|
||
),
|
||
const SizedBox(width: 4),
|
||
_CommentCount(count: card.commentCount, onPressed: onCommentTap),
|
||
const SizedBox(width: 4),
|
||
LikeButton(
|
||
variant: LikeButtonVariant.bookmark,
|
||
active: card.bookmarkedByMe,
|
||
count: card.bookmarkCount,
|
||
onPressed: onBookmarkTap,
|
||
semanticLabel: '收藏',
|
||
),
|
||
const Spacer(),
|
||
InkWell(
|
||
onTap: onShareTap,
|
||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||
child: const SizedBox(
|
||
width: 44,
|
||
height: 44,
|
||
child: Icon(
|
||
Icons.ios_share_outlined,
|
||
size: 20,
|
||
color: AppColors.inkSoft,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 评论计数钮(与 LikeButton 同视觉规格,无激活态)。
|
||
class _CommentCount extends StatelessWidget {
|
||
const _CommentCount({required this.count, this.onPressed});
|
||
|
||
final int count;
|
||
final VoidCallback? onPressed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return InkWell(
|
||
onTap: onPressed,
|
||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
Icons.chat_bubble_outline,
|
||
size: 20,
|
||
color: AppColors.inkSoft,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'$count',
|
||
style: const TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|