import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.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()); class CurrentHomeNotifier extends Notifier { @override HomeConfig? build() => null; /// Загрузить текущий дом из SharedPreferences Future load() async { final svc = ref.read(settingsServiceProvider); state = await svc.getCurrentHome(); if (state != null) { _initApi(state!); } } /// Переключиться на другой дом Future 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()); class HomesNotifier extends Notifier> { @override List build() => []; Future load() async { state = await ref.read(settingsServiceProvider).getHomes(); } Future add(HomeConfig home) async { await ref.read(settingsServiceProvider).upsertHome(home); await load(); } Future remove(String id) async { await ref.read(settingsServiceProvider).deleteHome(id); await load(); } Future update(HomeConfig home) async { await ref.read(settingsServiceProvider).upsertHome(home); await load(); } } // ─── Геолокация пользователя ───────────────────────────────── /// Состояние геолокации: позиция или причина отсутствия. /// Запрашивается один раз, кешируется до перезапуска провайдера. class UserLocation { final Position? position; final String? error; // null -- всё ок, иначе причина const UserLocation({this.position, this.error}); bool get hasPosition => position != null; /// Расстояние в км до точки. Возвращает null если нет позиции /// или у цели нет координат. double? distanceToKm(double? lat, double? lon) { if (position == null || lat == null || lon == null) return null; return calculateDistanceKm( position!.latitude, position!.longitude, lat, lon, ); } } final userLocationProvider = NotifierProvider( () => UserLocationNotifier()); class UserLocationNotifier extends Notifier { StreamSubscription? _sub; int _watchers = 0; @override UserLocation build() { ref.onDispose(() { _sub?.cancel(); _sub = null; }); return const UserLocation(); } /// Запросить текущую позицию. Первый вызов проверяет пермишены /// и отдаёт lastKnown мгновенно (если есть). Future fetch() async { if (state.hasPosition) return; final err = await _ensurePermission(); if (err != null) { state = UserLocation(error: err); return; } try { // getLastKnownPosition -- мгновенно, без GPS-фикса final last = await Geolocator.getLastKnownPosition(); if (last != null) { state = UserLocation(position: last); return; } // Если lastKnown нет -- одноразовый запрос final pos = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.low, timeLimit: Duration(seconds: 10), ), ); state = UserLocation(position: pos); } catch (e) { state = UserLocation(error: 'Ошибка: $e'); } } /// Начать непрерывное отслеживание. Вызывать из initState экрана. /// Ref-counted: несколько экранов могут вызвать startWatching, /// стрим остановится только когда все вызовут stopWatching. Future startWatching() async { _watchers++; if (_sub != null) return; // уже слушаем final err = await _ensurePermission(); if (err != null) { state = UserLocation(error: err); return; } // Отдать lastKnown сразу, пока стрим ещё не дал первый event if (!state.hasPosition) { try { final last = await Geolocator.getLastKnownPosition(); if (last != null) { state = UserLocation(position: last); } } catch (_) {} } const settings = LocationSettings( accuracy: LocationAccuracy.low, distanceFilter: 20, // минимум 20м между событиями ); _sub = Geolocator.getPositionStream(locationSettings: settings).listen( (pos) => state = UserLocation(position: pos), onError: (e) { // Не затираем последнюю позицию -- просто логируем debugPrint('Ошибка стрима геолокации: $e'); }, ); } /// Остановить отслеживание. Вызывать из dispose экрана. void stopWatching() { _watchers = (_watchers - 1).clamp(0, 999); if (_watchers == 0) { _sub?.cancel(); _sub = null; } } /// Проверить сервис и пермишены. Возвращает null если всё ок, /// иначе строку с причиной ошибки. Future _ensurePermission() async { if (!await Geolocator.isLocationServiceEnabled()) { return 'Геолокация выключена'; } var perm = await Geolocator.checkPermission(); if (perm == LocationPermission.denied) { perm = await Geolocator.requestPermission(); } if (perm == LocationPermission.denied) { return 'Нет разрешения'; } if (perm == LocationPermission.deniedForever) { return 'Разрешение запрещено навсегда'; } return null; } } // ─── Группы текущего дома ──────────────────────────────────── final groupsProvider = NotifierProvider>(() => GroupsNotifier()); class GroupsNotifier extends Notifier> { IgnisApi get _api => ref.read(apiProvider); Timer? _timer; /// Блокировка обновления для группы после управления -- /// чтобы UI не прыгал пока лампа ещё не ответила. final Map _lockUntil = {}; /// Debounce-таймеры для слайдеров (яркость, темп, цвет) final Map _debounceTimers = {}; @override List build() { ref.onDispose(() { _timer?.cancel(); for (final t in _debounceTimers.values) { t.cancel(); } }); return []; } /// Инициализация: настроить API и начать периодический опрос Future 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 refresh() async { try { final resGroups = await _api.getGroups(); List rawList = []; if (resGroups.data is Map) { // Бэкенд возвращает {id: GroupModel, ...} -- values уже содержат id внутри rawList = 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.from(g); final id = map['id'].toString(); // Если группа залочена (недавно управляли) -- берём локальное состояние if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) { final existing = state.cast().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 results = resStatus.data['results'] as List; final validResult = results.firstWhere( (r) => r['status'] != null && r['error'] == null, orElse: () => results.first, ); final data = validResult['status']; if (data != null) { 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().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 patch) { state = [ for (final g in state) if (g['id'].toString() == id) { ...g, 'last_state': { ...Map.from(g['last_state'] ?? {}), ...patch } } else g ]; } /// Debounce: отправить API-запрос с задержкой, но UI обновить сразу. /// Если значение меняется быстро (слайдер тянут), отправляется только /// последнее значение после паузы. void _debouncedControl(String id, String key, Map localPatch, Map apiParams) { _setLock(id); _updateLocal(id, localPatch); final timerKey = '$id:$key'; _debounceTimers[timerKey]?.cancel(); _debounceTimers[timerKey] = Timer( const Duration(milliseconds: 300), () async { try { await _api.controlGroup(id, apiParams); } catch (e) { _lockUntil.remove(id); refresh(); } }, ); } /// Включить/выключить группу (без debounce -- мгновенно) Future 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) -- с debounce void setBrightness(String id, int value) { _debouncedControl(id, 'brightness', {'brightness': value}, {'brightness': value}); } /// Установить цветовую температуру (2700-6500K) -- с debounce void setTemperature(String id, int value) { _debouncedControl(id, 'temp', {'temp': value}, {'temp': value}); } /// Установить RGB-цвет -- с debounce void setColor(String id, int r, int g, int b) { _debouncedControl(id, 'color', {'r': r, 'g': g, 'b': b}, {'r': r, 'g': g, 'b': b}); } /// Установить сцену (без debounce) Future setScene(String id, String scene) async { _setLock(id); _updateLocal(id, {'scene': scene}); try { await _api.controlGroup(id, {'scene': scene}); } catch (e) { _lockUntil.remove(id); refresh(); } } /// Таймер: включить на 4 часа Future 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()); class DevicesNotifier extends Notifier> { @override List build() => []; /// Загрузить список устройств из текущего дома Future 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()); class ScenesNotifier extends Notifier> { @override List build() => []; Future load() async { try { final api = ref.read(apiProvider); final res = await api.getScenes(); final data = res.data; if (data is List) { state = data; } else if (data is Map) { // Бэкенд может вернуть {scene_id: "Scene Name", ...} // или {data: [...]} или {scenes: [...]} if (data.containsKey('data') && data['data'] is List) { state = data['data']; } else if (data.containsKey('scenes') && data['scenes'] is List) { state = data['scenes']; } else { // Map вида {id: name} -- преобразуем в список state = data.entries .map((e) => {'id': e.key.toString(), 'name': e.value.toString()}) .toList(); } } } catch (e) { debugPrint("Ошибка загрузки сцен: $e"); } } } // ─── Расписания ────────────────────────────────────────────── final tasksProvider = NotifierProvider>(() => TasksNotifier()); class TasksNotifier extends Notifier> { @override List build() => []; Future load() async { try { final api = ref.read(apiProvider); final res = await api.getTasks(); final data = res.data; if (data is List) { state = data; } else if (data is Map) { state = data['tasks'] ?? data['data'] ?? data.values.toList(); } } catch (e) { debugPrint("Ошибка загрузки расписаний: $e"); } } Future cancel(String jobId) async { try { await ref.read(apiProvider).cancelTask(jobId); await load(); } catch (e) { debugPrint("Ошибка отмены задачи: $e"); } } /// Создать одноразовый таймер Future addOnce({ required String targetId, required bool targetState, int? hoursFromNow, String? runAt, bool isGroup = true, }) async { final params = { '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 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(); } } // ─── Статистика ────────────────────────────────────────────── final statsProvider = NotifierProvider>(() => StatsNotifier()); class StatsNotifier extends Notifier> { @override Map build() => {}; Future load({int days = 7}) async { try { final api = ref.read(apiProvider); final res = await api.getStatsSummary(days: days); final data = res.data; if (data is Map) { state = Map.from(data); } } catch (e) { debugPrint("Ошибка загрузки статистики: $e"); } } } // ─── Лог событий ───────────────────────────────────────────── final eventLogProvider = NotifierProvider>(() => EventLogNotifier()); class EventLogNotifier extends Notifier> { @override List build() => []; Future load({int limit = 100}) async { try { final api = ref.read(apiProvider); final res = await api.getStatsLog(limit: limit); final data = res.data; if (data is List) { state = data; } else if (data is Map) { state = data['data'] ?? data['events'] ?? data.values.toList(); } } catch (e) { debugPrint("Ошибка загрузки логов: $e"); } } } // ─── API-ключи ─────────────────────────────────────────────── final apiKeysProvider = NotifierProvider>(() => ApiKeysNotifier()); class ApiKeysNotifier extends Notifier> { @override List build() => []; Future load() async { try { final api = ref.read(apiProvider); final res = await api.getApiKeys(); final data = res.data; if (data is List) { state = data; } else if (data is Map) { state = data['data'] ?? data['keys'] ?? data.values.toList(); } } catch (e) { debugPrint("Ошибка загрузки API-ключей: $e"); } } Future create(String name, {bool isAdmin = false}) async { try { final api = ref.read(apiProvider); final res = await api.createApiKey(name, isAdmin: isAdmin); await load(); if (res.data is Map) { return res.data['key']?.toString(); } return null; } catch (e) { debugPrint("Ошибка создания ключа: $e"); return null; } } Future revoke(String key) async { try { await ref.read(apiProvider).revokeApiKey(key); await load(); } catch (e) { debugPrint("Ошибка отзыва ключа: $e"); } } Future activate(String key) async { try { await ref.read(apiProvider).activateApiKey(key); await load(); } catch (e) { debugPrint("Ошибка активации ключа: $e"); } } } // ─── Информация об авторизации ──────────────────────────────── final authInfoProvider = NotifierProvider?>(() => AuthInfoNotifier()); class AuthInfoNotifier extends Notifier?> { @override Map? build() => null; Future load() async { try { final api = ref.read(apiProvider); final res = await api.getAuthMe(); if (res.data is Map) { state = Map.from(res.data); } } catch (e) { debugPrint("Ошибка загрузки auth/me: $e"); } } bool get isAdmin => state?['is_admin'] == true; } // ─── Утилита: расчёт расстояния (Haversine) ────────────────── double calculateDistanceKm( double lat1, double lon1, double lat2, double lon2) { const earthRadiusKm = 6371.0; final dLat = _degToRad(lat2 - lat1); final dLon = _degToRad(lon2 - lon1); final a = math.sin(dLat / 2) * math.sin(dLat / 2) + math.cos(_degToRad(lat1)) * math.cos(_degToRad(lat2)) * math.sin(dLon / 2) * math.sin(dLon / 2); final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); return earthRadiusKm * c; } double _degToRad(double deg) => deg * (math.pi / 180); /// Форматирование расстояния в человекочитаемый вид String formatDistance(double km) { if (km < 1.0) { return '${(km * 1000).round()} м'; } else if (km < 10.0) { return '${km.toStringAsFixed(1)} км'; } else { return '${km.round()} км'; } }