volume_card.dart 10 KB

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