Replace geofence polling with native Android geofence
This commit is contained in:
@@ -59,13 +59,11 @@ class AppBootstrapNotifier extends Notifier<AppBootstrapState> {
|
||||
|
||||
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), currentHome: home);
|
||||
|
||||
state = const AppBootstrapState.ready();
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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)} км';
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
import '../../models/home_config.dart';
|
||||
import '../../services/geofence_worker.dart';
|
||||
import 'services/geofence_runtime_store.dart';
|
||||
|
||||
/// Синхронизировать состояние фонового таска с настройками домов.
|
||||
/// Вызывать при старте приложения и при изменении настроек.
|
||||
///
|
||||
/// Геофенс работает только для текущего активного дома.
|
||||
/// Если активный дом не готов -- таск снимается и runtime разоружается.
|
||||
Future<void> syncGeofenceTask(
|
||||
List<HomeConfig> homes, {
|
||||
required HomeConfig? currentHome,
|
||||
}) async {
|
||||
final runtimeStore = GeofenceRuntimeStore();
|
||||
final activeHome = currentHome;
|
||||
final needGeofence =
|
||||
activeHome != null &&
|
||||
homes.any((home) => home.id == activeHome.id) &&
|
||||
activeHome.geofenceReady;
|
||||
|
||||
if (needGeofence) {
|
||||
await runtimeStore.armForHome(activeHome.id);
|
||||
|
||||
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 runtimeStore.disarm();
|
||||
try {
|
||||
await Workmanager().cancelByUniqueName(geofenceTaskUniqueName);
|
||||
} catch (_) {
|
||||
// См. комментарий выше: runtime должен синхронизироваться
|
||||
// даже если platform plugin недоступен.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../../../app/load_state.dart';
|
||||
import '../../../models/home_config.dart';
|
||||
import '../geofence_logic.dart';
|
||||
import '../models/geofence_diagnostics.dart';
|
||||
import '../models/geofence_runtime_state.dart';
|
||||
import '../services/geofence_notifications_service.dart';
|
||||
import '../services/geofence_runtime_store.dart';
|
||||
import 'homes_providers.dart';
|
||||
|
||||
final geofenceRuntimeStoreProvider = Provider((ref) => GeofenceRuntimeStore());
|
||||
|
||||
final geofenceNotificationsServiceProvider = Provider(
|
||||
(ref) => GeofenceNotificationsService(),
|
||||
);
|
||||
|
||||
final geofenceDiagnosticsProvider =
|
||||
NotifierProvider<
|
||||
GeofenceDiagnosticsNotifier,
|
||||
LoadState<GeofenceDiagnostics>
|
||||
>(GeofenceDiagnosticsNotifier.new);
|
||||
|
||||
class GeofenceDiagnosticsNotifier
|
||||
extends Notifier<LoadState<GeofenceDiagnostics>> {
|
||||
bool _refreshing = false;
|
||||
|
||||
@override
|
||||
LoadState<GeofenceDiagnostics> build() {
|
||||
return const LoadState.idle(GeofenceDiagnostics.initial());
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
|
||||
final previous = state.data;
|
||||
state = LoadState.loading(previous);
|
||||
|
||||
try {
|
||||
final currentHome = ref.read(currentHomeProvider);
|
||||
final runtime = await ref.read(geofenceRuntimeStoreProvider).load();
|
||||
|
||||
if (currentHome == null) {
|
||||
state = LoadState.data(
|
||||
GeofenceDiagnostics(
|
||||
activeHome: null,
|
||||
status: GeofenceStatusKind.noActiveHome,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: LocationPermission.denied,
|
||||
notificationsEnabled: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHome.geofenceEnabled) {
|
||||
state = LoadState.data(
|
||||
GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.disabled,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: LocationPermission.denied,
|
||||
notificationsEnabled: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHome.hasCoordinates) {
|
||||
state = LoadState.data(
|
||||
GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.missingCoordinates,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: LocationPermission.denied,
|
||||
notificationsEnabled: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final locationServicesEnabled =
|
||||
await Geolocator.isLocationServiceEnabled();
|
||||
final locationPermission = await Geolocator.checkPermission();
|
||||
final notificationsEnabled = await ref
|
||||
.read(geofenceNotificationsServiceProvider)
|
||||
.areNotificationsEnabled();
|
||||
|
||||
final diagnostics = _buildDiagnostics(
|
||||
currentHome: currentHome,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: locationServicesEnabled,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
state = LoadState.data(diagnostics);
|
||||
} catch (error) {
|
||||
state = LoadState.error(
|
||||
previous,
|
||||
'Не удалось обновить состояние geofence: $error',
|
||||
);
|
||||
} finally {
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestLocationPermission() async {
|
||||
await Geolocator.requestPermission();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> requestBackgroundLocationPermission() async {
|
||||
final result = await Geolocator.requestPermission();
|
||||
if (!hasBackgroundLocationAccess(result)) {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> requestNotificationPermission() async {
|
||||
await ref
|
||||
.read(geofenceNotificationsServiceProvider)
|
||||
.requestNotificationsPermission();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() async {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
|
||||
Future<void> openLocationSettings() async {
|
||||
await Geolocator.openLocationSettings();
|
||||
}
|
||||
|
||||
GeofenceDiagnostics _buildDiagnostics({
|
||||
required HomeConfig currentHome,
|
||||
required GeofenceRuntimeState runtime,
|
||||
required bool locationServicesEnabled,
|
||||
required LocationPermission locationPermission,
|
||||
required bool notificationsEnabled,
|
||||
}) {
|
||||
if (!locationServicesEnabled) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.locationServicesDisabled,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: false,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasForegroundLocationAccess(locationPermission)) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.locationPermissionDenied,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasBackgroundLocationAccess(locationPermission)) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.backgroundPermissionDenied,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (!notificationsEnabled) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.notificationsPermissionDenied,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: false,
|
||||
);
|
||||
}
|
||||
|
||||
final failureAt = runtime.failureAtFor(currentHome.id);
|
||||
final retryRemaining = geofenceRetryRemaining(failureAt);
|
||||
if (retryRemaining != null) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.cooldown,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: true,
|
||||
retryRemaining: retryRemaining,
|
||||
detail: runtime.failureMessageFor(currentHome.id),
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.isTriggeredFor(currentHome.id)) {
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.triggered,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: true,
|
||||
);
|
||||
}
|
||||
|
||||
return GeofenceDiagnostics(
|
||||
activeHome: currentHome,
|
||||
status: GeofenceStatusKind.ready,
|
||||
runtime: runtime,
|
||||
locationServicesEnabled: true,
|
||||
locationPermission: locationPermission,
|
||||
notificationsEnabled: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -22,6 +20,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
if (state != null) {
|
||||
await _initApi(state!);
|
||||
}
|
||||
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
|
||||
}
|
||||
|
||||
/// Переключиться на другой дом
|
||||
@@ -30,6 +29,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
await svc.setCurrentHomeId(home.id);
|
||||
state = home;
|
||||
await _initApi(home);
|
||||
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
|
||||
}
|
||||
|
||||
/// Выбрать дом как активный и сразу проверить auth-state.
|
||||
@@ -41,11 +41,9 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
try {
|
||||
await switchTo(home);
|
||||
await ref.read(authInfoProvider.notifier).load(failOnError: true);
|
||||
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
|
||||
} catch (error) {
|
||||
await _restoreSelection(previousHome);
|
||||
ref.read(authInfoProvider.notifier).restore(previousAuthState);
|
||||
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +51,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
Future<void> clear() async {
|
||||
await ref.read(settingsServiceProvider).setCurrentHomeId(null);
|
||||
state = null;
|
||||
await syncGeofenceTask(ref.read(homesProvider), currentHome: null);
|
||||
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(null);
|
||||
}
|
||||
|
||||
/// Инициализировать API-клиент текущим домом
|
||||
@@ -74,6 +72,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
await svc.setCurrentHomeId(home.id);
|
||||
state = home;
|
||||
await _initApi(home);
|
||||
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +95,6 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
await ref.read(settingsServiceProvider).deleteHome(id);
|
||||
await GeofenceRuntimeStore().removeHome(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
|
||||
40
lib/features/homes/services/geofence_automation_service.dart
Normal file
40
lib/features/homes/services/geofence_automation_service.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../models/home_config.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
|
||||
class GeofenceAutomationService {
|
||||
GeofenceAutomationService({SettingsService? settingsService})
|
||||
: _settingsService = settingsService ?? SettingsService();
|
||||
|
||||
static const _channel = MethodChannel('ignis/geofence_automation');
|
||||
|
||||
final SettingsService _settingsService;
|
||||
|
||||
Future<void> syncActiveHome(HomeConfig? home) async {
|
||||
if (home == null || !home.geofenceReady) {
|
||||
await _invoke('disarmGeofence');
|
||||
return;
|
||||
}
|
||||
|
||||
final apiKey = await _settingsService.requireHomeApiKey(home.id);
|
||||
await _invoke('armGeofence', {
|
||||
'homeId': home.id,
|
||||
'baseUrl': home.url,
|
||||
'apiKey': apiKey,
|
||||
'latitude': home.latitude,
|
||||
'longitude': home.longitude,
|
||||
'radiusMeters': home.geofenceRadiusMeters,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _invoke(String method, [Map<String, Object?>? arguments]) async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>(method, arguments);
|
||||
} on MissingPluginException {
|
||||
// В тестах и на не-Android средах platform channel может отсутствовать.
|
||||
} on PlatformException {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import '../../../services/api_client.dart';
|
||||
import '../../homes/providers/homes_providers.dart';
|
||||
import '../../shared/providers/core_providers.dart';
|
||||
|
||||
final remotePollingEnabledProvider = Provider<bool>((ref) => true);
|
||||
|
||||
final groupsProvider = NotifierProvider<GroupsNotifier, List<IgnisGroup>>(
|
||||
() => GroupsNotifier(),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../homes/services/geofence_automation_service.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
|
||||
@@ -8,3 +9,9 @@ final settingsServiceProvider = Provider((ref) => SettingsService());
|
||||
|
||||
/// API-клиент текущего дома. Конфигурация меняется через init().
|
||||
final apiProvider = Provider((ref) => IgnisApi());
|
||||
|
||||
/// Нативная geofence-автоматика Android.
|
||||
final geofenceAutomationServiceProvider = Provider(
|
||||
(ref) =>
|
||||
GeofenceAutomationService(settingsService: ref.read(settingsServiceProvider)),
|
||||
);
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
import 'dart:ui';
|
||||
|
||||
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';
|
||||
|
||||
/// Top-level callback для workmanager (выполняется в отдельном изоляте).
|
||||
@pragma('vm:entry-point')
|
||||
void callbackDispatcher() {
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
Workmanager().executeTask((taskName, inputData) async {
|
||||
if (taskName == geofenceTaskName) {
|
||||
return await executeGeofenceCheck();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await GeofenceNotificationsService().initialize();
|
||||
|
||||
// Инициализация workmanager
|
||||
Workmanager().initialize(callbackDispatcher);
|
||||
|
||||
runApp(const ProviderScope(child: IgnisApp()));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/// Модель "дома" -- один физический сервер Ignis.
|
||||
/// Содержит только несекретные настройки. API-ключ хранится отдельно.
|
||||
class HomeConfig {
|
||||
static const int defaultGeofenceRadiusMeters = 500;
|
||||
|
||||
final String id; // уникальный идентификатор (uuid или timestamp)
|
||||
final String name; // человекочитаемое название ("Квартира", "Дача")
|
||||
final String url; // адрес сервера (например ignis.akokos.ru)
|
||||
final double? latitude; // GPS-широта дома (для гео-автоматизации)
|
||||
final double? longitude; // GPS-долгота дома (для гео-автоматизации)
|
||||
final bool geofenceEnabled; // автовыключение при уходе из дома
|
||||
final int geofenceRadiusMeters; // радиус geofence для автодействий
|
||||
|
||||
HomeConfig({
|
||||
required this.id,
|
||||
@@ -15,6 +18,7 @@ class HomeConfig {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.geofenceEnabled = false,
|
||||
this.geofenceRadiusMeters = defaultGeofenceRadiusMeters,
|
||||
});
|
||||
|
||||
/// Есть ли координаты у дома
|
||||
@@ -31,6 +35,7 @@ class HomeConfig {
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
'geofenceEnabled': geofenceEnabled,
|
||||
'geofenceRadiusMeters': geofenceRadiusMeters,
|
||||
};
|
||||
|
||||
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
|
||||
@@ -40,6 +45,10 @@ class HomeConfig {
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
geofenceEnabled: json['geofenceEnabled'] as bool? ?? false,
|
||||
geofenceRadiusMeters:
|
||||
((json['geofenceRadiusMeters'] as num?)?.toInt() ??
|
||||
defaultGeofenceRadiusMeters)
|
||||
.clamp(100, 5000),
|
||||
);
|
||||
|
||||
/// Копирование с изменениями
|
||||
@@ -49,6 +58,7 @@ class HomeConfig {
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
bool? geofenceEnabled,
|
||||
int? geofenceRadiusMeters,
|
||||
bool clearCoordinates = false,
|
||||
}) => HomeConfig(
|
||||
id: id,
|
||||
@@ -60,5 +70,6 @@ class HomeConfig {
|
||||
geofenceEnabled: clearCoordinates
|
||||
? false
|
||||
: (geofenceEnabled ?? this.geofenceEnabled),
|
||||
geofenceRadiusMeters: geofenceRadiusMeters ?? this.geofenceRadiusMeters,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -22,6 +22,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
final _keyCtrl = TextEditingController();
|
||||
final _latCtrl = TextEditingController();
|
||||
final _lonCtrl = TextEditingController();
|
||||
final _radiusCtrl = TextEditingController();
|
||||
bool _geofenceEnabled = false;
|
||||
bool _saving = false;
|
||||
|
||||
@@ -43,8 +44,11 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
if (widget.home!.longitude != null) {
|
||||
_lonCtrl.text = widget.home!.longitude.toString();
|
||||
}
|
||||
_radiusCtrl.text = widget.home!.geofenceRadiusMeters.toString();
|
||||
_geofenceEnabled = widget.home!.geofenceEnabled;
|
||||
_loadApiKey();
|
||||
} else {
|
||||
_radiusCtrl.text = HomeConfig.defaultGeofenceRadiusMeters.toString();
|
||||
}
|
||||
|
||||
// Следим за полями координат чтобы обновлять доступность Switch
|
||||
@@ -79,6 +83,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
_keyCtrl.dispose();
|
||||
_latCtrl.dispose();
|
||||
_lonCtrl.dispose();
|
||||
_radiusCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -224,12 +229,34 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _radiusCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Радиус geofence, м',
|
||||
hintText: '500',
|
||||
helperText: 'Автовыключение сработает после выхода за этот радиус',
|
||||
prefixIcon: Icon(Icons.radar),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
final normalized = value?.trim() ?? '';
|
||||
final radius = int.tryParse(normalized);
|
||||
if (radius == null) {
|
||||
return 'Введите радиус в метрах';
|
||||
}
|
||||
if (radius < 100 || radius > 5000) {
|
||||
return 'От 100 до 5000 м';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Выключать свет при уходе'),
|
||||
subtitle: Text(
|
||||
_hasCoordinates
|
||||
? 'Автовыключение при удалении на 500 м'
|
||||
? 'Автовыключение после выхода за радиус geofence'
|
||||
: 'Задайте координаты для активации',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
@@ -253,9 +280,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 40, bottom: 4),
|
||||
child: Text(
|
||||
'Проверка раз в ~15 мин (ограничение Android).\n'
|
||||
'Работает только для текущего активного дома.\n'
|
||||
'Нужны фоновые разрешения на геолокацию и уведомления.',
|
||||
'Использует системный Android geofence, а не polling.\n'
|
||||
'Нужны фоновые разрешения на геолокацию.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white24),
|
||||
),
|
||||
),
|
||||
@@ -300,8 +327,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
final key = _keyCtrl.text.trim();
|
||||
final latText = _latCtrl.text.trim();
|
||||
final lonText = _lonCtrl.text.trim();
|
||||
final radiusText = _radiusCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty) {
|
||||
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty || radiusText.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Заполните все обязательные поля')),
|
||||
);
|
||||
@@ -348,6 +376,14 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
final radiusMeters = int.tryParse(radiusText);
|
||||
if (radiusMeters == null || radiusMeters < 100 || radiusMeters > 5000) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Радиус geofence должен быть от 100 до 5000 м')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
|
||||
final clearCoords = latText.isEmpty && lonText.isEmpty;
|
||||
@@ -359,6 +395,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
geofenceEnabled: clearCoords ? false : _geofenceEnabled,
|
||||
geofenceRadiusMeters: radiusMeters,
|
||||
clearCoordinates: clearCoords,
|
||||
)
|
||||
: HomeConfig(
|
||||
@@ -368,6 +405,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
geofenceEnabled: _geofenceEnabled,
|
||||
geofenceRadiusMeters: radiusMeters,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -384,13 +422,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
await ref.read(currentHomeProvider.notifier).select(home);
|
||||
}
|
||||
|
||||
// Синхронизировать фоновый таск с новыми настройками
|
||||
final allHomes = ref.read(homesProvider);
|
||||
await syncGeofenceTask(
|
||||
allHomes,
|
||||
currentHome: ref.read(currentHomeProvider),
|
||||
);
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -21,6 +20,7 @@ class HomesScreen extends ConsumerStatefulWidget {
|
||||
class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||
with WidgetsBindingObserver {
|
||||
late final UserLocationNotifier _userLocationNotifier;
|
||||
bool _isWatchingLocation = false;
|
||||
String? _switchingHomeId;
|
||||
String? _deletingHomeId;
|
||||
|
||||
@@ -30,15 +30,17 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_userLocationNotifier = ref.read(userLocationProvider.notifier);
|
||||
Future.microtask(() async {
|
||||
await _userLocationNotifier.startWatching();
|
||||
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
|
||||
await _syncLocationWatching();
|
||||
await _syncGeofenceAutomation();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_userLocationNotifier.stopWatching();
|
||||
if (_isWatchingLocation) {
|
||||
_userLocationNotifier.stopWatching();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -54,10 +56,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||
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(
|
||||
@@ -69,101 +67,90 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||
Expanded(
|
||||
child: homes.isEmpty
|
||||
? const _EmptyHomesView()
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
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,
|
||||
);
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: _refreshEnvironmentState,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
...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,
|
||||
isActive: isActive,
|
||||
),
|
||||
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 SafeArea(
|
||||
@@ -264,10 +251,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||
if (deletedCurrentHome) {
|
||||
ref.read(authInfoProvider.notifier).clear();
|
||||
}
|
||||
await syncGeofenceTask(
|
||||
ref.read(homesProvider),
|
||||
currentHome: ref.read(currentHomeProvider),
|
||||
);
|
||||
await _refreshEnvironmentState();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
@@ -285,42 +268,33 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||
}
|
||||
|
||||
Future<void> _refreshEnvironmentState() async {
|
||||
await _userLocationNotifier.refresh();
|
||||
await _refreshGeofenceDiagnostics();
|
||||
await _syncLocationWatching();
|
||||
if (_isWatchingLocation) {
|
||||
await _userLocationNotifier.refresh();
|
||||
}
|
||||
await _syncGeofenceAutomation();
|
||||
}
|
||||
|
||||
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 {
|
||||
Future<void> _syncGeofenceAutomation() async {
|
||||
await ref
|
||||
.read(geofenceDiagnosticsProvider.notifier)
|
||||
.requestBackgroundLocationPermission();
|
||||
await _userLocationNotifier.refresh();
|
||||
.read(geofenceAutomationServiceProvider)
|
||||
.syncActiveHome(ref.read(currentHomeProvider));
|
||||
}
|
||||
|
||||
Future<void> _requestGeofenceNotificationPermission() async {
|
||||
await ref
|
||||
.read(geofenceDiagnosticsProvider.notifier)
|
||||
.requestNotificationPermission();
|
||||
}
|
||||
|
||||
Future<void> _openRelevantAppSettings() async {
|
||||
if (ref.read(userLocationProvider).needsAppSettings) {
|
||||
await _userLocationNotifier.openAppSettings();
|
||||
Future<void> _syncLocationWatching() async {
|
||||
final shouldWatch = ref.read(homesProvider).any((home) => home.hasCoordinates);
|
||||
if (shouldWatch == _isWatchingLocation) {
|
||||
return;
|
||||
}
|
||||
await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings();
|
||||
|
||||
if (shouldWatch) {
|
||||
await _userLocationNotifier.startWatching();
|
||||
_isWatchingLocation = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_userLocationNotifier.stopWatching();
|
||||
_isWatchingLocation = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,19 +375,19 @@ class _HomeSubtitle extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else if (home.geofenceReady && isActive)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.shield_moon_outlined,
|
||||
size: 12,
|
||||
color: Colors.deepOrangeAccent,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Geofence включён',
|
||||
style: TextStyle(
|
||||
'Автовыключение: ${home.geofenceRadiusMeters} м',
|
||||
style: const TextStyle(
|
||||
color: Colors.deepOrangeAccent,
|
||||
fontSize: 11,
|
||||
),
|
||||
@@ -422,19 +396,19 @@ class _HomeSubtitle extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else if (home.geofenceReady)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.shield_moon_outlined,
|
||||
size: 12,
|
||||
color: Colors.white24,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Geofence включён',
|
||||
style: TextStyle(color: Colors.white24, fontSize: 11),
|
||||
'Автовыключение: ${home.geofenceRadiusMeters} м',
|
||||
style: const TextStyle(color: Colors.white24, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -443,214 +417,3 @@ class _HomeSubtitle extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_groupsNotifier = ref.read(groupsProvider.notifier);
|
||||
Future.microtask(_groupsNotifier.startPolling);
|
||||
if (ref.read(remotePollingEnabledProvider)) {
|
||||
Future.microtask(_groupsNotifier.startPolling);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.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';
|
||||
|
||||
import '../features/homes/geofence_logic.dart';
|
||||
import '../features/homes/services/geofence_runtime_store.dart';
|
||||
|
||||
const String _apiKeyPrefix = 'ignis_home_api_key_';
|
||||
|
||||
/// Имя задачи в workmanager
|
||||
const String geofenceTaskName = 'ignis_geofence_check';
|
||||
|
||||
/// Уникальное имя для registerPeriodicTask
|
||||
const String geofenceTaskUniqueName = 'ignis_geofence_periodic';
|
||||
|
||||
/// ID notification channel (должен совпадать с AndroidManifest)
|
||||
const String _channelId = 'ignis_geofence';
|
||||
const String _channelName = 'Геофенс';
|
||||
const String _channelDesc = 'Уведомления об автовыключении света';
|
||||
|
||||
/// Основная логика фонового таска.
|
||||
/// Вызывается из workmanager callback (в отдельном изоляте).
|
||||
/// Возвращает true если таск выполнен успешно (workmanager convention).
|
||||
Future<bool> executeGeofenceCheck() async {
|
||||
try {
|
||||
final runtimeStore = GeofenceRuntimeStore();
|
||||
var runtime = await runtimeStore.load();
|
||||
final armedHomeId = runtime.armedHomeId;
|
||||
if (armedHomeId == null || armedHomeId.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString('ignis_homes');
|
||||
if (raw == null || raw.isEmpty) return true;
|
||||
|
||||
final List<dynamic> homesList = jsonDecode(raw);
|
||||
final targetHome = _findArmedHome(homesList, armedHomeId);
|
||||
if (targetHome == null) {
|
||||
await runtimeStore.disarm();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!await Geolocator.isLocationServiceEnabled()) return true;
|
||||
|
||||
final perm = await Geolocator.checkPermission();
|
||||
if (!hasBackgroundLocationAccess(perm)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final pos = await _getCurrentPosition();
|
||||
if (pos == null) return true;
|
||||
|
||||
final now = DateTime.now();
|
||||
final homeLat = (targetHome['latitude'] as num).toDouble();
|
||||
final homeLon = (targetHome['longitude'] as num).toDouble();
|
||||
final distMeters = _haversineMeters(
|
||||
pos.latitude,
|
||||
pos.longitude,
|
||||
homeLat,
|
||||
homeLon,
|
||||
);
|
||||
|
||||
if (distMeters <= geofenceThresholdMeters) {
|
||||
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;
|
||||
}
|
||||
|
||||
final url = _normalizeUrl(targetHome['url'] as String);
|
||||
final apiKey = await _getHomeApiKey(targetHome);
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
runtime = runtime.recordFailure(
|
||||
armedHomeId,
|
||||
failedAt: now,
|
||||
distanceMeters: distMeters,
|
||||
message: 'Не найден API key для armed geofence дома.',
|
||||
);
|
||||
await runtimeStore.save(runtime);
|
||||
return true;
|
||||
}
|
||||
|
||||
final homeName = (targetHome['name'] ?? 'Дом') as String;
|
||||
|
||||
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 (_) {
|
||||
// Любая ошибка -- не крашим воркер
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getHomeApiKey(Map<String, dynamic> home) async {
|
||||
final id = home['id']?.toString();
|
||||
if (id == null || id.isEmpty) return null;
|
||||
|
||||
const secureStorage = FlutterSecureStorage();
|
||||
final secureKey = await secureStorage.read(key: '$_apiKeyPrefix$id');
|
||||
if (secureKey != null && secureKey.isNotEmpty) return secureKey;
|
||||
|
||||
// Backward compatibility: if the app has not run after migration yet,
|
||||
// old background tasks can still read the legacy key once.
|
||||
return home['apiKey']?.toString();
|
||||
}
|
||||
|
||||
// ─── Уведомления ─────────────────────────────────────────────
|
||||
|
||||
/// Показать локальное уведомление из фонового изолята
|
||||
Future<void> _showNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
// Инициализация (в изоляте нужна заново)
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const initSettings = InitializationSettings(android: androidSettings);
|
||||
await plugin.initialize(initSettings);
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDesc,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
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,
|
||||
body,
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Внутренние хелперы ──────────────────────────────────────
|
||||
|
||||
Map<String, dynamic>? _findArmedHome(
|
||||
List<dynamic> homesList,
|
||||
String armedHomeId,
|
||||
) {
|
||||
for (final item in homesList) {
|
||||
if (item is! Map) continue;
|
||||
final map = Map<String, dynamic>.from(item);
|
||||
if (map['id'] == armedHomeId &&
|
||||
map['geofenceEnabled'] == true &&
|
||||
map['latitude'] != null &&
|
||||
map['longitude'] != null) {
|
||||
return map;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Position?> _getCurrentPosition() async {
|
||||
try {
|
||||
var pos = await Geolocator.getLastKnownPosition();
|
||||
final lastTimestamp = pos?.timestamp;
|
||||
|
||||
if (pos == null ||
|
||||
lastTimestamp == null ||
|
||||
DateTime.now().difference(lastTimestamp).inMinutes > 5) {
|
||||
pos = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.low,
|
||||
timeLimit: Duration(seconds: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return pos;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Выключить все группы на сервере.
|
||||
Future<_TurnOffGroupsResult> _turnOffAllGroups(
|
||||
String baseUrl,
|
||||
String apiKey,
|
||||
) async {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
headers: {'X-API-Key': apiKey},
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final res = await dio.get('/devices/groups');
|
||||
final groupIds = <String>[];
|
||||
|
||||
if (res.data is Map) {
|
||||
final map = res.data as Map;
|
||||
for (final entry in map.entries) {
|
||||
groupIds.add(entry.key.toString());
|
||||
}
|
||||
} else if (res.data is List) {
|
||||
for (final g in res.data) {
|
||||
if (g is Map && g['id'] != null) {
|
||||
groupIds.add(g['id'].toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int success = 0;
|
||||
await Future.wait(
|
||||
groupIds.map((id) async {
|
||||
try {
|
||||
await dio.post(
|
||||
'/control/group/$id',
|
||||
queryParameters: {'state': false},
|
||||
);
|
||||
success++;
|
||||
} catch (_) {
|
||||
// Одна группа упала -- не останавливаем остальные
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
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();
|
||||
if (!u.startsWith('http')) u = 'https://$u';
|
||||
if (u.endsWith('/')) u = u.substring(0, u.length - 1);
|
||||
return u;
|
||||
}
|
||||
|
||||
/// Расстояние в метрах (Haversine)
|
||||
double _haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||
const earthRadiusM = 6371000.0;
|
||||
final dLat = _degToRad(lat2 - lat1);
|
||||
final dLon = _degToRad(lon2 - lon1);
|
||||
final a =
|
||||
math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_degToRad(lat1)) *
|
||||
math.cos(_degToRad(lat2)) *
|
||||
math.sin(dLon / 2) *
|
||||
math.sin(dLon / 2);
|
||||
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||
return earthRadiusM * c;
|
||||
}
|
||||
|
||||
double _degToRad(double deg) => deg * (math.pi / 180);
|
||||
|
||||
class _TurnOffGroupsResult {
|
||||
final int totalGroups;
|
||||
final int successCount;
|
||||
|
||||
const _TurnOffGroupsResult({
|
||||
required this.totalGroups,
|
||||
required this.successCount,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user