424 lines
14 KiB
Dart
424 lines
14 KiB
Dart
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'].toString();
|
||
final name = g['name']?.toString() ?? id;
|
||
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)}')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|