import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/error_message.dart'; import '../models/ignis_group.dart'; import '../providers/providers.dart'; import 'color_picker.dart'; /// Карточка одной группы ламп с управлением: /// вкл/выкл, яркость, температура, цвет, сцена. class GroupCard extends ConsumerStatefulWidget { final IgnisGroup group; const GroupCard({super.key, required this.group}); @override ConsumerState createState() => _GroupCardState(); } class _GroupCardState extends ConsumerState { /// Текущий режим управления: temp (температура) или color (RGB) String _mode = 'temp'; // Локальные значения слайдеров -- обновляются мгновенно, // а на сервер отправляются через debounce в провайдере. double? _localBrightness; double? _localTemp; int _channelValue(double channel) => (channel * 255.0).round().clamp(0, 255); @override Widget build(BuildContext context) { final g = widget.group; final id = g.id; final name = g.name; final state = g.state; final bool isOn = state.isOn; final int bri = state.brightness; final int temp = state.temp; final int r = state.r; final int gVal = state.g; final int b = state.b; ref.listen(groupControlErrorProvider, (previous, next) { if (next == null || next.groupId != id) return; if (previous?.sequence == next.sequence) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Не удалось применить ${next.action}: ${next.message}'), ), ); }); // Значения слайдеров: локальные (если тянем) или серверные final briValue = (_localBrightness ?? bri.toDouble()).clamp(10.0, 100.0); final tempValue = (_localTemp ?? temp.toDouble()).clamp(2700.0, 6500.0); // Цвет подсветки карточки зависит от режима и состояния final cardAccent = isOn ? (_mode == 'temp' ? Color.lerp( Colors.orange, Colors.blueAccent, (tempValue - 2700) / 3800, )! : Color.fromARGB(255, r, gVal, b)) : Colors.white12; return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: AnimatedContainer( duration: const Duration(milliseconds: 300), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), border: isOn ? Border.all(color: cardAccent.withValues(alpha: 0.3), width: 1) : null, ), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ─── Заголовок + переключатель ─── Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( name, style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: isOn ? Colors.white : Colors.white54, ), ), ), // Кнопка "таймер на 4 часа" if (isOn) IconButton( icon: const Icon( Icons.timer, size: 20, color: Colors.white38, ), tooltip: 'Включить на 4 часа', onPressed: () => _setTimer4h(id), ), Switch( value: isOn, activeThumbColor: Colors.deepOrange, onChanged: (v) => _toggleGroup(id, v), ), ], ), // ─── Управление (когда включено) ─── if (isOn) ...[ const SizedBox(height: 8), // Яркость _SliderRow( icon: Icons.sunny, value: briValue, min: 10, max: 100, divisions: 9, label: "${briValue.toInt()}%", activeColor: Colors.amber, onChanged: (v) { setState(() => _localBrightness = v); ref .read(groupsProvider.notifier) .setBrightness(id, v.toInt()); }, onChangeEnd: (v) { setState(() => _localBrightness = null); }, ), // Переключатель режима: температура / цвет / сцена Row( children: [ _ModeChip( label: 'Темп.', icon: Icons.wb_twilight, selected: _mode == 'temp', onTap: () => setState(() => _mode = 'temp'), ), const SizedBox(width: 8), _ModeChip( label: 'Цвет', icon: Icons.palette, selected: _mode == 'color', onTap: () => setState(() => _mode = 'color'), ), const SizedBox(width: 8), _ModeChip( label: 'Сцена', icon: Icons.auto_awesome, selected: _mode == 'scene', onTap: () => setState(() => _mode = 'scene'), ), ], ), const SizedBox(height: 8), // ─── Режим: температура ─── if (_mode == 'temp') _SliderRow( icon: Icons.wb_twilight, value: tempValue, min: 2700, max: 6500, divisions: 38, // шаг 100K label: "${tempValue.toInt()}K", activeColor: Color.lerp( Colors.orange, Colors.blueAccent, (tempValue - 2700) / 3800, ), onChanged: (v) { setState(() => _localTemp = v); ref .read(groupsProvider.notifier) .setTemperature(id, v.toInt()); }, onChangeEnd: (v) { setState(() => _localTemp = null); }, ), // ─── Режим: цвет ─── if (_mode == 'color') SimpleColorPicker( initialColor: Color.fromARGB(255, r, gVal, b), onColorChanged: (c) { // Обновление UI-превью -- через debounce отправляется на сервер ref .read(groupsProvider.notifier) .setColor( id, _channelValue(c.r), _channelValue(c.g), _channelValue(c.b), ); }, ), // ─── Режим: сцена ─── if (_mode == 'scene') _SceneSelector(groupId: id), ], ], ), ), ), ); } Future _toggleGroup(String id, bool value) async { try { await ref.read(groupsProvider.notifier).toggleGroup(id, value); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка управления группой: ${describeLoadError(e)}'), ), ); } } Future _setTimer4h(String id) async { try { await ref.read(groupsProvider.notifier).setTimer4h(id); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка создания таймера: ${describeLoadError(e)}'), ), ); } } } /// Слайдер с иконкой и подписью class _SliderRow extends StatelessWidget { final IconData icon; final double value; final double min; final double max; final int divisions; final String label; final Color? activeColor; final ValueChanged onChanged; final ValueChanged? onChangeEnd; const _SliderRow({ required this.icon, required this.value, required this.min, required this.max, required this.divisions, required this.label, this.activeColor, required this.onChanged, this.onChangeEnd, }); @override Widget build(BuildContext context) { return Row( children: [ Icon(icon, size: 18, color: Colors.white54), Expanded( child: Slider( value: value, min: min, max: max, divisions: divisions, label: label, activeColor: activeColor, onChanged: onChanged, onChangeEnd: onChangeEnd, ), ), SizedBox( width: 50, child: Text( label, style: const TextStyle(fontSize: 12, color: Colors.white54), textAlign: TextAlign.right, ), ), ], ); } } /// Чип переключения режима class _ModeChip extends StatelessWidget { final String label; final IconData icon; final bool selected; final VoidCallback onTap; const _ModeChip({ required this.label, required this.icon, required this.selected, required this.onTap, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: selected ? Colors.deepOrange.withValues(alpha: 0.2) : Colors.white10, borderRadius: BorderRadius.circular(20), border: selected ? Border.all(color: Colors.deepOrange, width: 1) : Border.all(color: Colors.transparent), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, size: 14, color: selected ? Colors.deepOrange : Colors.white54, ), const SizedBox(width: 4), Text( label, style: TextStyle( fontSize: 12, color: selected ? Colors.deepOrange : Colors.white54, ), ), ], ), ), ); } } /// Выбор сцены из списка, загруженного с сервера class _SceneSelector extends ConsumerStatefulWidget { final String groupId; const _SceneSelector({required this.groupId}); @override ConsumerState<_SceneSelector> createState() => _SceneSelectorState(); } class _SceneSelectorState extends ConsumerState<_SceneSelector> { @override void initState() { super.initState(); Future.microtask(() => ref.read(scenesProvider.notifier).load()); } @override Widget build(BuildContext context) { final scenesState = ref.watch(scenesProvider); final scenes = scenesState.data; if ((scenesState.isIdle || scenesState.isLoading) && scenes.isEmpty) { return const Padding( padding: EdgeInsets.all(8.0), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } if (scenesState.hasError && scenes.isEmpty) { return Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Не удалось загрузить сцены', style: const TextStyle(color: Colors.white54, fontSize: 12), ), if (scenesState.errorMessage != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( scenesState.errorMessage!, style: const TextStyle(color: Colors.white30, fontSize: 11), ), ), TextButton.icon( onPressed: _loadScenes, icon: const Icon(Icons.refresh, size: 16), label: const Text('Повторить'), ), ], ), ); } if (scenes.isEmpty) { return const Padding( padding: EdgeInsets.all(8.0), child: Text( 'Сцены не найдены', style: TextStyle(color: Colors.white38, fontSize: 12), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (scenesState.hasError) Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ const Icon( Icons.warning_amber_rounded, size: 16, color: Colors.deepOrange, ), const SizedBox(width: 6), const Expanded( child: Text( 'Сцены не обновились', style: TextStyle(color: Colors.white38, fontSize: 12), ), ), IconButton( tooltip: 'Повторить', onPressed: _loadScenes, icon: const Icon(Icons.refresh, size: 16), ), ], ), ), Wrap( spacing: 8, runSpacing: 4, children: scenes.map((scene) { return ActionChip( label: Text( scene.displayName, style: const TextStyle(fontSize: 12), ), backgroundColor: Colors.white10, onPressed: () => _setScene(scene.id), ); }).toList(), ), ], ); } Future _loadScenes() => ref.read(scenesProvider.notifier).load(); Future _setScene(String sceneId) async { try { await ref.read(groupsProvider.notifier).setScene(widget.groupId, sceneId); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Ошибка сцены: ${describeLoadError(e)}')), ); } } }