volume_card.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  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. this.large = false,
  23. this.onPrevious,
  24. this.onNext,
  25. this.canPrevious = false,
  26. this.canNext = false,
  27. this.diagnostics,
  28. this.onToggleDiagnostics,
  29. this.unconfirmed = false,
  30. });
  31. final VolumeInfo volume;
  32. final ValueChanged<int> onSetVolume;
  33. final VoidCallback onToggleMute;
  34. final bool isMusicPlaying;
  35. final VoidCallback onPlayPause;
  36. final MediaPlayback media;
  37. final bool compact;
  38. final bool large;
  39. final VoidCallback? onPrevious;
  40. final VoidCallback? onNext;
  41. final bool canPrevious;
  42. final bool canNext;
  43. final String? diagnostics;
  44. final VoidCallback? onToggleDiagnostics;
  45. /// The car kept reporting a different level than the one written.
  46. final bool unconfirmed;
  47. @override
  48. State<VolumeCard> createState() => _VolumeCardState();
  49. }
  50. class _VolumeCardState extends State<VolumeCard> {
  51. double? _dragValue;
  52. int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max;
  53. int get _value => widget.volume.index.clamp(0, _max);
  54. bool get _muted => _value == 0;
  55. bool get _fixed => !widget.volume.isControllable;
  56. int get _percent => ((_value / _max) * 100).round();
  57. String get _nowPlayingLabel {
  58. return widget.media.displayTitle ??
  59. (widget.isMusicPlaying ? 'Playing' : 'Nothing playing');
  60. }
  61. @override
  62. Widget build(BuildContext context) {
  63. final colors = context.colors;
  64. final theme = Theme.of(context);
  65. final compact = widget.compact;
  66. final displayValue = (_dragValue ?? _value.toDouble())
  67. .clamp(0, _max.toDouble())
  68. .toDouble();
  69. return SurfaceCard(
  70. padding: EdgeInsets.all(compact ? 20 : 26),
  71. radius: 30,
  72. child: Column(
  73. mainAxisSize: MainAxisSize.min,
  74. crossAxisAlignment: CrossAxisAlignment.stretch,
  75. children: [
  76. Row(
  77. crossAxisAlignment: CrossAxisAlignment.center,
  78. children: [
  79. Row(
  80. mainAxisSize: MainAxisSize.min,
  81. children: [
  82. if (widget.onPrevious != null) ...[
  83. _SkipButton(
  84. asset: 'assets/icons/skip-back.svg',
  85. semanticLabel: 'Previous track',
  86. onTap: widget.canPrevious ? widget.onPrevious : null,
  87. compact: compact,
  88. large: widget.large,
  89. ),
  90. SizedBox(width: compact ? 4 : 6),
  91. ],
  92. Pressable(
  93. onTap: widget.onPlayPause,
  94. semanticLabel: widget.isMusicPlaying
  95. ? 'Pause music'
  96. : 'Play music',
  97. pressedScale: 0.94,
  98. child: _VolumeRing(
  99. progress: widget.media.progress,
  100. isPlaying: widget.isMusicPlaying,
  101. size: widget.large ? 124 : (compact ? 78 : 92),
  102. ),
  103. ),
  104. if (widget.onNext != null) ...[
  105. SizedBox(width: compact ? 4 : 6),
  106. _SkipButton(
  107. asset: 'assets/icons/skip-forward.svg',
  108. semanticLabel: 'Next track',
  109. onTap: widget.canNext ? widget.onNext : null,
  110. compact: compact,
  111. large: widget.large,
  112. ),
  113. ],
  114. ],
  115. ),
  116. SizedBox(width: compact ? 18 : 24),
  117. Expanded(
  118. child: Column(
  119. mainAxisSize: MainAxisSize.min,
  120. crossAxisAlignment: CrossAxisAlignment.start,
  121. children: [
  122. GestureDetector(
  123. behavior: HitTestBehavior.opaque,
  124. onLongPress: widget.onToggleDiagnostics,
  125. child: Row(
  126. children: [
  127. Flexible(
  128. child: Text(
  129. 'Media volume',
  130. maxLines: 1,
  131. overflow: TextOverflow.ellipsis,
  132. style: theme.textTheme.labelLarge?.copyWith(
  133. color: colors.inkMuted,
  134. fontSize: compact ? 12 : 13,
  135. ),
  136. ),
  137. ),
  138. const SizedBox(width: 6),
  139. Text(
  140. '$_percent%${widget.unconfirmed ? '?' : ''}',
  141. style: theme.textTheme.headlineMedium?.copyWith(
  142. fontSize: compact ? 24 : 28,
  143. fontFeatures: const [
  144. FontFeature.tabularFigures(),
  145. ],
  146. ),
  147. ),
  148. ],
  149. ),
  150. ),
  151. const SizedBox(height: 2),
  152. SliderTheme(
  153. data: SliderTheme.of(context).copyWith(
  154. trackHeight: compact ? 5 : 6,
  155. thumbShape: RoundSliderThumbShape(
  156. enabledThumbRadius: compact ? 9 : 10,
  157. ),
  158. ),
  159. child: Slider(
  160. value: displayValue,
  161. min: 0,
  162. max: _max.toDouble(),
  163. divisions: _max <= 30 ? _max : null,
  164. onChanged: _fixed
  165. ? null
  166. : (next) {
  167. setState(() => _dragValue = next);
  168. widget.onSetVolume(next.round());
  169. },
  170. onChangeEnd: _fixed
  171. ? null
  172. : (_) => setState(() => _dragValue = null),
  173. semanticFormatterCallback: (value) =>
  174. '${(value / _max * 100).round()} percent',
  175. ),
  176. ),
  177. SizedBox(height: compact ? 4 : 8),
  178. if (_fixed)
  179. Container(
  180. height: compact ? 36 : 40,
  181. alignment: Alignment.center,
  182. decoration: BoxDecoration(
  183. color: colors.surfaceStrong,
  184. borderRadius: BorderRadius.circular(999),
  185. border: Border.all(color: colors.border),
  186. ),
  187. child: Text(
  188. 'Volume is controlled by the car',
  189. style: theme.textTheme.labelMedium?.copyWith(
  190. color: colors.inkMuted,
  191. ),
  192. ),
  193. )
  194. else
  195. Row(
  196. children: [
  197. _StepButton(
  198. icon: Icons.remove_rounded,
  199. semanticLabel: 'Volume down',
  200. onTap: () => widget.onSetVolume(_value - 1),
  201. compact: compact,
  202. ),
  203. SizedBox(width: compact ? 8 : 10),
  204. Expanded(
  205. child: Pressable(
  206. onTap: widget.onToggleMute,
  207. semanticLabel: _muted ? 'Unmute' : 'Mute',
  208. pressedScale: 0.97,
  209. child: Container(
  210. height: compact ? 36 : 40,
  211. decoration: BoxDecoration(
  212. color: _muted
  213. ? colors.accentSoft
  214. : colors.surfaceStrong,
  215. borderRadius: BorderRadius.circular(999),
  216. border: Border.all(
  217. color: _muted
  218. ? colors.accent.withValues(alpha: 0.25)
  219. : colors.border,
  220. ),
  221. ),
  222. child: FittedBox(
  223. fit: BoxFit.scaleDown,
  224. child: Row(
  225. mainAxisSize: MainAxisSize.min,
  226. mainAxisAlignment: MainAxisAlignment.center,
  227. children: [
  228. AppIcon(
  229. _muted
  230. ? 'assets/icons/volume-x.svg'
  231. : 'assets/icons/volume-2.svg',
  232. size: compact ? 16 : 18,
  233. color: _muted
  234. ? colors.accent
  235. : colors.inkMuted,
  236. ),
  237. const SizedBox(width: 8),
  238. Text(
  239. _muted ? 'Unmute' : 'Mute',
  240. style: theme.textTheme.labelMedium
  241. ?.copyWith(
  242. color: _muted
  243. ? colors.accent
  244. : colors.inkMuted,
  245. ),
  246. ),
  247. ],
  248. ),
  249. ),
  250. ),
  251. ),
  252. ),
  253. SizedBox(width: compact ? 8 : 10),
  254. _StepButton(
  255. icon: Icons.add_rounded,
  256. semanticLabel: 'Volume up',
  257. onTap: () => widget.onSetVolume(_value + 1),
  258. compact: compact,
  259. ),
  260. ],
  261. ),
  262. if (widget.diagnostics != null) ...[
  263. const SizedBox(height: 8),
  264. Container(
  265. width: double.infinity,
  266. padding: const EdgeInsets.all(8),
  267. decoration: BoxDecoration(
  268. color: colors.surfaceStrong,
  269. borderRadius: BorderRadius.circular(10),
  270. border: Border.all(color: colors.border),
  271. ),
  272. child: Text(
  273. widget.diagnostics!,
  274. style: theme.textTheme.bodySmall?.copyWith(
  275. color: colors.inkMuted,
  276. fontSize: 10,
  277. height: 1.35,
  278. ),
  279. ),
  280. ),
  281. ],
  282. ],
  283. ),
  284. ),
  285. ],
  286. ),
  287. SizedBox(height: compact ? 8 : 10),
  288. SizedBox(
  289. height: compact ? 18 : 20,
  290. child: Center(
  291. child: MarqueeText(
  292. text: _nowPlayingLabel,
  293. animate:
  294. widget.isMusicPlaying && widget.media.displayTitle != null,
  295. style: theme.textTheme.bodySmall?.copyWith(
  296. color: widget.media.displayTitle == null
  297. ? colors.inkMuted
  298. : colors.ink,
  299. fontSize: compact ? 11 : 12,
  300. ),
  301. ),
  302. ),
  303. ),
  304. ],
  305. ),
  306. );
  307. }
  308. }
  309. class _SkipButton extends StatelessWidget {
  310. const _SkipButton({
  311. required this.asset,
  312. required this.semanticLabel,
  313. required this.onTap,
  314. required this.compact,
  315. required this.large,
  316. });
  317. final String asset;
  318. final String semanticLabel;
  319. final VoidCallback? onTap;
  320. final bool compact;
  321. final bool large;
  322. @override
  323. Widget build(BuildContext context) {
  324. final colors = context.colors;
  325. final size = large ? 46.0 : (compact ? 36.0 : 40.0);
  326. return Opacity(
  327. opacity: onTap == null ? 0.4 : 1,
  328. child: Pressable(
  329. onTap: onTap,
  330. semanticLabel: semanticLabel,
  331. pressedScale: 0.92,
  332. child: Container(
  333. width: size,
  334. height: size,
  335. decoration: BoxDecoration(
  336. color: colors.surfaceStrong,
  337. shape: BoxShape.circle,
  338. border: Border.all(color: colors.border),
  339. ),
  340. alignment: Alignment.center,
  341. child: AppIcon(asset, size: size * 0.45, color: colors.inkMuted),
  342. ),
  343. ),
  344. );
  345. }
  346. }
  347. class _StepButton extends StatelessWidget {
  348. const _StepButton({
  349. required this.icon,
  350. required this.semanticLabel,
  351. required this.onTap,
  352. required this.compact,
  353. });
  354. final IconData icon;
  355. final String semanticLabel;
  356. final VoidCallback onTap;
  357. final bool compact;
  358. @override
  359. Widget build(BuildContext context) {
  360. final colors = context.colors;
  361. return Pressable(
  362. onTap: onTap,
  363. semanticLabel: semanticLabel,
  364. pressedScale: 0.92,
  365. child: Container(
  366. width: compact ? 36 : 40,
  367. height: compact ? 36 : 40,
  368. decoration: BoxDecoration(
  369. color: colors.surfaceStrong,
  370. shape: BoxShape.circle,
  371. border: Border.all(color: colors.border),
  372. ),
  373. child: Icon(icon, size: compact ? 18 : 20, color: colors.inkMuted),
  374. ),
  375. );
  376. }
  377. }
  378. class _VolumeRing extends StatelessWidget {
  379. const _VolumeRing({
  380. required this.progress,
  381. required this.isPlaying,
  382. required this.size,
  383. });
  384. final double progress;
  385. final bool isPlaying;
  386. final double size;
  387. @override
  388. Widget build(BuildContext context) {
  389. final colors = context.colors;
  390. return TweenAnimationBuilder<double>(
  391. tween: Tween(end: progress.clamp(0.0, 1.0)),
  392. duration: Duration(milliseconds: isPlaying ? 850 : 250),
  393. curve: Curves.easeOutCubic,
  394. builder: (context, value, _) {
  395. return SizedBox(
  396. width: size,
  397. height: size,
  398. child: Stack(
  399. alignment: Alignment.center,
  400. children: [
  401. CustomPaint(
  402. size: Size.square(size),
  403. painter: _RingPainter(
  404. value: value,
  405. trackColor: colors.border,
  406. activeColor: colors.accent,
  407. ),
  408. ),
  409. Container(
  410. width: size * 0.62,
  411. height: size * 0.62,
  412. decoration: BoxDecoration(
  413. color: colors.accentSoft,
  414. shape: BoxShape.circle,
  415. ),
  416. alignment: Alignment.center,
  417. child: Icon(
  418. isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
  419. size: size * 0.34,
  420. color: colors.accent,
  421. ),
  422. ),
  423. ],
  424. ),
  425. );
  426. },
  427. );
  428. }
  429. }
  430. class _RingPainter extends CustomPainter {
  431. _RingPainter({
  432. required this.value,
  433. required this.trackColor,
  434. required this.activeColor,
  435. });
  436. final double value;
  437. final Color trackColor;
  438. final Color activeColor;
  439. @override
  440. void paint(Canvas canvas, Size size) {
  441. final stroke = size.width * 0.055;
  442. final rect = Offset.zero & size;
  443. final center = rect.center;
  444. final radius = (size.width - stroke) / 2;
  445. final track = Paint()
  446. ..color = trackColor
  447. ..style = PaintingStyle.stroke
  448. ..strokeWidth = stroke
  449. ..strokeCap = StrokeCap.round;
  450. final active = Paint()
  451. ..color = activeColor
  452. ..style = PaintingStyle.stroke
  453. ..strokeWidth = stroke
  454. ..strokeCap = StrokeCap.round;
  455. canvas.drawCircle(center, radius, track);
  456. if (value <= 0) return;
  457. canvas.drawArc(
  458. Rect.fromCircle(center: center, radius: radius),
  459. -math.pi / 2,
  460. 2 * math.pi * value,
  461. false,
  462. active,
  463. );
  464. }
  465. @override
  466. bool shouldRepaint(covariant _RingPainter oldDelegate) {
  467. return oldDelegate.value != value ||
  468. oldDelegate.trackColor != trackColor ||
  469. oldDelegate.activeColor != activeColor;
  470. }
  471. }