volume_card.dart 14 KB

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