feat: polish phase 7 forms and schedules
This commit is contained in:
@@ -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<SchedulesScreen> {
|
||||
final Set<String> _cancellingJobIds = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -26,6 +28,74 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final tasksState = ref.watch(tasksProvider);
|
||||
@@ -36,25 +106,12 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
||||
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<List<ScheduleTask>> tasksState,
|
||||
List<ScheduleTask> tasks,
|
||||
@@ -75,16 +132,27 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
||||
}
|
||||
|
||||
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<SchedulesScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<FormState>();
|
||||
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<int> _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<void>.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<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
|
||||
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<String>(
|
||||
initialValue: _selectedGroupId,
|
||||
decoration: const InputDecoration(labelText: 'Группа'),
|
||||
items: groups.map((g) {
|
||||
final id = g.id;
|
||||
final name = g.name;
|
||||
return DropdownMenuItem(value: id, child: Text(name));
|
||||
}).toList(),
|
||||
onChanged: (v) => setState(() => _selectedGroupId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Действие: включить / выключить
|
||||
SwitchListTile(
|
||||
title: Text(_targetState ? 'Включить' : 'Выключить'),
|
||||
subtitle: const Text('Действие при срабатывании'),
|
||||
value: _targetState,
|
||||
activeThumbColor: Colors.deepOrange,
|
||||
onChanged: (v) => setState(() => _targetState = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ─── Once: через сколько часов ───
|
||||
if (_type == 'once') ...[
|
||||
Text(
|
||||
'Через $_hoursFromNow ч.',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
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<String>(
|
||||
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<String>(
|
||||
value: group.id,
|
||||
child: Text(group.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: groups.isEmpty
|
||||
? null
|
||||
: (value) => setState(() => _selectedGroupId = value),
|
||||
),
|
||||
if (groups.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Сначала создайте хотя бы одну группу света.',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
title: Text(_targetState ? 'Включить свет' : 'Выключить свет'),
|
||||
subtitle: const Text('Что должно произойти по расписанию'),
|
||||
value: _targetState,
|
||||
activeThumbColor: Colors.deepOrange,
|
||||
onChanged: (value) => setState(() => _targetState = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_type == 'once') ...[
|
||||
TextFormField(
|
||||
controller: _onceAtCtrl,
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Дата и время',
|
||||
helperText: 'Запустится один раз в выбранный момент',
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.edit_calendar),
|
||||
onPressed: _pickOnceDateTime,
|
||||
),
|
||||
),
|
||||
onTap: _pickOnceDateTime,
|
||||
validator: (_) =>
|
||||
_type == 'once' ? validateOnceSchedule(_onceAt) : null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_QuickTimeChip(
|
||||
label: '+1 ч',
|
||||
onTap: () =>
|
||||
_applyQuickOnceOffset(const Duration(hours: 1)),
|
||||
),
|
||||
_QuickTimeChip(
|
||||
label: '+4 ч',
|
||||
onTap: () =>
|
||||
_applyQuickOnceOffset(const Duration(hours: 4)),
|
||||
),
|
||||
_QuickTimeChip(
|
||||
label: 'Завтра 09:00',
|
||||
onTap: _applyTomorrowMorningPreset,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ScheduleSummaryCard(
|
||||
icon: Icons.alarm,
|
||||
text:
|
||||
'${_targetState ? 'Включить' : 'Выключить'} группу в ${_onceAtCtrl.text}',
|
||||
),
|
||||
],
|
||||
if (_type == 'cron') ...[
|
||||
TextFormField(
|
||||
controller: _cronTimeCtrl,
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Время',
|
||||
helperText:
|
||||
'Повтор будет выполняться каждый выбранный день',
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.access_time),
|
||||
onPressed: _pickCronTime,
|
||||
),
|
||||
),
|
||||
onTap: _pickCronTime,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Дни недели',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Каждый день'),
|
||||
selected:
|
||||
_selectedWeekdays.length ==
|
||||
scheduleWeekdayOptions.length,
|
||||
selectedColor: Colors.deepOrange,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_selectedWeekdays = selected
|
||||
? scheduleWeekdayOptions
|
||||
.map((option) => option.backendValue)
|
||||
.toSet()
|
||||
: <int>{};
|
||||
});
|
||||
},
|
||||
),
|
||||
for (final option in scheduleWeekdayOptions)
|
||||
FilterChip(
|
||||
label: Text(option.shortLabel),
|
||||
selected: _selectedWeekdays.contains(
|
||||
option.backendValue,
|
||||
),
|
||||
selectedColor: Colors.deepOrange,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedWeekdays.add(option.backendValue);
|
||||
} else {
|
||||
_selectedWeekdays.remove(option.backendValue);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (cronWeekdaysError != null && _selectedWeekdays.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
cronWeekdaysError,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ScheduleSummaryCard(
|
||||
icon: Icons.repeat,
|
||||
text:
|
||||
'${_targetState ? 'Включать' : 'Выключать'} группу ${describeCronWeekdays(_selectedWeekdays)} в ${_cronTimeCtrl.text}',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: _saving || groups.isEmpty ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('СОЗДАТЬ РАСПИСАНИЕ'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Кнопка создания
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: _selectedGroupId == null ? null : _save,
|
||||
child: const Text('СОЗДАТЬ'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
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<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 {
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user