import 'dart:math' as math; import 'package:flutter/material.dart'; enum TTSButtonState { idle, ready, generating, completed, playing, paused, error, } class PillProgressButton extends StatefulWidget { final TTSButtonState state; final double progress; final VoidCallback? onTap; final double height; const PillProgressButton({ super.key, required this.state, this.progress = 0.0, this.onTap, this.height = 48, }); @override State createState() => _PillProgressButtonState(); } class _PillProgressButtonState extends State with TickerProviderStateMixin { late AnimationController _progressCtrl; double _displayProgress = 0.0; late AnimationController _glowCtrl; late Animation _glowAnim; late AnimationController _waveCtrl; bool _wasCompleted = false; @override void initState() { super.initState(); _progressCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), ); _progressCtrl.addListener(() => setState(() {})); _glowCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1000), ); _glowAnim = TweenSequence([ TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 35), TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 65), ]).animate(CurvedAnimation(parent: _glowCtrl, curve: Curves.easeOut)); _glowCtrl.addListener(() => setState(() {})); _waveCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _syncAnimations(); } @override void didUpdateWidget(PillProgressButton oldWidget) { super.didUpdateWidget(oldWidget); if (widget.progress != oldWidget.progress) { if (oldWidget.state == TTSButtonState.completed && (widget.state == TTSButtonState.playing || widget.state == TTSButtonState.ready)) { _displayProgress = 0.0; } else { _animateProgressTo(widget.progress); } } if (widget.state == TTSButtonState.completed && !_wasCompleted) { _wasCompleted = true; _glowCtrl.forward(from: 0); } else if (widget.state != TTSButtonState.completed) { _wasCompleted = false; } _syncAnimations(); } void _animateProgressTo(double target) { final from = _displayProgress; _progressCtrl.reset(); _progressCtrl.addListener(() { final t = Curves.easeInOut.transform(_progressCtrl.value); _displayProgress = from + (target - from) * t; }); _progressCtrl.forward(); } void _syncAnimations() { if (widget.state == TTSButtonState.generating) { if (!_waveCtrl.isAnimating) _waveCtrl.repeat(); } else { if (_waveCtrl.isAnimating) { _waveCtrl.stop(); _waveCtrl.value = 0; } } } @override void dispose() { _progressCtrl.dispose(); _glowCtrl.dispose(); _waveCtrl.dispose(); super.dispose(); } bool get _showBorder => widget.state == TTSButtonState.generating || widget.state == TTSButtonState.completed || widget.state == TTSButtonState.playing || widget.state == TTSButtonState.paused; @override Widget build(BuildContext context) { const borderColor = Color(0xFFE5E7EB); const progressColor = Color(0xFFECCFA8); const bgColor = Color(0xCCFFFFFF); return GestureDetector( onTap: widget.state == TTSButtonState.generating ? null : widget.onTap, child: Container( height: widget.height, decoration: BoxDecoration( borderRadius: BorderRadius.circular(widget.height / 2), boxShadow: _glowAnim.value > 0 ? [ BoxShadow( color: progressColor.withOpacity(0.5 * _glowAnim.value), blurRadius: 16 * _glowAnim.value, spreadRadius: 2 * _glowAnim.value, ), ] : null, ), child: CustomPaint( painter: PillBorderPainter( progress: _showBorder ? _displayProgress.clamp(0.0, 1.0) : 0.0, borderColor: borderColor, progressColor: progressColor, radius: widget.height / 2, stroke: _showBorder ? 2.5 : 1.0, bg: bgColor, ), child: Center(child: _buildContent()), ), ), ); } Widget _buildContent() { switch (widget.state) { case TTSButtonState.idle: return _label(Icons.headphones_rounded, '\u6717\u8bfb'); case TTSButtonState.generating: return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedBuilder( animation: _waveCtrl, builder: (context, _) => CustomPaint( size: const Size(20, 18), painter: WavePainter(t: _waveCtrl.value, color: const Color(0xFFC99672)), ), ), const SizedBox(width: 6), const Text('\u751f\u6210\u4e2d', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF4B5563))), ], ); case TTSButtonState.ready: return _label(Icons.play_arrow_rounded, '\u64ad\u653e'); case TTSButtonState.completed: return _label(Icons.play_arrow_rounded, '\u64ad\u653e'); case TTSButtonState.playing: return _label(Icons.pause_rounded, '\u6682\u505c'); case TTSButtonState.paused: return _label(Icons.play_arrow_rounded, '\u7ee7\u7eed'); case TTSButtonState.error: return _label(Icons.refresh_rounded, '\u91cd\u8bd5', isError: true); } } Widget _label(IconData icon, String text, {bool isError = false}) { final c = isError ? const Color(0xFFEF4444) : const Color(0xFF4B5563); return Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 20, color: c), const SizedBox(width: 4), Text(text, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: c)), ], ); } } class PillBorderPainter extends CustomPainter { final double progress; final Color borderColor; final Color progressColor; final double radius; final double stroke; final Color bg; PillBorderPainter({ required this.progress, required this.borderColor, required this.progressColor, required this.radius, required this.stroke, required this.bg, }); @override void paint(Canvas canvas, Size size) { final r = radius.clamp(0.0, size.height / 2); final rrect = RRect.fromRectAndRadius( Rect.fromLTWH(0, 0, size.width, size.height), Radius.circular(r), ); canvas.drawRRect(rrect, Paint() ..color = bg ..style = PaintingStyle.fill); canvas.drawRRect(rrect, Paint() ..color = borderColor ..style = PaintingStyle.stroke ..strokeWidth = stroke); if (progress <= 0.001) return; final straightH = size.width - 2 * r; final halfTop = straightH / 2; final arcLen = math.pi * r; final totalLen = halfTop + arcLen + straightH + arcLen + halfTop; final target = totalLen * progress; final path = Path(); double done = 0; final cx = size.width / 2; path.moveTo(cx, 0); var seg = math.min(halfTop, target - done); path.lineTo(cx + seg, 0); done += seg; if (done >= target) { _drawPath(canvas, path); return; } seg = math.min(arcLen, target - done); _traceArc(path, size.width - r, r, r, -math.pi / 2, seg / r); done += seg; if (done >= target) { _drawPath(canvas, path); return; } seg = math.min(straightH, target - done); path.lineTo(size.width - r - seg, size.height); done += seg; if (done >= target) { _drawPath(canvas, path); return; } seg = math.min(arcLen, target - done); _traceArc(path, r, r, r, math.pi / 2, seg / r); done += seg; if (done >= target) { _drawPath(canvas, path); return; } seg = math.min(halfTop, target - done); path.lineTo(r + seg, 0); _drawPath(canvas, path); } void _drawPath(Canvas canvas, Path path) { canvas.drawPath(path, Paint() ..color = progressColor ..style = PaintingStyle.stroke ..strokeWidth = stroke ..strokeCap = StrokeCap.round); } void _traceArc(Path p, double cx, double cy, double r, double start, double sweep) { const n = 24; final step = sweep / n; for (int i = 0; i <= n; i++) { final a = start + step * i; p.lineTo(cx + r * math.cos(a), cy + r * math.sin(a)); } } @override bool shouldRepaint(PillBorderPainter old) => old.progress != progress || old.stroke != stroke; } class WavePainter extends CustomPainter { final double t; final Color color; WavePainter({required this.t, required this.color}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..style = PaintingStyle.fill; final bw = size.width * 0.2; final gap = size.width * 0.1; final tw = 3 * bw + 2 * gap; final sx = (size.width - tw) / 2; for (int i = 0; i < 3; i++) { final phase = t * 2 * math.pi + i * math.pi * 0.7; final hr = 0.3 + 0.7 * ((math.sin(phase) + 1) / 2); final bh = size.height * hr; final x = sx + i * (bw + gap); final y = (size.height - bh) / 2; canvas.drawRRect( RRect.fromRectAndRadius(Rect.fromLTWH(x, y, bw, bh), Radius.circular(bw / 2)), paint, ); } } @override bool shouldRepaint(WavePainter old) => old.t != t; }