feat: polish phase 7 forms and schedules
This commit is contained in:
38
README.md
38
README.md
@@ -5,7 +5,7 @@
|
|||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- **Мульти-дом** -- поддержка нескольких серверов Ignis (квартира, дача, друзья). Каждый дом -- отдельный сервер со своим URL и API-ключом.
|
- **Мульти-дом** -- поддержка нескольких серверов Ignis (квартира, дача, друзья). Каждый дом -- отдельный сервер со своим URL и API-ключом.
|
||||||
- **Группы ламп** -- создание, удаление, управление. При создании группы можно выбрать нужные лампы из списка обнаруженных устройств, пересканировать сеть.
|
- **Группы ламп** -- создание, удаление и управление. При создании группы есть product-валидация, автогенерация `ID`, предупреждение о конфликтах по устройствам и более честный перескан сети.
|
||||||
- **Управление освещением:**
|
- **Управление освещением:**
|
||||||
- Включение/выключение
|
- Включение/выключение
|
||||||
- Яркость 10--100% с шагом 10%
|
- Яркость 10--100% с шагом 10%
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
- RGB-цвет через HSV-пикер
|
- RGB-цвет через HSV-пикер
|
||||||
- Сцены (загружаются с сервера, отображаются с человекочитаемыми названиями)
|
- Сцены (загружаются с сервера, отображаются с человекочитаемыми названиями)
|
||||||
- Таймер "включить на 4 часа"
|
- Таймер "включить на 4 часа"
|
||||||
- **Расписания** -- одноразовые таймеры и cron-задачи с выбором дней недели. Просмотр и отмена активных задач.
|
- **Расписания** -- одноразовые таймеры с выбором даты/времени и повторяющиеся задачи с выбором дней недели. Просмотр, создание, валидация и отмена активных задач.
|
||||||
- **API-ключи** -- просмотр, создание, отзыв и повторная активация гостевых ключей для администраторов.
|
- **API-ключи** -- просмотр, создание, отзыв и повторная активация гостевых ключей для администраторов с отдельным UX для только что созданного ключа.
|
||||||
- **Статистика и лог событий** -- просмотр сводки по группам и последних событий сервера.
|
- **Статистика и лог событий** -- просмотр сводки по группам и последних событий сервера.
|
||||||
- **Геофенс** -- опциональное автовыключение света при уходе от дома.
|
- **Геофенс и расстояния** -- live-дистанция до дома в UI и опциональное автовыключение света при уходе. Геофенс работает для текущего активного дома, показывает диагностический статус и использует cooldown/re-arm поведение.
|
||||||
- **Устойчивость к ошибкам** -- гранулярные состояния загрузки (`LoadState`), централизованная обработка сетевых сбоев, soft-ошибки при управлении ползунками без спама в UI.
|
- **Устойчивость к ошибкам** -- гранулярные состояния загрузки (`LoadState`), централизованная обработка сетевых сбоев, soft-ошибки при управлении ползунками без спама в UI.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
@@ -58,22 +58,23 @@ lib/
|
|||||||
├── features/
|
├── features/
|
||||||
│ ├── api_keys/providers/ -- управление гостевыми API-ключами
|
│ ├── api_keys/providers/ -- управление гостевыми API-ключами
|
||||||
│ ├── auth/providers/ -- auth/me и auth-state
|
│ ├── auth/providers/ -- auth/me и auth-state
|
||||||
│ ├── homes/ -- дома, геолокация, geofence sync
|
│ ├── groups/ -- валидация и логика форм групп
|
||||||
|
│ ├── homes/ -- дома, геолокация, geofence sync/runtime
|
||||||
│ ├── remote/providers/ -- polling групп, устройства, сцены, control errors
|
│ ├── remote/providers/ -- polling групп, устройства, сцены, control errors
|
||||||
│ ├── schedules/providers/ -- задачи расписания
|
│ ├── schedules/ -- логика и providers расписаний
|
||||||
│ ├── shared/providers/ -- базовые core providers
|
│ ├── shared/providers/ -- базовые core providers
|
||||||
│ └── stats/providers/ -- статистика и лог событий
|
│ └── stats/providers/ -- статистика и лог событий
|
||||||
├── providers/
|
├── providers/
|
||||||
│ └── providers.dart -- compatibility barrel для публичных provider-экспортов
|
│ └── providers.dart -- compatibility barrel для публичных provider-экспортов
|
||||||
├── screens/
|
├── screens/
|
||||||
│ ├── api_keys_screen.dart
|
│ ├── api_keys_screen.dart -- экран гостевых API-ключей
|
||||||
│ ├── event_log_screen.dart
|
│ ├── event_log_screen.dart -- последние события сервера
|
||||||
│ ├── homes_screen.dart
|
│ ├── homes_screen.dart -- список домов, distance/geofence статус
|
||||||
│ ├── home_edit_screen.dart
|
│ ├── home_edit_screen.dart -- создание и редактирование дома
|
||||||
│ ├── remote_screen.dart
|
│ ├── remote_screen.dart -- основной экран управления светом
|
||||||
│ ├── group_edit_screen.dart
|
│ ├── group_edit_screen.dart -- создание группы с выбором устройств
|
||||||
│ ├── schedules_screen.dart
|
│ ├── schedules_screen.dart -- создание и просмотр расписаний
|
||||||
│ └── stats_screen.dart
|
│ └── stats_screen.dart -- статистика по командам
|
||||||
└── widgets/
|
└── widgets/
|
||||||
├── build_info_text.dart -- лейбл с версией сборки
|
├── build_info_text.dart -- лейбл с версией сборки
|
||||||
├── group_card.dart
|
├── group_card.dart
|
||||||
@@ -107,10 +108,17 @@ flutter test
|
|||||||
|
|
||||||
Текущий baseline зелёный: `flutter analyze`, `flutter test` и release APK сборка проходят штатно.
|
Текущий baseline зелёный: `flutter analyze`, `flutter test` и release APK сборка проходят штатно.
|
||||||
|
|
||||||
|
Дополнительно тестами уже прикрыты:
|
||||||
|
- typed parsing/load-state для основных backend-ответов;
|
||||||
|
- geofence distance/runtime логика;
|
||||||
|
- чистая логика форм расписаний и групп.
|
||||||
|
|
||||||
## Настройка
|
## Настройка
|
||||||
|
|
||||||
При первом запуске приложение попросит добавить "дом" -- указать адрес сервера Ignis и API-ключ. После этого откроется пульт управления группами.
|
При первом запуске приложение попросит добавить "дом" -- указать адрес сервера Ignis и API-ключ. После этого откроется пульт управления группами.
|
||||||
|
|
||||||
|
Если задать координаты дома, экран домов начнёт показывать расстояние до активного дома. Если дополнительно включить автовыключение при уходе и выдать Android фоновые разрешения на геолокацию и уведомления, приложение сможет в фоне выключать свет при удалении от текущего активного дома.
|
||||||
|
|
||||||
Для добавления второго дома: кнопка "домик" в левом верхнем углу пульта -> экран домов -> кнопка "+".
|
Для добавления второго дома: кнопка "домик" в левом верхнем углу пульта -> экран домов -> кнопка "+".
|
||||||
|
|
||||||
API-ключи хранятся отдельно от конфигурации домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически.
|
API-ключи хранятся отдельно от конфигурации домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически.
|
||||||
@@ -125,7 +133,7 @@ API-ключи хранятся отдельно от конфигурации
|
|||||||
|
|
||||||
- Целевая платформа сейчас Android.
|
- Целевая платформа сейчас Android.
|
||||||
- Release APK пока подписывается debug-ключом из Flutter-шаблона.
|
- Release APK пока подписывается debug-ключом из Flutter-шаблона.
|
||||||
- Геофенс всё ещё требует отдельной продуктовой и технической доводки: multi-home semantics, background permissions и retry/cooldown поведение пока не доведены до конца.
|
- Build info в APK показывает дату сборки и короткий git hash текущего `HEAD`. Если сборка делается поверх незакоммиченного рабочего дерева, hash будет от последнего коммита, а не от локальных незакоммиченных изменений.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
101
lib/features/groups/group_form_logic.dart
Normal file
101
lib/features/groups/group_form_logic.dart
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import '../../models/ignis_group.dart';
|
||||||
|
|
||||||
|
final RegExp _groupIdAllowedPattern = RegExp(r'^[a-z0-9][a-z0-9_-]*$');
|
||||||
|
final RegExp _groupIdSanitizePattern = RegExp(r'[^a-z0-9_-]+');
|
||||||
|
final RegExp _dashCollapsePattern = RegExp(r'[-_]{2,}');
|
||||||
|
|
||||||
|
const Map<String, String> _transliterationMap = {
|
||||||
|
'а': 'a',
|
||||||
|
'б': 'b',
|
||||||
|
'в': 'v',
|
||||||
|
'г': 'g',
|
||||||
|
'д': 'd',
|
||||||
|
'е': 'e',
|
||||||
|
'ё': 'e',
|
||||||
|
'ж': 'zh',
|
||||||
|
'з': 'z',
|
||||||
|
'и': 'i',
|
||||||
|
'й': 'y',
|
||||||
|
'к': 'k',
|
||||||
|
'л': 'l',
|
||||||
|
'м': 'm',
|
||||||
|
'н': 'n',
|
||||||
|
'о': 'o',
|
||||||
|
'п': 'p',
|
||||||
|
'р': 'r',
|
||||||
|
'с': 's',
|
||||||
|
'т': 't',
|
||||||
|
'у': 'u',
|
||||||
|
'ф': 'f',
|
||||||
|
'х': 'h',
|
||||||
|
'ц': 'ts',
|
||||||
|
'ч': 'ch',
|
||||||
|
'ш': 'sh',
|
||||||
|
'щ': 'sch',
|
||||||
|
'ъ': '',
|
||||||
|
'ы': 'y',
|
||||||
|
'ь': '',
|
||||||
|
'э': 'e',
|
||||||
|
'ю': 'yu',
|
||||||
|
'я': 'ya',
|
||||||
|
};
|
||||||
|
|
||||||
|
String slugifyGroupId(String input) {
|
||||||
|
final lower = input.trim().toLowerCase();
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final rune in lower.runes) {
|
||||||
|
final char = String.fromCharCode(rune);
|
||||||
|
buffer.write(_transliterationMap[char] ?? char);
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = buffer
|
||||||
|
.toString()
|
||||||
|
.replaceAll(RegExp(r'\s+'), '-')
|
||||||
|
.replaceAll(_groupIdSanitizePattern, '-')
|
||||||
|
.replaceAll(_dashCollapsePattern, '-')
|
||||||
|
.replaceAll(RegExp(r'^[-_]+|[-_]+$'), '');
|
||||||
|
|
||||||
|
if (value.length > 32) {
|
||||||
|
value = value.substring(0, 32).replaceAll(RegExp(r'[-_]+$'), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? validateGroupId(String value, Iterable<IgnisGroup> existingGroups) {
|
||||||
|
final normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return 'Укажите ID группы';
|
||||||
|
}
|
||||||
|
if (!_groupIdAllowedPattern.hasMatch(normalized)) {
|
||||||
|
return 'Только латиница, цифры, "-" и "_"';
|
||||||
|
}
|
||||||
|
final alreadyExists = existingGroups.any((group) => group.id == normalized);
|
||||||
|
if (alreadyExists) {
|
||||||
|
return 'Группа с таким ID уже существует';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? validateGroupName(String value) {
|
||||||
|
if (value.trim().isEmpty) {
|
||||||
|
return 'Укажите название группы';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<IgnisGroup> findGroupMembershipConflicts(
|
||||||
|
Set<String> selectedDeviceIds,
|
||||||
|
Iterable<IgnisGroup> existingGroups,
|
||||||
|
) {
|
||||||
|
if (selectedDeviceIds.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingGroups
|
||||||
|
.where(
|
||||||
|
(group) =>
|
||||||
|
group.macs.any((deviceId) => selectedDeviceIds.contains(deviceId)),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
147
lib/features/schedules/schedule_form_logic.dart
Normal file
147
lib/features/schedules/schedule_form_logic.dart
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
class ScheduleWeekdayOption {
|
||||||
|
final int backendValue;
|
||||||
|
final String shortLabel;
|
||||||
|
final String fullLabel;
|
||||||
|
|
||||||
|
const ScheduleWeekdayOption({
|
||||||
|
required this.backendValue,
|
||||||
|
required this.shortLabel,
|
||||||
|
required this.fullLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleWeekdayOptions = <ScheduleWeekdayOption>[
|
||||||
|
ScheduleWeekdayOption(
|
||||||
|
backendValue: 1,
|
||||||
|
shortLabel: 'Пн',
|
||||||
|
fullLabel: 'понедельник',
|
||||||
|
),
|
||||||
|
ScheduleWeekdayOption(
|
||||||
|
backendValue: 2,
|
||||||
|
shortLabel: 'Вт',
|
||||||
|
fullLabel: 'вторник',
|
||||||
|
),
|
||||||
|
ScheduleWeekdayOption(backendValue: 3, shortLabel: 'Ср', fullLabel: 'среда'),
|
||||||
|
ScheduleWeekdayOption(
|
||||||
|
backendValue: 4,
|
||||||
|
shortLabel: 'Чт',
|
||||||
|
fullLabel: 'четверг',
|
||||||
|
),
|
||||||
|
ScheduleWeekdayOption(
|
||||||
|
backendValue: 5,
|
||||||
|
shortLabel: 'Пт',
|
||||||
|
fullLabel: 'пятница',
|
||||||
|
),
|
||||||
|
ScheduleWeekdayOption(
|
||||||
|
backendValue: 6,
|
||||||
|
shortLabel: 'Сб',
|
||||||
|
fullLabel: 'суббота',
|
||||||
|
),
|
||||||
|
ScheduleWeekdayOption(
|
||||||
|
backendValue: 0,
|
||||||
|
shortLabel: 'Вс',
|
||||||
|
fullLabel: 'воскресенье',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
DateTime defaultOnceScheduleTime({DateTime? now}) {
|
||||||
|
final base = now ?? DateTime.now();
|
||||||
|
final candidate = base.add(const Duration(hours: 4));
|
||||||
|
final roundedMinute = ((candidate.minute + 4) ~/ 5) * 5;
|
||||||
|
final normalized = DateTime(
|
||||||
|
candidate.year,
|
||||||
|
candidate.month,
|
||||||
|
candidate.day,
|
||||||
|
candidate.hour,
|
||||||
|
roundedMinute,
|
||||||
|
);
|
||||||
|
return normalized.isAfter(base)
|
||||||
|
? normalized
|
||||||
|
: normalized.add(const Duration(minutes: 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
String? validateOnceSchedule(DateTime? scheduledAt, {DateTime? now}) {
|
||||||
|
if (scheduledAt == null) {
|
||||||
|
return 'Выберите дату и время';
|
||||||
|
}
|
||||||
|
final threshold = (now ?? DateTime.now()).add(const Duration(minutes: 1));
|
||||||
|
if (!scheduledAt.isAfter(threshold)) {
|
||||||
|
return 'Нужна дата хотя бы на минуту вперёд';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? validateCronSchedule({
|
||||||
|
required int? hour,
|
||||||
|
required int? minute,
|
||||||
|
required Set<int> weekdays,
|
||||||
|
}) {
|
||||||
|
if (hour == null || hour < 0 || hour > 23) {
|
||||||
|
return 'Час должен быть от 0 до 23';
|
||||||
|
}
|
||||||
|
if (minute == null || minute < 0 || minute > 59) {
|
||||||
|
return 'Минута должна быть от 0 до 59';
|
||||||
|
}
|
||||||
|
if (weekdays.isEmpty) {
|
||||||
|
return 'Выберите хотя бы один день недели';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String serializeCronWeekdays(Set<int> weekdays) {
|
||||||
|
if (weekdays.length == scheduleWeekdayOptions.length) {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
final sorted = weekdays.toList()..sort();
|
||||||
|
return sorted.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
String describeCronWeekdays(Set<int> weekdays) {
|
||||||
|
if (weekdays.isEmpty) {
|
||||||
|
return 'дни не выбраны';
|
||||||
|
}
|
||||||
|
if (weekdays.length == scheduleWeekdayOptions.length) {
|
||||||
|
return 'каждый день';
|
||||||
|
}
|
||||||
|
final weekdaySet = weekdays.toSet();
|
||||||
|
final selected = scheduleWeekdayOptions
|
||||||
|
.where((option) => weekdaySet.contains(option.backendValue))
|
||||||
|
.map((option) => option.shortLabel)
|
||||||
|
.toList();
|
||||||
|
return selected.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String describeCronWeekdaysExpression(String? value) {
|
||||||
|
if (value == null || value.isEmpty || value == '*') {
|
||||||
|
return 'каждый день';
|
||||||
|
}
|
||||||
|
|
||||||
|
final weekdays = value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => int.tryParse(item.trim()))
|
||||||
|
.whereType<int>()
|
||||||
|
.toSet();
|
||||||
|
if (weekdays.isEmpty) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return describeCronWeekdays(weekdays);
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatLocalScheduleDateTime(DateTime value) {
|
||||||
|
String pad(int number) => number.toString().padLeft(2, '0');
|
||||||
|
return '${pad(value.day)}.${pad(value.month)}.${value.year} ${pad(value.hour)}:${pad(value.minute)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatRunAtLabel(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'время не указано';
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsed = DateTime.tryParse(value);
|
||||||
|
if (parsed == null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatLocalScheduleDateTime(parsed.toLocal());
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
import '../app/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
import '../models/api_key_info.dart';
|
import '../models/api_key_info.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/load_error_view.dart';
|
import '../widgets/load_error_view.dart';
|
||||||
|
|
||||||
/// Экран управления гостевыми API-ключами.
|
|
||||||
/// Доступен только администраторам.
|
|
||||||
class ApiKeysScreen extends ConsumerStatefulWidget {
|
class ApiKeysScreen extends ConsumerStatefulWidget {
|
||||||
const ApiKeysScreen({super.key});
|
const ApiKeysScreen({super.key});
|
||||||
|
|
||||||
@@ -17,6 +16,7 @@ class ApiKeysScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
||||||
|
final Set<String> _busyKeys = <String>{};
|
||||||
String? _lastCreatedKey;
|
String? _lastCreatedKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,13 +39,16 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (_lastCreatedKey != null)
|
if (_lastCreatedKey != null)
|
||||||
_LastCreatedKeyBanner(keyValue: _lastCreatedKey!),
|
_LastCreatedKeyBanner(
|
||||||
|
keyValue: _lastCreatedKey!,
|
||||||
|
onDismiss: () => setState(() => _lastCreatedKey = null),
|
||||||
|
),
|
||||||
Expanded(child: _buildContent(keysState, keys)),
|
Expanded(child: _buildContent(keysState, keys)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
backgroundColor: Colors.deepOrange,
|
backgroundColor: Colors.deepOrange,
|
||||||
onPressed: () => _showCreateDialog(context),
|
onPressed: _showCreateDialog,
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -73,7 +76,7 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
if (keys.isEmpty) {
|
if (keys.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Нет гостевых ключей',
|
'Гостевых ключей пока нет',
|
||||||
style: TextStyle(color: Colors.white54),
|
style: TextStyle(color: Colors.white54),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -105,85 +108,131 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final keyIndex = index - statusHeaderCount;
|
final keyData = keys[index - statusHeaderCount];
|
||||||
final key = keys[keyIndex];
|
final busy = _busyKeys.contains(keyData.key);
|
||||||
return _ApiKeyCard(
|
return _ApiKeyCard(
|
||||||
data: key,
|
data: keyData,
|
||||||
onRevoke: () => _revokeKey(key),
|
busy: busy,
|
||||||
onActivate: () => _activateKey(key),
|
onRevoke: busy ? null : () => _revokeKey(keyData),
|
||||||
|
onActivate: busy ? null : () => _activateKey(keyData),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showCreateDialog(BuildContext context) {
|
Future<void> _showCreateDialog() async {
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
final nameCtrl = TextEditingController();
|
final nameCtrl = TextEditingController();
|
||||||
bool isAdmin = false;
|
var isAdmin = false;
|
||||||
|
var isCreating = false;
|
||||||
|
|
||||||
showDialog(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
builder: (ctx, setDialogState) => AlertDialog(
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
title: const Text('Новый API-ключ'),
|
title: const Text('Новый API-ключ'),
|
||||||
content: Column(
|
content: Form(
|
||||||
mainAxisSize: MainAxisSize.min,
|
key: formKey,
|
||||||
children: [
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
TextField(
|
child: Column(
|
||||||
controller: nameCtrl,
|
mainAxisSize: MainAxisSize.min,
|
||||||
decoration: const InputDecoration(
|
children: [
|
||||||
labelText: 'Имя ключа',
|
TextFormField(
|
||||||
hintText: 'Например: "Гость"',
|
controller: nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Имя ключа',
|
||||||
|
hintText: 'Например: Гость',
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
validator: (value) {
|
||||||
|
final normalized = value?.trim() ?? '';
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return 'Укажите имя ключа';
|
||||||
|
}
|
||||||
|
if (normalized.length < 2) {
|
||||||
|
return 'Слишком короткое имя';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
autofocus: true,
|
const SizedBox(height: 12),
|
||||||
),
|
SwitchListTile(
|
||||||
const SizedBox(height: 12),
|
title: const Text('Дать права администратора'),
|
||||||
SwitchListTile(
|
subtitle: const Text(
|
||||||
title: const Text('Администратор'),
|
'Используйте только для доверенных людей',
|
||||||
value: isAdmin,
|
),
|
||||||
activeThumbColor: Colors.deepOrange,
|
value: isAdmin,
|
||||||
onChanged: (v) => setDialogState(() => isAdmin = v),
|
activeThumbColor: Colors.deepOrange,
|
||||||
contentPadding: EdgeInsets.zero,
|
onChanged: isCreating
|
||||||
),
|
? null
|
||||||
],
|
: (value) => setDialogState(() => isAdmin = value),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
onPressed: isCreating ? null : () => Navigator.of(ctx).pop(),
|
||||||
child: const Text('Отмена'),
|
child: const Text('Отмена'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.deepOrange,
|
backgroundColor: Colors.deepOrange,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: isCreating
|
||||||
final name = nameCtrl.text.trim();
|
? null
|
||||||
if (name.isEmpty) return;
|
: () async {
|
||||||
Navigator.of(ctx).pop();
|
if (!formKey.currentState!.validate()) {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
return;
|
||||||
try {
|
}
|
||||||
final key = await ref
|
|
||||||
.read(apiKeysProvider.notifier)
|
setDialogState(() => isCreating = true);
|
||||||
.create(name, isAdmin: isAdmin);
|
try {
|
||||||
if (!mounted) return;
|
final key = await ref
|
||||||
setState(() => _lastCreatedKey = key);
|
.read(apiKeysProvider.notifier)
|
||||||
} catch (e) {
|
.create(nameCtrl.text.trim(), isAdmin: isAdmin);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
messenger.showSnackBar(
|
setState(() => _lastCreatedKey = key);
|
||||||
SnackBar(
|
if (ctx.mounted) {
|
||||||
content: Text(
|
Navigator.of(ctx).pop();
|
||||||
'Ошибка создания ключа: ${describeLoadError(e)}',
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Ключ создан'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!ctx.mounted) return;
|
||||||
|
setDialogState(() => isCreating = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Ошибка создания ключа: ${describeLoadError(e)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: isCreating
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
);
|
: const Text('Создать'),
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Создать'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
nameCtrl.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _revokeKey(ApiKeyInfo data) async {
|
Future<void> _revokeKey(ApiKeyInfo data) async {
|
||||||
@@ -191,7 +240,7 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Отозвать ключ?'),
|
title: const Text('Отозвать ключ?'),
|
||||||
content: Text('Отозвать "${data.name}"?'),
|
content: Text('Ключ "${data.name}" перестанет работать.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
@@ -207,31 +256,43 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed == true) {
|
if (confirmed != true) {
|
||||||
try {
|
return;
|
||||||
await ref.read(apiKeysProvider.notifier).revoke(data.key);
|
}
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
setState(() => _busyKeys.add(data.key));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
try {
|
||||||
SnackBar(
|
await ref.read(apiKeysProvider.notifier).revoke(data.key);
|
||||||
content: Text('Ошибка отзыва ключа: ${describeLoadError(e)}'),
|
if (!mounted) return;
|
||||||
),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
);
|
const SnackBar(
|
||||||
|
content: Text('Ключ отозван'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Ошибка отзыва ключа: ${describeLoadError(e)}')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _busyKeys.remove(data.key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _activateKey(ApiKeyInfo data) async {
|
Future<void> _activateKey(ApiKeyInfo data) async {
|
||||||
|
setState(() => _busyKeys.add(data.key));
|
||||||
try {
|
try {
|
||||||
await ref.read(apiKeysProvider.notifier).activate(data.key);
|
await ref.read(apiKeysProvider.notifier).activate(data.key);
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Ключ активирован'),
|
content: Text('Ключ снова активен'),
|
||||||
duration: Duration(seconds: 1),
|
duration: Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -239,14 +300,22 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
content: Text('Ошибка активации ключа: ${describeLoadError(e)}'),
|
content: Text('Ошибка активации ключа: ${describeLoadError(e)}'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _busyKeys.remove(data.key));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LastCreatedKeyBanner extends StatelessWidget {
|
class _LastCreatedKeyBanner extends StatelessWidget {
|
||||||
final String keyValue;
|
final String keyValue;
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
|
||||||
const _LastCreatedKeyBanner({required this.keyValue});
|
const _LastCreatedKeyBanner({
|
||||||
|
required this.keyValue,
|
||||||
|
required this.onDismiss,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -261,13 +330,24 @@ class _LastCreatedKeyBanner extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Row(
|
||||||
'Новый ключ создан! Скопируйте его сейчас:',
|
children: [
|
||||||
style: TextStyle(
|
const Expanded(
|
||||||
fontSize: 13,
|
child: Text(
|
||||||
fontWeight: FontWeight.bold,
|
'Новый ключ создан. Скопируйте его сейчас, потом приложение уже не покажет полный токен.',
|
||||||
color: Colors.deepOrange,
|
style: TextStyle(
|
||||||
),
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onDismiss,
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
tooltip: 'Скрыть',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
@@ -280,7 +360,7 @@ class _LastCreatedKeyBanner extends StatelessWidget {
|
|||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -304,14 +384,15 @@ class _LastCreatedKeyBanner extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Карточка одного API-ключа
|
|
||||||
class _ApiKeyCard extends StatelessWidget {
|
class _ApiKeyCard extends StatelessWidget {
|
||||||
final ApiKeyInfo data;
|
final ApiKeyInfo data;
|
||||||
final VoidCallback onRevoke;
|
final bool busy;
|
||||||
final VoidCallback onActivate;
|
final VoidCallback? onRevoke;
|
||||||
|
final VoidCallback? onActivate;
|
||||||
|
|
||||||
const _ApiKeyCard({
|
const _ApiKeyCard({
|
||||||
required this.data,
|
required this.data,
|
||||||
|
required this.busy,
|
||||||
required this.onRevoke,
|
required this.onRevoke,
|
||||||
required this.onActivate,
|
required this.onActivate,
|
||||||
});
|
});
|
||||||
@@ -329,11 +410,13 @@ class _ApiKeyCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
data.name,
|
child: Text(
|
||||||
style: TextStyle(
|
data.name,
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
color: data.isActive ? Colors.white : Colors.white38,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: data.isActive ? Colors.white : Colors.white38,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (data.isAdmin) ...[
|
if (data.isAdmin) ...[
|
||||||
@@ -372,24 +455,23 @@ class _ApiKeyCard extends StatelessWidget {
|
|||||||
style: const TextStyle(fontSize: 11, color: Colors.white30),
|
style: const TextStyle(fontSize: 11, color: Colors.white30),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
trailing: data.isActive
|
trailing: busy
|
||||||
? IconButton(
|
? const SizedBox(
|
||||||
icon: const Icon(
|
width: 20,
|
||||||
Icons.block,
|
height: 20,
|
||||||
size: 20,
|
child: CircularProgressIndicator(
|
||||||
color: Colors.redAccent,
|
strokeWidth: 2,
|
||||||
|
color: Colors.deepOrange,
|
||||||
),
|
),
|
||||||
tooltip: 'Отозвать',
|
|
||||||
onPressed: onRevoke,
|
|
||||||
)
|
)
|
||||||
: IconButton(
|
: IconButton(
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.check_circle_outline,
|
data.isActive ? Icons.block : Icons.check_circle_outline,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: Colors.green,
|
color: data.isActive ? Colors.redAccent : Colors.green,
|
||||||
),
|
),
|
||||||
tooltip: 'Активировать',
|
tooltip: data.isActive ? 'Отозвать' : 'Активировать',
|
||||||
onPressed: onActivate,
|
onPressed: data.isActive ? onRevoke : onActivate,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
import '../app/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
|
import '../features/groups/group_form_logic.dart';
|
||||||
import '../models/ignis_device.dart';
|
import '../models/ignis_device.dart';
|
||||||
|
import '../models/ignis_group.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/load_error_view.dart';
|
import '../widgets/load_error_view.dart';
|
||||||
|
|
||||||
/// Экран создания новой группы ламп.
|
|
||||||
/// Загружает список устройств, позволяет выбрать нужные.
|
|
||||||
class GroupEditScreen extends ConsumerStatefulWidget {
|
class GroupEditScreen extends ConsumerStatefulWidget {
|
||||||
const GroupEditScreen({super.key});
|
const GroupEditScreen({super.key});
|
||||||
|
|
||||||
@@ -16,47 +17,114 @@ class GroupEditScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _idCtrl = TextEditingController();
|
final _idCtrl = TextEditingController();
|
||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final Set<String> _selectedMacs = {};
|
final Set<String> _selectedMacs = <String>{};
|
||||||
|
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _rescanning = false;
|
bool _rescanning = false;
|
||||||
|
bool _deviceSelectionTouched = false;
|
||||||
|
bool _idEditedManually = false;
|
||||||
|
bool _syncingIdFromName = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadDevices();
|
_nameCtrl.addListener(_handleNameChanged);
|
||||||
|
_idCtrl.addListener(_handleIdChanged);
|
||||||
|
Future<void>.microtask(() async {
|
||||||
|
await _loadDevices();
|
||||||
|
try {
|
||||||
|
await ref.read(groupsProvider.notifier).refresh();
|
||||||
|
} catch (_) {
|
||||||
|
// Покажем текущее состояние через экран, без лишнего шума.
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_nameCtrl.removeListener(_handleNameChanged);
|
||||||
|
_idCtrl.removeListener(_handleIdChanged);
|
||||||
_idCtrl.dispose();
|
_idCtrl.dispose();
|
||||||
_nameCtrl.dispose();
|
_nameCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleNameChanged() {
|
||||||
|
if (_idEditedManually) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final slug = slugifyGroupId(_nameCtrl.text);
|
||||||
|
_syncingIdFromName = true;
|
||||||
|
_idCtrl.value = _idCtrl.value.copyWith(
|
||||||
|
text: slug,
|
||||||
|
selection: TextSelection.collapsed(offset: slug.length),
|
||||||
|
composing: TextRange.empty,
|
||||||
|
);
|
||||||
|
_syncingIdFromName = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleIdChanged() {
|
||||||
|
if (_syncingIdFromName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final suggested = slugifyGroupId(_nameCtrl.text);
|
||||||
|
final current = _idCtrl.text.trim();
|
||||||
|
_idEditedManually = current.isNotEmpty && current != suggested;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadDevices() async {
|
Future<void> _loadDevices() async {
|
||||||
await ref.read(devicesProvider.notifier).load();
|
await ref.read(devicesProvider.notifier).load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Пересканировать сеть и перезагрузить устройства
|
|
||||||
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();
|
await ref.read(apiProvider).rescanNetwork();
|
||||||
// Подождать немного -- сканирование асинхронное
|
|
||||||
await Future.delayed(const Duration(seconds: 3));
|
var changed = false;
|
||||||
await ref.read(devicesProvider.notifier).load();
|
for (var attempt = 0; attempt < 6; attempt++) {
|
||||||
} catch (e) {
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
if (mounted) {
|
await ref.read(devicesProvider.notifier).load();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final currentIds = ref
|
||||||
SnackBar(
|
.read(devicesProvider)
|
||||||
content: Text('Ошибка сканирования: ${describeLoadError(e)}'),
|
.data
|
||||||
),
|
.map((device) => device.groupMemberId)
|
||||||
);
|
.toSet();
|
||||||
|
if (!_sameSet(beforeIds, currentIds)) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
changed
|
||||||
|
? 'Список устройств обновился'
|
||||||
|
: 'Сканирование завершилось, но новых устройств пока не видно',
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Ошибка сканирования: ${describeLoadError(e)}')),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _rescanning = false);
|
if (mounted) {
|
||||||
|
setState(() => _rescanning = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +132,13 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final devicesState = ref.watch(devicesProvider);
|
final devicesState = ref.watch(devicesProvider);
|
||||||
final devices = devicesState.data;
|
final devices = devicesState.data;
|
||||||
|
final groups = ref.watch(groupsProvider);
|
||||||
|
final conflicts = findGroupMembershipConflicts(_selectedMacs, groups);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('НОВАЯ ГРУППА'),
|
title: const Text('НОВАЯ ГРУППА'),
|
||||||
actions: [
|
actions: [
|
||||||
// Кнопка ресканирования сети
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: _rescanning
|
icon: _rescanning
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
@@ -83,99 +152,129 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: Form(
|
||||||
padding: const EdgeInsets.all(16),
|
key: _formKey,
|
||||||
child: Column(
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.all(16),
|
||||||
// ID группы
|
child: Column(
|
||||||
TextField(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
controller: _idCtrl,
|
children: [
|
||||||
decoration: const InputDecoration(
|
TextFormField(
|
||||||
labelText: 'ID группы (например "bedroom")',
|
controller: _nameCtrl,
|
||||||
prefixIcon: Icon(Icons.tag),
|
decoration: const InputDecoration(
|
||||||
),
|
labelText: 'Название группы',
|
||||||
),
|
hintText: 'Например: Спальня',
|
||||||
const SizedBox(height: 12),
|
prefixIcon: Icon(Icons.label),
|
||||||
|
|
||||||
// Название группы
|
|
||||||
TextField(
|
|
||||||
controller: _nameCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Название (например "Спальня")',
|
|
||||||
prefixIcon: Icon(Icons.label),
|
|
||||||
),
|
|
||||||
textCapitalization: TextCapitalization.sentences,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Заголовок списка устройств
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Устройства (${_selectedMacs.length} выбрано)',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
TextButton(
|
textCapitalization: TextCapitalization.sentences,
|
||||||
onPressed: devices.isEmpty
|
validator: (value) => validateGroupName(value ?? ''),
|
||||||
? null
|
),
|
||||||
: () {
|
const SizedBox(height: 12),
|
||||||
setState(() {
|
TextFormField(
|
||||||
if (_selectedMacs.length == devices.length) {
|
controller: _idCtrl,
|
||||||
_selectedMacs.clear();
|
decoration: InputDecoration(
|
||||||
} else {
|
labelText: 'ID группы',
|
||||||
for (final d in devices) {
|
hintText: 'Например: bedroom',
|
||||||
_selectedMacs.add(d.groupMemberId);
|
helperText: _idEditedManually
|
||||||
|
? 'Используется в API и расписаниях'
|
||||||
|
: 'Подставляется автоматически из названия',
|
||||||
|
prefixIcon: const Icon(Icons.tag),
|
||||||
|
),
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
validator: (value) => validateGroupId(value ?? '', groups),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Выберите устройства, которые будут управляться как одна группа.',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Устройства (${_selectedMacs.length} выбрано)',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: devices.isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
_deviceSelectionTouched = true;
|
||||||
|
if (_selectedMacs.length == devices.length) {
|
||||||
|
_selectedMacs.clear();
|
||||||
|
} else {
|
||||||
|
_selectedMacs
|
||||||
|
..clear()
|
||||||
|
..addAll(
|
||||||
|
devices.map(
|
||||||
|
(device) => device.groupMemberId,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
},
|
||||||
},
|
child: Text(
|
||||||
|
_selectedMacs.length == devices.length
|
||||||
|
? 'Снять все'
|
||||||
|
: 'Выбрать все',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_deviceSelectionTouched && _selectedMacs.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 4, bottom: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
_selectedMacs.length == devices.length
|
'Выберите хотя бы одно устройство',
|
||||||
? 'Снять все'
|
style: TextStyle(color: Colors.redAccent, fontSize: 12),
|
||||||
: 'Выбрать все',
|
),
|
||||||
style: const TextStyle(fontSize: 12),
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (conflicts.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _ConflictBanner(conflicts: conflicts),
|
||||||
|
),
|
||||||
|
Expanded(child: _buildDevices(devicesState, devices)),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: 12,
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.deepOrange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('СОЗДАТЬ ГРУППУ'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// Список устройств
|
|
||||||
Expanded(child: _buildDevices(devicesState, devices)),
|
|
||||||
|
|
||||||
// Кнопка сохранения
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
|
||||||
),
|
),
|
||||||
child: SizedBox(
|
],
|
||||||
width: double.infinity,
|
),
|
||||||
height: 48,
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.deepOrange,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
onPressed: _saving ? null : _save,
|
|
||||||
child: _saving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text('СОЗДАТЬ ГРУППУ'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -203,7 +302,7 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
if (devices.isEmpty) {
|
if (devices.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Устройства не найдены.\nПопробуйте пересканировать сеть.',
|
'Устройства не найдены.\nПересканируйте сеть и попробуйте снова.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white38),
|
style: TextStyle(color: Colors.white38),
|
||||||
),
|
),
|
||||||
@@ -231,26 +330,26 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final deviceIndex = index - statusHeaderCount;
|
final device = devices[index - statusHeaderCount];
|
||||||
final d = devices[deviceIndex];
|
final selected = _selectedMacs.contains(device.groupMemberId);
|
||||||
final selected = _selectedMacs.contains(d.groupMemberId);
|
|
||||||
|
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
value: selected,
|
value: selected,
|
||||||
activeColor: Colors.deepOrange,
|
activeColor: Colors.deepOrange,
|
||||||
title: Text(d.name),
|
title: Text(device.name),
|
||||||
subtitle: d.subtitle == null
|
subtitle: device.subtitle == null
|
||||||
? null
|
? null
|
||||||
: Text(
|
: Text(
|
||||||
d.subtitle!,
|
device.subtitle!,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white38),
|
style: const TextStyle(fontSize: 11, color: Colors.white38),
|
||||||
),
|
),
|
||||||
onChanged: (v) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (v == true) {
|
_deviceSelectionTouched = true;
|
||||||
_selectedMacs.add(d.groupMemberId);
|
if (value == true) {
|
||||||
|
_selectedMacs.add(device.groupMemberId);
|
||||||
} else {
|
} else {
|
||||||
_selectedMacs.remove(d.groupMemberId);
|
_selectedMacs.remove(device.groupMemberId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -260,38 +359,106 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final id = _idCtrl.text.trim();
|
FocusScope.of(context).unfocus();
|
||||||
final name = _nameCtrl.text.trim();
|
setState(() => _deviceSelectionTouched = true);
|
||||||
|
|
||||||
if (id.isEmpty || name.isEmpty) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('Укажите ID и название')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_selectedMacs.isEmpty) {
|
if (_selectedMacs.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Выберите хотя бы одно устройство')),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _saving = true);
|
final groups = ref.read(groupsProvider);
|
||||||
|
final conflicts = findGroupMembershipConflicts(_selectedMacs, groups);
|
||||||
try {
|
if (conflicts.isNotEmpty) {
|
||||||
await ref.read(apiProvider).createGroup(id, name, _selectedMacs.toList());
|
final shouldContinue = await _confirmConflicts(conflicts);
|
||||||
// Обновить список групп
|
if (shouldContinue != true) {
|
||||||
await ref.read(groupsProvider.notifier).refresh();
|
return;
|
||||||
if (mounted) Navigator.of(context).pop();
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Ошибка создания: ${describeLoadError(e)}')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) setState(() => _saving = false);
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
final id = _idCtrl.text.trim().toLowerCase();
|
||||||
|
final name = _nameCtrl.text.trim();
|
||||||
|
await ref.read(apiProvider).createGroup(id, name, _selectedMacs.toList());
|
||||||
|
await ref.read(groupsProvider.notifier).refresh();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Группа создана'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Ошибка создания: ${describeLoadError(e)}')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _confirmConflicts(List<IgnisGroup> conflicts) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Устройства уже состоят в других группах'),
|
||||||
|
content: Text(
|
||||||
|
'Выбранные устройства уже входят в: ${conflicts.map((group) => group.name).join(', ')}.\n\nПродолжить создание новой группы?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.deepOrange),
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Продолжить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _sameSet(Set<String> left, Set<String> right) {
|
||||||
|
if (left.length != right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (final item in left) {
|
||||||
|
if (!right.contains(item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConflictBanner extends StatelessWidget {
|
||||||
|
final List<IgnisGroup> conflicts;
|
||||||
|
|
||||||
|
const _ConflictBanner({required this.conflicts});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.amber.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Внимание: часть выбранных устройств уже состоит в группах ${conflicts.map((group) => group.name).join(', ')}.',
|
||||||
|
style: const TextStyle(color: Colors.amber),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class HomeEditScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final _urlCtrl = TextEditingController();
|
final _urlCtrl = TextEditingController();
|
||||||
final _keyCtrl = TextEditingController();
|
final _keyCtrl = TextEditingController();
|
||||||
@@ -85,158 +86,215 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ')),
|
appBar: AppBar(title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ')),
|
||||||
body: SingleChildScrollView(
|
body: Form(
|
||||||
padding: const EdgeInsets.all(20),
|
key: _formKey,
|
||||||
child: Column(
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
padding: const EdgeInsets.all(20),
|
||||||
TextField(
|
child: Column(
|
||||||
controller: _nameCtrl,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: const InputDecoration(
|
children: [
|
||||||
labelText: 'Название (например "Квартира")',
|
TextFormField(
|
||||||
prefixIcon: Icon(Icons.home),
|
controller: _nameCtrl,
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
textCapitalization: TextCapitalization.sentences,
|
labelText: 'Название',
|
||||||
),
|
hintText: 'Например: Квартира',
|
||||||
const SizedBox(height: 12),
|
prefixIcon: Icon(Icons.home),
|
||||||
TextField(
|
|
||||||
controller: _urlCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Адрес сервера (например ignis.akokos.ru)',
|
|
||||||
prefixIcon: Icon(Icons.dns),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.url,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: _keyCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'API Key',
|
|
||||||
prefixIcon: Icon(Icons.key),
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// ─── GPS-координаты (опционально) ───
|
|
||||||
const Text(
|
|
||||||
'Координаты дома (опционально)',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white54,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
const Text(
|
|
||||||
'Для автоматизации по геолокации',
|
|
||||||
style: TextStyle(color: Colors.white30, fontSize: 12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _latCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Широта',
|
|
||||||
prefixIcon: Icon(Icons.location_on, size: 20),
|
|
||||||
hintText: '51.128',
|
|
||||||
),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
signed: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
textCapitalization: TextCapitalization.sentences,
|
||||||
Expanded(
|
validator: (value) => (value?.trim().isEmpty ?? true)
|
||||||
child: TextField(
|
? 'Укажите название дома'
|
||||||
controller: _lonCtrl,
|
: null,
|
||||||
decoration: const InputDecoration(
|
),
|
||||||
labelText: 'Долгота',
|
const SizedBox(height: 12),
|
||||||
prefixIcon: Icon(Icons.location_on, size: 20),
|
TextFormField(
|
||||||
hintText: '71.430',
|
controller: _urlCtrl,
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
labelText: 'Адрес сервера',
|
||||||
decimal: true,
|
hintText: 'Например: ignis.akokos.ru',
|
||||||
signed: true,
|
helperText: 'Можно без https, приложение подставит его само',
|
||||||
),
|
prefixIcon: Icon(Icons.dns),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
keyboardType: TextInputType.url,
|
||||||
),
|
validator: (value) {
|
||||||
|
final rawUrl = value?.trim() ?? '';
|
||||||
const SizedBox(height: 16),
|
if (rawUrl.isEmpty) {
|
||||||
|
return 'Укажите адрес сервера';
|
||||||
// ─── Геофенс ───
|
}
|
||||||
SwitchListTile(
|
try {
|
||||||
title: const Text('Выключать свет при уходе'),
|
final normalized = IgnisApi.normalizeBaseUrl(rawUrl);
|
||||||
subtitle: Text(
|
final parsed = Uri.parse(normalized);
|
||||||
_hasCoordinates
|
if ((parsed.scheme != 'http' && parsed.scheme != 'https') ||
|
||||||
? 'Автовыключение при удалении на 500 м'
|
parsed.host.isEmpty) {
|
||||||
: 'Задайте координаты для активации',
|
return 'Некорректный адрес сервера';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return 'Некорректный адрес сервера';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _keyCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'API Key',
|
||||||
|
helperText: 'Ключ проверяется перед сохранением дома',
|
||||||
|
prefixIcon: Icon(Icons.key),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
validator: (value) =>
|
||||||
|
(value?.trim().isEmpty ?? true) ? 'Укажите API key' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Координаты дома (опционально)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
color: Colors.white54,
|
||||||
color: _hasCoordinates ? Colors.white38 : Colors.white24,
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
value: _geofenceEnabled,
|
const SizedBox(height: 4),
|
||||||
activeThumbColor: Colors.deepOrange,
|
const Text(
|
||||||
onChanged: _hasCoordinates
|
'Нужны только для расстояния и автовыключения по геолокации',
|
||||||
? (v) => setState(() => _geofenceEnabled = v)
|
style: TextStyle(color: Colors.white30, fontSize: 12),
|
||||||
: null,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
secondary: Icon(
|
|
||||||
Icons.directions_walk,
|
|
||||||
color: _geofenceEnabled && _hasCoordinates
|
|
||||||
? Colors.deepOrange
|
|
||||||
: Colors.white24,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
if (_geofenceEnabled && _hasCoordinates)
|
Row(
|
||||||
const Padding(
|
children: [
|
||||||
padding: EdgeInsets.only(left: 40, bottom: 4),
|
Expanded(
|
||||||
child: Text(
|
child: TextFormField(
|
||||||
'Проверка раз в ~15 мин (ограничение Android).\n'
|
controller: _latCtrl,
|
||||||
'Работает только для текущего активного дома.\n'
|
decoration: const InputDecoration(
|
||||||
'Нужны фоновые разрешения на геолокацию и уведомления.',
|
labelText: 'Широта',
|
||||||
style: TextStyle(fontSize: 11, color: Colors.white24),
|
prefixIcon: Icon(Icons.location_on, size: 20),
|
||||||
|
hintText: '51.128',
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
signed: true,
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
final latText = value?.trim() ?? '';
|
||||||
|
final lonText = _lonCtrl.text.trim();
|
||||||
|
if (latText.isEmpty && lonText.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (latText.isEmpty || lonText.isEmpty) {
|
||||||
|
return 'Введите обе координаты';
|
||||||
|
}
|
||||||
|
final lat = double.tryParse(latText);
|
||||||
|
if (lat == null || lat < -90 || lat > 90) {
|
||||||
|
return 'От -90 до 90';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _lonCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Долгота',
|
||||||
|
prefixIcon: Icon(Icons.location_on, size: 20),
|
||||||
|
hintText: '71.430',
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
signed: true,
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
final lonText = value?.trim() ?? '';
|
||||||
|
final latText = _latCtrl.text.trim();
|
||||||
|
if (latText.isEmpty && lonText.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (latText.isEmpty || lonText.isEmpty) {
|
||||||
|
return 'Введите обе координаты';
|
||||||
|
}
|
||||||
|
final lon = double.tryParse(lonText);
|
||||||
|
if (lon == null || lon < -180 || lon > 180) {
|
||||||
|
return 'От -180 до 180';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Выключать свет при уходе'),
|
||||||
|
subtitle: Text(
|
||||||
|
_hasCoordinates
|
||||||
|
? 'Автовыключение при удалении на 500 м'
|
||||||
|
: 'Задайте координаты для активации',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _hasCoordinates ? Colors.white38 : Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
value: _geofenceEnabled,
|
||||||
|
activeThumbColor: Colors.deepOrange,
|
||||||
|
onChanged: _hasCoordinates
|
||||||
|
? (v) => setState(() => _geofenceEnabled = v)
|
||||||
|
: null,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
secondary: Icon(
|
||||||
|
Icons.directions_walk,
|
||||||
|
color: _geofenceEnabled && _hasCoordinates
|
||||||
|
? Colors.deepOrange
|
||||||
|
: Colors.white24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_geofenceEnabled && _hasCoordinates)
|
||||||
const SizedBox(height: 24),
|
const Padding(
|
||||||
SizedBox(
|
padding: EdgeInsets.only(left: 40, bottom: 4),
|
||||||
width: double.infinity,
|
child: Text(
|
||||||
height: 48,
|
'Проверка раз в ~15 мин (ограничение Android).\n'
|
||||||
child: ElevatedButton(
|
'Работает только для текущего активного дома.\n'
|
||||||
style: ElevatedButton.styleFrom(
|
'Нужны фоновые разрешения на геолокацию и уведомления.',
|
||||||
backgroundColor: Colors.deepOrange,
|
style: TextStyle(fontSize: 11, color: Colors.white24),
|
||||||
foregroundColor: Colors.white,
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.deepOrange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
|
||||||
),
|
),
|
||||||
onPressed: _saving ? null : _save,
|
|
||||||
child: _saving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
|
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
// Отступ внизу для системных кнопок
|
],
|
||||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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 key = _keyCtrl.text.trim();
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
import '../app/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
|
import '../features/schedules/schedule_form_logic.dart';
|
||||||
import '../models/schedule_task.dart';
|
import '../models/schedule_task.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/load_error_view.dart';
|
import '../widgets/load_error_view.dart';
|
||||||
|
|
||||||
/// Экран управления расписаниями.
|
|
||||||
/// Показывает все задачи (one-shot и cron), позволяет создавать и удалять.
|
|
||||||
class SchedulesScreen extends ConsumerStatefulWidget {
|
class SchedulesScreen extends ConsumerStatefulWidget {
|
||||||
const SchedulesScreen({super.key});
|
const SchedulesScreen({super.key});
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@ class SchedulesScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
||||||
|
final Set<String> _cancellingJobIds = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -26,6 +28,74 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
|||||||
await ref.read(tasksProvider.notifier).load();
|
await ref.read(tasksProvider.notifier).load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelTask(ScheduleTask task) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Отменить расписание?'),
|
||||||
|
content: Text(
|
||||||
|
task.isCron
|
||||||
|
? 'Повтор для "${task.targetId}" будет удалён.'
|
||||||
|
: 'Одноразовый запуск для "${task.targetId}" будет удалён.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Нет'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text(
|
||||||
|
'Удалить',
|
||||||
|
style: TextStyle(color: Colors.redAccent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _cancellingJobIds.add(task.jobId));
|
||||||
|
try {
|
||||||
|
await ref.read(tasksProvider.notifier).cancel(task.jobId);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Расписание удалено'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Не удалось удалить расписание: ${describeLoadError(e)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _cancellingJobIds.remove(task.jobId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddDialog() async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: const Color(0xFF1E1E1E),
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (_) => const _AddScheduleSheet(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tasksState = ref.watch(tasksProvider);
|
final tasksState = ref.watch(tasksProvider);
|
||||||
@@ -36,25 +106,12 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
|||||||
body: _buildContent(tasksState, tasks),
|
body: _buildContent(tasksState, tasks),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
backgroundColor: Colors.deepOrange,
|
backgroundColor: Colors.deepOrange,
|
||||||
onPressed: () => _showAddDialog(context),
|
onPressed: _showAddDialog,
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Диалог создания расписания
|
|
||||||
void _showAddDialog(BuildContext context) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: const Color(0xFF1E1E1E),
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
),
|
|
||||||
builder: (ctx) => const _AddScheduleSheet(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContent(
|
Widget _buildContent(
|
||||||
LoadState<List<ScheduleTask>> tasksState,
|
LoadState<List<ScheduleTask>> tasksState,
|
||||||
List<ScheduleTask> tasks,
|
List<ScheduleTask> tasks,
|
||||||
@@ -75,16 +132,27 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.isEmpty) {
|
if (tasks.isEmpty) {
|
||||||
return const Center(
|
return RefreshIndicator(
|
||||||
child: Column(
|
color: Colors.deepOrange,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onRefresh: _load,
|
||||||
children: [
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: const [
|
||||||
|
SizedBox(height: 120),
|
||||||
Icon(Icons.schedule, size: 64, color: Colors.white24),
|
Icon(Icons.schedule, size: 64, color: Colors.white24),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Нет активных расписаний',
|
'Пока нет активных расписаний',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 16),
|
style: TextStyle(color: Colors.white54, fontSize: 16),
|
||||||
),
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Добавьте таймер или повтор для нужной группы.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.white30),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -116,80 +184,87 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final taskIndex = index - statusHeaderCount;
|
final task = tasks[index - statusHeaderCount];
|
||||||
return _TaskCard(task: tasks[taskIndex]);
|
final busy = _cancellingJobIds.contains(task.jobId);
|
||||||
|
return _TaskCard(
|
||||||
|
task: task,
|
||||||
|
busy: busy,
|
||||||
|
onDelete: busy ? null : () => _cancelTask(task),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Карточка одной задачи расписания
|
class _TaskCard extends StatelessWidget {
|
||||||
class _TaskCard extends ConsumerWidget {
|
|
||||||
final ScheduleTask task;
|
final ScheduleTask task;
|
||||||
|
final bool busy;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
const _TaskCard({required this.task});
|
const _TaskCard({
|
||||||
|
required this.task,
|
||||||
|
required this.busy,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Container(
|
||||||
task.isCron ? Icons.repeat : Icons.timer,
|
width: 42,
|
||||||
color: Colors.deepOrange,
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.deepOrange.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
task.isCron ? Icons.repeat : Icons.alarm,
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
task.title,
|
'${task.actionText} группу ${task.targetId}',
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Padding(
|
||||||
task.subtitle,
|
padding: const EdgeInsets.only(top: 6),
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.white54),
|
child: Text(
|
||||||
),
|
_taskDescription(task),
|
||||||
trailing: IconButton(
|
style: const TextStyle(fontSize: 12, color: Colors.white60),
|
||||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
),
|
||||||
onPressed: () => _confirmCancel(context, ref, task.jobId),
|
|
||||||
),
|
),
|
||||||
|
trailing: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||||
|
tooltip: 'Удалить',
|
||||||
|
onPressed: onDelete,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmCancel(BuildContext context, WidgetRef ref, String jobId) {
|
String _taskDescription(ScheduleTask task) {
|
||||||
showDialog(
|
if (task.isCron) {
|
||||||
context: context,
|
final hour = task.hour?.padLeft(2, '0') ?? '--';
|
||||||
builder: (ctx) => AlertDialog(
|
final minute = task.minute?.padLeft(2, '0') ?? '--';
|
||||||
title: const Text('Отменить задачу?'),
|
final days = describeCronWeekdaysExpression(task.dayOfWeek);
|
||||||
actions: [
|
return 'Повтор: $days в $hour:$minute';
|
||||||
TextButton(
|
}
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
return 'Один раз: ${formatRunAtLabel(task.runAt)}';
|
||||||
child: const Text('Нет'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.of(ctx).pop();
|
|
||||||
try {
|
|
||||||
await ref.read(tasksProvider.notifier).cancel(jobId);
|
|
||||||
} catch (e) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Ошибка отмены задачи: ${describeLoadError(e)}',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Да', style: TextStyle(color: Colors.redAccent)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Нижний лист для создания расписания
|
|
||||||
class _AddScheduleSheet extends ConsumerStatefulWidget {
|
class _AddScheduleSheet extends ConsumerStatefulWidget {
|
||||||
const _AddScheduleSheet();
|
const _AddScheduleSheet();
|
||||||
|
|
||||||
@@ -198,34 +273,98 @@ class _AddScheduleSheet extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
||||||
/// Тип: 'once' или 'cron'
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _onceAtCtrl = TextEditingController();
|
||||||
|
final _cronTimeCtrl = TextEditingController();
|
||||||
|
|
||||||
String _type = 'once';
|
String _type = 'once';
|
||||||
|
|
||||||
/// Общие поля
|
|
||||||
String? _selectedGroupId;
|
String? _selectedGroupId;
|
||||||
bool _targetState = false; // включить или выключить
|
bool _targetState = false;
|
||||||
|
bool _saving = false;
|
||||||
|
DateTime _onceAt = defaultOnceScheduleTime();
|
||||||
|
TimeOfDay _cronTime = const TimeOfDay(hour: 22, minute: 0);
|
||||||
|
Set<int> _selectedWeekdays = scheduleWeekdayOptions
|
||||||
|
.map((option) => option.backendValue)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
/// Поля для once
|
@override
|
||||||
int _hoursFromNow = 4;
|
void initState() {
|
||||||
|
super.initState();
|
||||||
/// Поля для cron
|
_syncControllers();
|
||||||
final _hourCtrl = TextEditingController(text: '22');
|
Future<void>.microtask(() async {
|
||||||
final _minuteCtrl = TextEditingController(text: '00');
|
try {
|
||||||
final _dowCtrl = TextEditingController(text: '*');
|
await ref.read(groupsProvider.notifier).refresh();
|
||||||
|
} catch (_) {
|
||||||
|
// Ошибка загрузки уже отражается через groupsLoadStateProvider.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_hourCtrl.dispose();
|
_onceAtCtrl.dispose();
|
||||||
_minuteCtrl.dispose();
|
_cronTimeCtrl.dispose();
|
||||||
_dowCtrl.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncControllers() {
|
||||||
|
_onceAtCtrl.text = formatLocalScheduleDateTime(_onceAt);
|
||||||
|
_cronTimeCtrl.text =
|
||||||
|
'${_cronTime.hour.toString().padLeft(2, '0')}:${_cronTime.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickOnceDateTime() async {
|
||||||
|
final pickedDate = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _onceAt,
|
||||||
|
firstDate: DateTime.now(),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (!mounted || pickedDate == null) return;
|
||||||
|
|
||||||
|
final pickedTime = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(_onceAt),
|
||||||
|
);
|
||||||
|
if (!mounted || pickedTime == null) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_onceAt = DateTime(
|
||||||
|
pickedDate.year,
|
||||||
|
pickedDate.month,
|
||||||
|
pickedDate.day,
|
||||||
|
pickedTime.hour,
|
||||||
|
pickedTime.minute,
|
||||||
|
);
|
||||||
|
_syncControllers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickCronTime() async {
|
||||||
|
final pickedTime = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _cronTime,
|
||||||
|
);
|
||||||
|
if (!mounted || pickedTime == null) return;
|
||||||
|
setState(() {
|
||||||
|
_cronTime = pickedTime;
|
||||||
|
_syncControllers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final groups = ref.watch(groupsProvider);
|
final groups = ref.watch(groupsProvider);
|
||||||
|
final groupsLoadState = ref.watch(groupsLoadStateProvider);
|
||||||
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||||
final systemPadding = MediaQuery.of(context).padding.bottom;
|
final systemPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
final cronWeekdaysError = _type == 'cron'
|
||||||
|
? validateCronSchedule(
|
||||||
|
hour: _cronTime.hour,
|
||||||
|
minute: _cronTime.minute,
|
||||||
|
weekdays: _selectedWeekdays,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
@@ -235,137 +374,278 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
|||||||
bottom: bottomPadding + systemPadding + 20,
|
bottom: bottomPadding + systemPadding + 20,
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Form(
|
||||||
mainAxisSize: MainAxisSize.min,
|
key: _formKey,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
children: [
|
child: Column(
|
||||||
// Заголовок
|
mainAxisSize: MainAxisSize.min,
|
||||||
const Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
'Новое расписание',
|
children: [
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
const Text(
|
||||||
),
|
'Новое расписание',
|
||||||
const SizedBox(height: 16),
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
|
||||||
// Тип расписания
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text('Таймер'),
|
|
||||||
selected: _type == 'once',
|
|
||||||
selectedColor: Colors.deepOrange,
|
|
||||||
onSelected: (_) => setState(() => _type = 'once'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text('Cron (повтор)'),
|
|
||||||
selected: _type == 'cron',
|
|
||||||
selectedColor: Colors.deepOrange,
|
|
||||||
onSelected: (_) => setState(() => _type = 'cron'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Выбор группы
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
initialValue: _selectedGroupId,
|
|
||||||
decoration: const InputDecoration(labelText: 'Группа'),
|
|
||||||
items: groups.map((g) {
|
|
||||||
final id = g.id;
|
|
||||||
final name = g.name;
|
|
||||||
return DropdownMenuItem(value: id, child: Text(name));
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (v) => setState(() => _selectedGroupId = v),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Действие: включить / выключить
|
|
||||||
SwitchListTile(
|
|
||||||
title: Text(_targetState ? 'Включить' : 'Выключить'),
|
|
||||||
subtitle: const Text('Действие при срабатывании'),
|
|
||||||
value: _targetState,
|
|
||||||
activeThumbColor: Colors.deepOrange,
|
|
||||||
onChanged: (v) => setState(() => _targetState = v),
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// ─── Once: через сколько часов ───
|
|
||||||
if (_type == 'once') ...[
|
|
||||||
Text(
|
|
||||||
'Через $_hoursFromNow ч.',
|
|
||||||
style: const TextStyle(color: Colors.white70),
|
|
||||||
),
|
),
|
||||||
Slider(
|
const SizedBox(height: 6),
|
||||||
value: _hoursFromNow.toDouble(),
|
const Text(
|
||||||
min: 1,
|
'Сейчас расписания создаются для групп. Сначала выберите группу, потом задайте таймер или повтор.',
|
||||||
max: 24,
|
style: TextStyle(color: Colors.white54),
|
||||||
divisions: 23,
|
|
||||||
label: '$_hoursFromNow ч.',
|
|
||||||
activeColor: Colors.deepOrange,
|
|
||||||
onChanged: (v) => setState(() => _hoursFromNow = v.toInt()),
|
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ─── Cron: час, минута, дни недели ───
|
|
||||||
if (_type == 'cron') ...[
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
ChoiceChip(
|
||||||
child: TextField(
|
label: const Text('Один раз'),
|
||||||
controller: _hourCtrl,
|
selected: _type == 'once',
|
||||||
decoration: const InputDecoration(
|
selectedColor: Colors.deepOrange,
|
||||||
labelText: 'Час (0-23)',
|
onSelected: (_) => setState(() => _type = 'once'),
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
ChoiceChip(
|
||||||
child: TextField(
|
label: const Text('Повтор'),
|
||||||
controller: _minuteCtrl,
|
selected: _type == 'cron',
|
||||||
decoration: const InputDecoration(
|
selectedColor: Colors.deepOrange,
|
||||||
labelText: 'Минута (0-59)',
|
onSelected: (_) => setState(() => _type = 'cron'),
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
if (groupsLoadState.isLoading && groups.isEmpty)
|
||||||
controller: _dowCtrl,
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 12),
|
||||||
|
child: LinearProgressIndicator(color: Colors.deepOrange),
|
||||||
|
),
|
||||||
|
if (groupsLoadState.hasError && groups.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: LoadErrorBanner(
|
||||||
|
title: 'Не удалось загрузить группы',
|
||||||
|
message:
|
||||||
|
groupsLoadState.errorMessage ?? 'Неизвестная ошибка',
|
||||||
|
onRetry: () => ref.read(groupsProvider.notifier).refresh(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _selectedGroupId,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Дни недели (* = каждый, 0-6 = вс-сб)',
|
labelText: 'Группа',
|
||||||
helperText: 'Например: 1-5 (пн-пт), 0,6 (выходные)',
|
helperText: 'Расписание применяется к одной группе света',
|
||||||
|
),
|
||||||
|
validator: (value) => value == null ? 'Выберите группу' : null,
|
||||||
|
items: groups
|
||||||
|
.map(
|
||||||
|
(group) => DropdownMenuItem<String>(
|
||||||
|
value: group.id,
|
||||||
|
child: Text(group.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: groups.isEmpty
|
||||||
|
? null
|
||||||
|
: (value) => setState(() => _selectedGroupId = value),
|
||||||
|
),
|
||||||
|
if (groups.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
'Сначала создайте хотя бы одну группу света.',
|
||||||
|
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(_targetState ? 'Включить свет' : 'Выключить свет'),
|
||||||
|
subtitle: const Text('Что должно произойти по расписанию'),
|
||||||
|
value: _targetState,
|
||||||
|
activeThumbColor: Colors.deepOrange,
|
||||||
|
onChanged: (value) => setState(() => _targetState = value),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_type == 'once') ...[
|
||||||
|
TextFormField(
|
||||||
|
controller: _onceAtCtrl,
|
||||||
|
readOnly: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Дата и время',
|
||||||
|
helperText: 'Запустится один раз в выбранный момент',
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.edit_calendar),
|
||||||
|
onPressed: _pickOnceDateTime,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: _pickOnceDateTime,
|
||||||
|
validator: (_) =>
|
||||||
|
_type == 'once' ? validateOnceSchedule(_onceAt) : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_QuickTimeChip(
|
||||||
|
label: '+1 ч',
|
||||||
|
onTap: () =>
|
||||||
|
_applyQuickOnceOffset(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
_QuickTimeChip(
|
||||||
|
label: '+4 ч',
|
||||||
|
onTap: () =>
|
||||||
|
_applyQuickOnceOffset(const Duration(hours: 4)),
|
||||||
|
),
|
||||||
|
_QuickTimeChip(
|
||||||
|
label: 'Завтра 09:00',
|
||||||
|
onTap: _applyTomorrowMorningPreset,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ScheduleSummaryCard(
|
||||||
|
icon: Icons.alarm,
|
||||||
|
text:
|
||||||
|
'${_targetState ? 'Включить' : 'Выключить'} группу в ${_onceAtCtrl.text}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_type == 'cron') ...[
|
||||||
|
TextFormField(
|
||||||
|
controller: _cronTimeCtrl,
|
||||||
|
readOnly: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Время',
|
||||||
|
helperText:
|
||||||
|
'Повтор будет выполняться каждый выбранный день',
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
onPressed: _pickCronTime,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: _pickCronTime,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Дни недели',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Каждый день'),
|
||||||
|
selected:
|
||||||
|
_selectedWeekdays.length ==
|
||||||
|
scheduleWeekdayOptions.length,
|
||||||
|
selectedColor: Colors.deepOrange,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
_selectedWeekdays = selected
|
||||||
|
? scheduleWeekdayOptions
|
||||||
|
.map((option) => option.backendValue)
|
||||||
|
.toSet()
|
||||||
|
: <int>{};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
for (final option in scheduleWeekdayOptions)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(option.shortLabel),
|
||||||
|
selected: _selectedWeekdays.contains(
|
||||||
|
option.backendValue,
|
||||||
|
),
|
||||||
|
selectedColor: Colors.deepOrange,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedWeekdays.add(option.backendValue);
|
||||||
|
} else {
|
||||||
|
_selectedWeekdays.remove(option.backendValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (cronWeekdaysError != null && _selectedWeekdays.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
cronWeekdaysError,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.redAccent,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ScheduleSummaryCard(
|
||||||
|
icon: Icons.repeat,
|
||||||
|
text:
|
||||||
|
'${_targetState ? 'Включать' : 'Выключать'} группу ${describeCronWeekdays(_selectedWeekdays)} в ${_cronTimeCtrl.text}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.deepOrange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _saving || groups.isEmpty ? null : _save,
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('СОЗДАТЬ РАСПИСАНИЕ'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Кнопка создания
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 48,
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.deepOrange,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
onPressed: _selectedGroupId == null ? null : _save,
|
|
||||||
child: const Text('СОЗДАТЬ'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
void _applyQuickOnceOffset(Duration duration) {
|
||||||
if (_selectedGroupId == null) return;
|
setState(() {
|
||||||
|
_onceAt = DateTime.now().add(duration);
|
||||||
|
_syncControllers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyTomorrowMorningPreset() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
setState(() {
|
||||||
|
_onceAt = DateTime(now.year, now.month, now.day + 1, 9);
|
||||||
|
_syncControllers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_type == 'cron' && _selectedWeekdays.isEmpty) {
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedGroupId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
if (_type == 'once') {
|
if (_type == 'once') {
|
||||||
await ref
|
await ref
|
||||||
@@ -373,26 +653,87 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
|||||||
.addOnce(
|
.addOnce(
|
||||||
targetId: _selectedGroupId!,
|
targetId: _selectedGroupId!,
|
||||||
targetState: _targetState,
|
targetState: _targetState,
|
||||||
hoursFromNow: _hoursFromNow,
|
runAt: _onceAt.toUtc().toIso8601String(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await ref
|
await ref
|
||||||
.read(tasksProvider.notifier)
|
.read(tasksProvider.notifier)
|
||||||
.addCron(
|
.addCron(
|
||||||
targetId: _selectedGroupId!,
|
targetId: _selectedGroupId!,
|
||||||
hour: _hourCtrl.text.trim(),
|
hour: _cronTime.hour.toString(),
|
||||||
minute: _minuteCtrl.text.trim(),
|
minute: _cronTime.minute.toString(),
|
||||||
dayOfWeek: _dowCtrl.text.trim(),
|
dayOfWeek: serializeCronWeekdays(_selectedWeekdays),
|
||||||
targetState: _targetState,
|
targetState: _targetState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (mounted) Navigator.of(context).pop();
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Расписание создано'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Не удалось создать расписание: ${describeLoadError(e)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() => _saving = false);
|
||||||
SnackBar(content: Text('Ошибка: ${describeLoadError(e)}')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _QuickTimeChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _QuickTimeChip({required this.label, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(label),
|
||||||
|
onPressed: onTap,
|
||||||
|
backgroundColor: Colors.white10,
|
||||||
|
side: BorderSide(color: Colors.deepOrange.withValues(alpha: 0.3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScheduleSummaryCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _ScheduleSummaryCard({required this.icon, required this.text});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.04),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: Colors.deepOrange),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(text, style: const TextStyle(color: Colors.white70)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
37
test/group_form_logic_test.dart
Normal file
37
test/group_form_logic_test.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/features/groups/group_form_logic.dart';
|
||||||
|
import 'package:ignis_app/models/ignis_group.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('slugify transliterates russian names into stable ids', () {
|
||||||
|
expect(slugifyGroupId('Спальня родителей'), 'spalnya-roditeley');
|
||||||
|
expect(slugifyGroupId(' Kitchen + Hall '), 'kitchen-hall');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('group id validation rejects duplicates and invalid chars', () {
|
||||||
|
const existing = [IgnisGroup(id: 'bedroom', name: 'Спальня')];
|
||||||
|
|
||||||
|
expect(validateGroupId('', existing), 'Укажите ID группы');
|
||||||
|
expect(
|
||||||
|
validateGroupId('спальня', existing),
|
||||||
|
'Только латиница, цифры, "-" и "_"',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
validateGroupId('bedroom', existing),
|
||||||
|
'Группа с таким ID уже существует',
|
||||||
|
);
|
||||||
|
expect(validateGroupId('hall_way', existing), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('membership conflicts are detected by device ids', () {
|
||||||
|
const groups = [
|
||||||
|
IgnisGroup(id: 'bedroom', name: 'Спальня', macs: ['AA:BB', 'CC:DD']),
|
||||||
|
IgnisGroup(id: 'kitchen', name: 'Кухня', macs: ['EE:FF']),
|
||||||
|
];
|
||||||
|
|
||||||
|
final conflicts = findGroupMembershipConflicts({'EE:FF', '11:22'}, groups);
|
||||||
|
|
||||||
|
expect(conflicts, hasLength(1));
|
||||||
|
expect(conflicts.single.id, 'kitchen');
|
||||||
|
});
|
||||||
|
}
|
||||||
43
test/schedule_form_logic_test.dart
Normal file
43
test/schedule_form_logic_test.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ignis_app/features/schedules/schedule_form_logic.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('default once time rounds forward and stays in future', () {
|
||||||
|
final now = DateTime(2026, 5, 1, 10, 2);
|
||||||
|
|
||||||
|
final result = defaultOnceScheduleTime(now: now);
|
||||||
|
|
||||||
|
expect(result, DateTime(2026, 5, 1, 14, 5));
|
||||||
|
expect(result.isAfter(now), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('once validation rejects missing and past dates', () {
|
||||||
|
final now = DateTime(2026, 5, 1, 10, 0);
|
||||||
|
|
||||||
|
expect(validateOnceSchedule(null, now: now), 'Выберите дату и время');
|
||||||
|
expect(
|
||||||
|
validateOnceSchedule(now.add(const Duration(seconds: 30)), now: now),
|
||||||
|
'Нужна дата хотя бы на минуту вперёд',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
validateOnceSchedule(now.add(const Duration(minutes: 2)), now: now),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cron weekday serialization and descriptions stay human readable', () {
|
||||||
|
expect(serializeCronWeekdays({0, 1, 2, 3, 4, 5, 6}), '*');
|
||||||
|
expect(serializeCronWeekdays({1, 5, 0}), '0,1,5');
|
||||||
|
expect(describeCronWeekdays({1, 2, 3, 4, 5}), 'Пн, Вт, Ср, Чт, Пт');
|
||||||
|
expect(describeCronWeekdaysExpression('*'), 'каждый день');
|
||||||
|
expect(describeCronWeekdaysExpression('1,5,0'), 'Пн, Пт, Вс');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('format runAt converts utc timestamp to local label', () {
|
||||||
|
final label = formatRunAtLabel('2026-05-01T12:30:00Z');
|
||||||
|
|
||||||
|
expect(label, isNotEmpty);
|
||||||
|
expect(label.contains('2026'), isTrue);
|
||||||
|
expect(label.contains('12:30') || label.contains('19:30'), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user