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,365 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/home_config.dart';
import '../services/api_client.dart';
import '../services/settings_service.dart';
// ─── Сервисы ─────────────────────────────────────────────────
/// Синглтон сервиса настроек
final settingsServiceProvider = Provider((ref) => SettingsService());
/// API-клиент -- пересоздаётся при смене дома
final apiProvider = Provider((ref) => IgnisApi());
// ─── Текущий дом ─────────────────────────────────────────────
/// Текущий выбранный дом (null если ни одного нет)
final currentHomeProvider =
NotifierProvider<CurrentHomeNotifier, HomeConfig?>(() => CurrentHomeNotifier());
class CurrentHomeNotifier extends Notifier<HomeConfig?> {
@override
HomeConfig? build() => null;
/// Загрузить текущий дом из SharedPreferences
Future<void> load() async {
final svc = ref.read(settingsServiceProvider);
state = await svc.getCurrentHome();
if (state != null) {
_initApi(state!);
}
}
/// Переключиться на другой дом
Future<void> switchTo(HomeConfig home) async {
final svc = ref.read(settingsServiceProvider);
await svc.setCurrentHomeId(home.id);
state = home;
_initApi(home);
// Перезагрузить группы для нового дома
await ref.read(groupsProvider.notifier).initAndRefresh();
}
/// Инициализировать API-клиент текущим домом
void _initApi(HomeConfig home) {
ref.read(apiProvider).init(home.url, home.apiKey);
}
}
// ─── Список домов ────────────────────────────────────────────
final homesProvider =
NotifierProvider<HomesNotifier, List<HomeConfig>>(() => HomesNotifier());
class HomesNotifier extends Notifier<List<HomeConfig>> {
@override
List<HomeConfig> build() => [];
Future<void> load() async {
state = await ref.read(settingsServiceProvider).getHomes();
}
Future<void> add(HomeConfig home) async {
await ref.read(settingsServiceProvider).upsertHome(home);
await load();
}
Future<void> remove(String id) async {
await ref.read(settingsServiceProvider).deleteHome(id);
await load();
}
Future<void> update(HomeConfig home) async {
await ref.read(settingsServiceProvider).upsertHome(home);
await load();
}
}
// ─── Группы текущего дома ────────────────────────────────────
final groupsProvider =
NotifierProvider<GroupsNotifier, List<dynamic>>(() => GroupsNotifier());
class GroupsNotifier extends Notifier<List<dynamic>> {
IgnisApi get _api => ref.read(apiProvider);
Timer? _timer;
/// Блокировка обновления для группы после управления --
/// чтобы UI не прыгал пока лампа ещё не ответила.
final Map<String, DateTime> _lockUntil = {};
@override
List<dynamic> build() {
ref.onDispose(() => _timer?.cancel());
return [];
}
/// Инициализация: настроить API и начать периодический опрос
Future<void> initAndRefresh() async {
final home = ref.read(currentHomeProvider);
if (home == null) return;
_api.init(home.url, home.apiKey);
await refresh();
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
}
/// Полный опрос: загрузить группы + статус каждой
Future<void> refresh() async {
try {
final resGroups = await _api.getGroups();
List<dynamic> rawList = [];
// Бэкенд может вернуть и Map, и List -- обрабатываем оба варианта
if (resGroups.data is Map) {
rawList = resGroups.data['data'] ??
resGroups.data['groups'] ??
resGroups.data.values.toList();
} else if (resGroups.data is List) {
rawList = resGroups.data;
}
final now = DateTime.now();
// Параллельный опрос статусов всех групп
final updatedList = await Future.wait(rawList.map((g) async {
final map = Map<String, dynamic>.from(g);
final id = map['id'].toString();
// Если группа залочена (недавно управляли) -- берём локальное состояние
if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) {
final existing = state.cast<dynamic?>().firstWhere(
(old) => old?['id'].toString() == id,
orElse: () => null);
return existing ?? map;
}
try {
final resStatus = await _api.getGroupStatus(id);
// Формат ответа: { results: [ { status: { state, dimming, temp, ... } } ] }
if (resStatus.data != null &&
resStatus.data['results'] is List &&
resStatus.data['results'].isNotEmpty) {
final data = resStatus.data['results'][0]['status'];
map['last_state'] = {
'state': data['state'] == true,
'brightness': (data['dimming'] ?? 100).toInt(),
'temp': (data['temp'] ?? 4000).toInt(),
'r': (data['r'] ?? 0).toInt(),
'g': (data['g'] ?? 0).toInt(),
'b': (data['b'] ?? 0).toInt(),
'scene': data['scene'],
};
}
} catch (e) {
// При ошибке опроса -- сохраняем предыдущее состояние
final existing = state.cast<dynamic?>().firstWhere(
(s) => s?['id'].toString() == id,
orElse: () => null);
map['last_state'] = existing?['last_state'] ??
{'state': false, 'brightness': 100, 'temp': 4000};
}
return map;
}));
state = updatedList;
} catch (e) {
debugPrint("Ошибка глобального опроса: $e");
}
}
/// Установить блокировку на 5 секунд (чтобы UI не перетирал значения)
void _setLock(String id) =>
_lockUntil[id] = DateTime.now().add(const Duration(seconds: 5));
/// Обновить локальное состояние группы (оптимистичный UI)
void _updateLocal(String id, Map<String, dynamic> patch) {
state = [
for (final g in state)
if (g['id'].toString() == id)
{
...g,
'last_state': {
...Map<String, dynamic>.from(g['last_state'] ?? {}),
...patch
}
}
else
g
];
}
/// Включить/выключить группу
Future<void> toggleGroup(String id, bool on) async {
_setLock(id);
_updateLocal(id, {'state': on});
try {
await _api.controlGroup(id, {'state': on});
} catch (e) {
_lockUntil.remove(id);
refresh();
}
}
/// Установить яркость (0-100)
Future<void> setBrightness(String id, int value) async {
_setLock(id);
_updateLocal(id, {'brightness': value});
await _api.controlGroup(id, {'brightness': value});
}
/// Установить цветовую температуру (2700-6500K)
Future<void> setTemperature(String id, int value) async {
_setLock(id);
_updateLocal(id, {'temp': value});
await _api.controlGroup(id, {'temp': value});
}
/// Установить RGB-цвет
Future<void> setColor(String id, int r, int g, int b) async {
_setLock(id);
_updateLocal(id, {'r': r, 'g': g, 'b': b});
await _api.controlGroup(id, {'r': r, 'g': g, 'b': b});
}
/// Установить сцену
Future<void> setScene(String id, String scene) async {
_setLock(id);
_updateLocal(id, {'scene': scene});
await _api.controlGroup(id, {'scene': scene});
}
/// Таймер: включить на 4 часа
Future<void> setTimer4h(String id) async {
await toggleGroup(id, true);
await _api.scheduleOnce({
'target_id': id,
'state': false,
'hours_from_now': 4,
'is_group': true,
});
}
}
// ─── Устройства (для создания групп) ─────────────────────────
final devicesProvider =
NotifierProvider<DevicesNotifier, List<dynamic>>(() => DevicesNotifier());
class DevicesNotifier extends Notifier<List<dynamic>> {
@override
List<dynamic> build() => [];
/// Загрузить список устройств из текущего дома
Future<void> load() async {
try {
final api = ref.read(apiProvider);
final res = await api.getDevices();
if (res.data is List) {
state = res.data;
} else if (res.data is Map) {
state = res.data['data'] ?? res.data['devices'] ?? res.data.values.toList();
}
} catch (e) {
debugPrint("Ошибка загрузки устройств: $e");
}
}
}
// ─── Сцены ───────────────────────────────────────────────────
final scenesProvider =
NotifierProvider<ScenesNotifier, List<dynamic>>(() => ScenesNotifier());
class ScenesNotifier extends Notifier<List<dynamic>> {
@override
List<dynamic> build() => [];
Future<void> load() async {
try {
final api = ref.read(apiProvider);
final res = await api.getScenes();
if (res.data is List) {
state = res.data;
} else if (res.data is Map) {
state = res.data['data'] ?? res.data['scenes'] ?? res.data.values.toList();
}
} catch (e) {
debugPrint("Ошибка загрузки сцен: $e");
}
}
}
// ─── Расписания ──────────────────────────────────────────────
final tasksProvider =
NotifierProvider<TasksNotifier, List<dynamic>>(() => TasksNotifier());
class TasksNotifier extends Notifier<List<dynamic>> {
@override
List<dynamic> build() => [];
Future<void> load() async {
try {
final api = ref.read(apiProvider);
final res = await api.getTasks();
if (res.data is List) {
state = res.data;
} else if (res.data is Map) {
state = res.data['data'] ?? res.data['tasks'] ?? res.data.values.toList();
}
} catch (e) {
debugPrint("Ошибка загрузки расписаний: $e");
}
}
Future<void> cancel(String jobId) async {
try {
await ref.read(apiProvider).cancelTask(jobId);
await load();
} catch (e) {
debugPrint("Ошибка отмены задачи: $e");
}
}
/// Создать одноразовый таймер
Future<void> addOnce({
required String targetId,
required bool targetState,
int? hoursFromNow,
String? runAt,
bool isGroup = true,
}) async {
final params = <String, dynamic>{
'target_id': targetId,
'state': targetState,
'is_group': isGroup,
};
if (hoursFromNow != null) params['hours_from_now'] = hoursFromNow;
if (runAt != null) params['run_at'] = runAt;
await ref.read(apiProvider).scheduleOnce(params);
await load();
}
/// Создать cron-задачу
Future<void> addCron({
required String targetId,
required String hour,
required String minute,
String dayOfWeek = '*',
bool isGroup = true,
bool targetState = true,
}) async {
await ref.read(apiProvider).scheduleCron({
'target_id': targetId,
'hour': hour,
'minute': minute,
'day_of_week': dayOfWeek,
'is_group': isGroup,
'state': targetState,
});
await load();
}
}