749 lines
25 KiB
Dart
749 lines
25 KiB
Dart
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 '../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-клиент -- пересоздаётся при смене дома
|
||
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) {
|
||
_initApi(state!);
|
||
}
|
||
}
|
||
|
||
/// Переключиться на другой дом
|
||
Future<void> switchTo(HomeConfig home) async {
|
||
final svc = ref.read(settingsServiceProvider);
|
||
await svc.setCurrentHomeId(home.id);
|
||
state = home;
|
||
_initApi(home);
|
||
// Перезагрузить группы для нового дома
|
||
await ref.read(groupsProvider.notifier).initAndRefresh();
|
||
}
|
||
|
||
/// Инициализировать API-клиент текущим домом
|
||
void _initApi(HomeConfig home) {
|
||
ref.read(apiProvider).init(home.url, home.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) async {
|
||
await ref.read(settingsServiceProvider).upsertHome(home);
|
||
await load();
|
||
}
|
||
|
||
Future<void> remove(String id) async {
|
||
await ref.read(settingsServiceProvider).deleteHome(id);
|
||
await load();
|
||
}
|
||
|
||
Future<void> update(HomeConfig home) async {
|
||
await ref.read(settingsServiceProvider).upsertHome(home);
|
||
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());
|
||
|
||
class GroupsNotifier extends Notifier<List<dynamic>> {
|
||
IgnisApi get _api => ref.read(apiProvider);
|
||
Timer? _timer;
|
||
|
||
/// Блокировка обновления для группы после управления --
|
||
/// чтобы UI не прыгал пока лампа ещё не ответила.
|
||
final Map<String, DateTime> _lockUntil = {};
|
||
|
||
/// Debounce-таймеры для слайдеров (яркость, темп, цвет)
|
||
final Map<String, Timer> _debounceTimers = {};
|
||
|
||
@override
|
||
List<dynamic> build() {
|
||
ref.onDispose(() {
|
||
_timer?.cancel();
|
||
for (final t in _debounceTimers.values) {
|
||
t.cancel();
|
||
}
|
||
});
|
||
return [];
|
||
}
|
||
|
||
/// Инициализация: настроить API и начать периодический опрос
|
||
Future<void> initAndRefresh() async {
|
||
final home = ref.read(currentHomeProvider);
|
||
if (home == null) return;
|
||
_api.init(home.url, home.apiKey);
|
||
await refresh();
|
||
_timer?.cancel();
|
||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
|
||
}
|
||
|
||
/// Полный опрос: загрузить группы + статус каждой
|
||
Future<void> refresh() async {
|
||
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;
|
||
}));
|
||
|
||
state = updatedList;
|
||
} catch (e) {
|
||
debugPrint("Ошибка глобального опроса: $e");
|
||
}
|
||
}
|
||
|
||
/// Установить блокировку на 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);
|
||
refresh();
|
||
}
|
||
}
|
||
|
||
/// Установить яркость (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);
|
||
refresh();
|
||
}
|
||
}
|
||
|
||
/// Таймер: включить на 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, List<dynamic>>(() => DevicesNotifier());
|
||
|
||
class DevicesNotifier extends Notifier<List<dynamic>> {
|
||
@override
|
||
List<dynamic> build() => [];
|
||
|
||
/// Загрузить список устройств из текущего дома
|
||
Future<void> load() async {
|
||
try {
|
||
final api = ref.read(apiProvider);
|
||
final res = await api.getDevices();
|
||
if (res.data is List) {
|
||
state = res.data;
|
||
} else if (res.data is Map) {
|
||
state = res.data['data'] ?? res.data['devices'] ?? res.data.values.toList();
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка загрузки устройств: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Сцены ───────────────────────────────────────────────────
|
||
|
||
final scenesProvider =
|
||
NotifierProvider<ScenesNotifier, List<dynamic>>(() => ScenesNotifier());
|
||
|
||
class ScenesNotifier extends Notifier<List<dynamic>> {
|
||
@override
|
||
List<dynamic> build() => [];
|
||
|
||
Future<void> load() async {
|
||
try {
|
||
final api = ref.read(apiProvider);
|
||
final res = await api.getScenes();
|
||
final data = res.data;
|
||
if (data is List) {
|
||
state = data;
|
||
} else if (data is Map) {
|
||
// Бэкенд может вернуть {scene_id: "Scene Name", ...}
|
||
// или {data: [...]} или {scenes: [...]}
|
||
if (data.containsKey('data') && data['data'] is List) {
|
||
state = data['data'];
|
||
} else if (data.containsKey('scenes') && data['scenes'] is List) {
|
||
state = data['scenes'];
|
||
} else {
|
||
// Map вида {id: name} -- преобразуем в список
|
||
state = data.entries
|
||
.map((e) => {'id': e.key.toString(), 'name': e.value.toString()})
|
||
.toList();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка загрузки сцен: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Расписания ──────────────────────────────────────────────
|
||
|
||
final tasksProvider =
|
||
NotifierProvider<TasksNotifier, List<dynamic>>(() => TasksNotifier());
|
||
|
||
class TasksNotifier extends Notifier<List<dynamic>> {
|
||
@override
|
||
List<dynamic> build() => [];
|
||
|
||
Future<void> load() async {
|
||
try {
|
||
final api = ref.read(apiProvider);
|
||
final res = await api.getTasks();
|
||
final data = res.data;
|
||
if (data is List) {
|
||
state = data;
|
||
} else if (data is Map) {
|
||
state = data['tasks'] ?? data['data'] ?? data.values.toList();
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка загрузки расписаний: $e");
|
||
}
|
||
}
|
||
|
||
Future<void> cancel(String jobId) async {
|
||
try {
|
||
await ref.read(apiProvider).cancelTask(jobId);
|
||
await load();
|
||
} catch (e) {
|
||
debugPrint("Ошибка отмены задачи: $e");
|
||
}
|
||
}
|
||
|
||
/// Создать одноразовый таймер
|
||
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, Map<String, dynamic>>(() => StatsNotifier());
|
||
|
||
class StatsNotifier extends Notifier<Map<String, dynamic>> {
|
||
@override
|
||
Map<String, dynamic> build() => {};
|
||
|
||
Future<void> load({int days = 7}) async {
|
||
try {
|
||
final api = ref.read(apiProvider);
|
||
final res = await api.getStatsSummary(days: days);
|
||
final data = res.data;
|
||
if (data is Map) {
|
||
state = Map<String, dynamic>.from(data);
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка загрузки статистики: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Лог событий ─────────────────────────────────────────────
|
||
|
||
final eventLogProvider =
|
||
NotifierProvider<EventLogNotifier, List<dynamic>>(() => EventLogNotifier());
|
||
|
||
class EventLogNotifier extends Notifier<List<dynamic>> {
|
||
@override
|
||
List<dynamic> build() => [];
|
||
|
||
Future<void> load({int limit = 100}) async {
|
||
try {
|
||
final api = ref.read(apiProvider);
|
||
final res = await api.getStatsLog(limit: limit);
|
||
final data = res.data;
|
||
if (data is List) {
|
||
state = data;
|
||
} else if (data is Map) {
|
||
state = data['data'] ?? data['events'] ?? data.values.toList();
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка загрузки логов: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── API-ключи ───────────────────────────────────────────────
|
||
|
||
final apiKeysProvider =
|
||
NotifierProvider<ApiKeysNotifier, List<dynamic>>(() => ApiKeysNotifier());
|
||
|
||
class ApiKeysNotifier extends Notifier<List<dynamic>> {
|
||
@override
|
||
List<dynamic> build() => [];
|
||
|
||
Future<void> load() async {
|
||
try {
|
||
final api = ref.read(apiProvider);
|
||
final res = await api.getApiKeys();
|
||
final data = res.data;
|
||
if (data is List) {
|
||
state = data;
|
||
} else if (data is Map) {
|
||
state = data['data'] ?? data['keys'] ?? data.values.toList();
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка загрузки API-ключей: $e");
|
||
}
|
||
}
|
||
|
||
Future<String?> create(String name, {bool isAdmin = false}) async {
|
||
try {
|
||
final api = ref.read(apiProvider);
|
||
final res = await api.createApiKey(name, isAdmin: isAdmin);
|
||
await load();
|
||
if (res.data is Map) {
|
||
return res.data['key']?.toString();
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
debugPrint("Ошибка создания ключа: $e");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> revoke(String key) async {
|
||
try {
|
||
await ref.read(apiProvider).revokeApiKey(key);
|
||
await load();
|
||
} catch (e) {
|
||
debugPrint("Ошибка отзыва ключа: $e");
|
||
}
|
||
}
|
||
|
||
Future<void> activate(String key) async {
|
||
try {
|
||
await ref.read(apiProvider).activateApiKey(key);
|
||
await load();
|
||
} catch (e) {
|
||
debugPrint("Ошибка активации ключа: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Информация об авторизации ────────────────────────────────
|
||
|
||
final authInfoProvider =
|
||
NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(() => AuthInfoNotifier());
|
||
|
||
class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
|
||
@override
|
||
Map<String, dynamic>? build() => null;
|
||
|
||
Future<void> load() 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");
|
||
}
|
||
}
|
||
|
||
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()} км';
|
||
}
|
||
}
|