diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart index 13fd744..1c7cf66 100644 --- a/lib/app/app_bootstrap.dart +++ b/lib/app/app_bootstrap.dart @@ -59,12 +59,13 @@ class AppBootstrapNotifier extends Notifier { final home = ref.read(currentHomeProvider); if (home == null) { + await syncGeofenceTask(ref.read(homesProvider), currentHome: null); state = const AppBootstrapState.noHomes(); return; } await ref.read(authInfoProvider.notifier).load(failOnError: true); - await syncGeofenceTask(ref.read(homesProvider)); + await syncGeofenceTask(ref.read(homesProvider), currentHome: home); state = const AppBootstrapState.ready(); } catch (e) { diff --git a/lib/app/build_info.dart b/lib/app/build_info.dart index c206d1f..511d372 100644 --- a/lib/app/build_info.dart +++ b/lib/app/build_info.dart @@ -1,12 +1,35 @@ class BuildInfo { - static const String date = String.fromEnvironment( + static const String _date = String.fromEnvironment( 'IGNIS_BUILD_DATE', - defaultValue: 'dev', + defaultValue: '', ); - static const String gitSha = String.fromEnvironment( + static const String _gitSha = String.fromEnvironment( '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'; } diff --git a/lib/features/homes/geofence_logic.dart b/lib/features/homes/geofence_logic.dart new file mode 100644 index 0000000..e083b6e --- /dev/null +++ b/lib/features/homes/geofence_logic.dart @@ -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)} км'; +} diff --git a/lib/features/homes/geofence_task_sync.dart b/lib/features/homes/geofence_task_sync.dart index fb31439..7f49dc7 100644 --- a/lib/features/homes/geofence_task_sync.dart +++ b/lib/features/homes/geofence_task_sync.dart @@ -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 syncGeofenceTask(List homes) async { - final needGeofence = homes.any((h) => h.geofenceReady); +/// Геофенс работает только для текущего активного дома. +/// Если активный дом не готов -- таск снимается и runtime разоружается. +Future syncGeofenceTask( + List 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 недоступен. + } } } diff --git a/lib/features/homes/models/geofence_diagnostics.dart b/lib/features/homes/models/geofence_diagnostics.dart new file mode 100644 index 0000000..0c702e3 --- /dev/null +++ b/lib/features/homes/models/geofence_diagnostics.dart @@ -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; +} diff --git a/lib/features/homes/models/geofence_runtime_state.dart b/lib/features/homes/models/geofence_runtime_state.dart new file mode 100644 index 0000000..588f207 --- /dev/null +++ b/lib/features/homes/models/geofence_runtime_state.dart @@ -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 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 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); + } +} diff --git a/lib/features/homes/providers/geofence_providers.dart b/lib/features/homes/providers/geofence_providers.dart new file mode 100644 index 0000000..9845e92 --- /dev/null +++ b/lib/features/homes/providers/geofence_providers.dart @@ -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 + >(GeofenceDiagnosticsNotifier.new); + +class GeofenceDiagnosticsNotifier + extends Notifier> { + bool _refreshing = false; + + @override + LoadState build() { + return const LoadState.idle(GeofenceDiagnostics.initial()); + } + + Future 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 requestLocationPermission() async { + await Geolocator.requestPermission(); + await refresh(); + } + + Future requestBackgroundLocationPermission() async { + final result = await Geolocator.requestPermission(); + if (!hasBackgroundLocationAccess(result)) { + await Geolocator.openAppSettings(); + } + await refresh(); + } + + Future requestNotificationPermission() async { + await ref + .read(geofenceNotificationsServiceProvider) + .requestNotificationsPermission(); + await refresh(); + } + + Future openAppSettings() async { + await Geolocator.openAppSettings(); + } + + Future 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, + ); + } +} diff --git a/lib/features/homes/providers/homes_providers.dart b/lib/features/homes/providers/homes_providers.dart index fab6c68..6961682 100644 --- a/lib/features/homes/providers/homes_providers.dart +++ b/lib/features/homes/providers/homes_providers.dart @@ -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 { 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 { Future 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> { Future remove(String id) async { await ref.read(settingsServiceProvider).deleteHome(id); + await GeofenceRuntimeStore().removeHome(id); await load(); } diff --git a/lib/features/homes/providers/location_providers.dart b/lib/features/homes/providers/location_providers.dart index 504ae6d..912f5a1 100644 --- a/lib/features/homes/providers/location_providers.dart +++ b/lib/features/homes/providers/location_providers.dart @@ -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 { /// и отдаёт lastKnown мгновенно (если есть). Future 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 { _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 { 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 { ); _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 { } } + Future 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 requestPermission() async { + await Geolocator.requestPermission(); + if (_watchers > 0 && _sub == null) { + await startWatching(); + return; + } + await refresh(); + } + + Future openAppSettings() async { + await Geolocator.openAppSettings(); + } + + Future openLocationSettings() async { + await Geolocator.openLocationSettings(); + } + /// Проверить сервис и пермишены. Возвращает null если всё ок, /// иначе строку с причиной ошибки. - Future _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 { 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); } } diff --git a/lib/features/homes/services/geofence_notifications_service.dart b/lib/features/homes/services/geofence_notifications_service.dart new file mode 100644 index 0000000..23493cb --- /dev/null +++ b/lib/features/homes/services/geofence_notifications_service.dart @@ -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 initialize() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return; + } + + const settings = InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ); + await _plugin.initialize(settings); + } + + Future areNotificationsEnabled() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return true; + } + + try { + final android = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + return await android?.areNotificationsEnabled() ?? true; + } catch (_) { + return true; + } + } + + Future 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; + } + } +} diff --git a/lib/features/homes/services/geofence_runtime_store.dart b/lib/features/homes/services/geofence_runtime_store.dart new file mode 100644 index 0000000..9acdcc7 --- /dev/null +++ b/lib/features/homes/services/geofence_runtime_store.dart @@ -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 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) { + return const GeofenceRuntimeState(); + } + + return GeofenceRuntimeState.fromJson(decoded); + } + + Future 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 armForHome(String homeId) async { + final next = (await load()).armForHome(homeId); + await save(next); + return next; + } + + Future disarm() async { + final next = (await load()).disarm(); + await save(next); + return next; + } + + Future removeHome(String homeId) async { + final next = (await load()).removeHome(homeId); + await save(next); + return next; + } +} diff --git a/lib/main.dart b/lib/main.dart index 86e7c06..a698bbb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:workmanager/workmanager.dart'; import 'app/app_bootstrap.dart'; +import 'features/homes/services/geofence_notifications_service.dart'; import 'screens/homes_screen.dart'; import 'screens/remote_screen.dart'; import 'services/geofence_worker.dart'; @@ -21,8 +22,9 @@ void callbackDispatcher() { }); } -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); + await GeofenceNotificationsService().initialize(); // Инициализация workmanager Workmanager().initialize(callbackDispatcher); diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 6d1ccde..8baec80 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -1,6 +1,7 @@ export '../features/api_keys/providers/api_keys_providers.dart'; export '../features/auth/providers/auth_providers.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/location_providers.dart'; export '../features/remote/providers/remote_providers.dart'; diff --git a/lib/screens/home_edit_screen.dart b/lib/screens/home_edit_screen.dart index 77a3a36..fb988c1 100644 --- a/lib/screens/home_edit_screen.dart +++ b/lib/screens/home_edit_screen.dart @@ -200,7 +200,8 @@ class _HomeEditScreenState extends ConsumerState { padding: EdgeInsets.only(left: 40, bottom: 4), child: Text( 'Проверка раз в ~15 мин (ограничение Android).\n' - 'Работает в фоне, без постоянной нотификации.', + 'Работает только для текущего активного дома.\n' + 'Нужны фоновые разрешения на геолокацию и уведомления.', style: TextStyle(fontSize: 11, color: Colors.white24), ), ), @@ -327,16 +328,17 @@ class _HomeEditScreenState extends ConsumerState { // Синхронизировать фоновый таск с новыми настройками final allHomes = ref.read(homesProvider); - await syncGeofenceTask(allHomes); + await syncGeofenceTask( + allHomes, + currentHome: ref.read(currentHomeProvider), + ); if (mounted) Navigator.of(context).pop(); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - 'Не удалось сохранить дом: ${describeLoadError(e)}', - ), + content: Text('Не удалось сохранить дом: ${describeLoadError(e)}'), ), ); } diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart index 1eeafd8..9b2c58d 100644 --- a/lib/screens/homes_screen.dart +++ b/lib/screens/homes_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../app/error_message.dart'; +import '../features/homes/models/geofence_diagnostics.dart'; import '../models/home_config.dart'; import '../providers/providers.dart'; import '../widgets/build_info_text.dart'; @@ -16,7 +18,8 @@ class HomesScreen extends ConsumerStatefulWidget { ConsumerState createState() => _HomesScreenState(); } -class _HomesScreenState extends ConsumerState { +class _HomesScreenState extends ConsumerState + with WidgetsBindingObserver { late final UserLocationNotifier _userLocationNotifier; String? _switchingHomeId; String? _deletingHomeId; @@ -24,21 +27,37 @@ class _HomesScreenState extends ConsumerState { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _userLocationNotifier = ref.read(userLocationProvider.notifier); - Future.microtask(() => _userLocationNotifier.startWatching()); + Future.microtask(() async { + await _userLocationNotifier.startWatching(); + await ref.read(geofenceDiagnosticsProvider.notifier).refresh(); + }); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _userLocationNotifier.stopWatching(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + Future.microtask(_refreshEnvironmentState); + } + } + @override Widget build(BuildContext context) { final homes = ref.watch(homesProvider); final currentHome = ref.watch(currentHomeProvider); final location = ref.watch(userLocationProvider); + final geofenceState = ref.watch(geofenceDiagnosticsProvider); + final activeDistanceKm = currentHome == null + ? null + : location.distanceToKm(currentHome.latitude, currentHome.longitude); return Scaffold( appBar: AppBar( @@ -50,87 +69,110 @@ class _HomesScreenState extends ConsumerState { Expanded( child: homes.isEmpty ? const _EmptyHomesView() - : ListView.builder( + : ListView( padding: const EdgeInsets.all(12), - itemCount: homes.length, - itemBuilder: (context, index) { - final home = homes[index]; - final isActive = currentHome?.id == home.id; - final isSwitching = _switchingHomeId == home.id; - final isDeleting = _deletingHomeId == home.id; - final isBusy = isSwitching || isDeleting; - final distKm = location.distanceToKm( - home.latitude, - home.longitude, - ); + children: [ + _HomesOverviewCard( + 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 isSwitching = _switchingHomeId == home.id; + final isDeleting = _deletingHomeId == home.id; + final isBusy = isSwitching || isDeleting; + final distKm = location.distanceToKm( + home.latitude, + home.longitude, + ); - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - enabled: !isBusy, - leading: Icon( - Icons.home, - color: isActive - ? Colors.deepOrange - : Colors.white38, - size: 28, - ), - title: Text( - home.name, - style: TextStyle( - fontWeight: isActive - ? FontWeight.bold - : FontWeight.normal, + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + enabled: !isBusy, + leading: Icon( + Icons.home, color: isActive ? Colors.deepOrange - : Colors.white, + : Colors.white38, + size: 28, ), - ), - subtitle: _HomeSubtitle( - home: home, - location: location, - distKm: distKm, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isBusy) - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + title: Text( + home.name, + style: TextStyle( + fontWeight: isActive + ? FontWeight.bold + : FontWeight.normal, + color: isActive + ? Colors.deepOrange + : Colors.white, + ), + ), + subtitle: _HomeSubtitle( + home: home, + location: location, + distKm: distKm, + isActive: isActive, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isBusy) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + else ...[ + IconButton( + icon: const Icon( + Icons.edit, + size: 20, + color: Colors.white38, + ), + onPressed: () => _editHome(context, home), ), - ) - else ...[ - IconButton( - icon: const Icon( - Icons.edit, - size: 20, - color: Colors.white38, + IconButton( + icon: const Icon( + Icons.delete_outline, + size: 20, + color: Colors.redAccent, + ), + onPressed: () => + _confirmDelete(context, home), ), - onPressed: () => _editHome(context, home), - ), - IconButton( - icon: const Icon( - Icons.delete_outline, - size: 20, - color: Colors.redAccent, - ), - onPressed: () => _confirmDelete(context, home), - ), + ], ], - ], + ), + onTap: isBusy + ? null + : () => _selectHome(context, home), ), - onTap: isBusy ? null : () => _selectHome(context, home), - ), - ); - }, + ); + }), + ], ), ), - const Padding( - padding: EdgeInsets.only(bottom: 10), - child: BuildInfoText(), + const SafeArea( + top: false, + minimum: EdgeInsets.only(bottom: 10), + child: Padding( + padding: EdgeInsets.only(bottom: 6), + child: BuildInfoText(), + ), ), ], ), @@ -158,9 +200,7 @@ class _HomesScreenState extends ConsumerState { if (context.mounted) { messenger.showSnackBar( SnackBar( - content: Text( - 'Не удалось выбрать дом: ${describeLoadError(e)}', - ), + content: Text('Не удалось выбрать дом: ${describeLoadError(e)}'), ), ); } @@ -171,16 +211,18 @@ class _HomesScreenState extends ConsumerState { } } - void _addHome(BuildContext context) { - Navigator.of( + Future _addHome(BuildContext context) async { + await Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const HomeEditScreen())); + await _refreshEnvironmentState(); } - void _editHome(BuildContext context, HomeConfig home) { - Navigator.of( + Future _editHome(BuildContext context, HomeConfig home) async { + await Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home))); + await _refreshEnvironmentState(); } void _confirmDelete(BuildContext context, HomeConfig home) { @@ -222,14 +264,16 @@ class _HomesScreenState extends ConsumerState { if (deletedCurrentHome) { ref.read(authInfoProvider.notifier).clear(); } - await syncGeofenceTask(ref.read(homesProvider)); + await syncGeofenceTask( + ref.read(homesProvider), + currentHome: ref.read(currentHomeProvider), + ); + await _refreshEnvironmentState(); } catch (e) { if (context.mounted) { messenger.showSnackBar( SnackBar( - content: Text( - 'Не удалось удалить дом: ${describeLoadError(e)}', - ), + content: Text('Не удалось удалить дом: ${describeLoadError(e)}'), ), ); } @@ -239,6 +283,45 @@ class _HomesScreenState extends ConsumerState { } } } + + Future _refreshEnvironmentState() async { + await _userLocationNotifier.refresh(); + await _refreshGeofenceDiagnostics(); + } + + Future _refreshGeofenceDiagnostics() async { + await ref.read(geofenceDiagnosticsProvider.notifier).refresh(); + } + + Future _requestLocationPermission() async { + await _userLocationNotifier.requestPermission(); + await _refreshGeofenceDiagnostics(); + } + + Future _openLocationSettings() async { + await _userLocationNotifier.openLocationSettings(); + } + + Future _requestGeofenceBackgroundPermission() async { + await ref + .read(geofenceDiagnosticsProvider.notifier) + .requestBackgroundLocationPermission(); + await _userLocationNotifier.refresh(); + } + + Future _requestGeofenceNotificationPermission() async { + await ref + .read(geofenceDiagnosticsProvider.notifier) + .requestNotificationPermission(); + } + + Future _openRelevantAppSettings() async { + if (ref.read(userLocationProvider).needsAppSettings) { + await _userLocationNotifier.openAppSettings(); + return; + } + await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings(); + } } class _EmptyHomesView extends StatelessWidget { @@ -271,11 +354,13 @@ class _HomeSubtitle extends StatelessWidget { final HomeConfig home; final UserLocation location; final double? distKm; + final bool isActive; const _HomeSubtitle({ required this.home, required this.location, required this.distKm, + required this.isActive, }); @override @@ -302,17 +387,270 @@ class _HomeSubtitle extends StatelessWidget { ), ) else if (home.hasCoordinates && !location.hasPosition) - Row( - children: [ - const Icon(Icons.location_on, size: 12, color: Colors.white24), - const SizedBox(width: 4), - Text( - location.error ?? 'Координаты заданы', - style: const TextStyle(color: Colors.white24, fontSize: 11), - ), - ], + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + children: [ + const Icon(Icons.location_on, size: 12, color: Colors.white24), + const SizedBox(width: 4), + Text( + location.error ?? 'Координаты заданы', + style: const TextStyle(color: Colors.white24, fontSize: 11), + ), + ], + ), + ) + 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 Function() onRefresh; + final Future Function() onRequestLocationPermission; + final Future Function() onRequestBackgroundPermission; + final Future Function() onRequestNotificationsPermission; + final Future Function() onOpenAppSettings; + final Future 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 = []; + + 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'; +} diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart index 2f05d32..f4b5186 100644 --- a/lib/screens/remote_screen.dart +++ b/lib/screens/remote_screen.dart @@ -126,7 +126,7 @@ class _RemoteScreenState extends ConsumerState { enabled: false, child: Padding( padding: EdgeInsets.symmetric(vertical: 4), - child: BuildInfoText(), + child: BuildInfoText(compact: false, alignStart: true), ), ), ], diff --git a/lib/services/geofence_worker.dart b/lib/services/geofence_worker.dart index a82f31b..03798f5 100644 --- a/lib/services/geofence_worker.dart +++ b/lib/services/geofence_worker.dart @@ -1,16 +1,15 @@ import 'dart:convert'; import 'dart:math' as math; + import 'package:dio/dio.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; -/// Порог расстояния для срабатывания геофенса (метры) -const double geofenceThresholdMeters = 500.0; +import '../features/homes/geofence_logic.dart'; +import '../features/homes/services/geofence_runtime_store.dart'; -/// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать -const String _firedKey = 'ignis_geofence_fired'; const String _apiKeyPrefix = 'ignis_home_api_key_'; /// Имя задачи в workmanager @@ -29,70 +28,35 @@ const String _channelDesc = 'Уведомления об автовыключе /// Возвращает true если таск выполнен успешно (workmanager convention). Future executeGeofenceCheck() async { try { - // 1. Проверяем, не сработал ли уже - final prefs = await SharedPreferences.getInstance(); - if (prefs.getBool(_firedKey) == true) { - // Уже сработал -- ничего не делаем. - // Таск будет отменён при следующем запуске приложения. + final runtimeStore = GeofenceRuntimeStore(); + var runtime = await runtimeStore.load(); + final armedHomeId = runtime.armedHomeId; + if (armedHomeId == null || armedHomeId.isEmpty) { return true; } - // 2. Загружаем дома из SharedPreferences + final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('ignis_homes'); if (raw == null || raw.isEmpty) return true; final List homesList = jsonDecode(raw); - final currentHomeId = prefs.getString('ignis_current_home_id'); - - // Ищем текущий дом с включённым геофенсом - Map? targetHome; - for (final h in homesList) { - final map = h as Map; - final isTarget = (currentHomeId != null) - ? map['id'] == currentHomeId - : true; // если нет текущего -- берём первый подходящий - if (isTarget && - map['geofenceEnabled'] == true && - map['latitude'] != null && - map['longitude'] != null) { - targetHome = map; - break; - } + final targetHome = _findArmedHome(homesList, armedHomeId); + if (targetHome == null) { + await runtimeStore.disarm(); + return true; } - if (targetHome == null) return true; // нет дома с геофенсом - - // 3. Получаем текущую позицию if (!await Geolocator.isLocationServiceEnabled()) return true; final perm = await Geolocator.checkPermission(); - if (perm == LocationPermission.denied || - perm == LocationPermission.deniedForever) { - return true; // нет пермишена -- молча выходим + if (!hasBackgroundLocationAccess(perm)) { + return true; } - Position? pos; - try { - // Сначала lastKnown (мгновенно) - pos = await Geolocator.getLastKnownPosition(); - final lastTimestamp = pos?.timestamp; + final pos = await _getCurrentPosition(); + if (pos == null) return true; - // Если позиции нет или она несвежая -- запрашиваем новую - if (pos == null || - lastTimestamp == null || - DateTime.now().difference(lastTimestamp).inMinutes > 5) { - pos = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.low, - timeLimit: Duration(seconds: 15), - ), - ); - } - } catch (_) { - return true; // не удалось получить позицию - } - - // 4. Считаем расстояние + final now = DateTime.now(); final homeLat = (targetHome['latitude'] as num).toDouble(); final homeLon = (targetHome['longitude'] as num).toDouble(); final distMeters = _haversineMeters( @@ -103,39 +67,82 @@ Future executeGeofenceCheck() async { ); if (distMeters <= geofenceThresholdMeters) { - return true; // всё ещё рядом с домом + runtime = runtime.recordInsideHome( + armedHomeId, + checkedAt: now, + distanceMeters: distMeters, + ); + await runtimeStore.save(runtime); + return true; + } + + runtime = runtime.recordOutsideCheck( + armedHomeId, + checkedAt: now, + distanceMeters: distMeters, + ); + await runtimeStore.save(runtime); + + if (runtime.isTriggeredFor(armedHomeId)) { + return true; + } + + final retryRemaining = geofenceRetryRemaining( + runtime.failureAtFor(armedHomeId), + now: now, + ); + if (retryRemaining != null) { + return true; } - // 5. Ушли за порог -- выключаем все группы final url = _normalizeUrl(targetHome['url'] as String); final apiKey = await _getHomeApiKey(targetHome); - if (apiKey == null || apiKey.isEmpty) return true; - final homeName = (targetHome['name'] ?? 'Дом') as String; - - int groupCount = 0; - try { - groupCount = await _turnOffAllGroups(url, apiKey); - } catch (_) { - // Даже если не удалось выключить -- помечаем как сработавший, - // чтобы не спамить запросами + if (apiKey == null || apiKey.isEmpty) { + runtime = runtime.recordFailure( + armedHomeId, + failedAt: now, + distanceMeters: distMeters, + message: 'Не найден API key для armed geofence дома.', + ); + await runtimeStore.save(runtime); + return true; } - // 6. Помечаем как сработавший - await prefs.setBool(_firedKey, true); + final homeName = (targetHome['name'] ?? 'Дом') as String; - // 7. Показываем уведомление - final distText = distMeters < 1000 - ? '${distMeters.round()} м' - : '${(distMeters / 1000).toStringAsFixed(1)} км'; - await _showNotification( - title: 'Свет выключен', - body: - '$homeName -- вы ушли на $distText. ' - 'Выключено групп: $groupCount.', - ); + try { + final result = await _turnOffAllGroups(url, apiKey); + if (result.totalGroups > 0 && result.successCount < result.totalGroups) { + throw StateError( + 'Выключено только ${result.successCount} из ${result.totalGroups} групп.', + ); + } + + runtime = runtime.recordSuccess( + armedHomeId, + triggeredAt: now, + distanceMeters: distMeters, + ); + await runtimeStore.save(runtime); + + await _showNotification( + title: 'Свет выключен', + body: + '$homeName -- вы ушли на ${formatDistanceMeters(distMeters)}. ' + 'Выключено групп: ${result.successCount}.', + ); + } catch (error) { + runtime = runtime.recordFailure( + armedHomeId, + failedAt: now, + distanceMeters: distMeters, + message: _describeFailure(error), + ); + await runtimeStore.save(runtime); + } return true; - } catch (e) { + } catch (_) { // Любая ошибка -- не крашим воркер return true; } @@ -154,19 +161,6 @@ Future _getHomeApiKey(Map home) async { return home['apiKey']?.toString(); } -/// Сбросить флаг "сработал" -- вызывать при включении геофенса -/// или при возврате в приложение. -Future resetGeofenceFired() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_firedKey); -} - -/// Проверить, сработал ли геофенс -Future isGeofenceFired() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_firedKey) == true; -} - // ─── Уведомления ───────────────────────────────────────────── /// Показать локальное уведомление из фонового изолята @@ -192,6 +186,15 @@ Future _showNotification({ const details = NotificationDetails(android: androidDetails); + final android = plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + final notificationsEnabled = await android?.areNotificationsEnabled() ?? true; + if (!notificationsEnabled) { + return; + } + await plugin.show( 42, // фиксированный id -- перезаписывает предыдущее уведомление title, @@ -202,8 +205,50 @@ Future _showNotification({ // ─── Внутренние хелперы ────────────────────────────────────── -/// Выключить все группы на сервере. Возвращает кол-во выключенных. -Future _turnOffAllGroups(String baseUrl, String apiKey) async { +Map? _findArmedHome( + List homesList, + String armedHomeId, +) { + for (final item in homesList) { + if (item is! Map) continue; + final map = Map.from(item); + if (map['id'] == armedHomeId && + map['geofenceEnabled'] == true && + map['latitude'] != null && + map['longitude'] != null) { + return map; + } + } + return null; +} + +Future _getCurrentPosition() async { + try { + var pos = await Geolocator.getLastKnownPosition(); + final lastTimestamp = pos?.timestamp; + + if (pos == null || + lastTimestamp == null || + DateTime.now().difference(lastTimestamp).inMinutes > 5) { + pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 15), + ), + ); + } + + return pos; + } catch (_) { + return null; + } +} + +/// Выключить все группы на сервере. +Future<_TurnOffGroupsResult> _turnOffAllGroups( + String baseUrl, + String apiKey, +) async { final dio = Dio( BaseOptions( baseUrl: baseUrl, @@ -214,16 +259,13 @@ Future _turnOffAllGroups(String baseUrl, String apiKey) async { ); try { - // Получаем список групп final res = await dio.get('/devices/groups'); - List groupIds = []; + final groupIds = []; if (res.data is Map) { - // {id: {...}, ...} -- ключи и есть id final map = res.data as Map; for (final entry in map.entries) { - final id = entry.key.toString(); - groupIds.add(id); + groupIds.add(entry.key.toString()); } } else if (res.data is List) { for (final g in res.data) { @@ -233,7 +275,6 @@ Future _turnOffAllGroups(String baseUrl, String apiKey) async { } } - // Выключаем каждую группу int success = 0; await Future.wait( groupIds.map((id) async { @@ -249,12 +290,28 @@ Future _turnOffAllGroups(String baseUrl, String apiKey) async { }), ); - return success; + return _TurnOffGroupsResult( + totalGroups: groupIds.length, + successCount: success, + ); } finally { dio.close(); } } +String _describeFailure(Object error) { + if (error is DioException) { + final statusCode = error.response?.statusCode; + final detail = error.response?.data; + if (statusCode != null) { + return 'Backend ответил ошибкой $statusCode${detail != null ? ': $detail' : ''}'; + } + return 'Сетевой запрос сломался: ${error.message ?? error.type.name}'; + } + + return error.toString(); +} + /// Нормализация URL String _normalizeUrl(String url) { var u = url.trim(); @@ -279,3 +336,13 @@ double _haversineMeters(double lat1, double lon1, double lat2, double lon2) { } double _degToRad(double deg) => deg * (math.pi / 180); + +class _TurnOffGroupsResult { + final int totalGroups; + final int successCount; + + const _TurnOffGroupsResult({ + required this.totalGroups, + required this.successCount, + }); +} diff --git a/lib/widgets/build_info_text.dart b/lib/widgets/build_info_text.dart index d85d025..e2f24a7 100644 --- a/lib/widgets/build_info_text.dart +++ b/lib/widgets/build_info_text.dart @@ -3,14 +3,50 @@ import 'package:flutter/material.dart'; import '../app/build_info.dart'; 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 Widget build(BuildContext context) { - return Text( - BuildInfo.label, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white24, fontSize: 10), + final alignment = alignStart + ? CrossAxisAlignment.start + : CrossAxisAlignment.center; + final textAlign = alignStart ? TextAlign.left : TextAlign.center; + + if (compact) { + return Text( + BuildInfo.label, + textAlign: textAlign, + 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), + ), + ], ); } } diff --git a/test/geofence_logic_test.dart b/test/geofence_logic_test.dart new file mode 100644 index 0000000..5f911ff --- /dev/null +++ b/test/geofence_logic_test.dart @@ -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, + ); + }); +} diff --git a/test/geofence_runtime_store_test.dart b/test/geofence_runtime_store_test.dart new file mode 100644 index 0000000..37558b7 --- /dev/null +++ b/test/geofence_runtime_store_test.dart @@ -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); + }, + ); +}