From 872ddf95137867d9e36d64cd2f917336737ea7c3 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Mon, 27 Apr 2026 23:21:47 +0700 Subject: [PATCH] refactor: split providers into feature modules --- README.md | 14 +- .../providers/api_keys_providers.dart | 52 + .../auth/providers/auth_providers.dart | 37 + lib/features/homes/geofence_task_sync.dart | 29 + .../homes/providers/homes_providers.dart | 101 ++ .../homes/providers/location_providers.dart | 169 ++++ .../remote/providers/remote_providers.dart | 375 +++++++ .../schedules/providers/tasks_providers.dart | 72 ++ .../shared/providers/core_providers.dart | 10 + .../stats/providers/event_log_providers.dart | 30 + .../stats/providers/stats_providers.dart | 29 + lib/providers/providers.dart | 931 +----------------- 12 files changed, 925 insertions(+), 924 deletions(-) create mode 100644 lib/features/api_keys/providers/api_keys_providers.dart create mode 100644 lib/features/auth/providers/auth_providers.dart create mode 100644 lib/features/homes/geofence_task_sync.dart create mode 100644 lib/features/homes/providers/homes_providers.dart create mode 100644 lib/features/homes/providers/location_providers.dart create mode 100644 lib/features/remote/providers/remote_providers.dart create mode 100644 lib/features/schedules/providers/tasks_providers.dart create mode 100644 lib/features/shared/providers/core_providers.dart create mode 100644 lib/features/stats/providers/event_log_providers.dart create mode 100644 lib/features/stats/providers/stats_providers.dart diff --git a/README.md b/README.md index f422d53..1f7604d 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,16 @@ lib/ │ ├── credentials_storage.dart -- безопасное хранение ключей │ ├── geofence_worker.dart -- фоновая логика геофенса │ └── settings_service.dart -- хранение списка "домов" +├── features/ +│ ├── api_keys/providers/ -- управление гостевыми API-ключами +│ ├── auth/providers/ -- auth/me и auth-state +│ ├── homes/ -- дома, геолокация, geofence sync +│ ├── remote/providers/ -- polling групп, устройства, сцены, control errors +│ ├── schedules/providers/ -- задачи расписания +│ ├── shared/providers/ -- базовые core providers +│ └── stats/providers/ -- статистика и лог событий ├── providers/ -│ └── providers.dart -- Riverpod-провайдеры (god object, подлежит распилу) +│ └── providers.dart -- compatibility barrel для публичных provider-экспортов ├── screens/ │ ├── api_keys_screen.dart │ ├── event_log_screen.dart @@ -97,7 +105,7 @@ flutter analyze flutter test ``` -Текущий baseline зелёный: анализатор без issues, юнит-тесты на парсинг домена и состояния проходят штатно. +Текущий baseline зелёный: `flutter analyze`, `flutter test` и release APK сборка проходят штатно. ## Настройка @@ -117,7 +125,7 @@ API-ключи хранятся отдельно от конфигурации - Целевая платформа сейчас Android. - Release APK пока подписывается debug-ключом из Flutter-шаблона. -- Архитектура всё ещё содержит крупный `providers.dart`, который подлежит разделению на feature-oriented модули в рамках грядущих рефакторингов. +- Геофенс всё ещё требует отдельной продуктовой и технической доводки: multi-home semantics, background permissions и retry/cooldown поведение пока не доведены до конца. ## Лицензия diff --git a/lib/features/api_keys/providers/api_keys_providers.dart b/lib/features/api_keys/providers/api_keys_providers.dart new file mode 100644 index 0000000..edd28d7 --- /dev/null +++ b/lib/features/api_keys/providers/api_keys_providers.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/error_message.dart'; +import '../../../app/load_state.dart'; +import '../../../models/api_key_info.dart'; +import '../../shared/providers/core_providers.dart'; + +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(); + } +} diff --git a/lib/features/auth/providers/auth_providers.dart b/lib/features/auth/providers/auth_providers.dart new file mode 100644 index 0000000..8b72cd1 --- /dev/null +++ b/lib/features/auth/providers/auth_providers.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/error_message.dart'; +import '../../../app/load_state.dart'; +import '../../../models/auth_info.dart'; +import '../../shared/providers/core_providers.dart'; + +final authInfoProvider = + NotifierProvider>( + () => AuthInfoNotifier(), + ); + +class AuthInfoNotifier extends Notifier> { + @override + LoadState build() => const LoadState.idle(null); + + Future load({bool failOnError = false}) async { + state = LoadState.loading(state.data); + try { + final api = ref.read(apiProvider); + final res = await api.getAuthMe(); + final authInfo = AuthInfo.fromApi(res.data); + state = LoadState.data(authInfo); + return authInfo; + } catch (e) { + state = LoadState.error(null, describeLoadError(e)); + if (failOnError) rethrow; + return null; + } + } + + void clear() => state = const LoadState.idle(null); + + void restore(LoadState restoredState) => state = restoredState; + + bool get isAdmin => state.data?.isAdmin == true; +} diff --git a/lib/features/homes/geofence_task_sync.dart b/lib/features/homes/geofence_task_sync.dart new file mode 100644 index 0000000..fb31439 --- /dev/null +++ b/lib/features/homes/geofence_task_sync.dart @@ -0,0 +1,29 @@ +import 'package:workmanager/workmanager.dart'; + +import '../../models/home_config.dart'; +import '../../services/geofence_worker.dart'; + +/// Синхронизировать состояние фонового таска с настройками домов. +/// Вызывать при старте приложения и при изменении настроек. +/// +/// Если хотя бы один дом имеет 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); + } +} diff --git a/lib/features/homes/providers/homes_providers.dart b/lib/features/homes/providers/homes_providers.dart new file mode 100644 index 0000000..fab6c68 --- /dev/null +++ b/lib/features/homes/providers/homes_providers.dart @@ -0,0 +1,101 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/home_config.dart'; +import '../../auth/providers/auth_providers.dart'; +import '../../shared/providers/core_providers.dart'; + +/// Текущий выбранный дом (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); + } + + /// Выбрать дом как активный и сразу проверить auth-state. + /// Если `auth/me` падает, откатываемся к предыдущему дому и auth-state. + Future select(HomeConfig home) async { + final previousHome = state; + final previousAuthState = ref.read(authInfoProvider); + + try { + await switchTo(home); + await ref.read(authInfoProvider.notifier).load(failOnError: true); + } catch (error) { + await _restoreSelection(previousHome); + ref.read(authInfoProvider.notifier).restore(previousAuthState); + rethrow; + } + } + + Future clear() async { + await ref.read(settingsServiceProvider).setCurrentHomeId(null); + state = null; + } + + /// Инициализировать API-клиент текущим домом + Future _initApi(HomeConfig home) async { + final apiKey = await ref + .read(settingsServiceProvider) + .requireHomeApiKey(home.id); + ref.read(apiProvider).init(home.url, apiKey); + } + + Future _restoreSelection(HomeConfig? home) async { + if (home == null) { + await clear(); + return; + } + + final svc = ref.read(settingsServiceProvider); + await svc.setCurrentHomeId(home.id); + state = home; + await _initApi(home); + } +} + +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(); + } +} diff --git a/lib/features/homes/providers/location_providers.dart b/lib/features/homes/providers/location_providers.dart new file mode 100644 index 0000000..504ae6d --- /dev/null +++ b/lib/features/homes/providers/location_providers.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; + +/// Состояние геолокации: позиция или причина отсутствия. +/// Запрашивается один раз, кешируется до перезапуска провайдера. +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 { + final last = await Geolocator.getLastKnownPosition(); + if (last != null) { + state = UserLocation(position: last); + return; + } + + 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; + } + + 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, + ); + + _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; + } +} + +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()} км'; + } +} diff --git a/lib/features/remote/providers/remote_providers.dart b/lib/features/remote/providers/remote_providers.dart new file mode 100644 index 0000000..181d1ed --- /dev/null +++ b/lib/features/remote/providers/remote_providers.dart @@ -0,0 +1,375 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/error_message.dart'; +import '../../../app/load_state.dart'; +import '../../../models/ignis_device.dart'; +import '../../../models/ignis_group.dart'; +import '../../../models/ignis_scene.dart'; +import '../../../services/api_client.dart'; +import '../../homes/providers/homes_providers.dart'; +import '../../shared/providers/core_providers.dart'; + +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; + + final Map _lockUntil = {}; + final Map _debounceTimers = {}; + + @override + List build() { + ref.onDispose(() { + _stopPolling(resetStatus: false); + for (final t in _debounceTimers.values) { + t.cancel(); + } + }); + return []; + } + + 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); + + void _setLock(String id) => + _lockUntil[id] = DateTime.now().add(const Duration(seconds: 5)); + + 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, + ]; + } + + 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); + } + }, + ); + } + + 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; + } + } + + void setBrightness(String id, int value) { + _debouncedControl( + id, + 'brightness', + 'яркость', + {'brightness': value}, + {'brightness': value}, + ); + } + + void setTemperature(String id, int value) { + _debouncedControl( + id, + 'temp', + 'температуру', + {'temp': value}, + {'temp': value}, + ); + } + + 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}, + ); + } + + 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; + } + } + + 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)); + } + } +} diff --git a/lib/features/schedules/providers/tasks_providers.dart b/lib/features/schedules/providers/tasks_providers.dart new file mode 100644 index 0000000..c8a2ba8 --- /dev/null +++ b/lib/features/schedules/providers/tasks_providers.dart @@ -0,0 +1,72 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/error_message.dart'; +import '../../../app/load_state.dart'; +import '../../../models/schedule_task.dart'; +import '../../shared/providers/core_providers.dart'; + +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(); + } + + 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(); + } +} diff --git a/lib/features/shared/providers/core_providers.dart b/lib/features/shared/providers/core_providers.dart new file mode 100644 index 0000000..3a38a53 --- /dev/null +++ b/lib/features/shared/providers/core_providers.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../services/api_client.dart'; +import '../../../services/settings_service.dart'; + +/// Синглтон сервиса настроек +final settingsServiceProvider = Provider((ref) => SettingsService()); + +/// API-клиент текущего дома. Конфигурация меняется через init(). +final apiProvider = Provider((ref) => IgnisApi()); diff --git a/lib/features/stats/providers/event_log_providers.dart b/lib/features/stats/providers/event_log_providers.dart new file mode 100644 index 0000000..974f636 --- /dev/null +++ b/lib/features/stats/providers/event_log_providers.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/error_message.dart'; +import '../../../app/load_state.dart'; +import '../../../models/event_log_item.dart'; +import '../../shared/providers/core_providers.dart'; + +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)); + } + } +} diff --git a/lib/features/stats/providers/stats_providers.dart b/lib/features/stats/providers/stats_providers.dart new file mode 100644 index 0000000..54179f2 --- /dev/null +++ b/lib/features/stats/providers/stats_providers.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/error_message.dart'; +import '../../../app/load_state.dart'; +import '../../../models/stats_summary.dart'; +import '../../shared/providers/core_providers.dart'; + +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)); + } + } +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 222d6f0..6d1ccde 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -1,921 +1,10 @@ -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); - } - - /// Выбрать дом как активный и сразу проверить auth-state. - /// Если `auth/me` падает, откатываемся к предыдущему дому и auth-state. - Future select(HomeConfig home) async { - final previousHome = state; - final previousAuthState = ref.read(authInfoProvider); - - try { - await switchTo(home); - await ref.read(authInfoProvider.notifier).load(failOnError: true); - } catch (error) { - await _restoreSelection(previousHome); - ref.read(authInfoProvider.notifier).restore(previousAuthState); - rethrow; - } - } - - Future clear() async { - await ref.read(settingsServiceProvider).setCurrentHomeId(null); - state = null; - } - - /// Инициализировать API-клиент текущим домом - Future _initApi(HomeConfig home) async { - final apiKey = await ref - .read(settingsServiceProvider) - .requireHomeApiKey(home.id); - ref.read(apiProvider).init(home.url, apiKey); - } - - Future _restoreSelection(HomeConfig? home) async { - if (home == null) { - await clear(); - return; - } - - final svc = ref.read(settingsServiceProvider); - await svc.setCurrentHomeId(home.id); - state = home; - await _initApi(home); - } -} - -// ─── Список домов ──────────────────────────────────────────── - -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 - LoadState build() => const LoadState.idle(null); - - Future load({bool failOnError = false}) async { - state = LoadState.loading(state.data); - try { - final api = ref.read(apiProvider); - final res = await api.getAuthMe(); - final authInfo = AuthInfo.fromApi(res.data); - state = LoadState.data(authInfo); - return authInfo; - } catch (e) { - state = LoadState.error(null, describeLoadError(e)); - if (failOnError) rethrow; - return null; - } - } - - void clear() => state = const LoadState.idle(null); - - void restore(LoadState restoredState) => state = restoredState; - - bool get isAdmin => state.data?.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()} км'; - } -} +export '../features/api_keys/providers/api_keys_providers.dart'; +export '../features/auth/providers/auth_providers.dart'; +export '../features/homes/geofence_task_sync.dart'; +export '../features/homes/providers/homes_providers.dart'; +export '../features/homes/providers/location_providers.dart'; +export '../features/remote/providers/remote_providers.dart'; +export '../features/schedules/providers/tasks_providers.dart'; +export '../features/shared/providers/core_providers.dart'; +export '../features/stats/providers/event_log_providers.dart'; +export '../features/stats/providers/stats_providers.dart';