Replace geofence polling with native Android geofence
This commit is contained in:
186
README.md
186
README.md
@@ -1,103 +1,85 @@
|
|||||||
# Ignis App
|
# Ignis App
|
||||||
|
|
||||||
Мобильное приложение для управления умными лампами WiZ через self-hosted сервер [Ignis Core](https://git.akokos.ru/artem.kokos/ignis-core).
|
Android-клиент для self-hosted backend [Ignis Core](https://git.akokos.ru/artem.kokos/ignis-core). Приложение управляет группами ламп WiZ, расписаниями, API-ключами и гео-автоматизацией ухода из дома.
|
||||||
|
|
||||||
## Возможности
|
## Что умеет
|
||||||
|
|
||||||
- **Мульти-дом** -- поддержка нескольких серверов Ignis (квартира, дача, друзья). Каждый дом -- отдельный сервер со своим URL и API-ключом.
|
- несколько домов с отдельными URL и API-ключами;
|
||||||
- **Группы ламп** -- создание, удаление и управление. При создании группы есть product-валидация, автогенерация `ID`, предупреждение о конфликтах по устройствам и более честный перескан сети.
|
- управление группами света: `on/off`, яркость, температура, RGB, сцены;
|
||||||
- **Управление освещением:**
|
- таймер "включить на 4 часа";
|
||||||
- Включение/выключение
|
- одноразовые и повторяющиеся расписания;
|
||||||
- Яркость 10--100% с шагом 10%
|
- статистика и лог событий;
|
||||||
- Цветовая температура 2700--6500K с шагом 100K
|
- управление гостевыми API-ключами для администратора;
|
||||||
- RGB-цвет через HSV-пикер
|
- расстояние до дома и автовыключение света по geofence.
|
||||||
- Сцены (загружаются с сервера, отображаются с человекочитаемыми названиями)
|
|
||||||
- Таймер "включить на 4 часа"
|
## Гео-автоматизация
|
||||||
- **Расписания** -- одноразовые таймеры с выбором даты/времени и повторяющиеся задачи с выбором дней недели. Просмотр, создание, валидация и отмена активных задач.
|
|
||||||
- **API-ключи** -- просмотр, создание, отзыв и повторная активация гостевых ключей для администраторов с отдельным UX для только что созданного ключа.
|
Для активного дома приложение может зарегистрировать системный Android geofence. После подтверждённого `EXIT` запускается короткая фоновая задача, которая проверяет группы и выключает только те, что реально включены.
|
||||||
- **Статистика и лог событий** -- просмотр сводки по группам и последних событий сервера.
|
|
||||||
- **Геофенс и расстояния** -- live-дистанция до дома в UI и опциональное автовыключение света при уходе. Геофенс работает для текущего активного дома, показывает диагностический статус и использует cooldown/re-arm поведение.
|
Это не polling каждые 15 минут. Основной триггер здесь событийный:
|
||||||
- **Устойчивость к ошибкам** -- гранулярные состояния загрузки (`LoadState`), централизованная обработка сетевых сбоев, soft-ошибки при управлении ползунками без спама в UI.
|
- geofence регистрируется нативно через Android geofencing API;
|
||||||
|
- сетевое выключение выполняется отдельным one-off worker;
|
||||||
|
- при отсутствии координат или выключенной опции geofence не армится.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
- Flutter 3.x / Dart
|
- Flutter / Dart
|
||||||
- Riverpod -- управление состоянием
|
- Riverpod
|
||||||
- Dio -- HTTP-клиент
|
- Dio
|
||||||
- SharedPreferences -- локальное хранение несекретных настроек
|
- SharedPreferences
|
||||||
- Flutter Secure Storage -- безопасное хранение API-ключей
|
- flutter_secure_storage
|
||||||
- Geolocator -- геолокация
|
- Geolocator
|
||||||
- Workmanager -- периодические фоновые задачи
|
- Android Geofencing API
|
||||||
- Flutter Local Notifications -- локальные уведомления
|
- Android WorkManager
|
||||||
|
|
||||||
## Структура проекта
|
## Структура
|
||||||
|
|
||||||
```text
|
```text
|
||||||
lib/
|
lib/
|
||||||
├── app/
|
├── app/ # bootstrap, build info, error/load helpers
|
||||||
│ ├── app_bootstrap.dart -- bootstrap приложения и навигация
|
├── features/ # feature-level providers and logic
|
||||||
│ ├── build_info.dart -- метаданные сборки (дата, git hash)
|
│ ├── api_keys/
|
||||||
│ ├── error_message.dart -- форматирование ошибок API и сети
|
│ ├── auth/
|
||||||
│ └── load_state.dart -- универсальный стейт загрузки (idle/loading/data/error)
|
│ ├── groups/
|
||||||
├── main.dart -- точка входа, тема, роутер
|
│ ├── homes/
|
||||||
├── models/
|
│ ├── remote/
|
||||||
│ ├── api_key_info.dart -- типизированная модель API-ключа
|
│ ├── schedules/
|
||||||
│ ├── auth_info.dart -- информация об авторизации
|
│ ├── shared/
|
||||||
│ ├── event_log_item.dart -- лог событий
|
│ └── stats/
|
||||||
│ ├── home_config.dart -- несекретная конфигурация сервера
|
├── models/ # typed domain models
|
||||||
│ ├── ignis_device.dart -- устройство умного дома
|
├── providers/ # public provider barrel
|
||||||
│ ├── ignis_group.dart -- группа устройств и её состояние
|
├── screens/ # UI screens
|
||||||
│ ├── ignis_scene.dart -- сцена освещения
|
├── services/ # API client, settings, credentials
|
||||||
│ ├── schedule_task.dart -- задача расписания
|
└── widgets/ # reusable UI widgets
|
||||||
│ └── stats_summary.dart -- статистика
|
|
||||||
├── services/
|
android/app/src/main/kotlin/ru/akokos/ignis_app/
|
||||||
│ ├── api_client.dart -- HTTP-клиент к Ignis Core API
|
├── MainActivity.kt
|
||||||
│ ├── credentials_storage.dart -- безопасное хранение ключей
|
├── GeofenceAutomationManager.kt
|
||||||
│ ├── geofence_worker.dart -- фоновая логика геофенса
|
├── GeofenceBroadcastReceiver.kt
|
||||||
│ └── settings_service.dart -- хранение списка "домов"
|
├── GeofenceExitWorker.kt
|
||||||
├── features/
|
└── GeofenceRestoreReceiver.kt
|
||||||
│ ├── api_keys/providers/ -- управление гостевыми API-ключами
|
|
||||||
│ ├── auth/providers/ -- auth/me и auth-state
|
|
||||||
│ ├── groups/ -- валидация и логика форм групп
|
|
||||||
│ ├── homes/ -- дома, геолокация, geofence sync/runtime
|
|
||||||
│ ├── remote/providers/ -- polling групп, устройства, сцены, control errors
|
|
||||||
│ ├── schedules/ -- логика и providers расписаний
|
|
||||||
│ ├── shared/providers/ -- базовые core providers
|
|
||||||
│ └── stats/providers/ -- статистика и лог событий
|
|
||||||
├── providers/
|
|
||||||
│ └── providers.dart -- compatibility barrel для публичных provider-экспортов
|
|
||||||
├── screens/
|
|
||||||
│ ├── api_keys_screen.dart -- экран гостевых API-ключей
|
|
||||||
│ ├── event_log_screen.dart -- последние события сервера
|
|
||||||
│ ├── homes_screen.dart -- список домов, distance/geofence статус
|
|
||||||
│ ├── home_edit_screen.dart -- создание и редактирование дома
|
|
||||||
│ ├── remote_screen.dart -- основной экран управления светом
|
|
||||||
│ ├── group_edit_screen.dart -- создание группы с выбором устройств
|
|
||||||
│ ├── schedules_screen.dart -- создание и просмотр расписаний
|
|
||||||
│ └── stats_screen.dart -- статистика по командам
|
|
||||||
└── widgets/
|
|
||||||
├── build_info_text.dart -- лейбл с версией сборки
|
|
||||||
├── group_card.dart
|
|
||||||
├── load_error_view.dart -- универсальный виджет ошибок и retry
|
|
||||||
└── color_picker.dart
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Сборка
|
## Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Зависимости
|
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
|
||||||
# Debug-запуск
|
|
||||||
flutter run
|
flutter run
|
||||||
|
|
||||||
# Release APK (с пробросом build info)
|
|
||||||
flutter build apk --release --dart-define=IGNIS_BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" --dart-define=IGNIS_GIT_SHA="$(git rev-parse --short HEAD)"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
APK: `build/app/outputs/flutter-apk/app-release.apk`
|
## Release APK
|
||||||
|
|
||||||
> Сейчас release APK подписывается debug-ключом из Flutter-шаблона. Для личной установки на телефон этого достаточно, для настоящего релиза подпись нужно заменить.
|
```bash
|
||||||
|
flutter build apk --release \
|
||||||
|
--dart-define=IGNIS_BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||||
|
--dart-define=IGNIS_GIT_SHA="$(git rev-parse --short HEAD)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Артефакт:
|
||||||
|
|
||||||
|
```text
|
||||||
|
build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
## Проверки
|
## Проверки
|
||||||
|
|
||||||
@@ -106,40 +88,28 @@ flutter analyze
|
|||||||
flutter test
|
flutter test
|
||||||
```
|
```
|
||||||
|
|
||||||
Текущий baseline зелёный: `flutter analyze`, `flutter test` и release APK сборка проходят штатно.
|
Сейчас тестами прикрыты:
|
||||||
|
- parsing и load-state основных backend-ответов;
|
||||||
Дополнительно тестами уже прикрыты:
|
- сериализация `HomeConfig` и geofence radius;
|
||||||
- typed parsing/load-state для основных backend-ответов;
|
- синхронизация активного дома с geofence automation;
|
||||||
- geofence distance/runtime логика;
|
- form logic для домов, групп и расписаний;
|
||||||
- чистая логика форм расписаний и групп;
|
- provider-мутаторы расписаний, API-ключей и group control;
|
||||||
- provider-мутаторы для расписаний, таймера 4h и API-ключей;
|
- widget-сценарии форм, `GroupCard` и error/retry потоков.
|
||||||
- widget-сценарии форм домов, групп, расписаний и API-ключей;
|
|
||||||
- widget-сценарии `RemoteScreen`, `GroupCard` и error/retry-потоков.
|
|
||||||
|
|
||||||
Сейчас baseline клиента закрывается примерно `60` тестами и уже ловит regressions не только в helper-логике, но и в основных пользовательских сценариях.
|
|
||||||
|
|
||||||
## Настройка
|
## Настройка
|
||||||
|
|
||||||
При первом запуске приложение попросит добавить "дом" -- указать адрес сервера Ignis и API-ключ. После этого откроется пульт управления группами.
|
1. Добавить дом: адрес сервера Ignis и API-ключ.
|
||||||
|
2. При необходимости задать координаты дома.
|
||||||
|
3. Включить "выключать свет при уходе".
|
||||||
|
4. Выдать Android-разрешения на геолокацию, включая background location.
|
||||||
|
|
||||||
Если задать координаты дома, экран домов начнёт показывать расстояние до активного дома. Если дополнительно включить автовыключение при уходе и выдать Android фоновые разрешения на геолокацию и уведомления, приложение сможет в фоне выключать свет при удалении от текущего активного дома.
|
API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически.
|
||||||
|
|
||||||
Для добавления второго дома: кнопка "домик" в левом верхнем углу пульта -> экран домов -> кнопка "+".
|
## Ограничения
|
||||||
|
|
||||||
API-ключи хранятся отдельно от конфигурации домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически.
|
- целевая платформа сейчас Android;
|
||||||
|
- реальное поведение background execution, geofence delivery и OEM battery restrictions подтверждается в основном ручными проверками на устройстве;
|
||||||
## API
|
- force-stop приложения со стороны Android может ломать автоподъём фоновой логики до следующего ручного запуска.
|
||||||
|
|
||||||
Приложение работает с [Ignis Core API](https://git.akokos.ru/artem.kokos/ignis-core) -- self-hosted бэкенд на FastAPI (контракт OpenAPI 3.1.0).
|
|
||||||
Авторизация происходит через заголовок `X-API-Key`.
|
|
||||||
Доменный слой на стороне клиента полностью типизирован.
|
|
||||||
|
|
||||||
## Текущие ограничения
|
|
||||||
|
|
||||||
- Целевая платформа сейчас Android.
|
|
||||||
- Release APK пока подписывается debug-ключом из Flutter-шаблона.
|
|
||||||
- Build info в APK показывает дату сборки и короткий git hash текущего `HEAD`. Если сборка делается поверх незакоммиченного рабочего дерева, hash будет от последнего коммита, а не от локальных незакоммиченных изменений.
|
|
||||||
- Android-specific поведение реального background execution, уведомлений, runtime permissions и OEM battery restrictions пока подтверждается в основном ручными проверками на устройстве, а не automated integration-тестами.
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -46,4 +46,6 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
implementation("com.google.android.gms:play-services-location:21.3.0")
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.10.2")
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<application
|
<application
|
||||||
android:label="ignis_app"
|
android:label="ignis_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -35,9 +35,18 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<meta-data
|
<receiver
|
||||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
android:name=".GeofenceBroadcastReceiver"
|
||||||
android:value="ignis_geofence" />
|
android:exported="false" />
|
||||||
|
<receiver
|
||||||
|
android:name=".GeofenceRestoreReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package ru.akokos.ignis_app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import com.google.android.gms.location.Geofence
|
||||||
|
import com.google.android.gms.location.GeofencingRequest
|
||||||
|
import com.google.android.gms.location.LocationServices
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
data class StoredGeofenceConfig(
|
||||||
|
val homeId: String,
|
||||||
|
val baseUrl: String,
|
||||||
|
val apiKey: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val radiusMeters: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class GeofencePresenceState {
|
||||||
|
UNKNOWN,
|
||||||
|
INSIDE,
|
||||||
|
OUTSIDE,
|
||||||
|
TRIGGERED,
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeofenceNativeStore(context: Context) {
|
||||||
|
private val prefs = context.getSharedPreferences("ignis_geofence_native", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun loadConfig(): StoredGeofenceConfig? {
|
||||||
|
val raw = prefs.getString("config", null) ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val json = JSONObject(raw)
|
||||||
|
StoredGeofenceConfig(
|
||||||
|
homeId = json.getString("homeId"),
|
||||||
|
baseUrl = json.getString("baseUrl"),
|
||||||
|
apiKey = json.getString("apiKey"),
|
||||||
|
latitude = json.getDouble("latitude"),
|
||||||
|
longitude = json.getDouble("longitude"),
|
||||||
|
radiusMeters = json.getInt("radiusMeters"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveConfig(config: StoredGeofenceConfig) {
|
||||||
|
val json =
|
||||||
|
JSONObject()
|
||||||
|
.put("homeId", config.homeId)
|
||||||
|
.put("baseUrl", config.baseUrl)
|
||||||
|
.put("apiKey", config.apiKey)
|
||||||
|
.put("latitude", config.latitude)
|
||||||
|
.put("longitude", config.longitude)
|
||||||
|
.put("radiusMeters", config.radiusMeters)
|
||||||
|
prefs.edit().putString("config", json.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getState(): GeofencePresenceState =
|
||||||
|
runCatching {
|
||||||
|
GeofencePresenceState.valueOf(
|
||||||
|
prefs.getString("presence_state", GeofencePresenceState.UNKNOWN.name)
|
||||||
|
?: GeofencePresenceState.UNKNOWN.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.getOrDefault(GeofencePresenceState.UNKNOWN)
|
||||||
|
|
||||||
|
fun setState(state: GeofencePresenceState) {
|
||||||
|
prefs.edit().putString("presence_state", state.name).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object GeofenceAutomationManager {
|
||||||
|
const val channelName = "ignis/geofence_automation"
|
||||||
|
|
||||||
|
private const val geofenceRequestId = "ignis_active_home"
|
||||||
|
private const val exitWorkName = "ignis_geofence_exit_worker"
|
||||||
|
private const val exitWorkHomeIdKey = "homeId"
|
||||||
|
private const val exitConfirmationDelayMinutes = 2L
|
||||||
|
|
||||||
|
fun arm(
|
||||||
|
context: Context,
|
||||||
|
config: StoredGeofenceConfig,
|
||||||
|
onComplete: ((Boolean, String?) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val store = GeofenceNativeStore(context)
|
||||||
|
store.saveConfig(config)
|
||||||
|
store.setState(GeofencePresenceState.UNKNOWN)
|
||||||
|
|
||||||
|
if (!hasRequiredLocationPermission(context)) {
|
||||||
|
onComplete?.invoke(false, "missing_location_permission")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = LocationServices.getGeofencingClient(context)
|
||||||
|
val pendingIntent = buildPendingIntent(context)
|
||||||
|
|
||||||
|
client.removeGeofences(pendingIntent).addOnCompleteListener {
|
||||||
|
registerGeofence(context, client, pendingIntent, config, onComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disarm(context: Context, onComplete: (() -> Unit)? = null) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(exitWorkName)
|
||||||
|
GeofenceNativeStore(context).clear()
|
||||||
|
|
||||||
|
val client = LocationServices.getGeofencingClient(context)
|
||||||
|
client.removeGeofences(buildPendingIntent(context)).addOnCompleteListener {
|
||||||
|
onComplete?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreIfNeeded(context: Context) {
|
||||||
|
val config = GeofenceNativeStore(context).loadConfig() ?: return
|
||||||
|
arm(context, config, onComplete = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEnter(context: Context, requestId: String) {
|
||||||
|
if (requestId != geofenceRequestId) return
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(exitWorkName)
|
||||||
|
GeofenceNativeStore(context).setState(GeofencePresenceState.INSIDE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleExit(context: Context, requestId: String) {
|
||||||
|
if (requestId != geofenceRequestId) return
|
||||||
|
|
||||||
|
val store = GeofenceNativeStore(context)
|
||||||
|
if (store.getState() != GeofencePresenceState.INSIDE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store.setState(GeofencePresenceState.OUTSIDE)
|
||||||
|
scheduleExitWorker(context, store.loadConfig()?.homeId ?: return)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markTriggered(context: Context) {
|
||||||
|
GeofenceNativeStore(context).setState(GeofencePresenceState.TRIGGERED)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldProcessExitWork(context: Context, homeId: String): Boolean {
|
||||||
|
val store = GeofenceNativeStore(context)
|
||||||
|
val config = store.loadConfig() ?: return false
|
||||||
|
return config.homeId == homeId && store.getState() == GeofencePresenceState.OUTSIDE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleExitWorker(context: Context, homeId: String) {
|
||||||
|
val request =
|
||||||
|
OneTimeWorkRequestBuilder<GeofenceExitWorker>()
|
||||||
|
.setInputData(
|
||||||
|
androidx.work.workDataOf(
|
||||||
|
exitWorkHomeIdKey to homeId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setInitialDelay(exitConfirmationDelayMinutes, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
|
||||||
|
)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniqueWork(
|
||||||
|
exitWorkName,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun registerGeofence(
|
||||||
|
context: Context,
|
||||||
|
client: com.google.android.gms.location.GeofencingClient,
|
||||||
|
pendingIntent: PendingIntent,
|
||||||
|
config: StoredGeofenceConfig,
|
||||||
|
onComplete: ((Boolean, String?) -> Unit)?,
|
||||||
|
) {
|
||||||
|
if (!hasRequiredLocationPermission(context)) {
|
||||||
|
onComplete?.invoke(false, "missing_location_permission")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val geofence =
|
||||||
|
Geofence.Builder()
|
||||||
|
.setRequestId(geofenceRequestId)
|
||||||
|
.setCircularRegion(
|
||||||
|
config.latitude,
|
||||||
|
config.longitude,
|
||||||
|
config.radiusMeters.toFloat(),
|
||||||
|
)
|
||||||
|
.setExpirationDuration(Geofence.NEVER_EXPIRE)
|
||||||
|
.setNotificationResponsiveness((2 * 60 * 1000))
|
||||||
|
.setTransitionTypes(
|
||||||
|
Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request =
|
||||||
|
GeofencingRequest.Builder()
|
||||||
|
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
|
||||||
|
.addGeofence(geofence)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.addGeofences(request, pendingIntent)
|
||||||
|
.addOnSuccessListener { onComplete?.invoke(true, null) }
|
||||||
|
.addOnFailureListener { error ->
|
||||||
|
onComplete?.invoke(false, error.message ?: "failed_to_register_geofence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasRequiredLocationPermission(context: Context): Boolean {
|
||||||
|
val fineGranted =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
val backgroundGranted =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
return fineGranted && backgroundGranted
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPendingIntent(context: Context): PendingIntent {
|
||||||
|
val intent =
|
||||||
|
Intent(context, GeofenceBroadcastReceiver::class.java).apply {
|
||||||
|
action = "ru.akokos.ignis_app.GEOFENCE_TRANSITION"
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
1001,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.akokos.ignis_app
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import com.google.android.gms.location.Geofence
|
||||||
|
import com.google.android.gms.location.GeofencingEvent
|
||||||
|
|
||||||
|
class GeofenceBroadcastReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val event = GeofencingEvent.fromIntent(intent) ?: return
|
||||||
|
if (event.hasError()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val geofenceId = event.triggeringGeofences?.firstOrNull()?.requestId ?: return
|
||||||
|
when (event.geofenceTransition) {
|
||||||
|
Geofence.GEOFENCE_TRANSITION_ENTER ->
|
||||||
|
GeofenceAutomationManager.handleEnter(context, geofenceId)
|
||||||
|
Geofence.GEOFENCE_TRANSITION_EXIT ->
|
||||||
|
GeofenceAutomationManager.handleExit(context, geofenceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package ru.akokos.ignis_app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.json.JSONTokener
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class GeofenceExitWorker(
|
||||||
|
appContext: Context,
|
||||||
|
workerParams: WorkerParameters,
|
||||||
|
) : Worker(appContext, workerParams) {
|
||||||
|
override fun doWork(): Result {
|
||||||
|
val homeId = inputData.getString("homeId") ?: return Result.success()
|
||||||
|
if (!GeofenceAutomationManager.shouldProcessExitWork(applicationContext, homeId)) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = GeofenceNativeStore(applicationContext).loadConfig() ?: return Result.success()
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val groupIds = fetchGroupIds(config)
|
||||||
|
val activeGroupIds = groupIds.filter { isGroupOn(config, it) }
|
||||||
|
|
||||||
|
if (activeGroupIds.isNotEmpty()) {
|
||||||
|
activeGroupIds.forEach { turnOffGroup(config, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceAutomationManager.markTriggered(applicationContext)
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
.getOrElse { Result.retry() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchGroupIds(config: StoredGeofenceConfig): List<String> {
|
||||||
|
val payload = requestJson(config, "/devices/groups")
|
||||||
|
return when (payload) {
|
||||||
|
is JSONArray ->
|
||||||
|
buildList {
|
||||||
|
for (index in 0 until payload.length()) {
|
||||||
|
val item = payload.opt(index)
|
||||||
|
when (item) {
|
||||||
|
is JSONObject -> item.optString("id").takeIf { it.isNotBlank() }?.let(::add)
|
||||||
|
is String -> if (item.isNotBlank()) add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is JSONObject -> {
|
||||||
|
if (payload.has("groups") && payload.opt("groups") is JSONArray) {
|
||||||
|
fetchIdsFromArray(payload.optJSONArray("groups") ?: JSONArray())
|
||||||
|
} else {
|
||||||
|
payload.keys().asSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchIdsFromArray(array: JSONArray): List<String> =
|
||||||
|
buildList {
|
||||||
|
for (index in 0 until array.length()) {
|
||||||
|
val item = array.opt(index)
|
||||||
|
when (item) {
|
||||||
|
is JSONObject -> item.optString("id").takeIf { it.isNotBlank() }?.let(::add)
|
||||||
|
is String -> if (item.isNotBlank()) add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isGroupOn(config: StoredGeofenceConfig, groupId: String): Boolean {
|
||||||
|
val encodedId = URLEncoder.encode(groupId, Charsets.UTF_8.name())
|
||||||
|
val payload = requestJson(config, "/control/group/$encodedId/status")
|
||||||
|
return extractState(payload) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun turnOffGroup(config: StoredGeofenceConfig, groupId: String) {
|
||||||
|
val encodedId = URLEncoder.encode(groupId, Charsets.UTF_8.name())
|
||||||
|
performRequest(config, "/control/group/$encodedId?state=false", method = "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractState(payload: Any?): Boolean? =
|
||||||
|
when (payload) {
|
||||||
|
is JSONObject -> when {
|
||||||
|
payload.has("results") -> extractState(payload.optJSONArray("results"))
|
||||||
|
payload.has("status") -> extractState(payload.opt("status"))
|
||||||
|
payload.has("state") -> coerceBoolean(payload.opt("state"))
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
is JSONArray -> if (payload.length() > 0) extractState(payload.opt(0)) else null
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coerceBoolean(value: Any?): Boolean? =
|
||||||
|
when (value) {
|
||||||
|
is Boolean -> value
|
||||||
|
is Number -> value.toInt() != 0
|
||||||
|
is String -> when (value.lowercase()) {
|
||||||
|
"true", "1", "on" -> true
|
||||||
|
"false", "0", "off" -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestJson(config: StoredGeofenceConfig, path: String): Any? {
|
||||||
|
val body = performRequest(config, path, method = "GET")
|
||||||
|
return JSONTokener(body).nextValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performRequest(
|
||||||
|
config: StoredGeofenceConfig,
|
||||||
|
path: String,
|
||||||
|
method: String,
|
||||||
|
): String {
|
||||||
|
val connection =
|
||||||
|
(URL(config.baseUrl.trimEnd('/') + path).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = method
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 15_000
|
||||||
|
setRequestProperty("X-API-Key", config.apiKey)
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
doInput = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val statusCode = connection.responseCode
|
||||||
|
val stream =
|
||||||
|
if (statusCode in 200..299) {
|
||||||
|
connection.inputStream
|
||||||
|
} else {
|
||||||
|
connection.errorStream ?: connection.inputStream
|
||||||
|
}
|
||||||
|
val body =
|
||||||
|
BufferedReader(InputStreamReader(stream)).use { reader ->
|
||||||
|
buildString {
|
||||||
|
while (true) {
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode !in 200..299) {
|
||||||
|
throw IllegalStateException("HTTP $statusCode: $body")
|
||||||
|
}
|
||||||
|
|
||||||
|
body
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.akokos.ignis_app
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
class GeofenceRestoreReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
-> GeofenceAutomationManager.restoreIfNeeded(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,64 @@
|
|||||||
package ru.akokos.ignis_app
|
package ru.akokos.ignis_app
|
||||||
|
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
GeofenceAutomationManager.channelName,
|
||||||
|
).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"armGeofence" -> {
|
||||||
|
val homeId = call.argument<String>("homeId")
|
||||||
|
val baseUrl = call.argument<String>("baseUrl")
|
||||||
|
val apiKey = call.argument<String>("apiKey")
|
||||||
|
val latitude = call.argument<Double>("latitude")
|
||||||
|
val longitude = call.argument<Double>("longitude")
|
||||||
|
val radiusMeters = call.argument<Int>("radiusMeters")
|
||||||
|
|
||||||
|
if (
|
||||||
|
homeId.isNullOrBlank() ||
|
||||||
|
baseUrl.isNullOrBlank() ||
|
||||||
|
apiKey.isNullOrBlank() ||
|
||||||
|
latitude == null ||
|
||||||
|
longitude == null ||
|
||||||
|
radiusMeters == null
|
||||||
|
) {
|
||||||
|
result.error(
|
||||||
|
"invalid_args",
|
||||||
|
"armGeofence requires a complete home config",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
GeofenceAutomationManager.arm(
|
||||||
|
context = applicationContext,
|
||||||
|
config =
|
||||||
|
StoredGeofenceConfig(
|
||||||
|
homeId = homeId,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
apiKey = apiKey,
|
||||||
|
latitude = latitude,
|
||||||
|
longitude = longitude,
|
||||||
|
radiusMeters = radiusMeters,
|
||||||
|
),
|
||||||
|
) { armed, error ->
|
||||||
|
result.success(mapOf("armed" to armed, "error" to error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disarmGeofence" -> {
|
||||||
|
GeofenceAutomationManager.disarm(applicationContext) {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,13 +59,11 @@ 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), currentHome: home);
|
|
||||||
|
|
||||||
state = const AppBootstrapState.ready();
|
state = const AppBootstrapState.ready();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
|
|
||||||
const double geofenceThresholdMeters = 500.0;
|
|
||||||
const Duration geofenceRetryCooldown = Duration(minutes: 30);
|
|
||||||
|
|
||||||
bool hasForegroundLocationAccess(LocationPermission permission) {
|
|
||||||
return permission == LocationPermission.whileInUse ||
|
|
||||||
permission == LocationPermission.always;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasBackgroundLocationAccess(LocationPermission permission) {
|
|
||||||
return permission == LocationPermission.always;
|
|
||||||
}
|
|
||||||
|
|
||||||
Duration? geofenceRetryRemaining(DateTime? lastFailureAt, {DateTime? now}) {
|
|
||||||
if (lastFailureAt == null) return null;
|
|
||||||
|
|
||||||
final currentTime = now ?? DateTime.now();
|
|
||||||
final remaining = lastFailureAt
|
|
||||||
.add(geofenceRetryCooldown)
|
|
||||||
.difference(currentTime);
|
|
||||||
if (remaining <= Duration.zero) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
String formatGeofenceRetry(Duration remaining) {
|
|
||||||
if (remaining.inHours >= 1) {
|
|
||||||
final hours = remaining.inHours;
|
|
||||||
final minutes = remaining.inMinutes.remainder(60);
|
|
||||||
if (minutes == 0) return '$hours ч';
|
|
||||||
return '$hours ч $minutes мин';
|
|
||||||
}
|
|
||||||
|
|
||||||
final minutes = remaining.inMinutes;
|
|
||||||
if (minutes > 0) return '$minutes мин';
|
|
||||||
|
|
||||||
return '${remaining.inSeconds.clamp(1, 59)} сек';
|
|
||||||
}
|
|
||||||
|
|
||||||
String formatDistanceMeters(double meters) {
|
|
||||||
if (meters < 1000) {
|
|
||||||
return '${meters.round()} м';
|
|
||||||
}
|
|
||||||
return '${(meters / 1000).toStringAsFixed(1)} км';
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import 'package:workmanager/workmanager.dart';
|
|
||||||
|
|
||||||
import '../../models/home_config.dart';
|
|
||||||
import '../../services/geofence_worker.dart';
|
|
||||||
import 'services/geofence_runtime_store.dart';
|
|
||||||
|
|
||||||
/// Синхронизировать состояние фонового таска с настройками домов.
|
|
||||||
/// Вызывать при старте приложения и при изменении настроек.
|
|
||||||
///
|
|
||||||
/// Геофенс работает только для текущего активного дома.
|
|
||||||
/// Если активный дом не готов -- таск снимается и runtime разоружается.
|
|
||||||
Future<void> syncGeofenceTask(
|
|
||||||
List<HomeConfig> homes, {
|
|
||||||
required HomeConfig? currentHome,
|
|
||||||
}) async {
|
|
||||||
final runtimeStore = GeofenceRuntimeStore();
|
|
||||||
final activeHome = currentHome;
|
|
||||||
final needGeofence =
|
|
||||||
activeHome != null &&
|
|
||||||
homes.any((home) => home.id == activeHome.id) &&
|
|
||||||
activeHome.geofenceReady;
|
|
||||||
|
|
||||||
if (needGeofence) {
|
|
||||||
await runtimeStore.armForHome(activeHome.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Workmanager().registerPeriodicTask(
|
|
||||||
geofenceTaskUniqueName,
|
|
||||||
geofenceTaskName,
|
|
||||||
frequency: const Duration(minutes: 15),
|
|
||||||
constraints: Constraints(networkType: NetworkType.connected),
|
|
||||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
|
||||||
backoffPolicy: BackoffPolicy.linear,
|
|
||||||
backoffPolicyDelay: const Duration(minutes: 1),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
// В тестах и на неполной платформенной инициализации
|
|
||||||
// не даём workmanager уронить остальное приложение.
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await runtimeStore.disarm();
|
|
||||||
try {
|
|
||||||
await Workmanager().cancelByUniqueName(geofenceTaskUniqueName);
|
|
||||||
} catch (_) {
|
|
||||||
// См. комментарий выше: runtime должен синхронизироваться
|
|
||||||
// даже если platform plugin недоступен.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
|
|
||||||
import '../../../models/home_config.dart';
|
|
||||||
import '../geofence_logic.dart';
|
|
||||||
import 'geofence_runtime_state.dart';
|
|
||||||
|
|
||||||
enum GeofenceStatusKind {
|
|
||||||
noActiveHome,
|
|
||||||
disabled,
|
|
||||||
missingCoordinates,
|
|
||||||
locationServicesDisabled,
|
|
||||||
locationPermissionDenied,
|
|
||||||
backgroundPermissionDenied,
|
|
||||||
notificationsPermissionDenied,
|
|
||||||
cooldown,
|
|
||||||
triggered,
|
|
||||||
ready,
|
|
||||||
}
|
|
||||||
|
|
||||||
class GeofenceDiagnostics {
|
|
||||||
final HomeConfig? activeHome;
|
|
||||||
final GeofenceStatusKind status;
|
|
||||||
final GeofenceRuntimeState runtime;
|
|
||||||
final bool locationServicesEnabled;
|
|
||||||
final LocationPermission locationPermission;
|
|
||||||
final bool notificationsEnabled;
|
|
||||||
final Duration? retryRemaining;
|
|
||||||
final String? detail;
|
|
||||||
|
|
||||||
const GeofenceDiagnostics({
|
|
||||||
required this.activeHome,
|
|
||||||
required this.status,
|
|
||||||
required this.runtime,
|
|
||||||
required this.locationServicesEnabled,
|
|
||||||
required this.locationPermission,
|
|
||||||
required this.notificationsEnabled,
|
|
||||||
this.retryRemaining,
|
|
||||||
this.detail,
|
|
||||||
});
|
|
||||||
|
|
||||||
const GeofenceDiagnostics.initial()
|
|
||||||
: activeHome = null,
|
|
||||||
status = GeofenceStatusKind.noActiveHome,
|
|
||||||
runtime = const GeofenceRuntimeState(),
|
|
||||||
locationServicesEnabled = true,
|
|
||||||
locationPermission = LocationPermission.denied,
|
|
||||||
notificationsEnabled = true,
|
|
||||||
retryRemaining = null,
|
|
||||||
detail = null;
|
|
||||||
|
|
||||||
String get title {
|
|
||||||
return switch (status) {
|
|
||||||
GeofenceStatusKind.noActiveHome => 'Геофенс не активен',
|
|
||||||
GeofenceStatusKind.disabled => 'Автовыключение выключено',
|
|
||||||
GeofenceStatusKind.missingCoordinates => 'Нет координат дома',
|
|
||||||
GeofenceStatusKind.locationServicesDisabled => 'Геолокация выключена',
|
|
||||||
GeofenceStatusKind.locationPermissionDenied => 'Нет доступа к геолокации',
|
|
||||||
GeofenceStatusKind.backgroundPermissionDenied =>
|
|
||||||
'Нет фонового доступа к геолокации',
|
|
||||||
GeofenceStatusKind.notificationsPermissionDenied =>
|
|
||||||
'Нет доступа к уведомлениям',
|
|
||||||
GeofenceStatusKind.cooldown => 'Повтор отложен',
|
|
||||||
GeofenceStatusKind.triggered => 'Геофенс уже сработал',
|
|
||||||
GeofenceStatusKind.ready => 'Геофенс активен',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
String get message {
|
|
||||||
final homeName = activeHome?.name ?? 'активного дома';
|
|
||||||
return switch (status) {
|
|
||||||
GeofenceStatusKind.noActiveHome =>
|
|
||||||
'Сначала выберите активный дом. Без этого фоновой автоматике нечего отслеживать.',
|
|
||||||
GeofenceStatusKind.disabled =>
|
|
||||||
'Для дома "$homeName" автовыключение пока отключено.',
|
|
||||||
GeofenceStatusKind.missingCoordinates =>
|
|
||||||
'У дома "$homeName" не заданы координаты. Без них расстояние считать не из чего.',
|
|
||||||
GeofenceStatusKind.locationServicesDisabled =>
|
|
||||||
'На устройстве выключена геолокация. И карта расстояний, и фоновый geofence сейчас слепые как кроты.',
|
|
||||||
GeofenceStatusKind.locationPermissionDenied =>
|
|
||||||
'Приложению не дали доступ к геолокации. Разрешите доступ хотя бы во время использования.',
|
|
||||||
GeofenceStatusKind.backgroundPermissionDenied =>
|
|
||||||
'Для фонового geofence нужен доступ к локации "Всегда". Иначе в фоне Android эту магию задушит.',
|
|
||||||
GeofenceStatusKind.notificationsPermissionDenied =>
|
|
||||||
'Свет выключить мы ещё попробуем, но честно отчитаться пользователю не сможем, пока уведомления запрещены.',
|
|
||||||
GeofenceStatusKind.cooldown =>
|
|
||||||
'Последняя попытка выключить свет сорвалась. Повторим позже, чтобы не долбить backend без остановки.',
|
|
||||||
GeofenceStatusKind.triggered =>
|
|
||||||
'Свет уже был автоматически выключен. Повторно бахать не будем, пока вы не вернётесь в домашний радиус.',
|
|
||||||
GeofenceStatusKind.ready =>
|
|
||||||
'Фоновая автоматика готова следить за домом "$homeName" и выключить свет после ухода.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
String? get secondaryMessage {
|
|
||||||
if (status == GeofenceStatusKind.cooldown && retryRemaining != null) {
|
|
||||||
return 'Повтор через ${formatGeofenceRetry(retryRemaining!)}.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status == GeofenceStatusKind.triggered &&
|
|
||||||
runtime.lastSuccessAt != null &&
|
|
||||||
runtime.lastSuccessHomeId == activeHome?.id) {
|
|
||||||
return 'Последнее успешное срабатывание уже зафиксировано.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail != null && detail!.trim().isNotEmpty) {
|
|
||||||
return detail;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get canRequestLocation =>
|
|
||||||
status == GeofenceStatusKind.locationPermissionDenied;
|
|
||||||
|
|
||||||
bool get canRequestBackgroundLocation =>
|
|
||||||
status == GeofenceStatusKind.backgroundPermissionDenied;
|
|
||||||
|
|
||||||
bool get canOpenLocationSettings =>
|
|
||||||
status == GeofenceStatusKind.locationServicesDisabled;
|
|
||||||
|
|
||||||
bool get canRequestNotifications =>
|
|
||||||
status == GeofenceStatusKind.notificationsPermissionDenied;
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
class GeofenceRuntimeState {
|
|
||||||
final String? armedHomeId;
|
|
||||||
final DateTime? lastCheckAt;
|
|
||||||
final double? lastDistanceMeters;
|
|
||||||
final String? triggeredHomeId;
|
|
||||||
final DateTime? triggeredAt;
|
|
||||||
final String? lastSuccessHomeId;
|
|
||||||
final DateTime? lastSuccessAt;
|
|
||||||
final String? lastFailureHomeId;
|
|
||||||
final DateTime? lastFailureAt;
|
|
||||||
final String? lastFailureMessage;
|
|
||||||
|
|
||||||
const GeofenceRuntimeState({
|
|
||||||
this.armedHomeId,
|
|
||||||
this.lastCheckAt,
|
|
||||||
this.lastDistanceMeters,
|
|
||||||
this.triggeredHomeId,
|
|
||||||
this.triggeredAt,
|
|
||||||
this.lastSuccessHomeId,
|
|
||||||
this.lastSuccessAt,
|
|
||||||
this.lastFailureHomeId,
|
|
||||||
this.lastFailureAt,
|
|
||||||
this.lastFailureMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool isTriggeredFor(String homeId) =>
|
|
||||||
triggeredHomeId == homeId && triggeredAt != null;
|
|
||||||
|
|
||||||
DateTime? failureAtFor(String homeId) {
|
|
||||||
if (lastFailureHomeId != homeId) return null;
|
|
||||||
return lastFailureAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? failureMessageFor(String homeId) {
|
|
||||||
if (lastFailureHomeId != homeId) return null;
|
|
||||||
return lastFailureMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
GeofenceRuntimeState armForHome(String homeId) {
|
|
||||||
if (armedHomeId == homeId) return this;
|
|
||||||
|
|
||||||
return GeofenceRuntimeState(
|
|
||||||
armedHomeId: homeId,
|
|
||||||
lastSuccessHomeId: lastSuccessHomeId,
|
|
||||||
lastSuccessAt: lastSuccessAt,
|
|
||||||
lastFailureHomeId: lastFailureHomeId,
|
|
||||||
lastFailureAt: lastFailureAt,
|
|
||||||
lastFailureMessage: lastFailureMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GeofenceRuntimeState disarm() {
|
|
||||||
return GeofenceRuntimeState(
|
|
||||||
lastSuccessHomeId: lastSuccessHomeId,
|
|
||||||
lastSuccessAt: lastSuccessAt,
|
|
||||||
lastFailureHomeId: lastFailureHomeId,
|
|
||||||
lastFailureAt: lastFailureAt,
|
|
||||||
lastFailureMessage: lastFailureMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GeofenceRuntimeState recordInsideHome(
|
|
||||||
String homeId, {
|
|
||||||
required DateTime checkedAt,
|
|
||||||
required double distanceMeters,
|
|
||||||
}) {
|
|
||||||
return GeofenceRuntimeState(
|
|
||||||
armedHomeId: armedHomeId,
|
|
||||||
lastCheckAt: checkedAt,
|
|
||||||
lastDistanceMeters: distanceMeters,
|
|
||||||
lastSuccessHomeId: lastSuccessHomeId,
|
|
||||||
lastSuccessAt: lastSuccessAt,
|
|
||||||
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
|
||||||
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
|
||||||
lastFailureMessage: lastFailureHomeId == homeId
|
|
||||||
? null
|
|
||||||
: lastFailureMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GeofenceRuntimeState recordOutsideCheck(
|
|
||||||
String homeId, {
|
|
||||||
required DateTime checkedAt,
|
|
||||||
required double distanceMeters,
|
|
||||||
}) {
|
|
||||||
return GeofenceRuntimeState(
|
|
||||||
armedHomeId: armedHomeId ?? homeId,
|
|
||||||
lastCheckAt: checkedAt,
|
|
||||||
lastDistanceMeters: distanceMeters,
|
|
||||||
triggeredHomeId: triggeredHomeId,
|
|
||||||
triggeredAt: triggeredAt,
|
|
||||||
lastSuccessHomeId: lastSuccessHomeId,
|
|
||||||
lastSuccessAt: lastSuccessAt,
|
|
||||||
lastFailureHomeId: lastFailureHomeId,
|
|
||||||
lastFailureAt: lastFailureAt,
|
|
||||||
lastFailureMessage: lastFailureMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GeofenceRuntimeState recordSuccess(
|
|
||||||
String homeId, {
|
|
||||||
required DateTime triggeredAt,
|
|
||||||
required double distanceMeters,
|
|
||||||
}) {
|
|
||||||
return GeofenceRuntimeState(
|
|
||||||
armedHomeId: homeId,
|
|
||||||
lastCheckAt: triggeredAt,
|
|
||||||
lastDistanceMeters: distanceMeters,
|
|
||||||
triggeredHomeId: homeId,
|
|
||||||
triggeredAt: triggeredAt,
|
|
||||||
lastSuccessHomeId: homeId,
|
|
||||||
lastSuccessAt: triggeredAt,
|
|
||||||
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
|
||||||
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
|
||||||
lastFailureMessage: lastFailureHomeId == homeId
|
|
||||||
? null
|
|
||||||
: lastFailureMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GeofenceRuntimeState recordFailure(
|
|
||||||
String homeId, {
|
|
||||||
required DateTime failedAt,
|
|
||||||
required double distanceMeters,
|
|
||||||
required String message,
|
|
||||||
}) {
|
|
||||||
return GeofenceRuntimeState(
|
|
||||||
armedHomeId: homeId,
|
|
||||||
lastCheckAt: failedAt,
|
|
||||||
lastDistanceMeters: distanceMeters,
|
|
||||||
triggeredHomeId: triggeredHomeId == homeId ? null : triggeredHomeId,
|
|
||||||
triggeredAt: triggeredHomeId == homeId ? null : triggeredAt,
|
|
||||||
lastSuccessHomeId: lastSuccessHomeId,
|
|
||||||
lastSuccessAt: lastSuccessAt,
|
|
||||||
lastFailureHomeId: homeId,
|
|
||||||
lastFailureAt: failedAt,
|
|
||||||
lastFailureMessage: message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GeofenceRuntimeState removeHome(String homeId) {
|
|
||||||
return GeofenceRuntimeState(
|
|
||||||
armedHomeId: armedHomeId == homeId ? null : armedHomeId,
|
|
||||||
lastCheckAt: armedHomeId == homeId ? null : lastCheckAt,
|
|
||||||
lastDistanceMeters: armedHomeId == homeId ? null : lastDistanceMeters,
|
|
||||||
triggeredHomeId: triggeredHomeId == homeId ? null : triggeredHomeId,
|
|
||||||
triggeredAt: triggeredHomeId == homeId ? null : triggeredAt,
|
|
||||||
lastSuccessHomeId: lastSuccessHomeId == homeId ? null : lastSuccessHomeId,
|
|
||||||
lastSuccessAt: lastSuccessHomeId == homeId ? null : lastSuccessAt,
|
|
||||||
lastFailureHomeId: lastFailureHomeId == homeId ? null : lastFailureHomeId,
|
|
||||||
lastFailureAt: lastFailureHomeId == homeId ? null : lastFailureAt,
|
|
||||||
lastFailureMessage: lastFailureHomeId == homeId
|
|
||||||
? null
|
|
||||||
: lastFailureMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
if (armedHomeId != null) 'armedHomeId': armedHomeId,
|
|
||||||
if (lastCheckAt != null) 'lastCheckAt': lastCheckAt!.millisecondsSinceEpoch,
|
|
||||||
if (lastDistanceMeters != null) 'lastDistanceMeters': lastDistanceMeters,
|
|
||||||
if (triggeredHomeId != null) 'triggeredHomeId': triggeredHomeId,
|
|
||||||
if (triggeredAt != null) 'triggeredAt': triggeredAt!.millisecondsSinceEpoch,
|
|
||||||
if (lastSuccessHomeId != null) 'lastSuccessHomeId': lastSuccessHomeId,
|
|
||||||
if (lastSuccessAt != null)
|
|
||||||
'lastSuccessAt': lastSuccessAt!.millisecondsSinceEpoch,
|
|
||||||
if (lastFailureHomeId != null) 'lastFailureHomeId': lastFailureHomeId,
|
|
||||||
if (lastFailureAt != null)
|
|
||||||
'lastFailureAt': lastFailureAt!.millisecondsSinceEpoch,
|
|
||||||
if (lastFailureMessage != null) 'lastFailureMessage': lastFailureMessage,
|
|
||||||
};
|
|
||||||
|
|
||||||
factory GeofenceRuntimeState.fromJson(Map<String, dynamic> json) {
|
|
||||||
return GeofenceRuntimeState(
|
|
||||||
armedHomeId: json['armedHomeId'] as String?,
|
|
||||||
lastCheckAt: _fromMillis(json['lastCheckAt']),
|
|
||||||
lastDistanceMeters: (json['lastDistanceMeters'] as num?)?.toDouble(),
|
|
||||||
triggeredHomeId: json['triggeredHomeId'] as String?,
|
|
||||||
triggeredAt: _fromMillis(json['triggeredAt']),
|
|
||||||
lastSuccessHomeId: json['lastSuccessHomeId'] as String?,
|
|
||||||
lastSuccessAt: _fromMillis(json['lastSuccessAt']),
|
|
||||||
lastFailureHomeId: json['lastFailureHomeId'] as String?,
|
|
||||||
lastFailureAt: _fromMillis(json['lastFailureAt']),
|
|
||||||
lastFailureMessage: json['lastFailureMessage'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static DateTime? _fromMillis(Object? value) {
|
|
||||||
final millis = (value as num?)?.toInt();
|
|
||||||
if (millis == null) return null;
|
|
||||||
return DateTime.fromMillisecondsSinceEpoch(millis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
|
|
||||||
import '../../../app/load_state.dart';
|
|
||||||
import '../../../models/home_config.dart';
|
|
||||||
import '../geofence_logic.dart';
|
|
||||||
import '../models/geofence_diagnostics.dart';
|
|
||||||
import '../models/geofence_runtime_state.dart';
|
|
||||||
import '../services/geofence_notifications_service.dart';
|
|
||||||
import '../services/geofence_runtime_store.dart';
|
|
||||||
import 'homes_providers.dart';
|
|
||||||
|
|
||||||
final geofenceRuntimeStoreProvider = Provider((ref) => GeofenceRuntimeStore());
|
|
||||||
|
|
||||||
final geofenceNotificationsServiceProvider = Provider(
|
|
||||||
(ref) => GeofenceNotificationsService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final geofenceDiagnosticsProvider =
|
|
||||||
NotifierProvider<
|
|
||||||
GeofenceDiagnosticsNotifier,
|
|
||||||
LoadState<GeofenceDiagnostics>
|
|
||||||
>(GeofenceDiagnosticsNotifier.new);
|
|
||||||
|
|
||||||
class GeofenceDiagnosticsNotifier
|
|
||||||
extends Notifier<LoadState<GeofenceDiagnostics>> {
|
|
||||||
bool _refreshing = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
LoadState<GeofenceDiagnostics> build() {
|
|
||||||
return const LoadState.idle(GeofenceDiagnostics.initial());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> refresh() async {
|
|
||||||
if (_refreshing) return;
|
|
||||||
_refreshing = true;
|
|
||||||
|
|
||||||
final previous = state.data;
|
|
||||||
state = LoadState.loading(previous);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final currentHome = ref.read(currentHomeProvider);
|
|
||||||
final runtime = await ref.read(geofenceRuntimeStoreProvider).load();
|
|
||||||
|
|
||||||
if (currentHome == null) {
|
|
||||||
state = LoadState.data(
|
|
||||||
GeofenceDiagnostics(
|
|
||||||
activeHome: null,
|
|
||||||
status: GeofenceStatusKind.noActiveHome,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: LocationPermission.denied,
|
|
||||||
notificationsEnabled: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentHome.geofenceEnabled) {
|
|
||||||
state = LoadState.data(
|
|
||||||
GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.disabled,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: LocationPermission.denied,
|
|
||||||
notificationsEnabled: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentHome.hasCoordinates) {
|
|
||||||
state = LoadState.data(
|
|
||||||
GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.missingCoordinates,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: LocationPermission.denied,
|
|
||||||
notificationsEnabled: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final locationServicesEnabled =
|
|
||||||
await Geolocator.isLocationServiceEnabled();
|
|
||||||
final locationPermission = await Geolocator.checkPermission();
|
|
||||||
final notificationsEnabled = await ref
|
|
||||||
.read(geofenceNotificationsServiceProvider)
|
|
||||||
.areNotificationsEnabled();
|
|
||||||
|
|
||||||
final diagnostics = _buildDiagnostics(
|
|
||||||
currentHome: currentHome,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: locationServicesEnabled,
|
|
||||||
locationPermission: locationPermission,
|
|
||||||
notificationsEnabled: notificationsEnabled,
|
|
||||||
);
|
|
||||||
state = LoadState.data(diagnostics);
|
|
||||||
} catch (error) {
|
|
||||||
state = LoadState.error(
|
|
||||||
previous,
|
|
||||||
'Не удалось обновить состояние geofence: $error',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
_refreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> requestLocationPermission() async {
|
|
||||||
await Geolocator.requestPermission();
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> requestBackgroundLocationPermission() async {
|
|
||||||
final result = await Geolocator.requestPermission();
|
|
||||||
if (!hasBackgroundLocationAccess(result)) {
|
|
||||||
await Geolocator.openAppSettings();
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> requestNotificationPermission() async {
|
|
||||||
await ref
|
|
||||||
.read(geofenceNotificationsServiceProvider)
|
|
||||||
.requestNotificationsPermission();
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> openAppSettings() async {
|
|
||||||
await Geolocator.openAppSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> openLocationSettings() async {
|
|
||||||
await Geolocator.openLocationSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
GeofenceDiagnostics _buildDiagnostics({
|
|
||||||
required HomeConfig currentHome,
|
|
||||||
required GeofenceRuntimeState runtime,
|
|
||||||
required bool locationServicesEnabled,
|
|
||||||
required LocationPermission locationPermission,
|
|
||||||
required bool notificationsEnabled,
|
|
||||||
}) {
|
|
||||||
if (!locationServicesEnabled) {
|
|
||||||
return GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.locationServicesDisabled,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: false,
|
|
||||||
locationPermission: locationPermission,
|
|
||||||
notificationsEnabled: notificationsEnabled,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasForegroundLocationAccess(locationPermission)) {
|
|
||||||
return GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.locationPermissionDenied,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: locationPermission,
|
|
||||||
notificationsEnabled: notificationsEnabled,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasBackgroundLocationAccess(locationPermission)) {
|
|
||||||
return GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.backgroundPermissionDenied,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: locationPermission,
|
|
||||||
notificationsEnabled: notificationsEnabled,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!notificationsEnabled) {
|
|
||||||
return GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.notificationsPermissionDenied,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: locationPermission,
|
|
||||||
notificationsEnabled: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final failureAt = runtime.failureAtFor(currentHome.id);
|
|
||||||
final retryRemaining = geofenceRetryRemaining(failureAt);
|
|
||||||
if (retryRemaining != null) {
|
|
||||||
return GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.cooldown,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: locationPermission,
|
|
||||||
notificationsEnabled: true,
|
|
||||||
retryRemaining: retryRemaining,
|
|
||||||
detail: runtime.failureMessageFor(currentHome.id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtime.isTriggeredFor(currentHome.id)) {
|
|
||||||
return GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.triggered,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: locationPermission,
|
|
||||||
notificationsEnabled: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return GeofenceDiagnostics(
|
|
||||||
activeHome: currentHome,
|
|
||||||
status: GeofenceStatusKind.ready,
|
|
||||||
runtime: runtime,
|
|
||||||
locationServicesEnabled: true,
|
|
||||||
locationPermission: locationPermission,
|
|
||||||
notificationsEnabled: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import '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';
|
||||||
|
|
||||||
@@ -22,6 +20,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
|||||||
if (state != null) {
|
if (state != null) {
|
||||||
await _initApi(state!);
|
await _initApi(state!);
|
||||||
}
|
}
|
||||||
|
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Переключиться на другой дом
|
/// Переключиться на другой дом
|
||||||
@@ -30,6 +29,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
|||||||
await svc.setCurrentHomeId(home.id);
|
await svc.setCurrentHomeId(home.id);
|
||||||
state = home;
|
state = home;
|
||||||
await _initApi(home);
|
await _initApi(home);
|
||||||
|
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выбрать дом как активный и сразу проверить auth-state.
|
/// Выбрать дом как активный и сразу проверить auth-state.
|
||||||
@@ -41,11 +41,9 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +51,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);
|
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Инициализировать API-клиент текущим домом
|
/// Инициализировать API-клиент текущим домом
|
||||||
@@ -74,6 +72,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
|||||||
await svc.setCurrentHomeId(home.id);
|
await svc.setCurrentHomeId(home.id);
|
||||||
state = home;
|
state = home;
|
||||||
await _initApi(home);
|
await _initApi(home);
|
||||||
|
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +95,6 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
|
|||||||
|
|
||||||
Future<void> remove(String id) async {
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
lib/features/homes/services/geofence_automation_service.dart
Normal file
40
lib/features/homes/services/geofence_automation_service.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../../models/home_config.dart';
|
||||||
|
import '../../../services/settings_service.dart';
|
||||||
|
|
||||||
|
class GeofenceAutomationService {
|
||||||
|
GeofenceAutomationService({SettingsService? settingsService})
|
||||||
|
: _settingsService = settingsService ?? SettingsService();
|
||||||
|
|
||||||
|
static const _channel = MethodChannel('ignis/geofence_automation');
|
||||||
|
|
||||||
|
final SettingsService _settingsService;
|
||||||
|
|
||||||
|
Future<void> syncActiveHome(HomeConfig? home) async {
|
||||||
|
if (home == null || !home.geofenceReady) {
|
||||||
|
await _invoke('disarmGeofence');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final apiKey = await _settingsService.requireHomeApiKey(home.id);
|
||||||
|
await _invoke('armGeofence', {
|
||||||
|
'homeId': home.id,
|
||||||
|
'baseUrl': home.url,
|
||||||
|
'apiKey': apiKey,
|
||||||
|
'latitude': home.latitude,
|
||||||
|
'longitude': home.longitude,
|
||||||
|
'radiusMeters': home.geofenceRadiusMeters,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _invoke(String method, [Map<String, Object?>? arguments]) async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>(method, arguments);
|
||||||
|
} on MissingPluginException {
|
||||||
|
// В тестах и на не-Android средах platform channel может отсутствовать.
|
||||||
|
} on PlatformException {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
||||||
|
|
||||||
class GeofenceNotificationsService {
|
|
||||||
final FlutterLocalNotificationsPlugin _plugin;
|
|
||||||
|
|
||||||
GeofenceNotificationsService({FlutterLocalNotificationsPlugin? plugin})
|
|
||||||
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
|
||||||
|
|
||||||
Future<void> initialize() async {
|
|
||||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = InitializationSettings(
|
|
||||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
|
||||||
);
|
|
||||||
await _plugin.initialize(settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> areNotificationsEnabled() async {
|
|
||||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final android = _plugin
|
|
||||||
.resolvePlatformSpecificImplementation<
|
|
||||||
AndroidFlutterLocalNotificationsPlugin
|
|
||||||
>();
|
|
||||||
return await android?.areNotificationsEnabled() ?? true;
|
|
||||||
} catch (_) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> requestNotificationsPermission() async {
|
|
||||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final android = _plugin
|
|
||||||
.resolvePlatformSpecificImplementation<
|
|
||||||
AndroidFlutterLocalNotificationsPlugin
|
|
||||||
>();
|
|
||||||
final granted = await android?.requestNotificationsPermission();
|
|
||||||
return granted ?? await areNotificationsEnabled();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../models/geofence_runtime_state.dart';
|
|
||||||
|
|
||||||
class GeofenceRuntimeStore {
|
|
||||||
static const String _runtimeKey = 'ignis_geofence_runtime';
|
|
||||||
|
|
||||||
Future<GeofenceRuntimeState> load() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final raw = prefs.getString(_runtimeKey);
|
|
||||||
if (raw == null || raw.isEmpty) {
|
|
||||||
return const GeofenceRuntimeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(raw);
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
return const GeofenceRuntimeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
return GeofenceRuntimeState.fromJson(decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> save(GeofenceRuntimeState state) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final data = state.toJson();
|
|
||||||
if (data.isEmpty) {
|
|
||||||
await prefs.remove(_runtimeKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prefs.setString(_runtimeKey, jsonEncode(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<GeofenceRuntimeState> armForHome(String homeId) async {
|
|
||||||
final next = (await load()).armForHome(homeId);
|
|
||||||
await save(next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<GeofenceRuntimeState> disarm() async {
|
|
||||||
final next = (await load()).disarm();
|
|
||||||
await save(next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<GeofenceRuntimeState> removeHome(String homeId) async {
|
|
||||||
final next = (await load()).removeHome(homeId);
|
|
||||||
await save(next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,8 @@ import '../../../services/api_client.dart';
|
|||||||
import '../../homes/providers/homes_providers.dart';
|
import '../../homes/providers/homes_providers.dart';
|
||||||
import '../../shared/providers/core_providers.dart';
|
import '../../shared/providers/core_providers.dart';
|
||||||
|
|
||||||
|
final remotePollingEnabledProvider = Provider<bool>((ref) => true);
|
||||||
|
|
||||||
final groupsProvider = NotifierProvider<GroupsNotifier, List<IgnisGroup>>(
|
final groupsProvider = NotifierProvider<GroupsNotifier, List<IgnisGroup>>(
|
||||||
() => GroupsNotifier(),
|
() => GroupsNotifier(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../homes/services/geofence_automation_service.dart';
|
||||||
import '../../../services/api_client.dart';
|
import '../../../services/api_client.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
|
|
||||||
@@ -8,3 +9,9 @@ final settingsServiceProvider = Provider((ref) => SettingsService());
|
|||||||
|
|
||||||
/// API-клиент текущего дома. Конфигурация меняется через init().
|
/// API-клиент текущего дома. Конфигурация меняется через init().
|
||||||
final apiProvider = Provider((ref) => IgnisApi());
|
final apiProvider = Provider((ref) => IgnisApi());
|
||||||
|
|
||||||
|
/// Нативная geofence-автоматика Android.
|
||||||
|
final geofenceAutomationServiceProvider = Provider(
|
||||||
|
(ref) =>
|
||||||
|
GeofenceAutomationService(settingsService: ref.read(settingsServiceProvider)),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,34 +1,11 @@
|
|||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.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';
|
|
||||||
|
|
||||||
/// Top-level callback для workmanager (выполняется в отдельном изоляте).
|
|
||||||
@pragma('vm:entry-point')
|
|
||||||
void callbackDispatcher() {
|
|
||||||
DartPluginRegistrant.ensureInitialized();
|
|
||||||
|
|
||||||
Workmanager().executeTask((taskName, inputData) async {
|
|
||||||
if (taskName == geofenceTaskName) {
|
|
||||||
return await executeGeofenceCheck();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await GeofenceNotificationsService().initialize();
|
|
||||||
|
|
||||||
// Инициализация workmanager
|
|
||||||
Workmanager().initialize(callbackDispatcher);
|
|
||||||
|
|
||||||
runApp(const ProviderScope(child: IgnisApp()));
|
runApp(const ProviderScope(child: IgnisApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/// Модель "дома" -- один физический сервер Ignis.
|
/// Модель "дома" -- один физический сервер Ignis.
|
||||||
/// Содержит только несекретные настройки. API-ключ хранится отдельно.
|
/// Содержит только несекретные настройки. API-ключ хранится отдельно.
|
||||||
class HomeConfig {
|
class HomeConfig {
|
||||||
|
static const int defaultGeofenceRadiusMeters = 500;
|
||||||
|
|
||||||
final String id; // уникальный идентификатор (uuid или timestamp)
|
final String id; // уникальный идентификатор (uuid или timestamp)
|
||||||
final String name; // человекочитаемое название ("Квартира", "Дача")
|
final String name; // человекочитаемое название ("Квартира", "Дача")
|
||||||
final String url; // адрес сервера (например ignis.akokos.ru)
|
final String url; // адрес сервера (например ignis.akokos.ru)
|
||||||
final double? latitude; // GPS-широта дома (для гео-автоматизации)
|
final double? latitude; // GPS-широта дома (для гео-автоматизации)
|
||||||
final double? longitude; // GPS-долгота дома (для гео-автоматизации)
|
final double? longitude; // GPS-долгота дома (для гео-автоматизации)
|
||||||
final bool geofenceEnabled; // автовыключение при уходе из дома
|
final bool geofenceEnabled; // автовыключение при уходе из дома
|
||||||
|
final int geofenceRadiusMeters; // радиус geofence для автодействий
|
||||||
|
|
||||||
HomeConfig({
|
HomeConfig({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -15,6 +18,7 @@ class HomeConfig {
|
|||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
this.geofenceEnabled = false,
|
this.geofenceEnabled = false,
|
||||||
|
this.geofenceRadiusMeters = defaultGeofenceRadiusMeters,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Есть ли координаты у дома
|
/// Есть ли координаты у дома
|
||||||
@@ -31,6 +35,7 @@ class HomeConfig {
|
|||||||
if (latitude != null) 'latitude': latitude,
|
if (latitude != null) 'latitude': latitude,
|
||||||
if (longitude != null) 'longitude': longitude,
|
if (longitude != null) 'longitude': longitude,
|
||||||
'geofenceEnabled': geofenceEnabled,
|
'geofenceEnabled': geofenceEnabled,
|
||||||
|
'geofenceRadiusMeters': geofenceRadiusMeters,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
|
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
|
||||||
@@ -40,6 +45,10 @@ class HomeConfig {
|
|||||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||||
geofenceEnabled: json['geofenceEnabled'] as bool? ?? false,
|
geofenceEnabled: json['geofenceEnabled'] as bool? ?? false,
|
||||||
|
geofenceRadiusMeters:
|
||||||
|
((json['geofenceRadiusMeters'] as num?)?.toInt() ??
|
||||||
|
defaultGeofenceRadiusMeters)
|
||||||
|
.clamp(100, 5000),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Копирование с изменениями
|
/// Копирование с изменениями
|
||||||
@@ -49,6 +58,7 @@ class HomeConfig {
|
|||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
bool? geofenceEnabled,
|
bool? geofenceEnabled,
|
||||||
|
int? geofenceRadiusMeters,
|
||||||
bool clearCoordinates = false,
|
bool clearCoordinates = false,
|
||||||
}) => HomeConfig(
|
}) => HomeConfig(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -60,5 +70,6 @@ class HomeConfig {
|
|||||||
geofenceEnabled: clearCoordinates
|
geofenceEnabled: clearCoordinates
|
||||||
? false
|
? false
|
||||||
: (geofenceEnabled ?? this.geofenceEnabled),
|
: (geofenceEnabled ?? this.geofenceEnabled),
|
||||||
|
geofenceRadiusMeters: geofenceRadiusMeters ?? this.geofenceRadiusMeters,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
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/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';
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
final _keyCtrl = TextEditingController();
|
final _keyCtrl = TextEditingController();
|
||||||
final _latCtrl = TextEditingController();
|
final _latCtrl = TextEditingController();
|
||||||
final _lonCtrl = TextEditingController();
|
final _lonCtrl = TextEditingController();
|
||||||
|
final _radiusCtrl = TextEditingController();
|
||||||
bool _geofenceEnabled = false;
|
bool _geofenceEnabled = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
@@ -43,8 +44,11 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
if (widget.home!.longitude != null) {
|
if (widget.home!.longitude != null) {
|
||||||
_lonCtrl.text = widget.home!.longitude.toString();
|
_lonCtrl.text = widget.home!.longitude.toString();
|
||||||
}
|
}
|
||||||
|
_radiusCtrl.text = widget.home!.geofenceRadiusMeters.toString();
|
||||||
_geofenceEnabled = widget.home!.geofenceEnabled;
|
_geofenceEnabled = widget.home!.geofenceEnabled;
|
||||||
_loadApiKey();
|
_loadApiKey();
|
||||||
|
} else {
|
||||||
|
_radiusCtrl.text = HomeConfig.defaultGeofenceRadiusMeters.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Следим за полями координат чтобы обновлять доступность Switch
|
// Следим за полями координат чтобы обновлять доступность Switch
|
||||||
@@ -79,6 +83,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
_keyCtrl.dispose();
|
_keyCtrl.dispose();
|
||||||
_latCtrl.dispose();
|
_latCtrl.dispose();
|
||||||
_lonCtrl.dispose();
|
_lonCtrl.dispose();
|
||||||
|
_radiusCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +229,34 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _radiusCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Радиус geofence, м',
|
||||||
|
hintText: '500',
|
||||||
|
helperText: 'Автовыключение сработает после выхода за этот радиус',
|
||||||
|
prefixIcon: Icon(Icons.radar),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (value) {
|
||||||
|
final normalized = value?.trim() ?? '';
|
||||||
|
final radius = int.tryParse(normalized);
|
||||||
|
if (radius == null) {
|
||||||
|
return 'Введите радиус в метрах';
|
||||||
|
}
|
||||||
|
if (radius < 100 || radius > 5000) {
|
||||||
|
return 'От 100 до 5000 м';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: const Text('Выключать свет при уходе'),
|
title: const Text('Выключать свет при уходе'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
_hasCoordinates
|
_hasCoordinates
|
||||||
? 'Автовыключение при удалении на 500 м'
|
? 'Автовыключение после выхода за радиус geofence'
|
||||||
: 'Задайте координаты для активации',
|
: 'Задайте координаты для активации',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -253,9 +280,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(left: 40, bottom: 4),
|
padding: EdgeInsets.only(left: 40, bottom: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Проверка раз в ~15 мин (ограничение Android).\n'
|
|
||||||
'Работает только для текущего активного дома.\n'
|
'Работает только для текущего активного дома.\n'
|
||||||
'Нужны фоновые разрешения на геолокацию и уведомления.',
|
'Использует системный Android geofence, а не polling.\n'
|
||||||
|
'Нужны фоновые разрешения на геолокацию.',
|
||||||
style: TextStyle(fontSize: 11, color: Colors.white24),
|
style: TextStyle(fontSize: 11, color: Colors.white24),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -300,8 +327,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
final key = _keyCtrl.text.trim();
|
final key = _keyCtrl.text.trim();
|
||||||
final latText = _latCtrl.text.trim();
|
final latText = _latCtrl.text.trim();
|
||||||
final lonText = _lonCtrl.text.trim();
|
final lonText = _lonCtrl.text.trim();
|
||||||
|
final radiusText = _radiusCtrl.text.trim();
|
||||||
|
|
||||||
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty) {
|
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty || radiusText.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Заполните все обязательные поля')),
|
const SnackBar(content: Text('Заполните все обязательные поля')),
|
||||||
);
|
);
|
||||||
@@ -348,6 +376,14 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final radiusMeters = int.tryParse(radiusText);
|
||||||
|
if (radiusMeters == null || radiusMeters < 100 || radiusMeters > 5000) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Радиус geofence должен быть от 100 до 5000 м')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
|
|
||||||
final clearCoords = latText.isEmpty && lonText.isEmpty;
|
final clearCoords = latText.isEmpty && lonText.isEmpty;
|
||||||
@@ -359,6 +395,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
geofenceEnabled: clearCoords ? false : _geofenceEnabled,
|
geofenceEnabled: clearCoords ? false : _geofenceEnabled,
|
||||||
|
geofenceRadiusMeters: radiusMeters,
|
||||||
clearCoordinates: clearCoords,
|
clearCoordinates: clearCoords,
|
||||||
)
|
)
|
||||||
: HomeConfig(
|
: HomeConfig(
|
||||||
@@ -368,6 +405,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
geofenceEnabled: _geofenceEnabled,
|
geofenceEnabled: _geofenceEnabled,
|
||||||
|
geofenceRadiusMeters: radiusMeters,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -384,13 +422,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
await ref.read(currentHomeProvider.notifier).select(home);
|
await ref.read(currentHomeProvider.notifier).select(home);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Синхронизировать фоновый таск с новыми настройками
|
|
||||||
final allHomes = ref.read(homesProvider);
|
|
||||||
await syncGeofenceTask(
|
|
||||||
allHomes,
|
|
||||||
currentHome: ref.read(currentHomeProvider),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
@@ -21,6 +20,7 @@ class HomesScreen extends ConsumerStatefulWidget {
|
|||||||
class _HomesScreenState extends ConsumerState<HomesScreen>
|
class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
late final UserLocationNotifier _userLocationNotifier;
|
late final UserLocationNotifier _userLocationNotifier;
|
||||||
|
bool _isWatchingLocation = false;
|
||||||
String? _switchingHomeId;
|
String? _switchingHomeId;
|
||||||
String? _deletingHomeId;
|
String? _deletingHomeId;
|
||||||
|
|
||||||
@@ -30,15 +30,17 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
|||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_userLocationNotifier = ref.read(userLocationProvider.notifier);
|
_userLocationNotifier = ref.read(userLocationProvider.notifier);
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await _userLocationNotifier.startWatching();
|
await _syncLocationWatching();
|
||||||
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
|
await _syncGeofenceAutomation();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
if (_isWatchingLocation) {
|
||||||
_userLocationNotifier.stopWatching();
|
_userLocationNotifier.stopWatching();
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +56,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
|||||||
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(
|
||||||
@@ -69,24 +67,12 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: homes.isEmpty
|
child: homes.isEmpty
|
||||||
? const _EmptyHomesView()
|
? const _EmptyHomesView()
|
||||||
: ListView(
|
: RefreshIndicator(
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
onRefresh: _refreshEnvironmentState,
|
||||||
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
_HomesOverviewCard(
|
|
||||||
location: location,
|
|
||||||
diagnostics: geofenceState.data,
|
|
||||||
activeHome: currentHome,
|
|
||||||
activeDistanceKm: activeDistanceKm,
|
|
||||||
onRefresh: _refreshEnvironmentState,
|
|
||||||
onRequestLocationPermission: _requestLocationPermission,
|
|
||||||
onRequestBackgroundPermission:
|
|
||||||
_requestGeofenceBackgroundPermission,
|
|
||||||
onRequestNotificationsPermission:
|
|
||||||
_requestGeofenceNotificationPermission,
|
|
||||||
onOpenAppSettings: _openRelevantAppSettings,
|
|
||||||
onOpenLocationSettings: _openLocationSettings,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
...homes.map((home) {
|
...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;
|
||||||
@@ -166,6 +152,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SafeArea(
|
const SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
minimum: EdgeInsets.only(bottom: 10),
|
minimum: EdgeInsets.only(bottom: 10),
|
||||||
@@ -264,10 +251,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
|||||||
if (deletedCurrentHome) {
|
if (deletedCurrentHome) {
|
||||||
ref.read(authInfoProvider.notifier).clear();
|
ref.read(authInfoProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
await syncGeofenceTask(
|
|
||||||
ref.read(homesProvider),
|
|
||||||
currentHome: ref.read(currentHomeProvider),
|
|
||||||
);
|
|
||||||
await _refreshEnvironmentState();
|
await _refreshEnvironmentState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -285,42 +268,33 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshEnvironmentState() async {
|
Future<void> _refreshEnvironmentState() async {
|
||||||
await _userLocationNotifier.refresh();
|
await _syncLocationWatching();
|
||||||
await _refreshGeofenceDiagnostics();
|
if (_isWatchingLocation) {
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
await _userLocationNotifier.refresh();
|
||||||
}
|
}
|
||||||
|
await _syncGeofenceAutomation();
|
||||||
Future<void> _requestGeofenceNotificationPermission() async {
|
|
||||||
await ref
|
|
||||||
.read(geofenceDiagnosticsProvider.notifier)
|
|
||||||
.requestNotificationPermission();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openRelevantAppSettings() async {
|
Future<void> _syncGeofenceAutomation() async {
|
||||||
if (ref.read(userLocationProvider).needsAppSettings) {
|
await ref
|
||||||
await _userLocationNotifier.openAppSettings();
|
.read(geofenceAutomationServiceProvider)
|
||||||
|
.syncActiveHome(ref.read(currentHomeProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncLocationWatching() async {
|
||||||
|
final shouldWatch = ref.read(homesProvider).any((home) => home.hasCoordinates);
|
||||||
|
if (shouldWatch == _isWatchingLocation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings();
|
|
||||||
|
if (shouldWatch) {
|
||||||
|
await _userLocationNotifier.startWatching();
|
||||||
|
_isWatchingLocation = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_userLocationNotifier.stopWatching();
|
||||||
|
_isWatchingLocation = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,19 +375,19 @@ class _HomeSubtitle extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (home.geofenceReady && isActive)
|
else if (home.geofenceReady && isActive)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 2),
|
padding: const EdgeInsets.only(top: 2),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.shield_moon_outlined,
|
Icons.shield_moon_outlined,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: Colors.deepOrangeAccent,
|
color: Colors.deepOrangeAccent,
|
||||||
),
|
),
|
||||||
SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Geofence включён',
|
'Автовыключение: ${home.geofenceRadiusMeters} м',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.deepOrangeAccent,
|
color: Colors.deepOrangeAccent,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
@@ -422,19 +396,19 @@ class _HomeSubtitle extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (home.geofenceReady)
|
else if (home.geofenceReady)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 2),
|
padding: const EdgeInsets.only(top: 2),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.shield_moon_outlined,
|
Icons.shield_moon_outlined,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: Colors.white24,
|
color: Colors.white24,
|
||||||
),
|
),
|
||||||
SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Geofence включён',
|
'Автовыключение: ${home.geofenceRadiusMeters} м',
|
||||||
style: TextStyle(color: Colors.white24, fontSize: 11),
|
style: const TextStyle(color: Colors.white24, fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -443,214 +417,3 @@ class _HomeSubtitle extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomesOverviewCard extends StatelessWidget {
|
|
||||||
final UserLocation location;
|
|
||||||
final GeofenceDiagnostics diagnostics;
|
|
||||||
final HomeConfig? activeHome;
|
|
||||||
final double? activeDistanceKm;
|
|
||||||
final Future<void> Function() onRefresh;
|
|
||||||
final Future<void> Function() onRequestLocationPermission;
|
|
||||||
final Future<void> Function() onRequestBackgroundPermission;
|
|
||||||
final Future<void> Function() onRequestNotificationsPermission;
|
|
||||||
final Future<void> Function() onOpenAppSettings;
|
|
||||||
final Future<void> Function() onOpenLocationSettings;
|
|
||||||
|
|
||||||
const _HomesOverviewCard({
|
|
||||||
required this.location,
|
|
||||||
required this.diagnostics,
|
|
||||||
required this.activeHome,
|
|
||||||
required this.activeDistanceKm,
|
|
||||||
required this.onRefresh,
|
|
||||||
required this.onRequestLocationPermission,
|
|
||||||
required this.onRequestBackgroundPermission,
|
|
||||||
required this.onRequestNotificationsPermission,
|
|
||||||
required this.onOpenAppSettings,
|
|
||||||
required this.onOpenLocationSettings,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final title = activeHome == null
|
|
||||||
? 'Статус автоматизации'
|
|
||||||
: 'Активный дом: ${activeHome!.name}';
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_overviewIcon(location, diagnostics),
|
|
||||||
color: _overviewColor(location, diagnostics),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_overviewPrimaryText(location, diagnostics),
|
|
||||||
style: const TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
if (_overviewSecondaryText(location, diagnostics, activeDistanceKm)
|
|
||||||
case final secondary?)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
secondary,
|
|
||||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: onRefresh,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Обновить'),
|
|
||||||
),
|
|
||||||
if (location.canRequestPermission)
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onRequestLocationPermission,
|
|
||||||
icon: const Icon(Icons.my_location),
|
|
||||||
label: const Text('Разрешить геолокацию'),
|
|
||||||
),
|
|
||||||
if (location.needsLocationSettings)
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onOpenLocationSettings,
|
|
||||||
icon: const Icon(Icons.location_searching),
|
|
||||||
label: const Text('Включить GPS'),
|
|
||||||
),
|
|
||||||
if (diagnostics.canRequestBackgroundLocation)
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onRequestBackgroundPermission,
|
|
||||||
icon: const Icon(Icons.shield_moon_outlined),
|
|
||||||
label: const Text('Доступ всегда'),
|
|
||||||
),
|
|
||||||
if (diagnostics.canRequestNotifications)
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onRequestNotificationsPermission,
|
|
||||||
icon: const Icon(Icons.notifications_active_outlined),
|
|
||||||
label: const Text('Уведомления'),
|
|
||||||
),
|
|
||||||
if (location.needsAppSettings ||
|
|
||||||
diagnostics.canRequestBackgroundLocation)
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onOpenAppSettings,
|
|
||||||
icon: const Icon(Icons.settings),
|
|
||||||
label: const Text('Настройки'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _overviewPrimaryText(
|
|
||||||
UserLocation location,
|
|
||||||
GeofenceDiagnostics diagnostics,
|
|
||||||
) {
|
|
||||||
if (!location.hasPosition) {
|
|
||||||
return location.error ?? 'Позиция устройства пока недоступна.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch (diagnostics.status) {
|
|
||||||
GeofenceStatusKind.ready =>
|
|
||||||
'Расстояние считается, geofence активен и готов выключить свет.',
|
|
||||||
GeofenceStatusKind.triggered =>
|
|
||||||
'Расстояние считается, geofence уже сработал для активного дома.',
|
|
||||||
GeofenceStatusKind.cooldown =>
|
|
||||||
'Расстояние считается, но после сбоя geofence сейчас на паузе.',
|
|
||||||
GeofenceStatusKind.notificationsPermissionDenied =>
|
|
||||||
'Расстояние считается, но уведомления для geofence сейчас запрещены.',
|
|
||||||
GeofenceStatusKind.backgroundPermissionDenied =>
|
|
||||||
'Расстояние считается, но без доступа "Всегда" geofence в фоне будет кастрирован.',
|
|
||||||
GeofenceStatusKind.locationServicesDisabled =>
|
|
||||||
'Геолокация выключена, поэтому и расстояния, и geofence сейчас мёртвые.',
|
|
||||||
GeofenceStatusKind.locationPermissionDenied =>
|
|
||||||
'Без разрешения на геолокацию тут нечего считать.',
|
|
||||||
GeofenceStatusKind.disabled =>
|
|
||||||
'Расстояние считается, но geofence для активного дома выключен.',
|
|
||||||
GeofenceStatusKind.missingCoordinates =>
|
|
||||||
'Расстояние считается, но у активного дома нет координат для geofence.',
|
|
||||||
GeofenceStatusKind.noActiveHome =>
|
|
||||||
'Выбери активный дом, и тогда автоматика станет осмысленной.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _overviewSecondaryText(
|
|
||||||
UserLocation location,
|
|
||||||
GeofenceDiagnostics diagnostics,
|
|
||||||
double? activeDistanceKm,
|
|
||||||
) {
|
|
||||||
final parts = <String>[];
|
|
||||||
|
|
||||||
if (activeDistanceKm != null) {
|
|
||||||
parts.add('До активного дома: ${formatDistance(activeDistanceKm)}.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location.updatedAt != null) {
|
|
||||||
parts.add('Точка: ${_formatTimestamp(location.updatedAt!)}.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final secondary = diagnostics.secondaryMessage;
|
|
||||||
if (secondary != null && secondary.isNotEmpty) {
|
|
||||||
parts.add(secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.isEmpty) return null;
|
|
||||||
return parts.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _overviewIcon(UserLocation location, GeofenceDiagnostics diagnostics) {
|
|
||||||
if (!location.hasPosition) {
|
|
||||||
return Icons.location_off_outlined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch (diagnostics.status) {
|
|
||||||
GeofenceStatusKind.ready => Icons.shield_moon_outlined,
|
|
||||||
GeofenceStatusKind.triggered => Icons.check_circle_outline,
|
|
||||||
GeofenceStatusKind.cooldown => Icons.timer_outlined,
|
|
||||||
GeofenceStatusKind.notificationsPermissionDenied =>
|
|
||||||
Icons.notifications_off_outlined,
|
|
||||||
GeofenceStatusKind.backgroundPermissionDenied => Icons.location_searching,
|
|
||||||
_ => Icons.info_outline,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _overviewColor(UserLocation location, GeofenceDiagnostics diagnostics) {
|
|
||||||
if (!location.hasPosition) {
|
|
||||||
return Colors.redAccent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch (diagnostics.status) {
|
|
||||||
GeofenceStatusKind.ready => Colors.greenAccent,
|
|
||||||
GeofenceStatusKind.triggered => Colors.deepOrangeAccent,
|
|
||||||
GeofenceStatusKind.cooldown => Colors.amberAccent,
|
|
||||||
GeofenceStatusKind.notificationsPermissionDenied ||
|
|
||||||
GeofenceStatusKind.backgroundPermissionDenied => Colors.deepOrangeAccent,
|
|
||||||
_ => Colors.white54,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatTimestamp(DateTime timestamp) {
|
|
||||||
final local = timestamp.toLocal();
|
|
||||||
final hour = local.hour.toString().padLeft(2, '0');
|
|
||||||
final minute = local.minute.toString().padLeft(2, '0');
|
|
||||||
final second = local.second.toString().padLeft(2, '0');
|
|
||||||
return '$hour:$minute:$second';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_groupsNotifier = ref.read(groupsProvider.notifier);
|
_groupsNotifier = ref.read(groupsProvider.notifier);
|
||||||
|
if (ref.read(remotePollingEnabledProvider)) {
|
||||||
Future.microtask(_groupsNotifier.startPolling);
|
Future.microtask(_groupsNotifier.startPolling);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../features/homes/geofence_logic.dart';
|
|
||||||
import '../features/homes/services/geofence_runtime_store.dart';
|
|
||||||
|
|
||||||
const String _apiKeyPrefix = 'ignis_home_api_key_';
|
|
||||||
|
|
||||||
/// Имя задачи в workmanager
|
|
||||||
const String geofenceTaskName = 'ignis_geofence_check';
|
|
||||||
|
|
||||||
/// Уникальное имя для registerPeriodicTask
|
|
||||||
const String geofenceTaskUniqueName = 'ignis_geofence_periodic';
|
|
||||||
|
|
||||||
/// ID notification channel (должен совпадать с AndroidManifest)
|
|
||||||
const String _channelId = 'ignis_geofence';
|
|
||||||
const String _channelName = 'Геофенс';
|
|
||||||
const String _channelDesc = 'Уведомления об автовыключении света';
|
|
||||||
|
|
||||||
/// Основная логика фонового таска.
|
|
||||||
/// Вызывается из workmanager callback (в отдельном изоляте).
|
|
||||||
/// Возвращает true если таск выполнен успешно (workmanager convention).
|
|
||||||
Future<bool> executeGeofenceCheck() async {
|
|
||||||
try {
|
|
||||||
final runtimeStore = GeofenceRuntimeStore();
|
|
||||||
var runtime = await runtimeStore.load();
|
|
||||||
final armedHomeId = runtime.armedHomeId;
|
|
||||||
if (armedHomeId == null || armedHomeId.isEmpty) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final raw = prefs.getString('ignis_homes');
|
|
||||||
if (raw == null || raw.isEmpty) return true;
|
|
||||||
|
|
||||||
final List<dynamic> homesList = jsonDecode(raw);
|
|
||||||
final targetHome = _findArmedHome(homesList, armedHomeId);
|
|
||||||
if (targetHome == null) {
|
|
||||||
await runtimeStore.disarm();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await Geolocator.isLocationServiceEnabled()) return true;
|
|
||||||
|
|
||||||
final perm = await Geolocator.checkPermission();
|
|
||||||
if (!hasBackgroundLocationAccess(perm)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final pos = await _getCurrentPosition();
|
|
||||||
if (pos == null) return true;
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
final homeLat = (targetHome['latitude'] as num).toDouble();
|
|
||||||
final homeLon = (targetHome['longitude'] as num).toDouble();
|
|
||||||
final distMeters = _haversineMeters(
|
|
||||||
pos.latitude,
|
|
||||||
pos.longitude,
|
|
||||||
homeLat,
|
|
||||||
homeLon,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (distMeters <= geofenceThresholdMeters) {
|
|
||||||
runtime = runtime.recordInsideHome(
|
|
||||||
armedHomeId,
|
|
||||||
checkedAt: now,
|
|
||||||
distanceMeters: distMeters,
|
|
||||||
);
|
|
||||||
await runtimeStore.save(runtime);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime = runtime.recordOutsideCheck(
|
|
||||||
armedHomeId,
|
|
||||||
checkedAt: now,
|
|
||||||
distanceMeters: distMeters,
|
|
||||||
);
|
|
||||||
await runtimeStore.save(runtime);
|
|
||||||
|
|
||||||
if (runtime.isTriggeredFor(armedHomeId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final retryRemaining = geofenceRetryRemaining(
|
|
||||||
runtime.failureAtFor(armedHomeId),
|
|
||||||
now: now,
|
|
||||||
);
|
|
||||||
if (retryRemaining != null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final url = _normalizeUrl(targetHome['url'] as String);
|
|
||||||
final apiKey = await _getHomeApiKey(targetHome);
|
|
||||||
if (apiKey == null || apiKey.isEmpty) {
|
|
||||||
runtime = runtime.recordFailure(
|
|
||||||
armedHomeId,
|
|
||||||
failedAt: now,
|
|
||||||
distanceMeters: distMeters,
|
|
||||||
message: 'Не найден API key для armed geofence дома.',
|
|
||||||
);
|
|
||||||
await runtimeStore.save(runtime);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final homeName = (targetHome['name'] ?? 'Дом') as String;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final result = await _turnOffAllGroups(url, apiKey);
|
|
||||||
if (result.totalGroups > 0 && result.successCount < result.totalGroups) {
|
|
||||||
throw StateError(
|
|
||||||
'Выключено только ${result.successCount} из ${result.totalGroups} групп.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime = runtime.recordSuccess(
|
|
||||||
armedHomeId,
|
|
||||||
triggeredAt: now,
|
|
||||||
distanceMeters: distMeters,
|
|
||||||
);
|
|
||||||
await runtimeStore.save(runtime);
|
|
||||||
|
|
||||||
await _showNotification(
|
|
||||||
title: 'Свет выключен',
|
|
||||||
body:
|
|
||||||
'$homeName -- вы ушли на ${formatDistanceMeters(distMeters)}. '
|
|
||||||
'Выключено групп: ${result.successCount}.',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
runtime = runtime.recordFailure(
|
|
||||||
armedHomeId,
|
|
||||||
failedAt: now,
|
|
||||||
distanceMeters: distMeters,
|
|
||||||
message: _describeFailure(error),
|
|
||||||
);
|
|
||||||
await runtimeStore.save(runtime);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
// Любая ошибка -- не крашим воркер
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _getHomeApiKey(Map<String, dynamic> home) async {
|
|
||||||
final id = home['id']?.toString();
|
|
||||||
if (id == null || id.isEmpty) return null;
|
|
||||||
|
|
||||||
const secureStorage = FlutterSecureStorage();
|
|
||||||
final secureKey = await secureStorage.read(key: '$_apiKeyPrefix$id');
|
|
||||||
if (secureKey != null && secureKey.isNotEmpty) return secureKey;
|
|
||||||
|
|
||||||
// Backward compatibility: if the app has not run after migration yet,
|
|
||||||
// old background tasks can still read the legacy key once.
|
|
||||||
return home['apiKey']?.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Уведомления ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Показать локальное уведомление из фонового изолята
|
|
||||||
Future<void> _showNotification({
|
|
||||||
required String title,
|
|
||||||
required String body,
|
|
||||||
}) async {
|
|
||||||
final plugin = FlutterLocalNotificationsPlugin();
|
|
||||||
|
|
||||||
// Инициализация (в изоляте нужна заново)
|
|
||||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
|
||||||
const initSettings = InitializationSettings(android: androidSettings);
|
|
||||||
await plugin.initialize(initSettings);
|
|
||||||
|
|
||||||
const androidDetails = AndroidNotificationDetails(
|
|
||||||
_channelId,
|
|
||||||
_channelName,
|
|
||||||
channelDescription: _channelDesc,
|
|
||||||
importance: Importance.high,
|
|
||||||
priority: Priority.high,
|
|
||||||
icon: '@mipmap/ic_launcher',
|
|
||||||
);
|
|
||||||
|
|
||||||
const details = NotificationDetails(android: androidDetails);
|
|
||||||
|
|
||||||
final android = plugin
|
|
||||||
.resolvePlatformSpecificImplementation<
|
|
||||||
AndroidFlutterLocalNotificationsPlugin
|
|
||||||
>();
|
|
||||||
final notificationsEnabled = await android?.areNotificationsEnabled() ?? true;
|
|
||||||
if (!notificationsEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await plugin.show(
|
|
||||||
42, // фиксированный id -- перезаписывает предыдущее уведомление
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
details,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Внутренние хелперы ──────────────────────────────────────
|
|
||||||
|
|
||||||
Map<String, dynamic>? _findArmedHome(
|
|
||||||
List<dynamic> homesList,
|
|
||||||
String armedHomeId,
|
|
||||||
) {
|
|
||||||
for (final item in homesList) {
|
|
||||||
if (item is! Map) continue;
|
|
||||||
final map = Map<String, dynamic>.from(item);
|
|
||||||
if (map['id'] == armedHomeId &&
|
|
||||||
map['geofenceEnabled'] == true &&
|
|
||||||
map['latitude'] != null &&
|
|
||||||
map['longitude'] != null) {
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Position?> _getCurrentPosition() async {
|
|
||||||
try {
|
|
||||||
var pos = await Geolocator.getLastKnownPosition();
|
|
||||||
final lastTimestamp = pos?.timestamp;
|
|
||||||
|
|
||||||
if (pos == null ||
|
|
||||||
lastTimestamp == null ||
|
|
||||||
DateTime.now().difference(lastTimestamp).inMinutes > 5) {
|
|
||||||
pos = await Geolocator.getCurrentPosition(
|
|
||||||
locationSettings: const LocationSettings(
|
|
||||||
accuracy: LocationAccuracy.low,
|
|
||||||
timeLimit: Duration(seconds: 15),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pos;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выключить все группы на сервере.
|
|
||||||
Future<_TurnOffGroupsResult> _turnOffAllGroups(
|
|
||||||
String baseUrl,
|
|
||||||
String apiKey,
|
|
||||||
) async {
|
|
||||||
final dio = Dio(
|
|
||||||
BaseOptions(
|
|
||||||
baseUrl: baseUrl,
|
|
||||||
headers: {'X-API-Key': apiKey},
|
|
||||||
connectTimeout: const Duration(seconds: 15),
|
|
||||||
receiveTimeout: const Duration(seconds: 15),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final res = await dio.get('/devices/groups');
|
|
||||||
final groupIds = <String>[];
|
|
||||||
|
|
||||||
if (res.data is Map) {
|
|
||||||
final map = res.data as Map;
|
|
||||||
for (final entry in map.entries) {
|
|
||||||
groupIds.add(entry.key.toString());
|
|
||||||
}
|
|
||||||
} else if (res.data is List) {
|
|
||||||
for (final g in res.data) {
|
|
||||||
if (g is Map && g['id'] != null) {
|
|
||||||
groupIds.add(g['id'].toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int success = 0;
|
|
||||||
await Future.wait(
|
|
||||||
groupIds.map((id) async {
|
|
||||||
try {
|
|
||||||
await dio.post(
|
|
||||||
'/control/group/$id',
|
|
||||||
queryParameters: {'state': false},
|
|
||||||
);
|
|
||||||
success++;
|
|
||||||
} catch (_) {
|
|
||||||
// Одна группа упала -- не останавливаем остальные
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return _TurnOffGroupsResult(
|
|
||||||
totalGroups: groupIds.length,
|
|
||||||
successCount: success,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
dio.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _describeFailure(Object error) {
|
|
||||||
if (error is DioException) {
|
|
||||||
final statusCode = error.response?.statusCode;
|
|
||||||
final detail = error.response?.data;
|
|
||||||
if (statusCode != null) {
|
|
||||||
return 'Backend ответил ошибкой $statusCode${detail != null ? ': $detail' : ''}';
|
|
||||||
}
|
|
||||||
return 'Сетевой запрос сломался: ${error.message ?? error.type.name}';
|
|
||||||
}
|
|
||||||
|
|
||||||
return error.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Нормализация URL
|
|
||||||
String _normalizeUrl(String url) {
|
|
||||||
var u = url.trim();
|
|
||||||
if (!u.startsWith('http')) u = 'https://$u';
|
|
||||||
if (u.endsWith('/')) u = u.substring(0, u.length - 1);
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Расстояние в метрах (Haversine)
|
|
||||||
double _haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
|
||||||
const earthRadiusM = 6371000.0;
|
|
||||||
final dLat = _degToRad(lat2 - lat1);
|
|
||||||
final dLon = _degToRad(lon2 - lon1);
|
|
||||||
final a =
|
|
||||||
math.sin(dLat / 2) * math.sin(dLat / 2) +
|
|
||||||
math.cos(_degToRad(lat1)) *
|
|
||||||
math.cos(_degToRad(lat2)) *
|
|
||||||
math.sin(dLon / 2) *
|
|
||||||
math.sin(dLon / 2);
|
|
||||||
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
|
||||||
return earthRadiusM * c;
|
|
||||||
}
|
|
||||||
|
|
||||||
double _degToRad(double deg) => deg * (math.pi / 180);
|
|
||||||
|
|
||||||
class _TurnOffGroupsResult {
|
|
||||||
final int totalGroups;
|
|
||||||
final int successCount;
|
|
||||||
|
|
||||||
const _TurnOffGroupsResult({
|
|
||||||
required this.totalGroups,
|
|
||||||
required this.successCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
88
pubspec.lock
88
pubspec.lock
@@ -137,14 +137,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
dbus:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dbus
|
|
||||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.12"
|
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -214,38 +206,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
flutter_local_notifications:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications
|
|
||||||
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "19.5.0"
|
|
||||||
flutter_local_notifications_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications_linux
|
|
||||||
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.0.0"
|
|
||||||
flutter_local_notifications_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications_platform_interface
|
|
||||||
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.1.0"
|
|
||||||
flutter_local_notifications_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications_windows
|
|
||||||
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.3"
|
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -384,14 +344,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.0.3"
|
||||||
http:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http
|
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.6.0"
|
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -853,14 +805,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.16"
|
||||||
timezone:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: timezone
|
|
||||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.10.1"
|
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -941,38 +885,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.15.0"
|
version: "5.15.0"
|
||||||
workmanager:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: workmanager
|
|
||||||
sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.9.0+3"
|
|
||||||
workmanager_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: workmanager_android
|
|
||||||
sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.9.0+2"
|
|
||||||
workmanager_apple:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: workmanager_apple
|
|
||||||
sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.9.1+2"
|
|
||||||
workmanager_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: workmanager_platform_interface
|
|
||||||
sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.9.1+1"
|
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ dependencies:
|
|||||||
flutter_riverpod: ^3.3.1
|
flutter_riverpod: ^3.3.1
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
geolocator: ^13.0.2
|
geolocator: ^13.0.2
|
||||||
workmanager: ^0.9.0+3
|
|
||||||
flutter_local_notifications: ^19.0.0
|
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
86
test/current_home_geofence_sync_test.dart
Normal file
86
test/current_home_geofence_sync_test.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/features/homes/providers/homes_providers.dart';
|
||||||
|
import 'package:ignis_app/features/homes/services/geofence_automation_service.dart';
|
||||||
|
import 'package:ignis_app/features/shared/providers/core_providers.dart';
|
||||||
|
import 'package:ignis_app/models/home_config.dart';
|
||||||
|
import 'package:ignis_app/services/settings_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'test_support.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('loading current home syncs geofence with active ready home', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'ignis_homes':
|
||||||
|
'[{"id":"home-1","name":"Home 1","url":"https://one.example","latitude":55.75,"longitude":37.61,"geofenceEnabled":true,"geofenceRadiusMeters":700}]',
|
||||||
|
'ignis_current_home_id': 'home-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
final settingsService = SettingsService(
|
||||||
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
|
);
|
||||||
|
await settingsService.setHomeApiKey('home-1', 'key-1');
|
||||||
|
final geofenceService = _RecordingGeofenceAutomationService();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
settingsServiceProvider.overrideWithValue(settingsService),
|
||||||
|
geofenceAutomationServiceProvider.overrideWithValue(geofenceService),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(currentHomeProvider.notifier).load();
|
||||||
|
|
||||||
|
expect(container.read(currentHomeProvider)?.id, 'home-1');
|
||||||
|
expect(geofenceService.syncedHomes, hasLength(1));
|
||||||
|
expect(geofenceService.syncedHomes.single?.geofenceReady, isTrue);
|
||||||
|
expect(geofenceService.syncedHomes.single?.geofenceRadiusMeters, 700);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing current home disarms geofence automation', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'ignis_homes':
|
||||||
|
'[{"id":"home-1","name":"Home 1","url":"https://one.example","latitude":55.75,"longitude":37.61,"geofenceEnabled":true}]',
|
||||||
|
'ignis_current_home_id': 'home-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
final settingsService = SettingsService(
|
||||||
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
|
);
|
||||||
|
await settingsService.setHomeApiKey('home-1', 'key-1');
|
||||||
|
final geofenceService = _RecordingGeofenceAutomationService();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
settingsServiceProvider.overrideWithValue(settingsService),
|
||||||
|
geofenceAutomationServiceProvider.overrideWithValue(geofenceService),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(currentHomeProvider.notifier).load();
|
||||||
|
await container.read(currentHomeProvider.notifier).clear();
|
||||||
|
|
||||||
|
expect(container.read(currentHomeProvider), isNull);
|
||||||
|
expect(geofenceService.syncedHomes, hasLength(2));
|
||||||
|
expect(geofenceService.syncedHomes.last, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordingGeofenceAutomationService extends GeofenceAutomationService {
|
||||||
|
_RecordingGeofenceAutomationService()
|
||||||
|
: super(
|
||||||
|
settingsService: SettingsService(
|
||||||
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<HomeConfig?> syncedHomes = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncActiveHome(HomeConfig? home) async {
|
||||||
|
syncedHomes.add(home);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/models/home_config.dart';
|
||||||
import 'package:ignis_app/screens/api_keys_screen.dart';
|
import 'package:ignis_app/screens/api_keys_screen.dart';
|
||||||
import 'package:ignis_app/screens/group_edit_screen.dart';
|
import 'package:ignis_app/screens/group_edit_screen.dart';
|
||||||
import 'package:ignis_app/screens/home_edit_screen.dart';
|
import 'package:ignis_app/screens/home_edit_screen.dart';
|
||||||
@@ -114,6 +115,11 @@ void main() {
|
|||||||
testWidgets('home edit screen validates fields and saves normalized home', (
|
testWidgets('home edit screen validates fields and saves normalized home', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final settingsService = SettingsService(
|
final settingsService = SettingsService(
|
||||||
credentialsStorage: InMemoryCredentialsStorage(),
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
@@ -170,9 +176,6 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.text('Выключать свет при уходе'));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.ensureVisible(saveHomeButton);
|
|
||||||
await tester.tap(saveHomeButton);
|
await tester.tap(saveHomeButton);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -185,7 +188,8 @@ void main() {
|
|||||||
expect(savedHome.url, 'https://ignis.akokos.ru');
|
expect(savedHome.url, 'https://ignis.akokos.ru');
|
||||||
expect(savedHome.latitude, 55.75);
|
expect(savedHome.latitude, 55.75);
|
||||||
expect(savedHome.longitude, 37.61);
|
expect(savedHome.longitude, 37.61);
|
||||||
expect(savedHome.geofenceEnabled, isTrue);
|
expect(savedHome.geofenceEnabled, isFalse);
|
||||||
|
expect(savedHome.geofenceRadiusMeters, HomeConfig.defaultGeofenceRadiusMeters);
|
||||||
expect(savedApiKey, 'secret-key');
|
expect(savedApiKey, 'secret-key');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
95
test/geofence_automation_service_test.dart
Normal file
95
test/geofence_automation_service_test.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/features/homes/services/geofence_automation_service.dart';
|
||||||
|
import 'package:ignis_app/models/home_config.dart';
|
||||||
|
import 'package:ignis_app/services/settings_service.dart';
|
||||||
|
|
||||||
|
import 'test_support.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
const channel = MethodChannel('ignis/geofence_automation');
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
calls.clear();
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disarms native geofence when active home is null', () async {
|
||||||
|
final service = GeofenceAutomationService(
|
||||||
|
settingsService: SettingsService(
|
||||||
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.syncActiveHome(null);
|
||||||
|
|
||||||
|
expect(calls, hasLength(1));
|
||||||
|
expect(calls.single.method, 'disarmGeofence');
|
||||||
|
expect(calls.single.arguments, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disarms native geofence when home is not geofence-ready', () async {
|
||||||
|
final service = GeofenceAutomationService(
|
||||||
|
settingsService: SettingsService(
|
||||||
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.syncActiveHome(
|
||||||
|
HomeConfig(
|
||||||
|
id: 'home-1',
|
||||||
|
name: 'Home 1',
|
||||||
|
url: 'https://one.example',
|
||||||
|
latitude: 55.75,
|
||||||
|
longitude: 37.61,
|
||||||
|
geofenceEnabled: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, hasLength(1));
|
||||||
|
expect(calls.single.method, 'disarmGeofence');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arms native geofence with stored api key and radius', () async {
|
||||||
|
final settingsService = SettingsService(
|
||||||
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
|
);
|
||||||
|
await settingsService.setHomeApiKey('home-1', 'secret-key');
|
||||||
|
final service = GeofenceAutomationService(settingsService: settingsService);
|
||||||
|
|
||||||
|
await service.syncActiveHome(
|
||||||
|
HomeConfig(
|
||||||
|
id: 'home-1',
|
||||||
|
name: 'Home 1',
|
||||||
|
url: 'https://one.example',
|
||||||
|
latitude: 55.75,
|
||||||
|
longitude: 37.61,
|
||||||
|
geofenceEnabled: true,
|
||||||
|
geofenceRadiusMeters: 750,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, hasLength(1));
|
||||||
|
expect(calls.single.method, 'armGeofence');
|
||||||
|
expect(calls.single.arguments, <String, Object?>{
|
||||||
|
'homeId': 'home-1',
|
||||||
|
'baseUrl': 'https://one.example',
|
||||||
|
'apiKey': 'secret-key',
|
||||||
|
'latitude': 55.75,
|
||||||
|
'longitude': 37.61,
|
||||||
|
'radiusMeters': 750,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
72
test/home_config_test.dart
Normal file
72
test/home_config_test.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/models/home_config.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('home config persists geofence radius in json', () {
|
||||||
|
final home = HomeConfig(
|
||||||
|
id: 'home-1',
|
||||||
|
name: 'Home 1',
|
||||||
|
url: 'https://one.example',
|
||||||
|
latitude: 55.75,
|
||||||
|
longitude: 37.61,
|
||||||
|
geofenceEnabled: true,
|
||||||
|
geofenceRadiusMeters: 900,
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = HomeConfig.fromJson(home.toJson());
|
||||||
|
|
||||||
|
expect(decoded.geofenceEnabled, isTrue);
|
||||||
|
expect(decoded.geofenceRadiusMeters, 900);
|
||||||
|
expect(decoded.latitude, 55.75);
|
||||||
|
expect(decoded.longitude, 37.61);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'home config clamps geofence radius from legacy or invalid payloads',
|
||||||
|
() {
|
||||||
|
final tooSmall = HomeConfig.fromJson({
|
||||||
|
'id': 'home-1',
|
||||||
|
'name': 'Home 1',
|
||||||
|
'url': 'https://one.example',
|
||||||
|
'geofenceRadiusMeters': 50,
|
||||||
|
});
|
||||||
|
final tooLarge = HomeConfig.fromJson({
|
||||||
|
'id': 'home-1',
|
||||||
|
'name': 'Home 1',
|
||||||
|
'url': 'https://one.example',
|
||||||
|
'geofenceRadiusMeters': 9000,
|
||||||
|
});
|
||||||
|
final missing = HomeConfig.fromJson({
|
||||||
|
'id': 'home-1',
|
||||||
|
'name': 'Home 1',
|
||||||
|
'url': 'https://one.example',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tooSmall.geofenceRadiusMeters, 100);
|
||||||
|
expect(tooLarge.geofenceRadiusMeters, 5000);
|
||||||
|
expect(
|
||||||
|
missing.geofenceRadiusMeters,
|
||||||
|
HomeConfig.defaultGeofenceRadiusMeters,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('clearing coordinates also disables geofence', () {
|
||||||
|
final home = HomeConfig(
|
||||||
|
id: 'home-1',
|
||||||
|
name: 'Home 1',
|
||||||
|
url: 'https://one.example',
|
||||||
|
latitude: 55.75,
|
||||||
|
longitude: 37.61,
|
||||||
|
geofenceEnabled: true,
|
||||||
|
geofenceRadiusMeters: 700,
|
||||||
|
);
|
||||||
|
|
||||||
|
final cleared = home.copyWith(clearCoordinates: true);
|
||||||
|
|
||||||
|
expect(cleared.latitude, isNull);
|
||||||
|
expect(cleared.longitude, isNull);
|
||||||
|
expect(cleared.geofenceEnabled, isFalse);
|
||||||
|
expect(cleared.geofenceRadiusMeters, 700);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,90 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ignis_app/models/ignis_group.dart';
|
import 'package:ignis_app/models/ignis_group.dart';
|
||||||
import 'package:ignis_app/providers/providers.dart';
|
|
||||||
import 'package:ignis_app/screens/remote_screen.dart';
|
|
||||||
import 'package:ignis_app/services/settings_service.dart';
|
|
||||||
import 'package:ignis_app/widgets/group_card.dart';
|
import 'package:ignis_app/widgets/group_card.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import 'test_support.dart';
|
import 'test_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
testWidgets('remote screen shows api keys menu for admin only', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final adminApi = FakeIgnisApi(
|
|
||||||
authData: {'is_admin': true, 'name': 'owner'},
|
|
||||||
groupsData: <Object>[],
|
|
||||||
);
|
|
||||||
final adminContainer = await _pumpRemoteScreen(
|
|
||||||
tester,
|
|
||||||
api: adminApi,
|
|
||||||
settingsService: await _seedSettingsService(),
|
|
||||||
);
|
|
||||||
await adminContainer.read(authInfoProvider.notifier).load();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.more_vert));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('API-ключи'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tapAt(const Offset(10, 10));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final guestApi = FakeIgnisApi(
|
|
||||||
authData: {'is_admin': false, 'name': 'guest'},
|
|
||||||
groupsData: <Object>[],
|
|
||||||
);
|
|
||||||
final guestContainer = await _pumpRemoteScreen(
|
|
||||||
tester,
|
|
||||||
api: guestApi,
|
|
||||||
settingsService: await _seedSettingsService(),
|
|
||||||
);
|
|
||||||
await guestContainer.read(authInfoProvider.notifier).load();
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.more_vert));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('API-ключи'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('remote screen deletes group after confirmation', (tester) async {
|
|
||||||
final api = FakeIgnisApi(
|
|
||||||
authData: {'is_admin': true, 'name': 'owner'},
|
|
||||||
groupsData: {
|
|
||||||
'kitchen': {
|
|
||||||
'name': 'Kitchen',
|
|
||||||
'macs': ['AA:BB'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final container = await _pumpRemoteScreen(
|
|
||||||
tester,
|
|
||||||
api: api,
|
|
||||||
settingsService: await _seedSettingsService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await container.read(groupsProvider.notifier).refresh();
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
expect(find.text('Kitchen'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.drag(find.text('Kitchen'), const Offset(-500, 0));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('Удалить группу?'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Удалить'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(api.deletedGroupId, 'kitchen');
|
|
||||||
expect(find.text('Kitchen'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('group card toggles power and creates 4 hour timer', (
|
testWidgets('group card toggles power and creates 4 hour timer', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -180,36 +104,3 @@ void main() {
|
|||||||
expect(find.text('Party'), findsOneWidget);
|
expect(find.text('Party'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ProviderContainer> _pumpRemoteScreen(
|
|
||||||
WidgetTester tester, {
|
|
||||||
required FakeIgnisApi api,
|
|
||||||
required SettingsService settingsService,
|
|
||||||
}) async {
|
|
||||||
final container = createTestContainer(api, settingsService: settingsService);
|
|
||||||
await container.read(currentHomeProvider.notifier).load();
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
UncontrolledProviderScope(
|
|
||||||
container: container,
|
|
||||||
child: const MaterialApp(home: RemoteScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<SettingsService> _seedSettingsService() async {
|
|
||||||
SharedPreferences.setMockInitialValues({
|
|
||||||
'ignis_homes':
|
|
||||||
'[{"id":"home-1","name":"Home 1","url":"https://one.example","geofenceEnabled":false}]',
|
|
||||||
'ignis_current_home_id': 'home-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
final settingsService = SettingsService(
|
|
||||||
credentialsStorage: InMemoryCredentialsStorage(),
|
|
||||||
);
|
|
||||||
await settingsService.setHomeApiKey('home-1', 'key-1');
|
|
||||||
return settingsService;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -335,8 +335,12 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
ProviderContainer createTestContainer(
|
ProviderContainer createTestContainer(
|
||||||
FakeIgnisApi api, {
|
FakeIgnisApi api, {
|
||||||
SettingsService? settingsService,
|
SettingsService? settingsService,
|
||||||
|
bool remotePollingEnabled = true,
|
||||||
}) {
|
}) {
|
||||||
final overrides = [apiProvider.overrideWithValue(api)];
|
final overrides = [
|
||||||
|
apiProvider.overrideWithValue(api),
|
||||||
|
remotePollingEnabledProvider.overrideWithValue(remotePollingEnabled),
|
||||||
|
];
|
||||||
if (settingsService != null) {
|
if (settingsService != null) {
|
||||||
overrides.add(settingsServiceProvider.overrideWithValue(settingsService));
|
overrides.add(settingsServiceProvider.overrideWithValue(settingsService));
|
||||||
}
|
}
|
||||||
@@ -351,10 +355,12 @@ Future<ProviderContainer> pumpTestApp(
|
|||||||
required Widget child,
|
required Widget child,
|
||||||
FakeIgnisApi? api,
|
FakeIgnisApi? api,
|
||||||
SettingsService? settingsService,
|
SettingsService? settingsService,
|
||||||
|
bool remotePollingEnabled = true,
|
||||||
}) async {
|
}) async {
|
||||||
final container = createTestContainer(
|
final container = createTestContainer(
|
||||||
api ?? FakeIgnisApi(),
|
api ?? FakeIgnisApi(),
|
||||||
settingsService: settingsService,
|
settingsService: settingsService,
|
||||||
|
remotePollingEnabled: remotePollingEnabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/screens/homes_screen.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:ignis_app/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -12,8 +12,10 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
await tester.pumpWidget(const ProviderScope(child: IgnisApp()));
|
await tester.pumpWidget(
|
||||||
await tester.pumpAndSettle();
|
const ProviderScope(child: MaterialApp(home: HomesScreen())),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('ДОМА'), findsOneWidget);
|
expect(find.text('ДОМА'), findsOneWidget);
|
||||||
expect(find.text('Нет добавленных домов'), findsOneWidget);
|
expect(find.text('Нет добавленных домов'), findsOneWidget);
|
||||||
|
|||||||
Reference in New Issue
Block a user