volume_card.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. import 'dart:math' as math;
  2. import 'package:flutter/material.dart';
  3. import '../../../core/theme/app_colors.dart';
  4. import '../../../core/widgets/app_icon.dart';
  5. import '../../../core/widgets/pressable.dart';
  6. import '../../../core/widgets/surface_card.dart';
  7. import '../../../data/models/media_playback.dart';
  8. import '../../../data/models/system_status.dart';
  9. import 'marquee_text.dart';
  10. /// The primary media control: a play/pause ring, a slider and step/mute
  11. /// actions. The ring remains a volume gauge; its centre is the playback button.
  12. class VolumeCard extends StatefulWidget {
  13. const VolumeCard({
  14. super.key,
  15. required this.volume,
  16. required this.onSetVolume,
  17. required this.onToggleMute,
  18. required this.isMusicPlaying,
  19. required this.onPlayPause,
  20. required this.media,
  21. this.compact = false,
  22. });
  23. final VolumeInfo volume;
  24. final ValueChanged<int> onSetVolume;
  25. final VoidCallback onToggleMute;
  26. final bool isMusicPlaying;
  27. final VoidCallback onPlayPause;
  28. final MediaPlayback media;
  29. final bool compact;
  30. @override
  31. State<VolumeCard> createState() => _VolumeCardState();
  32. }
  33. class _VolumeCardState extends State<VolumeCard> {
  34. double? _dragValue;
  35. int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max;
  36. int get _value => widget.volume.index.clamp(0, _max);
  37. bool get _muted => _value == 0;
  38. bool get _fixed => widget.volume.fixed;
  39. int get _percent => ((_value / _max) * 100).round();
  40. String get _nowPlayingLabel {
  41. return widget.media.displayTitle ??
  42. (widget.isMusicPlaying ? 'Playing' : 'Nothing playing');
  43. }
  44. @override
  45. Widget build(BuildContext context) {
  46. final colors = context.colors;
  47. final theme = Theme.of(context);
  48. final compact = widget.compact;
  49. final displayValue = (_dragValue ?? _value.toDouble())
  50. .clamp(0, _max.toDouble())
  51. .toDouble();
  52. return SurfaceCard(
  53. padding: EdgeInsets.all(compact ? 20 : 26),
  54. radius: 30,
  55. child: Column(
  56. mainAxisSize: MainAxisSize.min,
  57. crossAxisAlignment: CrossAxisAlignment.stretch,
  58. children: [
  59. Row(
  60. crossAxisAlignment: CrossAxisAlignment.center,
  61. children: [
  62. Pressable(
  63. onTap: widget.onPlayPause,
  64. semanticLabel: widget.isMusicPlaying
  65. ? 'Pause music'
  66. : 'Play music',
  67. pressedScale: 0.94,
  68. child: _VolumeRing(
  69. progress: widget.media.progress,
  70. isPlaying: widget.isMusicPlaying,
  71. size: compact ? 78 : 92,
  72. ),
  73. ),
  74. SizedBox(width: compact ? 18 : 24),
  75. Expanded(
  76. child: Column(
  77. mainAxisSize: MainAxisSize.min,
  78. crossAxisAlignment: CrossAxisAlignment.start,
  79. children: [
  80. Row(
  81. children: [
  82. Text(
  83. 'Media volume',
  84. style: theme.textTheme.labelLarge?.copyWith(
  85. color: colors.inkMuted,
  86. fontSize: compact ? 12 : 13,
  87. ),
  88. ),
  89. const Spacer(),
  90. Text(
  91. '$_percent%',
  92. style: theme.textTheme.headlineMedium?.copyWith(
  93. fontSize: compact ? 24 : 28,
  94. fontFeatures: const [FontFeature.tabularFigures()],
  95. ),
  96. ),
  97. ],
  98. ),
  99. const SizedBox(height: 2),
  100. SliderTheme(
  101. data: SliderTheme.of(context).copyWith(
  102. trackHeight: compact ? 5 : 6,
  103. thumbShape: RoundSliderThumbShape(
  104. enabledThumbRadius: compact ? 9 : 10,
  105. ),
  106. ),
  107. child: Slider(
  108. value: displayValue,
  109. min: 0,
  110. max: _max.toDouble(),
  111. divisions: _max <= 30 ? _max : null,
  112. onChanged: _fixed
  113. ? null
  114. : (next) {
  115. setState(() => _dragValue = next);
  116. widget.onSetVolume(next.round());
  117. },
  118. onChangeEnd: _fixed
  119. ? null
  120. : (_) => setState(() => _dragValue = null),
  121. semanticFormatterCallback: (value) =>
  122. '${(value / _max * 100).round()} percent',
  123. ),
  124. ),
  125. SizedBox(height: compact ? 4 : 8),
  126. if (_fixed)
  127. Container(
  128. height: compact ? 36 : 40,
  129. alignment: Alignment.center,
  130. decoration: BoxDecoration(
  131. color: colors.surfaceStrong,
  132. borderRadius: BorderRadius.circular(999),
  133. border: Border.all(color: colors.border),
  134. ),
  135. child: Text(
  136. 'Volume is controlled by the car',
  137. style: theme.textTheme.labelMedium?.copyWith(
  138. color: colors.inkMuted,
  139. ),
  140. ),
  141. )
  142. else
  143. Row(
  144. children: [
  145. _StepButton(
  146. icon: Icons.remove_rounded,
  147. semanticLabel: 'Volume down',
  148. onTap: () => widget.onSetVolume(_value - 1),
  149. compact: compact,
  150. ),
  151. SizedBox(width: compact ? 8 : 10),
  152. Expanded(
  153. child: Pressable(
  154. onTap: widget.onToggleMute,
  155. semanticLabel: _muted ? 'Unmute' : 'Mute',
  156. pressedScale: 0.97,
  157. child: Container(
  158. height: compact ? 36 : 40,
  159. decoration: BoxDecoration(
  160. color: _muted
  161. ? colors.accentSoft
  162. : colors.surfaceStrong,
  163. borderRadius: BorderRadius.circular(999),
  164. border: Border.all(
  165. color: _muted
  166. ? colors.accent.withValues(alpha: 0.25)
  167. : colors.border,
  168. ),
  169. ),
  170. child: Row(
  171. mainAxisAlignment: MainAxisAlignment.center,
  172. children: [
  173. AppIcon(
  174. _muted
  175. ? 'assets/icons/volume-x.svg'
  176. : 'assets/icons/volume-2.svg',
  177. size: compact ? 16 : 18,
  178. color: _muted
  179. ? colors.accent
  180. : colors.inkMuted,
  181. ),
  182. const SizedBox(width: 8),
  183. Text(
  184. _muted ? 'Unmute' : 'Mute',
  185. style: theme.textTheme.labelMedium
  186. ?.copyWith(
  187. color: _muted
  188. ? colors.accent
  189. : colors.inkMuted,
  190. ),
  191. ),
  192. ],
  193. ),
  194. ),
  195. ),
  196. ),
  197. SizedBox(width: compact ? 8 : 10),
  198. _StepButton(
  199. icon: Icons.add_rounded,
  200. semanticLabel: 'Volume up',
  201. onTap: () => widget.onSetVolume(_value + 1),
  202. compact: compact,
  203. ),
  204. ],
  205. ),
  206. ],
  207. ),
  208. ),
  209. ],
  210. ),
  211. SizedBox(height: compact ? 8 : 10),
  212. SizedBox(
  213. height: compact ? 18 : 20,
  214. child: Center(
  215. child: MarqueeText(
  216. text: _nowPlayingLabel,
  217. animate:
  218. widget.isMusicPlaying && widget.media.displayTitle != null,
  219. style: theme.textTheme.bodySmall?.copyWith(
  220. color: widget.media.displayTitle == null
  221. ? colors.inkMuted
  222. : colors.ink,
  223. fontSize: compact ? 11 : 12,
  224. ),
  225. ),
  226. ),
  227. ),
  228. ],
  229. ),
  230. );
  231. }
  232. }
  233. class _StepButton extends StatelessWidget {
  234. const _StepButton({
  235. required this.icon,
  236. required this.semanticLabel,
  237. required this.onTap,
  238. required this.compact,
  239. });
  240. final IconData icon;
  241. final String semanticLabel;
  242. final VoidCallback onTap;
  243. final bool compact;
  244. @override
  245. Widget build(BuildContext context) {
  246. final colors = context.colors;
  247. return Pressable(
  248. onTap: onTap,
  249. semanticLabel: semanticLabel,
  250. pressedScale: 0.92,
  251. child: Container(
  252. width: compact ? 36 : 40,
  253. height: compact ? 36 : 40,
  254. decoration: BoxDecoration(
  255. color: colors.surfaceStrong,
  256. shape: BoxShape.circle,
  257. border: Border.all(color: colors.border),
  258. ),
  259. child: Icon(icon, size: compact ? 18 : 20, color: colors.inkMuted),
  260. ),
  261. );
  262. }
  263. }
  264. class _VolumeRing extends StatelessWidget {
  265. const _VolumeRing({
  266. required this.progress,
  267. required this.isPlaying,
  268. required this.size,
  269. });
  270. final double progress;
  271. final bool isPlaying;
  272. final double size;
  273. @override
  274. Widget build(BuildContext context) {
  275. final colors = context.colors;
  276. return TweenAnimationBuilder<double>(
  277. tween: Tween(end: progress.clamp(0.0, 1.0)),
  278. duration: Duration(milliseconds: isPlaying ? 850 : 250),
  279. curve: Curves.easeOutCubic,
  280. builder: (context, value, _) {
  281. return SizedBox(
  282. width: size,
  283. height: size,
  284. child: Stack(
  285. alignment: Alignment.center,
  286. children: [
  287. CustomPaint(
  288. size: Size.square(size),
  289. painter: _RingPainter(
  290. value: value,
  291. trackColor: colors.border,
  292. activeColor: colors.accent,
  293. ),
  294. ),
  295. Container(
  296. width: size * 0.62,
  297. height: size * 0.62,
  298. decoration: BoxDecoration(
  299. color: colors.accentSoft,
  300. shape: BoxShape.circle,
  301. ),
  302. alignment: Alignment.center,
  303. child: Icon(
  304. isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
  305. size: size * 0.34,
  306. color: colors.accent,
  307. ),
  308. ),
  309. ],
  310. ),
  311. );
  312. },
  313. );
  314. }
  315. }
  316. class _RingPainter extends CustomPainter {
  317. _RingPainter({
  318. required this.value,
  319. required this.trackColor,
  320. required this.activeColor,
  321. });
  322. final double value;
  323. final Color trackColor;
  324. final Color activeColor;
  325. @override
  326. void paint(Canvas canvas, Size size) {
  327. final stroke = size.width * 0.055;
  328. final rect = Offset.zero & size;
  329. final center = rect.center;
  330. final radius = (size.width - stroke) / 2;
  331. final track = Paint()
  332. ..color = trackColor
  333. ..style = PaintingStyle.stroke
  334. ..strokeWidth = stroke
  335. ..strokeCap = StrokeCap.round;
  336. final active = Paint()
  337. ..color = activeColor
  338. ..style = PaintingStyle.stroke
  339. ..strokeWidth = stroke
  340. ..strokeCap = StrokeCap.round;
  341. canvas.drawCircle(center, radius, track);
  342. if (value <= 0) return;
  343. canvas.drawArc(
  344. Rect.fromCircle(center: center, radius: radius),
  345. -math.pi / 2,
  346. 2 * math.pi * value,
  347. false,
  348. active,
  349. );
  350. }
  351. @override
  352. bool shouldRepaint(covariant _RingPainter oldDelegate) {
  353. return oldDelegate.value != value ||
  354. oldDelegate.trackColor != trackColor ||
  355. oldDelegate.activeColor != activeColor;
  356. }
  357. }