volume_card.dart 17 KB

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