1import 'dart:math' as math; 2 3import 'package:flutter/material.dart'; 4 5/// Animated heart icon with outline and filled states 6/// 7/// Features a dramatic animation sequence: 8/// 1. Heart shrinks to nothing 9/// 2. Red hollow circle expands outwards 10/// 3. Small heart grows from center 11/// 4. Heart pops to 1.3x with 7 particle dots 12/// 5. Heart settles back to 1x filled 13class AnimatedHeartIcon extends StatefulWidget { 14 const AnimatedHeartIcon({ 15 required this.isLiked, 16 this.size = 18, 17 this.color, 18 this.likedColor, 19 super.key, 20 }); 21 22 final bool isLiked; 23 final double size; 24 final Color? color; 25 final Color? likedColor; 26 27 @override 28 State<AnimatedHeartIcon> createState() => _AnimatedHeartIconState(); 29} 30 31class _AnimatedHeartIconState extends State<AnimatedHeartIcon> 32 with SingleTickerProviderStateMixin { 33 late AnimationController _controller; 34 35 // Heart scale animations 36 late Animation<double> _heartShrinkAnimation; 37 late Animation<double> _heartGrowAnimation; 38 late Animation<double> _heartPopAnimation; 39 40 // Hollow circle animation 41 late Animation<double> _circleScaleAnimation; 42 late Animation<double> _circleOpacityAnimation; 43 44 // Particle burst animations 45 late Animation<double> _particleScaleAnimation; 46 late Animation<double> _particleOpacityAnimation; 47 48 bool _hasBeenToggled = false; 49 bool _previousIsLiked = false; 50 51 @override 52 void initState() { 53 super.initState(); 54 _previousIsLiked = widget.isLiked; 55 56 _controller = AnimationController( 57 duration: const Duration(milliseconds: 800), 58 vsync: this, 59 ); 60 61 // Phase 1 (0-15%): Heart shrinks to nothing 62 _heartShrinkAnimation = Tween<double>(begin: 1, end: 0).animate( 63 CurvedAnimation( 64 parent: _controller, 65 curve: const Interval(0, 0.15, curve: Curves.easeIn), 66 ), 67 ); 68 69 // Phase 2 (15-40%): Hollow circle expands 70 _circleScaleAnimation = Tween<double>(begin: 0, end: 2).animate( 71 CurvedAnimation( 72 parent: _controller, 73 curve: const Interval(0.15, 0.4, curve: Curves.easeOut), 74 ), 75 ); 76 77 _circleOpacityAnimation = TweenSequence<double>([ 78 TweenSequenceItem(tween: Tween(begin: 0, end: 0.8), weight: 50), 79 TweenSequenceItem(tween: Tween(begin: 0.8, end: 0), weight: 50), 80 ]).animate( 81 CurvedAnimation(parent: _controller, curve: const Interval(0.15, 0.4)), 82 ); 83 84 // Phase 3 (25-55%): Heart grows from small in center 85 _heartGrowAnimation = Tween<double>(begin: 0.2, end: 1.3).animate( 86 CurvedAnimation( 87 parent: _controller, 88 curve: const Interval(0.25, 0.55, curve: Curves.easeOut), 89 ), 90 ); 91 92 // Phase 4 (55-65%): Particle burst at peak 93 _particleScaleAnimation = Tween<double>(begin: 0, end: 1).animate( 94 CurvedAnimation( 95 parent: _controller, 96 curve: const Interval(0.55, 0.65, curve: Curves.easeOut), 97 ), 98 ); 99 100 _particleOpacityAnimation = TweenSequence<double>([ 101 TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 30), 102 TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 70), 103 ]).animate( 104 CurvedAnimation(parent: _controller, curve: const Interval(0.55, 0.75)), 105 ); 106 107 // Phase 5 (65-100%): Heart settles to 1x 108 _heartPopAnimation = Tween<double>(begin: 1.3, end: 1).animate( 109 CurvedAnimation( 110 parent: _controller, 111 curve: const Interval(0.65, 1, curve: Curves.elasticOut), 112 ), 113 ); 114 } 115 116 @override 117 void didUpdateWidget(AnimatedHeartIcon oldWidget) { 118 super.didUpdateWidget(oldWidget); 119 120 if (widget.isLiked != _previousIsLiked) { 121 _hasBeenToggled = true; 122 _previousIsLiked = widget.isLiked; 123 124 if (widget.isLiked && mounted) { 125 _controller.forward(from: 0); 126 } 127 } 128 } 129 130 @override 131 void dispose() { 132 _controller.dispose(); 133 super.dispose(); 134 } 135 136 double _getHeartScale() { 137 if (!widget.isLiked || !_hasBeenToggled) { 138 return 1; 139 } 140 141 final progress = _controller.value; 142 if (progress < 0.15) { 143 // Phase 1: Shrinking 144 return _heartShrinkAnimation.value; 145 } else if (progress < 0.55) { 146 // Phase 3: Growing from center 147 return _heartGrowAnimation.value; 148 } else { 149 // Phase 5: Settling back 150 return _heartPopAnimation.value; 151 } 152 } 153 154 @override 155 Widget build(BuildContext context) { 156 final effectiveColor = 157 widget.color ?? Theme.of(context).iconTheme.color ?? Colors.grey; 158 final effectiveLikedColor = widget.likedColor ?? Colors.red; 159 160 // Use 2.5x size for animation overflow space (for 1.3x scale + particles) 161 final containerSize = widget.size * 2.5; 162 163 return SizedBox( 164 width: widget.size, 165 height: widget.size, 166 child: OverflowBox( 167 maxWidth: containerSize, 168 maxHeight: containerSize, 169 child: SizedBox( 170 width: containerSize, 171 height: containerSize, 172 child: AnimatedBuilder( 173 animation: _controller, 174 builder: (context, child) { 175 return Stack( 176 clipBehavior: Clip.none, 177 alignment: Alignment.center, 178 children: [ 179 // Phase 2: Expanding hollow circle 180 if (widget.isLiked && 181 _hasBeenToggled && 182 _controller.value >= 0.15 && 183 _controller.value <= 0.4) ...[ 184 Opacity( 185 opacity: _circleOpacityAnimation.value, 186 child: Transform.scale( 187 scale: _circleScaleAnimation.value, 188 child: Container( 189 width: widget.size, 190 height: widget.size, 191 decoration: BoxDecoration( 192 shape: BoxShape.circle, 193 border: Border.all( 194 color: effectiveLikedColor, 195 width: 2, 196 ), 197 ), 198 ), 199 ), 200 ), 201 ], 202 203 // Phase 4: Particle burst (7 dots) 204 if (widget.isLiked && 205 _hasBeenToggled && 206 _controller.value >= 0.55 && 207 _controller.value <= 0.75) 208 ..._buildParticleBurst(effectiveLikedColor), 209 210 // Heart icon (all phases) 211 Transform.scale( 212 scale: _getHeartScale(), 213 child: CustomPaint( 214 size: Size(widget.size, widget.size), 215 painter: _HeartIconPainter( 216 color: 217 widget.isLiked 218 ? effectiveLikedColor 219 : effectiveColor, 220 filled: widget.isLiked, 221 ), 222 ), 223 ), 224 ], 225 ); 226 }, 227 ), 228 ), 229 ), 230 ); 231 } 232 233 List<Widget> _buildParticleBurst(Color color) { 234 const particleCount = 7; 235 final particles = <Widget>[]; 236 final containerSize = widget.size * 2.5; 237 238 for (var i = 0; i < particleCount; i++) { 239 final angle = (2 * math.pi * i) / particleCount; 240 final distance = widget.size * 1 * _particleScaleAnimation.value; 241 final dx = math.cos(angle) * distance; 242 final dy = math.sin(angle) * distance; 243 244 particles.add( 245 Positioned( 246 left: containerSize / 2 + dx - 2, 247 top: containerSize / 2 + dy - 2, 248 child: Opacity( 249 opacity: _particleOpacityAnimation.value, 250 child: Container( 251 width: 2, 252 height: 2, 253 decoration: BoxDecoration(color: color, shape: BoxShape.circle), 254 ), 255 ), 256 ), 257 ); 258 } 259 260 return particles; 261 } 262} 263 264/// Custom painter for heart icon 265/// 266/// SVG path data from Bluesky's Heart2 icon component 267class _HeartIconPainter extends CustomPainter { 268 _HeartIconPainter({required this.color, required this.filled}); 269 270 final Color color; 271 final bool filled; 272 273 @override 274 void paint(Canvas canvas, Size size) { 275 final paint = 276 Paint() 277 ..color = color 278 ..style = PaintingStyle.fill; 279 280 // Scale factor to fit 24x24 viewBox into widget size 281 final scale = size.width / 24; 282 canvas.scale(scale); 283 284 final path = Path(); 285 286 if (filled) { 287 // Filled heart path from Bluesky 288 path 289 ..moveTo(12.489, 21.372) 290 ..cubicTo(21.017, 16.592, 23.115, 10.902, 21.511, 6.902) 291 ..cubicTo(20.732, 4.961, 19.097, 3.569, 17.169, 3.139) 292 ..cubicTo(15.472, 2.761, 13.617, 3.142, 12, 4.426) 293 ..cubicTo(10.383, 3.142, 8.528, 2.761, 6.83, 3.139) 294 ..cubicTo(4.903, 3.569, 3.268, 4.961, 2.49, 6.903) 295 ..cubicTo(0.885, 10.903, 2.983, 16.593, 11.511, 21.373) 296 ..cubicTo(11.826, 21.558, 12.174, 21.558, 12.489, 21.372) 297 ..close(); 298 } else { 299 // Outline heart path from Bluesky 300 path 301 ..moveTo(16.734, 5.091) 302 ..cubicTo(15.496, 4.815, 14.026, 5.138, 12.712, 6.471) 303 ..cubicTo(12.318, 6.865, 11.682, 6.865, 11.288, 6.471) 304 ..cubicTo(9.974, 5.137, 8.504, 4.814, 7.266, 5.09) 305 ..cubicTo(6.003, 5.372, 4.887, 6.296, 4.346, 7.646) 306 ..cubicTo(3.33, 10.18, 4.252, 14.84, 12, 19.348) 307 ..cubicTo(19.747, 14.84, 20.67, 10.18, 19.654, 7.648) 308 ..cubicTo(19.113, 6.297, 17.997, 5.373, 16.734, 5.091) 309 ..close() 310 ..moveTo(21.511, 6.903) 311 ..cubicTo(23.115, 10.903, 21.017, 16.593, 12.489, 21.373) 312 ..cubicTo(12.174, 21.558, 11.826, 21.558, 11.511, 21.373) 313 ..cubicTo(2.983, 16.592, 0.885, 10.902, 2.49, 6.902) 314 ..cubicTo(3.269, 4.96, 4.904, 3.568, 6.832, 3.138) 315 ..cubicTo(8.529, 2.76, 10.384, 3.141, 12.001, 4.424) 316 ..cubicTo(13.618, 3.141, 15.473, 2.76, 17.171, 3.138) 317 ..cubicTo(19.098, 3.568, 20.733, 4.96, 21.511, 6.903) 318 ..close(); 319 } 320 321 canvas.drawPath(path, paint); 322 } 323 324 @override 325 bool shouldRepaint(_HeartIconPainter oldDelegate) { 326 return oldDelegate.color != color || oldDelegate.filled != filled; 327 } 328}