282 lines
9.9 KiB
Dart
282 lines
9.9 KiB
Dart
import 'dart:convert';
|
||
import 'dart:math' as math;
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter_secure_storage/flutter_secure_storage.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';
|
||
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 {
|
||
// 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();
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
} catch (_) {
|
||
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 = await _getHomeApiKey(targetHome);
|
||
if (apiKey == null || apiKey.isEmpty) return true;
|
||
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<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> 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);
|