| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496 |
- 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/media_playback.dart';
- import '../../../data/models/system_status.dart';
- import 'marquee_text.dart';
- /// The primary media control: a play/pause ring, a slider and step/mute
- /// actions. The ring remains a volume gauge; its centre is the playback button.
- class VolumeCard extends StatefulWidget {
- const VolumeCard({
- super.key,
- required this.volume,
- required this.onSetVolume,
- required this.onToggleMute,
- required this.isMusicPlaying,
- required this.onPlayPause,
- required this.media,
- this.compact = false,
- this.large = false,
- this.onPrevious,
- this.onNext,
- this.canPrevious = false,
- this.canNext = false,
- this.diagnostics,
- this.onToggleDiagnostics,
- this.unconfirmed = false,
- });
- final VolumeInfo volume;
- final ValueChanged<int> onSetVolume;
- final VoidCallback onToggleMute;
- final bool isMusicPlaying;
- final VoidCallback onPlayPause;
- final MediaPlayback media;
- final bool compact;
- final bool large;
- final VoidCallback? onPrevious;
- final VoidCallback? onNext;
- final bool canPrevious;
- final bool canNext;
- final String? diagnostics;
- final VoidCallback? onToggleDiagnostics;
- /// The car kept reporting a different level than the one written.
- final bool unconfirmed;
- @override
- State<VolumeCard> createState() => _VolumeCardState();
- }
- class _VolumeCardState extends State<VolumeCard> {
- 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;
- bool get _fixed => !widget.volume.isControllable;
- int get _percent => ((_value / _max) * 100).round();
- String get _nowPlayingLabel {
- return widget.media.displayTitle ??
- (widget.isMusicPlaying ? 'Playing' : 'Nothing playing');
- }
- @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: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (widget.onPrevious != null) ...[
- _SkipButton(
- asset: 'assets/icons/skip-back.svg',
- semanticLabel: 'Previous track',
- onTap: widget.canPrevious ? widget.onPrevious : null,
- compact: compact,
- large: widget.large,
- ),
- SizedBox(width: compact ? 4 : 6),
- ],
- Pressable(
- onTap: widget.onPlayPause,
- semanticLabel: widget.isMusicPlaying
- ? 'Pause music'
- : 'Play music',
- pressedScale: 0.94,
- child: _VolumeRing(
- progress: widget.media.progress,
- isPlaying: widget.isMusicPlaying,
- size: widget.large ? 124 : (compact ? 78 : 92),
- ),
- ),
- if (widget.onNext != null) ...[
- SizedBox(width: compact ? 4 : 6),
- _SkipButton(
- asset: 'assets/icons/skip-forward.svg',
- semanticLabel: 'Next track',
- onTap: widget.canNext ? widget.onNext : null,
- compact: compact,
- large: widget.large,
- ),
- ],
- ],
- ),
- SizedBox(width: compact ? 18 : 24),
- Expanded(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- GestureDetector(
- behavior: HitTestBehavior.opaque,
- onLongPress: widget.onToggleDiagnostics,
- child: Row(
- children: [
- Flexible(
- child: Text(
- 'Media volume',
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.labelLarge?.copyWith(
- color: colors.inkMuted,
- fontSize: compact ? 12 : 13,
- ),
- ),
- ),
- const SizedBox(width: 6),
- Text(
- '$_percent%${widget.unconfirmed ? '?' : ''}',
- 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: _fixed
- ? null
- : (next) {
- setState(() => _dragValue = next);
- widget.onSetVolume(next.round());
- },
- onChangeEnd: _fixed
- ? null
- : (_) => setState(() => _dragValue = null),
- semanticFormatterCallback: (value) =>
- '${(value / _max * 100).round()} percent',
- ),
- ),
- SizedBox(height: compact ? 4 : 8),
- if (_fixed)
- Container(
- height: compact ? 36 : 40,
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: colors.surfaceStrong,
- borderRadius: BorderRadius.circular(999),
- border: Border.all(color: colors.border),
- ),
- child: Text(
- 'Volume is controlled by the car',
- style: theme.textTheme.labelMedium?.copyWith(
- color: colors.inkMuted,
- ),
- ),
- )
- else
- 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: FittedBox(
- fit: BoxFit.scaleDown,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- 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,
- ),
- ],
- ),
- if (widget.diagnostics != null) ...[
- const SizedBox(height: 8),
- Container(
- width: double.infinity,
- padding: const EdgeInsets.all(8),
- decoration: BoxDecoration(
- color: colors.surfaceStrong,
- borderRadius: BorderRadius.circular(10),
- border: Border.all(color: colors.border),
- ),
- child: Text(
- widget.diagnostics!,
- style: theme.textTheme.bodySmall?.copyWith(
- color: colors.inkMuted,
- fontSize: 10,
- height: 1.35,
- ),
- ),
- ),
- ],
- ],
- ),
- ),
- ],
- ),
- SizedBox(height: compact ? 8 : 10),
- SizedBox(
- height: compact ? 18 : 20,
- child: Center(
- child: MarqueeText(
- text: _nowPlayingLabel,
- animate:
- widget.isMusicPlaying && widget.media.displayTitle != null,
- style: theme.textTheme.bodySmall?.copyWith(
- color: widget.media.displayTitle == null
- ? colors.inkMuted
- : colors.ink,
- fontSize: compact ? 11 : 12,
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
- }
- class _SkipButton extends StatelessWidget {
- const _SkipButton({
- required this.asset,
- required this.semanticLabel,
- required this.onTap,
- required this.compact,
- required this.large,
- });
- final String asset;
- final String semanticLabel;
- final VoidCallback? onTap;
- final bool compact;
- final bool large;
- @override
- Widget build(BuildContext context) {
- final colors = context.colors;
- final size = large ? 46.0 : (compact ? 36.0 : 40.0);
- return Opacity(
- opacity: onTap == null ? 0.4 : 1,
- child: Pressable(
- onTap: onTap,
- semanticLabel: semanticLabel,
- pressedScale: 0.92,
- child: Container(
- width: size,
- height: size,
- decoration: BoxDecoration(
- color: colors.surfaceStrong,
- shape: BoxShape.circle,
- border: Border.all(color: colors.border),
- ),
- alignment: Alignment.center,
- child: AppIcon(asset, size: size * 0.45, color: colors.inkMuted),
- ),
- ),
- );
- }
- }
- 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.progress,
- required this.isPlaying,
- required this.size,
- });
- final double progress;
- final bool isPlaying;
- final double size;
- @override
- Widget build(BuildContext context) {
- final colors = context.colors;
- return TweenAnimationBuilder<double>(
- tween: Tween(end: progress.clamp(0.0, 1.0)),
- duration: Duration(milliseconds: isPlaying ? 850 : 250),
- 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,
- shape: BoxShape.circle,
- ),
- alignment: Alignment.center,
- child: Icon(
- isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
- size: size * 0.34,
- color: 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;
- }
- }
|