Files
ignis_app/lib/screens/group_edit_screen.dart
2026-05-16 10:59:31 +07:00

459 lines
15 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 '../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<GroupEditScreen> createState() => _GroupEditScreenState();
}
class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
final _formKey = GlobalKey<FormState>();
final _idCtrl = TextEditingController();
final _nameCtrl = TextEditingController();
final Set<String> _selectedMacs = <String>{};
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<void>.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<void> _loadDevices() async {
await ref.read(devicesProvider.notifier).load();
}
Future<void> _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<String, dynamic>.from(response.data as Map)
: const <String, dynamic>{};
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<List<IgnisDevice>> devicesState,
List<IgnisDevice> 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<void> _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<bool?> _confirmConflicts(List<IgnisGroup> conflicts) {
return showDialog<bool>(
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<String, dynamic> summary, String key) {
final value = summary[key];
if (value is int) {
return value;
}
return int.tryParse(value?.toString() ?? '') ?? 0;
}
String formatRescanSummary(Map<String, dynamic> 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<IgnisGroup> 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),
),
);
}
}