Replace geofence polling with native Android geofence

This commit is contained in:
Artem Kokos
2026-05-12 11:23:44 +07:00
parent 0a5ef9af17
commit 1963488479
38 changed files with 1099 additions and 1931 deletions

186
README.md
View File

@@ -1,103 +1,85 @@
# 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-ключом.
- **Группы ламп** -- создание, удаление и управление. При создании группы есть product-валидация, автогенерация `ID`, предупреждение о конфликтах по устройствам и более честный перескан сети.
- **Управление освещением:**
- Включение/выключение
- Яркость 10--100% с шагом 10%
- Цветовая температура 2700--6500K с шагом 100K
- RGB-цвет через HSV-пикер
- Сцены (загружаются с сервера, отображаются с человекочитаемыми названиями)
- Таймер "включить на 4 часа"
- **Расписания** -- одноразовые таймеры с выбором даты/времени и повторяющиеся задачи с выбором дней недели. Просмотр, создание, валидация и отмена активных задач.
- **API-ключи** -- просмотр, создание, отзыв и повторная активация гостевых ключей для администраторов с отдельным UX для только что созданного ключа.
- **Статистика и лог событий** -- просмотр сводки по группам и последних событий сервера.
- **Геофенс и расстояния** -- live-дистанция до дома в UI и опциональное автовыключение света при уходе. Геофенс работает для текущего активного дома, показывает диагностический статус и использует cooldown/re-arm поведение.
- **Устойчивость к ошибкам** -- гранулярные состояния загрузки (`LoadState`), централизованная обработка сетевых сбоев, soft-ошибки при управлении ползунками без спама в UI.
- несколько домов с отдельными URL и API-ключами;
- управление группами света: `on/off`, яркость, температура, RGB, сцены;
- таймер "включить на 4 часа";
- одноразовые и повторяющиеся расписания;
- статистика и лог событий;
- управление гостевыми API-ключами для администратора;
- расстояние до дома и автовыключение света по geofence.
## Гео-автоматизация
Для активного дома приложение может зарегистрировать системный Android geofence. После подтверждённого `EXIT` запускается короткая фоновая задача, которая проверяет группы и выключает только те, что реально включены.
Это не polling каждые 15 минут. Основной триггер здесь событийный:
- geofence регистрируется нативно через Android geofencing API;
- сетевое выключение выполняется отдельным one-off worker;
- при отсутствии координат или выключенной опции geofence не армится.
## Стек
- Flutter 3.x / Dart
- Riverpod -- управление состоянием
- Dio -- HTTP-клиент
- SharedPreferences -- локальное хранение несекретных настроек
- Flutter Secure Storage -- безопасное хранение API-ключей
- Geolocator -- геолокация
- Workmanager -- периодические фоновые задачи
- Flutter Local Notifications -- локальные уведомления
- Flutter / Dart
- Riverpod
- Dio
- SharedPreferences
- flutter_secure_storage
- Geolocator
- Android Geofencing API
- Android WorkManager
## Структура проекта
## Структура
```text
lib/
├── app/
│ ├── app_bootstrap.dart -- bootstrap приложения и навигация
│ ├── build_info.dart -- метаданные сборки (дата, git hash)
│ ├── error_message.dart -- форматирование ошибок API и сети
── load_state.dart -- универсальный стейт загрузки (idle/loading/data/error)
├── main.dart -- точка входа, тема, роутер
├── models/
│ ├── api_key_info.dart -- типизированная модель API-ключа
│ ├── auth_info.dart -- информация об авторизации
── event_log_item.dart -- лог событий
│ ├── home_config.dart -- несекретная конфигурация сервера
│ ├── ignis_device.dart -- устройство умного дома
│ ├── ignis_group.dart -- группа устройств и её состояние
│ ├── ignis_scene.dart -- сцена освещения
│ ├── schedule_task.dart -- задача расписания
│ └── stats_summary.dart -- статистика
├── services/
│ ├── api_client.dart -- HTTP-клиент к Ignis Core API
│ ├── credentials_storage.dart -- безопасное хранение ключей
├── geofence_worker.dart -- фоновая логика геофенса
│ └── settings_service.dart -- хранение списка "домов"
── features/
│ ├── 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
├── app/ # bootstrap, build info, error/load helpers
├── features/ # feature-level providers and logic
│ ├── api_keys/
│ ├── auth/
── groups/
│ ├── homes/
│ ├── remote/
│ ├── schedules/
│ ├── shared/
── stats/
├── models/ # typed domain models
├── providers/ # public provider barrel
├── screens/ # UI screens
├── services/ # API client, settings, credentials
└── widgets/ # reusable UI widgets
android/app/src/main/kotlin/ru/akokos/ignis_app/
├── MainActivity.kt
├── GeofenceAutomationManager.kt
├── GeofenceBroadcastReceiver.kt
├── GeofenceExitWorker.kt
── GeofenceRestoreReceiver.kt
```
## Сборка
## Запуск
```bash
# Зависимости
flutter pub get
# Debug-запуск
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
```
Текущий baseline зелёный: `flutter analyze`, `flutter test` и release APK сборка проходят штатно.
Дополнительно тестами уже прикрыты:
- typed parsing/load-state для основных backend-ответов;
- geofence distance/runtime логика;
- чистая логика форм расписаний и групп;
- provider-мутаторы для расписаний, таймера 4h и API-ключей;
- widget-сценарии форм домов, групп, расписаний и API-ключей;
- widget-сценарии `RemoteScreen`, `GroupCard` и error/retry-потоков.
Сейчас baseline клиента закрывается примерно `60` тестами и уже ловит regressions не только в helper-логике, но и в основных пользовательских сценариях.
Сейчас тестами прикрыты:
- parsing и load-state основных backend-ответов;
- сериализация `HomeConfig` и geofence radius;
- синхронизация активного дома с geofence automation;
- form logic для домов, групп и расписаний;
- provider-мутаторы расписаний, API-ключей и group control;
- widget-сценарии форм, `GroupCard` и error/retry потоков.
## Настройка
При первом запуске приложение попросит добавить "дом" -- указать адрес сервера Ignis и API-ключ. После этого откроется пульт управления группами.
1. Добавить дом: адрес сервера Ignis и API-ключ.
2. При необходимости задать координаты дома.
3. Включить "выключать свет при уходе".
4. Выдать Android-разрешения на геолокацию, включая background location.
Если задать координаты дома, экран домов начнёт показывать расстояние до активного дома. Если дополнительно включить автовыключение при уходе и выдать Android фоновые разрешения на геолокацию и уведомления, приложение сможет в фоне выключать свет при удалении от текущего активного дома.
API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически.
Для добавления второго дома: кнопка "домик" в левом верхнем углу пульта -> экран домов -> кнопка "+".
## Ограничения
API-ключи хранятся отдельно от конфигурации домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически.
## API
Приложение работает с [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-тестами.
- целевая платформа сейчас Android;
- реальное поведение background execution, geofence delivery и OEM battery restrictions подтверждается в основном ручными проверками на устройстве;
- force-stop приложения со стороны Android может ломать автоподъём фоновой логики до следующего ручного запуска.
## Лицензия

View File

@@ -46,4 +46,6 @@ flutter {
dependencies {
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")
}

View File

@@ -3,7 +3,7 @@
<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_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:label="ignis_app"
android:name="${applicationName}"
@@ -35,9 +35,18 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="ignis_geofence" />
<receiver
android:name=".GeofenceBroadcastReceiver"
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>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -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,
)
}
}

