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'; class SchedulesScreen extends ConsumerStatefulWidget { const SchedulesScreen({super.key}); @override ConsumerState createState() => _SchedulesScreenState(); } class _SchedulesScreenState extends ConsumerState { final Set _cancellingJobIds = {}; @override void initState() { super.initState(); _load(); } Future _load() async { await ref.read(tasksProvider.notifier).load(); } Future _cancelTask(ScheduleTask task) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Отменить расписание?'), content: Text( task.isCron ? 'Повтор для "${task.targetId}" будет удалён.' : 'Одноразовый запуск для "${task.targetId}" будет удалён.', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Нет'), ), TextButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text( 'Удалить', style: TextStyle(color: Colors.redAccent), ), ), ], ), ); if (confirmed != true) { return; } setState(() => _cancellingJobIds.add(task.jobId)); try { await ref.read(tasksProvider.notifier).cancel(task.jobId); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Расписание удалено'), duration: Duration(seconds: 1), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Не удалось удалить расписание: ${describeLoadError(e)}', ), ), ); } finally { if (mounted) { setState(() => _cancellingJobIds.remove(task.jobId)); } } } Future _showAddDialog() async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: const Color(0xFF1E1E1E), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (_) => const _AddScheduleSheet(), ); } @override Widget build(BuildContext context) { final tasksState = ref.watch(tasksProvider); final tasks = tasksState.data; return Scaffold( appBar: AppBar(title: const Text('РАСПИСАНИЯ')), body: _buildContent(tasksState, tasks), floatingActionButton: FloatingActionButton( backgroundColor: Colors.deepOrange, onPressed: _showAddDialog, child: const Icon(Icons.add), ), ); } Widget _buildContent( LoadState> tasksState, List tasks, ) { if ((tasksState.isIdle || tasksState.isLoading) && tasks.isEmpty) { return const Center( child: CircularProgressIndicator(color: Colors.deepOrange), ); } if (tasksState.hasError && tasks.isEmpty) { return LoadErrorView( title: 'Не удалось загрузить расписания', message: tasksState.errorMessage, icon: Icons.schedule, onRetry: _load, ); } if (tasks.isEmpty) { 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), ), ], ), ); } final hasStatusHeader = tasksState.isLoading || tasksState.hasError; final statusHeaderCount = hasStatusHeader ? 1 : 0; return RefreshIndicator( color: Colors.deepOrange, onRefresh: _load, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(12), itemCount: tasks.length + statusHeaderCount, itemBuilder: (context, index) { if (hasStatusHeader && index == 0) { if (tasksState.isLoading) { return const Padding( padding: EdgeInsets.only(bottom: 12), child: LinearProgressIndicator(color: Colors.deepOrange), ); } return LoadErrorBanner( title: 'Не удалось обновить расписания', message: tasksState.errorMessage, onRetry: _load, ); } 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 StatelessWidget { final ScheduleTask task; final bool busy; final VoidCallback? onDelete; const _TaskCard({ required this.task, required this.busy, required this.onDelete, }); @override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.only(bottom: 10), child: ListTile( 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.actionText} группу ${task.targetId}', style: const TextStyle(fontWeight: FontWeight.bold), ), 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, ), ), ); } 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(); @override ConsumerState<_AddScheduleSheet> createState() => _AddScheduleSheetState(); } class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { final _formKey = GlobalKey(); final _onceAtCtrl = TextEditingController(); final _cronTimeCtrl = TextEditingController(); String _type = 'once'; String? _selectedGroupId; bool _targetState = false; bool _saving = false; DateTime _onceAt = defaultOnceScheduleTime(); TimeOfDay _cronTime = const TimeOfDay(hour: 22, minute: 0); Set _selectedWeekdays = scheduleWeekdayOptions .map((option) => option.backendValue) .toSet(); @override void initState() { super.initState(); _syncControllers(); Future.microtask(() async { try { await ref.read(groupsProvider.notifier).refresh(); } catch (_) { // Ошибка загрузки уже отражается через groupsLoadStateProvider. } }); } @override void dispose() { _onceAtCtrl.dispose(); _cronTimeCtrl.dispose(); super.dispose(); } void _syncControllers() { _onceAtCtrl.text = formatLocalScheduleDateTime(_onceAt); _cronTimeCtrl.text = '${_cronTime.hour.toString().padLeft(2, '0')}:${_cronTime.minute.toString().padLeft(2, '0')}'; } Future _pickOnceDateTime() async { final pickedDate = await showDatePicker( context: context, initialDate: _onceAt, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365)), ); if (!mounted || pickedDate == null) return; final pickedTime = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_onceAt), ); if (!mounted || pickedTime == null) return; setState(() { _onceAt = DateTime( pickedDate.year, pickedDate.month, pickedDate.day, pickedTime.hour, pickedTime.minute, ); _syncControllers(); }); } Future _pickCronTime() async { final pickedTime = await showTimePicker( context: context, initialTime: _cronTime, ); if (!mounted || pickedTime == null) return; setState(() { _cronTime = pickedTime; _syncControllers(); }); } @override Widget build(BuildContext context) { final groups = ref.watch(groupsProvider); final groupsLoadState = ref.watch(groupsLoadStateProvider); final bottomPadding = MediaQuery.of(context).viewInsets.bottom; final systemPadding = MediaQuery.of(context).padding.bottom; final cronWeekdaysError = _type == 'cron' ? validateCronSchedule( hour: _cronTime.hour, minute: _cronTime.minute, weekdays: _selectedWeekdays, ) : null; return Padding( padding: EdgeInsets.only( left: 20, right: 20, top: 20, bottom: bottomPadding + systemPadding + 20, ), child: SingleChildScrollView( 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), ), const SizedBox(height: 6), const Text( 'Сейчас расписания создаются для групп. Сначала выберите группу, потом задайте таймер или повтор.', style: TextStyle(color: Colors.white54), ), 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('Повтор'), selected: _type == 'cron', selectedColor: Colors.deepOrange, onSelected: (_) => setState(() => _type = 'cron'), ), ], ), const SizedBox(height: 16), if (groupsLoadState.isLoading && groups.isEmpty) const Padding( padding: EdgeInsets.only(bottom: 12), child: LinearProgressIndicator(color: Colors.deepOrange), ), if (groupsLoadState.hasError && groups.isEmpty) Padding( padding: const EdgeInsets.only(bottom: 12), child: LoadErrorBanner( title: 'Не удалось загрузить группы', message: groupsLoadState.errorMessage ?? 'Неизвестная ошибка', onRetry: () => ref.read(groupsProvider.notifier).refresh(), ), ), DropdownButtonFormField( initialValue: _selectedGroupId, decoration: const InputDecoration( labelText: 'Группа', helperText: 'Расписание применяется к одной группе света', ), validator: (value) => value == null ? 'Выберите группу' : null, items: groups .map( (group) => DropdownMenuItem( value: group.id, child: Text(group.name), ), ) .toList(), onChanged: groups.isEmpty ? null : (value) => setState(() => _selectedGroupId = value), ), if (groups.isEmpty) const Padding( padding: EdgeInsets.only(top: 8), child: Text( 'Сначала создайте хотя бы одну группу света.', style: TextStyle(color: Colors.white38, fontSize: 12), ), ), const SizedBox(height: 12), SwitchListTile( title: Text(_targetState ? 'Включить свет' : 'Выключить свет'), subtitle: const Text('Что должно произойти по расписанию'), value: _targetState, activeThumbColor: Colors.deepOrange, onChanged: (value) => setState(() => _targetState = value), contentPadding: EdgeInsets.zero, ), const SizedBox(height: 12), if (_type == 'once') ...[ TextFormField( controller: _onceAtCtrl, readOnly: true, decoration: InputDecoration( labelText: 'Дата и время', helperText: 'Запустится один раз в выбранный момент', suffixIcon: IconButton( icon: const Icon(Icons.edit_calendar), onPressed: _pickOnceDateTime, ), ), onTap: _pickOnceDateTime, validator: (_) => _type == 'once' ? validateOnceSchedule(_onceAt) : null, ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ _QuickTimeChip( label: '+1 ч', onTap: () => _applyQuickOnceOffset(const Duration(hours: 1)), ), _QuickTimeChip( label: '+4 ч', onTap: () => _applyQuickOnceOffset(const Duration(hours: 4)), ), _QuickTimeChip( label: 'Завтра 09:00', onTap: _applyTomorrowMorningPreset, ), ], ), const SizedBox(height: 12), _ScheduleSummaryCard( icon: Icons.alarm, text: '${_targetState ? 'Включить' : 'Выключить'} группу в ${_onceAtCtrl.text}', ), ], if (_type == 'cron') ...[ TextFormField( controller: _cronTimeCtrl, readOnly: true, decoration: InputDecoration( labelText: 'Время', helperText: 'Повтор будет выполняться каждый выбранный день', suffixIcon: IconButton( icon: const Icon(Icons.access_time), onPressed: _pickCronTime, ), ), onTap: _pickCronTime, ), const SizedBox(height: 12), const Text( 'Дни недели', style: TextStyle( color: Colors.white70, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ FilterChip( label: const Text('Каждый день'), selected: _selectedWeekdays.length == scheduleWeekdayOptions.length, selectedColor: Colors.deepOrange, onSelected: (selected) { setState(() { _selectedWeekdays = selected ? scheduleWeekdayOptions .map((option) => option.backendValue) .toSet() : {}; }); }, ), for (final option in scheduleWeekdayOptions) FilterChip( label: Text(option.shortLabel), selected: _selectedWeekdays.contains( option.backendValue, ), selectedColor: Colors.deepOrange, onSelected: (selected) { setState(() { if (selected) { _selectedWeekdays.add(option.backendValue); } else { _selectedWeekdays.remove(option.backendValue); } }); }, ), ], ), if (cronWeekdaysError != null && _selectedWeekdays.isEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: Text( cronWeekdaysError, style: const TextStyle( color: Colors.redAccent, fontSize: 12, ), ), ), const SizedBox(height: 12), _ScheduleSummaryCard( icon: Icons.repeat, text: '${_targetState ? 'Включать' : 'Выключать'} группу ${describeCronWeekdays(_selectedWeekdays)} в ${_cronTimeCtrl.text}', ), ], const SizedBox(height: 20), SizedBox( width: double.infinity, height: 48, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.deepOrange, foregroundColor: Colors.white, ), onPressed: _saving || groups.isEmpty ? null : _save, child: _saving ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text('СОЗДАТЬ РАСПИСАНИЕ'), ), ), ], ), ), ), ); } void _applyQuickOnceOffset(Duration duration) { setState(() { _onceAt = DateTime.now().add(duration); _syncControllers(); }); } void _applyTomorrowMorningPreset() { final now = DateTime.now(); setState(() { _onceAt = DateTime(now.year, now.month, now.day + 1, 9); _syncControllers(); }); } Future _save() async { FocusScope.of(context).unfocus(); if (!_formKey.currentState!.validate()) { return; } if (_type == 'cron' && _selectedWeekdays.isEmpty) { setState(() {}); return; } if (_selectedGroupId == null) { return; } setState(() => _saving = true); try { if (_type == 'once') { await ref .read(tasksProvider.notifier) .addOnce( targetId: _selectedGroupId!, targetState: _targetState, runAt: _onceAt.toUtc().toIso8601String(), ); } else { await ref .read(tasksProvider.notifier) .addCron( targetId: _selectedGroupId!, hour: _cronTime.hour.toString(), minute: _cronTime.minute.toString(), dayOfWeek: serializeCronWeekdays(_selectedWeekdays), targetState: _targetState, ); } if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Расписание создано'), duration: Duration(seconds: 1), ), ); Navigator.of(context).pop(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Не удалось создать расписание: ${describeLoadError(e)}', ), ), ); } finally { if (mounted) { setState(() => _saving = false); } } } } 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)), ), ], ), ); } }