import 'dart:convert'; import 'dart:math' as math; import 'package:dio/dio.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'; /// Имя задачи в 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 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 homesList = jsonDecode(raw); final currentHomeId = prefs.getString('ignis_current_home_id'); // Ищем текущий дом с включённым геофенсом Map? targetHome; for (final h in homesList) { final map = h as Map; 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(); // Если старше 5 минут -- запрашиваем свежую if (pos == null || DateTime.now().difference(pos.timestamp).inMinutes > 5) { pos = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.low, timeLimit: Duration(seconds: 15), ), ); } } catch (_) { return true; // не удалось получить позицию } if (pos == null) 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 = targetHome['apiKey'] as String; 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 resetGeofenceFired() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_firedKey); } /// Проверить, сработал ли геофенс Future isGeofenceFired() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_firedKey) == true; } // ─── Уведомления ───────────────────────────────────────────── /// Показать локальное уведомление из фонового изолята Future _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 _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 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);