View File

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

View File

@@ -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()
}
}
}

View File

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

View File

@@ -1,5 +1,64 @@
package ru.akokos.ignis_app
import io.flutter.embedding.engine.FlutterEngine
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()
}
}
}
}

View File

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

View File

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

View File

@@ -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 недоступен.
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/home_config.dart';
import '../geofence_task_sync.dart';
import '../services/geofence_runtime_store.dart';
import '../../auth/providers/auth_providers.dart';
import '../../shared/providers/core_providers.dart';
@@ -22,6 +20,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
if (state != null) {
await _initApi(state!);
}
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
}
/// Переключиться на другой дом
@@ -30,6 +29,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
await svc.setCurrentHomeId(home.id);
state = home;
await _initApi(home);
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
}
/// Выбрать дом как активный и сразу проверить auth-state.
@@ -41,11 +41,9 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
try {
await switchTo(home);
await ref.read(authInfoProvider.notifier).load(failOnError: true);
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
} catch (error) {
await _restoreSelection(previousHome);
ref.read(authInfoProvider.notifier).restore(previousAuthState);
await syncGeofenceTask(ref.read(homesProvider), currentHome: state);
rethrow;
}
}
@@ -53,7 +51,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
Future<void> clear() async {
await ref.read(settingsServiceProvider).setCurrentHomeId(null);
state = null;
await syncGeofenceTask(ref.read(homesProvider), currentHome: null);
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(null);
}
/// Инициализировать API-клиент текущим домом
@@ -74,6 +72,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
await svc.setCurrentHomeId(home.id);
state = home;
await _initApi(home);
await ref.read(geofenceAutomationServiceProvider).syncActiveHome(state);
}
}
@@ -96,7 +95,6 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
Future<void> remove(String id) async {
await ref.read(settingsServiceProvider).deleteHome(id);
await GeofenceRuntimeStore().removeHome(id);
await load();
}

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ import '../../../services/api_client.dart';
import '../../homes/providers/homes_providers.dart';
import '../../shared/providers/core_providers.dart';
final remotePollingEnabledProvider = Provider<bool>((ref) => true);
final groupsProvider = NotifierProvider<GroupsNotifier, List<IgnisGroup>>(
() => GroupsNotifier(),
);

