Files
ignis_app/lib/services/geofence_worker.dart
2026-04-22 23:25:48 +07:00

282 lines
9.9 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:convert';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Порог расстояния для срабатывания геофенса (метры)
const double geofenceThresholdMeters = 500.0;
/// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать
const String _firedKey = 'ignis_geofence_fired';
const String _apiKeyPrefix = 'ignis_home_api_key_';
/// Имя задачи в workmanager
const String geofenceTaskName = 'ignis_geofence_check';
/// Уникальное имя для registerPeriodicTask
const String geofenceTaskUniqueName = 'ignis_geofence_periodic';
/// ID notification channel (должен совпадать с AndroidManifest)
const String _channelId = 'ignis_geofence';
const String _channelName = 'Геофенс';
const String _channelDesc = 'Уведомления об автовыключении света';
/// Основная логика фонового таска.
/// Вызывается из workmanager callback (в отдельном изоляте).
/// Возвращает true если таск выполнен успешно (workmanager convention).
Future<bool> executeGeofenceCheck() async {
try {
// 1. Проверяем, не сработал ли уже
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_firedKey) == true) {
// Уже сработал -- ничего не делаем.
// Таск будет отменён при следующем запуске приложения.
return true;
}
// 2. Загружаем дома из SharedPreferences
final raw = prefs.getString('ignis_homes');
if (raw == null || raw.isEmpty) return true;
final List<dynamic> homesList = jsonDecode(raw);
final currentHomeId = prefs.getString('ignis_current_home_id');
// Ищем текущий дом с включённым геофенсом
Map<String, dynamic>? targetHome;
for (final h in homesList) {
final map = h as Map<String, dynamic>;
final isTarget = (currentHomeId != null)
? map['id'] == currentHomeId
: true; // если нет текущего -- берём первый подходящий
if (isTarget &&
map['geofenceEnabled'] == true &&
map['latitude'] != null &&
map['longitude'] != null) {
targetHome = map;
break;
}
}
if (targetHome == null) return true; // нет дома с геофенсом
// 3. Получаем текущую позицию
if (!await Geolocator.isLocationServiceEnabled()) return true;
final perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) {
return true; // нет пермишена -- молча выходим
}
Position? pos;
try {
// Сначала lastKnown (мгновенно)
pos = await Geolocator.getLastKnownPosition();
final lastTimestamp = pos?.timestamp;
// Если позиции нет или она несвежая -- запрашиваем новую
if (pos == null ||
lastTimestamp == null ||
DateTime.now().difference(lastTimestamp).inMinutes > 5) {
pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 15),
),
);
}
} catch (_) {
return true; // не удалось получить позицию
}
// 4. Считаем расстояние
final homeLat = (targetHome['latitude'] as num).toDouble();
final homeLon = (targetHome['longitude'] as num).toDouble();
final distMeters = _haversineMeters(
pos.latitude,
pos.longitude,
homeLat,
homeLon,
);
if (distMeters <= geofenceThresholdMeters) {
return true; // всё ещё рядом с домом
}
// 5. Ушли за порог -- выключаем все группы
final url = _normalizeUrl(targetHome['url'] as String);
final apiKey = await _getHomeApiKey(targetHome);
if (apiKey == null || apiKey.isEmpty) return true;
final homeName = (targetHome['name'] ?? 'Дом') as String;
int groupCount = 0;
try {
groupCount = await _turnOffAllGroups(url, apiKey);
} catch (_) {
// Даже если не удалось выключить -- помечаем как сработавший,
// чтобы не спамить запросами
}
// 6. Помечаем как сработавший
await prefs.setBool(_firedKey, true);
// 7. Показываем уведомление
final distText = distMeters < 1000
? '${distMeters.round()} м'
: '${(distMeters / 1000).toStringAsFixed(1)} км';
await _showNotification(
title: 'Свет выключен',
body:
'$homeName -- вы ушли на $distText. '
'Выключено групп: $groupCount.',
);
return true;
} catch (e) {
// Любая ошибка -- не крашим воркер
return true;
}
}
Future<String?> _getHomeApiKey(Map<String, dynamic> home) async {
final id = home['id']?.toString();
if (id == null || id.isEmpty) return null;
const secureStorage = FlutterSecureStorage();
final secureKey = await secureStorage.read(key: '$_apiKeyPrefix$id');
if (secureKey != null && secureKey.isNotEmpty) return secureKey;
// Backward compatibility: if the app has not run after migration yet,
// old background tasks can still read the legacy key once.
return home['apiKey']?.toString();
}
/// Сбросить флаг "сработал" -- вызывать при включении геофенса
/// или при возврате в приложение.
Future<void> resetGeofenceFired() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_firedKey);
}
/// Проверить, сработал ли геофенс
Future<bool> isGeofenceFired() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_firedKey) == true;
}
// ─── Уведомления ─────────────────────────────────────────────
/// Показать локальное уведомление из фонового изолята
Future<void> _showNotification({
required String title,
required String body,
}) async {
final plugin = FlutterLocalNotificationsPlugin();
// Инициализация (в изоляте нужна заново)
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidSettings);
await plugin.initialize(initSettings);
const androidDetails = AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDesc,
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const details = NotificationDetails(android: androidDetails);
await plugin.show(
42, // фиксированный id -- перезаписывает предыдущее уведомление
title,
body,
details,
);
}
// ─── Внутренние хелперы ──────────────────────────────────────
/// Выключить все группы на сервере. Возвращает кол-во выключенных.
Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
headers: {'X-API-Key': apiKey},
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
),
);
try {
// Получаем список групп
final res = await dio.get('/devices/groups');
List<String> groupIds = [];
if (res.data is Map) {
// {id: {...}, ...} -- ключи и есть id
final map = res.data as Map;
for (final entry in map.entries) {
final id = entry.key.toString();
groupIds.add(id);
}
} else if (res.data is List) {
for (final g in res.data) {
if (g is Map && g['id'] != null) {
groupIds.add(g['id'].toString());
}
}
}
// Выключаем каждую группу
int success = 0;
await Future.wait(
groupIds.map((id) async {
try {
await dio.post(
'/control/group/$id',
queryParameters: {'state': false},
);
success++;
} catch (_) {
// Одна группа упала -- не останавливаем остальные
}
}),
);
return success;
} finally {
dio.close();
}
}
/// Нормализация URL
String _normalizeUrl(String url) {
var u = url.trim();
if (!u.startsWith('http')) u = 'https://$u';
if (u.endsWith('/')) u = u.substring(0, u.length - 1);
return u;
}
/// Расстояние в метрах (Haversine)
double _haversineMeters(double lat1, double lon1, double lat2, double lon2) {
const earthRadiusM = 6371000.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 earthRadiusM * c;
}
double _degToRad(double deg) => deg * (math.pi / 180);