From 2fa89f6be0d3a56ea357d12127ebf86c01c99fa7 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Fri, 1 May 2026 09:47:08 +0700 Subject: [PATCH] feat: polish phase 7 forms and schedules --- README.md | 38 +- lib/features/groups/group_form_logic.dart | 101 +++ .../schedules/schedule_form_logic.dart | 147 ++++ lib/screens/api_keys_screen.dart | 290 ++++--- lib/screens/group_edit_screen.dart | 443 +++++++---- lib/screens/home_edit_screen.dart | 330 ++++---- lib/screens/schedules_screen.dart | 753 +++++++++++++----- test/group_form_logic_test.dart | 37 + test/schedule_form_logic_test.dart | 43 + 9 files changed, 1583 insertions(+), 599 deletions(-) create mode 100644 lib/features/groups/group_form_logic.dart create mode 100644 lib/features/schedules/schedule_form_logic.dart create mode 100644 test/group_form_logic_test.dart create mode 100644 test/schedule_form_logic_test.dart diff --git a/README.md b/README.md index 1f7604d..80695d4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Возможности - **Мульти-дом** -- поддержка нескольких серверов Ignis (квартира, дача, друзья). Каждый дом -- отдельный сервер со своим URL и API-ключом. -- **Группы ламп** -- создание, удаление, управление. При создании группы можно выбрать нужные лампы из списка обнаруженных устройств, пересканировать сеть. +- **Группы ламп** -- создание, удаление и управление. При создании группы есть product-валидация, автогенерация `ID`, предупреждение о конфликтах по устройствам и более честный перескан сети. - **Управление освещением:** - Включение/выключение - Яркость 10--100% с шагом 10% @@ -13,10 +13,10 @@ - RGB-цвет через HSV-пикер - Сцены (загружаются с сервера, отображаются с человекочитаемыми названиями) - Таймер "включить на 4 часа" -- **Расписания** -- одноразовые таймеры и cron-задачи с выбором дней недели. Просмотр и отмена активных задач. -- **API-ключи** -- просмотр, создание, отзыв и повторная активация гостевых ключей для администраторов. +- **Расписания** -- одноразовые таймеры с выбором даты/времени и повторяющиеся задачи с выбором дней недели. Просмотр, создание, валидация и отмена активных задач. +- **API-ключи** -- просмотр, создание, отзыв и повторная активация гостевых ключей для администраторов с отдельным UX для только что созданного ключа. - **Статистика и лог событий** -- просмотр сводки по группам и последних событий сервера. -- **Геофенс** -- опциональное автовыключение света при уходе от дома. +- **Геофенс и расстояния** -- live-дистанция до дома в UI и опциональное автовыключение света при уходе. Геофенс работает для текущего активного дома, показывает диагностический статус и использует cooldown/re-arm поведение. - **Устойчивость к ошибкам** -- гранулярные состояния загрузки (`LoadState`), централизованная обработка сетевых сбоев, soft-ошибки при управлении ползунками без спама в UI. ## Стек @@ -58,22 +58,23 @@ lib/ ├── features/ │ ├── api_keys/providers/ -- управление гостевыми API-ключами │ ├── auth/providers/ -- auth/me и auth-state -│ ├── homes/ -- дома, геолокация, geofence sync +│ ├── groups/ -- валидация и логика форм групп +│ ├── homes/ -- дома, геолокация, geofence sync/runtime │ ├── remote/providers/ -- polling групп, устройства, сцены, control errors -│ ├── schedules/providers/ -- задачи расписания +│ ├── schedules/ -- логика и providers расписаний │ ├── shared/providers/ -- базовые core providers │ └── stats/providers/ -- статистика и лог событий ├── providers/ │ └── providers.dart -- compatibility barrel для публичных provider-экспортов ├── screens/ -│ ├── api_keys_screen.dart -│ ├── event_log_screen.dart -│ ├── homes_screen.dart -│ ├── home_edit_screen.dart -│ ├── remote_screen.dart -│ ├── group_edit_screen.dart -│ ├── schedules_screen.dart -│ └── stats_screen.dart +│ ├── api_keys_screen.dart -- экран гостевых API-ключей +│ ├── event_log_screen.dart -- последние события сервера +│ ├── homes_screen.dart -- список домов, distance/geofence статус +│ ├── home_edit_screen.dart -- создание и редактирование дома +│ ├── remote_screen.dart -- основной экран управления светом +│ ├── group_edit_screen.dart -- создание группы с выбором устройств +│ ├── schedules_screen.dart -- создание и просмотр расписаний +│ └── stats_screen.dart -- статистика по командам └── widgets/ ├── build_info_text.dart -- лейбл с версией сборки ├── group_card.dart @@ -107,10 +108,17 @@ flutter test Текущий baseline зелёный: `flutter analyze`, `flutter test` и release APK сборка проходят штатно. +Дополнительно тестами уже прикрыты: +- typed parsing/load-state для основных backend-ответов; +- geofence distance/runtime логика; +- чистая логика форм расписаний и групп. + ## Настройка При первом запуске приложение попросит добавить "дом" -- указать адрес сервера Ignis и API-ключ. После этого откроется пульт управления группами. +Если задать координаты дома, экран домов начнёт показывать расстояние до активного дома. Если дополнительно включить автовыключение при уходе и выдать Android фоновые разрешения на геолокацию и уведомления, приложение сможет в фоне выключать свет при удалении от текущего активного дома. + Для добавления второго дома: кнопка "домик" в левом верхнем углу пульта -> экран домов -> кнопка "+". API-ключи хранятся отдельно от конфигурации домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически. @@ -125,7 +133,7 @@ API-ключи хранятся отдельно от конфигурации - Целевая платформа сейчас Android. - Release APK пока подписывается debug-ключом из Flutter-шаблона. -- Геофенс всё ещё требует отдельной продуктовой и технической доводки: multi-home semantics, background permissions и retry/cooldown поведение пока не доведены до конца. +- Build info в APK показывает дату сборки и короткий git hash текущего `HEAD`. Если сборка делается поверх незакоммиченного рабочего дерева, hash будет от последнего коммита, а не от локальных незакоммиченных изменений. ## Лицензия diff --git a/lib/features/groups/group_form_logic.dart b/lib/features/groups/group_form_logic.dart new file mode 100644 index 0000000..89d2e51 --- /dev/null +++ b/lib/features/groups/group_form_logic.dart @@ -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 _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 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 findGroupMembershipConflicts( + Set selectedDeviceIds, + Iterable existingGroups, +) { + if (selectedDeviceIds.isEmpty) { + return const []; + } + + return existingGroups + .where( + (group) => + group.macs.any((deviceId) => selectedDeviceIds.contains(deviceId)), + ) + .toList(); +} diff --git a/lib/features/schedules/schedule_form_logic.dart b/lib/features/schedules/schedule_form_logic.dart new file mode 100644 index 0000000..add9fbe --- /dev/null +++ b/lib/features/schedules/schedule_form_logic.dart @@ -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( + 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 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 weekdays) { + if (weekdays.length == scheduleWeekdayOptions.length) { + return '*'; + } + final sorted = weekdays.toList()..sort(); + return sorted.join(','); +} + +String describeCronWeekdays(Set 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() + .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()); +} diff --git a/lib/screens/api_keys_screen.dart b/lib/screens/api_keys_screen.dart index 00bd2ab..f2d7843 100644 --- a/lib/screens/api_keys_screen.dart +++ b/lib/screens/api_keys_screen.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../app/error_message.dart'; import '../app/load_state.dart'; import '../models/api_key_info.dart'; import '../providers/providers.dart'; import '../widgets/load_error_view.dart'; -/// Экран управления гостевыми API-ключами. -/// Доступен только администраторам. class ApiKeysScreen extends ConsumerStatefulWidget { const ApiKeysScreen({super.key}); @@ -17,6 +16,7 @@ class ApiKeysScreen extends ConsumerStatefulWidget { } class _ApiKeysScreenState extends ConsumerState { + final Set _busyKeys = {}; String? _lastCreatedKey; @override @@ -39,13 +39,16 @@ class _ApiKeysScreenState extends ConsumerState { body: Column( children: [ if (_lastCreatedKey != null) - _LastCreatedKeyBanner(keyValue: _lastCreatedKey!), + _LastCreatedKeyBanner( + keyValue: _lastCreatedKey!, + onDismiss: () => setState(() => _lastCreatedKey = null), + ), Expanded(child: _buildContent(keysState, keys)), ], ), floatingActionButton: FloatingActionButton( backgroundColor: Colors.deepOrange, - onPressed: () => _showCreateDialog(context), + onPressed: _showCreateDialog, child: const Icon(Icons.add), ), ); @@ -73,7 +76,7 @@ class _ApiKeysScreenState extends ConsumerState { if (keys.isEmpty) { return const Center( child: Text( - 'Нет гостевых ключей', + 'Гостевых ключей пока нет', style: TextStyle(color: Colors.white54), ), ); @@ -105,85 +108,131 @@ class _ApiKeysScreenState extends ConsumerState { ); } - final keyIndex = index - statusHeaderCount; - final key = keys[keyIndex]; + final keyData = keys[index - statusHeaderCount]; + final busy = _busyKeys.contains(keyData.key); return _ApiKeyCard( - data: key, - onRevoke: () => _revokeKey(key), - onActivate: () => _activateKey(key), + data: keyData, + busy: busy, + onRevoke: busy ? null : () => _revokeKey(keyData), + onActivate: busy ? null : () => _activateKey(keyData), ); }, ), ); } - void _showCreateDialog(BuildContext context) { + Future _showCreateDialog() async { + final formKey = GlobalKey(); final nameCtrl = TextEditingController(); - bool isAdmin = false; + var isAdmin = false; + var isCreating = false; - showDialog( + await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( title: const Text('Новый API-ключ'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameCtrl, - decoration: const InputDecoration( - labelText: 'Имя ключа', - hintText: 'Например: "Гость"', + content: Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + 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( - title: const Text('Администратор'), - value: isAdmin, - activeThumbColor: Colors.deepOrange, - onChanged: (v) => setDialogState(() => isAdmin = v), - contentPadding: EdgeInsets.zero, - ), - ], + const SizedBox(height: 12), + SwitchListTile( + title: const Text('Дать права администратора'), + subtitle: const Text( + 'Используйте только для доверенных людей', + ), + value: isAdmin, + activeThumbColor: Colors.deepOrange, + onChanged: isCreating + ? null + : (value) => setDialogState(() => isAdmin = value), + contentPadding: EdgeInsets.zero, + ), + ], + ), ), actions: [ TextButton( - onPressed: () => Navigator.of(ctx).pop(), + onPressed: isCreating ? null : () => Navigator.of(ctx).pop(), child: const Text('Отмена'), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.deepOrange, ), - onPressed: () async { - final name = nameCtrl.text.trim(); - if (name.isEmpty) return; - Navigator.of(ctx).pop(); - final messenger = ScaffoldMessenger.of(context); - try { - final key = await ref - .read(apiKeysProvider.notifier) - .create(name, isAdmin: isAdmin); - if (!mounted) return; - setState(() => _lastCreatedKey = key); - } catch (e) { - if (!mounted) return; - messenger.showSnackBar( - SnackBar( - content: Text( - 'Ошибка создания ключа: ${describeLoadError(e)}', + onPressed: isCreating + ? null + : () async { + if (!formKey.currentState!.validate()) { + return; + } + + setDialogState(() => isCreating = true); + try { + final key = await ref + .read(apiKeysProvider.notifier) + .create(nameCtrl.text.trim(), isAdmin: isAdmin); + if (!mounted) return; + setState(() => _lastCreatedKey = key); + if (ctx.mounted) { + Navigator.of(ctx).pop(); + } + 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, ), - ), - ); - } - }, - child: const Text('Создать'), + ) + : const Text('Создать'), ), ], ), ), ); + + nameCtrl.dispose(); } Future _revokeKey(ApiKeyInfo data) async { @@ -191,7 +240,7 @@ class _ApiKeysScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('Отозвать ключ?'), - content: Text('Отозвать "${data.name}"?'), + content: Text('Ключ "${data.name}" перестанет работать.'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), @@ -207,31 +256,43 @@ class _ApiKeysScreenState extends ConsumerState { ], ), ); - if (confirmed == true) { - try { - await ref.read(apiKeysProvider.notifier).revoke(data.key); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Ошибка отзыва ключа: ${describeLoadError(e)}'), - ), - ); + if (confirmed != true) { + return; + } + + setState(() => _busyKeys.add(data.key)); + try { + await ref.read(apiKeysProvider.notifier).revoke(data.key); + 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 _activateKey(ApiKeyInfo data) async { + setState(() => _busyKeys.add(data.key)); try { await ref.read(apiKeysProvider.notifier).activate(data.key); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Ключ активирован'), - duration: Duration(seconds: 1), - ), - ); - } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ключ снова активен'), + duration: Duration(seconds: 1), + ), + ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -239,14 +300,22 @@ class _ApiKeysScreenState extends ConsumerState { content: Text('Ошибка активации ключа: ${describeLoadError(e)}'), ), ); + } finally { + if (mounted) { + setState(() => _busyKeys.remove(data.key)); + } } } } class _LastCreatedKeyBanner extends StatelessWidget { final String keyValue; + final VoidCallback onDismiss; - const _LastCreatedKeyBanner({required this.keyValue}); + const _LastCreatedKeyBanner({ + required this.keyValue, + required this.onDismiss, + }); @override Widget build(BuildContext context) { @@ -261,13 +330,24 @@ class _LastCreatedKeyBanner extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Новый ключ создан! Скопируйте его сейчас:', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.deepOrange, - ), + Row( + children: [ + const Expanded( + child: Text( + 'Новый ключ создан. Скопируйте его сейчас, потом приложение уже не покажет полный токен.', + 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), Row( @@ -280,7 +360,7 @@ class _LastCreatedKeyBanner extends StatelessWidget { fontFamily: 'monospace', color: Colors.white70, ), - maxLines: 2, + maxLines: 3, overflow: TextOverflow.ellipsis, ), ), @@ -304,14 +384,15 @@ class _LastCreatedKeyBanner extends StatelessWidget { } } -/// Карточка одного API-ключа class _ApiKeyCard extends StatelessWidget { final ApiKeyInfo data; - final VoidCallback onRevoke; - final VoidCallback onActivate; + final bool busy; + final VoidCallback? onRevoke; + final VoidCallback? onActivate; const _ApiKeyCard({ required this.data, + required this.busy, required this.onRevoke, required this.onActivate, }); @@ -329,11 +410,13 @@ class _ApiKeyCard extends StatelessWidget { ), title: Row( children: [ - Text( - data.name, - style: TextStyle( - fontWeight: FontWeight.bold, - color: data.isActive ? Colors.white : Colors.white38, + Expanded( + child: Text( + data.name, + style: TextStyle( + fontWeight: FontWeight.bold, + color: data.isActive ? Colors.white : Colors.white38, + ), ), ), if (data.isAdmin) ...[ @@ -372,24 +455,23 @@ class _ApiKeyCard extends StatelessWidget { style: const TextStyle(fontSize: 11, color: Colors.white30), ) : null, - trailing: data.isActive - ? IconButton( - icon: const Icon( - Icons.block, - size: 20, - color: Colors.redAccent, + trailing: busy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.deepOrange, ), - tooltip: 'Отозвать', - onPressed: onRevoke, ) : IconButton( - icon: const Icon( - Icons.check_circle_outline, + icon: Icon( + data.isActive ? Icons.block : Icons.check_circle_outline, size: 20, - color: Colors.green, + color: data.isActive ? Colors.redAccent : Colors.green, ), - tooltip: 'Активировать', - onPressed: onActivate, + tooltip: data.isActive ? 'Отозвать' : 'Активировать', + onPressed: data.isActive ? onRevoke : onActivate, ), ), ); diff --git a/lib/screens/group_edit_screen.dart b/lib/screens/group_edit_screen.dart index 49e6a92..d139c88 100644 --- a/lib/screens/group_edit_screen.dart +++ b/lib/screens/group_edit_screen.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../app/error_message.dart'; import '../app/load_state.dart'; +import '../features/groups/group_form_logic.dart'; import '../models/ignis_device.dart'; +import '../models/ignis_group.dart'; import '../providers/providers.dart'; import '../widgets/load_error_view.dart'; -/// Экран создания новой группы ламп. -/// Загружает список устройств, позволяет выбрать нужные. class GroupEditScreen extends ConsumerStatefulWidget { const GroupEditScreen({super.key}); @@ -16,47 +17,114 @@ class GroupEditScreen extends ConsumerStatefulWidget { } class _GroupEditScreenState extends ConsumerState { + final _formKey = GlobalKey(); final _idCtrl = TextEditingController(); final _nameCtrl = TextEditingController(); - final Set _selectedMacs = {}; + final Set _selectedMacs = {}; + bool _saving = false; bool _rescanning = false; + bool _deviceSelectionTouched = false; + bool _idEditedManually = false; + bool _syncingIdFromName = false; @override void initState() { super.initState(); - _loadDevices(); + _nameCtrl.addListener(_handleNameChanged); + _idCtrl.addListener(_handleIdChanged); + Future.microtask(() async { + await _loadDevices(); + try { + await ref.read(groupsProvider.notifier).refresh(); + } catch (_) { + // Покажем текущее состояние через экран, без лишнего шума. + } + }); } @override void dispose() { + _nameCtrl.removeListener(_handleNameChanged); + _idCtrl.removeListener(_handleIdChanged); _idCtrl.dispose(); _nameCtrl.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 _loadDevices() async { await ref.read(devicesProvider.notifier).load(); } - /// Пересканировать сеть и перезагрузить устройства Future _rescan() async { + final beforeIds = ref + .read(devicesProvider) + .data + .map((device) => device.groupMemberId) + .toSet(); + setState(() => _rescanning = true); try { await ref.read(apiProvider).rescanNetwork(); - // Подождать немного -- сканирование асинхронное - await Future.delayed(const Duration(seconds: 3)); - await ref.read(devicesProvider.notifier).load(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Ошибка сканирования: ${describeLoadError(e)}'), - ), - ); + + var changed = false; + for (var attempt = 0; attempt < 6; attempt++) { + await Future.delayed(const Duration(seconds: 1)); + await ref.read(devicesProvider.notifier).load(); + final currentIds = ref + .read(devicesProvider) + .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 { - if (mounted) setState(() => _rescanning = false); + if (mounted) { + setState(() => _rescanning = false); + } } } @@ -64,12 +132,13 @@ class _GroupEditScreenState extends ConsumerState { Widget build(BuildContext context) { final devicesState = ref.watch(devicesProvider); final devices = devicesState.data; + final groups = ref.watch(groupsProvider); + final conflicts = findGroupMembershipConflicts(_selectedMacs, groups); return Scaffold( appBar: AppBar( title: const Text('НОВАЯ ГРУППА'), actions: [ - // Кнопка ресканирования сети IconButton( icon: _rescanning ? const SizedBox( @@ -83,99 +152,129 @@ class _GroupEditScreenState extends ConsumerState { ), ], ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ID группы - TextField( - controller: _idCtrl, - decoration: const InputDecoration( - labelText: 'ID группы (например "bedroom")', - prefixIcon: Icon(Icons.tag), - ), - ), - const SizedBox(height: 12), - - // Название группы - 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, - ), + body: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration( + labelText: 'Название группы', + hintText: 'Например: Спальня', + prefixIcon: Icon(Icons.label), ), - TextButton( - onPressed: devices.isEmpty - ? null - : () { - setState(() { - if (_selectedMacs.length == devices.length) { - _selectedMacs.clear(); - } else { - for (final d in devices) { - _selectedMacs.add(d.groupMemberId); + textCapitalization: TextCapitalization.sentences, + validator: (value) => validateGroupName(value ?? ''), + ), + const SizedBox(height: 12), + TextFormField( + controller: _idCtrl, + decoration: InputDecoration( + labelText: 'ID группы', + hintText: 'Например: bedroom', + 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( - _selectedMacs.length == devices.length - ? 'Снять все' - : 'Выбрать все', - style: const TextStyle(fontSize: 12), + 'Выберите хотя бы одно устройство', + style: TextStyle(color: Colors.redAccent, 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 { if (devices.isEmpty) { return const Center( child: Text( - 'Устройства не найдены.\nПопробуйте пересканировать сеть.', + 'Устройства не найдены.\nПересканируйте сеть и попробуйте снова.', textAlign: TextAlign.center, style: TextStyle(color: Colors.white38), ), @@ -231,26 +330,26 @@ class _GroupEditScreenState extends ConsumerState { ); } - final deviceIndex = index - statusHeaderCount; - final d = devices[deviceIndex]; - final selected = _selectedMacs.contains(d.groupMemberId); + final device = devices[index - statusHeaderCount]; + final selected = _selectedMacs.contains(device.groupMemberId); return CheckboxListTile( value: selected, activeColor: Colors.deepOrange, - title: Text(d.name), - subtitle: d.subtitle == null + title: Text(device.name), + subtitle: device.subtitle == null ? null : Text( - d.subtitle!, + device.subtitle!, style: const TextStyle(fontSize: 11, color: Colors.white38), ), - onChanged: (v) { + onChanged: (value) { setState(() { - if (v == true) { - _selectedMacs.add(d.groupMemberId); + _deviceSelectionTouched = true; + if (value == true) { + _selectedMacs.add(device.groupMemberId); } else { - _selectedMacs.remove(d.groupMemberId); + _selectedMacs.remove(device.groupMemberId); } }); }, @@ -260,38 +359,106 @@ class _GroupEditScreenState extends ConsumerState { } Future _save() async { - final id = _idCtrl.text.trim(); - final name = _nameCtrl.text.trim(); + FocusScope.of(context).unfocus(); + setState(() => _deviceSelectionTouched = true); - if (id.isEmpty || name.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Укажите ID и название'))); + if (!_formKey.currentState!.validate()) { return; } - if (_selectedMacs.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Выберите хотя бы одно устройство')), - ); return; } - setState(() => _saving = true); - - try { - await ref.read(apiProvider).createGroup(id, name, _selectedMacs.toList()); - // Обновить список групп - await ref.read(groupsProvider.notifier).refresh(); - if (mounted) Navigator.of(context).pop(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка создания: ${describeLoadError(e)}')), - ); + final groups = ref.read(groupsProvider); + final conflicts = findGroupMembershipConflicts(_selectedMacs, groups); + if (conflicts.isNotEmpty) { + final shouldContinue = await _confirmConflicts(conflicts); + if (shouldContinue != true) { + return; } } - 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 _confirmConflicts(List conflicts) { + return showDialog( + 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 left, Set 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 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), + ), + ); } } diff --git a/lib/screens/home_edit_screen.dart b/lib/screens/home_edit_screen.dart index fb988c1..c8932f3 100644 --- a/lib/screens/home_edit_screen.dart +++ b/lib/screens/home_edit_screen.dart @@ -16,6 +16,7 @@ class HomeEditScreen extends ConsumerStatefulWidget { } class _HomeEditScreenState extends ConsumerState { + final _formKey = GlobalKey(); final _nameCtrl = TextEditingController(); final _urlCtrl = TextEditingController(); final _keyCtrl = TextEditingController(); @@ -85,158 +86,215 @@ class _HomeEditScreenState extends ConsumerState { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ')), - body: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _nameCtrl, - decoration: const InputDecoration( - labelText: 'Название (например "Квартира")', - prefixIcon: Icon(Icons.home), - ), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: 12), - 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, - ), - ), + body: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration( + labelText: 'Название', + hintText: 'Например: Квартира', + prefixIcon: Icon(Icons.home), ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _lonCtrl, - decoration: const InputDecoration( - labelText: 'Долгота', - prefixIcon: Icon(Icons.location_on, size: 20), - hintText: '71.430', - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), - ), + textCapitalization: TextCapitalization.sentences, + validator: (value) => (value?.trim().isEmpty ?? true) + ? 'Укажите название дома' + : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _urlCtrl, + decoration: const InputDecoration( + labelText: 'Адрес сервера', + hintText: 'Например: ignis.akokos.ru', + helperText: 'Можно без https, приложение подставит его само', + prefixIcon: Icon(Icons.dns), ), - ], - ), - - const SizedBox(height: 16), - - // ─── Геофенс ─── - SwitchListTile( - title: const Text('Выключать свет при уходе'), - subtitle: Text( - _hasCoordinates - ? 'Автовыключение при удалении на 500 м' - : 'Задайте координаты для активации', + keyboardType: TextInputType.url, + validator: (value) { + final rawUrl = value?.trim() ?? ''; + if (rawUrl.isEmpty) { + return 'Укажите адрес сервера'; + } + try { + final normalized = IgnisApi.normalizeBaseUrl(rawUrl); + final parsed = Uri.parse(normalized); + if ((parsed.scheme != 'http' && parsed.scheme != 'https') || + 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( - fontSize: 12, - color: _hasCoordinates ? Colors.white38 : Colors.white24, + color: Colors.white54, + fontSize: 13, + fontWeight: FontWeight.bold, ), ), - 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, + const SizedBox(height: 4), + const Text( + 'Нужны только для расстояния и автовыключения по геолокации', + style: TextStyle(color: Colors.white30, fontSize: 12), ), - ), - if (_geofenceEnabled && _hasCoordinates) - const Padding( - padding: EdgeInsets.only(left: 40, bottom: 4), - child: Text( - 'Проверка раз в ~15 мин (ограничение Android).\n' - 'Работает только для текущего активного дома.\n' - 'Нужны фоновые разрешения на геолокацию и уведомления.', - style: TextStyle(fontSize: 11, color: Colors.white24), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _latCtrl, + decoration: const InputDecoration( + labelText: 'Широта', + 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, ), ), - - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepOrange, - foregroundColor: Colors.white, + if (_geofenceEnabled && _hasCoordinates) + const Padding( + padding: EdgeInsets.only(left: 40, bottom: 4), + child: Text( + 'Проверка раз в ~15 мин (ограничение Android).\n' + 'Работает только для текущего активного дома.\n' + 'Нужны фоновые разрешения на геолокацию и уведомления.', + style: TextStyle(fontSize: 11, color: Colors.white24), + ), + ), + 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 _save() async { + FocusScope.of(context).unfocus(); + if (!_formKey.currentState!.validate()) { + return; + } + final name = _nameCtrl.text.trim(); final rawUrl = _urlCtrl.text.trim(); final key = _keyCtrl.text.trim(); diff --git a/lib/screens/schedules_screen.dart b/lib/screens/schedules_screen.dart index 79b0727..13f3a02 100644 --- a/lib/screens/schedules_screen.dart +++ b/lib/screens/schedules_screen.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../app/error_message.dart'; import '../app/load_state.dart'; +import '../features/schedules/schedule_form_logic.dart'; import '../models/schedule_task.dart'; import '../providers/providers.dart'; import '../widgets/load_error_view.dart'; -/// Экран управления расписаниями. -/// Показывает все задачи (one-shot и cron), позволяет создавать и удалять. class SchedulesScreen extends ConsumerStatefulWidget { const SchedulesScreen({super.key}); @@ -16,6 +16,8 @@ class SchedulesScreen extends ConsumerStatefulWidget { } class _SchedulesScreenState extends ConsumerState { + final Set _cancellingJobIds = {}; + @override void initState() { super.initState(); @@ -26,6 +28,74 @@ class _SchedulesScreenState extends ConsumerState { await ref.read(tasksProvider.notifier).load(); } + Future _cancelTask(ScheduleTask task) async { + final confirmed = await showDialog( + 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 _showAddDialog() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF1E1E1E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => const _AddScheduleSheet(), + ); + } + @override Widget build(BuildContext context) { final tasksState = ref.watch(tasksProvider); @@ -36,25 +106,12 @@ class _SchedulesScreenState extends ConsumerState { body: _buildContent(tasksState, tasks), floatingActionButton: FloatingActionButton( backgroundColor: Colors.deepOrange, - onPressed: () => _showAddDialog(context), + onPressed: _showAddDialog, 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( LoadState> tasksState, List tasks, @@ -75,16 +132,27 @@ class _SchedulesScreenState extends ConsumerState { } if (tasks.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + return RefreshIndicator( + color: Colors.deepOrange, + onRefresh: _load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(24), + children: const [ + SizedBox(height: 120), Icon(Icons.schedule, size: 64, color: Colors.white24), SizedBox(height: 16), Text( - 'Нет активных расписаний', + 'Пока нет активных расписаний', + textAlign: TextAlign.center, 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 { ); } - final taskIndex = index - statusHeaderCount; - return _TaskCard(task: tasks[taskIndex]); + final task = tasks[index - statusHeaderCount]; + final busy = _cancellingJobIds.contains(task.jobId); + return _TaskCard( + task: task, + busy: busy, + onDelete: busy ? null : () => _cancelTask(task), + ); }, ), ); } } -/// Карточка одной задачи расписания -class _TaskCard extends ConsumerWidget { +class _TaskCard extends StatelessWidget { 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 - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Card( - margin: const EdgeInsets.only(bottom: 8), + margin: const EdgeInsets.only(bottom: 10), child: ListTile( - leading: Icon( - task.isCron ? Icons.repeat : Icons.timer, - color: Colors.deepOrange, + leading: Container( + width: 42, + 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( - task.title, + '${task.actionText} группу ${task.targetId}', style: const TextStyle(fontWeight: FontWeight.bold), ), - subtitle: Text( - task.subtitle, - style: const TextStyle(fontSize: 12, color: Colors.white54), - ), - trailing: IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.redAccent), - onPressed: () => _confirmCancel(context, ref, task.jobId), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _taskDescription(task), + style: const TextStyle(fontSize: 12, color: Colors.white60), + ), ), + 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) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Отменить задачу?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - 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)), - ), - ], - ), - ); + String _taskDescription(ScheduleTask task) { + if (task.isCron) { + final hour = task.hour?.padLeft(2, '0') ?? '--'; + final minute = task.minute?.padLeft(2, '0') ?? '--'; + final days = describeCronWeekdaysExpression(task.dayOfWeek); + return 'Повтор: $days в $hour:$minute'; + } + return 'Один раз: ${formatRunAtLabel(task.runAt)}'; } } -/// Нижний лист для создания расписания class _AddScheduleSheet extends ConsumerStatefulWidget { const _AddScheduleSheet(); @@ -198,34 +273,98 @@ class _AddScheduleSheet extends ConsumerStatefulWidget { } class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { - /// Тип: 'once' или 'cron' + final _formKey = GlobalKey(); + final _onceAtCtrl = TextEditingController(); + final _cronTimeCtrl = TextEditingController(); + String _type = 'once'; - - /// Общие поля String? _selectedGroupId; - bool _targetState = false; // включить или выключить + bool _targetState = false; + bool _saving = false; + DateTime _onceAt = defaultOnceScheduleTime(); + TimeOfDay _cronTime = const TimeOfDay(hour: 22, minute: 0); + Set _selectedWeekdays = scheduleWeekdayOptions + .map((option) => option.backendValue) + .toSet(); - /// Поля для once - int _hoursFromNow = 4; - - /// Поля для cron - final _hourCtrl = TextEditingController(text: '22'); - final _minuteCtrl = TextEditingController(text: '00'); - final _dowCtrl = TextEditingController(text: '*'); + @override + void initState() { + super.initState(); + _syncControllers(); + Future.microtask(() async { + try { + await ref.read(groupsProvider.notifier).refresh(); + } catch (_) { + // Ошибка загрузки уже отражается через groupsLoadStateProvider. + } + }); + } @override void dispose() { - _hourCtrl.dispose(); - _minuteCtrl.dispose(); - _dowCtrl.dispose(); + _onceAtCtrl.dispose(); + _cronTimeCtrl.dispose(); super.dispose(); } + void _syncControllers() { + _onceAtCtrl.text = formatLocalScheduleDateTime(_onceAt); + _cronTimeCtrl.text = + '${_cronTime.hour.toString().padLeft(2, '0')}:${_cronTime.minute.toString().padLeft(2, '0')}'; + } + + Future _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 _pickCronTime() async { + final pickedTime = await showTimePicker( + context: context, + initialTime: _cronTime, + ); + if (!mounted || pickedTime == null) return; + setState(() { + _cronTime = pickedTime; + _syncControllers(); + }); + } + @override Widget build(BuildContext context) { final groups = ref.watch(groupsProvider); + final groupsLoadState = ref.watch(groupsLoadStateProvider); final bottomPadding = MediaQuery.of(context).viewInsets.bottom; final systemPadding = MediaQuery.of(context).padding.bottom; + final cronWeekdaysError = _type == 'cron' + ? validateCronSchedule( + hour: _cronTime.hour, + minute: _cronTime.minute, + weekdays: _selectedWeekdays, + ) + : null; return Padding( padding: EdgeInsets.only( @@ -235,137 +374,278 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { bottom: bottomPadding + systemPadding + 20, ), child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Заголовок - const Text( - 'Новое расписание', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - - // Тип расписания - 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( - 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), + child: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Новое расписание', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), - Slider( - value: _hoursFromNow.toDouble(), - min: 1, - max: 24, - divisions: 23, - label: '$_hoursFromNow ч.', - activeColor: Colors.deepOrange, - onChanged: (v) => setState(() => _hoursFromNow = v.toInt()), + const SizedBox(height: 6), + const Text( + 'Сейчас расписания создаются для групп. Сначала выберите группу, потом задайте таймер или повтор.', + style: TextStyle(color: Colors.white54), ), - ], - - // ─── Cron: час, минута, дни недели ─── - if (_type == 'cron') ...[ + const SizedBox(height: 16), Row( children: [ - Expanded( - child: TextField( - controller: _hourCtrl, - decoration: const InputDecoration( - labelText: 'Час (0-23)', - ), - keyboardType: TextInputType.number, - ), + ChoiceChip( + label: const Text('Один раз'), + selected: _type == 'once', + selectedColor: Colors.deepOrange, + onSelected: (_) => setState(() => _type = 'once'), ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _minuteCtrl, - decoration: const InputDecoration( - labelText: 'Минута (0-59)', - ), - keyboardType: TextInputType.number, - ), + const SizedBox(width: 8), + ChoiceChip( + label: const Text('Повтор'), + selected: _type == 'cron', + selectedColor: Colors.deepOrange, + onSelected: (_) => setState(() => _type = 'cron'), ), ], ), - const SizedBox(height: 12), - TextField( - controller: _dowCtrl, + const SizedBox(height: 16), + if (groupsLoadState.isLoading && groups.isEmpty) + 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( + initialValue: _selectedGroupId, decoration: const InputDecoration( - labelText: 'Дни недели (* = каждый, 0-6 = вс-сб)', - helperText: 'Например: 1-5 (пн-пт), 0,6 (выходные)', + labelText: 'Группа', + helperText: 'Расписание применяется к одной группе света', + ), + validator: (value) => value == null ? 'Выберите группу' : null, + items: groups + .map( + (group) => DropdownMenuItem( + 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() + : {}; + }); + }, + ), + 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 _save() async { - if (_selectedGroupId == null) return; + void _applyQuickOnceOffset(Duration duration) { + 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 _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 { if (_type == 'once') { await ref @@ -373,26 +653,87 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { .addOnce( targetId: _selectedGroupId!, targetState: _targetState, - hoursFromNow: _hoursFromNow, + runAt: _onceAt.toUtc().toIso8601String(), ); } else { await ref .read(tasksProvider.notifier) .addCron( targetId: _selectedGroupId!, - hour: _hourCtrl.text.trim(), - minute: _minuteCtrl.text.trim(), - dayOfWeek: _dowCtrl.text.trim(), + hour: _cronTime.hour.toString(), + minute: _cronTime.minute.toString(), + dayOfWeek: serializeCronWeekdays(_selectedWeekdays), 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) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Не удалось создать расписание: ${describeLoadError(e)}', + ), + ), + ); + } finally { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка: ${describeLoadError(e)}')), - ); + setState(() => _saving = false); } } } } + +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)), + ), + ], + ), + ); + } +} diff --git a/test/group_form_logic_test.dart b/test/group_form_logic_test.dart new file mode 100644 index 0000000..62f09c4 --- /dev/null +++ b/test/group_form_logic_test.dart @@ -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'); + }); +} diff --git a/test/schedule_form_logic_test.dart b/test/schedule_form_logic_test.dart new file mode 100644 index 0000000..c86eebd --- /dev/null +++ b/test/schedule_form_logic_test.dart @@ -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); + }); +}