View File

@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../homes/services/geofence_automation_service.dart';
import '../../../services/api_client.dart';
import '../../../services/settings_service.dart';
@@ -8,3 +9,9 @@ final settingsServiceProvider = Provider((ref) => SettingsService());
/// API-клиент текущего дома. Конфигурация меняется через init().
final apiProvider = Provider((ref) => IgnisApi());
/// Нативная geofence-автоматика Android.
final geofenceAutomationServiceProvider = Provider(
(ref) =>
GeofenceAutomationService(settingsService: ref.read(settingsServiceProvider)),
);

View File

@@ -1,34 +1,11 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:workmanager/workmanager.dart';
import 'app/app_bootstrap.dart';
import 'features/homes/services/geofence_notifications_service.dart';
import 'screens/homes_screen.dart';
import 'screens/remote_screen.dart';
import 'services/geofence_worker.dart';
/// Top-level callback для workmanager (выполняется в отдельном изоляте).
@pragma('vm:entry-point')
void callbackDispatcher() {
DartPluginRegistrant.ensureInitialized();
Workmanager().executeTask((taskName, inputData) async {
if (taskName == geofenceTaskName) {
return await executeGeofenceCheck();
}
return true;
});
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await GeofenceNotificationsService().initialize();
// Инициализация workmanager
Workmanager().initialize(callbackDispatcher);
runApp(const ProviderScope(child: IgnisApp()));
}

View File

