refactor: split providers into feature modules
This commit is contained in:
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';
|
||||
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, 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()} км';
|
||||
}
|
||||
}
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user