feat: harden geofence and distance diagnostics
This commit is contained in:
123
lib/features/homes/models/geofence_diagnostics.dart
Normal file
123
lib/features/homes/models/geofence_diagnostics.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../../../models/home_config.dart';
|
||||
import '../geofence_logic.dart';
|
||||
import 'geofence_runtime_state.dart';
|
||||
|
||||
enum GeofenceStatusKind {
|
||||
noActiveHome,
|
||||
disabled,
|
||||
missingCoordinates,
|
||||
locationServicesDisabled,
|
||||
locationPermissionDenied,
|
||||
backgroundPermissionDenied,
|
||||
notificationsPermissionDenied,
|
||||
cooldown,
|
||||
triggered,
|
||||
ready,
|
||||
}
|
||||
|
||||
class GeofenceDiagnostics {
|
||||
final HomeConfig? activeHome;
|
||||
final GeofenceStatusKind status;
|
||||
final GeofenceRuntimeState runtime;
|
||||
final bool locationServicesEnabled;
|
||||
final LocationPermission locationPermission;
|
||||
final bool notificationsEnabled;
|
||||
final Duration? retryRemaining;
|
||||
final String? detail;
|
||||
|
||||
const GeofenceDiagnostics({
|
||||
required this.activeHome,
|
||||
required this.status,
|
||||
required this.runtime,
|
||||
required this.locationServicesEnabled,
|
||||
required this.locationPermission,
|
||||
required this.notificationsEnabled,
|
||||
this.retryRemaining,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
const GeofenceDiagnostics.initial()
|
||||
: activeHome = null,
|
||||
status = GeofenceStatusKind.noActiveHome,
|
||||
runtime = const GeofenceRuntimeState(),
|
||||
locationServicesEnabled = true,
|
||||
locationPermission = LocationPermission.denied,
|
||||
notificationsEnabled = true,
|
||||
retryRemaining = null,
|
||||
detail = null;
|
||||
|
||||
String get title {
|
||||
return switch (status) {
|
||||
GeofenceStatusKind.noActiveHome => 'Геофенс не активен',
|
||||
GeofenceStatusKind.disabled => 'Автовыключение выключено',
|
||||
GeofenceStatusKind.missingCoordinates => 'Нет координат дома',
|
||||
GeofenceStatusKind.locationServicesDisabled => 'Геолокация выключена',
|
||||
GeofenceStatusKind.locationPermissionDenied => 'Нет доступа к геолокации',
|
||||
GeofenceStatusKind.backgroundPermissionDenied =>
|
||||
'Нет фонового доступа к геолокации',
|
||||
GeofenceStatusKind.notificationsPermissionDenied =>
|
||||
'Нет доступа к уведомлениям',
|
||||
GeofenceStatusKind.cooldown => 'Повтор отложен',
|
||||
GeofenceStatusKind.triggered => 'Геофенс уже сработал',
|
||||
GeofenceStatusKind.ready => 'Геофенс активен',
|
||||
};
|
||||
}
|
||||
|
||||
String get message {
|
||||
final homeName = activeHome?.name ?? 'активного дома';
|
||||
return switch (status) {
|
||||
GeofenceStatusKind.noActiveHome =>
|
||||
'Сначала выберите активный дом. Без этого фоновой автоматике нечего отслеживать.',
|
||||
GeofenceStatusKind.disabled =>
|
||||
'Для дома "$homeName" автовыключение пока отключено.',
|
||||
GeofenceStatusKind.missingCoordinates =>
|
||||
'У дома "$homeName" не заданы координаты. Без них расстояние считать не из чего.',
|
||||
GeofenceStatusKind.locationServicesDisabled =>
|
||||
'На устройстве выключена геолокация. И карта расстояний, и фоновый geofence сейчас слепые как кроты.',
|
||||
GeofenceStatusKind.locationPermissionDenied =>
|
||||
'Приложению не дали доступ к геолокации. Разрешите доступ хотя бы во время использования.',
|
||||
GeofenceStatusKind.backgroundPermissionDenied =>
|
||||
'Для фонового geofence нужен доступ к локации "Всегда". Иначе в фоне Android эту магию задушит.',
|
||||
GeofenceStatusKind.notificationsPermissionDenied =>
|
||||
'Свет выключить мы ещё попробуем, но честно отчитаться пользователю не сможем, пока уведомления запрещены.',
|
||||
GeofenceStatusKind.cooldown =>
|
||||
'Последняя попытка выключить свет сорвалась. Повторим позже, чтобы не долбить backend без остановки.',
|
||||
GeofenceStatusKind.triggered =>
|
||||
'Свет уже был автоматически выключен. Повторно бахать не будем, пока вы не вернётесь в домашний радиус.',
|
||||
GeofenceStatusKind.ready =>
|
||||
'Фоновая автоматика готова следить за домом "$homeName" и выключить свет после ухода.',
|
||||
};
|
||||
}
|
||||
|
||||
String? get secondaryMessage {
|
||||
if (status == GeofenceStatusKind.cooldown && retryRemaining != null) {
|
||||
return 'Повтор через ${formatGeofenceRetry(retryRemaining!)}.';
|
||||
}
|
||||
|
||||
if (status == GeofenceStatusKind.triggered &&
|
||||
runtime.lastSuccessAt != null &&
|
||||
runtime.lastSuccessHomeId == activeHome?.id) {
|
||||
return 'Последнее успешное срабатывание уже зафиксировано.';
|
||||
}
|
||||
|
||||
if (detail != null && detail!.trim().isNotEmpty) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get canRequestLocation =>
|
||||
status == GeofenceStatusKind.locationPermissionDenied;
|
||||
|
||||
bool get canRequestBackgroundLocation =>
|
||||
status == GeofenceStatusKind.backgroundPermissionDenied;
|
||||
|
||||
bool get canOpenLocationSettings =>
|
||||
status == GeofenceStatusKind.locationServicesDisabled;
|
||||
|
||||
bool get canRequestNotifications =>
|
||||
status == GeofenceStatusKind.notificationsPermissionDenied;
|
||||
}
|
||||
193
lib/features/homes/models/geofence_runtime_state.dart
Normal file
193
lib/features/homes/models/geofence_runtime_state.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
class GeofenceRuntimeState {
|
||||
final String? armedHomeId;
|
||||
final DateTime? lastCheckAt;
|
||||
final double? lastDistanceMeters;
|
||||
final String? triggeredHomeId;
|
||||
final DateTime? triggeredAt;
|
||||
final String? lastSuccessHomeId;
|
||||
final DateTime? lastSuccessAt;
|
||||
final String? lastFailureHomeId;
|
||||
final DateTime? lastFailureAt;
|
||||
final String? lastFailureMessage;
|
||||
|
||||
const GeofenceRuntimeState({
|
||||
this.armedHomeId,
|
||||
this.lastCheckAt,
|
||||
this.lastDistanceMeters,
|
||||
this.triggeredHomeId,
|
||||
this.triggeredAt,
|
||||
this.lastSuccessHomeId,
|
||||
this.lastSuccessAt,
|
||||
this.lastFailureHomeId,
|
||||
this.lastFailureAt,
|
||||
this.lastFailureMessage,
|
||||
});
|
||||
|
||||
bool isTriggeredFor(String homeId) =>
|
||||
triggeredHomeId == homeId && triggeredAt != null;
|
||||
|
||||
DateTime? failureAtFor(String homeId) {
|
||||
if (lastFailureHomeId != homeId) return null;
|
||||
return lastFailureAt;
|
||||
}
|
||||
|
||||
String? failureMessageFor(String homeId) {
|
||||
if (lastFailureHomeId != homeId) return null;
|
||||
return lastFailureMessage;
|
||||
}
|
||||
|
||||
GeofenceRuntimeState armForHome(String homeId) {
|
||||
if (armedHomeId == homeId) return this;
|
||||
|
||||
return GeofenceRuntimeState(
|
||||
armedHomeId: homeId,
|
||||
lastSuccessHomeId: lastSuccessHomeId,
|
||||
lastSuccessAt: lastSuccessAt,
|
||||
lastFailureHomeId: lastFailureHomeId,
|
||||
lastFailureAt: lastFailureAt,
|
||||
lastFailureMessage: lastFailureMessage,
|
||||
);
|
||||
}
|
||||
|
||||
GeofenceRuntimeState disarm() {
|
||||
return GeofenceRuntimeState(
|
||||
lastSuccessHomeId: lastSuccessHomeId,
|
||||
lastSuccessAt: lastSuccessAt,
|
||||
lastFailureHomeId: lastFailureHomeId,
|
||||
lastFailureAt: lastFailureAt,
|
||||
lastFailureMessage: lastFailureMessage,
|
||||
);
|
||||
}
|
||||
|
||||
GeofenceRuntimeState recordInsideHome(
|
||||
String homeId, {
|
||||
required DateTime checkedAt,
|
||||
required double distanceMeters,
|
||||
}) {
|
||||
return GeofenceRuntimeState(
|
||||
armedHomeId: armedHomeId,
|
||||
lastCheckAt: checkedAt,
|
||||
lastDistanceMeters: distanceMeters,
|
||||
lastSuccessHomeId: lastSuccessHomeId,
|
||||
lastSuccessAt: lastSuccessAt,
|
||||
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
||||
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
||||
lastFailureMessage: lastFailureHomeId == homeId
|
||||
? null
|
||||
: lastFailureMessage,
|
||||
);
|
||||
}
|
||||
|
||||
GeofenceRuntimeState recordOutsideCheck(
|
||||
String homeId, {
|
||||
required DateTime checkedAt,
|
||||
required double distanceMeters,
|
||||
}) {
|
||||
return GeofenceRuntimeState(
|
||||
armedHomeId: armedHomeId ?? homeId,
|
||||
lastCheckAt: checkedAt,
|
||||
lastDistanceMeters: distanceMeters,
|
||||
triggeredHomeId: triggeredHomeId,
|
||||
triggeredAt: triggeredAt,
|
||||
lastSuccessHomeId: lastSuccessHomeId,
|
||||
lastSuccessAt: lastSuccessAt,
|
||||
lastFailureHomeId: lastFailureHomeId,
|
||||
lastFailureAt: lastFailureAt,
|
||||
lastFailureMessage: lastFailureMessage,
|
||||
);
|
||||
}
|
||||
|
||||
GeofenceRuntimeState recordSuccess(
|
||||
String homeId, {
|
||||
required DateTime triggeredAt,
|
||||
required double distanceMeters,
|
||||
}) {
|
||||
return GeofenceRuntimeState(
|
||||
armedHomeId: homeId,
|
||||
lastCheckAt: triggeredAt,
|
||||
lastDistanceMeters: distanceMeters,
|
||||
triggeredHomeId: homeId,
|
||||
triggeredAt: triggeredAt,
|
||||
lastSuccessHomeId: homeId,
|
||||
lastSuccessAt: triggeredAt,
|
||||
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
||||
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
||||
lastFailureMessage: lastFailureHomeId == homeId
|
||||
? null
|
||||
: lastFailureMessage,
|
||||
);
|
||||
}
|
||||
|
||||
GeofenceRuntimeState recordFailure(
|
||||
String homeId, {
|
||||
required DateTime failedAt,
|
||||
required double distanceMeters,
|
||||
required String message,
|
||||
}) {
|
||||
return GeofenceRuntimeState(
|
||||
armedHomeId: homeId,
|
||||
lastCheckAt: failedAt,
|
||||
lastDistanceMeters: distanceMeters,
|
||||
triggeredHomeId: triggeredHomeId == homeId ? null : triggeredHomeId,
|
||||
triggeredAt: triggeredHomeId == homeId ? null : triggeredAt,
|
||||
lastSuccessHomeId: lastSuccessHomeId,
|
||||
lastSuccessAt: lastSuccessAt,
|
||||
lastFailureHomeId: homeId,
|
||||
lastFailureAt: failedAt,
|
||||
lastFailureMessage: message,
|
||||
);
|
||||
}
|
||||
|
||||
GeofenceRuntimeState removeHome(String homeId) {
|
||||
return GeofenceRuntimeState(
|
||||
armedHomeId: armedHomeId == homeId ? null : armedHomeId,
|
||||
lastCheckAt: armedHomeId == homeId ? null : lastCheckAt,
|
||||
lastDistanceMeters: armedHomeId == homeId ? null : lastDistanceMeters,
|
||||
triggeredHomeId: triggeredHomeId == homeId ? null : triggeredHomeId,
|
||||
triggeredAt: triggeredHomeId == homeId ? null : triggeredAt,
|
||||
lastSuccessHomeId: lastSuccessHomeId == homeId ? null : lastSuccessHomeId,
|
||||
lastSuccessAt: lastSuccessHomeId == homeId ? null : lastSuccessAt,
|
||||
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
||||
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
||||
lastFailureMessage: lastFailureHomeId == homeId
|
||||
? null
|
||||
: lastFailureMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (armedHomeId != null) 'armedHomeId': armedHomeId,
|
||||
if (lastCheckAt != null) 'lastCheckAt': lastCheckAt!.millisecondsSinceEpoch,
|
||||
if (lastDistanceMeters != null) 'lastDistanceMeters': lastDistanceMeters,
|
||||
if (triggeredHomeId != null) 'triggeredHomeId': triggeredHomeId,
|
||||
if (triggeredAt != null) 'triggeredAt': triggeredAt!.millisecondsSinceEpoch,
|
||||
if (lastSuccessHomeId != null) 'lastSuccessHomeId': lastSuccessHomeId,
|
||||
if (lastSuccessAt != null)
|
||||
'lastSuccessAt': lastSuccessAt!.millisecondsSinceEpoch,
|
||||
if (lastFailureHomeId != null) 'lastFailureHomeId': lastFailureHomeId,
|
||||
if (lastFailureAt != null)
|
||||
'lastFailureAt': lastFailureAt!.millisecondsSinceEpoch,
|
||||
if (lastFailureMessage != null) 'lastFailureMessage': lastFailureMessage,
|
||||
};
|
||||
|
||||
factory GeofenceRuntimeState.fromJson(Map<String, dynamic> json) {
|
||||
return GeofenceRuntimeState(
|
||||
armedHomeId: json['armedHomeId'] as String?,
|
||||
lastCheckAt: _fromMillis(json['lastCheckAt']),
|
||||
lastDistanceMeters: (json['lastDistanceMeters'] as num?)?.toDouble(),
|
||||
triggeredHomeId: json['triggeredHomeId'] as String?,
|
||||
triggeredAt: _fromMillis(json['triggeredAt']),
|
||||
lastSuccessHomeId: json['lastSuccessHomeId'] as String?,
|
||||
lastSuccessAt: _fromMillis(json['lastSuccessAt']),
|
||||
lastFailureHomeId: json['lastFailureHomeId'] as String?,
|
||||
lastFailureAt: _fromMillis(json['lastFailureAt']),
|
||||
lastFailureMessage: json['lastFailureMessage'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _fromMillis(Object? value) {
|
||||
final millis = (value as num?)?.toInt();
|
||||
if (millis == null) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(millis);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user