feat: ignis app v1.0.0 -- управление WiZ лампами

This commit is contained in:
Artem Kokos
2026-03-28 18:55:54 +07:00
commit 688139a75a
143 changed files with 6464 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/providers.dart';
/// Экран создания новой группы ламп.
/// Загружает список устройств, позволяет выбрать нужные.
class GroupEditScreen extends ConsumerStatefulWidget {
const GroupEditScreen({super.key});
@override
ConsumerState<GroupEditScreen> createState() => _GroupEditScreenState();
}
class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
final _idCtrl = TextEditingController();
final _nameCtrl = TextEditingController();
final Set<String> _selectedMacs = {};
bool _loading = true;
bool _saving = false;
bool _rescanning = false;
@override
void initState() {
super.initState();
_loadDevices();
}
@override
void dispose() {
_idCtrl.dispose();
_nameCtrl.dispose();
super.dispose();
}
Future<void> _loadDevices() async {
await ref.read(devicesProvider.notifier).load();
if (mounted) setState(() => _loading = false);
}
/// Пересканировать сеть и перезагрузить устройства
Future<void> _rescan() async {
setState(() => _rescanning = true);
try {
await ref.read(apiProvider).rescanNetwork();
// Подождать немного -- сканирование асинхронное
await Future.delayed(const Duration(seconds: 3));
await ref.read(devicesProvider.notifier).load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка сканирования: $e')),
);
}
}
if (mounted) setState(() => _rescanning = false);
}
@override
Widget build(BuildContext context) {
final devices = ref.watch(devicesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('НОВАЯ ГРУППА'),
actions: [
// Кнопка ресканирования сети
IconButton(
icon: _rescanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
tooltip: 'Пересканировать сеть',
onPressed: _rescanning ? null : _rescan,
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator(color: Colors.deepOrange))
: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ID группы
TextField(
controller: _idCtrl,
decoration: const InputDecoration(
labelText: 'ID группы (например "bedroom")',
prefixIcon: Icon(Icons.tag),
),
),
const SizedBox(height: 12),
// Название группы
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Название (например "Спальня")',
prefixIcon: Icon(Icons.label),
),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
// Заголовок списка устройств
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Устройства (${_selectedMacs.length} выбрано)',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
TextButton(
onPressed: () {
setState(() {
if (_selectedMacs.length == devices.length) {
_selectedMacs.clear();
} else {
for (final d in devices) {
final mac = _extractMac(d);
if (mac != null) _selectedMacs.add(mac);
}
}
});
},
child: Text(
_selectedMacs.length == devices.length
? 'Снять все'
: 'Выбрать все',
style: const TextStyle(fontSize: 12),
),
),
],
),
// Список устройств
Expanded(
child: devices.isEmpty
? const Center(
child: Text(
'Устройства не найдены.\nПопробуйте пересканировать сеть.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38),
),
)
: ListView.builder(
itemCount: devices.length,
itemBuilder: (context, index) {
final d = devices[index];
final mac = _extractMac(d) ?? '';
final name = _extractName(d);
final ip = _extractIp(d);
final selected = _selectedMacs.contains(mac);
return CheckboxListTile(
value: selected,
activeColor: Colors.deepOrange,
title: Text(name),
subtitle: Text(
'${mac}${ip != null ? ' - $ip' : ''}',
style: const TextStyle(
fontSize: 11, color: Colors.white38),
),
onChanged: (v) {
setState(() {
if (v == true) {
_selectedMacs.add(mac);
} else {
_selectedMacs.remove(mac);
}
});
},
);
},
),
),
// Кнопка сохранения
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepOrange,
foregroundColor: Colors.white,
),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('СОЗДАТЬ ГРУППУ'),
),
),
],
),
),
);
}
/// Извлечь MAC-адрес из объекта устройства
String? _extractMac(dynamic device) {
if (device is Map) {
return (device['mac'] ?? device['id'] ?? device['mac_address'])?.toString();
}
return device?.toString();
}
/// Извлечь имя устройства
String _extractName(dynamic device) {
if (device is Map) {
return (device['name'] ?? device['model'] ?? device['mac'] ?? 'Лампа')
.toString();
}
return device?.toString() ?? 'Лампа';
}
/// Извлечь IP-адрес
String? _extractIp(dynamic device) {
if (device is Map) {
return (device['ip'] ?? device['address'])?.toString();
}
return null;
}
Future<void> _save() async {
final id = _idCtrl.text.trim();
final name = _nameCtrl.text.trim();
if (id.isEmpty || name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Укажите ID и название')),
);
return;
}
if (_selectedMacs.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Выберите хотя бы одно устройство')),
);
return;
}
setState(() => _saving = true);
try {
await ref.read(apiProvider).createGroup(id, name, _selectedMacs.toList());
// Обновить список групп
await ref.read(groupsProvider.notifier).refresh();
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка создания: $e')),
);
}
}
if (mounted) setState(() => _saving = false);
}
}

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/home_config.dart';
import '../providers/providers.dart';
/// Экран создания или редактирования "дома" (сервера Ignis).
class HomeEditScreen extends ConsumerStatefulWidget {
final HomeConfig? home; // null -- создание, иначе редактирование
const HomeEditScreen({super.key, this.home});
@override
ConsumerState<HomeEditScreen> createState() => _HomeEditScreenState();
}
class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final _nameCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
final _keyCtrl = TextEditingController();
bool _saving = false;
bool get _isEdit => widget.home != null;
@override
void initState() {
super.initState();
if (_isEdit) {
_nameCtrl.text = widget.home!.name;
_urlCtrl.text = widget.home!.url;
_keyCtrl.text = widget.home!.apiKey;
}
}
@override
void dispose() {
_nameCtrl.dispose();
_urlCtrl.dispose();
_keyCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'),
),
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Название (например "Квартира")',
prefixIcon: Icon(Icons.home),
),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 12),
TextField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: 'Адрес сервера (например ignis.akokos.ru)',
prefixIcon: Icon(Icons.dns),
),
keyboardType: TextInputType.url,
),
const SizedBox(height: 12),
TextField(
controller: _keyCtrl,
decoration: const InputDecoration(
labelText: 'API Key',
prefixIcon: Icon(Icons.key),
),
obscureText: true,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepOrange,
foregroundColor: Colors.white,
),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
),
),
],
),
),
);
}
Future<void> _save() async {
final name = _nameCtrl.text.trim();
final url = _urlCtrl.text.trim();
final key = _keyCtrl.text.trim();
if (name.isEmpty || url.isEmpty || key.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Заполните все поля')),
);
return;
}
setState(() => _saving = true);
final home = _isEdit
? widget.home!.copyWith(name: name, url: url, apiKey: key)
: HomeConfig(
id: DateTime.now().millisecondsSinceEpoch.toString(),
name: name,
url: url,
apiKey: key,
);
if (_isEdit) {
await ref.read(homesProvider.notifier).update(home);
} else {
await ref.read(homesProvider.notifier).add(home);
}
if (mounted) Navigator.of(context).pop();
}
}

