feat: ignis app v1.0.0 -- управление WiZ лампами

This commit is contained in:
Artem Kokos
2026-03-28 18:55:54 +07:00
commit 688139a75a
143 changed files with 6464 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
import 'dart:math';
import 'package:flutter/material.dart';
/// Простой цветовой пикер в виде кольца HSV.
/// Возвращает RGB через callback.
class SimpleColorPicker extends StatefulWidget {
final Color initialColor;
final ValueChanged<Color> onColorChanged;
const SimpleColorPicker({
super.key,
this.initialColor = Colors.red,
required this.onColorChanged,
});
@override
State<SimpleColorPicker> createState() => _SimpleColorPickerState();
}
class _SimpleColorPickerState extends State<SimpleColorPicker> {
late double _hue;
late double _saturation;
late double _value;
@override
void initState() {
super.initState();
final hsv = HSVColor.fromColor(widget.initialColor);
_hue = hsv.hue;
_saturation = hsv.saturation;
_value = hsv.value;
}
Color get _currentColor =>
HSVColor.fromAHSV(1.0, _hue, _saturation, _value).toColor();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Превью текущего цвета
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _currentColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.white24, width: 2),
),
),
const SizedBox(height: 16),
// Hue -- оттенок (0-360)
_buildSliderRow(
label: 'Оттенок',
value: _hue,
min: 0,
max: 360,
divisions: 36,
activeColor: HSVColor.fromAHSV(1, _hue, 1, 1).toColor(),
onChanged: (v) {
setState(() => _hue = v);
widget.onColorChanged(_currentColor);
},
),
// Saturation -- насыщенность (0-1)
_buildSliderRow(
label: 'Насыщ.',
value: _saturation,
min: 0,
max: 1,
divisions: 10,
activeColor: _currentColor,
onChanged: (v) {
setState(() => _saturation = v);
widget.onColorChanged(_currentColor);
},
),
// Value -- яркость (0-1)
_buildSliderRow(
label: 'Яркость',
value: _value,
min: 0,
max: 1,
divisions: 10,
activeColor: _currentColor,
onChanged: (v) {
setState(() => _value = v);
widget.onColorChanged(_currentColor);
},
),
],
);
}
Widget _buildSliderRow({
required String label,
required double value,
required double min,
required double max,
required int divisions,
required Color activeColor,
required ValueChanged<double> onChanged,
}) {
return Row(
children: [
SizedBox(
width: 60,
child: Text(label, style: const TextStyle(fontSize: 12, color: Colors.white54)),
),
Expanded(
child: Slider(
value: value.clamp(min, max),
min: min,
max: max,
divisions: divisions,
activeColor: activeColor,
onChanged: onChanged,
),
),
],
);
}
}

321
lib/widgets/group_card.dart Normal file
View File

@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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';
bool _showColorPicker = false;
@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 cardAccent = isOn
? (_mode == 'temp'
? Color.lerp(Colors.orange, Colors.blueAccent, (temp - 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.withOpacity(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: () => ref.read(groupsProvider.notifier).setTimer4h(id),
),
Switch(
value: isOn,
activeColor: Colors.deepOrange,
onChanged: (v) =>
ref.read(groupsProvider.notifier).toggleGroup(id, v),
),
],
),
// ─── Управление (когда включено) ───
if (isOn) ...[
const SizedBox(height: 8),
// Яркость
_SliderRow(
icon: Icons.sunny,
value: bri.toDouble().clamp(10, 100),
min: 10,
max: 100,
divisions: 9,
label: "$bri%",
activeColor: Colors.amber,
onChanged: (v) => ref
.read(groupsProvider.notifier)
.setBrightness(id, v.toInt()),
),
// Переключатель режима: температура / цвет / сцена
Row(
children: [
_ModeChip(
label: 'Темп.',
icon: Icons.wb_twilight,
selected: _mode == 'temp',
onTap: () => setState(() {
_mode = 'temp';
_showColorPicker = false;
}),
),
const SizedBox(width: 8),
_ModeChip(
label: 'Цвет',
icon: Icons.palette,
selected: _mode == 'color',
onTap: () => setState(() {
_mode = 'color';
_showColorPicker = true;
}),
),
const SizedBox(width: 8),
_ModeChip(
label: 'Сцена',
icon: Icons.auto_awesome,
selected: _mode == 'scene',
onTap: () => setState(() {
_mode = 'scene';
_showColorPicker = false;
}),
),
],
),
const SizedBox(height: 8),
// ─── Режим: температура ───
if (_mode == 'temp')
_SliderRow(
icon: Icons.wb_twilight,
value: temp.toDouble().clamp(2700, 6500),
min: 2700,
max: 6500,
divisions: 38, // шаг 100K
label: "${temp}K",
activeColor: Color.lerp(
Colors.orange, Colors.blueAccent, (temp - 2700) / 3800),
onChanged: (v) => ref
.read(groupsProvider.notifier)
.setTemperature(id, v.toInt()),
),
// ─── Режим: цвет ───
if (_mode == 'color')
SimpleColorPicker(
initialColor: Color.fromARGB(255, r, gVal, b),
onColorChanged: (c) => ref
.read(groupsProvider.notifier)
.setColor(id, c.red, c.green, c.blue),
),
// ─── Режим: сцена ───
if (_mode == 'scene') _SceneSelector(groupId: id),
],
],
),
),
),
);
}
}
/// Слайдер с иконкой и подписью
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;
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,
});
@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,
),
),
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.withOpacity(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 ConsumerWidget {
final String groupId;
const _SceneSelector({required this.groupId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scenes = ref.watch(scenesProvider);
if (scenes.isEmpty) {
// Загрузить сцены при первом показе
Future.microtask(() => ref.read(scenesProvider.notifier).load());
return const Padding(
padding: EdgeInsets.all(8.0),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return Wrap(
spacing: 8,
runSpacing: 4,
children: scenes.map((scene) {
// Сцена может быть строкой или Map с полем 'name'/'id'
final sceneName = scene is String
? scene
: (scene['name'] ?? scene['id'] ?? scene.toString());
final sceneId = scene is String
? scene
: (scene['id'] ?? scene['name'] ?? scene.toString());
return ActionChip(
label: Text(sceneName.toString(), style: const TextStyle(fontSize: 12)),
backgroundColor: Colors.white10,
onPressed: () => ref
.read(groupsProvider.notifier)
.setScene(groupId, sceneId.toString()),
);
}).toList(),
);
}
}