@@ -1,12 +1,15 @@
/// Модель "дома" -- один физический сервер Ignis.
/// Содержит только несекретные настройки. API-ключ хранится отдельно.
class HomeConfig {
static const int defaultGeofenceRadiusMeters = 500;
final String id; // уникальный идентификатор (uuid или timestamp)
final String name; // человекочитаемое название ("Квартира", "Дача")
final String url; // адрес сервера (например ignis.akokos.ru)
final double? latitude; // GPS-широта дома (для гео-автоматизации)
final double? longitude; // GPS-долгота дома (для гео-автоматизации)
final bool geofenceEnabled; // автовыключение при уходе из дома
final int geofenceRadiusMeters; // радиус geofence для автодействий
HomeConfig({
required this.id,
@@ -15,6 +18,7 @@ class HomeConfig {
this.latitude,
this.longitude,
this.geofenceEnabled = false,
this.geofenceRadiusMeters = defaultGeofenceRadiusMeters,
});
/// Есть ли координаты у дома
@@ -31,6 +35,7 @@ class HomeConfig {
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
'geofenceEnabled': geofenceEnabled,
'geofenceRadiusMeters': geofenceRadiusMeters,
};
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
@@ -40,6 +45,10 @@ class HomeConfig {
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
geofenceEnabled: json['geofenceEnabled'] as bool? ?? false,
geofenceRadiusMeters:
((json['geofenceRadiusMeters'] as num?)?.toInt() ??
defaultGeofenceRadiusMeters)
.clamp(100, 5000),
);
/// Копирование с изменениями
@@ -49,6 +58,7 @@ class HomeConfig {
double? latitude,
double? longitude,
bool? geofenceEnabled,
int? geofenceRadiusMeters,
bool clearCoordinates = false,
}) => HomeConfig(
id: id,
@@ -60,5 +70,6 @@ class HomeConfig {
geofenceEnabled: clearCoordinates
? false
: (geofenceEnabled ?? this.geofenceEnabled),
geofenceRadiusMeters: geofenceRadiusMeters ?? this.geofenceRadiusMeters,
);
}

View File

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

View File

@@ -22,6 +22,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final _keyCtrl = TextEditingController();
final _latCtrl = TextEditingController();
final _lonCtrl = TextEditingController();
final _radiusCtrl = TextEditingController();
bool _geofenceEnabled = false;
bool _saving = false;
@@ -43,8 +44,11 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
if (widget.home!.longitude != null) {
_lonCtrl.text = widget.home!.longitude.toString();
}
_radiusCtrl.text = widget.home!.geofenceRadiusMeters.toString();
_geofenceEnabled = widget.home!.geofenceEnabled;
_loadApiKey();
} else {
_radiusCtrl.text = HomeConfig.defaultGeofenceRadiusMeters.toString();
}
// Следим за полями координат чтобы обновлять доступность Switch
@@ -79,6 +83,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
_keyCtrl.dispose();
_latCtrl.dispose();
_lonCtrl.dispose();
_radiusCtrl.dispose();
super.dispose();
}
@@ -224,12 +229,34 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _radiusCtrl,
decoration: const InputDecoration(
labelText: 'Радиус geofence, м',
hintText: '500',
helperText: 'Автовыключение сработает после выхода за этот радиус',
prefixIcon: Icon(Icons.radar),
),
keyboardType: TextInputType.number,
validator: (value) {
final normalized = value?.trim() ?? '';
final radius = int.tryParse(normalized);
if (radius == null) {
return 'Введите радиус в метрах';
}
if (radius < 100 || radius > 5000) {
return 'От 100 до 5000 м';
}
return null;
},
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Выключать свет при уходе'),
subtitle: Text(
_hasCoordinates
? 'Автовыключение при удалении на 500 м'
? 'Автовыключение после выхода за радиус geofence'
: 'Задайте координаты для активации',
style: TextStyle(
fontSize: 12,
@@ -253,9 +280,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
const Padding(
padding: EdgeInsets.only(left: 40, bottom: 4),
child: Text(
'Проверка раз в ~15 мин (ограничение Android).\n'
'Работает только для текущего активного дома.\n'
'Нужны фоновые разрешения на геолокацию и уведомления.',
'Использует системный Android geofence, а не polling.\n'
'Нужны фоновые разрешения на геолокацию.',
style: TextStyle(fontSize: 11, color: Colors.white24),
),
),
@@ -300,8 +327,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final key = _keyCtrl.text.trim();
final latText = _latCtrl.text.trim();
final lonText = _lonCtrl.text.trim();
final radiusText = _radiusCtrl.text.trim();
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty) {
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty || radiusText.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Заполните все обязательные поля')),
);
@@ -348,6 +376,14 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
}
}
final radiusMeters = int.tryParse(radiusText);
if (radiusMeters == null || radiusMeters < 100 || radiusMeters > 5000) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Радиус geofence должен быть от 100 до 5000 м')),
);
return;
}
setState(() => _saving = true);
final clearCoords = latText.isEmpty && lonText.isEmpty;
@@ -359,6 +395,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
latitude: lat,
longitude: lon,
geofenceEnabled: clearCoords ? false : _geofenceEnabled,
geofenceRadiusMeters: radiusMeters,
clearCoordinates: clearCoords,
)
: HomeConfig(
@@ -368,6 +405,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
latitude: lat,
longitude: lon,
geofenceEnabled: _geofenceEnabled,
geofenceRadiusMeters: radiusMeters,
);
try {
@@ -384,13 +422,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
await ref.read(currentHomeProvider.notifier).select(home);
}
// Синхронизировать фоновый таск с новыми настройками
final allHomes = ref.read(homesProvider);
await syncGeofenceTask(
allHomes,
currentHome: ref.read(currentHomeProvider),
);
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../features/homes/models/geofence_diagnostics.dart';
import '../models/home_config.dart';
import '../providers/providers.dart';
import '../widgets/build_info_text.dart';
@@ -21,6 +20,7 @@ class HomesScreen extends ConsumerStatefulWidget {
class _HomesScreenState extends ConsumerState<HomesScreen>
with WidgetsBindingObserver {
late final UserLocationNotifier _userLocationNotifier;
bool _isWatchingLocation = false;
String? _switchingHomeId;
String? _deletingHomeId;
@@ -30,15 +30,17 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
WidgetsBinding.instance.addObserver(this);
_userLocationNotifier = ref.read(userLocationProvider.notifier);
Future.microtask(() async {
await _userLocationNotifier.startWatching();
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
await _syncLocationWatching();
await _syncGeofenceAutomation();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_userLocationNotifier.stopWatching();
if (_isWatchingLocation) {
_userLocationNotifier.stopWatching();
}
super.dispose();
}
@@ -54,10 +56,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
final homes = ref.watch(homesProvider);
final currentHome = ref.watch(currentHomeProvider);
final location = ref.watch(userLocationProvider);
final geofenceState = ref.watch(geofenceDiagnosticsProvider);
final activeDistanceKm = currentHome == null
? null
: location.distanceToKm(currentHome.latitude, currentHome.longitude);
return Scaffold(
appBar: AppBar(
@@ -69,101 +67,90 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
Expanded(
child: homes.isEmpty
? const _EmptyHomesView()
: ListView(
padding: const EdgeInsets.all(12),
children: [
_HomesOverviewCard(
location: location,
diagnostics: geofenceState.data,
activeHome: currentHome,
activeDistanceKm: activeDistanceKm,
onRefresh: _refreshEnvironmentState,
onRequestLocationPermission: _requestLocationPermission,
onRequestBackgroundPermission:
_requestGeofenceBackgroundPermission,
onRequestNotificationsPermission:
_requestGeofenceNotificationPermission,
onOpenAppSettings: _openRelevantAppSettings,
onOpenLocationSettings: _openLocationSettings,
),
const SizedBox(height: 14),
...homes.map((home) {
final isActive = currentHome?.id == home.id;
final isSwitching = _switchingHomeId == home.id;
final isDeleting = _deletingHomeId == home.id;
final isBusy = isSwitching || isDeleting;
final distKm = location.distanceToKm(
home.latitude,
home.longitude,
);
: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: _refreshEnvironmentState,
child: ListView(
padding: const EdgeInsets.all(12),
children: [
...homes.map((home) {
final isActive = currentHome?.id == home.id;
final isSwitching = _switchingHomeId == home.id;
final isDeleting = _deletingHomeId == home.id;
final isBusy = isSwitching || isDeleting;
final distKm = location.distanceToKm(
home.latitude,
home.longitude,
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
enabled: !isBusy,
leading: Icon(
Icons.home,
color: isActive
? Colors.deepOrange
: Colors.white38,
size: 28,
),
title: Text(
home.name,
style: TextStyle(
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
enabled: !isBusy,
leading: Icon(
Icons.home,
color: isActive
? Colors.deepOrange
: Colors.white,
: Colors.white38,
size: 28,
),
),
subtitle: _HomeSubtitle(
home: home,
location: location,
distKm: distKm,
isActive: isActive,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isBusy)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
title: Text(
home.name,
style: TextStyle(
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
color: isActive
? Colors.deepOrange
: Colors.white,
),
),
subtitle: _HomeSubtitle(
home: home,
location: location,
distKm: distKm,
isActive: isActive,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isBusy)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
else ...[
IconButton(
icon: const Icon(
Icons.edit,
size: 20,
color: Colors.white38,
),
onPressed: () => _editHome(context, home),
),
)
else ...[
IconButton(
icon: const Icon(
Icons.edit,
size: 20,
color: Colors.white38,
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 20,
color: Colors.redAccent,
),
onPressed: () =>
_confirmDelete(context, home),
),
onPressed: () => _editHome(context, home),
),
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 20,
color: Colors.redAccent,
),
onPressed: () =>
_confirmDelete(context, home),
),
],
],
],
),
onTap: isBusy
? null
: () => _selectHome(context, home),
),
onTap: isBusy
? null
: () => _selectHome(context, home),
),
);
}),
],
);
}),
],
),
),
),
const SafeArea(
@@ -264,10 +251,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
if (deletedCurrentHome) {
ref.read(authInfoProvider.notifier).clear();
}
await syncGeofenceTask(
ref.read(homesProvider),
currentHome: ref.read(currentHomeProvider),
);
await _refreshEnvironmentState();
} catch (e) {
if (context.mounted) {
@@ -285,42 +268,33 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
}
Future<void> _refreshEnvironmentState() async {
await _userLocationNotifier.refresh();
await _refreshGeofenceDiagnostics();
await _syncLocationWatching();
if (_isWatchingLocation) {
await _userLocationNotifier.refresh();
}
await _syncGeofenceAutomation();
}
Future<void> _refreshGeofenceDiagnostics() async {
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
}
Future<void> _requestLocationPermission() async {
await _userLocationNotifier.requestPermission();
await _refreshGeofenceDiagnostics();
}
Future<void> _openLocationSettings() async {
await _userLocationNotifier.openLocationSettings();
}
Future<void> _requestGeofenceBackgroundPermission() async {
Future<void> _syncGeofenceAutomation() async {
await ref
.read(geofenceDiagnosticsProvider.notifier)
.requestBackgroundLocationPermission();
await _userLocationNotifier.refresh();
.read(geofenceAutomationServiceProvider)
.syncActiveHome(ref.read(currentHomeProvider));
}
Future<void> _requestGeofenceNotificationPermission() async {
await ref
.read(geofenceDiagnosticsProvider.notifier)
.requestNotificationPermission();
}
Future<void> _openRelevantAppSettings() async {
if (ref.read(userLocationProvider).needsAppSettings) {
await _userLocationNotifier.openAppSettings();
Future<void> _syncLocationWatching() async {
final shouldWatch = ref.read(homesProvider).any((home) => home.hasCoordinates);
if (shouldWatch == _isWatchingLocation) {
return;
}
await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings();
if (shouldWatch) {
await _userLocationNotifier.startWatching();
_isWatchingLocation = true;
return;
}
_userLocationNotifier.stopWatching();
_isWatchingLocation = false;
}
}
@@ -401,19 +375,19 @@ class _HomeSubtitle extends StatelessWidget {
),
)
else if (home.geofenceReady && isActive)
const Padding(
padding: EdgeInsets.only(top: 2),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(
const Icon(
Icons.shield_moon_outlined,
size: 12,
color: Colors.deepOrangeAccent,
),
SizedBox(width: 4),
const SizedBox(width: 4),
Text(
'Geofence включён',
style: TextStyle(
'Автовыключение: ${home.geofenceRadiusMeters} м',
style: const TextStyle(
color: Colors.deepOrangeAccent,
fontSize: 11,
),
@@ -422,19 +396,19 @@ class _HomeSubtitle extends StatelessWidget {
),
)
else if (home.geofenceReady)
const Padding(
padding: EdgeInsets.only(top: 2),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(
const Icon(
Icons.shield_moon_outlined,
size: 12,
color: Colors.white24,
),
SizedBox(width: 4),
const SizedBox(width: 4),
Text(
'Geofence включён',
style: TextStyle(color: Colors.white24, fontSize: 11),
'Автовыключение: ${home.geofenceRadiusMeters} м',
style: const TextStyle(color: Colors.white24, fontSize: 11),
),
],
),
@@ -443,214 +417,3 @@ class _HomeSubtitle extends StatelessWidget {
);
}
}
class _HomesOverviewCard extends StatelessWidget {
final UserLocation location;
final GeofenceDiagnostics diagnostics;
final HomeConfig? activeHome;
final double? activeDistanceKm;
final Future<void> Function() onRefresh;
final Future<void> Function() onRequestLocationPermission;
final Future<void> Function() onRequestBackgroundPermission;
final Future<void> Function() onRequestNotificationsPermission;
final Future<void> Function() onOpenAppSettings;
final Future<void> Function() onOpenLocationSettings;
const _HomesOverviewCard({
required this.location,
required this.diagnostics,
required this.activeHome,
required this.activeDistanceKm,
required this.onRefresh,
required this.onRequestLocationPermission,
required this.onRequestBackgroundPermission,
required this.onRequestNotificationsPermission,
required this.onOpenAppSettings,
required this.onOpenLocationSettings,
});
@override
Widget build(BuildContext context) {
final title = activeHome == null
? 'Статус автоматизации'
: 'Активный дом: ${activeHome!.name}';
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_overviewIcon(location, diagnostics),
color: _overviewColor(location, diagnostics),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
Text(
_overviewPrimaryText(location, diagnostics),
style: const TextStyle(color: Colors.white70),
),
if (_overviewSecondaryText(location, diagnostics, activeDistanceKm)
case final secondary?)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
secondary,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Обновить'),
),
if (location.canRequestPermission)
OutlinedButton.icon(
onPressed: onRequestLocationPermission,
icon: const Icon(Icons.my_location),
label: const Text('Разрешить геолокацию'),
),
if (location.needsLocationSettings)
OutlinedButton.icon(
onPressed: onOpenLocationSettings,
icon: const Icon(Icons.location_searching),
label: const Text('Включить GPS'),
),
if (diagnostics.canRequestBackgroundLocation)
OutlinedButton.icon(
onPressed: onRequestBackgroundPermission,
icon: const Icon(Icons.shield_moon_outlined),
label: const Text('Доступ всегда'),
),
if (diagnostics.canRequestNotifications)
OutlinedButton.icon(
onPressed: onRequestNotificationsPermission,
icon: const Icon(Icons.notifications_active_outlined),
label: const Text('Уведомления'),
),
if (location.needsAppSettings ||
diagnostics.canRequestBackgroundLocation)
OutlinedButton.icon(
onPressed: onOpenAppSettings,
icon: const Icon(Icons.settings),
label: const Text('Настройки'),
),
],
),
],
),
),
);
}
}
String _overviewPrimaryText(
UserLocation location,
GeofenceDiagnostics diagnostics,
) {
if (!location.hasPosition) {
return location.error ?? 'Позиция устройства пока недоступна.';
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready =>
'Расстояние считается, geofence активен и готов выключить свет.',
GeofenceStatusKind.triggered =>
'Расстояние считается, geofence уже сработал для активного дома.',
GeofenceStatusKind.cooldown =>
'Расстояние считается, но после сбоя geofence сейчас на паузе.',
GeofenceStatusKind.notificationsPermissionDenied =>
'Расстояние считается, но уведомления для geofence сейчас запрещены.',
GeofenceStatusKind.backgroundPermissionDenied =>
'Расстояние считается, но без доступа "Всегда" geofence в фоне будет кастрирован.',
GeofenceStatusKind.locationServicesDisabled =>
'Геолокация выключена, поэтому и расстояния, и geofence сейчас мёртвые.',
GeofenceStatusKind.locationPermissionDenied =>
'Без разрешения на геолокацию тут нечего считать.',
GeofenceStatusKind.disabled =>
'Расстояние считается, но geofence для активного дома выключен.',
GeofenceStatusKind.missingCoordinates =>
'Расстояние считается, но у активного дома нет координат для geofence.',
GeofenceStatusKind.noActiveHome =>
'Выбери активный дом, и тогда автоматика станет осмысленной.',
};
}
String? _overviewSecondaryText(
UserLocation location,
GeofenceDiagnostics diagnostics,
double? activeDistanceKm,
) {
final parts = <String>[];
if (activeDistanceKm != null) {
parts.add('До активного дома: ${formatDistance(activeDistanceKm)}.');
}
if (location.updatedAt != null) {
parts.add('Точка: ${_formatTimestamp(location.updatedAt!)}.');
}
final secondary = diagnostics.secondaryMessage;
if (secondary != null && secondary.isNotEmpty) {
parts.add(secondary);
}
if (parts.isEmpty) return null;
return parts.join(' ');
}
IconData _overviewIcon(UserLocation location, GeofenceDiagnostics diagnostics) {
if (!location.hasPosition) {
return Icons.location_off_outlined;
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready => Icons.shield_moon_outlined,
GeofenceStatusKind.triggered => Icons.check_circle_outline,
GeofenceStatusKind.cooldown => Icons.timer_outlined,
GeofenceStatusKind.notificationsPermissionDenied =>
Icons.notifications_off_outlined,
GeofenceStatusKind.backgroundPermissionDenied => Icons.location_searching,
_ => Icons.info_outline,
};
}
Color _overviewColor(UserLocation location, GeofenceDiagnostics diagnostics) {
if (!location.hasPosition) {
return Colors.redAccent;
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready => Colors.greenAccent,
GeofenceStatusKind.triggered => Colors.deepOrangeAccent,
GeofenceStatusKind.cooldown => Colors.amberAccent,
GeofenceStatusKind.notificationsPermissionDenied ||
GeofenceStatusKind.backgroundPermissionDenied => Colors.deepOrangeAccent,
_ => Colors.white54,
};
}
String _formatTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
final second = local.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}

