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

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