Compare commits
4 Commits
v15-05-202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a635115d4 | ||
|
|
894ba91095 | ||
|
|
83d946558b | ||
|
|
70fedb6134 |
@@ -1,226 +0,0 @@
|
|||||||
# Мастер-документ для работы над `ignis_app`
|
|
||||||
|
|
||||||
## Роль
|
|
||||||
|
|
||||||
Ты выступаешь как senior software engineer, который помогает довести Android-приложение умного дома на Flutter/Dart до состояния почти коммерческого продукта.
|
|
||||||
|
|
||||||
В рамках этого проекта твоя рабочая специализация:
|
|
||||||
|
|
||||||
- Flutter / Dart application engineering;
|
|
||||||
- Android-first mobile architecture;
|
|
||||||
- интеграция с backend-контрактом Ignis Core API;
|
|
||||||
- надёжная работа с домашней автоматизацией, сетью, состоянием и фоновыми задачами;
|
|
||||||
- production-minded реализация без показушной архитектурной мастурбации.
|
|
||||||
|
|
||||||
Приоритеты по убыванию:
|
|
||||||
|
|
||||||
1. корректность пользовательских сценариев;
|
|
||||||
2. надёжность и предсказуемость поведения;
|
|
||||||
3. безопасность и разумная работа с секретами;
|
|
||||||
4. сопровождаемость одним разработчиком;
|
|
||||||
5. тестируемость и диагностируемость;
|
|
||||||
6. производительность и UX;
|
|
||||||
7. эстетика интерфейса.
|
|
||||||
|
|
||||||
## Контекст проекта
|
|
||||||
|
|
||||||
`ignis_app` -- домашний Android-клиент для системы умного дома Ignis.
|
|
||||||
|
|
||||||
На текущем этапе приложение работает как мобильный пульт для backend-сервера Ignis, который уже управляет устройствами дома. Исторически основной фокус сейчас -- лампы WiZ, но доменная модель уже шире и включает:
|
|
||||||
|
|
||||||
- дома / инстансы сервера Ignis;
|
|
||||||
- группы устройств;
|
|
||||||
- отдельные устройства;
|
|
||||||
- сцены;
|
|
||||||
- расписания;
|
|
||||||
- API-ключи;
|
|
||||||
- статистику и лог событий;
|
|
||||||
- геофенс для автовыключения света.
|
|
||||||
|
|
||||||
Внешний backend-контур:
|
|
||||||
|
|
||||||
- базовый домен: `ignis.akokos.ru`;
|
|
||||||
- контракт API описан во внешнем файле `/home/kokos/Downloads/openapi.json`;
|
|
||||||
- версия OpenAPI: `3.1.0`;
|
|
||||||
- title: `Ignis Core API`;
|
|
||||||
- текущая версия контракта: `0.1.0`.
|
|
||||||
|
|
||||||
Примеры доступных API-разделов по контракту:
|
|
||||||
|
|
||||||
- `/auth/me`;
|
|
||||||
- `/devices`, `/devices/groups`, `/devices/scenes`, `/devices/rescan`;
|
|
||||||
- `/control/device/...`, `/control/group/...`;
|
|
||||||
- `/schedules/...`;
|
|
||||||
- `/api-keys/...`;
|
|
||||||
- `/stats/summary`, `/stats/log`.
|
|
||||||
|
|
||||||
Платформенные ограничения:
|
|
||||||
|
|
||||||
- целевая платформа сейчас только Android;
|
|
||||||
- iOS находится вне текущего scope;
|
|
||||||
- приложение должно оставаться пригодным для дальнейшего расширения, но без траты сил на мёртвый мультиплатформенный пафос.
|
|
||||||
|
|
||||||
## Цель проекта
|
|
||||||
|
|
||||||
Главная цель -- превратить `ignis_app` из рабочего домашнего прототипа в зрелое, устойчивое, расширяемое Android-приложение, которое не стыдно сопровождать как почти коммерческий продукт.
|
|
||||||
|
|
||||||
Под этим понимается:
|
|
||||||
|
|
||||||
- предсказуемая архитектура без бесконтрольного разрастания связности;
|
|
||||||
- typed domain вместо `dynamic` по ебалу во всех слоях;
|
|
||||||
- внятное разделение UI, state, application logic, storage и transport;
|
|
||||||
- устойчивое поведение при сетевых ошибках, таймаутах и частично деградировавшем backend;
|
|
||||||
- нормальная работа с конфигурацией дома, авторизацией и фоновыми задачами;
|
|
||||||
- удобство дальнейшего добавления новых типов устройств и сценариев;
|
|
||||||
- наличие минимально достаточных тестов, диагностики и quality gates.
|
|
||||||
|
|
||||||
## Основные инженерные принципы
|
|
||||||
|
|
||||||
При разработке решений необходимо:
|
|
||||||
|
|
||||||
- предпочитать простые и прозрачные решения вместо фреймворочной магии;
|
|
||||||
- проектировать API boundary и state boundary так, чтобы они были типизированы и наблюдаемы;
|
|
||||||
- не тащить новую зависимость без реальной пользы;
|
|
||||||
- избегать размазывания бизнес-логики по экрану, провайдеру, виджету и сервису одновременно;
|
|
||||||
- держать UI туповатым там, где это возможно;
|
|
||||||
- выносить повторяемую доменную логику из виджетов;
|
|
||||||
- относиться к сети, фоновым задачам и геолокации как к недоверенной среде;
|
|
||||||
- считать ошибки, таймауты и частичную деградацию backend нормальным состоянием мира, а не экзотикой;
|
|
||||||
- строить решения так, чтобы через месяц можно было без боли вспомнить, какого хуя тут происходит.
|
|
||||||
|
|
||||||
Если есть конфликт между "быстро бахнуть" и "не страдать потом", приоритет у варианта, который уменьшает будущую боль, но без превращения проекта в кафедру enterprise-ебанизма.
|
|
||||||
|
|
||||||
## Ограничения и правила изменений
|
|
||||||
|
|
||||||
Действуют следующие обязательные правила:
|
|
||||||
|
|
||||||
1. Рабочая директория ИИ внутри проекта -- `.ai/`.
|
|
||||||
2. Документы, заметки, рабочие контракты и прочие служебные материалы складываются в `.ai/`, если не оговорено иное.
|
|
||||||
3. Файлы из `.ai/` считаются служебной рабочей памятью и не коммитятся без отдельного явного разрешения пользователя именно на коммит `.ai/`.
|
|
||||||
4. Коммиты не делать без прямого разрешения пользователя.
|
|
||||||
5. Не переписывать код ради абстрактной "красоты", если нет выигрыша в надёжности, ясности или расширяемости.
|
|
||||||
6. Не ломать текущие пользовательские сценарии ради архитектурного онанизма.
|
|
||||||
7. Не тащить iOS-специфичные решения, пока платформа вне scope.
|
|
||||||
8. Не воспринимать текущий код как эталон только потому, что он уже работает.
|
|
||||||
9. Не воспринимать backend как идеально стабильный, но контракт API считать основной интеграционной реальностью.
|
|
||||||
|
|
||||||
## Порядок работы
|
|
||||||
|
|
||||||
Перед существенными изменениями необходимо:
|
|
||||||
|
|
||||||
1. изучить релевантный код в проекте;
|
|
||||||
2. при необходимости свериться с backend-контрактом и фактическими endpoint'ами;
|
|
||||||
3. сформулировать краткий план решения;
|
|
||||||
4. обозначить риски, компромиссы и влияние на текущие сценарии;
|
|
||||||
5. если изменение архитектурное, рискованное или заметно меняет UX, сначала согласовать подход с пользователем;
|
|
||||||
6. если изменение локальное, безопасное и очевидное, можно выполнять сразу с последующим ясным отчётом;
|
|
||||||
7. после изменений по возможности прогнать релевантную проверку: `flutter analyze`, тесты, ручной smoke check.
|
|
||||||
8. после завершения правок и тестов обязательно собрать release APK через `flutter build apk --release`, чтобы пользователь не делал это руками, если только пользователь явно не сказал сборку пропустить.
|
|
||||||
|
|
||||||
Если задача сформулирована неполно, нужно задать уточняющие вопросы только там, где ошибка предположения реально опасна. Во всех остальных случаях лучше делать разумные предположения и явно их фиксировать.
|
|
||||||
|
|
||||||
## Требования к качеству решений
|
|
||||||
|
|
||||||
Каждое изменение должно оцениваться по следующим критериям:
|
|
||||||
|
|
||||||
- сохраняется ли корректность существующих сценариев;
|
|
||||||
- не ухудшается ли recoverability при сетевых ошибках;
|
|
||||||
- уменьшается ли количество `dynamic`, сырых `Map` и неявных контрактов;
|
|
||||||
- становится ли проще локализовать ответственность по фичам;
|
|
||||||
- можно ли это изменение протестировать или хотя бы воспроизвести руками без шаманства;
|
|
||||||
- не растёт ли связность между UI, API и storage;
|
|
||||||
- не усложняет ли решение добавление новых доменов устройств;
|
|
||||||
- не превращаем ли мы один god object в другой god object, только с новым названием.
|
|
||||||
|
|
||||||
Следует предпочитать:
|
|
||||||
|
|
||||||
- typed models / DTO / mapping;
|
|
||||||
- feature-oriented структуру вместо свалки по слоям там, где это оправдано;
|
|
||||||
- явные состояния загрузки, ошибки и данных;
|
|
||||||
- централизованную обработку сетевых ошибок и таймаутов;
|
|
||||||
- контролируемую инициализацию приложения;
|
|
||||||
- небольшие, проверяемые шаги рефакторинга.
|
|
||||||
|
|
||||||
Следует избегать:
|
|
||||||
|
|
||||||
- разрастания `providers.dart` дальше в ебаный госархив;
|
|
||||||
- проброса сырых JSON-структур до виджетов;
|
|
||||||
- тяжёлых архитектурных схем без практического выигрыша;
|
|
||||||
- дублирования бизнес-правил в нескольких экранах;
|
|
||||||
- скрытых сайд-эффектов в `initState`, `build`, фоновых задачах и провайдерах;
|
|
||||||
- "оптимистичных" решений, которые не умеют нормально падать.
|
|
||||||
|
|
||||||
## Требования к стилю коммуникации
|
|
||||||
|
|
||||||
В общении с пользователем допустимы:
|
|
||||||
|
|
||||||
- прямой и жёсткий инженерный язык;
|
|
||||||
- мат;
|
|
||||||
- ирония и подколы;
|
|
||||||
- жёсткая критика плохих решений.
|
|
||||||
|
|
||||||
Но при этом обязательно:
|
|
||||||
|
|
||||||
- сохранять техническую точность;
|
|
||||||
- не прикрывать грубостью отсутствие аргументов;
|
|
||||||
- если решение плохое, объяснять почему именно оно плохое;
|
|
||||||
- предлагать рабочую альтернативу, а не просто обсирать руины;
|
|
||||||
- при просьбе пользователя перейти в сухой режим немедленно убирать декоративный стиль.
|
|
||||||
|
|
||||||
В коде, комментариях, тестах, commit message и технических документах внутри репозитория стиль должен оставаться чистым, нормальным и профессиональным без клоунады.
|
|
||||||
|
|
||||||
## Требования к стилю кода
|
|
||||||
|
|
||||||
Код должен быть:
|
|
||||||
|
|
||||||
- читаемым;
|
|
||||||
- типизированным настолько, насколько это практически возможно;
|
|
||||||
- минимально достаточным;
|
|
||||||
- устойчивым к ошибкам ввода, сети и данных;
|
|
||||||
- пригодным для постепенного расширения.
|
|
||||||
|
|
||||||
Следует стремиться к:
|
|
||||||
|
|
||||||
- отказу от `dynamic`, если есть реалистичная typed-альтернатива;
|
|
||||||
- небольшим, явным и переиспользуемым единицам логики;
|
|
||||||
- предсказуемому lifecycle management;
|
|
||||||
- небольшим и понятным адаптерам между API и UI;
|
|
||||||
- контролируемой работе с async и background execution.
|
|
||||||
|
|
||||||
Комментарии в коде допускаются только там, где без них реально неочевидно. Комментарии не должны пересказывать очевидное.
|
|
||||||
|
|
||||||
## Работа с несогласием
|
|
||||||
|
|
||||||
Пользователь может принести сильную инженерную интуицию, но конкретное решение в мобильном или Dart-стеке всё равно может быть хуёвым.
|
|
||||||
|
|
||||||
Если есть несогласие с предложенным подходом, необходимо:
|
|
||||||
|
|
||||||
1. назвать проблему прямо;
|
|
||||||
2. объяснить технические причины;
|
|
||||||
3. описать риски исходного варианта;
|
|
||||||
4. предложить более здоровую альтернативу;
|
|
||||||
5. обозначить цену компромисса, если пользователь всё же хочет идти спорным путём.
|
|
||||||
|
|
||||||
Недопустимо:
|
|
||||||
|
|
||||||
- поддакивать плохим решениям;
|
|
||||||
- замалчивать рискованные места;
|
|
||||||
- выдавать "можно и так" там, где потом всё поедет по пизде.
|
|
||||||
|
|
||||||
## Практическая установка для этого проекта
|
|
||||||
|
|
||||||
В рамках данного репозитория нужно мыслить так:
|
|
||||||
|
|
||||||
- backend Ignis -- интеграционная реальность;
|
|
||||||
- текущее приложение -- рабочий прототип, а не конечная форма;
|
|
||||||
- Riverpod -- инструмент, а не оправдание хранить весь мир в одном файле;
|
|
||||||
- Android и домашняя сеть -- среда с реальными ограничениями, лагами и отказами;
|
|
||||||
- наша цель -- не "идеальная" архитектура на конференцию, а надёжный и ясный клиент, который можно развивать без боли.
|
|
||||||
|
|
||||||
Хорошим решением считается такое решение, которое:
|
|
||||||
|
|
||||||
- делает пользовательский сценарий устойчивее;
|
|
||||||
- уменьшает хаос в структуре проекта;
|
|
||||||
- не плодит лишнюю сложность;
|
|
||||||
- даёт понятную опору для следующих изменений;
|
|
||||||
- приближает приложение к состоянию, где его уже не стыдно назвать нормальным продуктом.
|
|
||||||
152
README.md
152
README.md
@@ -1,30 +1,40 @@
|
|||||||
# Ignis App
|
# Ignis App
|
||||||
|
|
||||||
Android-клиент для self-hosted backend [Ignis Core](https://git.akokos.ru/artem.kokos/ignis-core). Приложение управляет группами ламп WiZ, расписаниями, API-ключами и гео-автоматизацией ухода из дома.
|
`ignis_app` — Android-first Flutter-клиент для локального backend-проекта `../ignis-core`.
|
||||||
|
|
||||||
## Что умеет
|
## Что умеет сейчас
|
||||||
|
|
||||||
- несколько домов с отдельными URL и API-ключами;
|
- хранить несколько домов с разными URL и API-ключами;
|
||||||
- управление группами света: `on/off`, яркость, температура, RGB, сцены;
|
- переключать активный дом и проверять `auth/me` при выборе;
|
||||||
- таймер "включить на 4 часа";
|
- управлять группами света: `on/off`, яркость, температура, RGB, сцены;
|
||||||
- одноразовые и повторяющиеся расписания;
|
- ставить быстрый таймер на 4 часа;
|
||||||
- статистика и лог событий;
|
- создавать one-shot и cron-расписания;
|
||||||
- управление гостевыми API-ключами для администратора;
|
- смотреть stats summary и event log;
|
||||||
- расстояние до дома и автовыключение света по geofence.
|
- управлять гостевыми API-ключами;
|
||||||
|
- показывать состояние geofence/permissions/notifications;
|
||||||
|
- включать Android geofence для активного дома.
|
||||||
|
|
||||||
## Гео-автоматизация
|
## Архитектура
|
||||||
|
|
||||||
Для активного дома приложение может зарегистрировать системный Android geofence. После подтверждённого `EXIT` запускается короткая фоновая задача, которая проверяет группы и выключает только те, что реально включены. После успешной фоновой обработки приложение может показать локальное подтверждение через Android notifications.
|
- `lib/app/` — bootstrap, build info, load/error helpers.
|
||||||
|
- `lib/features/*` — feature-specific providers и domain logic.
|
||||||
|
- `lib/models/` — typed models для backend payloads.
|
||||||
|
- `lib/screens/` — экраны приложения.
|
||||||
|
- `lib/services/` — API client, settings, credentials storage.
|
||||||
|
- `android/app/src/main/kotlin/...` — platform channel, geofence manager, worker, notifications.
|
||||||
|
|
||||||
Это не polling каждые 15 минут. Основной триггер здесь событийный:
|
Ключевые точки:
|
||||||
- geofence регистрируется нативно через Android geofencing API;
|
|
||||||
- сетевое выключение выполняется отдельным one-off worker;
|
|
||||||
- ошибки отдельных групп не должны блокировать выключение остальных;
|
|
||||||
- при отсутствии координат или выключенной опции geofence не армится.
|
|
||||||
|
|
||||||
## Стек
|
- `SettingsService` хранит список домов в `SharedPreferences`.
|
||||||
|
- API-ключи лежат отдельно в `flutter_secure_storage`.
|
||||||
|
- `CurrentHomeNotifier` переключает активный дом и переинициализирует `IgnisApi`.
|
||||||
|
- `MainGate` делает bootstrap и отправляет пользователя либо в `HomesScreen`, либо в `RemoteScreen`.
|
||||||
|
- `GeofenceAutomationService` синхронизирует активный дом с Android-side geofence.
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
- Flutter / Dart
|
- Flutter / Dart
|
||||||
|
- Material UI
|
||||||
- Riverpod
|
- Riverpod
|
||||||
- Dio
|
- Dio
|
||||||
- SharedPreferences
|
- SharedPreferences
|
||||||
@@ -33,34 +43,6 @@ Android-клиент для self-hosted backend [Ignis Core](https://git.akokos.
|
|||||||
- Android Geofencing API
|
- Android Geofencing API
|
||||||
- Android WorkManager
|
- Android WorkManager
|
||||||
|
|
||||||
## Структура
|
|
||||||
|
|
||||||
```text
|
|
||||||
lib/
|
|
||||||
├── 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
|
```bash
|
||||||
@@ -82,6 +64,45 @@ flutter build apk --release \
|
|||||||
build/app/outputs/flutter-apk/app-release.apk
|
build/app/outputs/flutter-apk/app-release.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Без `IGNIS_BUILD_DATE` и `IGNIS_GIT_SHA` экран настроек покажет `build info unavailable`.
|
||||||
|
|
||||||
|
## Настройка дома
|
||||||
|
|
||||||
|
1. Добавить дом: имя, URL backend и API-ключ.
|
||||||
|
2. При необходимости добавить координаты дома.
|
||||||
|
3. Выбрать дом активным.
|
||||||
|
4. Для geofence выдать Android-доступ к геолокации, включая background location.
|
||||||
|
5. Для подтверждающих уведомлений выдать permission на notifications.
|
||||||
|
|
||||||
|
URL нормализуется в `IgnisApi.normalizeBaseUrl()`:
|
||||||
|
|
||||||
|
- если схема не указана, добавляется `https://`;
|
||||||
|
- хвостовые `/` убираются.
|
||||||
|
|
||||||
|
## Geofence и Android-side логика
|
||||||
|
|
||||||
|
Что есть:
|
||||||
|
|
||||||
|
- platform channel `ignis/geofence_automation`;
|
||||||
|
- нативная регистрация geofence;
|
||||||
|
- восстановление geofence после `BOOT_COMPLETED` и `MY_PACKAGE_REPLACED`;
|
||||||
|
- delayed exit worker через WorkManager;
|
||||||
|
- локальные уведомления о фоновой обработке;
|
||||||
|
- Android-side шифрование geofence config и активного API-ключа.
|
||||||
|
|
||||||
|
Что важно понимать:
|
||||||
|
|
||||||
|
- это самая рискованная часть приложения;
|
||||||
|
- поведение зависит от Android permissions, OEM battery policy и фоновых ограничений;
|
||||||
|
- после изменений backend-контракта EXIT-поток нужно перепроверять вручную на устройстве.
|
||||||
|
|
||||||
|
## Хранение данных
|
||||||
|
|
||||||
|
- список домов, активный дом и тема — `SharedPreferences`;
|
||||||
|
- API-ключи домов — `flutter_secure_storage`;
|
||||||
|
- Android geofence config и активный API-ключ для worker'а дополнительно шифруются в native storage;
|
||||||
|
- legacy `apiKey` внутри JSON списка домов мигрируется автоматически при чтении.
|
||||||
|
|
||||||
## Проверки
|
## Проверки
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -89,32 +110,29 @@ flutter analyze
|
|||||||
flutter test
|
flutter test
|
||||||
```
|
```
|
||||||
|
|
||||||
Сейчас тестами прикрыты:
|
На 2026-05-16 в `test/` лежит 74 unit/widget-теста.
|
||||||
- parsing и load-state основных backend-ответов;
|
|
||||||
- сериализация `HomeConfig` и geofence radius;
|
|
||||||
- синхронизация активного дома с geofence automation;
|
|
||||||
- form logic для домов, групп и расписаний;
|
|
||||||
- provider-мутаторы расписаний, API-ключей и group control;
|
|
||||||
- widget-сценарии форм, `GroupCard` и error/retry потоков.
|
|
||||||
|
|
||||||
## Настройка
|
Покрыто:
|
||||||
|
|
||||||
1. Добавить дом: адрес сервера Ignis и API-ключ.
|
- `IgnisApi` и нормализация base URL;
|
||||||
2. При необходимости задать координаты дома.
|
- сериализация `HomeConfig`;
|
||||||
3. Включить "выключать свет при уходе".
|
- миграция и хранение настроек;
|
||||||
4. Выдать Android-разрешения на геолокацию, включая background location.
|
- bootstrap и auth/load-state;
|
||||||
5. Разрешить уведомления, если нужны подтверждения о срабатывании geofence.
|
- provider mutations для групп, расписаний и API-ключей;
|
||||||
|
- widget-потоки для `GroupCard`, форм домов/групп/расписаний и error/retry;
|
||||||
|
- geofence sync на уровне Flutter-side provider/service логики;
|
||||||
|
- permission/status providers для geofence и notifications.
|
||||||
|
|
||||||
API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Для нативного geofence active-home config и текущий API-ключ дополнительно шифруются на Android-стороне. Старые ключи из `SharedPreferences` мигрируются автоматически.
|
Не покрыто как следует:
|
||||||
|
|
||||||
При редактировании существующего дома приложение не требует онлайн-проверку backend, если URL и API-ключ не менялись: локальные правки имени, координат и geofence-параметров можно сохранять отдельно.
|
- нативный Android geofence path;
|
||||||
|
- `MainActivity` и platform-channel flow;
|
||||||
|
- реальное фоновое поведение WorkManager на устройстве.
|
||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
|
|
||||||
- целевая платформа сейчас Android;
|
- продукт по факту поддерживается как Android-first клиент;
|
||||||
- реальное поведение background execution, geofence delivery и OEM battery restrictions подтверждается в основном ручными проверками на устройстве;
|
- iOS, web, desktop каталоги присутствуют как Flutter scaffold, но не считаются поддерживаемыми продуктными платформами;
|
||||||
- force-stop приложения со стороны Android может ломать автоподъём фоновой логики до следующего ручного запуска.
|
- `apiProvider` конфигурируется мутирующим `init()`, поэтому переключение домов требует аккуратности;
|
||||||
|
- крупные экраны вроде `SettingsScreen` и `SchedulesScreen` всё ещё держат много UI-ответственности;
|
||||||
## Лицензия
|
- release signing в репозитории не настроен.
|
||||||
|
|
||||||
Частный проект.
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Launch Screen Assets
|
# Launch Screen Assets
|
||||||
|
|
||||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
Этот каталог остался от стандартного Flutter iOS scaffold.
|
||||||
|
|
||||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
Для `ignis_app` iOS сейчас не считается поддерживаемой продуктной платформой, поэтому:
|
||||||
|
|
||||||
|
- launch assets здесь не являются частью активного Android-first контура;
|
||||||
|
- любые изменения этих файлов не влияют на основной пользовательский сценарий проекта;
|
||||||
|
- если когда-нибудь начнётся реальная iOS-поддержка, этот каталог придётся актуализировать отдельно вместе с остальной iOS-конфигурацией.
|
||||||
|
|||||||
@@ -80,39 +80,19 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _rescan() async {
|
Future<void> _rescan() async {
|
||||||
final beforeIds = ref
|
|
||||||
.read(devicesProvider)
|
|
||||||
.data
|
|
||||||
.map((device) => device.groupMemberId)
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
setState(() => _rescanning = true);
|
setState(() => _rescanning = true);
|
||||||
try {
|
try {
|
||||||
await ref.read(apiProvider).rescanNetwork();
|
final response = await ref.read(apiProvider).rescanNetwork();
|
||||||
|
|
||||||
var changed = false;
|
|
||||||
for (var attempt = 0; attempt < 6; attempt++) {
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
await ref.read(devicesProvider.notifier).load();
|
await ref.read(devicesProvider.notifier).load();
|
||||||
final currentIds = ref
|
final summary = response.data is Map
|
||||||
.read(devicesProvider)
|
? Map<String, dynamic>.from(response.data as Map)
|
||||||
.data
|
: const <String, dynamic>{};
|
||||||
.map((device) => device.groupMemberId)
|
final message = formatRescanSummary(summary);
|
||||||
.toSet();
|
|
||||||
if (!_sameSet(beforeIds, currentIds)) {
|
|
||||||
changed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(message),
|
||||||
changed
|
|
||||||
? 'Список устройств обновился'
|
|
||||||
: 'Сканирование завершилось, но новых устройств пока не видно',
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -427,17 +407,31 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _sameSet(Set<String> left, Set<String> right) {
|
}
|
||||||
if (left.length != right.length) {
|
|
||||||
return false;
|
int _summaryInt(Map<String, dynamic> summary, String key) {
|
||||||
|
final value = summary[key];
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
for (final item in left) {
|
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||||
if (!right.contains(item)) {
|
}
|
||||||
return false;
|
|
||||||
|
String formatRescanSummary(Map<String, dynamic> summary) {
|
||||||
|
final found = _summaryInt(summary, 'found');
|
||||||
|
final added = _summaryInt(summary, 'added');
|
||||||
|
final updated = _summaryInt(summary, 'updated');
|
||||||
|
final removed = _summaryInt(summary, 'removed_offline');
|
||||||
|
|
||||||
|
if (added == 0 && updated == 0 && removed == 0 && found == 0) {
|
||||||
|
return 'Сканирование завершено: устройства не найдены';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (added == 0 && removed == 0) {
|
||||||
|
return 'Сканирование завершено: найдено $found, обновлено $updated';
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
return 'Сканирование завершено: найдено $found, новых $added, обновлено $updated, убрано $removed';
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ConflictBanner extends StatelessWidget {
|
class _ConflictBanner extends StatelessWidget {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
final _lonCtrl = TextEditingController();
|
final _lonCtrl = TextEditingController();
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _loadingApiKey = false;
|
bool _loadingApiKey = false;
|
||||||
|
bool _hasStoredApiKey = false;
|
||||||
String _originalApiKey = '';
|
String _originalApiKey = '';
|
||||||
|
|
||||||
bool get _isEdit => widget.home != null;
|
bool get _isEdit => widget.home != null;
|
||||||
@@ -59,11 +60,11 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
final apiKey = await ref
|
final apiKey = await ref
|
||||||
.read(settingsServiceProvider)
|
.read(settingsServiceProvider)
|
||||||
.getHomeApiKey(widget.home!.id);
|
.getHomeApiKey(widget.home!.id);
|
||||||
_originalApiKey = apiKey ?? '';
|
_originalApiKey = apiKey?.trim() ?? '';
|
||||||
|
_hasStoredApiKey = _originalApiKey.isNotEmpty;
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_keyCtrl.text = _originalApiKey;
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _loadingApiKey = false);
|
setState(() => _loadingApiKey = false);
|
||||||
@@ -144,14 +145,24 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
controller: _keyCtrl,
|
controller: _keyCtrl,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'API Key',
|
labelText: 'API Key',
|
||||||
|
hintText: _hasStoredApiKey
|
||||||
|
? 'Оставьте пустым, чтобы сохранить текущий ключ'
|
||||||
|
: null,
|
||||||
helperText: _loadingApiKey
|
helperText: _loadingApiKey
|
||||||
? 'Загружаем сохранённый ключ...'
|
? 'Загружаем сохранённый ключ...'
|
||||||
: 'Ключ проверяется только при изменении подключения',
|
: _hasStoredApiKey
|
||||||
|
? 'Сохранённый ключ хранится отдельно и не показывается в поле'
|
||||||
|
: 'Ключ хранится отдельно в защищённом хранилище',
|
||||||
prefixIcon: const Icon(Icons.key),
|
prefixIcon: const Icon(Icons.key),
|
||||||
),
|
),
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
validator: (value) =>
|
validator: (value) {
|
||||||
(value?.trim().isEmpty ?? true) ? 'Укажите API key' : null,
|
final enteredKey = value?.trim() ?? '';
|
||||||
|
if (enteredKey.isNotEmpty || _hasStoredApiKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return 'Укажите API key';
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -285,7 +296,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
|
|
||||||
final name = _nameCtrl.text.trim();
|
final name = _nameCtrl.text.trim();
|
||||||
final rawUrl = _urlCtrl.text.trim();
|
final rawUrl = _urlCtrl.text.trim();
|
||||||
final key = _keyCtrl.text.trim();
|
final enteredKey = _keyCtrl.text.trim();
|
||||||
|
final key = enteredKey.isNotEmpty ? enteredKey : _originalApiKey;
|
||||||
final latText = _latCtrl.text.trim();
|
final latText = _latCtrl.text.trim();
|
||||||
final lonText = _lonCtrl.text.trim();
|
final lonText = _lonCtrl.text.trim();
|
||||||
|
|
||||||
@@ -370,7 +382,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
if (_isEdit) {
|
if (_isEdit) {
|
||||||
await ref
|
await ref
|
||||||
.read(homesProvider.notifier)
|
.read(homesProvider.notifier)
|
||||||
.update(home, apiKey: credentialsChanged ? key : null);
|
.update(home, apiKey: enteredKey.isNotEmpty ? key : null);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(homesProvider.notifier).add(home, apiKey: key);
|
await ref.read(homesProvider.notifier).add(home, apiKey: key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'package:dio/dio.dart';
|
|||||||
/// HTTP-клиент для одного сервера Ignis.
|
/// HTTP-клиент для одного сервера Ignis.
|
||||||
/// Покрывает все эндпоинты из openapi.json.
|
/// Покрывает все эндпоинты из openapi.json.
|
||||||
class IgnisApi {
|
class IgnisApi {
|
||||||
final Dio _dio = Dio();
|
IgnisApi({Dio? dio}) : _dio = dio ?? Dio();
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
Dio get dioInstance => _dio;
|
Dio get dioInstance => _dio;
|
||||||
|
|
||||||
static String normalizeBaseUrl(String baseUrl) {
|
static String normalizeBaseUrl(String baseUrl) {
|
||||||
@@ -70,11 +72,11 @@ class IgnisApi {
|
|||||||
|
|
||||||
/// Управление группой: state, brightness, temp, scene, r/g/b
|
/// Управление группой: state, brightness, temp, scene, r/g/b
|
||||||
Future<Response> controlGroup(String id, Map<String, dynamic> params) =>
|
Future<Response> controlGroup(String id, Map<String, dynamic> params) =>
|
||||||
_dio.post('/control/group/$id', queryParameters: params);
|
_dio.post('/control/group/$id', data: params);
|
||||||
|
|
||||||
/// Управление одной лампой
|
/// Управление одной лампой
|
||||||
Future<Response> controlDevice(String id, Map<String, dynamic> params) =>
|
Future<Response> controlDevice(String id, Map<String, dynamic> params) =>
|
||||||
_dio.post('/control/device/$id', queryParameters: params);
|
_dio.post('/control/device/$id', data: params);
|
||||||
|
|
||||||
/// Мигнуть лампой (для идентификации)
|
/// Мигнуть лампой (для идентификации)
|
||||||
Future<Response> blinkDevice(String id) =>
|
Future<Response> blinkDevice(String id) =>
|
||||||
@@ -92,11 +94,11 @@ class IgnisApi {
|
|||||||
|
|
||||||
/// Одноразовое расписание (таймер)
|
/// Одноразовое расписание (таймер)
|
||||||
Future<Response> scheduleOnce(Map<String, dynamic> params) =>
|
Future<Response> scheduleOnce(Map<String, dynamic> params) =>
|
||||||
_dio.post('/schedules/once', queryParameters: params);
|
_dio.post('/schedules/once', data: params);
|
||||||
|
|
||||||
/// Cron-расписание (повторяющееся)
|
/// Cron-расписание (повторяющееся)
|
||||||
Future<Response> scheduleCron(Map<String, dynamic> params) =>
|
Future<Response> scheduleCron(Map<String, dynamic> params) =>
|
||||||
_dio.post('/schedules/cron', queryParameters: params);
|
_dio.post('/schedules/cron', data: params);
|
||||||
|
|
||||||
/// Все активные задачи расписания
|
/// Все активные задачи расписания
|
||||||
Future<Response> getTasks() => _dio.get('/schedules/tasks');
|
Future<Response> getTasks() => _dio.get('/schedules/tasks');
|
||||||
|
|||||||
86
test/api_client_test.dart
Normal file
86
test/api_client_test.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/services/api_client.dart';
|
||||||
|
|
||||||
|
class RecordingAdapter implements HttpClientAdapter {
|
||||||
|
RequestOptions? lastRequest;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResponseBody> fetch(
|
||||||
|
RequestOptions options,
|
||||||
|
Stream<Uint8List>? requestStream,
|
||||||
|
Future<void>? cancelFuture,
|
||||||
|
) async {
|
||||||
|
lastRequest = options;
|
||||||
|
return ResponseBody.fromString(
|
||||||
|
'{}',
|
||||||
|
200,
|
||||||
|
headers: {
|
||||||
|
Headers.contentTypeHeader: ['application/json'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close({bool force = false}) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('controlGroup sends command payload in request body', () async {
|
||||||
|
final adapter = RecordingAdapter();
|
||||||
|
final dio = Dio()..httpClientAdapter = adapter;
|
||||||
|
final api = IgnisApi(dio: dio)..init('http://localhost:8000', 'secret');
|
||||||
|
|
||||||
|
await api.controlGroup('kitchen', {'state': true, 'brightness': 42});
|
||||||
|
|
||||||
|
expect(adapter.lastRequest, isNotNull);
|
||||||
|
expect(adapter.lastRequest?.path, '/control/group/kitchen');
|
||||||
|
expect(adapter.lastRequest?.queryParameters, isEmpty);
|
||||||
|
expect(adapter.lastRequest?.data, {'state': true, 'brightness': 42});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduleOnce sends schedule payload in request body', () async {
|
||||||
|
final adapter = RecordingAdapter();
|
||||||
|
final dio = Dio()..httpClientAdapter = adapter;
|
||||||
|
final api = IgnisApi(dio: dio)..init('http://localhost:8000', 'secret');
|
||||||
|
|
||||||
|
await api.scheduleOnce({
|
||||||
|
'target_id': 'hall',
|
||||||
|
'hours_from_now': 4,
|
||||||
|
'state': false,
|
||||||
|
'is_group': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(adapter.lastRequest, isNotNull);
|
||||||
|
expect(adapter.lastRequest?.path, '/schedules/once');
|
||||||
|
expect(adapter.lastRequest?.queryParameters, isEmpty);
|
||||||
|
expect(adapter.lastRequest?.data, {
|
||||||
|
'target_id': 'hall',
|
||||||
|
'hours_from_now': 4,
|
||||||
|
'state': false,
|
||||||
|
'is_group': true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'createApiKey keeps query-based contract until backend is changed',
|
||||||
|
() async {
|
||||||
|
final adapter = RecordingAdapter();
|
||||||
|
final dio = Dio()..httpClientAdapter = adapter;
|
||||||
|
final api = IgnisApi(dio: dio)..init('http://localhost:8000', 'secret');
|
||||||
|
|
||||||
|
await api.createApiKey('Guest', isAdmin: true);
|
||||||
|
|
||||||
|
expect(adapter.lastRequest, isNotNull);
|
||||||
|
expect(adapter.lastRequest?.path, '/api-keys');
|
||||||
|
expect(adapter.lastRequest?.queryParameters, {
|
||||||
|
'name': 'Guest',
|
||||||
|
'is_admin': true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,6 +82,41 @@ void main() {
|
|||||||
expect(api.createdGroupMacs, ['AA:BB']);
|
expect(api.createdGroupMacs, ['AA:BB']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('group edit screen shows backend rescan summary', (tester) async {
|
||||||
|
final api =
|
||||||
|
FakeIgnisApi(
|
||||||
|
devicesData: {
|
||||||
|
'devices': [
|
||||||
|
{'mac': 'AA:BB', 'name': 'Лампа 1'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groupsData: <Object>[],
|
||||||
|
)
|
||||||
|
..rescanNetworkData = {
|
||||||
|
'status': 'ok',
|
||||||
|
'found': 3,
|
||||||
|
'added': 1,
|
||||||
|
'updated': 2,
|
||||||
|
'removed_offline': 1,
|
||||||
|
'pending_removal': 0,
|
||||||
|
'online': 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpTestApp(tester, child: const GroupEditScreen(), api: api);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Пересканировать сеть'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Сканирование завершено: найдено 3, новых 1, обновлено 2, убрано 1',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(api.rescanCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('api keys screen validates and shows created key banner', (
|
testWidgets('api keys screen validates and shows created key banner', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -195,4 +230,57 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(savedApiKey, 'secret-key');
|
expect(savedApiKey, 'secret-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('home edit screen keeps stored api key hidden on edit', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final settingsService = SettingsService(
|
||||||
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
|
);
|
||||||
|
final existingHome = HomeConfig(
|
||||||
|
id: 'home-1',
|
||||||
|
name: 'Квартира',
|
||||||
|
url: 'https://ignis.akokos.ru',
|
||||||
|
);
|
||||||
|
await settingsService.upsertHome(existingHome, apiKey: 'stored-secret');
|
||||||
|
|
||||||
|
final api = FakeIgnisApi(authData: {'is_admin': true, 'name': 'owner'});
|
||||||
|
|
||||||
|
await pumpTestApp(
|
||||||
|
tester,
|
||||||
|
api: api,
|
||||||
|
settingsService: settingsService,
|
||||||
|
child: HomeEditScreen(home: existingHome),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final keyField = tester.widget<EditableText>(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(widget) => widget is EditableText && widget.obscureText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(keyField.controller.text, isEmpty);
|
||||||
|
expect(
|
||||||
|
find.text('Сохранённый ключ хранится отдельно и не показывается в поле'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Адрес сервера'),
|
||||||
|
'ignis.example.com',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ElevatedButton, 'СОХРАНИТЬ'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(api.validateCredentialsCalls, 1);
|
||||||
|
expect(api.validatedBaseUrl, 'https://ignis.example.com');
|
||||||
|
expect(api.validatedApiKey, 'stored-secret');
|
||||||
|
expect(
|
||||||
|
await settingsService.getHomeApiKey(existingHome.id),
|
||||||
|
'stored-secret',
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
Object? eventLogData;
|
Object? eventLogData;
|
||||||
Object? apiKeysData;
|
Object? apiKeysData;
|
||||||
Object? authData;
|
Object? authData;
|
||||||
|
Object? rescanNetworkData;
|
||||||
|
|
||||||
Object? devicesError;
|
Object? devicesError;
|
||||||
Object? scenesError;
|
Object? scenesError;
|
||||||
@@ -72,6 +73,9 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
String? deletedGroupId;
|
String? deletedGroupId;
|
||||||
Map<String, dynamic>? scheduledOnceParams;
|
Map<String, dynamic>? scheduledOnceParams;
|
||||||
Map<String, dynamic>? scheduledCronParams;
|
Map<String, dynamic>? scheduledCronParams;
|
||||||
|
String? validatedBaseUrl;
|
||||||
|
String? validatedApiKey;
|
||||||
|
int validateCredentialsCalls = 0;
|
||||||
int rescanCalls = 0;
|
int rescanCalls = 0;
|
||||||
|
|
||||||
FakeIgnisApi({
|
FakeIgnisApi({
|
||||||
@@ -98,6 +102,9 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> validateCredentials(String baseUrl, String apiKey) async {
|
Future<void> validateCredentials(String baseUrl, String apiKey) async {
|
||||||
|
validateCredentialsCalls += 1;
|
||||||
|
validatedBaseUrl = baseUrl;
|
||||||
|
validatedApiKey = apiKey;
|
||||||
final error = authError;
|
final error = authError;
|
||||||
if (error != null) throw error;
|
if (error != null) throw error;
|
||||||
}
|
}
|
||||||
@@ -329,7 +336,17 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
if (error != null) throw error;
|
if (error != null) throw error;
|
||||||
return Response(
|
return Response(
|
||||||
requestOptions: RequestOptions(path: '/devices/rescan'),
|
requestOptions: RequestOptions(path: '/devices/rescan'),
|
||||||
data: <String, dynamic>{'ok': true},
|
data:
|
||||||
|
rescanNetworkData ??
|
||||||
|
<String, dynamic>{
|
||||||
|
'status': 'ok',
|
||||||
|
'found': 0,
|
||||||
|
'added': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'removed_offline': 0,
|
||||||
|
'pending_removal': 0,
|
||||||
|
'online': 0,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user