View File

@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/home_config.dart';
import '../providers/providers.dart';
import 'home_edit_screen.dart';
import 'remote_screen.dart';
/// Экран "Дома" -- список серверов Ignis.
/// Пользователь может добавить, удалить, переключить активный дом.
class HomesScreen extends ConsumerWidget {
const HomesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final homes = ref.watch(homesProvider);
final currentHome = ref.watch(currentHomeProvider);
return Scaffold(
appBar: AppBar(
title: const Text('ДОМА'),
automaticallyImplyLeading: false,
),
body: homes.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.home_outlined, size: 64, color: Colors.white24),
const SizedBox(height: 16),
const Text(
'Нет добавленных домов',
style: TextStyle(color: Colors.white54, fontSize: 16),
),
const SizedBox(height: 8),
const Text(
'Добавьте сервер Ignis',
style: TextStyle(color: Colors.white38, fontSize: 14),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: homes.length,
itemBuilder: (context, index) {
final home = homes[index];
final isActive = currentHome?.id == home.id;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
Icons.home,
color: isActive ? Colors.deepOrange : Colors.white38,
size: 28,
),
title: Text(
home.name,
style: TextStyle(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? Colors.deepOrange : Colors.white,
),
),
subtitle: Text(
home.url,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Кнопка редактирования
IconButton(
icon: const Icon(Icons.edit, size: 20, color: Colors.white38),
onPressed: () => _editHome(context, ref, home),
),
// Кнопка удаления
IconButton(
icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent),
onPressed: () => _confirmDelete(context, ref, home),
),
],
),
onTap: () => _selectHome(context, ref, home),
),
);
},
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _addHome(context),
child: const Icon(Icons.add),
),
);
}
/// Выбрать дом и перейти на пульт
void _selectHome(BuildContext context, WidgetRef ref, HomeConfig home) async {
await ref.read(currentHomeProvider.notifier).switchTo(home);
if (context.mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const RemoteScreen()),
);
}
}
/// Добавить новый дом
void _addHome(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const HomeEditScreen()),
);
}
/// Редактировать дом
void _editHome(BuildContext context, WidgetRef ref, HomeConfig home) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)),
);
}
/// Подтвердить удаление
void _confirmDelete(BuildContext context, WidgetRef ref, HomeConfig home) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить дом?'),
content: Text('Удалить "${home.name}" (${home.url})?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Отмена'),
),
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
await ref.read(homesProvider.notifier).remove(home.id);
},
child: const Text('Удалить', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
}
}

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/providers.dart';
import '../widgets/group_card.dart';
import 'homes_screen.dart';
import 'group_edit_screen.dart';
import 'schedules_screen.dart';
/// Основной экран пульта управления.
/// Показывает группы текущего дома с управлением.
class RemoteScreen extends ConsumerStatefulWidget {
const RemoteScreen({super.key});
@override
ConsumerState<RemoteScreen> createState() => _RemoteScreenState();
}
class _RemoteScreenState extends ConsumerState<RemoteScreen> {
bool _loading = true;
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
await ref.read(groupsProvider.notifier).initAndRefresh();
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final groups = ref.watch(groupsProvider);
final currentHome = ref.watch(currentHomeProvider);
return Scaffold(
appBar: AppBar(
title: Text(currentHome?.name ?? 'IGNIS'),
leading: IconButton(
icon: const Icon(Icons.home),
tooltip: 'Дома',
onPressed: () => Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomesScreen()),
),
),
actions: [
// Кнопка добавления группы
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Создать группу',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const GroupEditScreen()),
),
),
// Расписания
IconButton(
icon: const Icon(Icons.schedule),
tooltip: 'Расписания',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SchedulesScreen()),
),
),
],
),
body: _loading
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.deepOrange),
SizedBox(height: 20),
Text(
"Опрос ламп (это долго)...",
style: TextStyle(color: Colors.white54),
),
],
),
)
: groups.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.lightbulb_outline, size: 64, color: Colors.white24),
const SizedBox(height: 16),
const Text(
'Нет групп',
style: TextStyle(color: Colors.white54, fontSize: 16),
),
const SizedBox(height: 8),
TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Создать группу'),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const GroupEditScreen()),
),
),
],
),
)
: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: () =>
ref.read(groupsProvider.notifier).refresh(),
child: ListView.builder(
padding: const EdgeInsets.only(top: 8, bottom: 80),
itemCount: groups.length,
itemBuilder: (context, index) {
final g = Map<String, dynamic>.from(groups[index]);
return Dismissible(
key: Key(g['id'].toString()),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
margin: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.redAccent.withOpacity(0.3),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.delete, color: Colors.redAccent),
),
confirmDismiss: (_) => _confirmDeleteGroup(context, g),
onDismissed: (_) => _deleteGroup(g['id'].toString()),
child: GroupCard(group: g),
);
},
),
),
);
}
/// Подтверждение удаления группы свайпом
Future<bool> _confirmDeleteGroup(
BuildContext context, Map<String, dynamic> g) async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить группу?'),
content: Text('Удалить "${g['name']}"?'),
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)),
),
],
),
) ??
false;
}
Future<void> _deleteGroup(String id) async {
try {
await ref.read(apiProvider).deleteGroup(id);
await ref.read(groupsProvider.notifier).refresh();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка удаления: $e')),
);
}
}
}
}

View File

@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/providers.dart';
/// Экран управления расписаниями.
/// Показывает все задачи (one-shot и cron), позволяет создавать и удалять.
class SchedulesScreen extends ConsumerStatefulWidget {
const SchedulesScreen({super.key});
@override
ConsumerState<SchedulesScreen> createState() => _SchedulesScreenState();
}
class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ref.read(tasksProvider.notifier).load();
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final tasks = ref.watch(tasksProvider);
return Scaffold(
appBar: AppBar(
title: const Text('РАСПИСАНИЯ'),
),
body: _loading
? const Center(child: CircularProgressIndicator(color: Colors.deepOrange))
: tasks.isEmpty
? 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),
),
],
),
)
: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: () => ref.read(tasksProvider.notifier).load(),
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return _TaskCard(task: task);
},
),
),
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) => _AddScheduleSheet(),
);
}
}
/// Карточка одной задачи расписания
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: () {
Navigator.of(ctx).pop();
ref.read(tasksProvider.notifier).cancel(jobId);
},
child: const Text('Да', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
}
}
/// Нижний лист для создания расписания
class _AddScheduleSheet extends ConsumerStatefulWidget {
@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);
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 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>(
value: _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,
activeColor: 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('Ошибка: $e')),
);
}
}
}
}