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 createState() => _LikeButtonState(); } class _LikeButtonState extends State with SingleTickerProviderStateMixin { late final AnimationController _scaleController = AnimationController( vsync: this, duration: const Duration(milliseconds: 240), ); late final Animation _scale = TweenSequence([ TweenSequenceItem( tween: Tween( begin: 1, end: 1.25, ).chain(CurveTween(curve: Curves.easeOut)), weight: 40, ), TweenSequenceItem( tween: Tween( 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, ), ), ], ), ), ), ), ); } }