Files
ignis_app/lib/widgets/group_card.dart
2026-04-23 20:24:08 +07:00

486 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
// Значения слайдеров: локальные (если тянем) или серверные
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)}')),
);
}
}
}