92524da8e2
- 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>
204 lines
6.7 KiB
Dart
204 lines
6.7 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||
|
||
/// 点赞 / 收藏按钮语义变体(05 号规范 §3.5:同一组件,图标与语义色
|
||
/// 参数化)。点赞激活 = `error` 图标 + `errorDark` 计数(demo 的
|
||
/// `Colors.red` 3.13:1 修订弃用,D6);收藏激活 = `accentDark` 同色。
|
||
enum LikeButtonVariant {
|
||
like(
|
||
inactiveIcon: Icons.favorite_border,
|
||
activeIcon: Icons.favorite,
|
||
activeIconColor: AppColors.error,
|
||
activeCountColor: AppColors.errorDark,
|
||
),
|
||
bookmark(
|
||
inactiveIcon: Icons.bookmark_border,
|
||
activeIcon: Icons.bookmark,
|
||
activeIconColor: AppColors.accentDark,
|
||
activeCountColor: AppColors.accentDark,
|
||
);
|
||
|
||
const LikeButtonVariant({
|
||
required this.inactiveIcon,
|
||
required this.activeIcon,
|
||
required this.activeIconColor,
|
||
required this.activeCountColor,
|
||
});
|
||
|
||
final IconData inactiveIcon;
|
||
final IconData activeIcon;
|
||
final Color activeIconColor;
|
||
final Color activeCountColor;
|
||
}
|
||
|
||
/// 点赞/收藏交互钮(05 号规范 §3.5):图标 20 + 计数 13/w600,未激活
|
||
/// 一律 `inkSoft`(6.59:1)。触控 44×44 由 padding 撑足。
|
||
///
|
||
/// 乐观更新视觉(§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,
|
||
required this.count,
|
||
super.key,
|
||
this.onPressed,
|
||
this.semanticLabel,
|
||
});
|
||
|
||
final LikeButtonVariant variant;
|
||
final bool active;
|
||
final int count;
|
||
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: widget.semanticLabel,
|
||
button: widget.onPressed != null,
|
||
child: InkWell(
|
||
onTap: widget.onPressed == null ? null : _handleTap,
|
||
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: [
|
||
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(
|
||
'$_displayCount',
|
||
style: TextStyle(
|
||
color: countColor,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|