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>
126 lines
3.7 KiB
Dart
126 lines
3.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
|
|
|
/// Feed 骨架屏单元(05 号规范 §3.7):模拟单图卡——头部行(32 圆 +
|
|
/// 两条横条)→ 4:3 通栏块 → 两条正文横条。块色 `surfaceTint`
|
|
/// (1.18:1,装饰性占位不受对比度约束)。
|
|
///
|
|
/// 动效:整体不透明度 0.6 ↔ 1.0 呼吸循环 1200ms;系统「减弱动态效果」
|
|
/// 开启时静止在 1.0。首载用法为连排 3 张(P1/P4)。
|
|
class FeedSkeleton extends StatefulWidget {
|
|
const FeedSkeleton({super.key});
|
|
|
|
@override
|
|
State<FeedSkeleton> createState() => _FeedSkeletonState();
|
|
}
|
|
|
|
class _FeedSkeletonState extends State<FeedSkeleton>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1200),
|
|
lowerBound: 0.6,
|
|
value: 1,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final reduceMotion = MediaQuery.of(context).disableAnimations;
|
|
if (reduceMotion) {
|
|
_controller.stop();
|
|
_controller.value = 1;
|
|
} else if (!_controller.isAnimating) {
|
|
_controller.repeat(reverse: true);
|
|
}
|
|
return FadeTransition(
|
|
opacity: _controller,
|
|
child: Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(
|
|
children: [
|
|
const _SkeletonBlock(width: 32, height: 32, circle: true),
|
|
const SizedBox(width: 10),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SkeletonBlock(
|
|
width: _relative(context, 0.40),
|
|
height: 12,
|
|
),
|
|
const SizedBox(height: 6),
|
|
_SkeletonBlock(
|
|
width: _relative(context, 0.24),
|
|
height: 8,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const AspectRatio(
|
|
aspectRatio: 4 / 3,
|
|
child: ColoredBox(color: AppColors.surfaceTint),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SkeletonBlock(width: _relative(context, 0.90), height: 12),
|
|
const SizedBox(height: 8),
|
|
_SkeletonBlock(width: _relative(context, 0.60), height: 12),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 横条宽度按屏宽比例取(规范 40%/24%/90%/60%),免依赖父约束测量。
|
|
double _relative(BuildContext context, double fraction) =>
|
|
MediaQuery.of(context).size.width * fraction;
|
|
}
|
|
|
|
class _SkeletonBlock extends StatelessWidget {
|
|
const _SkeletonBlock({
|
|
required this.width,
|
|
required this.height,
|
|
this.circle = false,
|
|
});
|
|
|
|
final double width;
|
|
final double height;
|
|
final bool circle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: width,
|
|
height: height,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceTint,
|
|
borderRadius: BorderRadius.circular(circle ? width / 2 : 6),
|
|
),
|
|
);
|
|
}
|
|
}
|