feat: polish phase 7 forms and schedules

This commit is contained in:
Artem Kokos
2026-05-01 09:47:08 +07:00
parent 91a494adf5
commit 2fa89f6be0
9 changed files with 1583 additions and 599 deletions

View File

@@ -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 будет от последнего коммита, а не от локальных незакоммиченных изменений.
## Лицензия ## Лицензия

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

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

View File

@@ -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,71 +108,106 @@ 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(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( TextFormField(
controller: nameCtrl, controller: nameCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Имя ключа', labelText: 'Имя ключа',
hintText: 'Например: "Гость"', hintText: 'Например: Гость',
), ),
autofocus: true, autofocus: true,
validator: (value) {
final normalized = value?.trim() ?? '';
if (normalized.isEmpty) {
return 'Укажите имя ключа';
}
if (normalized.length < 2) {
return 'Слишком короткое имя';
}
return null;
},
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( SwitchListTile(
title: const Text('Администратор'), title: const Text('Дать права администратора'),
subtitle: const Text(
'Используйте только для доверенных людей',
),
value: isAdmin, value: isAdmin,
activeThumbColor: Colors.deepOrange, activeThumbColor: Colors.deepOrange,
onChanged: (v) => setDialogState(() => isAdmin = v), onChanged: isCreating
? null
: (value) => setDialogState(() => isAdmin = value),
contentPadding: EdgeInsets.zero, 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;
}
setDialogState(() => isCreating = true);
try { try {
final key = await ref final key = await ref
.read(apiKeysProvider.notifier) .read(apiKeysProvider.notifier)
.create(name, isAdmin: isAdmin); .create(nameCtrl.text.trim(), isAdmin: isAdmin);
if (!mounted) return; if (!mounted) return;
setState(() => _lastCreatedKey = key); setState(() => _lastCreatedKey = key);
if (ctx.mounted) {
Navigator.of(ctx).pop();
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ создан'),
duration: Duration(seconds: 1),
),
);
} catch (e) { } catch (e) {
if (!mounted) return; if (!ctx.mounted) return;
messenger.showSnackBar( setDialogState(() => isCreating = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Ошибка создания ключа: ${describeLoadError(e)}', 'Ошибка создания ключа: ${describeLoadError(e)}',
@@ -178,12 +216,23 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
); );
} }
}, },
child: const Text('Создать'), child: isCreating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: 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) {
return;
}
setState(() => _busyKeys.add(data.key));
try { try {
await ref.read(apiKeysProvider.notifier).revoke(data.key); 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) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text('Ошибка отзыва ключа: ${describeLoadError(e)}')),
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,14 +330,25 @@ class _LastCreatedKeyBanner extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Row(
'Новый ключ создан! Скопируйте его сейчас:', children: [
const Expanded(
child: Text(
'Новый ключ создан. Скопируйте его сейчас, потом приложение уже не покажет полный токен.',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.deepOrange, color: Colors.deepOrange,
), ),
), ),
),
IconButton(
onPressed: onDismiss,
icon: const Icon(Icons.close, size: 18),
tooltip: 'Скрыть',
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
@@ -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,13 +410,15 @@ class _ApiKeyCard extends StatelessWidget {
), ),
title: Row( title: Row(
children: [ children: [
Text( Expanded(
child: Text(
data.name, data.name,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: data.isActive ? Colors.white : Colors.white38, color: data.isActive ? Colors.white : Colors.white38,
), ),
), ),
),
if (data.isAdmin) ...[ if (data.isAdmin) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
@@ -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,
), ),
), ),
); );

View File

