import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/app_icon.dart'; import '../../../core/widgets/pressable.dart'; import '../../../core/widgets/surface_card.dart'; import '../../../data/models/system_status.dart'; /// The primary volume control: a ring, a slider, and step/mute actions. class VolumeCard extends StatefulWidget { const VolumeCard({ super.key, required this.volume, required this.onSetVolume, required this.onToggleMute, this.compact = false, }); final VolumeInfo volume; final ValueChanged onSetVolume; final VoidCallback onToggleMute; final bool compact; @override State createState() => _VolumeCardState(); } class _VolumeCardState extends State { double? _dragValue; int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max; int get _value => widget.volume.index.clamp(0, _max); bool get _muted => _value == 0; int get _percent => ((_value / _max) * 100).round(); @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final compact = widget.compact; final displayValue = (_dragValue ?? _value.toDouble()) .clamp(0, _max.toDouble()) .toDouble(); return SurfaceCard( padding: EdgeInsets.all(compact ? 20 : 26), radius: 30, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Pressable( onTap: widget.onToggleMute, semanticLabel: _muted ? 'Unmute media' : 'Mute media', pressedScale: 0.94, child: _VolumeRing( fraction: displayValue / _max, muted: _muted, size: compact ? 78 : 92, ), ), SizedBox(width: compact ? 18 : 24), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Media volume', style: theme.textTheme.labelLarge?.copyWith( color: colors.inkMuted, fontSize: compact ? 12 : 13, ), ), const Spacer(), Text( '$_percent%', style: theme.textTheme.headlineMedium?.copyWith( fontSize: compact ? 24 : 28, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), const SizedBox(height: 2), SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: compact ? 5 : 6, thumbShape: RoundSliderThumbShape( enabledThumbRadius: compact ? 9 : 10, ), ), child: Slider( value: displayValue, min: 0, max: _max.toDouble(), divisions: _max <= 30 ? _max : null, onChanged: (next) { setState(() => _dragValue = next); widget.onSetVolume(next.round()); }, onChangeEnd: (_) => setState(() => _dragValue = null), semanticFormatterCallback: (value) => '${(value / _max * 100).round()} percent', ), ), SizedBox(height: compact ? 4 : 8), Row( children: [ _StepButton( icon: Icons.remove_rounded, semanticLabel: 'Volume down', onTap: () => widget.onSetVolume(_value - 1), compact: compact, ), SizedBox(width: compact ? 8 : 10), Expanded( child: Pressable( onTap: widget.onToggleMute, semanticLabel: _muted ? 'Unmute' : 'Mute', pressedScale: 0.97, child: Container( height: compact ? 36 : 40, decoration: BoxDecoration( color: _muted ? colors.accentSoft : colors.surfaceStrong, borderRadius: BorderRadius.circular(999), border: Border.all( color: _muted ? colors.accent.withValues(alpha: 0.25) : colors.border, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AppIcon( _muted ? 'assets/icons/volume-x.svg' : 'assets/icons/volume-2.svg', size: compact ? 16 : 18, color: _muted ? colors.accent : colors.inkMuted, ), const SizedBox(width: 8), Text( _muted ? 'Unmute' : 'Mute', style: theme.textTheme.labelMedium?.copyWith( color: _muted ? colors.accent : colors.inkMuted, ), ), ], ), ), ), ), SizedBox(width: compact ? 8 : 10), _StepButton( icon: Icons.add_rounded, semanticLabel: 'Volume up', onTap: () => widget.onSetVolume(_value + 1), compact: compact, ), ], ), ], ), ), ], ), ); } } class _StepButton extends StatelessWidget { const _StepButton({ required this.icon, required this.semanticLabel, required this.onTap, required this.compact, }); final IconData icon; final String semanticLabel; final VoidCallback onTap; final bool compact; @override Widget build(BuildContext context) { final colors = context.colors; return Pressable( onTap: onTap, semanticLabel: semanticLabel, pressedScale: 0.92, child: Container( width: compact ? 36 : 40, height: compact ? 36 : 40, decoration: BoxDecoration( color: colors.surfaceStrong, shape: BoxShape.circle, border: Border.all(color: colors.border), ), child: Icon(icon, size: compact ? 18 : 20, color: colors.inkMuted), ), ); } } class _VolumeRing extends StatelessWidget { const _VolumeRing({ required this.fraction, required this.muted, required this.size, }); final double fraction; final bool muted; final double size; @override Widget build(BuildContext context) { final colors = context.colors; return TweenAnimationBuilder( tween: Tween(end: fraction.clamp(0.0, 1.0)), duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, builder: (context, value, _) { return SizedBox( width: size, height: size, child: Stack( alignment: Alignment.center, children: [ CustomPaint( size: Size.square(size), painter: _RingPainter( value: value, trackColor: colors.border, activeColor: colors.accent, ), ), Container( width: size * 0.62, height: size * 0.62, decoration: BoxDecoration( color: colors.accentSoft.withValues(alpha: muted ? 0.55 : 1), shape: BoxShape.circle, ), alignment: Alignment.center, child: AppIcon( muted ? 'assets/icons/volume-x.svg' : 'assets/icons/volume-2.svg', size: size * 0.26, color: muted ? colors.inkMuted : colors.accent, ), ), ], ), ); }, ); } } class _RingPainter extends CustomPainter { _RingPainter({ required this.value, required this.trackColor, required this.activeColor, }); final double value; final Color trackColor; final Color activeColor; @override void paint(Canvas canvas, Size size) { final stroke = size.width * 0.055; final rect = Offset.zero & size; final center = rect.center; final radius = (size.width - stroke) / 2; final track = Paint() ..color = trackColor ..style = PaintingStyle.stroke ..strokeWidth = stroke ..strokeCap = StrokeCap.round; final active = Paint() ..color = activeColor ..style = PaintingStyle.stroke ..strokeWidth = stroke ..strokeCap = StrokeCap.round; canvas.drawCircle(center, radius, track); if (value <= 0) return; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), -math.pi / 2, 2 * math.pi * value, false, active, ); } @override bool shouldRepaint(covariant _RingPainter oldDelegate) { return oldDelegate.value != value || oldDelegate.trackColor != trackColor || oldDelegate.activeColor != activeColor; } }