Files
ignis_app/lib/screens/schedules_screen.dart

740 lines
24 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<SchedulesScreen> createState() => _SchedulesScreenState();
}
class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
final Set<String> _cancellingJobIds = <String>{};
@override
void initState() {
super.initState();
Future<void>.microtask(_load);
}
Future<void> _load() async {
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);
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<List<ScheduleTask>> tasksState,
List<ScheduleTask> 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<FormState>();
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<int> _selectedWeekdays = scheduleWeekdayOptions
.map((option) => option.backendValue)
.toSet();
@override
void initState() {
super.initState();
_syncControllers();
Future<void>.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<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(
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<String>(
initialValue: _selectedGroupId,
decoration: const InputDecoration(
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('СОЗДАТЬ РАСПИСАНИЕ'),
),
),
],
),
),
),
);
}
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
.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)),
),
],
),
);
}
}