feat: harden geofence and distance diagnostics

This commit is contained in:
Artem Kokos
2026-05-01 09:13:23 +07:00
parent 872ddf9513
commit 91a494adf5
20 changed files with 1639 additions and 260 deletions

View File

@@ -59,12 +59,13 @@ class AppBootstrapNotifier extends Notifier<AppBootstrapState> {
final home = ref.read(currentHomeProvider); final home = ref.read(currentHomeProvider);
if (home == null) { if (home == null) {
await syncGeofenceTask(ref.read(homesProvider), currentHome: null);
state = const AppBootstrapState.noHomes(); state = const AppBootstrapState.noHomes();
return; return;
} }
await ref.read(authInfoProvider.notifier).load(failOnError: true); await ref.read(authInfoProvider.notifier).load(failOnError: true);
await syncGeofenceTask(ref.read(homesProvider)); await syncGeofenceTask(ref.read(homesProvider), currentHome: home);
state = const AppBootstrapState.ready(); state = const AppBootstrapState.ready();
} catch (e) { } catch (e) {

View File

@@ -1,12 +1,35 @@
class BuildInfo { class BuildInfo {
static const String date = String.fromEnvironment( static const String _date = String.fromEnvironment(
'IGNIS_BUILD_DATE', 'IGNIS_BUILD_DATE',
defaultValue: 'dev', defaultValue: '',
); );
static const String gitSha = String.fromEnvironment( static const String _gitSha = String.fromEnvironment(
'IGNIS_GIT_SHA', 'IGNIS_GIT_SHA',
defaultValue: 'local', defaultValue: '',
); );
static String get label => 'build $date - $gitSha'; static bool get hasMetadata => _date.isNotEmpty && _gitSha.isNotEmpty;
static String get shortSha {
if (_gitSha.isEmpty) return 'unknown';
return _gitSha.length <= 7 ? _gitSha : _gitSha.substring(0, 7);
}
static String get formattedDate {
if (_date.isEmpty) return 'unknown date';
final parsed = DateTime.tryParse(_date);
if (parsed == null) return _date;
final utc = parsed.toUtc();
final year = utc.year.toString().padLeft(4, '0');
final month = utc.month.toString().padLeft(2, '0');
final day = utc.day.toString().padLeft(2, '0');
final hour = utc.hour.toString().padLeft(2, '0');
final minute = utc.minute.toString().padLeft(2, '0');
return '$year-$month-$day $hour:$minute UTC';
}
static String get label =>
hasMetadata ? '$formattedDate · $shortSha' : 'build info unavailable';
} }

View 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)} км';
}

View File

@@ -2,18 +2,28 @@ import 'package:workmanager/workmanager.dart';
import '../../models/home_config.dart'; import '../../models/home_config.dart';
import '../../services/geofence_worker.dart'; import '../../services/geofence_worker.dart';
import 'services/geofence_runtime_store.dart';
/// Синхронизировать состояние фонового таска с настройками домов. /// Синхронизировать состояние фонового таска с настройками домов.
/// Вызывать при старте приложения и при изменении настроек. /// Вызывать при старте приложения и при изменении настроек.
/// ///
/// Если хотя бы один дом имеет geofenceReady -- регистрируем /// Геофенс работает только для текущего активного дома.
/// периодический таск. Иначе -- отменяем. /// Если активный дом не готов -- таск снимается и runtime разоружается.
Future<void> syncGeofenceTask(List<HomeConfig> homes) async { Future<void> syncGeofenceTask(
final needGeofence = homes.any((h) => h.geofenceReady); 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) { if (needGeofence) {
await resetGeofenceFired(); await runtimeStore.armForHome(activeHome.id);
try {
await Workmanager().registerPeriodicTask( await Workmanager().registerPeriodicTask(
geofenceTaskUniqueName, geofenceTaskUniqueName,
geofenceTaskName, geofenceTaskName,
@@ -23,7 +33,17 @@ Future<void> syncGeofenceTask(List<HomeConfig> homes) async {
backoffPolicy: BackoffPolicy.linear, backoffPolicy: BackoffPolicy.linear,
backoffPolicyDelay: const Duration(minutes: 1), backoffPolicyDelay: const Duration(minutes: 1),
); );
} catch (_) {
// В тестах и на неполной платформенной инициализации
// не даём workmanager уронить остальное приложение.
}
} else { } else {
await runtimeStore.disarm();
try {
await Workmanager().cancelByUniqueName(geofenceTaskUniqueName); await Workmanager().cancelByUniqueName(geofenceTaskUniqueName);
} catch (_) {
// См. комментарий выше: runtime должен синхронизироваться
// даже если platform plugin недоступен.
}
} }
} }

View 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;
}

View 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);
}
}

