Main coves client
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}