feat: harden geofence and distance diagnostics
This commit is contained in:
@@ -59,12 +59,13 @@ class AppBootstrapNotifier extends Notifier<AppBootstrapState> {
|
|||||||
|
|
||||||
final home = ref.read(currentHomeProvider);
|
final home = ref.read(currentHomeProvider);
|
||||||
if (home == null) {
|
if (home == null) {
|
||||||
|
await syncGeofenceTask(ref.read(homesProvider), currentHome: null);
|
||||||
state = const AppBootstrapState.noHomes();
|
state = const AppBootstrapState.noHomes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ref.read(authInfoProvider.notifier).load(failOnError: true);
|
await ref.read(authInfoProvider.notifier).load(failOnError: true);
|
||||||
await syncGeofenceTask(ref.read(homesProvider));
|
await syncGeofenceTask(ref.read(homesProvider), currentHome: home);
|
||||||
|
|
||||||
state = const AppBootstrapState.ready();
|
state = const AppBootstrapState.ready();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
class BuildInfo {
|
class BuildInfo {
|
||||||
static const String date = String.fromEnvironment(
|
static const String _date = String.fromEnvironment(
|
||||||
'IGNIS_BUILD_DATE',
|
'IGNIS_BUILD_DATE',
|
||||||
defaultValue: 'dev',
|
defaultValue: '',
|
||||||
);
|
);
|
||||||
static const String gitSha = String.fromEnvironment(
|
static const String _gitSha = String.fromEnvironment(
|
||||||
'IGNIS_GIT_SHA',
|
'IGNIS_GIT_SHA',
|
||||||
defaultValue: 'local',
|
defaultValue: '',
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get label => 'build $date - $gitSha';
|
static bool get hasMetadata => _date.isNotEmpty && _gitSha.isNotEmpty;
|
||||||
|
|
||||||
|
static String get shortSha {
|
||||||
|
if (_gitSha.isEmpty) return 'unknown';
|
||||||
|
return _gitSha.length <= 7 ? _gitSha : _gitSha.substring(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String get formattedDate {
|
||||||
|
if (_date.isEmpty) return 'unknown date';
|
||||||
|
|
||||||
|
final parsed = DateTime.tryParse(_date);
|
||||||
|
if (parsed == null) return _date;
|
||||||
|
|
||||||
|
final utc = parsed.toUtc();
|
||||||
|
final year = utc.year.toString().padLeft(4, '0');
|
||||||
|
final month = utc.month.toString().padLeft(2, '0');
|
||||||
|
final day = utc.day.toString().padLeft(2, '0');
|
||||||
|
final hour = utc.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = utc.minute.toString().padLeft(2, '0');
|
||||||
|
return '$year-$month-$day $hour:$minute UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String get label =>
|
||||||
|
hasMetadata ? '$formattedDate · $shortSha' : 'build info unavailable';
|
||||||
}
|
}
|
||||||
|
|||||||
47
lib/features/homes/geofence_logic.dart
Normal file
47
lib/features/homes/geofence_logic.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
const double geofenceThresholdMeters = 500.0;
|
||||||
|
const Duration geofenceRetryCooldown = Duration(minutes: 30);
|
||||||
|
|
||||||
|
bool hasForegroundLocationAccess(LocationPermission permission) {
|
||||||
|
return permission == LocationPermission.whileInUse ||
|
||||||
|
permission == LocationPermission.always;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasBackgroundLocationAccess(LocationPermission permission) {
|
||||||
|
return permission == LocationPermission.always;
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration? geofenceRetryRemaining(DateTime? lastFailureAt, {DateTime? now}) {
|
||||||
|
if (lastFailureAt == null) return null;
|
||||||
|
|
||||||
|
final currentTime = now ?? DateTime.now();
|
||||||
|
final remaining = lastFailureAt
|
||||||
|
.add(geofenceRetryCooldown)
|
||||||
|
.difference(currentTime);
|
||||||
|
if (remaining <= Duration.zero) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatGeofenceRetry(Duration remaining) {
|
||||||
|
if (remaining.inHours >= 1) {
|
||||||
|
final hours = remaining.inHours;
|
||||||
|
final minutes = remaining.inMinutes.remainder(60);
|
||||||
|
if (minutes == 0) return '$hours ч';
|
||||||
|
return '$hours ч $minutes мин';
|
||||||
|
}
|
||||||
|
|
||||||
|
final minutes = remaining.inMinutes;
|
||||||
|
if (minutes > 0) return '$minutes мин';
|
||||||
|
|
||||||
|
return '${remaining.inSeconds.clamp(1, 59)} сек';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatDistanceMeters(double meters) {
|
||||||
|
if (meters < 1000) {
|
||||||
|
return '${meters.round()} м';
|
||||||
|
}
|
||||||
|
return '${(meters / 1000).toStringAsFixed(1)} км';
|
||||||
|
}
|
||||||
@@ -2,18 +2,28 @@ import 'package:workmanager/workmanager.dart';
|
|||||||
|
|
||||||
import '../../models/home_config.dart';
|
import '../../models/home_config.dart';
|
||||||
import '../../services/geofence_worker.dart';
|
import '../../services/geofence_worker.dart';
|
||||||
|
import 'services/geofence_runtime_store.dart';
|
||||||
|
|
||||||
/// Синхронизировать состояние фонового таска с настройками домов.
|
/// Синхронизировать состояние фонового таска с настройками домов.
|
||||||
/// Вызывать при старте приложения и при изменении настроек.
|
/// Вызывать при старте приложения и при изменении настроек.
|
||||||
///
|
///
|
||||||
/// Если хотя бы один дом имеет geofenceReady -- регистрируем
|
/// Геофенс работает только для текущего активного дома.
|
||||||
/// периодический таск. Иначе -- отменяем.
|
/// Если активный дом не готов -- таск снимается и runtime разоружается.
|
||||||
Future<void> syncGeofenceTask(List<HomeConfig> homes) async {
|
Future<void> syncGeofenceTask(
|
||||||
final needGeofence = homes.any((h) => h.geofenceReady);
|
List<HomeConfig> homes, {
|
||||||
|
required HomeConfig? currentHome,
|
||||||
|
}) async {
|
||||||
|
final runtimeStore = GeofenceRuntimeStore();
|
||||||
|
final activeHome = currentHome;
|
||||||
|
final needGeofence =
|
||||||
|
activeHome != null &&
|
||||||
|
homes.any((home) => home.id == activeHome.id) &&
|
||||||
|
activeHome.geofenceReady;
|
||||||
|
|
||||||
if (needGeofence) {
|
if (needGeofence) {
|
||||||
await resetGeofenceFired();
|
await runtimeStore.armForHome(activeHome.id);
|
||||||
|
|
||||||
|
try {
|
||||||
await Workmanager().registerPeriodicTask(
|
await Workmanager().registerPeriodicTask(
|
||||||
geofenceTaskUniqueName,
|
geofenceTaskUniqueName,
|
||||||
geofenceTaskName,
|
geofenceTaskName,
|
||||||
@@ -23,7 +33,17 @@ Future<void> syncGeofenceTask(List<HomeConfig> homes) async {
|
|||||||
backoffPolicy: BackoffPolicy.linear,
|
backoffPolicy: BackoffPolicy.linear,
|
||||||
backoffPolicyDelay: const Duration(minutes: 1),
|
backoffPolicyDelay: const Duration(minutes: 1),
|
||||||
);
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// В тестах и на неполной платформенной инициализации
|
||||||
|
// не даём workmanager уронить остальное приложение.
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
await runtimeStore.disarm();
|
||||||
|
try {
|
||||||
await Workmanager().cancelByUniqueName(geofenceTaskUniqueName);
|
await Workmanager().cancelByUniqueName(geofenceTaskUniqueName);
|
||||||
|
} catch (_) {
|
||||||
|
// См. комментарий выше: runtime должен синхронизироваться
|
||||||
|
// даже если platform plugin недоступен.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
123
lib/features/homes/models/geofence_diagnostics.dart
Normal file
123
lib/features/homes/models/geofence_diagnostics.dart
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
import '../../../models/home_config.dart';
|
||||||
|
import '../geofence_logic.dart';
|
||||||
|
import 'geofence_runtime_state.dart';
|
||||||
|
|
||||||
|
enum GeofenceStatusKind {
|
||||||
|
noActiveHome,
|
||||||
|
disabled,
|
||||||
|
missingCoordinates,
|
||||||
|
locationServicesDisabled,
|
||||||
|
locationPermissionDenied,
|
||||||
|
backgroundPermissionDenied,
|
||||||
|
notificationsPermissionDenied,
|
||||||
|
cooldown,
|
||||||
|
triggered,
|
||||||
|
ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeofenceDiagnostics {
|
||||||
|
final HomeConfig? activeHome;
|
||||||
|
final GeofenceStatusKind status;
|
||||||
|
final GeofenceRuntimeState runtime;
|
||||||
|
final bool locationServicesEnabled;
|
||||||
|
final LocationPermission locationPermission;
|
||||||
|
final bool notificationsEnabled;
|
||||||
|
final Duration? retryRemaining;
|
||||||
|
final String? detail;
|
||||||
|
|
||||||
|
const GeofenceDiagnostics({
|
||||||
|
required this.activeHome,
|
||||||
|
required this.status,
|
||||||
|
required this.runtime,
|
||||||
|
required this.locationServicesEnabled,
|
||||||
|
required this.locationPermission,
|
||||||
|
required this.notificationsEnabled,
|
||||||
|
this.retryRemaining,
|
||||||
|
this.detail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const GeofenceDiagnostics.initial()
|
||||||
|
: activeHome = null,
|
||||||
|
status = GeofenceStatusKind.noActiveHome,
|
||||||
|
runtime = const GeofenceRuntimeState(),
|
||||||
|
locationServicesEnabled = true,
|
||||||
|
locationPermission = LocationPermission.denied,
|
||||||
|
notificationsEnabled = true,
|
||||||
|
retryRemaining = null,
|
||||||
|
detail = null;
|
||||||
|
|
||||||
|
String get title {
|
||||||
|
return switch (status) {
|
||||||
|
GeofenceStatusKind.noActiveHome => 'Геофенс не активен',
|
||||||
|
GeofenceStatusKind.disabled => 'Автовыключение выключено',
|
||||||
|
GeofenceStatusKind.missingCoordinates => 'Нет координат дома',
|
||||||
|
GeofenceStatusKind.locationServicesDisabled => 'Геолокация выключена',
|
||||||
|
GeofenceStatusKind.locationPermissionDenied => 'Нет доступа к геолокации',
|
||||||
|
GeofenceStatusKind.backgroundPermissionDenied =>
|
||||||
|
'Нет фонового доступа к геолокации',
|
||||||
|
GeofenceStatusKind.notificationsPermissionDenied =>
|
||||||
|
'Нет доступа к уведомлениям',
|
||||||
|
GeofenceStatusKind.cooldown => 'Повтор отложен',
|
||||||
|
GeofenceStatusKind.triggered => 'Геофенс уже сработал',
|
||||||
|
GeofenceStatusKind.ready => 'Геофенс активен',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String get message {
|
||||||
|
final homeName = activeHome?.name ?? 'активного дома';
|
||||||
|
return switch (status) {
|
||||||
|
GeofenceStatusKind.noActiveHome =>
|
||||||
|
'Сначала выберите активный дом. Без этого фоновой автоматике нечего отслеживать.',
|
||||||
|
GeofenceStatusKind.disabled =>
|
||||||
|
'Для дома "$homeName" автовыключение пока отключено.',
|
||||||
|
GeofenceStatusKind.missingCoordinates =>
|
||||||
|
'У дома "$homeName" не заданы координаты. Без них расстояние считать не из чего.',
|
||||||
|
GeofenceStatusKind.locationServicesDisabled =>
|
||||||
|
'На устройстве выключена геолокация. И карта расстояний, и фоновый geofence сейчас слепые как кроты.',
|
||||||
|
GeofenceStatusKind.locationPermissionDenied =>
|
||||||
|
'Приложению не дали доступ к геолокации. Разрешите доступ хотя бы во время использования.',
|
||||||
|
GeofenceStatusKind.backgroundPermissionDenied =>
|
||||||
|
'Для фонового geofence нужен доступ к локации "Всегда". Иначе в фоне Android эту магию задушит.',
|
||||||
|
GeofenceStatusKind.notificationsPermissionDenied =>
|
||||||
|
'Свет выключить мы ещё попробуем, но честно отчитаться пользователю не сможем, пока уведомления запрещены.',
|
||||||
|
GeofenceStatusKind.cooldown =>
|
||||||
|
'Последняя попытка выключить свет сорвалась. Повторим позже, чтобы не долбить backend без остановки.',
|
||||||
|
GeofenceStatusKind.triggered =>
|
||||||
|
'Свет уже был автоматически выключен. Повторно бахать не будем, пока вы не вернётесь в домашний радиус.',
|
||||||
|
GeofenceStatusKind.ready =>
|
||||||
|
'Фоновая автоматика готова следить за домом "$homeName" и выключить свет после ухода.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get secondaryMessage {
|
||||||
|
if (status == GeofenceStatusKind.cooldown && retryRemaining != null) {
|
||||||
|
return 'Повтор через ${formatGeofenceRetry(retryRemaining!)}.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == GeofenceStatusKind.triggered &&
|
||||||
|
runtime.lastSuccessAt != null &&
|
||||||
|
runtime.lastSuccessHomeId == activeHome?.id) {
|
||||||
|
return 'Последнее успешное срабатывание уже зафиксировано.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail != null && detail!.trim().isNotEmpty) {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get canRequestLocation =>
|
||||||
|
status == GeofenceStatusKind.locationPermissionDenied;
|
||||||
|
|
||||||
|
bool get canRequestBackgroundLocation =>
|
||||||
|
status == GeofenceStatusKind.backgroundPermissionDenied;
|
||||||
|
|
||||||
|
bool get canOpenLocationSettings =>
|
||||||
|
status == GeofenceStatusKind.locationServicesDisabled;
|
||||||
|
|
||||||
|
bool get canRequestNotifications =>
|
||||||
|
status == GeofenceStatusKind.notificationsPermissionDenied;
|
||||||
|
}
|
||||||
193
lib/features/homes/models/geofence_runtime_state.dart
Normal file
193
lib/features/homes/models/geofence_runtime_state.dart
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
class GeofenceRuntimeState {
|
||||||
|
final String? armedHomeId;
|
||||||
|
final DateTime? lastCheckAt;
|
||||||
|
final double? lastDistanceMeters;
|
||||||
|
final String? triggeredHomeId;
|
||||||
|
final DateTime? triggeredAt;
|
||||||
|
final String? lastSuccessHomeId;
|
||||||
|
final DateTime? lastSuccessAt;
|
||||||
|
final String? lastFailureHomeId;
|
||||||
|
final DateTime? lastFailureAt;
|
||||||
|
final String? lastFailureMessage;
|
||||||
|
|
||||||
|
const GeofenceRuntimeState({
|
||||||
|
this.armedHomeId,
|
||||||
|
this.lastCheckAt,
|
||||||
|
this.lastDistanceMeters,
|
||||||
|
this.triggeredHomeId,
|
||||||
|
this.triggeredAt,
|
||||||
|
this.lastSuccessHomeId,
|
||||||
|
this.lastSuccessAt,
|
||||||
|
this.lastFailureHomeId,
|
||||||
|
this.lastFailureAt,
|
||||||
|
this.lastFailureMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool isTriggeredFor(String homeId) =>
|
||||||
|
triggeredHomeId == homeId && triggeredAt != null;
|
||||||
|
|
||||||
|
DateTime? failureAtFor(String homeId) {
|
||||||
|
if (lastFailureHomeId != homeId) return null;
|
||||||
|
return lastFailureAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? failureMessageFor(String homeId) {
|
||||||
|
if (lastFailureHomeId != homeId) return null;
|
||||||
|
return lastFailureMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceRuntimeState armForHome(String homeId) {
|
||||||
|
if (armedHomeId == homeId) return this;
|
||||||
|
|
||||||
|
return GeofenceRuntimeState(
|
||||||
|
armedHomeId: homeId,
|
||||||
|
lastSuccessHomeId: lastSuccessHomeId,
|
||||||
|
lastSuccessAt: lastSuccessAt,
|
||||||
|
lastFailureHomeId: lastFailureHomeId,
|
||||||
|
lastFailureAt: lastFailureAt,
|
||||||
|
lastFailureMessage: lastFailureMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceRuntimeState disarm() {
|
||||||
|
return GeofenceRuntimeState(
|
||||||
|
lastSuccessHomeId: lastSuccessHomeId,
|
||||||
|
lastSuccessAt: lastSuccessAt,
|
||||||
|
lastFailureHomeId: lastFailureHomeId,
|
||||||
|
lastFailureAt: lastFailureAt,
|
||||||
|
lastFailureMessage: lastFailureMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceRuntimeState recordInsideHome(
|
||||||
|
String homeId, {
|
||||||
|
required DateTime checkedAt,
|
||||||
|
required double distanceMeters,
|
||||||
|
}) {
|
||||||
|
return GeofenceRuntimeState(
|
||||||
|
armedHomeId: armedHomeId,
|
||||||
|
lastCheckAt: checkedAt,
|
||||||
|
lastDistanceMeters: distanceMeters,
|
||||||
|
lastSuccessHomeId: lastSuccessHomeId,
|
||||||
|
lastSuccessAt: lastSuccessAt,
|
||||||
|
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
||||||
|
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
||||||
|
lastFailureMessage: lastFailureHomeId == homeId
|
||||||
|
? null
|
||||||
|
: lastFailureMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceRuntimeState recordOutsideCheck(
|
||||||
|
String homeId, {
|
||||||
|
required DateTime checkedAt,
|
||||||
|
required double distanceMeters,
|
||||||
|
}) {
|
||||||
|
return GeofenceRuntimeState(
|
||||||
|
armedHomeId: armedHomeId ?? homeId,
|
||||||
|
lastCheckAt: checkedAt,
|
||||||
|
lastDistanceMeters: distanceMeters,
|
||||||
|
triggeredHomeId: triggeredHomeId,
|
||||||
|
triggeredAt: triggeredAt,
|
||||||
|
lastSuccessHomeId: lastSuccessHomeId,
|
||||||
|
lastSuccessAt: lastSuccessAt,
|
||||||
|
lastFailureHomeId: lastFailureHomeId,
|
||||||
|
lastFailureAt: lastFailureAt,
|
||||||
|
lastFailureMessage: lastFailureMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceRuntimeState recordSuccess(
|
||||||
|
String homeId, {
|
||||||
|
required DateTime triggeredAt,
|
||||||
|
required double distanceMeters,
|
||||||
|
}) {
|
||||||
|
return GeofenceRuntimeState(
|
||||||
|
armedHomeId: homeId,
|
||||||
|
lastCheckAt: triggeredAt,
|
||||||
|
lastDistanceMeters: distanceMeters,
|
||||||
|
triggeredHomeId: homeId,
|
||||||
|
triggeredAt: triggeredAt,
|
||||||
|
lastSuccessHomeId: homeId,
|
||||||
|
lastSuccessAt: triggeredAt,
|
||||||
|
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
||||||
|
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
||||||
|
lastFailureMessage: lastFailureHomeId == homeId
|
||||||
|
? null
|
||||||
|
: lastFailureMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceRuntimeState recordFailure(
|
||||||
|
String homeId, {
|
||||||
|
required DateTime failedAt,
|
||||||
|
required double distanceMeters,
|
||||||
|
required String message,
|
||||||
|
}) {
|
||||||
|
return GeofenceRuntimeState(
|
||||||
|
armedHomeId: homeId,
|
||||||
|
lastCheckAt: failedAt,
|
||||||
|
lastDistanceMeters: distanceMeters,
|
||||||
|
triggeredHomeId: triggeredHomeId == homeId ? null : triggeredHomeId,
|
||||||
|
triggeredAt: triggeredHomeId == homeId ? null : triggeredAt,
|
||||||
|
lastSuccessHomeId: lastSuccessHomeId,
|
||||||
|
lastSuccessAt: lastSuccessAt,
|
||||||
|
lastFailureHomeId: homeId,
|
||||||
|
lastFailureAt: failedAt,
|
||||||
|
lastFailureMessage: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceRuntimeState removeHome(String homeId) {
|
||||||
|
return GeofenceRuntimeState(
|
||||||
|
armedHomeId: armedHomeId == homeId ? null : armedHomeId,
|
||||||
|
lastCheckAt: armedHomeId == homeId ? null : lastCheckAt,
|
||||||
|
lastDistanceMeters: armedHomeId == homeId ? null : lastDistanceMeters,
|
||||||
|
triggeredHomeId: triggeredHomeId == homeId ? null : triggeredHomeId,
|
||||||
|
triggeredAt: triggeredHomeId == homeId ? null : triggeredAt,
|
||||||
|
lastSuccessHomeId: lastSuccessHomeId == homeId ? null : lastSuccessHomeId,
|
||||||
|
lastSuccessAt: lastSuccessHomeId == homeId ? null : lastSuccessAt,
|
||||||
|
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
||||||
|
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
||||||
|
lastFailureMessage: lastFailureHomeId == homeId
|
||||||
|
? null
|
||||||
|
: lastFailureMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
if (armedHomeId != null) 'armedHomeId': armedHomeId,
|
||||||
|
if (lastCheckAt != null) 'lastCheckAt': lastCheckAt!.millisecondsSinceEpoch,
|
||||||
|
if (lastDistanceMeters != null) 'lastDistanceMeters': lastDistanceMeters,
|
||||||
|
if (triggeredHomeId != null) 'triggeredHomeId': triggeredHomeId,
|
||||||
|
if (triggeredAt != null) 'triggeredAt': triggeredAt!.millisecondsSinceEpoch,
|
||||||
|
if (lastSuccessHomeId != null) 'lastSuccessHomeId': lastSuccessHomeId,
|
||||||
|
if (lastSuccessAt != null)
|
||||||
|
'lastSuccessAt': lastSuccessAt!.millisecondsSinceEpoch,
|
||||||
|
if (lastFailureHomeId != null) 'lastFailureHomeId': lastFailureHomeId,
|
||||||
|
if (lastFailureAt != null)
|
||||||
|
'lastFailureAt': lastFailureAt!.millisecondsSinceEpoch,
|
||||||
|
if (lastFailureMessage != null) 'lastFailureMessage': lastFailureMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory GeofenceRuntimeState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return GeofenceRuntimeState(
|
||||||
|
armedHomeId: json['armedHomeId'] as String?,
|
||||||
|
lastCheckAt: _fromMillis(json['lastCheckAt']),
|
||||||
|
lastDistanceMeters: (json['lastDistanceMeters'] as num?)?.toDouble(),
|
||||||
|
triggeredHomeId: json['triggeredHomeId'] as String?,
|
||||||
|
triggeredAt: _fromMillis(json['triggeredAt']),
|
||||||
|
lastSuccessHomeId: json['lastSuccessHomeId'] as String?,
|
||||||
|
lastSuccessAt: _fromMillis(json['lastSuccessAt']),
|
||||||
|
lastFailureHomeId: json['lastFailureHomeId'] as String?,
|
||||||
|
lastFailureAt: _fromMillis(json['lastFailureAt']),
|
||||||
|
lastFailureMessage: json['lastFailureMessage'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _fromMillis(Object? value) {
|
||||||
|
final millis = (value as num?)?.toInt();
|
||||||
|
if (millis == null) return null;
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(millis);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
lib/features/homes/providers/geofence_providers.dart
Normal file
226
lib/features/homes/providers/geofence_providers.dart
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
import '../../../app/load_state.dart';
|
||||||
|
import '../../../models/home_config.dart';
|
||||||
|
import '../geofence_logic.dart';
|
||||||
|
import '../models/geofence_diagnostics.dart';
|
||||||
|
import '../models/geofence_runtime_state.dart';
|
||||||
|
import '../services/geofence_notifications_service.dart';
|
||||||
|
import '../services/geofence_runtime_store.dart';
|
||||||
|
import 'homes_providers.dart';
|
||||||
|
|
||||||
|
final geofenceRuntimeStoreProvider = Provider((ref) => GeofenceRuntimeStore());
|
||||||
|
|
||||||
|
final geofenceNotificationsServiceProvider = Provider(
|
||||||
|
(ref) => GeofenceNotificationsService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final geofenceDiagnosticsProvider =
|
||||||
|
NotifierProvider<
|
||||||
|
GeofenceDiagnosticsNotifier,
|
||||||
|
LoadState<GeofenceDiagnostics>
|
||||||
|
>(GeofenceDiagnosticsNotifier.new);
|
||||||
|
|
||||||
|
class GeofenceDiagnosticsNotifier
|
||||||
|
extends Notifier<LoadState<GeofenceDiagnostics>> {
|
||||||
|
bool _refreshing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
LoadState<GeofenceDiagnostics> build() {
|
||||||
|
return const LoadState.idle(GeofenceDiagnostics.initial());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
if (_refreshing) return;
|
||||||
|
_refreshing = true;
|
||||||
|
|
||||||
|
final previous = state.data;
|
||||||
|
state = LoadState.loading(previous);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final currentHome = ref.read(currentHomeProvider);
|
||||||
|
final runtime = await ref.read(geofenceRuntimeStoreProvider).load();
|
||||||
|
|
||||||
|
if (currentHome == null) {
|
||||||
|
state = LoadState.data(
|
||||||
|
GeofenceDiagnostics(
|
||||||
|
activeHome: null,
|
||||||
|
status: GeofenceStatusKind.noActiveHome,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: LocationPermission.denied,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentHome.geofenceEnabled) {
|
||||||
|
state = LoadState.data(
|
||||||
|
GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.disabled,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: LocationPermission.denied,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentHome.hasCoordinates) {
|
||||||
|
state = LoadState.data(
|
||||||
|
GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.missingCoordinates,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: LocationPermission.denied,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final locationServicesEnabled =
|
||||||
|
await Geolocator.isLocationServiceEnabled();
|
||||||
|
final locationPermission = await Geolocator.checkPermission();
|
||||||
|
final notificationsEnabled = await ref
|
||||||
|
.read(geofenceNotificationsServiceProvider)
|
||||||
|
.areNotificationsEnabled();
|
||||||
|
|
||||||
|
final diagnostics = _buildDiagnostics(
|
||||||
|
currentHome: currentHome,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: locationServicesEnabled,
|
||||||
|
locationPermission: locationPermission,
|
||||||
|
notificationsEnabled: notificationsEnabled,
|
||||||
|
);
|
||||||
|
state = LoadState.data(diagnostics);
|
||||||
|
} catch (error) {
|
||||||
|
state = LoadState.error(
|
||||||
|
previous,
|
||||||
|
'Не удалось обновить состояние geofence: $error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> requestLocationPermission() async {
|
||||||
|
await Geolocator.requestPermission();
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> requestBackgroundLocationPermission() async {
|
||||||
|
final result = await Geolocator.requestPermission();
|
||||||
|
if (!hasBackgroundLocationAccess(result)) {
|
||||||
|
await Geolocator.openAppSettings();
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> requestNotificationPermission() async {
|
||||||
|
await ref
|
||||||
|
.read(geofenceNotificationsServiceProvider)
|
||||||
|
.requestNotificationsPermission();
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openAppSettings() async {
|
||||||
|
await Geolocator.openAppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openLocationSettings() async {
|
||||||
|
await Geolocator.openLocationSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceDiagnostics _buildDiagnostics({
|
||||||
|
required HomeConfig currentHome,
|
||||||
|
required GeofenceRuntimeState runtime,
|
||||||
|
required bool locationServicesEnabled,
|
||||||
|
required LocationPermission locationPermission,
|
||||||
|
required bool notificationsEnabled,
|
||||||
|
}) {
|
||||||
|
if (!locationServicesEnabled) {
|
||||||
|
return GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.locationServicesDisabled,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: false,
|
||||||
|
locationPermission: locationPermission,
|
||||||
|
notificationsEnabled: notificationsEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasForegroundLocationAccess(locationPermission)) {
|
||||||
|
return GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.locationPermissionDenied,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: locationPermission,
|
||||||
|
notificationsEnabled: notificationsEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasBackgroundLocationAccess(locationPermission)) {
|
||||||
|
return GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.backgroundPermissionDenied,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: locationPermission,
|
||||||
|
notificationsEnabled: notificationsEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notificationsEnabled) {
|
||||||
|
return GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.notificationsPermissionDenied,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: locationPermission,
|
||||||
|
notificationsEnabled: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final failureAt = runtime.failureAtFor(currentHome.id);
|
||||||
|
final retryRemaining = geofenceRetryRemaining(failureAt);
|
||||||
|
if (retryRemaining != null) {
|
||||||
|
return GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.cooldown,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: locationPermission,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
retryRemaining: retryRemaining,
|
||||||
|
detail: runtime.failureMessageFor(currentHome.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.isTriggeredFor(currentHome.id)) {
|
||||||
|
return GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.triggered,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: locationPermission,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GeofenceDiagnostics(
|
||||||
|
activeHome: currentHome,
|
||||||
|
status: GeofenceStatusKind.ready,
|
||||||
|
runtime: runtime,
|
||||||
|
locationServicesEnabled: true,
|
||||||
|
locationPermission: locationPermission,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../models/home_config.dart';
|
import '../../../models/home_config.dart';
|
||||||
|
import '../geofence_task_sync.dart';
|
||||||
|
import '../services/geofence_runtime_store.dart';
|
||||||
import '../../auth/providers/auth_providers.dart';
|
import '../../auth/providers/auth_providers.dart';
|
||||||
import '../../shared/providers/core_providers.dart';
|
import '../../shared/providers/core_providers.dart';
|
||||||
|
|
||||||
@@ -39,9 +41,11 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
|||||||
try {
|
try {
|
||||||
await switchTo(home);
|
await switchTo(home);
|
||||||
await ref.read(authInfoProvider.notifier).load(failOnError: true);
|
await ref.read(authInfoProvider.notifier).load(failOnError: true);
|
||||||
|
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await _restoreSelection(previousHome);
|
await _restoreSelection(previousHome);
|
||||||
ref.read(authInfoProvider.notifier).restore(previousAuthState);
|
ref.read(authInfoProvider.notifier).restore(previousAuthState);
|
||||||
|
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +53,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
|||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await ref.read(settingsServiceProvider).setCurrentHomeId(null);
|
await ref.read(settingsServiceProvider).setCurrentHomeId(null);
|
||||||
state = null;
|
state = null;
|
||||||
|
await syncGeofenceTask(ref.read(homesProvider), currentHome: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Инициализировать API-клиент текущим домом
|
/// Инициализировать API-клиент текущим домом
|
||||||
@@ -91,6 +96,7 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
|
|||||||
|
|
||||||
Future<void> remove(String id) async {
|
Future<void> remove(String id) async {
|
||||||
await ref.read(settingsServiceProvider).deleteHome(id);
|
await ref.read(settingsServiceProvider).deleteHome(id);
|
||||||
|
await GeofenceRuntimeStore().removeHome(id);
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,28 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
enum UserLocationIssue {
|
||||||
|
servicesDisabled,
|
||||||
|
permissionDenied,
|
||||||
|
permissionDeniedForever,
|
||||||
|
unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
/// Состояние геолокации: позиция или причина отсутствия.
|
/// Состояние геолокации: позиция или причина отсутствия.
|
||||||
/// Запрашивается один раз, кешируется до перезапуска провайдера.
|
/// Запрашивается один раз, кешируется до перезапуска провайдера.
|
||||||
class UserLocation {
|
class UserLocation {
|
||||||
final Position? position;
|
final Position? position;
|
||||||
final String? error; // null -- всё ок, иначе причина
|
final String? error; // null -- всё ок, иначе причина
|
||||||
|
final UserLocationIssue? issue;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
const UserLocation({this.position, this.error});
|
const UserLocation({this.position, this.error, this.issue, this.updatedAt});
|
||||||
|
|
||||||
bool get hasPosition => position != null;
|
bool get hasPosition => position != null;
|
||||||
|
bool get needsAppSettings =>
|
||||||
|
issue == UserLocationIssue.permissionDeniedForever;
|
||||||
|
bool get needsLocationSettings => issue == UserLocationIssue.servicesDisabled;
|
||||||
|
bool get canRequestPermission => issue == UserLocationIssue.permissionDenied;
|
||||||
|
|
||||||
/// Расстояние в км до точки. Возвращает null если нет позиции
|
/// Расстояние в км до точки. Возвращает null если нет позиции
|
||||||
/// или у цели нет координат.
|
/// или у цели нет координат.
|
||||||
@@ -50,30 +63,7 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
|||||||
/// и отдаёт lastKnown мгновенно (если есть).
|
/// и отдаёт lastKnown мгновенно (если есть).
|
||||||
Future<void> fetch() async {
|
Future<void> fetch() async {
|
||||||
if (state.hasPosition) return;
|
if (state.hasPosition) return;
|
||||||
|
await refresh();
|
||||||
final err = await _ensurePermission();
|
|
||||||
if (err != null) {
|
|
||||||
state = UserLocation(error: err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final last = await Geolocator.getLastKnownPosition();
|
|
||||||
if (last != null) {
|
|
||||||
state = UserLocation(position: last);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final pos = await Geolocator.getCurrentPosition(
|
|
||||||
locationSettings: const LocationSettings(
|
|
||||||
accuracy: LocationAccuracy.low,
|
|
||||||
timeLimit: Duration(seconds: 10),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
state = UserLocation(position: pos);
|
|
||||||
} catch (e) {
|
|
||||||
state = UserLocation(error: 'Ошибка: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Начать непрерывное отслеживание. Вызывать из initState экрана.
|
/// Начать непрерывное отслеживание. Вызывать из initState экрана.
|
||||||
@@ -83,9 +73,9 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
|||||||
_watchers++;
|
_watchers++;
|
||||||
if (_sub != null) return;
|
if (_sub != null) return;
|
||||||
|
|
||||||
final err = await _ensurePermission();
|
final permissionState = await _ensurePermission();
|
||||||
if (err != null) {
|
if (!permissionState.isGranted) {
|
||||||
state = UserLocation(error: err);
|
state = permissionState.toLocation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +83,7 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
|||||||
try {
|
try {
|
||||||
final last = await Geolocator.getLastKnownPosition();
|
final last = await Geolocator.getLastKnownPosition();
|
||||||
if (last != null) {
|
if (last != null) {
|
||||||
state = UserLocation(position: last);
|
state = _fromPosition(last);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -104,9 +94,14 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_sub = Geolocator.getPositionStream(locationSettings: settings).listen(
|
_sub = Geolocator.getPositionStream(locationSettings: settings).listen(
|
||||||
(pos) => state = UserLocation(position: pos),
|
(pos) => state = _fromPosition(pos),
|
||||||
onError: (e) {
|
onError: (e) {
|
||||||
debugPrint('Ошибка стрима геолокации: $e');
|
debugPrint('Ошибка стрима геолокации: $e');
|
||||||
|
state = UserLocation(
|
||||||
|
error: 'Не удалось отслеживать позицию: $e',
|
||||||
|
issue: UserLocationIssue.unavailable,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,11 +115,61 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final permissionState = await _ensurePermission();
|
||||||
|
if (!permissionState.isGranted) {
|
||||||
|
state = permissionState.toLocation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final last = await Geolocator.getLastKnownPosition();
|
||||||
|
if (last != null) {
|
||||||
|
state = _fromPosition(last);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pos = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.low,
|
||||||
|
timeLimit: Duration(seconds: 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
state = _fromPosition(pos);
|
||||||
|
} catch (e) {
|
||||||
|
state = UserLocation(
|
||||||
|
error: 'Не удалось получить позицию: $e',
|
||||||
|
issue: UserLocationIssue.unavailable,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> requestPermission() async {
|
||||||
|
await Geolocator.requestPermission();
|
||||||
|
if (_watchers > 0 && _sub == null) {
|
||||||
|
await startWatching();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openAppSettings() async {
|
||||||
|
await Geolocator.openAppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openLocationSettings() async {
|
||||||
|
await Geolocator.openLocationSettings();
|
||||||
|
}
|
||||||
|
|
||||||
/// Проверить сервис и пермишены. Возвращает null если всё ок,
|
/// Проверить сервис и пермишены. Возвращает null если всё ок,
|
||||||
/// иначе строку с причиной ошибки.
|
/// иначе строку с причиной ошибки.
|
||||||
Future<String?> _ensurePermission() async {
|
Future<_LocationPermissionState> _ensurePermission() async {
|
||||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||||
return 'Геолокация выключена';
|
return const _LocationPermissionState(
|
||||||
|
issue: UserLocationIssue.servicesDisabled,
|
||||||
|
message: 'Геолокация выключена',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var perm = await Geolocator.checkPermission();
|
var perm = await Geolocator.checkPermission();
|
||||||
@@ -132,12 +177,35 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
|||||||
perm = await Geolocator.requestPermission();
|
perm = await Geolocator.requestPermission();
|
||||||
}
|
}
|
||||||
if (perm == LocationPermission.denied) {
|
if (perm == LocationPermission.denied) {
|
||||||
return 'Нет разрешения';
|
return const _LocationPermissionState(
|
||||||
|
issue: UserLocationIssue.permissionDenied,
|
||||||
|
message: 'Нет разрешения на геолокацию',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (perm == LocationPermission.deniedForever) {
|
if (perm == LocationPermission.deniedForever) {
|
||||||
return 'Разрешение запрещено навсегда';
|
return const _LocationPermissionState(
|
||||||
|
issue: UserLocationIssue.permissionDeniedForever,
|
||||||
|
message: 'Доступ к геолокации запрещён навсегда',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return const _LocationPermissionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
UserLocation _fromPosition(Position position) {
|
||||||
|
return UserLocation(position: position, updatedAt: position.timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LocationPermissionState {
|
||||||
|
final UserLocationIssue? issue;
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const _LocationPermissionState({this.issue, this.message});
|
||||||
|
|
||||||
|
bool get isGranted => issue == null;
|
||||||
|
|
||||||
|
UserLocation toLocation() {
|
||||||
|
return UserLocation(error: message, issue: issue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
class GeofenceNotificationsService {
|
||||||
|
final FlutterLocalNotificationsPlugin _plugin;
|
||||||
|
|
||||||
|
GeofenceNotificationsService({FlutterLocalNotificationsPlugin? plugin})
|
||||||
|
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||||
|
);
|
||||||
|
await _plugin.initialize(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> areNotificationsEnabled() async {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final android = _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
return await android?.areNotificationsEnabled() ?? true;
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> requestNotificationsPermission() async {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final android = _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
final granted = await android?.requestNotificationsPermission();
|
||||||
|
return granted ?? await areNotificationsEnabled();
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lib/features/homes/services/geofence_runtime_store.dart
Normal file
53
lib/features/homes/services/geofence_runtime_store.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/geofence_runtime_state.dart';
|
||||||
|
|
||||||
|
class GeofenceRuntimeStore {
|
||||||
|
static const String _runtimeKey = 'ignis_geofence_runtime';
|
||||||
|
|
||||||
|
Future<GeofenceRuntimeState> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_runtimeKey);
|
||||||
|
if (raw == null || raw.isEmpty) {
|
||||||
|
return const GeofenceRuntimeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return const GeofenceRuntimeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return GeofenceRuntimeState.fromJson(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(GeofenceRuntimeState state) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final data = state.toJson();
|
||||||
|
if (data.isEmpty) {
|
||||||
|
await prefs.remove(_runtimeKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prefs.setString(_runtimeKey, jsonEncode(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GeofenceRuntimeState> armForHome(String homeId) async {
|
||||||
|
final next = (await load()).armForHome(homeId);
|
||||||
|
await save(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GeofenceRuntimeState> disarm() async {
|
||||||
|
final next = (await load()).disarm();
|
||||||
|
await save(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GeofenceRuntimeState> removeHome(String homeId) async {
|
||||||
|
final next = (await load()).removeHome(homeId);
|
||||||
|
await save(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
import 'app/app_bootstrap.dart';
|
import 'app/app_bootstrap.dart';
|
||||||
|
import 'features/homes/services/geofence_notifications_service.dart';
|
||||||
import 'screens/homes_screen.dart';
|
import 'screens/homes_screen.dart';
|
||||||
import 'screens/remote_screen.dart';
|
import 'screens/remote_screen.dart';
|
||||||
import 'services/geofence_worker.dart';
|
import 'services/geofence_worker.dart';
|
||||||
@@ -21,8 +22,9 @@ void callbackDispatcher() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await GeofenceNotificationsService().initialize();
|
||||||
|
|
||||||
// Инициализация workmanager
|
// Инициализация workmanager
|
||||||
Workmanager().initialize(callbackDispatcher);
|
Workmanager().initialize(callbackDispatcher);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export '../features/api_keys/providers/api_keys_providers.dart';
|
export '../features/api_keys/providers/api_keys_providers.dart';
|
||||||
export '../features/auth/providers/auth_providers.dart';
|
export '../features/auth/providers/auth_providers.dart';
|
||||||
export '../features/homes/geofence_task_sync.dart';
|
export '../features/homes/geofence_task_sync.dart';
|
||||||
|
export '../features/homes/providers/geofence_providers.dart';
|
||||||
export '../features/homes/providers/homes_providers.dart';
|
export '../features/homes/providers/homes_providers.dart';
|
||||||
export '../features/homes/providers/location_providers.dart';
|
export '../features/homes/providers/location_providers.dart';
|
||||||
export '../features/remote/providers/remote_providers.dart';
|
export '../features/remote/providers/remote_providers.dart';
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
padding: EdgeInsets.only(left: 40, bottom: 4),
|
padding: EdgeInsets.only(left: 40, bottom: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Проверка раз в ~15 мин (ограничение Android).\n'
|
'Проверка раз в ~15 мин (ограничение Android).\n'
|
||||||
'Работает в фоне, без постоянной нотификации.',
|
'Работает только для текущего активного дома.\n'
|
||||||
|
'Нужны фоновые разрешения на геолокацию и уведомления.',
|
||||||
style: TextStyle(fontSize: 11, color: Colors.white24),
|
style: TextStyle(fontSize: 11, color: Colors.white24),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,16 +328,17 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
|
|
||||||
// Синхронизировать фоновый таск с новыми настройками
|
// Синхронизировать фоновый таск с новыми настройками
|
||||||
final allHomes = ref.read(homesProvider);
|
final allHomes = ref.read(homesProvider);
|
||||||
await syncGeofenceTask(allHomes);
|
await syncGeofenceTask(
|
||||||
|
allHomes,
|
||||||
|
currentHome: ref.read(currentHomeProvider),
|
||||||
|
);
|
||||||
|
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text('Не удалось сохранить дом: ${describeLoadError(e)}'),
|
||||||
'Не удалось сохранить дом: ${describeLoadError(e)}',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
|
import '../features/homes/models/geofence_diagnostics.dart';
|
||||||
import '../models/home_config.dart';
|
import '../models/home_config.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/build_info_text.dart';
|
import '../widgets/build_info_text.dart';
|
||||||
@@ -16,7 +18,8 @@ class HomesScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<HomesScreen> createState() => _HomesScreenState();
|
ConsumerState<HomesScreen> createState() => _HomesScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomesScreenState extends ConsumerState<HomesScreen> {
|
class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
late final UserLocationNotifier _userLocationNotifier;
|
late final UserLocationNotifier _userLocationNotifier;
|
||||||
String? _switchingHomeId;
|
String? _switchingHomeId;
|
||||||
String? _deletingHomeId;
|
String? _deletingHomeId;
|
||||||
@@ -24,21 +27,37 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_userLocationNotifier = ref.read(userLocationProvider.notifier);
|
_userLocationNotifier = ref.read(userLocationProvider.notifier);
|
||||||
Future.microtask(() => _userLocationNotifier.startWatching());
|
Future.microtask(() async {
|
||||||
|
await _userLocationNotifier.startWatching();
|
||||||
|
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_userLocationNotifier.stopWatching();
|
_userLocationNotifier.stopWatching();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
Future.microtask(_refreshEnvironmentState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final homes = ref.watch(homesProvider);
|
final homes = ref.watch(homesProvider);
|
||||||
final currentHome = ref.watch(currentHomeProvider);
|
final currentHome = ref.watch(currentHomeProvider);
|
||||||
final location = ref.watch(userLocationProvider);
|
final location = ref.watch(userLocationProvider);
|
||||||
|
final geofenceState = ref.watch(geofenceDiagnosticsProvider);
|
||||||
|
final activeDistanceKm = currentHome == null
|
||||||
|
? null
|
||||||
|
: location.distanceToKm(currentHome.latitude, currentHome.longitude);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -50,11 +69,25 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: homes.isEmpty
|
child: homes.isEmpty
|
||||||
? const _EmptyHomesView()
|
? const _EmptyHomesView()
|
||||||
: ListView.builder(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
itemCount: homes.length,
|
children: [
|
||||||
itemBuilder: (context, index) {
|
_HomesOverviewCard(
|
||||||
final home = homes[index];
|
location: location,
|
||||||
|
diagnostics: geofenceState.data,
|
||||||
|
activeHome: currentHome,
|
||||||
|
activeDistanceKm: activeDistanceKm,
|
||||||
|
onRefresh: _refreshEnvironmentState,
|
||||||
|
onRequestLocationPermission: _requestLocationPermission,
|
||||||
|
onRequestBackgroundPermission:
|
||||||
|
_requestGeofenceBackgroundPermission,
|
||||||
|
onRequestNotificationsPermission:
|
||||||
|
_requestGeofenceNotificationPermission,
|
||||||
|
onOpenAppSettings: _openRelevantAppSettings,
|
||||||
|
onOpenLocationSettings: _openLocationSettings,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
...homes.map((home) {
|
||||||
final isActive = currentHome?.id == home.id;
|
final isActive = currentHome?.id == home.id;
|
||||||
final isSwitching = _switchingHomeId == home.id;
|
final isSwitching = _switchingHomeId == home.id;
|
||||||
final isDeleting = _deletingHomeId == home.id;
|
final isDeleting = _deletingHomeId == home.id;
|
||||||
@@ -90,6 +123,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
home: home,
|
home: home,
|
||||||
location: location,
|
location: location,
|
||||||
distKm: distKm,
|
distKm: distKm,
|
||||||
|
isActive: isActive,
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -117,21 +151,29 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
size: 20,
|
size: 20,
|
||||||
color: Colors.redAccent,
|
color: Colors.redAccent,
|
||||||
),
|
),
|
||||||
onPressed: () => _confirmDelete(context, home),
|
onPressed: () =>
|
||||||
|
_confirmDelete(context, home),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: isBusy ? null : () => _selectHome(context, home),
|
onTap: isBusy
|
||||||
|
? null
|
||||||
|
: () => _selectHome(context, home),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Padding(
|
const SafeArea(
|
||||||
padding: EdgeInsets.only(bottom: 10),
|
top: false,
|
||||||
|
minimum: EdgeInsets.only(bottom: 10),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 6),
|
||||||
child: BuildInfoText(),
|
child: BuildInfoText(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
@@ -158,9 +200,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text('Не удалось выбрать дом: ${describeLoadError(e)}'),
|
||||||
'Не удалось выбрать дом: ${describeLoadError(e)}',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -171,16 +211,18 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addHome(BuildContext context) {
|
Future<void> _addHome(BuildContext context) async {
|
||||||
Navigator.of(
|
await Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
|
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
|
||||||
|
await _refreshEnvironmentState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _editHome(BuildContext context, HomeConfig home) {
|
Future<void> _editHome(BuildContext context, HomeConfig home) async {
|
||||||
Navigator.of(
|
await Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
|
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
|
||||||
|
await _refreshEnvironmentState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmDelete(BuildContext context, HomeConfig home) {
|
void _confirmDelete(BuildContext context, HomeConfig home) {
|
||||||
@@ -222,14 +264,16 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
if (deletedCurrentHome) {
|
if (deletedCurrentHome) {
|
||||||
ref.read(authInfoProvider.notifier).clear();
|
ref.read(authInfoProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
await syncGeofenceTask(ref.read(homesProvider));
|
await syncGeofenceTask(
|
||||||
|
ref.read(homesProvider),
|
||||||
|
currentHome: ref.read(currentHomeProvider),
|
||||||
|
);
|
||||||
|
await _refreshEnvironmentState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text('Не удалось удалить дом: ${describeLoadError(e)}'),
|
||||||
'Не удалось удалить дом: ${describeLoadError(e)}',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -239,6 +283,45 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshEnvironmentState() async {
|
||||||
|
await _userLocationNotifier.refresh();
|
||||||
|
await _refreshGeofenceDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshGeofenceDiagnostics() async {
|
||||||
|
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestLocationPermission() async {
|
||||||
|
await _userLocationNotifier.requestPermission();
|
||||||
|
await _refreshGeofenceDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openLocationSettings() async {
|
||||||
|
await _userLocationNotifier.openLocationSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestGeofenceBackgroundPermission() async {
|
||||||
|
await ref
|
||||||
|
.read(geofenceDiagnosticsProvider.notifier)
|
||||||
|
.requestBackgroundLocationPermission();
|
||||||
|
await _userLocationNotifier.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestGeofenceNotificationPermission() async {
|
||||||
|
await ref
|
||||||
|
.read(geofenceDiagnosticsProvider.notifier)
|
||||||
|
.requestNotificationPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openRelevantAppSettings() async {
|
||||||
|
if (ref.read(userLocationProvider).needsAppSettings) {
|
||||||
|
await _userLocationNotifier.openAppSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmptyHomesView extends StatelessWidget {
|
class _EmptyHomesView extends StatelessWidget {
|
||||||
@@ -271,11 +354,13 @@ class _HomeSubtitle extends StatelessWidget {
|
|||||||
final HomeConfig home;
|
final HomeConfig home;
|
||||||
final UserLocation location;
|
final UserLocation location;
|
||||||
final double? distKm;
|
final double? distKm;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
const _HomeSubtitle({
|
const _HomeSubtitle({
|
||||||
required this.home,
|
required this.home,
|
||||||
required this.location,
|
required this.location,
|
||||||
required this.distKm,
|
required this.distKm,
|
||||||
|
required this.isActive,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -302,7 +387,9 @@ class _HomeSubtitle extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (home.hasCoordinates && !location.hasPosition)
|
else if (home.hasCoordinates && !location.hasPosition)
|
||||||
Row(
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.location_on, size: 12, color: Colors.white24),
|
const Icon(Icons.location_on, size: 12, color: Colors.white24),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -312,7 +399,258 @@ class _HomeSubtitle extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
else if (home.geofenceReady && isActive)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.shield_moon_outlined,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.deepOrangeAccent,
|
||||||
|
),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Geofence включён',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.deepOrangeAccent,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (home.geofenceReady)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.shield_moon_outlined,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.white24,
|
||||||
|
),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Geofence включён',
|
||||||
|
style: TextStyle(color: Colors.white24, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HomesOverviewCard extends StatelessWidget {
|
||||||
|
final UserLocation location;
|
||||||
|
final GeofenceDiagnostics diagnostics;
|
||||||
|
final HomeConfig? activeHome;
|
||||||
|
final double? activeDistanceKm;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
final Future<void> Function() onRequestLocationPermission;
|
||||||
|
final Future<void> Function() onRequestBackgroundPermission;
|
||||||
|
final Future<void> Function() onRequestNotificationsPermission;
|
||||||
|
final Future<void> Function() onOpenAppSettings;
|
||||||
|
final Future<void> Function() onOpenLocationSettings;
|
||||||
|
|
||||||
|
const _HomesOverviewCard({
|
||||||
|
required this.location,
|
||||||
|
required this.diagnostics,
|
||||||
|
required this.activeHome,
|
||||||
|
required this.activeDistanceKm,
|
||||||
|
required this.onRefresh,
|
||||||
|
required this.onRequestLocationPermission,
|
||||||
|
required this.onRequestBackgroundPermission,
|
||||||
|
required this.onRequestNotificationsPermission,
|
||||||
|
required this.onOpenAppSettings,
|
||||||
|
required this.onOpenLocationSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final title = activeHome == null
|
||||||
|
? 'Статус автоматизации'
|
||||||
|
: 'Активный дом: ${activeHome!.name}';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_overviewIcon(location, diagnostics),
|
||||||
|
color: _overviewColor(location, diagnostics),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_overviewPrimaryText(location, diagnostics),
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
if (_overviewSecondaryText(location, diagnostics, activeDistanceKm)
|
||||||
|
case final secondary?)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
secondary,
|
||||||
|
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Обновить'),
|
||||||
|
),
|
||||||
|
if (location.canRequestPermission)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onRequestLocationPermission,
|
||||||
|
icon: const Icon(Icons.my_location),
|
||||||
|
label: const Text('Разрешить геолокацию'),
|
||||||
|
),
|
||||||
|
if (location.needsLocationSettings)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onOpenLocationSettings,
|
||||||
|
icon: const Icon(Icons.location_searching),
|
||||||
|
label: const Text('Включить GPS'),
|
||||||
|
),
|
||||||
|
if (diagnostics.canRequestBackgroundLocation)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onRequestBackgroundPermission,
|
||||||
|
icon: const Icon(Icons.shield_moon_outlined),
|
||||||
|
label: const Text('Доступ всегда'),
|
||||||
|
),
|
||||||
|
if (diagnostics.canRequestNotifications)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onRequestNotificationsPermission,
|
||||||
|
icon: const Icon(Icons.notifications_active_outlined),
|
||||||
|
label: const Text('Уведомления'),
|
||||||
|
),
|
||||||
|
if (location.needsAppSettings ||
|
||||||
|
diagnostics.canRequestBackgroundLocation)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onOpenAppSettings,
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
label: const Text('Настройки'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _overviewPrimaryText(
|
||||||
|
UserLocation location,
|
||||||
|
GeofenceDiagnostics diagnostics,
|
||||||
|
) {
|
||||||
|
if (!location.hasPosition) {
|
||||||
|
return location.error ?? 'Позиция устройства пока недоступна.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (diagnostics.status) {
|
||||||
|
GeofenceStatusKind.ready =>
|
||||||
|
'Расстояние считается, geofence активен и готов выключить свет.',
|
||||||
|
GeofenceStatusKind.triggered =>
|
||||||
|
'Расстояние считается, geofence уже сработал для активного дома.',
|
||||||
|
GeofenceStatusKind.cooldown =>
|
||||||
|
'Расстояние считается, но после сбоя geofence сейчас на паузе.',
|
||||||
|
GeofenceStatusKind.notificationsPermissionDenied =>
|
||||||
|
'Расстояние считается, но уведомления для geofence сейчас запрещены.',
|
||||||
|
GeofenceStatusKind.backgroundPermissionDenied =>
|
||||||
|
'Расстояние считается, но без доступа "Всегда" geofence в фоне будет кастрирован.',
|
||||||
|
GeofenceStatusKind.locationServicesDisabled =>
|
||||||
|
'Геолокация выключена, поэтому и расстояния, и geofence сейчас мёртвые.',
|
||||||
|
GeofenceStatusKind.locationPermissionDenied =>
|
||||||
|
'Без разрешения на геолокацию тут нечего считать.',
|
||||||
|
GeofenceStatusKind.disabled =>
|
||||||
|
'Расстояние считается, но geofence для активного дома выключен.',
|
||||||
|
GeofenceStatusKind.missingCoordinates =>
|
||||||
|
'Расстояние считается, но у активного дома нет координат для geofence.',
|
||||||
|
GeofenceStatusKind.noActiveHome =>
|
||||||
|
'Выбери активный дом, и тогда автоматика станет осмысленной.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _overviewSecondaryText(
|
||||||
|
UserLocation location,
|
||||||
|
GeofenceDiagnostics diagnostics,
|
||||||
|
double? activeDistanceKm,
|
||||||
|
) {
|
||||||
|
final parts = <String>[];
|
||||||
|
|
||||||
|
if (activeDistanceKm != null) {
|
||||||
|
parts.add('До активного дома: ${formatDistance(activeDistanceKm)}.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.updatedAt != null) {
|
||||||
|
parts.add('Точка: ${_formatTimestamp(location.updatedAt!)}.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final secondary = diagnostics.secondaryMessage;
|
||||||
|
if (secondary != null && secondary.isNotEmpty) {
|
||||||
|
parts.add(secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.isEmpty) return null;
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _overviewIcon(UserLocation location, GeofenceDiagnostics diagnostics) {
|
||||||
|
if (!location.hasPosition) {
|
||||||
|
return Icons.location_off_outlined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (diagnostics.status) {
|
||||||
|
GeofenceStatusKind.ready => Icons.shield_moon_outlined,
|
||||||
|
GeofenceStatusKind.triggered => Icons.check_circle_outline,
|
||||||
|
GeofenceStatusKind.cooldown => Icons.timer_outlined,
|
||||||
|
GeofenceStatusKind.notificationsPermissionDenied =>
|
||||||
|
Icons.notifications_off_outlined,
|
||||||
|
GeofenceStatusKind.backgroundPermissionDenied => Icons.location_searching,
|
||||||
|
_ => Icons.info_outline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _overviewColor(UserLocation location, GeofenceDiagnostics diagnostics) {
|
||||||
|
if (!location.hasPosition) {
|
||||||
|
return Colors.redAccent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (diagnostics.status) {
|
||||||
|
GeofenceStatusKind.ready => Colors.greenAccent,
|
||||||
|
GeofenceStatusKind.triggered => Colors.deepOrangeAccent,
|
||||||
|
GeofenceStatusKind.cooldown => Colors.amberAccent,
|
||||||
|
GeofenceStatusKind.notificationsPermissionDenied ||
|
||||||
|
GeofenceStatusKind.backgroundPermissionDenied => Colors.deepOrangeAccent,
|
||||||
|
_ => Colors.white54,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(DateTime timestamp) {
|
||||||
|
final local = timestamp.toLocal();
|
||||||
|
final hour = local.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = local.minute.toString().padLeft(2, '0');
|
||||||
|
final second = local.second.toString().padLeft(2, '0');
|
||||||
|
return '$hour:$minute:$second';
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 4),
|
padding: EdgeInsets.symmetric(vertical: 4),
|
||||||
child: BuildInfoText(),
|
child: BuildInfoText(compact: false, alignStart: true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Порог расстояния для срабатывания геофенса (метры)
|
import '../features/homes/geofence_logic.dart';
|
||||||
const double geofenceThresholdMeters = 500.0;
|
import '../features/homes/services/geofence_runtime_store.dart';
|
||||||
|
|
||||||
/// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать
|
|
||||||
const String _firedKey = 'ignis_geofence_fired';
|
|
||||||
const String _apiKeyPrefix = 'ignis_home_api_key_';
|
const String _apiKeyPrefix = 'ignis_home_api_key_';
|
||||||
|
|
||||||
/// Имя задачи в workmanager
|
/// Имя задачи в workmanager
|
||||||
@@ -29,70 +28,35 @@ const String _channelDesc = 'Уведомления об автовыключе
|
|||||||
/// Возвращает true если таск выполнен успешно (workmanager convention).
|
/// Возвращает true если таск выполнен успешно (workmanager convention).
|
||||||
Future<bool> executeGeofenceCheck() async {
|
Future<bool> executeGeofenceCheck() async {
|
||||||
try {
|
try {
|
||||||
// 1. Проверяем, не сработал ли уже
|
final runtimeStore = GeofenceRuntimeStore();
|
||||||
final prefs = await SharedPreferences.getInstance();
|
var runtime = await runtimeStore.load();
|
||||||
if (prefs.getBool(_firedKey) == true) {
|
final armedHomeId = runtime.armedHomeId;
|
||||||
// Уже сработал -- ничего не делаем.
|
if (armedHomeId == null || armedHomeId.isEmpty) {
|
||||||
// Таск будет отменён при следующем запуске приложения.
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Загружаем дома из SharedPreferences
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final raw = prefs.getString('ignis_homes');
|
final raw = prefs.getString('ignis_homes');
|
||||||
if (raw == null || raw.isEmpty) return true;
|
if (raw == null || raw.isEmpty) return true;
|
||||||
|
|
||||||
final List<dynamic> homesList = jsonDecode(raw);
|
final List<dynamic> homesList = jsonDecode(raw);
|
||||||
final currentHomeId = prefs.getString('ignis_current_home_id');
|
final targetHome = _findArmedHome(homesList, armedHomeId);
|
||||||
|
if (targetHome == null) {
|
||||||
// Ищем текущий дом с включённым геофенсом
|
await runtimeStore.disarm();
|
||||||
Map<String, dynamic>? targetHome;
|
return true;
|
||||||
for (final h in homesList) {
|
|
||||||
final map = h as Map<String, dynamic>;
|
|
||||||
final isTarget = (currentHomeId != null)
|
|
||||||
? map['id'] == currentHomeId
|
|
||||||
: true; // если нет текущего -- берём первый подходящий
|
|
||||||
if (isTarget &&
|
|
||||||
map['geofenceEnabled'] == true &&
|
|
||||||
map['latitude'] != null &&
|
|
||||||
map['longitude'] != null) {
|
|
||||||
targetHome = map;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetHome == null) return true; // нет дома с геофенсом
|
|
||||||
|
|
||||||
// 3. Получаем текущую позицию
|
|
||||||
if (!await Geolocator.isLocationServiceEnabled()) return true;
|
if (!await Geolocator.isLocationServiceEnabled()) return true;
|
||||||
|
|
||||||
final perm = await Geolocator.checkPermission();
|
final perm = await Geolocator.checkPermission();
|
||||||
if (perm == LocationPermission.denied ||
|
if (!hasBackgroundLocationAccess(perm)) {
|
||||||
perm == LocationPermission.deniedForever) {
|
return true;
|
||||||
return true; // нет пермишена -- молча выходим
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Position? pos;
|
final pos = await _getCurrentPosition();
|
||||||
try {
|
if (pos == null) return true;
|
||||||
// Сначала lastKnown (мгновенно)
|
|
||||||
pos = await Geolocator.getLastKnownPosition();
|
|
||||||
final lastTimestamp = pos?.timestamp;
|
|
||||||
|
|
||||||
// Если позиции нет или она несвежая -- запрашиваем новую
|
final now = DateTime.now();
|
||||||
if (pos == null ||
|
|
||||||
lastTimestamp == null ||
|
|
||||||
DateTime.now().difference(lastTimestamp).inMinutes > 5) {
|
|
||||||
pos = await Geolocator.getCurrentPosition(
|
|
||||||
locationSettings: const LocationSettings(
|
|
||||||
accuracy: LocationAccuracy.low,
|
|
||||||
timeLimit: Duration(seconds: 15),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
return true; // не удалось получить позицию
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Считаем расстояние
|
|
||||||
final homeLat = (targetHome['latitude'] as num).toDouble();
|
final homeLat = (targetHome['latitude'] as num).toDouble();
|
||||||
final homeLon = (targetHome['longitude'] as num).toDouble();
|
final homeLon = (targetHome['longitude'] as num).toDouble();
|
||||||
final distMeters = _haversineMeters(
|
final distMeters = _haversineMeters(
|
||||||
@@ -103,39 +67,82 @@ Future<bool> executeGeofenceCheck() async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (distMeters <= geofenceThresholdMeters) {
|
if (distMeters <= geofenceThresholdMeters) {
|
||||||
return true; // всё ещё рядом с домом
|
runtime = runtime.recordInsideHome(
|
||||||
|
armedHomeId,
|
||||||
|
checkedAt: now,
|
||||||
|
distanceMeters: distMeters,
|
||||||
|
);
|
||||||
|
await runtimeStore.save(runtime);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime = runtime.recordOutsideCheck(
|
||||||
|
armedHomeId,
|
||||||
|
checkedAt: now,
|
||||||
|
distanceMeters: distMeters,
|
||||||
|
);
|
||||||
|
await runtimeStore.save(runtime);
|
||||||
|
|
||||||
|
if (runtime.isTriggeredFor(armedHomeId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final retryRemaining = geofenceRetryRemaining(
|
||||||
|
runtime.failureAtFor(armedHomeId),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
if (retryRemaining != null) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Ушли за порог -- выключаем все группы
|
|
||||||
final url = _normalizeUrl(targetHome['url'] as String);
|
final url = _normalizeUrl(targetHome['url'] as String);
|
||||||
final apiKey = await _getHomeApiKey(targetHome);
|
final apiKey = await _getHomeApiKey(targetHome);
|
||||||
if (apiKey == null || apiKey.isEmpty) return true;
|
if (apiKey == null || apiKey.isEmpty) {
|
||||||
final homeName = (targetHome['name'] ?? 'Дом') as String;
|
runtime = runtime.recordFailure(
|
||||||
|
armedHomeId,
|
||||||
int groupCount = 0;
|
failedAt: now,
|
||||||
try {
|
distanceMeters: distMeters,
|
||||||
groupCount = await _turnOffAllGroups(url, apiKey);
|
message: 'Не найден API key для armed geofence дома.',
|
||||||
} catch (_) {
|
);
|
||||||
// Даже если не удалось выключить -- помечаем как сработавший,
|
await runtimeStore.save(runtime);
|
||||||
// чтобы не спамить запросами
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Помечаем как сработавший
|
final homeName = (targetHome['name'] ?? 'Дом') as String;
|
||||||
await prefs.setBool(_firedKey, true);
|
|
||||||
|
try {
|
||||||
|
final result = await _turnOffAllGroups(url, apiKey);
|
||||||
|
if (result.totalGroups > 0 && result.successCount < result.totalGroups) {
|
||||||
|
throw StateError(
|
||||||
|
'Выключено только ${result.successCount} из ${result.totalGroups} групп.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime = runtime.recordSuccess(
|
||||||
|
armedHomeId,
|
||||||
|
triggeredAt: now,
|
||||||
|
distanceMeters: distMeters,
|
||||||
|
);
|
||||||
|
await runtimeStore.save(runtime);
|
||||||
|
|
||||||
// 7. Показываем уведомление
|
|
||||||
final distText = distMeters < 1000
|
|
||||||
? '${distMeters.round()} м'
|
|
||||||
: '${(distMeters / 1000).toStringAsFixed(1)} км';
|
|
||||||
await _showNotification(
|
await _showNotification(
|
||||||
title: 'Свет выключен',
|
title: 'Свет выключен',
|
||||||
body:
|
body:
|
||||||
'$homeName -- вы ушли на $distText. '
|
'$homeName -- вы ушли на ${formatDistanceMeters(distMeters)}. '
|
||||||
'Выключено групп: $groupCount.',
|
'Выключено групп: ${result.successCount}.',
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
runtime = runtime.recordFailure(
|
||||||
|
armedHomeId,
|
||||||
|
failedAt: now,
|
||||||
|
distanceMeters: distMeters,
|
||||||
|
message: _describeFailure(error),
|
||||||
|
);
|
||||||
|
await runtimeStore.save(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
// Любая ошибка -- не крашим воркер
|
// Любая ошибка -- не крашим воркер
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -154,19 +161,6 @@ Future<String?> _getHomeApiKey(Map<String, dynamic> home) async {
|
|||||||
return home['apiKey']?.toString();
|
return home['apiKey']?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Сбросить флаг "сработал" -- вызывать при включении геофенса
|
|
||||||
/// или при возврате в приложение.
|
|
||||||
Future<void> resetGeofenceFired() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.remove(_firedKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверить, сработал ли геофенс
|
|
||||||
Future<bool> isGeofenceFired() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
return prefs.getBool(_firedKey) == true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Уведомления ─────────────────────────────────────────────
|
// ─── Уведомления ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// Показать локальное уведомление из фонового изолята
|
/// Показать локальное уведомление из фонового изолята
|
||||||
@@ -192,6 +186,15 @@ Future<void> _showNotification({
|
|||||||
|
|
||||||
const details = NotificationDetails(android: androidDetails);
|
const details = NotificationDetails(android: androidDetails);
|
||||||
|
|
||||||
|
final android = plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
final notificationsEnabled = await android?.areNotificationsEnabled() ?? true;
|
||||||
|
if (!notificationsEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await plugin.show(
|
await plugin.show(
|
||||||
42, // фиксированный id -- перезаписывает предыдущее уведомление
|
42, // фиксированный id -- перезаписывает предыдущее уведомление
|
||||||
title,
|
title,
|
||||||
@@ -202,8 +205,50 @@ Future<void> _showNotification({
|
|||||||
|
|
||||||
// ─── Внутренние хелперы ──────────────────────────────────────
|
// ─── Внутренние хелперы ──────────────────────────────────────
|
||||||
|
|
||||||
/// Выключить все группы на сервере. Возвращает кол-во выключенных.
|
Map<String, dynamic>? _findArmedHome(
|
||||||
Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
List<dynamic> homesList,
|
||||||
|
String armedHomeId,
|
||||||
|
) {
|
||||||
|
for (final item in homesList) {
|
||||||
|
if (item is! Map) continue;
|
||||||
|
final map = Map<String, dynamic>.from(item);
|
||||||
|
if (map['id'] == armedHomeId &&
|
||||||
|
map['geofenceEnabled'] == true &&
|
||||||
|
map['latitude'] != null &&
|
||||||
|
map['longitude'] != null) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Position?> _getCurrentPosition() async {
|
||||||
|
try {
|
||||||
|
var pos = await Geolocator.getLastKnownPosition();
|
||||||
|
final lastTimestamp = pos?.timestamp;
|
||||||
|
|
||||||
|
if (pos == null ||
|
||||||
|
lastTimestamp == null ||
|
||||||
|
DateTime.now().difference(lastTimestamp).inMinutes > 5) {
|
||||||
|
pos = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.low,
|
||||||
|
timeLimit: Duration(seconds: 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выключить все группы на сервере.
|
||||||
|
Future<_TurnOffGroupsResult> _turnOffAllGroups(
|
||||||
|
String baseUrl,
|
||||||
|
String apiKey,
|
||||||
|
) async {
|
||||||
final dio = Dio(
|
final dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
@@ -214,16 +259,13 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Получаем список групп
|
|
||||||
final res = await dio.get('/devices/groups');
|
final res = await dio.get('/devices/groups');
|
||||||
List<String> groupIds = [];
|
final groupIds = <String>[];
|
||||||
|
|
||||||
if (res.data is Map) {
|
if (res.data is Map) {
|
||||||
// {id: {...}, ...} -- ключи и есть id
|
|
||||||
final map = res.data as Map;
|
final map = res.data as Map;
|
||||||
for (final entry in map.entries) {
|
for (final entry in map.entries) {
|
||||||
final id = entry.key.toString();
|
groupIds.add(entry.key.toString());
|
||||||
groupIds.add(id);
|
|
||||||
}
|
}
|
||||||
} else if (res.data is List) {
|
} else if (res.data is List) {
|
||||||
for (final g in res.data) {
|
for (final g in res.data) {
|
||||||
@@ -233,7 +275,6 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выключаем каждую группу
|
|
||||||
int success = 0;
|
int success = 0;
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
groupIds.map((id) async {
|
groupIds.map((id) async {
|
||||||
@@ -249,12 +290,28 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return success;
|
return _TurnOffGroupsResult(
|
||||||
|
totalGroups: groupIds.length,
|
||||||
|
successCount: success,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
dio.close();
|
dio.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _describeFailure(Object error) {
|
||||||
|
if (error is DioException) {
|
||||||
|
final statusCode = error.response?.statusCode;
|
||||||
|
final detail = error.response?.data;
|
||||||
|
if (statusCode != null) {
|
||||||
|
return 'Backend ответил ошибкой $statusCode${detail != null ? ': $detail' : ''}';
|
||||||
|
}
|
||||||
|
return 'Сетевой запрос сломался: ${error.message ?? error.type.name}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/// Нормализация URL
|
/// Нормализация URL
|
||||||
String _normalizeUrl(String url) {
|
String _normalizeUrl(String url) {
|
||||||
var u = url.trim();
|
var u = url.trim();
|
||||||
@@ -279,3 +336,13 @@ double _haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double _degToRad(double deg) => deg * (math.pi / 180);
|
double _degToRad(double deg) => deg * (math.pi / 180);
|
||||||
|
|
||||||
|
class _TurnOffGroupsResult {
|
||||||
|
final int totalGroups;
|
||||||
|
final int successCount;
|
||||||
|
|
||||||
|
const _TurnOffGroupsResult({
|
||||||
|
required this.totalGroups,
|
||||||
|
required this.successCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,14 +3,50 @@ import 'package:flutter/material.dart';
|
|||||||
import '../app/build_info.dart';
|
import '../app/build_info.dart';
|
||||||
|
|
||||||
class BuildInfoText extends StatelessWidget {
|
class BuildInfoText extends StatelessWidget {
|
||||||
const BuildInfoText({super.key});
|
final bool compact;
|
||||||
|
final bool alignStart;
|
||||||
|
|
||||||
|
const BuildInfoText({
|
||||||
|
super.key,
|
||||||
|
this.compact = true,
|
||||||
|
this.alignStart = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final alignment = alignStart
|
||||||
|
? CrossAxisAlignment.start
|
||||||
|
: CrossAxisAlignment.center;
|
||||||
|
final textAlign = alignStart ? TextAlign.left : TextAlign.center;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
return Text(
|
return Text(
|
||||||
BuildInfo.label,
|
BuildInfo.label,
|
||||||
textAlign: TextAlign.center,
|
textAlign: textAlign,
|
||||||
style: const TextStyle(color: Colors.white24, fontSize: 10),
|
style: const TextStyle(color: Colors.white24, fontSize: 10),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: alignment,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
BuildInfo.hasMetadata ? BuildInfo.shortSha : 'build info unavailable',
|
||||||
|
textAlign: textAlign,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white54,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
BuildInfo.hasMetadata ? BuildInfo.formattedDate : '',
|
||||||
|
textAlign: textAlign,
|
||||||
|
style: const TextStyle(color: Colors.white24, fontSize: 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
test/geofence_logic_test.dart
Normal file
41
test/geofence_logic_test.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:ignis_app/features/homes/geofence_logic.dart';
|
||||||
|
import 'package:ignis_app/features/homes/providers/location_providers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('distance formatting stays readable across ranges', () {
|
||||||
|
expect(formatDistance(0.42), '420 м');
|
||||||
|
expect(formatDistance(2.34), '2.3 км');
|
||||||
|
expect(formatDistance(12.8), '13 км');
|
||||||
|
expect(formatDistanceMeters(450), '450 м');
|
||||||
|
expect(formatDistanceMeters(1450), '1.4 км');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distance calculation stays in realistic range', () {
|
||||||
|
final distanceKm = calculateDistanceKm(55.75, 37.61, 55.76, 37.61);
|
||||||
|
expect(distanceKm, closeTo(1.11, 0.15));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('background location access requires always permission', () {
|
||||||
|
expect(hasForegroundLocationAccess(LocationPermission.whileInUse), isTrue);
|
||||||
|
expect(hasBackgroundLocationAccess(LocationPermission.whileInUse), isFalse);
|
||||||
|
expect(hasBackgroundLocationAccess(LocationPermission.always), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retry remaining expires after cooldown window', () {
|
||||||
|
final now = DateTime(2026, 5, 1, 12, 0, 0);
|
||||||
|
final lastFailure = now.subtract(const Duration(minutes: 10));
|
||||||
|
final retryRemaining = geofenceRetryRemaining(lastFailure, now: now);
|
||||||
|
|
||||||
|
expect(retryRemaining, isNotNull);
|
||||||
|
expect(retryRemaining!.inMinutes, 20);
|
||||||
|
expect(
|
||||||
|
geofenceRetryRemaining(
|
||||||
|
now.subtract(const Duration(minutes: 31)),
|
||||||
|
now: now,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
79
test/geofence_runtime_store_test.dart
Normal file
79
test/geofence_runtime_store_test.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/features/homes/services/geofence_runtime_store.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('runtime store persists armed home and success markers', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
final store = GeofenceRuntimeStore();
|
||||||
|
await store.armForHome('home-1');
|
||||||
|
|
||||||
|
var runtime = await store.load();
|
||||||
|
expect(runtime.armedHomeId, 'home-1');
|
||||||
|
|
||||||
|
runtime = runtime.recordSuccess(
|
||||||
|
'home-1',
|
||||||
|
triggeredAt: DateTime(2026, 5, 1, 18, 30),
|
||||||
|
distanceMeters: 820,
|
||||||
|
);
|
||||||
|
await store.save(runtime);
|
||||||
|
|
||||||
|
final loaded = await store.load();
|
||||||
|
expect(loaded.isTriggeredFor('home-1'), isTrue);
|
||||||
|
expect(loaded.lastSuccessHomeId, 'home-1');
|
||||||
|
expect(loaded.lastDistanceMeters, 820);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'returning into home radius rearms geofence and clears failure',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
final store = GeofenceRuntimeStore();
|
||||||
|
var runtime = await store.armForHome('home-1');
|
||||||
|
runtime = runtime.recordFailure(
|
||||||
|
'home-1',
|
||||||
|
failedAt: DateTime(2026, 5, 1, 19, 0),
|
||||||
|
distanceMeters: 900,
|
||||||
|
message: 'Backend умер по дороге.',
|
||||||
|
);
|
||||||
|
runtime = runtime.recordInsideHome(
|
||||||
|
'home-1',
|
||||||
|
checkedAt: DateTime(2026, 5, 1, 22, 0),
|
||||||
|
distanceMeters: 120,
|
||||||
|
);
|
||||||
|
await store.save(runtime);
|
||||||
|
|
||||||
|
final loaded = await store.load();
|
||||||
|
expect(loaded.failureAtFor('home-1'), isNull);
|
||||||
|
expect(loaded.isTriggeredFor('home-1'), isFalse);
|
||||||
|
expect(loaded.lastDistanceMeters, 120);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'removing home clears armed and historical runtime for that home',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
final store = GeofenceRuntimeStore();
|
||||||
|
var runtime = await store.armForHome('home-1');
|
||||||
|
runtime = runtime.recordSuccess(
|
||||||
|
'home-1',
|
||||||
|
triggeredAt: DateTime(2026, 5, 1, 20, 0),
|
||||||
|
distanceMeters: 760,
|
||||||
|
);
|
||||||
|
await store.save(runtime);
|
||||||
|
|
||||||
|
await store.removeHome('home-1');
|
||||||
|
final loaded = await store.load();
|
||||||
|
|
||||||
|
expect(loaded.armedHomeId, isNull);
|
||||||
|
expect(loaded.lastSuccessHomeId, isNull);
|
||||||
|
expect(loaded.triggeredHomeId, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user