volume_card.dart 10 KB

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