import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/material.dart'; /// The press response and advancing skip glyphs used by Mornye's transport. class MornyePlaybackButton extends StatefulWidget { const MornyePlaybackButton({ super.key, required this.icon, required this.tooltip, required this.color, required this.onPressed, this.iconSize = 40, this.padding = const EdgeInsets.all(8), this.loading = false, }); final IconData icon; final String tooltip; final Color color; final VoidCallback? onPressed; final double iconSize; final EdgeInsetsGeometry padding; final bool loading; @override State createState() => _MornyePlaybackButtonState(); } class _MornyePlaybackButtonState extends State with SingleTickerProviderStateMixin { final _states = WidgetStatesController(); late final _skip = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); bool get _isSkip => widget.icon == CupertinoIcons.forward_fill || widget.icon == CupertinoIcons.backward_fill; @override void dispose() { _states.dispose(); _skip.dispose(); super.dispose(); } Widget _skipGlyph(bool reduceMotion) => AnimatedBuilder( animation: _skip, builder: (context, _) { if (!_skip.isAnimating || reduceMotion) return Icon(widget.icon); final progress = Curves.easeInOut.transform(_skip.value); final size = widget.iconSize; Widget triangle(double position, double scale) => Positioned( left: size * (position - 0.26), top: 0, width: size * 0.52, height: size, child: Transform.scale( scale: scale, child: const FittedBox( fit: BoxFit.fill, child: Icon(CupertinoIcons.play_fill), ), ), ); return Transform.flip( flipX: widget.icon == CupertinoIcons.backward_fill, child: Stack( clipBehavior: Clip.none, children: [ triangle(0.25, progress), triangle(0.25 + progress * 0.5, 1), triangle(0.75, 1 - progress), ], ), ); }, ); @override Widget build(BuildContext context) { final reduceMotion = MediaQuery.disableAnimationsOf(context); final motion = Duration(milliseconds: reduceMotion ? 120 : 180); final enabled = widget.onPressed != null && !widget.loading; return IconButton( statesController: _states, tooltip: widget.tooltip, color: widget.color, iconSize: widget.iconSize, padding: widget.padding, style: ButtonStyle( splashFactory: NoSplash.splashFactory, overlayColor: WidgetStateProperty.resolveWith((states) { return states.contains(WidgetState.focused) || states.contains(WidgetState.hovered) ? widget.color.withValues(alpha: 0.08) : Colors.transparent; }), ), onPressed: enabled ? () { if (_isSkip && !reduceMotion) _skip.forward(from: 0); widget.onPressed!(); } : null, icon: ValueListenableBuilder>( valueListenable: _states, builder: (context, states, _) { final pressed = states.contains(WidgetState.pressed); return AnimatedScale( scale: pressed && !reduceMotion ? 0.95 : 1, duration: motion, curve: Curves.easeOutCubic, child: AnimatedOpacity( opacity: pressed ? 0.72 : 1, duration: motion, child: SizedBox.square( dimension: widget.iconSize, child: AnimatedSwitcher( duration: Duration(milliseconds: reduceMotion ? 120 : 200), child: widget.loading ? const Center( child: SizedBox.square( dimension: 32, child: CircularProgressIndicator(strokeWidth: 3), ), ) : KeyedSubtree( key: ValueKey(widget.icon), child: _isSkip ? _skipGlyph(reduceMotion) : Icon(widget.icon), ), ), ), ), ); }, ), ); } }