View File

@@ -28,7 +28,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
void initState() {
super.initState();
_groupsNotifier = ref.read(groupsProvider.notifier);
Future.microtask(_groupsNotifier.startPolling);
if (ref.read(remotePollingEnabledProvider)) {
Future.microtask(_groupsNotifier.startPolling);
}
}
@override

View File

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

View File

@@ -137,14 +137,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
dio:
dependency: "direct main"
description:
@@ -214,38 +206,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -384,14 +344,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -853,14 +805,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.16"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data:
dependency: transitive
description:
@@ -941,38 +885,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:

View File

@@ -38,8 +38,6 @@ dependencies:
flutter_riverpod: ^3.3.1
shared_preferences: ^2.5.5
geolocator: ^13.0.2
workmanager: ^0.9.0+3
flutter_local_notifications: ^19.0.0
flutter_secure_storage: ^10.0.0
dev_dependencies:
@@ -101,4 +99,4 @@ flutter_launcher_icons:
ios: true
image_path: "assets/icon.png"
adaptive_icon_background: "#1A1A1A"
adaptive_icon_foreground: "assets/icon.png"
adaptive_icon_foreground: "assets/icon.png"

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

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.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/group_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', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
SharedPreferences.setMockInitialValues({});
final settingsService = SettingsService(
credentialsStorage: InMemoryCredentialsStorage(),
@@ -170,9 +176,6 @@ void main() {
);
await tester.pump();
await tester.tap(find.text('Выключать свет при уходе'));
await tester.pump();
await tester.ensureVisible(saveHomeButton);
await tester.tap(saveHomeButton);
await tester.pumpAndSettle();
@@ -185,7 +188,8 @@ void main() {
expect(savedHome.url, 'https://ignis.akokos.ru');
expect(savedHome.latitude, 55.75);
expect(savedHome.longitude, 37.61);
expect(savedHome.geofenceEnabled, isTrue);
expect(savedHome.geofenceEnabled, isFalse);
expect(savedHome.geofenceRadiusMeters, HomeConfig.defaultGeofenceRadiusMeters);
expect(savedApiKey, 'secret-key');
});
}

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

