feat: harden geofence and distance diagnostics
This commit is contained in:
@@ -1,16 +1,15 @@
|
||||
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:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Порог расстояния для срабатывания геофенса (метры)
|
||||
const double geofenceThresholdMeters = 500.0;
|
||||
import '../features/homes/geofence_logic.dart';
|
||||
import '../features/homes/services/geofence_runtime_store.dart';
|
||||
|
||||
/// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать
|
||||
const String _firedKey = 'ignis_geofence_fired';
|
||||
const String _apiKeyPrefix = 'ignis_home_api_key_';
|
||||
|
||||
/// Имя задачи в workmanager
|
||||
@@ -29,70 +28,35 @@ const String _channelDesc = 'Уведомления об автовыключе
|
||||
/// Возвращает true если таск выполнен успешно (workmanager convention).
|
||||
Future<bool> executeGeofenceCheck() async {
|
||||
try {
|
||||
// 1. Проверяем, не сработал ли уже
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool(_firedKey) == true) {
|
||||
// Уже сработал -- ничего не делаем.
|
||||
// Таск будет отменён при следующем запуске приложения.
|
||||
final runtimeStore = GeofenceRuntimeStore();
|
||||
var runtime = await runtimeStore.load();
|
||||
final armedHomeId = runtime.armedHomeId;
|
||||
if (armedHomeId == null || armedHomeId.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Загружаем дома из SharedPreferences
|
||||
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 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;
|
||||
}
|
||||
final targetHome = _findArmedHome(homesList, armedHomeId);
|
||||
if (targetHome == null) {
|
||||
await runtimeStore.disarm();
|
||||
return true;
|
||||
}
|
||||
|
||||
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; // нет пермишена -- молча выходим
|
||||
if (!hasBackgroundLocationAccess(perm)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Position? pos;
|
||||
try {
|
||||
// Сначала lastKnown (мгновенно)
|
||||
pos = await Geolocator.getLastKnownPosition();
|
||||
final lastTimestamp = pos?.timestamp;
|
||||
final pos = await _getCurrentPosition();
|
||||
if (pos == null) return true;
|
||||
|
||||
// Если позиции нет или она несвежая -- запрашиваем новую
|
||||
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 now = DateTime.now();
|
||||
final homeLat = (targetHome['latitude'] as num).toDouble();
|
||||
final homeLon = (targetHome['longitude'] as num).toDouble();
|
||||
final distMeters = _haversineMeters(
|
||||
@@ -103,39 +67,82 @@ Future<bool> executeGeofenceCheck() async {
|
||||
);
|
||||
|
||||
if (distMeters <= geofenceThresholdMeters) {
|
||||
return true; // всё ещё рядом с домом
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 (_) {
|
||||
// Даже если не удалось выключить -- помечаем как сработавший,
|
||||
// чтобы не спамить запросами
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
runtime = runtime.recordFailure(
|
||||
armedHomeId,
|
||||
failedAt: now,
|
||||
distanceMeters: distMeters,
|
||||
message: 'Не найден API key для armed geofence дома.',
|
||||
);
|
||||
await runtimeStore.save(runtime);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 6. Помечаем как сработавший
|
||||
await prefs.setBool(_firedKey, true);
|
||||
final homeName = (targetHome['name'] ?? 'Дом') as String;
|
||||
|
||||
// 7. Показываем уведомление
|
||||
final distText = distMeters < 1000
|
||||
? '${distMeters.round()} м'
|
||||
: '${(distMeters / 1000).toStringAsFixed(1)} км';
|
||||
await _showNotification(
|
||||
title: 'Свет выключен',
|
||||
body:
|
||||
'$homeName -- вы ушли на $distText. '
|
||||
'Выключено групп: $groupCount.',
|
||||
);
|
||||
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 (e) {
|
||||
} catch (_) {
|
||||
// Любая ошибка -- не крашим воркер
|
||||
return true;
|
||||
}
|
||||
@@ -154,19 +161,6 @@ Future<String?> _getHomeApiKey(Map<String, dynamic> home) async {
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Уведомления ─────────────────────────────────────────────
|
||||
|
||||
/// Показать локальное уведомление из фонового изолята
|
||||
@@ -192,6 +186,15 @@ Future<void> _showNotification({
|
||||
|
||||
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,
|
||||
@@ -202,8 +205,50 @@ Future<void> _showNotification({
|
||||
|
||||
// ─── Внутренние хелперы ──────────────────────────────────────
|
||||
|
||||
/// Выключить все группы на сервере. Возвращает кол-во выключенных.
|
||||
Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
||||
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,
|
||||
@@ -214,16 +259,13 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
||||
);
|
||||
|
||||
try {
|
||||
// Получаем список групп
|
||||
final res = await dio.get('/devices/groups');
|
||||
List<String> groupIds = [];
|
||||
final groupIds = <String>[];
|
||||
|
||||
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);
|
||||
groupIds.add(entry.key.toString());
|
||||
}
|
||||
} else if (res.data is List) {
|
||||
for (final g in res.data) {
|
||||
@@ -233,7 +275,6 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
||||
}
|
||||
}
|
||||
|
||||
// Выключаем каждую группу
|
||||
int success = 0;
|
||||
await Future.wait(
|
||||
groupIds.map((id) async {
|
||||
@@ -249,12 +290,28 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
||||
}),
|
||||
);
|
||||
|
||||
return success;
|
||||
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();
|
||||
@@ -279,3 +336,13 @@ double _haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||
}
|
||||
|
||||
double _degToRad(double deg) => deg * (math.pi / 180);
|
||||
|
||||
class _TurnOffGroupsResult {
|
||||
final int totalGroups;
|
||||
final int successCount;
|
||||
|
||||
const _TurnOffGroupsResult({
|
||||
required this.totalGroups,
|
||||
required this.successCount,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user