新增:互动埋点与乐观更新视觉基建——字典 v3 互动八事件封装 + ToggleSync 成功上报与触点归因 + LikeButton 三层闪烁抑制动画(T3-16)
- community_interaction_analytics:post_liked/unliked、post_favorited/unfavorited、 comment_create_succeeded/failed、user_followed/unfollowed 强类型封装(22 号 白名单键集逐一对齐;textLengthBucket 分桶、httpStatus 由五位码推导、 会话失效不上报);comment_create_started 锁死 unknown 不封装 - CommunityController:toggleLike/toggleBookmark 增 source 触点归因 (feed / post_detail),成功响应后经注入的 interactionAnalytics 上报 (乐观翻转与失败不报);adjustCommentCount 评论计数同源写入 (详情副本与 Feed 卡片一并更新) - LikeButton 升 Stateful:点按激活 240ms 弹性缩放 + 120ms 图标淡入、 取消仅颜色渐出;回滚/对账零动画直接跳变、计数与状态成对更新、 激活动画未播完等播完再跳(05 号 §3.5/§4);减弱动态设置降级瞬变 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,12 +31,21 @@ enum LikeButtonVariant {
|
||||
final Color activeCountColor;
|
||||
}
|
||||
|
||||
/// 点赞/收藏交互钮(05 号规范 §3.5 静态规格):图标 20 + 计数 13/w600,
|
||||
/// 未激活一律 `inkSoft`(6.59:1)。触控 44×44 由 padding 撑足。
|
||||
/// 点赞/收藏交互钮(05 号规范 §3.5):图标 20 + 计数 13/w600,未激活
|
||||
/// 一律 `inkSoft`(6.59:1)。触控 44×44 由 padding 撑足。
|
||||
///
|
||||
/// T3-14 只做展示([onPressed] 传 null 即禁用态,仍按正常色渲染计数与
|
||||
/// 状态);乐观更新动画与 ToggleSync 接线属 T3-15/16。
|
||||
class LikeButton extends StatelessWidget {
|
||||
/// 乐观更新视觉(§3.5/§4 三层闪烁抑制的 UI 半边,状态本体由持有方经
|
||||
/// ToggleSync 驱动):
|
||||
///
|
||||
/// - **点按驱动**的状态变化:激活播 240ms 弹性缩放(1→1.25→1)+ 图标
|
||||
/// 120ms 淡入;取消仅 120ms 颜色渐出、无缩放。
|
||||
/// - **非点按驱动**的状态变化(失败回滚 / 服务端对账):零动画直接跳变;
|
||||
/// 若激活动画未播完,等播完再跳(避免动画中途反转的抖动,§4.3a)。
|
||||
/// - 计数变化一律直接替换,不做滚动动画(回滚时无二次滚动)。
|
||||
/// - 系统「减弱动态效果」开启时全部降级为瞬变。
|
||||
///
|
||||
/// [onPressed] 传 null 即纯展示禁用态(仍按正常色渲染计数与状态)。
|
||||
class LikeButton extends StatefulWidget {
|
||||
const LikeButton({
|
||||
required this.variant,
|
||||
required this.active,
|
||||
@@ -52,15 +61,106 @@ class LikeButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
State<LikeButton> createState() => _LikeButtonState();
|
||||
}
|
||||
|
||||
class _LikeButtonState extends State<LikeButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _scaleController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 240),
|
||||
);
|
||||
late final Animation<double> _scale = TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween<double>(
|
||||
begin: 1,
|
||||
end: 1.25,
|
||||
).chain(CurveTween(curve: Curves.easeOut)),
|
||||
weight: 40,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween<double>(
|
||||
begin: 1.25,
|
||||
end: 1,
|
||||
).chain(CurveTween(curve: Curves.easeOutBack)),
|
||||
weight: 60,
|
||||
),
|
||||
]).animate(_scaleController);
|
||||
|
||||
/// 当前展示态(回滚等待动画播完期间可短暂落后于 widget.active)。
|
||||
late bool _displayActive = widget.active;
|
||||
|
||||
/// 展示计数与状态成对更新(§4.3c:回滚不出现「心已灭计数未减」中间帧)。
|
||||
late int _displayCount = widget.count;
|
||||
|
||||
/// 最近一次点按的期望目标态;didUpdateWidget 以此区分「点按驱动」
|
||||
/// (播动画)与「回滚/对账」(零动画跳变)。
|
||||
bool? _expectedTarget;
|
||||
|
||||
/// 图标切换是否走 120ms 淡入淡出(点按驱动);回滚跳变置 false。
|
||||
bool _fadeSwap = false;
|
||||
|
||||
bool get _reduceMotion =>
|
||||
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LikeButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.active == widget.active) {
|
||||
// 状态未变的计数变化 = 服务端对账:静默替换、不播动画(§4.4)。
|
||||
_displayCount = widget.count;
|
||||
return;
|
||||
}
|
||||
final tapDriven = _expectedTarget == widget.active;
|
||||
_expectedTarget = null;
|
||||
if (tapDriven && !_reduceMotion) {
|
||||
_fadeSwap = true;
|
||||
_displayActive = widget.active;
|
||||
_displayCount = widget.count;
|
||||
if (widget.active) _scaleController.forward(from: 0);
|
||||
return;
|
||||
}
|
||||
// 回滚 / 对账:零动画、计数与状态成对跳变。激活动画未播完则等
|
||||
// 播完再跳(§4.3a,避免动画中途反转的抖动)。
|
||||
_fadeSwap = false;
|
||||
if (_scaleController.isAnimating) {
|
||||
_scaleController.forward().whenComplete(() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_displayActive = widget.active;
|
||||
_displayCount = widget.count;
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_displayActive = widget.active;
|
||||
_displayCount = widget.count;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
_expectedTarget = !widget.active;
|
||||
widget.onPressed!();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final variant = widget.variant;
|
||||
final active = _displayActive;
|
||||
final iconColor = active ? variant.activeIconColor : AppColors.inkSoft;
|
||||
final countColor = active ? variant.activeCountColor : AppColors.inkSoft;
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
button: onPressed != null,
|
||||
label: widget.semanticLabel,
|
||||
button: widget.onPressed != null,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
onTap: widget.onPressed == null ? null : _handleTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
@@ -69,14 +169,24 @@ class LikeButton extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
active ? variant.activeIcon : variant.inactiveIcon,
|
||||
size: 20,
|
||||
color: iconColor,
|
||||
ScaleTransition(
|
||||
scale: _scale,
|
||||
child: AnimatedSwitcher(
|
||||
duration: _fadeSwap
|
||||
? const Duration(milliseconds: 120)
|
||||
: Duration.zero,
|
||||
child: Icon(
|
||||
active ? variant.activeIcon : variant.inactiveIcon,
|
||||
key: ValueKey(active),
|
||||
size: 20,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 计数直接替换(§3.5:不做滚动动画,避免回滚二次滚动)。
|
||||
Text(
|
||||
'$count',
|
||||
'$_displayCount',
|
||||
style: TextStyle(
|
||||
color: countColor,
|
||||
fontSize: 13,
|
||||
|
||||
Reference in New Issue
Block a user