Формы создания/редактирования зон, устройств и правил автоматизации
ZoneFormScreen/DeviceFormScreen/AutomationRuleFormScreen — одна форма на создание и редактирование, показ ошибок валидации Laravel (422) по полям. Списки зон/устройств/правил дополнены FAB "добавить", свайпом/меню удаления и переходом в форму по тапу — всё только для owner (RBAC), viewer видит те же списки в режиме только чтения. Опции дропдаунов (типы устройств, датчики/актуаторы, capabilities) берутся из данных бэкенда, не хардкодятся.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../core/api_service.dart';
|
||||
import '../models/automation_rule.dart';
|
||||
import '../models/device.dart';
|
||||
import '../models/device_type.dart';
|
||||
import '../models/zone.dart';
|
||||
|
||||
const _operators = ['>', '<', '>=', '<=', '=', '!='];
|
||||
|
||||
/// Create/edit automation rule form. Pass an existing [rule] to edit it in
|
||||
/// place; omit it to create a new one. Pops with `true` on success.
|
||||
///
|
||||
/// Dropdown options (sensor/actuator devices, sensor types, action types)
|
||||
/// are all derived from the backend's device/device-type data, never
|
||||
/// hardcoded — a new device_type with new capabilities shows up here
|
||||
/// automatically, per the platform's "don't hardcode the domain" rule.
|
||||
class AutomationRuleFormScreen extends StatefulWidget {
|
||||
const AutomationRuleFormScreen({super.key, this.rule});
|
||||
|
||||
final AutomationRule? rule;
|
||||
|
||||
bool get isEditing => rule != null;
|
||||
|
||||
@override
|
||||
State<AutomationRuleFormScreen> createState() => _AutomationRuleFormScreenState();
|
||||
}
|
||||
|
||||
class _AutomationRuleFormScreenState extends State<AutomationRuleFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final _conditionValueController =
|
||||
TextEditingController(text: widget.rule?.conditionValue.toString());
|
||||
late final _levelController = TextEditingController(text: widget.rule?.level?.toString());
|
||||
|
||||
late Future<(List<Zone>, List<Device>, List<DeviceType>)> _optionsFuture;
|
||||
|
||||
int? _zoneId;
|
||||
int? _targetDeviceId;
|
||||
int? _conditionSourceDeviceId;
|
||||
String? _conditionSensorType;
|
||||
late String _conditionOperator;
|
||||
String? _actionType;
|
||||
late bool _isActive;
|
||||
|
||||
bool _isSubmitting = false;
|
||||
Map<String, List<String>> _fieldErrors = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_zoneId = widget.rule?.zone?.id;
|
||||
_targetDeviceId = widget.rule?.targetDevice?.id;
|
||||
_conditionSourceDeviceId = widget.rule?.conditionSourceDevice?.id;
|
||||
_conditionSensorType = widget.rule?.conditionSensorType;
|
||||
_conditionOperator = widget.rule?.conditionOperator ?? _operators.first;
|
||||
_actionType = widget.rule?.actionType;
|
||||
_isActive = widget.rule?.isActive ?? true;
|
||||
|
||||
final apiService = context.read<ApiService>();
|
||||
_optionsFuture = (apiService.zones(), apiService.devices(), apiService.deviceTypes()).wait;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_conditionValueController.dispose();
|
||||
_levelController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final missing = <String, List<String>>{
|
||||
if (_zoneId == null) 'zone_id': ['Выберите зону'],
|
||||
if (_targetDeviceId == null) 'target_device_id': ['Выберите целевое устройство'],
|
||||
if (_conditionSourceDeviceId == null) 'condition_source_device_id': ['Выберите датчик'],
|
||||
if (_conditionSensorType == null) 'condition_sensor_type': ['Выберите тип показания'],
|
||||
if (_actionType == null) 'action_type': ['Выберите действие'],
|
||||
};
|
||||
if (missing.isNotEmpty) {
|
||||
setState(() => _fieldErrors = missing);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_fieldErrors = {};
|
||||
});
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'zone_id': _zoneId,
|
||||
'target_device_id': _targetDeviceId,
|
||||
'condition_source_device_id': _conditionSourceDeviceId,
|
||||
'condition_sensor_type': _conditionSensorType,
|
||||
'condition_operator': _conditionOperator,
|
||||
'condition_value': double.parse(_conditionValueController.text),
|
||||
'action_type': _actionType,
|
||||
'is_active': _isActive,
|
||||
if (_actionType == 'set_level') 'level': double.parse(_levelController.text),
|
||||
};
|
||||
|
||||
final apiService = context.read<ApiService>();
|
||||
|
||||
try {
|
||||
if (widget.isEditing) {
|
||||
await apiService.updateAutomationRule(widget.rule!.id, payload);
|
||||
} else {
|
||||
await apiService.createAutomationRule(payload);
|
||||
}
|
||||
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<Device>, 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, devices, deviceTypes) = snapshot.data!;
|
||||
final sensorDevices = devices.where((d) => d.deviceType?.isSensor ?? false).toList();
|
||||
final actuatorDevices = devices.where((d) => d.deviceType?.isActuator ?? false).toList();
|
||||
final sensorTypeOptions = deviceTypes
|
||||
.where((t) => t.isSensor)
|
||||
.expand((t) => t.capabilities)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final actionTypeOptions = deviceTypes
|
||||
.where((t) => t.isActuator)
|
||||
.expand((t) => t.capabilities)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
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: 24),
|
||||
Text('ЕСЛИ', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _conditionSourceDeviceId,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Датчик',
|
||||
errorText: _fieldErrors['condition_source_device_id']?.first,
|
||||
),
|
||||
items: sensorDevices.map((d) => DropdownMenuItem(value: d.id, child: Text(d.name))).toList(),
|
||||
onChanged: (value) => setState(() => _conditionSourceDeviceId = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _conditionSensorType,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Тип показания',
|
||||
errorText: _fieldErrors['condition_sensor_type']?.first,
|
||||
),
|
||||
items: sensorTypeOptions.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (value) => setState(() => _conditionSensorType = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _conditionOperator,
|
||||
decoration: const InputDecoration(labelText: 'Оператор'),
|
||||
items: _operators.map((op) => DropdownMenuItem(value: op, child: Text(op))).toList(),
|
||||
onChanged: (value) => setState(() => _conditionOperator = value!),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _conditionValueController,
|
||||
decoration: const InputDecoration(labelText: 'Значение'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) =>
|
||||
(value == null || double.tryParse(value) == null) ? 'Введите число' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('ТО', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _actionType,
|
||||
decoration:
|
||||
InputDecoration(labelText: 'Действие', errorText: _fieldErrors['action_type']?.first),
|
||||
items: actionTypeOptions.map((a) => DropdownMenuItem(value: a, child: Text(a))).toList(),
|
||||
onChanged: (value) => setState(() => _actionType = value),
|
||||
),
|
||||
if (_actionType == 'set_level') ...[
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _levelController,
|
||||
decoration: const InputDecoration(labelText: 'Уровень'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) => (_actionType == 'set_level' && double.tryParse(value ?? '') == null)
|
||||
? 'Введите число'
|
||||
: null,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _targetDeviceId,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Целевое устройство',
|
||||
errorText: _fieldErrors['target_device_id']?.first,
|
||||
),
|
||||
items: actuatorDevices.map((d) => DropdownMenuItem(value: d.id, child: Text(d.name))).toList(),
|
||||
onChanged: (value) => setState(() => _targetDeviceId = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Активно'),
|
||||
value: _isActive,
|
||||
onChanged: (value) => setState(() => _isActive = value),
|
||||
),
|
||||
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