View 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,
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/home_config.dart'; import '../../../models/home_config.dart';
import '../geofence_task_sync.dart';
import '../services/geofence_runtime_store.dart';
import '../../auth/providers/auth_providers.dart'; import '../../auth/providers/auth_providers.dart';
import '../../shared/providers/core_providers.dart'; import '../../shared/providers/core_providers.dart';
@@ -39,9 +41,11 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
try { try {
await switchTo(home); await switchTo(home);
await ref.read(authInfoProvider.notifier).load(failOnError: true); await ref.read(authInfoProvider.notifier).load(failOnError: true);
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
} catch (error) { } catch (error) {
await _restoreSelection(previousHome); await _restoreSelection(previousHome);
ref.read(authInfoProvider.notifier).restore(previousAuthState); ref.read(authInfoProvider.notifier).restore(previousAuthState);
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
rethrow; rethrow;
} }
} }
@@ -49,6 +53,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
Future<void> clear() async { Future<void> clear() async {
await ref.read(settingsServiceProvider).setCurrentHomeId(null); await ref.read(settingsServiceProvider).setCurrentHomeId(null);
state = null; state = null;
await syncGeofenceTask(ref.read(homesProvider), currentHome: null);
} }
/// Инициализировать API-клиент текущим домом /// Инициализировать API-клиент текущим домом
@@ -91,6 +96,7 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
Future<void> remove(String id) async { Future<void> remove(String id) async {
await ref.read(settingsServiceProvider).deleteHome(id); await ref.read(settingsServiceProvider).deleteHome(id);
await GeofenceRuntimeStore().removeHome(id);
await load(); await load();
} }

View File

@@ -5,15 +5,28 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
enum UserLocationIssue {
servicesDisabled,
permissionDenied,
permissionDeniedForever,
unavailable,
}
/// Состояние геолокации: позиция или причина отсутствия. /// Состояние геолокации: позиция или причина отсутствия.
/// Запрашивается один раз, кешируется до перезапуска провайдера. /// Запрашивается один раз, кешируется до перезапуска провайдера.
class UserLocation { class UserLocation {
final Position? position; final Position? position;
final String? error; // null -- всё ок, иначе причина 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 hasPosition => position != null;
bool get needsAppSettings =>
issue == UserLocationIssue.permissionDeniedForever;
bool get needsLocationSettings => issue == UserLocationIssue.servicesDisabled;
bool get canRequestPermission => issue == UserLocationIssue.permissionDenied;
/// Расстояние в км до точки. Возвращает null если нет позиции /// Расстояние в км до точки. Возвращает null если нет позиции
/// или у цели нет координат. /// или у цели нет координат.
@@ -50,30 +63,7 @@ class UserLocationNotifier extends Notifier<UserLocation> {
/// и отдаёт lastKnown мгновенно (если есть). /// и отдаёт lastKnown мгновенно (если есть).
Future<void> fetch() async { Future<void> fetch() async {
if (state.hasPosition) return; if (state.hasPosition) return;
await refresh();
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');
}
} }
/// Начать непрерывное отслеживание. Вызывать из initState экрана. /// Начать непрерывное отслеживание. Вызывать из initState экрана.
@@ -83,9 +73,9 @@ class UserLocationNotifier extends Notifier<UserLocation> {
_watchers++; _watchers++;
if (_sub != null) return; if (_sub != null) return;
final err = await _ensurePermission(); final permissionState = await _ensurePermission();
if (err != null) { if (!permissionState.isGranted) {
state = UserLocation(error: err); state = permissionState.toLocation();
return; return;
} }
@@ -93,7 +83,7 @@ class UserLocationNotifier extends Notifier<UserLocation> {
try { try {
final last = await Geolocator.getLastKnownPosition(); final last = await Geolocator.getLastKnownPosition();
if (last != null) { if (last != null) {
state = UserLocation(position: last); state = _fromPosition(last);
} }
} catch (_) {} } catch (_) {}
} }
@@ -104,9 +94,14 @@ class UserLocationNotifier extends Notifier<UserLocation> {
); );
_sub = Geolocator.getPositionStream(locationSettings: settings).listen( _sub = Geolocator.getPositionStream(locationSettings: settings).listen(
(pos) => state = UserLocation(position: pos), (pos) => state = _fromPosition(pos),
onError: (e) { onError: (e) {
debugPrint('Ошибка стрима геолокации: $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 если всё ок, /// Проверить сервис и пермишены. Возвращает null если всё ок,
/// иначе строку с причиной ошибки. /// иначе строку с причиной ошибки.
Future<String?> _ensurePermission() async { Future<_LocationPermissionState> _ensurePermission() async {
if (!await Geolocator.isLocationServiceEnabled()) { if (!await Geolocator.isLocationServiceEnabled()) {
return 'Геолокация выключена'; return const _LocationPermissionState(
issue: UserLocationIssue.servicesDisabled,
message: 'Геолокация выключена',
);
} }
var perm = await Geolocator.checkPermission(); var perm = await Geolocator.checkPermission();
@@ -132,12 +177,35 @@ class UserLocationNotifier extends Notifier<UserLocation> {
perm = await Geolocator.requestPermission(); perm = await Geolocator.requestPermission();
} }
if (perm == LocationPermission.denied) { if (perm == LocationPermission.denied) {
return 'Нет разрешения'; return const _LocationPermissionState(
issue: UserLocationIssue.permissionDenied,
message: 'Нет разрешения на геолокацию',
);
} }
if (perm == LocationPermission.deniedForever) { 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);
} }
} }

View File

@@ -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;
}
}
}

