refactor: split providers into feature modules

This commit is contained in:
Artem Kokos
2026-04-27 23:21:47 +07:00
parent eed04e9122
commit 872ddf9513
12 changed files with 925 additions and 924 deletions

View File

@@ -55,8 +55,16 @@ lib/
│ ├── credentials_storage.dart -- безопасное хранение ключей
│ ├── geofence_worker.dart -- фоновая логика геофенса
│ └── settings_service.dart -- хранение списка "домов"
├── features/
│ ├── api_keys/providers/ -- управление гостевыми API-ключами
│ ├── auth/providers/ -- auth/me и auth-state
│ ├── homes/ -- дома, геолокация, geofence sync
│ ├── remote/providers/ -- polling групп, устройства, сцены, control errors
│ ├── schedules/providers/ -- задачи расписания
│ ├── shared/providers/ -- базовые core providers
│ └── stats/providers/ -- статистика и лог событий
├── providers/
│ └── providers.dart -- Riverpod-провайдеры (god object, подлежит распилу)
│ └── providers.dart -- compatibility barrel для публичных provider-экспортов
├── screens/
│ ├── api_keys_screen.dart
│ ├── event_log_screen.dart
@@ -97,7 +105,7 @@ flutter analyze
flutter test
```
Текущий baseline зелёный: анализатор без issues, юнит-тесты на парсинг домена и состояния проходят штатно.
Текущий baseline зелёный: `flutter analyze`, `flutter test` и release APK сборка проходят штатно.
## Настройка
@@ -117,7 +125,7 @@ API-ключи хранятся отдельно от конфигурации
- Целевая платформа сейчас Android.
- Release APK пока подписывается debug-ключом из Flutter-шаблона.
- Архитектура всё ещё содержит крупный `providers.dart`, который подлежит разделению на feature-oriented модули в рамках грядущих рефакторингов.
- Геофенс всё ещё требует отдельной продуктовой и технической доводки: multi-home semantics, background permissions и retry/cooldown поведение пока не доведены до конца.
## Лицензия

View 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();
}
}

View 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;
}

View 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);
}
}

View 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();
}
}

View 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()} км';
}
}

View 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));
}
}
}

View 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();
}
}

View 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());

View 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));
}
}
}

View 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));
}
}
}

View File

@@ -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';