View File

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

View File

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

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

View File

@@ -1,90 +1,14 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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:shared_preferences/shared_preferences.dart';
import 'test_support.dart';
void main() {
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', (
tester,
) async {
@@ -180,36 +104,3 @@ void main() {
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;
}

View File

@@ -335,8 +335,12 @@ class FakeIgnisApi extends IgnisApi {
ProviderContainer createTestContainer(
FakeIgnisApi api, {
SettingsService? settingsService,
bool remotePollingEnabled = true,
}) {
final overrides = [apiProvider.overrideWithValue(api)];
final overrides = [
apiProvider.overrideWithValue(api),
remotePollingEnabledProvider.overrideWithValue(remotePollingEnabled),
];
if (settingsService != null) {
overrides.add(settingsServiceProvider.overrideWithValue(settingsService));
}
@@ -351,10 +355,12 @@ Future<ProviderContainer> pumpTestApp(
required Widget child,
FakeIgnisApi? api,
SettingsService? settingsService,
bool remotePollingEnabled = true,
}) async {
final container = createTestContainer(
api ?? FakeIgnisApi(),
settingsService: settingsService,
remotePollingEnabled: remotePollingEnabled,
);
await tester.pumpWidget(

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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:ignis_app/main.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -12,8 +12,10 @@ void main() {
) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const ProviderScope(child: IgnisApp()));
await tester.pumpAndSettle();
await tester.pumpWidget(
const ProviderScope(child: MaterialApp(home: HomesScreen())),
);
await tester.pump();
expect(find.text('ДОМА'), findsOneWidget);
expect(find.text('Нет добавленных домов'), findsOneWidget);