View 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;
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import 'app/app_bootstrap.dart'; import 'app/app_bootstrap.dart';
import 'features/homes/services/geofence_notifications_service.dart';
import 'screens/homes_screen.dart'; import 'screens/homes_screen.dart';
import 'screens/remote_screen.dart'; import 'screens/remote_screen.dart';
import 'services/geofence_worker.dart'; import 'services/geofence_worker.dart';
@@ -21,8 +22,9 @@ void callbackDispatcher() {
}); });
} }
void main() { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await GeofenceNotificationsService().initialize();
// Инициализация workmanager // Инициализация workmanager
Workmanager().initialize(callbackDispatcher); Workmanager().initialize(callbackDispatcher);

View File

@@ -1,6 +1,7 @@
export '../features/api_keys/providers/api_keys_providers.dart'; export '../features/api_keys/providers/api_keys_providers.dart';
export '../features/auth/providers/auth_providers.dart'; export '../features/auth/providers/auth_providers.dart';
export '../features/homes/geofence_task_sync.dart'; export '../features/homes/geofence_task_sync.dart';
export '../features/homes/providers/geofence_providers.dart';
export '../features/homes/providers/homes_providers.dart'; export '../features/homes/providers/homes_providers.dart';
export '../features/homes/providers/location_providers.dart'; export '../features/homes/providers/location_providers.dart';
export '../features/remote/providers/remote_providers.dart'; export '../features/remote/providers/remote_providers.dart';

View File

@@ -200,7 +200,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
padding: EdgeInsets.only(left: 40, bottom: 4), padding: EdgeInsets.only(left: 40, bottom: 4),
child: Text( child: Text(
'Проверка раз в ~15 мин (ограничение Android).\n' 'Проверка раз в ~15 мин (ограничение Android).\n'
'Работает в фоне, без постоянной нотификации.', 'Работает только для текущего активного дома.\n'
'Нужны фоновые разрешения на геолокацию и уведомления.',
style: TextStyle(fontSize: 11, color: Colors.white24), style: TextStyle(fontSize: 11, color: Colors.white24),
), ),
), ),
@@ -327,16 +328,17 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
// Синхронизировать фоновый таск с новыми настройками // Синхронизировать фоновый таск с новыми настройками
final allHomes = ref.read(homesProvider); final allHomes = ref.read(homesProvider);
await syncGeofenceTask(allHomes); await syncGeofenceTask(
allHomes,
currentHome: ref.read(currentHomeProvider),
);
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text('Не удалось сохранить дом: ${describeLoadError(e)}'),
'Не удалось сохранить дом: ${describeLoadError(e)}',
),
), ),
); );
} }

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart'; import '../app/error_message.dart';
import '../features/homes/models/geofence_diagnostics.dart';
import '../models/home_config.dart'; import '../models/home_config.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../widgets/build_info_text.dart'; import '../widgets/build_info_text.dart';
@@ -16,7 +18,8 @@ class HomesScreen extends ConsumerStatefulWidget {
ConsumerState<HomesScreen> createState() => _HomesScreenState(); ConsumerState<HomesScreen> createState() => _HomesScreenState();
} }
class _HomesScreenState extends ConsumerState<HomesScreen> { class _HomesScreenState extends ConsumerState<HomesScreen>
with WidgetsBindingObserver {
late final UserLocationNotifier _userLocationNotifier; late final UserLocationNotifier _userLocationNotifier;
String? _switchingHomeId; String? _switchingHomeId;
String? _deletingHomeId; String? _deletingHomeId;
@@ -24,21 +27,37 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_userLocationNotifier = ref.read(userLocationProvider.notifier); _userLocationNotifier = ref.read(userLocationProvider.notifier);
Future.microtask(() => _userLocationNotifier.startWatching()); Future.microtask(() async {
await _userLocationNotifier.startWatching();
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
});
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_userLocationNotifier.stopWatching(); _userLocationNotifier.stopWatching();
super.dispose(); super.dispose();
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
Future.microtask(_refreshEnvironmentState);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final homes = ref.watch(homesProvider); final homes = ref.watch(homesProvider);
final currentHome = ref.watch(currentHomeProvider); final currentHome = ref.watch(currentHomeProvider);
final location = ref.watch(userLocationProvider); final location = ref.watch(userLocationProvider);
final geofenceState = ref.watch(geofenceDiagnosticsProvider);
final activeDistanceKm = currentHome == null
? null
: location.distanceToKm(currentHome.latitude, currentHome.longitude);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -50,11 +69,25 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
Expanded( Expanded(
child: homes.isEmpty child: homes.isEmpty
? const _EmptyHomesView() ? const _EmptyHomesView()
: ListView.builder( : ListView(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
itemCount: homes.length, children: [
itemBuilder: (context, index) { _HomesOverviewCard(
final home = homes[index]; location: location,
diagnostics: geofenceState.data,
activeHome: currentHome,
activeDistanceKm: activeDistanceKm,
onRefresh: _refreshEnvironmentState,
onRequestLocationPermission: _requestLocationPermission,
onRequestBackgroundPermission:
_requestGeofenceBackgroundPermission,
onRequestNotificationsPermission:
_requestGeofenceNotificationPermission,
onOpenAppSettings: _openRelevantAppSettings,
onOpenLocationSettings: _openLocationSettings,
),
const SizedBox(height: 14),
...homes.map((home) {
final isActive = currentHome?.id == home.id; final isActive = currentHome?.id == home.id;
final isSwitching = _switchingHomeId == home.id; final isSwitching = _switchingHomeId == home.id;
final isDeleting = _deletingHomeId == home.id; final isDeleting = _deletingHomeId == home.id;
@@ -90,6 +123,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
home: home, home: home,
location: location, location: location,
distKm: distKm, distKm: distKm,
isActive: isActive,
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -117,21 +151,29 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
size: 20, size: 20,
color: Colors.redAccent, color: Colors.redAccent,
), ),
onPressed: () => _confirmDelete(context, home), onPressed: () =>
_confirmDelete(context, home),
), ),
], ],
], ],
), ),
onTap: isBusy ? null : () => _selectHome(context, home), onTap: isBusy
? null
: () => _selectHome(context, home),
), ),
); );
}, }),
],
), ),
), ),
const Padding( const SafeArea(
padding: EdgeInsets.only(bottom: 10), top: false,
minimum: EdgeInsets.only(bottom: 10),
child: Padding(
padding: EdgeInsets.only(bottom: 6),
child: BuildInfoText(), child: BuildInfoText(),
), ),
),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
@@ -158,9 +200,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
if (context.mounted) { if (context.mounted) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text('Не удалось выбрать дом: ${describeLoadError(e)}'),
'Не удалось выбрать дом: ${describeLoadError(e)}',
),
), ),
); );
} }
@@ -171,16 +211,18 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
} }
} }
void _addHome(BuildContext context) { Future<void> _addHome(BuildContext context) async {
Navigator.of( await Navigator.of(
context, context,
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen())); ).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
await _refreshEnvironmentState();
} }
void _editHome(BuildContext context, HomeConfig home) { Future<void> _editHome(BuildContext context, HomeConfig home) async {
Navigator.of( await Navigator.of(
context, context,
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home))); ).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
await _refreshEnvironmentState();
} }
void _confirmDelete(BuildContext context, HomeConfig home) { void _confirmDelete(BuildContext context, HomeConfig home) {
@@ -222,14 +264,16 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
if (deletedCurrentHome) { if (deletedCurrentHome) {
ref.read(authInfoProvider.notifier).clear(); ref.read(authInfoProvider.notifier).clear();
} }
await syncGeofenceTask(ref.read(homesProvider)); await syncGeofenceTask(
ref.read(homesProvider),
currentHome: ref.read(currentHomeProvider),
);
await _refreshEnvironmentState();
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text('Не удалось удалить дом: ${describeLoadError(e)}'),
'Не удалось удалить дом: ${describeLoadError(e)}',
),
), ),
); );
} }
@@ -239,6 +283,45 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
} }
} }
} }
Future<void> _refreshEnvironmentState() async {
await _userLocationNotifier.refresh();
await _refreshGeofenceDiagnostics();
}
Future<void> _refreshGeofenceDiagnostics() async {
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
}
Future<void> _requestLocationPermission() async {
await _userLocationNotifier.requestPermission();
await _refreshGeofenceDiagnostics();
}
Future<void> _openLocationSettings() async {
await _userLocationNotifier.openLocationSettings();
}
Future<void> _requestGeofenceBackgroundPermission() async {
await ref
.read(geofenceDiagnosticsProvider.notifier)
.requestBackgroundLocationPermission();
await _userLocationNotifier.refresh();
}
Future<void> _requestGeofenceNotificationPermission() async {
await ref
.read(geofenceDiagnosticsProvider.notifier)
.requestNotificationPermission();
}
Future<void> _openRelevantAppSettings() async {
if (ref.read(userLocationProvider).needsAppSettings) {
await _userLocationNotifier.openAppSettings();
return;
}
await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings();
}
} }
class _EmptyHomesView extends StatelessWidget { class _EmptyHomesView extends StatelessWidget {
@@ -271,11 +354,13 @@ class _HomeSubtitle extends StatelessWidget {
final HomeConfig home; final HomeConfig home;
final UserLocation location; final UserLocation location;
final double? distKm; final double? distKm;
final bool isActive;
const _HomeSubtitle({ const _HomeSubtitle({
required this.home, required this.home,
required this.location, required this.location,
required this.distKm, required this.distKm,
required this.isActive,
}); });
@override @override
@@ -302,7 +387,9 @@ class _HomeSubtitle extends StatelessWidget {
), ),
) )
else if (home.hasCoordinates && !location.hasPosition) else if (home.hasCoordinates && !location.hasPosition)
Row( Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [ children: [
const Icon(Icons.location_on, size: 12, color: Colors.white24), const Icon(Icons.location_on, size: 12, color: Colors.white24),
const SizedBox(width: 4), const SizedBox(width: 4),
@@ -312,7 +399,258 @@ class _HomeSubtitle extends StatelessWidget {
), ),
], ],
), ),
)
else if (home.geofenceReady && isActive)
const Padding(
padding: EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(
Icons.shield_moon_outlined,
size: 12,
color: Colors.deepOrangeAccent,
),
SizedBox(width: 4),
Text(
'Geofence включён',
style: TextStyle(
color: Colors.deepOrangeAccent,
fontSize: 11,
),
),
],
),
)
else if (home.geofenceReady)
const Padding(
padding: EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(
Icons.shield_moon_outlined,
size: 12,
color: Colors.white24,
),
SizedBox(width: 4),
Text(
'Geofence включён',
style: TextStyle(color: Colors.white24, fontSize: 11),
),
],
),
),
], ],
); );
} }
} }
class _HomesOverviewCard extends StatelessWidget {
final UserLocation location;
final GeofenceDiagnostics diagnostics;
final HomeConfig? activeHome;
final double? activeDistanceKm;
final Future<void> Function() onRefresh;
final Future<void> Function() onRequestLocationPermission;
final Future<void> Function() onRequestBackgroundPermission;
final Future<void> Function() onRequestNotificationsPermission;
final Future<void> Function() onOpenAppSettings;
final Future<void> Function() onOpenLocationSettings;
const _HomesOverviewCard({
required this.location,
required this.diagnostics,
required this.activeHome,
required this.activeDistanceKm,
required this.onRefresh,
required this.onRequestLocationPermission,
required this.onRequestBackgroundPermission,
required this.onRequestNotificationsPermission,
required this.onOpenAppSettings,
required this.onOpenLocationSettings,
});
@override
Widget build(BuildContext context) {
final title = activeHome == null
? 'Статус автоматизации'
: 'Активный дом: ${activeHome!.name}';
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_overviewIcon(location, diagnostics),
color: _overviewColor(location, diagnostics),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
Text(
_overviewPrimaryText(location, diagnostics),
style: const TextStyle(color: Colors.white70),
),
if (_overviewSecondaryText(location, diagnostics, activeDistanceKm)
case final secondary?)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
secondary,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Обновить'),
),
if (location.canRequestPermission)
OutlinedButton.icon(
onPressed: onRequestLocationPermission,
icon: const Icon(Icons.my_location),
label: const Text('Разрешить геолокацию'),
),
if (location.needsLocationSettings)
OutlinedButton.icon(
onPressed: onOpenLocationSettings,
icon: const Icon(Icons.location_searching),
label: const Text('Включить GPS'),
),
if (diagnostics.canRequestBackgroundLocation)
OutlinedButton.icon(
onPressed: onRequestBackgroundPermission,
icon: const Icon(Icons.shield_moon_outlined),
label: const Text('Доступ всегда'),
),
if (diagnostics.canRequestNotifications)
OutlinedButton.icon(
onPressed: onRequestNotificationsPermission,
icon: const Icon(Icons.notifications_active_outlined),
label: const Text('Уведомления'),
),
if (location.needsAppSettings ||
diagnostics.canRequestBackgroundLocation)
OutlinedButton.icon(
onPressed: onOpenAppSettings,
icon: const Icon(Icons.settings),
label: const Text('Настройки'),
),
],
),
],
),
),
);
}
}
String _overviewPrimaryText(
UserLocation location,
GeofenceDiagnostics diagnostics,
) {
if (!location.hasPosition) {
return location.error ?? 'Позиция устройства пока недоступна.';
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready =>
'Расстояние считается, geofence активен и готов выключить свет.',
GeofenceStatusKind.triggered =>
'Расстояние считается, geofence уже сработал для активного дома.',
GeofenceStatusKind.cooldown =>
'Расстояние считается, но после сбоя geofence сейчас на паузе.',
GeofenceStatusKind.notificationsPermissionDenied =>
'Расстояние считается, но уведомления для geofence сейчас запрещены.',
GeofenceStatusKind.backgroundPermissionDenied =>
'Расстояние считается, но без доступа "Всегда" geofence в фоне будет кастрирован.',
GeofenceStatusKind.locationServicesDisabled =>
'Геолокация выключена, поэтому и расстояния, и geofence сейчас мёртвые.',
GeofenceStatusKind.locationPermissionDenied =>
'Без разрешения на геолокацию тут нечего считать.',
GeofenceStatusKind.disabled =>
'Расстояние считается, но geofence для активного дома выключен.',
GeofenceStatusKind.missingCoordinates =>
'Расстояние считается, но у активного дома нет координат для geofence.',
GeofenceStatusKind.noActiveHome =>
'Выбери активный дом, и тогда автоматика станет осмысленной.',
};
}
String? _overviewSecondaryText(
UserLocation location,
GeofenceDiagnostics diagnostics,
double? activeDistanceKm,
) {
final parts = <String>[];
if (activeDistanceKm != null) {
parts.add('До активного дома: ${formatDistance(activeDistanceKm)}.');
}
if (location.updatedAt != null) {
parts.add('Точка: ${_formatTimestamp(location.updatedAt!)}.');
}
final secondary = diagnostics.secondaryMessage;
if (secondary != null && secondary.isNotEmpty) {
parts.add(secondary);
}
if (parts.isEmpty) return null;
return parts.join(' ');
}
IconData _overviewIcon(UserLocation location, GeofenceDiagnostics diagnostics) {
if (!location.hasPosition) {
return Icons.location_off_outlined;
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready => Icons.shield_moon_outlined,
GeofenceStatusKind.triggered => Icons.check_circle_outline,
GeofenceStatusKind.cooldown => Icons.timer_outlined,
GeofenceStatusKind.notificationsPermissionDenied =>
Icons.notifications_off_outlined,
GeofenceStatusKind.backgroundPermissionDenied => Icons.location_searching,
_ => Icons.info_outline,
};
}
Color _overviewColor(UserLocation location, GeofenceDiagnostics diagnostics) {
if (!location.hasPosition) {
return Colors.redAccent;
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready => Colors.greenAccent,
GeofenceStatusKind.triggered => Colors.deepOrangeAccent,
GeofenceStatusKind.cooldown => Colors.amberAccent,
GeofenceStatusKind.notificationsPermissionDenied ||
GeofenceStatusKind.backgroundPermissionDenied => Colors.deepOrangeAccent,
_ => Colors.white54,
};
}
String _formatTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
final second = local.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}

