Replace geofence polling with native Android geofence

This commit is contained in:
Artem Kokos
2026-05-12 11:23:44 +07:00
parent 0a5ef9af17
commit 1963488479
38 changed files with 1099 additions and 1931 deletions

View File

@@ -1,348 +0,0 @@
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,
});
}