import 'dart:convert'; import 'dart:math' as math; import 'package:dio/dio.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../features/homes/geofence_logic.dart'; import '../features/homes/services/geofence_runtime_store.dart'; 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 executeGeofenceCheck() async { try { final runtimeStore = GeofenceRuntimeStore(); var runtime = await runtimeStore.load(); final armedHomeId = runtime.armedHomeId; if (armedHomeId == null || armedHomeId.isEmpty) { return true; } final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('ignis_homes'); if (raw == null || raw.isEmpty) return true; final List homesList = jsonDecode(raw); final targetHome = _findArmedHome(homesList, armedHomeId); if (targetHome == null) { await runtimeStore.disarm(); return true; } if (!await Geolocator.isLocationServiceEnabled()) return true; final perm = await Geolocator.checkPermission(); if (!hasBackgroundLocationAccess(perm)) { return true; } final pos = await _getCurrentPosition(); if (pos == null) return true; final now = DateTime.now(); 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) { runtime = runtime.recordInsideHome( armedHomeId, checkedAt: now, distanceMeters: distMeters, ); await runtimeStore.save(runtime); return true; } runtime = runtime.recordOutsideCheck( armedHomeId, checkedAt: now, distanceMeters: distMeters, ); await runtimeStore.save(runtime); if (runtime.isTriggeredFor(armedHomeId)) { return true; } final retryRemaining = geofenceRetryRemaining( runtime.failureAtFor(armedHomeId), now: now, ); if (retryRemaining != null) { return true; } final url = _normalizeUrl(targetHome['url'] as String); final apiKey = await _getHomeApiKey(targetHome); if (apiKey == null || apiKey.isEmpty) { runtime = runtime.recordFailure( armedHomeId, failedAt: now, distanceMeters: distMeters, message: 'Не найден API key для armed geofence дома.', ); await runtimeStore.save(runtime); return true; } final homeName = (targetHome['name'] ?? 'Дом') as String; try { final result = await _turnOffAllGroups(url, apiKey); if (result.totalGroups > 0 && result.successCount < result.totalGroups) { throw StateError( 'Выключено только ${result.successCount} из ${result.totalGroups} групп.', ); } runtime = runtime.recordSuccess( armedHomeId, triggeredAt: now, distanceMeters: distMeters, ); await runtimeStore.save(runtime); await _showNotification( title: 'Свет выключен', body: '$homeName -- вы ушли на ${formatDistanceMeters(distMeters)}. ' 'Выключено групп: ${result.successCount}.', ); } catch (error) { runtime = runtime.recordFailure( armedHomeId, failedAt: now, distanceMeters: distMeters, message: _describeFailure(error), ); await runtimeStore.save(runtime); } return true; } catch (_) { // Любая ошибка -- не крашим воркер return true; } } Future _getHomeApiKey(Map 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 _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); final android = plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); final notificationsEnabled = await android?.areNotificationsEnabled() ?? true; if (!notificationsEnabled) { return; } await plugin.show( 42, // фиксированный id -- перезаписывает предыдущее уведомление title, body, details, ); } // ─── Внутренние хелперы ────────────────────────────────────── Map? _findArmedHome( List homesList, String armedHomeId, ) { for (final item in homesList) { if (item is! Map) continue; final map = Map.from(item); if (map['id'] == armedHomeId && map['geofenceEnabled'] == true && map['latitude'] != null && map['longitude'] != null) { return map; } } return null; } Future _getCurrentPosition() async { try { var 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), ), ); } return pos; } catch (_) { return null; } } /// Выключить все группы на сервере. Future<_TurnOffGroupsResult> _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'); final groupIds = []; if (res.data is Map) { final map = res.data as Map; for (final entry in map.entries) { groupIds.add(entry.key.toString()); } } 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 _TurnOffGroupsResult( totalGroups: groupIds.length, successCount: success, ); } finally { dio.close(); } } String _describeFailure(Object error) { if (error is DioException) { final statusCode = error.response?.statusCode; final detail = error.response?.data; if (statusCode != null) { return 'Backend ответил ошибкой $statusCode${detail != null ? ': $detail' : ''}'; } return 'Сетевой запрос сломался: ${error.message ?? error.type.name}'; } return error.toString(); } /// Нормализация 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); class _TurnOffGroupsResult { final int totalGroups; final int successCount; const _TurnOffGroupsResult({ required this.totalGroups, required this.successCount, }); }