497 lines
16 KiB
Dart
497 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../app/error_message.dart';
|
||
import '../providers/providers.dart';
|
||
import 'color_picker.dart';
|
||
|
||
/// Карточка одной группы ламп с управлением:
|
||
/// вкл/выкл, яркость, температура, цвет, сцена.
|
||
class GroupCard extends ConsumerStatefulWidget {
|
||
final Map<String, dynamic> group;
|
||
|
||
const GroupCard({super.key, required this.group});
|
||
|
||
@override
|
||
ConsumerState<GroupCard> createState() => _GroupCardState();
|
||
}
|
||
|
||
class _GroupCardState extends ConsumerState<GroupCard> {
|
||
/// Текущий режим управления: 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'].toString();
|
||
final name = g['name'] ?? 'Без имени';
|
||
final bool isOn = g['last_state']?['state'] ?? false;
|
||
final int bri = g['last_state']?['brightness'] ?? 100;
|
||
final int temp = g['last_state']?['temp'] ?? 4000;
|
||
final int r = g['last_state']?['r'] ?? 255;
|
||
final int gVal = g['last_state']?['g'] ?? 200;
|
||
final int b = g['last_state']?['b'] ?? 100;
|
||
|
||
ref.listen<GroupControlError?>(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<void> _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<void> _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<double> onChanged;
|
||
final ValueChanged<double>? 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) {
|
||
String sceneName;
|
||
String sceneId;
|
||
|
||
if (scene is String) {
|
||
sceneName = scene;
|
||
sceneId = scene;
|
||
} else if (scene is Map) {
|
||
sceneName = (scene['name'] ?? scene['id'] ?? scene.toString())
|
||
.toString();
|
||
sceneId = (scene['id'] ?? scene['name'] ?? scene.toString())
|
||
.toString();
|
||
} else {
|
||
sceneName = scene.toString();
|
||
sceneId = scene.toString();
|
||
}
|
||
|
||
return ActionChip(
|
||
label: Text(sceneName, style: const TextStyle(fontSize: 12)),
|
||
backgroundColor: Colors.white10,
|
||
onPressed: () => _setScene(sceneId),
|
||
);
|
||
}).toList(),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _loadScenes() => ref.read(scenesProvider.notifier).load();
|
||
|
||
Future<void> _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)}')),
|
||
);
|
||
}
|
||
}
|
||
}
|