@@ -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;
for (var attempt = 0; attempt < 6; attempt++) {
await Future.delayed(const Duration(seconds: 1));
await ref.read(devicesProvider.notifier).load(); await ref.read(devicesProvider.notifier).load();
} catch (e) { final currentIds = ref
if (mounted) { .read(devicesProvider)
.data
.map((device) => device.groupMemberId)
.toSet();
if (!_sameSet(beforeIds, currentIds)) {
changed = true;
break;
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Ошибка сканирования: ${describeLoadError(e)}'), 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,33 +152,45 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
), ),
], ],
), ),
body: Padding( body: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ID группы TextFormField(
TextField(
controller: _idCtrl,
decoration: const InputDecoration(
labelText: 'ID группы (например "bedroom")',
prefixIcon: Icon(Icons.tag),
),
),
const SizedBox(height: 12),
// Название группы
TextField(
controller: _nameCtrl, controller: _nameCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Название (например "Спальня")', labelText: 'Название группы',
hintText: 'Например: Спальня',
prefixIcon: Icon(Icons.label), prefixIcon: Icon(Icons.label),
), ),
textCapitalization: TextCapitalization.sentences, 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), const SizedBox(height: 16),
// Заголовок списка устройств
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -126,12 +207,17 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
? null ? null
: () { : () {
setState(() { setState(() {
_deviceSelectionTouched = true;
if (_selectedMacs.length == devices.length) { if (_selectedMacs.length == devices.length) {
_selectedMacs.clear(); _selectedMacs.clear();
} else { } else {
for (final d in devices) { _selectedMacs
_selectedMacs.add(d.groupMemberId); ..clear()
} ..addAll(
devices.map(
(device) => device.groupMemberId,
),
);
} }
}); });
}, },
@@ -144,13 +230,25 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
), ),
], ],
), ),
if (_deviceSelectionTouched && _selectedMacs.isEmpty)
// Список устройств const Padding(
padding: EdgeInsets.only(top: 4, bottom: 8),
child: Text(
'Выберите хотя бы одно устройство',
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)), Expanded(child: _buildDevices(devicesState, devices)),
// Кнопка сохранения
Padding( Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 12,
bottom: MediaQuery.of(context).padding.bottom + 8, bottom: MediaQuery.of(context).padding.bottom + 8,
), ),
child: SizedBox( child: SizedBox(
@@ -178,6 +276,7 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
], ],
), ),
), ),
),
); );
} }
@@ -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( return;
context, }
).showSnackBar(const SnackBar(content: Text('Укажите ID и название'))); if (_selectedMacs.isEmpty) {
return; return;
} }
if (_selectedMacs.isEmpty) { final groups = ref.read(groupsProvider);
ScaffoldMessenger.of(context).showSnackBar( final conflicts = findGroupMembershipConflicts(_selectedMacs, groups);
const SnackBar(content: Text('Выберите хотя бы одно устройство')), if (conflicts.isNotEmpty) {
); final shouldContinue = await _confirmConflicts(conflicts);
if (shouldContinue != true) {
return; return;
} }
}
setState(() => _saving = true); setState(() => _saving = true);
try { try {
final id = _idCtrl.text.trim().toLowerCase();
final name = _nameCtrl.text.trim();
await ref.read(apiProvider).createGroup(id, name, _selectedMacs.toList()); await ref.read(apiProvider).createGroup(id, name, _selectedMacs.toList());
// Обновить список групп
await ref.read(groupsProvider.notifier).refresh(); await ref.read(groupsProvider.notifier).refresh();
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) { if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка создания: ${describeLoadError(e)}')), SnackBar(content: Text('Ошибка создания: ${describeLoadError(e)}')),
); );
} finally {
if (mounted) {
setState(() => _saving = false);
}
} }
} }
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),
),
);
} }
} }

View File

