import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/error_message.dart'; import '../app/load_state.dart'; import '../features/groups/group_form_logic.dart'; import '../models/ignis_device.dart'; import '../models/ignis_group.dart'; import '../providers/providers.dart'; import '../widgets/load_error_view.dart'; class GroupEditScreen extends ConsumerStatefulWidget { const GroupEditScreen({super.key}); @override ConsumerState createState() => _GroupEditScreenState(); } class _GroupEditScreenState extends ConsumerState { final _formKey = GlobalKey(); final _idCtrl = TextEditingController(); final _nameCtrl = TextEditingController(); final Set _selectedMacs = {}; bool _saving = false; bool _rescanning = false; bool _deviceSelectionTouched = false; bool _idEditedManually = false; bool _syncingIdFromName = false; @override void initState() { super.initState(); _nameCtrl.addListener(_handleNameChanged); _idCtrl.addListener(_handleIdChanged); Future.microtask(() async { await _loadDevices(); try { await ref.read(groupsProvider.notifier).refresh(); } catch (_) { // Покажем текущее состояние через экран, без лишнего шума. } }); } @override void dispose() { _nameCtrl.removeListener(_handleNameChanged); _idCtrl.removeListener(_handleIdChanged); _idCtrl.dispose(); _nameCtrl.dispose(); super.dispose(); } void _handleNameChanged() { if (_idEditedManually) { return; } final slug = slugifyGroupId(_nameCtrl.text); _syncingIdFromName = true; _idCtrl.value = _idCtrl.value.copyWith( text: slug, selection: TextSelection.collapsed(offset: slug.length), composing: TextRange.empty, ); _syncingIdFromName = false; } void _handleIdChanged() { if (_syncingIdFromName) { return; } final suggested = slugifyGroupId(_nameCtrl.text); final current = _idCtrl.text.trim(); _idEditedManually = current.isNotEmpty && current != suggested; } Future _loadDevices() async { await ref.read(devicesProvider.notifier).load(); } Future _rescan() async { setState(() => _rescanning = true); try { final response = await ref.read(apiProvider).rescanNetwork(); await ref.read(devicesProvider.notifier).load(); final summary = response.data is Map ? Map.from(response.data as Map) : const {}; final message = formatRescanSummary(summary); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), duration: const Duration(seconds: 2), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Ошибка сканирования: ${describeLoadError(e)}')), ); } finally { if (mounted) { setState(() => _rescanning = false); } } } @override Widget build(BuildContext context) { final devicesState = ref.watch(devicesProvider); final devices = devicesState.data; final groups = ref.watch(groupsProvider); final conflicts = findGroupMembershipConflicts(_selectedMacs, groups); 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: Form( key: _formKey, autovalidateMode: AutovalidateMode.onUserInteraction, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( controller: _nameCtrl, decoration: const InputDecoration( labelText: 'Название группы', hintText: 'Например: Спальня', prefixIcon: Icon(Icons.label), ), textCapitalization: TextCapitalization.sentences, validator: (value) => validateGroupName(value ?? ''), ), const SizedBox(height: 12), TextFormField( controller: _idCtrl, decoration: InputDecoration( labelText: 'ID группы', hintText: 'Например: bedroom', helperText: _idEditedManually ? 'Используется в API и расписаниях' : 'Подставляется автоматически из названия', prefixIcon: const Icon(Icons.tag), ), autocorrect: false, enableSuggestions: false, validator: (value) => validateGroupId(value ?? '', groups), ), const SizedBox(height: 8), const Text( 'Выберите устройства, которые будут управляться как одна группа.', style: TextStyle(color: Colors.white54), ), 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: devices.isEmpty ? null : () { setState(() { _deviceSelectionTouched = true; if (_selectedMacs.length == devices.length) { _selectedMacs.clear(); } else { _selectedMacs ..clear() ..addAll( devices.map( (device) => device.groupMemberId, ), ); } }); }, child: Text( _selectedMacs.length == devices.length ? 'Снять все' : 'Выбрать все', style: const TextStyle(fontSize: 12), ), ), ], ), if (_deviceSelectionTouched && _selectedMacs.isEmpty) const Padding( padding: EdgeInsets.only(top: 4, bottom: 8), child: Text( 'Выберите хотя бы одно устройство', style: TextStyle(color: Colors.redAccent, fontSize: 12), ), ) else const SizedBox(height: 8), if (conflicts.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 12), child: _ConflictBanner(conflicts: conflicts), ), Expanded(child: _buildDevices(devicesState, devices)), Padding( padding: EdgeInsets.only( top: 12, bottom: MediaQuery.of(context).padding.bottom + 8, ), child: 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('СОЗДАТЬ ГРУППУ'), ), ), ), ], ), ), ), ); } Widget _buildDevices( LoadState> devicesState, List devices, ) { if ((devicesState.isIdle || devicesState.isLoading) && devices.isEmpty) { return const Center( child: CircularProgressIndicator(color: Colors.deepOrange), ); } if (devicesState.hasError && devices.isEmpty) { return LoadErrorView( title: 'Не удалось загрузить устройства', message: devicesState.errorMessage, icon: Icons.lightbulb_outline, onRetry: _loadDevices, ); } if (devices.isEmpty) { return const Center( child: Text( 'Устройства не найдены.\nПересканируйте сеть и попробуйте снова.', textAlign: TextAlign.center, style: TextStyle(color: Colors.white38), ), ); } final hasStatusHeader = devicesState.isLoading || devicesState.hasError; final statusHeaderCount = hasStatusHeader ? 1 : 0; return ListView.builder( itemCount: devices.length + statusHeaderCount, itemBuilder: (context, index) { if (hasStatusHeader && index == 0) { if (devicesState.isLoading) { return const Padding( padding: EdgeInsets.only(bottom: 12), child: LinearProgressIndicator(color: Colors.deepOrange), ); } return LoadErrorBanner( title: 'Не удалось обновить устройства', message: devicesState.errorMessage, onRetry: _loadDevices, ); } final device = devices[index - statusHeaderCount]; final selected = _selectedMacs.contains(device.groupMemberId); return CheckboxListTile( value: selected, activeColor: Colors.deepOrange, title: Text(device.name), subtitle: device.subtitle == null ? null : Text( device.subtitle!, style: const TextStyle(fontSize: 11, color: Colors.white38), ), onChanged: (value) { setState(() { _deviceSelectionTouched = true; if (value == true) { _selectedMacs.add(device.groupMemberId); } else { _selectedMacs.remove(device.groupMemberId); } }); }, ); }, ); } Future _save() async { FocusScope.of(context).unfocus(); setState(() => _deviceSelectionTouched = true); if (!_formKey.currentState!.validate()) { return; } if (_selectedMacs.isEmpty) { return; } final groups = ref.read(groupsProvider); final conflicts = findGroupMembershipConflicts(_selectedMacs, groups); if (conflicts.isNotEmpty) { final shouldContinue = await _confirmConflicts(conflicts); if (shouldContinue != true) { return; } } setState(() => _saving = true); try { final id = _idCtrl.text.trim().toLowerCase(); final name = _nameCtrl.text.trim(); await ref.read(apiProvider).createGroup(id, name, _selectedMacs.toList()); await ref.read(groupsProvider.notifier).refresh(); 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); } } } Future _confirmConflicts(List conflicts) { return showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Устройства уже состоят в других группах'), content: Text( 'Выбранные устройства уже входят в: ${conflicts.map((group) => group.name).join(', ')}.\n\nПродолжить создание новой группы?', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Отмена'), ), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.deepOrange), onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Продолжить'), ), ], ), ); } } int _summaryInt(Map summary, String key) { final value = summary[key]; if (value is int) { return value; } return int.tryParse(value?.toString() ?? '') ?? 0; } String formatRescanSummary(Map summary) { final found = _summaryInt(summary, 'found'); final added = _summaryInt(summary, 'added'); final updated = _summaryInt(summary, 'updated'); final removed = _summaryInt(summary, 'removed_offline'); if (added == 0 && updated == 0 && removed == 0 && found == 0) { return 'Сканирование завершено: устройства не найдены'; } if (added == 0 && removed == 0) { return 'Сканирование завершено: найдено $found, обновлено $updated'; } return 'Сканирование завершено: найдено $found, новых $added, обновлено $updated, убрано $removed'; } class _ConflictBanner extends StatelessWidget { final List conflicts; const _ConflictBanner({required this.conflicts}); @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.amber.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), ), child: Text( 'Внимание: часть выбранных устройств уже состоит в группах ${conflicts.map((group) => group.name).join(', ')}.', style: const TextStyle(color: Colors.amber), ), ); } }