refactor: split providers into feature modules
This commit is contained in:
14
README.md
14
README.md
@@ -55,8 +55,16 @@ lib/
|
|||||||
│ ├── credentials_storage.dart -- безопасное хранение ключей
|
│ ├── credentials_storage.dart -- безопасное хранение ключей
|
||||||
│ ├── geofence_worker.dart -- фоновая логика геофенса
|
│ ├── geofence_worker.dart -- фоновая логика геофенса
|
||||||
│ └── settings_service.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/
|
||||||
│ └── providers.dart -- Riverpod-провайдеры (god object, подлежит распилу)
|
│ └── providers.dart -- compatibility barrel для публичных provider-экспортов
|
||||||
├── screens/
|
├── screens/
|
||||||
│ ├── api_keys_screen.dart
|
│ ├── api_keys_screen.dart
|
||||||
│ ├── event_log_screen.dart
|
│ ├── event_log_screen.dart
|
||||||
@@ -97,7 +105,7 @@ flutter analyze
|
|||||||
flutter test
|
flutter test
|
||||||
```
|
```
|
||||||
|
|
||||||
Текущий baseline зелёный: анализатор без issues, юнит-тесты на парсинг домена и состояния проходят штатно.
|
Текущий baseline зелёный: `flutter analyze`, `flutter test` и release APK сборка проходят штатно.
|
||||||
|
|
||||||
## Настройка
|
## Настройка
|
||||||
|
|
||||||
@@ -117,7 +125,7 @@ API-ключи хранятся отдельно от конфигурации
|
|||||||
|
|
||||||
- Целевая платформа сейчас Android.
|
- Целевая платформа сейчас Android.
|
||||||
- Release APK пока подписывается debug-ключом из Flutter-шаблона.
|
- Release APK пока подписывается debug-ключом из Flutter-шаблона.
|
||||||
- Архитектура всё ещё содержит крупный `providers.dart`, который подлежит разделению на feature-oriented модули в рамках грядущих рефакторингов.
|
- Геофенс всё ещё требует отдельной продуктовой и технической доводки: multi-home semantics, background permissions и retry/cooldown поведение пока не доведены до конца.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
52
lib/features/api_keys/providers/api_keys_providers.dart
Normal file
52
lib/features/api_keys/providers/api_keys_providers.dart
Normal file
@@ -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, LoadState<List<ApiKeyInfo>>>(
|
||||||
|
() => ApiKeysNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class ApiKeysNotifier extends Notifier<LoadState<List<ApiKeyInfo>>> {
|
||||||
|
@override
|
||||||
|
LoadState<List<ApiKeyInfo>> build() => const LoadState.idle(<ApiKeyInfo>[]);
|
||||||
|
|
||||||
|
Future<void> 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<String> 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<void> revoke(String key) async {
|
||||||
|
await ref.read(apiProvider).revokeApiKey(key);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> activate(String key) async {
|
||||||
|
await ref.read(apiProvider).activateApiKey(key);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
lib/features/auth/providers/auth_providers.dart
Normal file
37
lib/features/auth/providers/auth_providers.dart
Normal file
@@ -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, LoadState<AuthInfo?>>(
|
||||||
|
() => AuthInfoNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class AuthInfoNotifier extends Notifier<LoadState<AuthInfo?>> {
|
||||||
|
@override
|
||||||
|
LoadState<AuthInfo?> build() => const LoadState.idle(null);
|
||||||
|
|
||||||
|
Future<AuthInfo?> 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<AuthInfo?> restoredState) => state = restoredState;
|
||||||
|
|
||||||
|
bool get isAdmin => state.data?.isAdmin == true;
|
||||||
|
}
|
||||||
29
lib/features/homes/geofence_task_sync.dart
Normal file
29
lib/features/homes/geofence_task_sync.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
|
import '../../models/home_config.dart';
|
||||||
|
import '../../services/geofence_worker.dart';
|
||||||
|
|
||||||
|
/// Синхронизировать состояние фонового таска с настройками домов.
|
||||||
|
/// Вызывать при старте приложения и при изменении настроек.
|
||||||
|
///
|
||||||
|
/// Если хотя бы один дом имеет geofenceReady -- регистрируем
|
||||||
|
/// периодический таск. Иначе -- отменяем.
|
||||||
|
Future<void> syncGeofenceTask(List<HomeConfig> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
lib/features/homes/providers/homes_providers.dart
Normal file
101
lib/features/homes/providers/homes_providers.dart
Normal file
@@ -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, 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) {
|
||||||
|
await _initApi(state!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Переключиться на другой дом
|
||||||
|
Future<void> 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<void> 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<void> clear() async {
|
||||||
|
await ref.read(settingsServiceProvider).setCurrentHomeId(null);
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Инициализировать API-клиент текущим домом
|
||||||
|
Future<void> _initApi(HomeConfig home) async {
|
||||||
|
final apiKey = await ref
|
||||||
|
.read(settingsServiceProvider)
|
||||||
|
.requireHomeApiKey(home.id);
|
||||||
|
ref.read(apiProvider).init(home.url, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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, 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, {required String apiKey}) async {
|
||||||
|
await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> remove(String id) async {
|
||||||
|
await ref.read(settingsServiceProvider).deleteHome(id);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> update(HomeConfig home, {String? apiKey}) async {
|
||||||
|
await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}
|
||||||
169
lib/features/homes/providers/location_providers.dart
Normal file
169
lib/features/homes/providers/location_providers.dart
Normal file
@@ -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, UserLocation>(
|
||||||
|
() => UserLocationNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class UserLocationNotifier extends Notifier<UserLocation> {
|
||||||
|
StreamSubscription<Position>? _sub;
|
||||||
|
int _watchers = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
UserLocation build() {
|
||||||
|
ref.onDispose(() {
|
||||||
|
_sub?.cancel();
|
||||||
|
_sub = null;
|
||||||
|
});
|
||||||
|
return const UserLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запросить текущую позицию. Первый вызов проверяет пермишены
|
||||||
|
/// и отдаёт lastKnown мгновенно (если есть).
|
||||||
|
Future<void> 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<void> 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<String?> _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()} км';
|
||||||
|
}
|
||||||
|
}
|
||||||
375
lib/features/remote/providers/remote_providers.dart
Normal file
375
lib/features/remote/providers/remote_providers.dart
Normal file
@@ -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, List<IgnisGroup>>(
|
||||||
|
() => 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, GroupsLoadState>(
|
||||||
|
GroupsLoadStateNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class GroupsLoadStateNotifier extends Notifier<GroupsLoadState> {
|
||||||
|
@override
|
||||||
|
GroupsLoadState build() => const GroupsLoadState.idle();
|
||||||
|
|
||||||
|
void setIdle() => state = const GroupsLoadState.idle();
|
||||||
|
|
||||||
|
void setLoading() => state = const GroupsLoadState.loading();
|
||||||
|
|
||||||
|
void setData(List<IgnisGroup> groups) {
|
||||||
|
state = groups.isEmpty
|
||||||
|
? const GroupsLoadState.empty()
|
||||||
|
: const GroupsLoadState.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setError(Object error) =>
|
||||||
|
state = GroupsLoadState.error(describeLoadError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupsNotifier extends Notifier<List<IgnisGroup>> {
|
||||||
|
IgnisApi get _api => ref.read(apiProvider);
|
||||||
|
Timer? _timer;
|
||||||
|
bool _polling = false;
|
||||||
|
bool _refreshInFlight = false;
|
||||||
|
int? _refreshGeneration;
|
||||||
|
int _pollingGeneration = 0;
|
||||||
|
String? _pollingHomeId;
|
||||||
|
|
||||||
|
final Map<String, DateTime> _lockUntil = {};
|
||||||
|
final Map<String, Timer> _debounceTimers = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<IgnisGroup> build() {
|
||||||
|
ref.onDispose(() {
|
||||||
|
_stopPolling(resetStatus: false);
|
||||||
|
for (final t in _debounceTimers.values) {
|
||||||
|
t.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> 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<String, dynamic> 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<String, dynamic> localPatch,
|
||||||
|
Map<String, dynamic> 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<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);
|
||||||
|
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<void> 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<void> 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, GroupControlError?>(
|
||||||
|
() => GroupControlErrorNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class GroupControlErrorNotifier extends Notifier<GroupControlError?> {
|
||||||
|
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, LoadState<List<IgnisDevice>>>(
|
||||||
|
() => DevicesNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class DevicesNotifier extends Notifier<LoadState<List<IgnisDevice>>> {
|
||||||
|
@override
|
||||||
|
LoadState<List<IgnisDevice>> build() => const LoadState.idle(<IgnisDevice>[]);
|
||||||
|
|
||||||
|
Future<void> 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, LoadState<List<IgnisScene>>>(
|
||||||
|
() => ScenesNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class ScenesNotifier extends Notifier<LoadState<List<IgnisScene>>> {
|
||||||
|
@override
|
||||||
|
LoadState<List<IgnisScene>> build() => const LoadState.idle(<IgnisScene>[]);
|
||||||
|
|
||||||
|
Future<void> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
lib/features/schedules/providers/tasks_providers.dart
Normal file
72
lib/features/schedules/providers/tasks_providers.dart
Normal file
@@ -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, LoadState<List<ScheduleTask>>>(
|
||||||
|
() => TasksNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class TasksNotifier extends Notifier<LoadState<List<ScheduleTask>>> {
|
||||||
|
@override
|
||||||
|
LoadState<List<ScheduleTask>> build() =>
|
||||||
|
const LoadState.idle(<ScheduleTask>[]);
|
||||||
|
|
||||||
|
Future<void> 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<void> cancel(String jobId) async {
|
||||||
|
await ref.read(apiProvider).cancelTask(jobId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
lib/features/shared/providers/core_providers.dart
Normal file
10
lib/features/shared/providers/core_providers.dart
Normal file
@@ -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());
|
||||||
30
lib/features/stats/providers/event_log_providers.dart
Normal file
30
lib/features/stats/providers/event_log_providers.dart
Normal file
@@ -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, LoadState<List<EventLogItem>>>(
|
||||||
|
() => EventLogNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class EventLogNotifier extends Notifier<LoadState<List<EventLogItem>>> {
|
||||||
|
@override
|
||||||
|
LoadState<List<EventLogItem>> build() =>
|
||||||
|
const LoadState.idle(<EventLogItem>[]);
|
||||||
|
|
||||||
|
Future<void> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/features/stats/providers/stats_providers.dart
Normal file
29
lib/features/stats/providers/stats_providers.dart
Normal file
@@ -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, LoadState<StatsSummary>>(
|
||||||
|
() => StatsNotifier(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class StatsNotifier extends Notifier<LoadState<StatsSummary>> {
|
||||||
|
@override
|
||||||
|
LoadState<StatsSummary> build() => const LoadState.idle(StatsSummary.empty);
|
||||||
|
|
||||||
|
Future<void> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,921 +1,10 @@
|
|||||||
import 'dart:async';
|
export '../features/api_keys/providers/api_keys_providers.dart';
|
||||||
import 'dart:math' as math;
|
export '../features/auth/providers/auth_providers.dart';
|
||||||
import 'package:flutter/material.dart';
|
export '../features/homes/geofence_task_sync.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
export '../features/homes/providers/homes_providers.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
export '../features/homes/providers/location_providers.dart';
|
||||||
import '../app/error_message.dart';
|
export '../features/remote/providers/remote_providers.dart';
|
||||||
import '../app/load_state.dart';
|
export '../features/schedules/providers/tasks_providers.dart';
|
||||||
import '../models/api_key_info.dart';
|
export '../features/shared/providers/core_providers.dart';
|
||||||
import '../models/auth_info.dart';
|
export '../features/stats/providers/event_log_providers.dart';
|
||||||
import '../models/event_log_item.dart';
|
export '../features/stats/providers/stats_providers.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, 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) {
|
|
||||||
await _initApi(state!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Переключиться на другой дом
|
|
||||||
Future<void> 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<void> 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<void> clear() async {
|
|
||||||
await ref.read(settingsServiceProvider).setCurrentHomeId(null);
|
|
||||||
state = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Инициализировать API-клиент текущим домом
|
|
||||||
Future<void> _initApi(HomeConfig home) async {
|
|
||||||
final apiKey = await ref
|
|
||||||
.read(settingsServiceProvider)
|
|
||||||
.requireHomeApiKey(home.id);
|
|
||||||
ref.read(apiProvider).init(home.url, apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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, 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, {required String apiKey}) async {
|
|
||||||
await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> remove(String id) async {
|
|
||||||
await ref.read(settingsServiceProvider).deleteHome(id);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> 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, UserLocation>(
|
|
||||||
() => UserLocationNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class UserLocationNotifier extends Notifier<UserLocation> {
|
|
||||||
StreamSubscription<Position>? _sub;
|
|
||||||
int _watchers = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
UserLocation build() {
|
|
||||||
ref.onDispose(() {
|
|
||||||
_sub?.cancel();
|
|
||||||
_sub = null;
|
|
||||||
});
|
|
||||||
return const UserLocation();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Запросить текущую позицию. Первый вызов проверяет пермишены
|
|
||||||
/// и отдаёт lastKnown мгновенно (если есть).
|
|
||||||
Future<void> 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<void> 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<String?> _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, List<IgnisGroup>>(
|
|
||||||
() => 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, GroupsLoadState>(
|
|
||||||
GroupsLoadStateNotifier.new,
|
|
||||||
);
|
|
||||||
|
|
||||||
class GroupsLoadStateNotifier extends Notifier<GroupsLoadState> {
|
|
||||||
@override
|
|
||||||
GroupsLoadState build() => const GroupsLoadState.idle();
|
|
||||||
|
|
||||||
void setIdle() => state = const GroupsLoadState.idle();
|
|
||||||
|
|
||||||
void setLoading() => state = const GroupsLoadState.loading();
|
|
||||||
|
|
||||||
void setData(List<IgnisGroup> groups) {
|
|
||||||
state = groups.isEmpty
|
|
||||||
? const GroupsLoadState.empty()
|
|
||||||
: const GroupsLoadState.data();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setError(Object error) =>
|
|
||||||
state = GroupsLoadState.error(describeLoadError(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
class GroupsNotifier extends Notifier<List<IgnisGroup>> {
|
|
||||||
IgnisApi get _api => ref.read(apiProvider);
|
|
||||||
Timer? _timer;
|
|
||||||
bool _polling = false;
|
|
||||||
bool _refreshInFlight = false;
|
|
||||||
int? _refreshGeneration;
|
|
||||||
int _pollingGeneration = 0;
|
|
||||||
String? _pollingHomeId;
|
|
||||||
|
|
||||||
/// Блокировка обновления для группы после управления --
|
|
||||||
/// чтобы UI не прыгал пока лампа ещё не ответила.
|
|
||||||
final Map<String, DateTime> _lockUntil = {};
|
|
||||||
|
|
||||||
/// Debounce-таймеры для слайдеров (яркость, темп, цвет)
|
|
||||||
final Map<String, Timer> _debounceTimers = {};
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<IgnisGroup> build() {
|
|
||||||
ref.onDispose(() {
|
|
||||||
_stopPolling(resetStatus: false);
|
|
||||||
for (final t in _debounceTimers.values) {
|
|
||||||
t.cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Настроить API и начать периодический опрос для текущего дома.
|
|
||||||
Future<void> 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<void> 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<String, dynamic> 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<String, dynamic> localPatch,
|
|
||||||
Map<String, dynamic> 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<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);
|
|
||||||
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<void> 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<void> 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, GroupControlError?>(
|
|
||||||
() => GroupControlErrorNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class GroupControlErrorNotifier extends Notifier<GroupControlError?> {
|
|
||||||
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, LoadState<List<IgnisDevice>>>(
|
|
||||||
() => DevicesNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class DevicesNotifier extends Notifier<LoadState<List<IgnisDevice>>> {
|
|
||||||
@override
|
|
||||||
LoadState<List<IgnisDevice>> build() => const LoadState.idle(<IgnisDevice>[]);
|
|
||||||
|
|
||||||
/// Загрузить список устройств из текущего дома
|
|
||||||
Future<void> 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, LoadState<List<IgnisScene>>>(
|
|
||||||
() => ScenesNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class ScenesNotifier extends Notifier<LoadState<List<IgnisScene>>> {
|
|
||||||
@override
|
|
||||||
LoadState<List<IgnisScene>> build() => const LoadState.idle(<IgnisScene>[]);
|
|
||||||
|
|
||||||
Future<void> 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, LoadState<List<ScheduleTask>>>(
|
|
||||||
() => TasksNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class TasksNotifier extends Notifier<LoadState<List<ScheduleTask>>> {
|
|
||||||
@override
|
|
||||||
LoadState<List<ScheduleTask>> build() =>
|
|
||||||
const LoadState.idle(<ScheduleTask>[]);
|
|
||||||
|
|
||||||
Future<void> 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<void> cancel(String jobId) async {
|
|
||||||
await ref.read(apiProvider).cancelTask(jobId);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Создать одноразовый таймер
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Статистика ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
final statsProvider = NotifierProvider<StatsNotifier, LoadState<StatsSummary>>(
|
|
||||||
() => StatsNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class StatsNotifier extends Notifier<LoadState<StatsSummary>> {
|
|
||||||
@override
|
|
||||||
LoadState<StatsSummary> build() => const LoadState.idle(StatsSummary.empty);
|
|
||||||
|
|
||||||
Future<void> 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, LoadState<List<EventLogItem>>>(
|
|
||||||
() => EventLogNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class EventLogNotifier extends Notifier<LoadState<List<EventLogItem>>> {
|
|
||||||
@override
|
|
||||||
LoadState<List<EventLogItem>> build() =>
|
|
||||||
const LoadState.idle(<EventLogItem>[]);
|
|
||||||
|
|
||||||
Future<void> 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, LoadState<List<ApiKeyInfo>>>(
|
|
||||||
() => ApiKeysNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class ApiKeysNotifier extends Notifier<LoadState<List<ApiKeyInfo>>> {
|
|
||||||
@override
|
|
||||||
LoadState<List<ApiKeyInfo>> build() => const LoadState.idle(<ApiKeyInfo>[]);
|
|
||||||
|
|
||||||
Future<void> 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<String> 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<void> revoke(String key) async {
|
|
||||||
await ref.read(apiProvider).revokeApiKey(key);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> activate(String key) async {
|
|
||||||
await ref.read(apiProvider).activateApiKey(key);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Информация об авторизации ────────────────────────────────
|
|
||||||
|
|
||||||
final authInfoProvider =
|
|
||||||
NotifierProvider<AuthInfoNotifier, LoadState<AuthInfo?>>(
|
|
||||||
() => AuthInfoNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
class AuthInfoNotifier extends Notifier<LoadState<AuthInfo?>> {
|
|
||||||
@override
|
|
||||||
LoadState<AuthInfo?> build() => const LoadState.idle(null);
|
|
||||||
|
|
||||||
Future<AuthInfo?> 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<AuthInfo?> restoredState) => state = restoredState;
|
|
||||||
|
|
||||||
bool get isAdmin => state.data?.isAdmin == true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Геофенс: управление фоновым таском ─────────────────────
|
|
||||||
|
|
||||||
/// Синхронизировать состояние фонового таска с настройками домов.
|
|
||||||
/// Вызывать при старте приложения и при изменении настроек.
|
|
||||||
///
|
|
||||||
/// Если хотя бы один дом имеет geofenceReady -- регистрируем
|
|
||||||
/// периодический таск. Иначе -- отменяем.
|
|
||||||
Future<void> syncGeofenceTask(List<HomeConfig> 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()} км';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user