Files
ignis_app/lib/providers/providers.dart
2026-04-23 20:24:08 +07:00

946 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/home_config.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);
}
/// Инициализировать API-клиент текущим домом
Future<void> _initApi(HomeConfig home) async {
final apiKey = await ref
.read(settingsServiceProvider)
.requireHomeApiKey(home.id);
ref.read(apiProvider).init(home.url, apiKey);
}
}
// ─── Список домов ────────────────────────────────────────────
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<dynamic>>(
() => 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<dynamic> groups) {
state = groups.isEmpty
? const GroupsLoadState.empty()
: const GroupsLoadState.data();
}
void setError(Object error) =>
state = GroupsLoadState.error(error.toString());
}
class GroupsNotifier extends Notifier<List<dynamic>> {
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<dynamic> 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)) 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;
if (_refreshInFlight && _refreshGeneration == generation) return;
_refreshInFlight = true;
_refreshGeneration = generation;
if (state.isEmpty) {
ref.read(groupsLoadStateProvider.notifier).setLoading();
}
try {
final resGroups = await _api.getGroups();
List<dynamic> rawList = [];
if (resGroups.data is Map) {
// Бэкенд возвращает {id: GroupModel, ...} -- values уже содержат id внутри
rawList = resGroups.data.values.toList();
} else if (resGroups.data is List) {
rawList = resGroups.data;
}
final now = DateTime.now();
// Параллельный опрос статусов всех групп
final updatedList = await Future.wait(
rawList.map((g) async {
final map = Map<String, dynamic>.from(g);
final id = map['id'].toString();
// Если группа залочена (недавно управляли) -- берём локальное состояние
if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) {
final existing = state.firstWhere(
(old) => old['id'].toString() == id,
orElse: () => null,
);
return existing ?? map;
}
try {
final resStatus = await _api.getGroupStatus(id);
// Формат ответа: { results: [ { status: { state, dimming, temp, ... } } ] }
if (resStatus.data != null &&
resStatus.data['results'] is List &&
resStatus.data['results'].isNotEmpty) {
// Берём первый результат без ошибки, или просто первый
final results = resStatus.data['results'] as List;
final validResult = results.firstWhere(
(r) => r['status'] != null && r['error'] == null,
orElse: () => results.first,
);
final data = validResult['status'];
if (data != null) {
map['last_state'] = {
'state': data['state'] == true,
'brightness': (data['dimming'] ?? 100).toInt(),
'temp': (data['temp'] ?? 4000).toInt(),
'r': (data['r'] ?? 0).toInt(),
'g': (data['g'] ?? 0).toInt(),
'b': (data['b'] ?? 0).toInt(),
'scene': data['scene'],
};
}
}
} catch (e) {
// При ошибке опроса -- сохраняем предыдущее состояние
final existing = state.firstWhere(
(s) => s['id'].toString() == id,
orElse: () => null,
);
map['last_state'] =
existing?['last_state'] ??
{'state': false, 'brightness': 100, 'temp': 4000};
}
return map;
}),
);
if (!_isActiveGeneration(generation)) return;
state = updatedList;
ref.read(groupsLoadStateProvider.notifier).setData(updatedList);
} catch (e) {
debugPrint("Ошибка глобального опроса: $e");
if (_isActiveGeneration(generation)) {
ref.read(groupsLoadStateProvider.notifier).setError(e);
}
} finally {
if (_refreshGeneration == generation) {
_refreshInFlight = false;
_refreshGeneration = null;
}
}
}
bool _isActiveGeneration(int generation) =>
generation == _pollingGeneration && _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'].toString() == id)
{
...g,
'last_state': {
...Map<String, dynamic>.from(g['last_state'] ?? {}),
...patch,
},
}
else
g,
];
}
/// Debounce: отправить API-запрос с задержкой, но UI обновить сразу.
/// Если значение меняется быстро (слайдер тянут), отправляется только
/// последнее значение после паузы.
void _debouncedControl(
String id,
String key,
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);
refresh();
}
},
);
}
/// Включить/выключить группу (без 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,
});
}
}
// ─── Устройства (для создания групп) ─────────────────────────
final devicesProvider =
NotifierProvider<DevicesNotifier, LoadState<List<dynamic>>>(
() => DevicesNotifier(),
);
class DevicesNotifier extends Notifier<LoadState<List<dynamic>>> {
@override
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
/// Загрузить список устройств из текущего дома
Future<void> load() async {
state = LoadState.loading(state.data);
try {
final api = ref.read(apiProvider);
final res = await api.getDevices();
final data = res.data;
late final List<dynamic> devices;
if (data is List) {
devices = List<dynamic>.from(data);
} else if (data is Map) {
final value = data['data'] ?? data['devices'] ?? data.values.toList();
if (value is! List) {
throw FormatException('devices должен быть списком устройств');
}
devices = List<dynamic>.from(value);
} else {
throw FormatException('devices должен быть списком устройств');
}
state = devices.isEmpty
? LoadState.empty(devices)
: LoadState.data(devices);
} catch (e) {
state = LoadState.error(state.data, describeLoadError(e));
}
}
}
// ─── Сцены ───────────────────────────────────────────────────
final scenesProvider =
NotifierProvider<ScenesNotifier, LoadState<List<dynamic>>>(
() => ScenesNotifier(),
);
class ScenesNotifier extends Notifier<LoadState<List<dynamic>>> {
@override
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
Future<void> load() async {
state = LoadState.loading(state.data);
try {
final api = ref.read(apiProvider);
final res = await api.getScenes();
final data = res.data;
late final List<dynamic> scenes;
if (data is List) {
scenes = List<dynamic>.from(data);
} else if (data is Map) {
// Бэкенд может вернуть {scene_id: "Scene Name", ...}
// или {data: [...]} или {scenes: [...]}
if (data.containsKey('data')) {
final value = data['data'];
if (value is! List) {
throw FormatException('scenes.data должен быть списком сцен');
}
scenes = List<dynamic>.from(value);
} else if (data.containsKey('scenes')) {
final value = data['scenes'];
if (value is! List) {
throw FormatException('scenes должен быть списком сцен');
}
scenes = List<dynamic>.from(value);
} else {
// Map вида {id: name} -- преобразуем в список
scenes = data.entries
.map((e) => {'id': e.key.toString(), 'name': e.value.toString()})
.toList();
}
} else {
throw FormatException('scenes должен быть списком сцен');
}
state = scenes.isEmpty ? LoadState.empty(scenes) : LoadState.data(scenes);
} catch (e) {
state = LoadState.error(state.data, describeLoadError(e));
}
}
}
// ─── Расписания ──────────────────────────────────────────────
final tasksProvider = NotifierProvider<TasksNotifier, LoadState<List<dynamic>>>(
() => TasksNotifier(),
);
class TasksNotifier extends Notifier<LoadState<List<dynamic>>> {
@override
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
Future<void> load() async {
state = LoadState.loading(state.data);
try {
final api = ref.read(apiProvider);
final res = await api.getTasks();
final data = res.data;
late final List<dynamic> tasks;
if (data is List) {
tasks = List<dynamic>.from(data);
} else if (data is Map) {
final value = data['tasks'] ?? data['data'] ?? data.values.toList();
if (value is! List) {
throw FormatException('tasks должен быть списком расписаний');
}
tasks = List<dynamic>.from(value);
} else {
throw FormatException('tasks должен быть списком расписаний');
}
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<Map<String, dynamic>>>(
() => StatsNotifier(),
);
class StatsNotifier extends Notifier<LoadState<Map<String, dynamic>>> {
@override
LoadState<Map<String, dynamic>> build() =>
const LoadState.idle(<String, dynamic>{});
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 data = res.data;
if (data is! Map) {
throw FormatException('stats summary должен быть объектом');
}
final stats = Map<String, dynamic>.from(data);
final groups = stats['groups'];
final hasGroups = groups is List && groups.isNotEmpty;
state = hasGroups ? LoadState.data(stats) : LoadState.empty(stats);
} catch (e) {
state = LoadState.error(state.data, describeLoadError(e));
}
}
}
// ─── Лог событий ─────────────────────────────────────────────
final eventLogProvider =
NotifierProvider<EventLogNotifier, LoadState<List<dynamic>>>(
() => EventLogNotifier(),
);
class EventLogNotifier extends Notifier<LoadState<List<dynamic>>> {
@override
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
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 data = res.data;
late final List<dynamic> events;
if (data is List) {
events = List<dynamic>.from(data);
} else if (data is Map) {
final value = data['data'] ?? data['events'] ?? data.values.toList();
if (value is! List) {
throw FormatException('stats log должен быть списком событий');
}
events = List<dynamic>.from(value);
} else {
throw FormatException('stats log должен быть списком событий');
}
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<dynamic>>>(
() => ApiKeysNotifier(),
);
class ApiKeysNotifier extends Notifier<LoadState<List<dynamic>>> {
@override
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
Future<void> load() async {
state = LoadState.loading(state.data);
try {
final api = ref.read(apiProvider);
final res = await api.getApiKeys();
final data = res.data;
late final List<dynamic> keys;
if (data is List) {
keys = List<dynamic>.from(data);
} else if (data is Map) {
final value = data['data'] ?? data['keys'] ?? data.values.toList();
if (value is! List) {
throw FormatException('api-keys должен быть списком ключей');
}
keys = List<dynamic>.from(value);
} else {
throw FormatException('api-keys должен быть списком ключей');
}
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, Map<String, dynamic>?>(
() => AuthInfoNotifier(),
);
class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
@override
Map<String, dynamic>? build() => null;
Future<void> load({bool failOnError = false}) async {
try {
final api = ref.read(apiProvider);
final res = await api.getAuthMe();
if (res.data is Map) {
state = Map<String, dynamic>.from(res.data);
}
} catch (e) {
debugPrint("Ошибка загрузки auth/me: $e");
if (failOnError) rethrow;
}
}
bool get isAdmin => state?['is_admin'] == 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()} км';
}
}