This commit is contained in:
Artem Kokos
2026-04-14 00:02:02 +07:00
parent 1d31767ee0
commit 8198ea09ae
9 changed files with 403 additions and 4 deletions

View File

@@ -0,0 +1,252 @@
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<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();
// Если старше 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<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);