main
dart 137 lines 3.91 KB
Raw
1 import 'package:flutter/material.dart';
2
3 /// A subtle, organic animated dot with a dual-pulse "heartbeat" rhythm.
4 ///
5 /// Designed as a compact, non-intrusive activity indicator inside chat list items
6 /// when background generations are in progress.
7 class HeartbeatDot extends StatefulWidget {
8 const HeartbeatDot({super.key, this.size = 7.0, this.color});
9
10 /// Diameter of the resting dot in pixels.
11 final double size;
12
13 /// Dot color. Defaults to [ColorScheme.primary].
14 final Color? color;
15
16 @override
17 State<HeartbeatDot> createState() => _HeartbeatDotState();
18 }
19
20 class _HeartbeatDotState extends State<HeartbeatDot>
21 with SingleTickerProviderStateMixin {
22 late final AnimationController _controller;
23 late final Animation<double> _scaleAnimation;
24 late final Animation<double> _opacityAnimation;
25
26 @override
27 void initState() {
28 super.initState();
29 _controller = AnimationController(
30 vsync: this,
31 duration: const Duration(milliseconds: 1400),
32 )..repeat();
33
34 // Subtle rhythmic heartbeat pattern:
35 // 0.00 - 0.16: First beat / Systole pulse (lub)
36 // 0.16 - 0.30: Slight recoil
37 // 0.30 - 0.44: Second beat (dub)
38 // 0.44 - 0.62: Return to baseline
39 // 0.62 - 1.00: Diastole resting pause
40 _scaleAnimation = TweenSequence<double>([
41 TweenSequenceItem(
42 tween: Tween<double>(
43 begin: 0.9,
44 end: 1.25,
45 ).chain(CurveTween(curve: Curves.easeOutCubic)),
46 weight: 16,
47 ),
48 TweenSequenceItem(
49 tween: Tween<double>(
50 begin: 1.25,
51 end: 1.02,
52 ).chain(CurveTween(curve: Curves.easeInOutCubic)),
53 weight: 14,
54 ),
55 TweenSequenceItem(
56 tween: Tween<double>(
57 begin: 1.02,
58 end: 1.18,
59 ).chain(CurveTween(curve: Curves.easeOutCubic)),
60 weight: 14,
61 ),
62 TweenSequenceItem(
63 tween: Tween<double>(
64 begin: 1.18,
65 end: 0.9,
66 ).chain(CurveTween(curve: Curves.easeInOutCubic)),
67 weight: 18,
68 ),
69 TweenSequenceItem(tween: ConstantTween<double>(0.9), weight: 38),
70 ]).animate(_controller);
71
72 _opacityAnimation = TweenSequence<double>([
73 TweenSequenceItem(
74 tween: Tween<double>(begin: 0.45, end: 1.0),
75 weight: 16,
76 ),
77 TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 0.7), weight: 14),
78 TweenSequenceItem(
79 tween: Tween<double>(begin: 0.7, end: 0.95),
80 weight: 14,
81 ),
82 TweenSequenceItem(
83 tween: Tween<double>(begin: 0.95, end: 0.45),
84 weight: 18,
85 ),
86 TweenSequenceItem(tween: ConstantTween<double>(0.45), weight: 38),
87 ]).animate(_controller);
88 }
89
90 @override
91 void dispose() {
92 _controller.dispose();
93 super.dispose();
94 }
95
96 @override
97 Widget build(BuildContext context) {
98 final dotColor = widget.color ?? Theme.of(context).colorScheme.primary;
99 final containerDim = widget.size * 1.8;
100
101 return SizedBox(
102 width: containerDim,
103 height: containerDim,
104 child: Center(
105 child: AnimatedBuilder(
106 animation: _controller,
107 builder: (context, _) {
108 final scale = _scaleAnimation.value;
109 final opacity = _opacityAnimation.value;
110
111 return Transform.scale(
112 scale: scale,
113 child: Opacity(
114 opacity: opacity,
115 child: Container(
116 width: widget.size,
117 height: widget.size,
118 decoration: BoxDecoration(
119 color: dotColor,
120 shape: BoxShape.circle,
121 boxShadow: [
122 BoxShadow(
123 color: dotColor.withValues(alpha: 0.35 * opacity),
124 blurRadius: 3,
125 spreadRadius: 0.5,
126 ),
127 ],
128 ),
129 ),
130 ),
131 );
132 },
133 ),
134 ),
135 );
136 }
137 }