View File

@@ -126,7 +126,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
enabled: false, enabled: false,
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(vertical: 4), padding: EdgeInsets.symmetric(vertical: 4),
child: BuildInfoText(), child: BuildInfoText(compact: false, alignStart: true),
), ),
), ),
], ],

View File

@@ -1,16 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:dio/dio.dart'; 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_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
/// Порог расстояния для срабатывания геофенса (метры) import '../features/homes/geofence_logic.dart';
const double geofenceThresholdMeters = 500.0; import '../features/homes/services/geofence_runtime_store.dart';
/// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать
const String _firedKey = 'ignis_geofence_fired';
const String _apiKeyPrefix = 'ignis_home_api_key_'; const String _apiKeyPrefix = 'ignis_home_api_key_';
/// Имя задачи в workmanager /// Имя задачи в workmanager
@@ -29,70 +28,35 @@ const String _channelDesc = 'Уведомления об автовыключе
/// Возвращает true если таск выполнен успешно (workmanager convention). /// Возвращает true если таск выполнен успешно (workmanager convention).
Future<bool> executeGeofenceCheck() async { Future<bool> executeGeofenceCheck() async {
try { try {
// 1. Проверяем, не сработал ли уже final runtimeStore = GeofenceRuntimeStore();
final prefs = await SharedPreferences.getInstance(); var runtime = await runtimeStore.load();
if (prefs.getBool(_firedKey) == true) { final armedHomeId = runtime.armedHomeId;
// Уже сработал -- ничего не делаем. if (armedHomeId == null || armedHomeId.isEmpty) {
// Таск будет отменён при следующем запуске приложения.
return true; return true;
} }
// 2. Загружаем дома из SharedPreferences final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('ignis_homes'); final raw = prefs.getString('ignis_homes');
if (raw == null || raw.isEmpty) return true; if (raw == null || raw.isEmpty) return true;
final List<dynamic> homesList = jsonDecode(raw); final List<dynamic> homesList = jsonDecode(raw);
final currentHomeId = prefs.getString('ignis_current_home_id'); final targetHome = _findArmedHome(homesList, armedHomeId);
if (targetHome == null) {
// Ищем текущий дом с включённым геофенсом await runtimeStore.disarm();
Map<String, dynamic>? targetHome; return true;
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; if (!await Geolocator.isLocationServiceEnabled()) return true;
final perm = await Geolocator.checkPermission(); final perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied || if (!hasBackgroundLocationAccess(perm)) {
perm == LocationPermission.deniedForever) { return true;
return true; // нет пермишена -- молча выходим
} }
Position? pos; final pos = await _getCurrentPosition();
try { if (pos == null) return true;
// Сначала lastKnown (мгновенно)
pos = await Geolocator.getLastKnownPosition();
final lastTimestamp = pos?.timestamp;
// Если позиции нет или она несвежая -- запрашиваем новую final now = DateTime.now();
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 homeLat = (targetHome['latitude'] as num).toDouble();
final homeLon = (targetHome['longitude'] as num).toDouble(); final homeLon = (targetHome['longitude'] as num).toDouble();
final distMeters = _haversineMeters( final distMeters = _haversineMeters(
@@ -103,39 +67,82 @@ Future<bool> executeGeofenceCheck() async {
); );
if (distMeters <= geofenceThresholdMeters) { 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 url = _normalizeUrl(targetHome['url'] as String);
final apiKey = await _getHomeApiKey(targetHome); final apiKey = await _getHomeApiKey(targetHome);
if (apiKey == null || apiKey.isEmpty) return true; if (apiKey == null || apiKey.isEmpty) {
final homeName = (targetHome['name'] ?? 'Дом') as String; runtime = runtime.recordFailure(
armedHomeId,
int groupCount = 0; failedAt: now,
try { distanceMeters: distMeters,
groupCount = await _turnOffAllGroups(url, apiKey); message: 'Не найден API key для armed geofence дома.',
} catch (_) { );
// Даже если не удалось выключить -- помечаем как сработавший, await runtimeStore.save(runtime);
// чтобы не спамить запросами return true;
} }
// 6. Помечаем как сработавший final homeName = (targetHome['name'] ?? 'Дом') as String;
await prefs.setBool(_firedKey, true);
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);
// 7. Показываем уведомление
final distText = distMeters < 1000
? '${distMeters.round()} м'
: '${(distMeters / 1000).toStringAsFixed(1)} км';
await _showNotification( await _showNotification(
title: 'Свет выключен', title: 'Свет выключен',
body: body:
'$homeName -- вы ушли на $distText. ' '$homeName -- вы ушли на ${formatDistanceMeters(distMeters)}. '
'Выключено групп: $groupCount.', 'Выключено групп: ${result.successCount}.',
); );
} catch (error) {
runtime = runtime.recordFailure(
armedHomeId,
failedAt: now,
distanceMeters: distMeters,
message: _describeFailure(error),
);
await runtimeStore.save(runtime);
}
return true; return true;
} catch (e) { } catch (_) {
// Любая ошибка -- не крашим воркер // Любая ошибка -- не крашим воркер
return true; return true;
} }
@@ -154,19 +161,6 @@ Future<String?> _getHomeApiKey(Map<String, dynamic> home) async {
return home['apiKey']?.toString(); 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); const details = NotificationDetails(android: androidDetails);
final android = plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
final notificationsEnabled = await android?.areNotificationsEnabled() ?? true;
if (!notificationsEnabled) {
return;
}
await plugin.show( await plugin.show(
42, // фиксированный id -- перезаписывает предыдущее уведомление 42, // фиксированный id -- перезаписывает предыдущее уведомление
title, title,
@@ -202,8 +205,50 @@ Future<void> _showNotification({
// ─── Внутренние хелперы ────────────────────────────────────── // ─── Внутренние хелперы ──────────────────────────────────────
/// Выключить все группы на сервере. Возвращает кол-во выключенных. Map<String, dynamic>? _findArmedHome(
Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async { 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( final dio = Dio(
BaseOptions( BaseOptions(
baseUrl: baseUrl, baseUrl: baseUrl,
@@ -214,16 +259,13 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
); );
try { try {
// Получаем список групп
final res = await dio.get('/devices/groups'); final res = await dio.get('/devices/groups');
List<String> groupIds = []; final groupIds = <String>[];
if (res.data is Map) { if (res.data is Map) {
// {id: {...}, ...} -- ключи и есть id
final map = res.data as Map; final map = res.data as Map;
for (final entry in map.entries) { for (final entry in map.entries) {
final id = entry.key.toString(); groupIds.add(entry.key.toString());
groupIds.add(id);
} }
} else if (res.data is List) { } else if (res.data is List) {
for (final g in res.data) { for (final g in res.data) {
@@ -233,7 +275,6 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
} }
} }
// Выключаем каждую группу
int success = 0; int success = 0;
await Future.wait( await Future.wait(
groupIds.map((id) async { 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 { } finally {
dio.close(); 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 /// Нормализация URL
String _normalizeUrl(String url) { String _normalizeUrl(String url) {
var u = url.trim(); 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); double _degToRad(double deg) => deg * (math.pi / 180);
class _TurnOffGroupsResult {
final int totalGroups;
final int successCount;
const _TurnOffGroupsResult({
required this.totalGroups,
required this.successCount,
});
}

View File

@@ -3,14 +3,50 @@ import 'package:flutter/material.dart';
import '../app/build_info.dart'; import '../app/build_info.dart';
class BuildInfoText extends StatelessWidget { class BuildInfoText extends StatelessWidget {
const BuildInfoText({super.key}); final bool compact;
final bool alignStart;
const BuildInfoText({
super.key,
this.compact = true,
this.alignStart = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final alignment = alignStart
? CrossAxisAlignment.start
: CrossAxisAlignment.center;
final textAlign = alignStart ? TextAlign.left : TextAlign.center;
if (compact) {
return Text( return Text(
BuildInfo.label, BuildInfo.label,
textAlign: TextAlign.center, textAlign: textAlign,
style: const TextStyle(color: Colors.white24, fontSize: 10), style: const TextStyle(color: Colors.white24, fontSize: 10),
); );
} }
return Column(
crossAxisAlignment: alignment,
mainAxisSize: MainAxisSize.min,
children: [
Text(
BuildInfo.hasMetadata ? BuildInfo.shortSha : 'build info unavailable',
textAlign: textAlign,
style: const TextStyle(
color: Colors.white54,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
BuildInfo.hasMetadata ? BuildInfo.formattedDate : '',
textAlign: textAlign,
style: const TextStyle(color: Colors.white24, fontSize: 10),
),
],
);
}
} }

View File

@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:ignis_app/features/homes/geofence_logic.dart';
import 'package:ignis_app/features/homes/providers/location_providers.dart';
void main() {
test('distance formatting stays readable across ranges', () {
expect(formatDistance(0.42), '420 м');
expect(formatDistance(2.34), '2.3 км');
expect(formatDistance(12.8), '13 км');
expect(formatDistanceMeters(450), '450 м');
expect(formatDistanceMeters(1450), '1.4 км');
});
test('distance calculation stays in realistic range', () {
final distanceKm = calculateDistanceKm(55.75, 37.61, 55.76, 37.61);
expect(distanceKm, closeTo(1.11, 0.15));
});
test('background location access requires always permission', () {
expect(hasForegroundLocationAccess(LocationPermission.whileInUse), isTrue);
expect(hasBackgroundLocationAccess(LocationPermission.whileInUse), isFalse);
expect(hasBackgroundLocationAccess(LocationPermission.always), isTrue);
});
test('retry remaining expires after cooldown window', () {
final now = DateTime(2026, 5, 1, 12, 0, 0);
final lastFailure = now.subtract(const Duration(minutes: 10));
final retryRemaining = geofenceRetryRemaining(lastFailure, now: now);
expect(retryRemaining, isNotNull);
expect(retryRemaining!.inMinutes, 20);
expect(
geofenceRetryRemaining(
now.subtract(const Duration(minutes: 31)),
now: now,
),
isNull,
);
});
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ignis_app/features/homes/services/geofence_runtime_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('runtime store persists armed home and success markers', () async {
SharedPreferences.setMockInitialValues({});
final store = GeofenceRuntimeStore();
await store.armForHome('home-1');
var runtime = await store.load();
expect(runtime.armedHomeId, 'home-1');
runtime = runtime.recordSuccess(
'home-1',
triggeredAt: DateTime(2026, 5, 1, 18, 30),
distanceMeters: 820,
);
await store.save(runtime);
final loaded = await store.load();
expect(loaded.isTriggeredFor('home-1'), isTrue);
expect(loaded.lastSuccessHomeId, 'home-1');
expect(loaded.lastDistanceMeters, 820);
});
test(
'returning into home radius rearms geofence and clears failure',
() async {
SharedPreferences.setMockInitialValues({});
final store = GeofenceRuntimeStore();
var runtime = await store.armForHome('home-1');
runtime = runtime.recordFailure(
'home-1',
failedAt: DateTime(2026, 5, 1, 19, 0),
distanceMeters: 900,
message: 'Backend умер по дороге.',
);
runtime = runtime.recordInsideHome(
'home-1',
checkedAt: DateTime(2026, 5, 1, 22, 0),
distanceMeters: 120,
);
await store.save(runtime);
final loaded = await store.load();
expect(loaded.failureAtFor('home-1'), isNull);
expect(loaded.isTriggeredFor('home-1'), isFalse);
expect(loaded.lastDistanceMeters, 120);
},
);
test(
'removing home clears armed and historical runtime for that home',
() async {
SharedPreferences.setMockInitialValues({});
final store = GeofenceRuntimeStore();
var runtime = await store.armForHome('home-1');
runtime = runtime.recordSuccess(
'home-1',
triggeredAt: DateTime(2026, 5, 1, 20, 0),
distanceMeters: 760,
);
await store.save(runtime);
await store.removeHome('home-1');
final loaded = await store.load();
expect(loaded.armedHomeId, isNull);
expect(loaded.lastSuccessHomeId, isNull);
expect(loaded.triggeredHomeId, isNull);
},
);
}