@@ -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,41 +86,67 @@ 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(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: SingleChildScrollView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextField( TextFormField(
controller: _nameCtrl, controller: _nameCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Название (например "Квартира")', labelText: 'Название',
hintText: 'Например: Квартира',
prefixIcon: Icon(Icons.home), prefixIcon: Icon(Icons.home),
), ),
textCapitalization: TextCapitalization.sentences, textCapitalization: TextCapitalization.sentences,
validator: (value) => (value?.trim().isEmpty ?? true)
? 'Укажите название дома'
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextFormField(
controller: _urlCtrl, controller: _urlCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Адрес сервера (например ignis.akokos.ru)', labelText: 'Адрес сервера',
hintText: 'Например: ignis.akokos.ru',
helperText: 'Можно без https, приложение подставит его само',
prefixIcon: Icon(Icons.dns), prefixIcon: Icon(Icons.dns),
), ),
keyboardType: TextInputType.url, 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), const SizedBox(height: 12),
TextField( TextFormField(
controller: _keyCtrl, controller: _keyCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'API Key', labelText: 'API Key',
helperText: 'Ключ проверяется перед сохранением дома',
prefixIcon: Icon(Icons.key), prefixIcon: Icon(Icons.key),
), ),
obscureText: true, obscureText: true,
validator: (value) =>
(value?.trim().isEmpty ?? true) ? 'Укажите API key' : null,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// ─── GPS-координаты (опционально) ───
const Text( const Text(
'Координаты дома (опционально)', 'Координаты дома (опционально)',
style: TextStyle( style: TextStyle(
@@ -130,14 +157,14 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( const Text(
'Для автоматизации по геолокации', 'Нужны только для расстояния и автовыключения по геолокации',
style: TextStyle(color: Colors.white30, fontSize: 12), style: TextStyle(color: Colors.white30, fontSize: 12),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextFormField(
controller: _latCtrl, controller: _latCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Широта', labelText: 'Широта',
@@ -148,11 +175,26 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
decimal: true, decimal: true,
signed: 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), const SizedBox(width: 12),
Expanded( Expanded(
child: TextField( child: TextFormField(
controller: _lonCtrl, controller: _lonCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Долгота', labelText: 'Долгота',
@@ -163,14 +205,26 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
decimal: true, decimal: true,
signed: 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), const SizedBox(height: 16),
// ─── Геофенс ───
SwitchListTile( SwitchListTile(
title: const Text('Выключать свет при уходе'), title: const Text('Выключать свет при уходе'),
subtitle: Text( subtitle: Text(
@@ -205,7 +259,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
style: TextStyle(fontSize: 11, color: Colors.white24), style: TextStyle(fontSize: 11, color: Colors.white24),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -228,15 +281,20 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'), : 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();

View File

@@ -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,
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, 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),
style: const TextStyle(fontSize: 12, color: Colors.white60),
), ),
trailing: IconButton( ),
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), icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
onPressed: () => _confirmCancel(context, ref, task.jobId), 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(),
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)}',
),
),
);
} }
}, return 'Один раз: ${formatRunAtLabel(task.runAt)}';
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,29 +374,34 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
bottom: bottomPadding + systemPadding + 20, bottom: bottomPadding + systemPadding + 20,
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Заголовок
const Text( const Text(
'Новое расписание', 'Новое расписание',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 6),
const Text(
'Сейчас расписания создаются для групп. Сначала выберите группу, потом задайте таймер или повтор.',
style: TextStyle(color: Colors.white54),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Тип расписания
Row( Row(
children: [ children: [
ChoiceChip( ChoiceChip(
label: const Text('Таймер'), label: const Text('Один раз'),
selected: _type == 'once', selected: _type == 'once',
selectedColor: Colors.deepOrange, selectedColor: Colors.deepOrange,
onSelected: (_) => setState(() => _type = 'once'), onSelected: (_) => setState(() => _type = 'once'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ChoiceChip( ChoiceChip(
label: const Text('Cron (повтор)'), label: const Text('Повтор'),
selected: _type == 'cron', selected: _type == 'cron',
selectedColor: Colors.deepOrange, selectedColor: Colors.deepOrange,
onSelected: (_) => setState(() => _type = 'cron'), onSelected: (_) => setState(() => _type = 'cron'),
@@ -265,86 +409,184 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
], ],
), ),
const SizedBox(height: 16), 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<String>( DropdownButtonFormField<String>(
initialValue: _selectedGroupId, initialValue: _selectedGroupId,
decoration: const InputDecoration(labelText: 'Группа'), decoration: const InputDecoration(
items: groups.map((g) { labelText: 'Группа',
final id = g.id; helperText: 'Расписание применяется к одной группе света',
final name = g.name; ),
return DropdownMenuItem(value: id, child: Text(name)); validator: (value) => value == null ? 'Выберите группу' : null,
}).toList(), items: groups
onChanged: (v) => setState(() => _selectedGroupId = v), .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), const SizedBox(height: 12),
// Действие: включить / выключить
SwitchListTile( SwitchListTile(
title: Text(_targetState ? 'Включить' : 'Выключить'), title: Text(_targetState ? 'Включить свет' : 'Выключить свет'),
subtitle: const Text('Действие при срабатывании'), subtitle: const Text('Что должно произойти по расписанию'),
value: _targetState, value: _targetState,
activeThumbColor: Colors.deepOrange, activeThumbColor: Colors.deepOrange,
onChanged: (v) => setState(() => _targetState = v), onChanged: (value) => setState(() => _targetState = value),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// ─── Once: через сколько часов ───
if (_type == 'once') ...[ if (_type == 'once') ...[
Text( TextFormField(
'Через $_hoursFromNow ч.', controller: _onceAtCtrl,
style: const TextStyle(color: Colors.white70), readOnly: true,
decoration: InputDecoration(
labelText: 'Дата и время',
helperText: 'Запустится один раз в выбранный момент',
suffixIcon: IconButton(
icon: const Icon(Icons.edit_calendar),
onPressed: _pickOnceDateTime,
), ),
Slider(
value: _hoursFromNow.toDouble(),
min: 1,
max: 24,
divisions: 23,
label: '$_hoursFromNow ч.',
activeColor: Colors.deepOrange,
onChanged: (v) => setState(() => _hoursFromNow = v.toInt()),
), ),
], onTap: _pickOnceDateTime,
validator: (_) =>
// ─── Cron: час, минута, дни недели ─── _type == 'once' ? validateOnceSchedule(_onceAt) : null,
if (_type == 'cron') ...[ ),
Row( const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [ children: [
Expanded( _QuickTimeChip(
child: TextField( label: '+1 ч',
controller: _hourCtrl, onTap: () =>
decoration: const InputDecoration( _applyQuickOnceOffset(const Duration(hours: 1)),
labelText: 'Час (0-23)',
), ),
keyboardType: TextInputType.number, _QuickTimeChip(
), label: '+4 ч',
), onTap: () =>
const SizedBox(width: 12), _applyQuickOnceOffset(const Duration(hours: 4)),
Expanded(
child: TextField(
controller: _minuteCtrl,
decoration: const InputDecoration(
labelText: 'Минута (0-59)',
),
keyboardType: TextInputType.number,
), ),
_QuickTimeChip(
label: 'Завтра 09:00',
onTap: _applyTomorrowMorningPreset,
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( _ScheduleSummaryCard(
controller: _dowCtrl, icon: Icons.alarm,
decoration: const InputDecoration( text:
labelText: 'Дни недели (* = каждый, 0-6 = вс-сб)', '${_targetState ? 'Включить' : 'Выключить'} группу в ${_onceAtCtrl.text}',
helperText: 'Например: 1-5 (пн-пт), 0,6 (выходные)', ),
), ],
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), const SizedBox(height: 20),
// Кнопка создания
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 48, height: 48,
@@ -353,19 +595,57 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
backgroundColor: Colors.deepOrange, backgroundColor: Colors.deepOrange,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
onPressed: _selectedGroupId == null ? null : _save, onPressed: _saving || groups.isEmpty ? null : _save,
child: const Text('СОЗДАТЬ'), child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: 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();
} catch (e) { if (!mounted) return;
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: ${describeLoadError(e)}')), 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);
}
}
}
}
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)),
),
],
),
);
} }
} }

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

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