feat: harden geofence and distance diagnostics
This commit is contained in:
47
lib/features/homes/geofence_logic.dart
Normal file
47
lib/features/homes/geofence_logic.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
const double geofenceThresholdMeters = 500.0;
|
||||
const Duration geofenceRetryCooldown = Duration(minutes: 30);
|
||||
|
||||
bool hasForegroundLocationAccess(LocationPermission permission) {
|
||||
return permission == LocationPermission.whileInUse ||
|
||||
permission == LocationPermission.always;
|
||||
}
|
||||
|
||||
bool hasBackgroundLocationAccess(LocationPermission permission) {
|
||||
return permission == LocationPermission.always;
|
||||
}
|
||||
|
||||
Duration? geofenceRetryRemaining(DateTime? lastFailureAt, {DateTime? now}) {
|
||||
if (lastFailureAt == null) return null;
|
||||
|
||||
final currentTime = now ?? DateTime.now();
|
||||
final remaining = lastFailureAt
|
||||
.add(geofenceRetryCooldown)
|
||||
.difference(currentTime);
|
||||
if (remaining <= Duration.zero) {
|
||||
return null;
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
String formatGeofenceRetry(Duration remaining) {
|
||||
if (remaining.inHours >= 1) {
|
||||
final hours = remaining.inHours;
|
||||
final minutes = remaining.inMinutes.remainder(60);
|
||||
if (minutes == 0) return '$hours ч';
|
||||
return '$hours ч $minutes мин';
|
||||
}
|
||||
|
||||
final minutes = remaining.inMinutes;
|
||||
if (minutes > 0) return '$minutes мин';
|
||||
|
||||
return '${remaining.inSeconds.clamp(1, 59)} сек';
|
||||
}
|
||||
|
||||
String formatDistanceMeters(double meters) {
|
||||
if (meters < 1000) {
|
||||
return '${meters.round()} м';
|
||||
}
|
||||
return '${(meters / 1000).toStringAsFixed(1)} км';
|
||||
}
|
||||
@@ -2,28 +2,48 @@ import 'package:workmanager/workmanager.dart';
|
||||
|
||||
import '../../models/home_config.dart';
|
||||
import '../../services/geofence_worker.dart';
|
||||
import 'services/geofence_runtime_store.dart';
|
||||
|
||||
/// Синхронизировать состояние фонового таска с настройками домов.
|
||||
/// Вызывать при старте приложения и при изменении настроек.
|
||||
///
|
||||
/// Если хотя бы один дом имеет geofenceReady -- регистрируем
|
||||
/// периодический таск. Иначе -- отменяем.
|
||||
Future<void> syncGeofenceTask(List<HomeConfig> homes) async {
|
||||
final needGeofence = homes.any((h) => h.geofenceReady);
|
||||
/// Геофенс работает только для текущего активного дома.
|
||||
/// Если активный дом не готов -- таск снимается и runtime разоружается.
|
||||
Future<void> syncGeofenceTask(
|
||||
List<HomeConfig> homes, {
|
||||
required HomeConfig? currentHome,
|
||||
}) async {
|
||||
final runtimeStore = GeofenceRuntimeStore();
|
||||
final activeHome = currentHome;
|
||||
final needGeofence =
|
||||
activeHome != null &&
|
||||
homes.any((home) => home.id == activeHome.id) &&
|
||||
activeHome.geofenceReady;
|
||||
|
||||
if (needGeofence) {
|
||||
await resetGeofenceFired();
|
||||
await runtimeStore.armForHome(activeHome.id);
|
||||
|
||||
await Workmanager().registerPeriodicTask(
|
||||
geofenceTaskUniqueName,
|
||||
geofenceTaskName,
|
||||
frequency: const Duration(minutes: 15),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||
backoffPolicy: BackoffPolicy.linear,
|
||||
backoffPolicyDelay: const Duration(minutes: 1),
|
||||
);
|
||||
try {
|
||||
await Workmanager().registerPeriodicTask(
|
||||
geofenceTaskUniqueName,
|
||||
geofenceTaskName,
|
||||
frequency: const Duration(minutes: 15),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||
backoffPolicy: BackoffPolicy.linear,
|
||||
backoffPolicyDelay: const Duration(minutes: 1),
|
||||
);
|
||||
} catch (_) {
|
||||
// В тестах и на неполной платформенной инициализации
|
||||
// не даём workmanager уронить остальное приложение.
|
||||
}
|
||||
} else {
|
||||
await Workmanager().cancelByUniqueName(geofenceTaskUniqueName);
|
||||
await runtimeStore.disarm();
|
||||
try {
|
||||
await Workmanager().cancelByUniqueName(geofenceTaskUniqueName);
|
||||
} catch (_) {
|
||||
// См. комментарий выше: runtime должен синхронизироваться
|
||||
// даже если platform plugin недоступен.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
226
lib/features/homes/providers/geofence_providers.dart
Normal file
226
lib/features/homes/providers/geofence_providers.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../../../app/load_state.dart';
|
||||
import '../../../models/home_config.dart';
|
||||
import '../geofence_logic.dart';
|
||||
import '../models/geofence_diagnostics.dart';
|
||||
import '../models/geofence_runtime_state.dart';
|
||||
import '../services/geofence_notifications_service.dart';
|
||||
import '../services/geofence_runtime_store.dart';
|
||||
import 'homes_providers.dart';
|
||||
|
||||
final geofenceRuntimeStoreProvider = Provider((ref) => GeofenceRuntimeStore());
|
||||
|
||||
final geofenceNotificationsServiceProvider = Provider(
|
||||
(ref) => GeofenceNotificationsService(),
|
||||
);
|
||||
|
||||
final geofenceDiagnosticsProvider =
|
||||
NotifierProvider<
|
||||
GeofenceDiagnosticsNotifier,
|
||||
LoadState<GeofenceDiagnostics>
|
||||
>(GeofenceDiagnosticsNotifier.new);
|
||||
|
||||
class GeofenceDiagnosticsNotifier
|
||||
extends Notifier<LoadState<GeofenceDiagnostics>> {
|
||||
bool _refreshing = false;
|
||||
|
||||
@override
|
||||
LoadState<GeofenceDiagnostics> build() {
|
||||
return const LoadState.idle(GeofenceDiagnostics.initial());
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
|
||||
final previous = state.data;
|
||||
state = LoadState.loading(previous);
|
||||
|
||||
try {
|
||||
final currentHome = ref.read(currentHomeProvider);
|
||||
final runtime = await ref.read(geofenceRuntimeStoreProvider).load();
|
||||
|
||||
if (currentHome == null) {
|
||||
state = LoadState.data(
|
||||
GeofenceDiagnostics(
|
||||
activeHome: null,
|
||||
status: GeofenceStatusKind.noActiveHome,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: LocationPermission.denied,
|
||||
notificationsEnabled: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHome.geofenceEnabled) {
|
||||
state = LoadState.data(
|
||||
GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.disabled,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: LocationPermission.denied,
|
||||
notificationsEnabled: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHome.hasCoordinates) {
|
||||
state = LoadState.data(
|
||||
GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.missingCoordinates,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: LocationPermission.denied,
|
||||
notificationsEnabled: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final locationServicesEnabled =
|
||||
await Geolocator.isLocationServiceEnabled();
|
||||
final locationPermission = await Geolocator.checkPermission();
|
||||
final notificationsEnabled = await ref
|
||||
.read(geofenceNotificationsServiceProvider)
|
||||
.areNotificationsEnabled();
|
||||
|
||||
final diagnostics = _buildDiagnostics(
|
||||
currentHome: currentHome,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: locationServicesEnabled,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
state = LoadState.data(diagnostics);
|
||||
} catch (error) {
|
||||
state = LoadState.error(
|
||||
previous,
|
||||
'Не удалось обновить состояние geofence: $error',
|
||||
);
|
||||
} finally {
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestLocationPermission() async {
|
||||
await Geolocator.requestPermission();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> requestBackgroundLocationPermission() async {
|
||||
final result = await Geolocator.requestPermission();
|
||||
if (!hasBackgroundLocationAccess(result)) {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> requestNotificationPermission() async {
|
||||
await ref
|
||||
.read(geofenceNotificationsServiceProvider)
|
||||
.requestNotificationsPermission();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() async {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
|
||||
Future<void> openLocationSettings() async {
|
||||
await Geolocator.openLocationSettings();
|
||||
}
|
||||
|
||||
GeofenceDiagnostics _buildDiagnostics({
|
||||
required HomeConfig currentHome,
|
||||
required GeofenceRuntimeState runtime,
|
||||
required bool locationServicesEnabled,
|
||||
required LocationPermission locationPermission,
|
||||
required bool notificationsEnabled,
|
||||
}) {
|
||||
if (!locationServicesEnabled) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.locationServicesDisabled,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: false,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasForegroundLocationAccess(locationPermission)) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.locationPermissionDenied,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasBackgroundLocationAccess(locationPermission)) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.backgroundPermissionDenied,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (!notificationsEnabled) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.notificationsPermissionDenied,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: false,
|
||||
);
|
||||
}
|
||||
|
||||
final failureAt = runtime.failureAtFor(currentHome.id);
|
||||
final retryRemaining = geofenceRetryRemaining(failureAt);
|
||||
if (retryRemaining != null) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.cooldown,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: true,
|
||||
retryRemaining: retryRemaining,
|
||||
detail: runtime.failureMessageFor(currentHome.id),
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.isTriggeredFor(currentHome.id)) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.triggered,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: true,
|
||||
);
|
||||
}
|
||||
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.ready,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/home_config.dart';
|
||||
import '../geofence_task_sync.dart';
|
||||
import '../services/geofence_runtime_store.dart';
|
||||
import '../../auth/providers/auth_providers.dart';
|
||||
import '../../shared/providers/core_providers.dart';
|
||||
|
||||
@@ -39,9 +41,11 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
try {
|
||||
await switchTo(home);
|
||||
await ref.read(authInfoProvider.notifier).load(failOnError: true);
|
||||
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
|
||||
} catch (error) {
|
||||
await _restoreSelection(previousHome);
|
||||
ref.read(authInfoProvider.notifier).restore(previousAuthState);
|
||||
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +53,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
Future<void> clear() async {
|
||||
await ref.read(settingsServiceProvider).setCurrentHomeId(null);
|
||||
state = null;
|
||||
await syncGeofenceTask(ref.read(homesProvider), currentHome: null);
|
||||
}
|
||||
|
||||
/// Инициализировать API-клиент текущим домом
|
||||
@@ -91,6 +96,7 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
await ref.read(settingsServiceProvider).deleteHome(id);
|
||||
await GeofenceRuntimeStore().removeHome(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,28 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
enum UserLocationIssue {
|
||||
servicesDisabled,
|
||||
permissionDenied,
|
||||
permissionDeniedForever,
|
||||
unavailable,
|
||||
}
|
||||
|
||||
/// Состояние геолокации: позиция или причина отсутствия.
|
||||
/// Запрашивается один раз, кешируется до перезапуска провайдера.
|
||||
class UserLocation {
|
||||
final Position? position;
|
||||
final String? error; // null -- всё ок, иначе причина
|
||||
final UserLocationIssue? issue;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const UserLocation({this.position, this.error});
|
||||
const UserLocation({this.position, this.error, this.issue, this.updatedAt});
|
||||
|
||||
bool get hasPosition => position != null;
|
||||
bool get needsAppSettings =>
|
||||
issue == UserLocationIssue.permissionDeniedForever;
|
||||
bool get needsLocationSettings => issue == UserLocationIssue.servicesDisabled;
|
||||
bool get canRequestPermission => issue == UserLocationIssue.permissionDenied;
|
||||
|
||||
/// Расстояние в км до точки. Возвращает null если нет позиции
|
||||
/// или у цели нет координат.
|
||||
@@ -50,30 +63,7 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
||||
/// и отдаёт lastKnown мгновенно (если есть).
|
||||
Future<void> fetch() async {
|
||||
if (state.hasPosition) return;
|
||||
|
||||
final err = await _ensurePermission();
|
||||
if (err != null) {
|
||||
state = UserLocation(error: err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final last = await Geolocator.getLastKnownPosition();
|
||||
if (last != null) {
|
||||
state = UserLocation(position: last);
|
||||
return;
|
||||
}
|
||||
|
||||
final pos = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.low,
|
||||
timeLimit: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
state = UserLocation(position: pos);
|
||||
} catch (e) {
|
||||
state = UserLocation(error: 'Ошибка: $e');
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// Начать непрерывное отслеживание. Вызывать из initState экрана.
|
||||
@@ -83,9 +73,9 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
||||
_watchers++;
|
||||
if (_sub != null) return;
|
||||
|
||||
final err = await _ensurePermission();
|
||||
if (err != null) {
|
||||
state = UserLocation(error: err);
|
||||
final permissionState = await _ensurePermission();
|
||||
if (!permissionState.isGranted) {
|
||||
state = permissionState.toLocation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +83,7 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
||||
try {
|
||||
final last = await Geolocator.getLastKnownPosition();
|
||||
if (last != null) {
|
||||
state = UserLocation(position: last);
|
||||
state = _fromPosition(last);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -104,9 +94,14 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
||||
);
|
||||
|
||||
_sub = Geolocator.getPositionStream(locationSettings: settings).listen(
|
||||
(pos) => state = UserLocation(position: pos),
|
||||
(pos) => state = _fromPosition(pos),
|
||||
onError: (e) {
|
||||
debugPrint('Ошибка стрима геолокации: $e');
|
||||
state = UserLocation(
|
||||
error: 'Не удалось отслеживать позицию: $e',
|
||||
issue: UserLocationIssue.unavailable,
|
||||
updatedAt: state.updatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -120,11 +115,61 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final permissionState = await _ensurePermission();
|
||||
if (!permissionState.isGranted) {
|
||||
state = permissionState.toLocation();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final last = await Geolocator.getLastKnownPosition();
|
||||
if (last != null) {
|
||||
state = _fromPosition(last);
|
||||
return;
|
||||
}
|
||||
|
||||
final pos = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.low,
|
||||
timeLimit: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
state = _fromPosition(pos);
|
||||
} catch (e) {
|
||||
state = UserLocation(
|
||||
error: 'Не удалось получить позицию: $e',
|
||||
issue: UserLocationIssue.unavailable,
|
||||
updatedAt: state.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestPermission() async {
|
||||
await Geolocator.requestPermission();
|
||||
if (_watchers > 0 && _sub == null) {
|
||||
await startWatching();
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() async {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
|
||||
Future<void> openLocationSettings() async {
|
||||
await Geolocator.openLocationSettings();
|
||||
}
|
||||
|
||||
/// Проверить сервис и пермишены. Возвращает null если всё ок,
|
||||
/// иначе строку с причиной ошибки.
|
||||
Future<String?> _ensurePermission() async {
|
||||
Future<_LocationPermissionState> _ensurePermission() async {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
return 'Геолокация выключена';
|
||||
return const _LocationPermissionState(
|
||||
issue: UserLocationIssue.servicesDisabled,
|
||||
message: 'Геолокация выключена',
|
||||
);
|
||||
}
|
||||
|
||||
var perm = await Geolocator.checkPermission();
|
||||
@@ -132,12 +177,35 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
||||
perm = await Geolocator.requestPermission();
|
||||
}
|
||||
if (perm == LocationPermission.denied) {
|
||||
return 'Нет разрешения';
|
||||
return const _LocationPermissionState(
|
||||
issue: UserLocationIssue.permissionDenied,
|
||||
message: 'Нет разрешения на геолокацию',
|
||||
);
|
||||
}
|
||||
if (perm == LocationPermission.deniedForever) {
|
||||
return 'Разрешение запрещено навсегда';
|
||||
return const _LocationPermissionState(
|
||||
issue: UserLocationIssue.permissionDeniedForever,
|
||||
message: 'Доступ к геолокации запрещён навсегда',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return const _LocationPermissionState();
|
||||
}
|
||||
|
||||
UserLocation _fromPosition(Position position) {
|
||||
return UserLocation(position: position, updatedAt: position.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocationPermissionState {
|
||||
final UserLocationIssue? issue;
|
||||
final String? message;
|
||||
|
||||
const _LocationPermissionState({this.issue, this.message});
|
||||
|
||||
bool get isGranted => issue == null;
|
||||
|
||||
UserLocation toLocation() {
|
||||
return UserLocation(error: message, issue: issue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class GeofenceNotificationsService {
|
||||
final FlutterLocalNotificationsPlugin _plugin;
|
||||
|
||||
GeofenceNotificationsService({FlutterLocalNotificationsPlugin? plugin})
|
||||
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
);
|
||||
await _plugin.initialize(settings);
|
||||
}
|
||||
|
||||
Future<bool> areNotificationsEnabled() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final android = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
return await android?.areNotificationsEnabled() ?? true;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> requestNotificationsPermission() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final android = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final granted = await android?.requestNotificationsPermission();
|
||||
return granted ?? await areNotificationsEnabled();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
lib/features/homes/services/geofence_runtime_store.dart
Normal file
53
lib/features/homes/services/geofence_runtime_store.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/geofence_runtime_state.dart';
|
||||
|
||||
class GeofenceRuntimeStore {
|
||||
static const String _runtimeKey = 'ignis_geofence_runtime';
|
||||
|
||||
Future<GeofenceRuntimeState> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_runtimeKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const GeofenceRuntimeState();
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const GeofenceRuntimeState();
|
||||
}
|
||||
|
||||
return GeofenceRuntimeState.fromJson(decoded);
|
||||
}
|
||||
|
||||
Future<void> save(GeofenceRuntimeState state) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = state.toJson();
|
||||
if (data.isEmpty) {
|
||||
await prefs.remove(_runtimeKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await prefs.setString(_runtimeKey, jsonEncode(data));
|
||||
}
|
||||
|
||||
Future<GeofenceRuntimeState> armForHome(String homeId) async {
|
||||
final next = (await load()).armForHome(homeId);
|
||||
await save(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
Future<GeofenceRuntimeState> disarm() async {
|
||||
final next = (await load()).disarm();
|
||||
await save(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
Future<GeofenceRuntimeState> removeHome(String homeId) async {
|
||||
final next = (await load()).removeHome(homeId);
|
||||
await save(next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user