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 '../app/error_message.dart'; import '../app/load_state.dart'; import '../models/api_key_info.dart'; import '../models/auth_info.dart'; import '../models/event_log_item.dart'; import '../models/ignis_device.dart'; import '../models/ignis_group.dart'; import '../models/ignis_scene.dart'; import '../models/home_config.dart'; import '../models/schedule_task.dart'; import '../models/stats_summary.dart'; import '../services/api_client.dart'; import '../services/settings_service.dart'; import '../services/geofence_worker.dart'; import 'package:workmanager/workmanager.dart'; // ─── Сервисы ───────────────────────────────────────────────── /// Синглтон сервиса настроек final settingsServiceProvider = Provider((ref) => SettingsService()); /// API-клиент текущего дома. Конфигурация меняется через init(). 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) { await _initApi(state!); } } /// Переключиться на другой дом Future switchTo(HomeConfig home) async { final svc = ref.read(settingsServiceProvider); await svc.setCurrentHomeId(home.id); state = home; await _initApi(home); } /// Инициализировать API-клиент текущим домом Future _initApi(HomeConfig home) async { final apiKey = await ref .read(settingsServiceProvider) .requireHomeApiKey(home.id); ref.read(apiProvider).init(home.url, 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, {required String apiKey}) async { await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey); await load(); } Future remove(String id) async { await ref.read(settingsServiceProvider).deleteHome(id); await load(); } Future update(HomeConfig home, {String? apiKey}) async { await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey); 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(), ); enum GroupsLoadStatus { idle, loading, data, empty, error } class GroupsLoadState { final GroupsLoadStatus status; final String? errorMessage; const GroupsLoadState._(this.status, {this.errorMessage}); const GroupsLoadState.idle() : this._(GroupsLoadStatus.idle); const GroupsLoadState.loading() : this._(GroupsLoadStatus.loading); const GroupsLoadState.data() : this._(GroupsLoadStatus.data); const GroupsLoadState.empty() : this._(GroupsLoadStatus.empty); const GroupsLoadState.error(String message) : this._(GroupsLoadStatus.error, errorMessage: message); bool get isLoading => status == GroupsLoadStatus.loading; bool get hasError => status == GroupsLoadStatus.error; } final groupsLoadStateProvider = NotifierProvider( GroupsLoadStateNotifier.new, ); class GroupsLoadStateNotifier extends Notifier { @override GroupsLoadState build() => const GroupsLoadState.idle(); void setIdle() => state = const GroupsLoadState.idle(); void setLoading() => state = const GroupsLoadState.loading(); void setData(List groups) { state = groups.isEmpty ? const GroupsLoadState.empty() : const GroupsLoadState.data(); } void setError(Object error) => state = GroupsLoadState.error(describeLoadError(error)); } class GroupsNotifier extends Notifier> { IgnisApi get _api => ref.read(apiProvider); Timer? _timer; bool _polling = false; bool _refreshInFlight = false; int? _refreshGeneration; int _pollingGeneration = 0; String? _pollingHomeId; /// Блокировка обновления для группы после управления -- /// чтобы UI не прыгал пока лампа ещё не ответила. final Map _lockUntil = {}; /// Debounce-таймеры для слайдеров (яркость, темп, цвет) final Map _debounceTimers = {}; @override List build() { ref.onDispose(() { _stopPolling(resetStatus: false); for (final t in _debounceTimers.values) { t.cancel(); } }); return []; } /// Настроить API и начать периодический опрос для текущего дома. Future startPolling() async { final home = ref.read(currentHomeProvider); if (home == null) { stopPolling(); return; } if (_polling && _pollingHomeId == home.id) { return; } _stopPolling(resetStatus: false); _polling = true; _pollingHomeId = home.id; final generation = ++_pollingGeneration; final apiKey = await ref .read(settingsServiceProvider) .requireHomeApiKey(home.id); _api.init(home.url, apiKey); await refresh(); if (!_isActiveGeneration(generation, pollingRequired: true)) return; _timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh()); } void stopPolling() => _stopPolling(); void _stopPolling({bool resetStatus = true}) { _timer?.cancel(); _timer = null; _polling = false; _pollingHomeId = null; _pollingGeneration++; if (resetStatus) { ref.read(groupsLoadStateProvider.notifier).setIdle(); } } /// Полный опрос: загрузить группы + статус каждой Future refresh() async { final generation = _pollingGeneration; final pollingRequired = _polling; if (_refreshInFlight && _refreshGeneration == generation) return; _refreshInFlight = true; _refreshGeneration = generation; if (state.isEmpty) { ref.read(groupsLoadStateProvider.notifier).setLoading(); } try { final resGroups = await _api.getGroups(); final rawList = IgnisGroup.listFromApi(resGroups.data); final now = DateTime.now(); // Параллельный опрос статусов всех групп final updatedList = await Future.wait( rawList.map((group) async { // Если группа залочена (недавно управляли) -- берём локальное состояние if (_lockUntil.containsKey(group.id) && _lockUntil[group.id]!.isAfter(now)) { final existing = state.firstWhere( (old) => old.id == group.id, orElse: () => group, ); return existing; } try { final resStatus = await _api.getGroupStatus(group.id); final groupState = IgnisGroupState.firstFromStatusResponse( resStatus.data, fallback: group.state, ); return group.copyWith(state: groupState); } catch (e) { // При ошибке опроса -- сохраняем предыдущее состояние final existing = state.firstWhere( (savedGroup) => savedGroup.id == group.id, orElse: () => group, ); return group.copyWith(state: existing.state); } }), ); if (!_isActiveGeneration(generation, pollingRequired: pollingRequired)) { return; } state = updatedList; ref.read(groupsLoadStateProvider.notifier).setData(updatedList); } catch (e) { if (_isActiveGeneration(generation, pollingRequired: pollingRequired)) { ref.read(groupsLoadStateProvider.notifier).setError(e); } } finally { if (_refreshGeneration == generation) { _refreshInFlight = false; _refreshGeneration = null; } } } bool _isActiveGeneration(int generation, {required bool pollingRequired}) => generation == _pollingGeneration && (!pollingRequired || _polling); /// Установить блокировку на 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 == id) g.copyWith(state: g.state.applyPatch(patch)) else g, ]; } /// Debounce: отправить API-запрос с задержкой, но UI обновить сразу. /// Если значение меняется быстро (слайдер тянут), отправляется только /// последнее значение после паузы. void _debouncedControl( String id, String key, String action, 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); await refresh(); ref.read(groupControlErrorProvider.notifier).report(id, action, e); } }, ); } /// Включить/выключить группу (без 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); await refresh(); rethrow; } } /// Установить яркость (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); await refresh(); rethrow; } } /// Таймер: включить на 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, }); } } class GroupControlError { final String groupId; final String action; final String message; final int sequence; const GroupControlError({ required this.groupId, required this.action, required this.message, required this.sequence, }); } final groupControlErrorProvider = NotifierProvider( () => GroupControlErrorNotifier(), ); class GroupControlErrorNotifier extends Notifier { int _sequence = 0; @override GroupControlError? build() => null; void report(String groupId, String action, Object error) { state = GroupControlError( groupId: groupId, action: action, message: describeLoadError(error), sequence: ++_sequence, ); } } // ─── Устройства (для создания групп) ───────────────────────── final devicesProvider = NotifierProvider>>( () => DevicesNotifier(), ); class DevicesNotifier extends Notifier>> { @override LoadState> build() => const LoadState.idle([]); /// Загрузить список устройств из текущего дома Future load() async { state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getDevices(); final devices = IgnisDevice.listFromApi(res.data); state = devices.isEmpty ? LoadState.empty(devices) : LoadState.data(devices); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } } } // ─── Сцены ─────────────────────────────────────────────────── final scenesProvider = NotifierProvider>>( () => ScenesNotifier(), ); class ScenesNotifier extends Notifier>> { @override LoadState> build() => const LoadState.idle([]); Future load() async { state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getScenes(); final scenes = IgnisScene.listFromApi(res.data); state = scenes.isEmpty ? LoadState.empty(scenes) : LoadState.data(scenes); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } } } // ─── Расписания ────────────────────────────────────────────── final tasksProvider = NotifierProvider>>( () => TasksNotifier(), ); class TasksNotifier extends Notifier>> { @override LoadState> build() => const LoadState.idle([]); Future load() async { state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getTasks(); final tasks = ScheduleTask.listFromApi(res.data); state = tasks.isEmpty ? LoadState.empty(tasks) : LoadState.data(tasks); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } } Future cancel(String jobId) async { await ref.read(apiProvider).cancelTask(jobId); await load(); } /// Создать одноразовый таймер 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 LoadState build() => const LoadState.idle(StatsSummary.empty); Future load({int days = 7}) async { state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getStatsSummary(days: days); final stats = StatsSummary.fromApi(res.data); state = stats.groups.isEmpty ? LoadState.empty(stats) : LoadState.data(stats); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } } } // ─── Лог событий ───────────────────────────────────────────── final eventLogProvider = NotifierProvider>>( () => EventLogNotifier(), ); class EventLogNotifier extends Notifier>> { @override LoadState> build() => const LoadState.idle([]); Future load({int limit = 100}) async { state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getStatsLog(limit: limit); final events = EventLogItem.listFromApi(res.data); state = events.isEmpty ? LoadState.empty(events) : LoadState.data(events); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } } } // ─── API-ключи ─────────────────────────────────────────────── final apiKeysProvider = NotifierProvider>>( () => ApiKeysNotifier(), ); class ApiKeysNotifier extends Notifier>> { @override LoadState> build() => const LoadState.idle([]); Future load() async { state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getApiKeys(); final keys = ApiKeyInfo.listFromApi(res.data); state = keys.isEmpty ? LoadState.empty(keys) : LoadState.data(keys); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } } Future create(String name, {bool isAdmin = false}) async { final api = ref.read(apiProvider); final res = await api.createApiKey(name, isAdmin: isAdmin); await load(); if (res.data is Map) { final key = res.data['key']?.toString(); if (key != null && key.isNotEmpty) { return key; } } throw const FormatException('backend не вернул созданный API key'); } Future revoke(String key) async { await ref.read(apiProvider).revokeApiKey(key); await load(); } Future activate(String key) async { await ref.read(apiProvider).activateApiKey(key); await load(); } } // ─── Информация об авторизации ──────────────────────────────── final authInfoProvider = NotifierProvider( () => AuthInfoNotifier(), ); class AuthInfoNotifier extends Notifier { @override AuthInfo? build() => null; Future load({bool failOnError = false}) async { try { final api = ref.read(apiProvider); final res = await api.getAuthMe(); state = AuthInfo.fromApi(res.data); } catch (e) { debugPrint("Ошибка загрузки auth/me: $e"); if (failOnError) rethrow; } } bool get isAdmin => state?.isAdmin == true; } // ─── Геофенс: управление фоновым таском ───────────────────── /// Синхронизировать состояние фонового таска с настройками домов. /// Вызывать при старте приложения и при изменении настроек. /// /// Если хотя бы один дом имеет geofenceReady -- регистрируем /// периодический таск. Иначе -- отменяем. Future syncGeofenceTask(List homes) async { final needGeofence = homes.any((h) => h.geofenceReady); if (needGeofence) { // Сбрасываем флаг "сработал" -- при открытии приложения // считаем что пользователь снова дома (или осознанно включил) await resetGeofenceFired(); await Workmanager().registerPeriodicTask( geofenceTaskUniqueName, geofenceTaskName, frequency: const Duration(minutes: 15), constraints: Constraints(networkType: NetworkType.connected), existingWorkPolicy: ExistingPeriodicWorkPolicy.replace, backoffPolicy: BackoffPolicy.linear, backoffPolicyDelay: const Duration(minutes: 1), ); } else { await Workmanager().cancelByUniqueName(geofenceTaskUniqueName); } } // ─── Утилита: расчёт расстояния (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()} км'; } }