Files
ignis_app/lib/screens/schedules_screen.dart
2026-04-23 20:44:51 +07:00

424 lines
14 KiB
Dart
Raw 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 '../providers/providers.dart';
import '../widgets/load_error_view.dart';
/// Экран управления расписаниями.
/// Показывает все задачи (one-shot и cron), позволяет создавать и удалять.
class SchedulesScreen extends ConsumerStatefulWidget {
const SchedulesScreen({super.key});
@override
ConsumerState<SchedulesScreen> createState() => _SchedulesScreenState();
}
class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ref.read(tasksProvider.notifier).load();
}
@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(context),
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<dynamic>> tasksState,
List<dynamic> 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 const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.schedule, size: 64, color: Colors.white24),
SizedBox(height: 16),
Text(
'Нет активных расписаний',
style: TextStyle(color: Colors.white54, fontSize: 16),
),
],
),
);
}
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 taskIndex = index - statusHeaderCount;
return _TaskCard(task: tasks[taskIndex]);
},
),
);
}
}
/// Карточка одной задачи расписания
class _TaskCard extends ConsumerWidget {
final dynamic task;
const _TaskCard({required this.task});
@override
Widget build(BuildContext context, WidgetRef ref) {
final map = task is Map
? Map<String, dynamic>.from(task)
: <String, dynamic>{};
final jobId = (map['id'] ?? map['job_id'] ?? '').toString();
final targetId = (map['target_id'] ?? map['target'] ?? '').toString();
final state = map['state'];
final runAt = map['run_at'] ?? map['next_run'] ?? map['next_run_time'];
final type = map['type'] ?? (map['cron'] != null ? 'cron' : 'once');
// Формирование описания
final stateStr = state == true
? 'Включить'
: state == false
? 'Выключить'
: '?';
String subtitle = 'Цель: $targetId';
if (runAt != null) subtitle += '\nЗапуск: $runAt';
if (map['cron'] != null) subtitle += '\nCron: ${map['cron']}';
if (map['hour'] != null && map['minute'] != null) {
subtitle += '\nВремя: ${map['hour']}:${map['minute']}';
}
if (map['day_of_week'] != null && map['day_of_week'] != '*') {
subtitle += ' (дни: ${map['day_of_week']})';
}
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
type == 'cron' ? Icons.repeat : Icons.timer,
color: Colors.deepOrange,
),
title: Text(
'$stateStr - $targetId',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 12, color: Colors.white54),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
onPressed: () => _confirmCancel(context, ref, jobId),
),
),
);
}
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)),
),
],
),
);
}
}
/// Нижний лист для создания расписания
class _AddScheduleSheet extends ConsumerStatefulWidget {
const _AddScheduleSheet();
@override
ConsumerState<_AddScheduleSheet> createState() => _AddScheduleSheetState();
}
class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
/// Тип: 'once' или 'cron'
String _type = 'once';
/// Общие поля
String? _selectedGroupId;
bool _targetState = false; // включить или выключить
/// Поля для once
int _hoursFromNow = 4;
/// Поля для cron
final _hourCtrl = TextEditingController(text: '22');
final _minuteCtrl = TextEditingController(text: '00');
final _dowCtrl = TextEditingController(text: '*');
@override
void dispose() {
_hourCtrl.dispose();
_minuteCtrl.dispose();
_dowCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final groups = ref.watch(groupsProvider);
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
final systemPadding = MediaQuery.of(context).padding.bottom;
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
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),
),
Slider(
value: _hoursFromNow.toDouble(),
min: 1,
max: 24,
divisions: 23,
label: '$_hoursFromNow ч.',
activeColor: Colors.deepOrange,
onChanged: (v) => setState(() => _hoursFromNow = v.toInt()),
),
],
// ─── Cron: час, минута, дни недели ───
if (_type == 'cron') ...[
Row(
children: [
Expanded(
child: TextField(
controller: _hourCtrl,
decoration: const InputDecoration(
labelText: 'Час (0-23)',
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _minuteCtrl,
decoration: const InputDecoration(
labelText: 'Минута (0-59)',
),
keyboardType: TextInputType.number,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _dowCtrl,
decoration: const InputDecoration(
labelText: 'Дни недели (* = каждый, 0-6 = вс-сб)',
helperText: 'Например: 1-5 (пн-пт), 0,6 (выходные)',
),
),
],
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;
try {
if (_type == 'once') {
await ref
.read(tasksProvider.notifier)
.addOnce(
targetId: _selectedGroupId!,
targetState: _targetState,
hoursFromNow: _hoursFromNow,
);
} else {
await ref
.read(tasksProvider.notifier)
.addCron(
targetId: _selectedGroupId!,
hour: _hourCtrl.text.trim(),
minute: _minuteCtrl.text.trim(),
dayOfWeek: _dowCtrl.text.trim(),
targetState: _targetState,
);
}
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: ${describeLoadError(e)}')),
);
}
}
}
}