Files
ignis_app/lib/services/geofence_worker.dart
2026-05-01 09:13:23 +07:00

349 lines
10 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_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<bool> 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<dynamic> 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<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> _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<String, dynamic>? _findArmedHome(
List<dynamic> homesList,
String armedHomeId,
) {
for (final item in homesList) {
if (item is! Map) continue;
final map = Map<String, dynamic>.from(item);
if (map['id'] == armedHomeId &&
map['geofenceEnabled'] == true &&
map['latitude'] != null &&
map['longitude'] != null) {
return map;
}
}
return null;
}
Future<Position?> _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 = <String>[];
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,
});
}