Формы создания/редактирования зон, устройств и правил автоматизации
ZoneFormScreen/DeviceFormScreen/AutomationRuleFormScreen — одна форма на создание и редактирование, показ ошибок валидации Laravel (422) по полям. Списки зон/устройств/правил дополнены FAB "добавить", свайпом/меню удаления и переходом в форму по тапу — всё только для owner (RBAC), viewer видит те же списки в режиме только чтения. Опции дропдаунов (типы устройств, датчики/актуаторы, capabilities) берутся из данных бэкенда, не хардкодятся.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../core/api_service.dart';
|
||||
import '../models/device.dart';
|
||||
import '../models/device_type.dart';
|
||||
import '../models/zone.dart';
|
||||
|
||||
/// Create/edit device form. Pass an existing [device] to edit it in place;
|
||||
/// omit it to create a new one. Pops with `true` on success.
|
||||
class DeviceFormScreen extends StatefulWidget {
|
||||
const DeviceFormScreen({super.key, this.device});
|
||||
|
||||
final Device? device;
|
||||
|
||||
bool get isEditing => device != null;
|
||||
|
||||
@override
|
||||
State<DeviceFormScreen> createState() => _DeviceFormScreenState();
|
||||
}
|
||||
|
||||
class _DeviceFormScreenState extends State<DeviceFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final _nameController = TextEditingController(text: widget.device?.name);
|
||||
late final _externalIdController = TextEditingController(text: widget.device?.externalId);
|
||||
late final _protocolController = TextEditingController(text: widget.device?.protocol ?? 'mqtt');
|
||||
|
||||
late Future<(List<Zone>, List<DeviceType>)> _optionsFuture;
|
||||
int? _zoneId;
|
||||
int? _deviceTypeId;
|
||||
|
||||
bool _isSubmitting = false;
|
||||
Map<String, List<String>> _fieldErrors = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_zoneId = widget.device?.zone?.id;
|
||||
_deviceTypeId = widget.device?.deviceType?.id;
|
||||
final apiService = context.read<ApiService>();
|
||||
_optionsFuture = (apiService.zones(), apiService.deviceTypes()).wait;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_externalIdController.dispose();
|
||||
_protocolController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_zoneId == null || _deviceTypeId == null) {
|
||||
setState(() => _fieldErrors = {
|
||||
if (_zoneId == null) 'zone_id': ['Выберите зону'],
|
||||
if (_deviceTypeId == null) 'device_type_id': ['Выберите тип устройства'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_fieldErrors = {};
|
||||
});
|
||||
|
||||
final apiService = context.read<ApiService>();
|
||||
final name = _nameController.text.trim();
|
||||
final externalId = _externalIdController.text.trim();
|
||||
final protocol = _protocolController.text.trim().isEmpty ? 'mqtt' : _protocolController.text.trim();
|
||||
|
||||
try {
|
||||
if (widget.isEditing) {
|
||||
await apiService.updateDevice(
|
||||
widget.device!.id,
|
||||
zoneId: _zoneId!,
|
||||
deviceTypeId: _deviceTypeId!,
|
||||
name: name,
|
||||
externalId: externalId,
|
||||
protocol: protocol,
|
||||
);
|
||||
} else {
|
||||
await apiService.createDevice(
|
||||
zoneId: _zoneId!,
|
||||
deviceTypeId: _deviceTypeId!,
|
||||
name: name,
|
||||
externalId: externalId,
|
||||
protocol: protocol,
|
||||
);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _fieldErrors = e.errors ?? {});
|
||||
if (e.errors == null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.isEditing ? 'Редактировать устройство' : 'Новое устройство')),
|
||||
body: FutureBuilder<(List<Zone>, List<DeviceType>)>(
|
||||
future: _optionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('${snapshot.error}'));
|
||||
}
|
||||
|
||||
final (zones, deviceTypes) = snapshot.data!;
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _zoneId,
|
||||
decoration: InputDecoration(labelText: 'Зона', errorText: _fieldErrors['zone_id']?.first),
|
||||
items: zones.map((z) => DropdownMenuItem(value: z.id, child: Text(z.name))).toList(),
|
||||
onChanged: (value) => setState(() => _zoneId = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _deviceTypeId,
|
||||
decoration:
|
||||
InputDecoration(labelText: 'Тип устройства', errorText: _fieldErrors['device_type_id']?.first),
|
||||
items: deviceTypes
|
||||
.map((t) => DropdownMenuItem(value: t.id, child: Text('${t.code} (${t.category})')))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _deviceTypeId = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(labelText: 'Название', errorText: _fieldErrors['name']?.first),
|
||||
validator: (value) => (value == null || value.trim().isEmpty) ? 'Введите название' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _externalIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'External ID',
|
||||
helperText: 'Идентификатор физического устройства (ESP32 chip id и т.п.)',
|
||||
errorText: _fieldErrors['external_id']?.first,
|
||||
),
|
||||
validator: (value) => (value == null || value.trim().isEmpty) ? 'Введите external_id' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _protocolController,
|
||||
decoration: InputDecoration(labelText: 'Протокол', errorText: _fieldErrors['protocol']?.first),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(widget.isEditing ? 'Сохранить' : 'Создать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user