Compare commits
1
Commits
feature/app-shell
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6749cdff0 |
@@ -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 ? 'Сохранить' : 'Создать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../core/api_service.dart';
|
import '../core/api_service.dart';
|
||||||
|
import '../core/auth_service.dart';
|
||||||
import '../models/automation_rule.dart';
|
import '../models/automation_rule.dart';
|
||||||
|
import 'automation_rule_form_screen.dart';
|
||||||
|
|
||||||
class AutomationRulesTab extends StatefulWidget {
|
class AutomationRulesTab extends StatefulWidget {
|
||||||
const AutomationRulesTab({super.key});
|
const AutomationRulesTab({super.key});
|
||||||
@@ -28,9 +30,42 @@ class _AutomationRulesTabState extends State<AutomationRulesTab> {
|
|||||||
await future;
|
await future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openForm({AutomationRule? rule}) async {
|
||||||
|
final saved = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(builder: (_) => AutomationRuleFormScreen(rule: rule)),
|
||||||
|
);
|
||||||
|
if (saved == true) _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmDelete(AutomationRule rule) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить правило?'),
|
||||||
|
content: const Text('Правило будет удалено без возможности восстановления.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.read<ApiService>().deleteAutomationRule(rule.id);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
final isOwner = context.watch<AuthService>().user?.isOwner ?? false;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: RefreshIndicator(
|
||||||
onRefresh: _refresh,
|
onRefresh: _refresh,
|
||||||
child: FutureBuilder<List<AutomationRule>>(
|
child: FutureBuilder<List<AutomationRule>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
@@ -75,16 +110,36 @@ class _AutomationRulesTabState extends State<AutomationRulesTab> {
|
|||||||
? '${rule.actionType} (${rule.level}) → ${rule.targetDevice?.name ?? '?'}'
|
? '${rule.actionType} (${rule.level}) → ${rule.targetDevice?.name ?? '?'}'
|
||||||
: '${rule.actionType} → ${rule.targetDevice?.name ?? '?'}';
|
: '${rule.actionType} → ${rule.targetDevice?.name ?? '?'}';
|
||||||
|
|
||||||
return ListTile(
|
final tile = ListTile(
|
||||||
leading: Icon(rule.isActive ? Icons.bolt : Icons.bolt_outlined),
|
leading: Icon(rule.isActive ? Icons.bolt : Icons.bolt_outlined),
|
||||||
title: Text('ЕСЛИ $condition'),
|
title: Text('ЕСЛИ $condition'),
|
||||||
subtitle: Text('ТО $action'),
|
subtitle: Text('ТО $action'),
|
||||||
isThreeLine: true,
|
isThreeLine: true,
|
||||||
|
onTap: isOwner ? () => _openForm(rule: rule) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOwner) return tile;
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(rule.id),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: const Icon(Icons.delete, color: Colors.white),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) => _confirmDelete(rule),
|
||||||
|
child: tile,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: isOwner
|
||||||
|
? FloatingActionButton(onPressed: () => _openForm(), child: const Icon(Icons.add))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? 'Сохранить' : 'Создать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../core/api_service.dart';
|
import '../core/api_service.dart';
|
||||||
|
import '../core/auth_service.dart';
|
||||||
import '../models/device.dart';
|
import '../models/device.dart';
|
||||||
import '../widgets/status_badge.dart';
|
import '../widgets/status_badge.dart';
|
||||||
import 'device_detail_screen.dart';
|
import 'device_detail_screen.dart';
|
||||||
|
import 'device_form_screen.dart';
|
||||||
|
|
||||||
class DevicesTab extends StatefulWidget {
|
class DevicesTab extends StatefulWidget {
|
||||||
const DevicesTab({super.key});
|
const DevicesTab({super.key});
|
||||||
@@ -30,9 +32,41 @@ class _DevicesTabState extends State<DevicesTab> {
|
|||||||
await future;
|
await future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openForm({Device? device}) async {
|
||||||
|
final saved = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(builder: (_) => DeviceFormScreen(device: device)),
|
||||||
|
);
|
||||||
|
if (saved == true) _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(Device device) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить устройство?'),
|
||||||
|
content: Text('«${device.name}» будет удалено без возможности восстановления.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.read<ApiService>().deleteDevice(device.id);
|
||||||
|
_refresh();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
final isOwner = context.watch<AuthService>().user?.isOwner ?? false;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: RefreshIndicator(
|
||||||
onRefresh: _refresh,
|
onRefresh: _refresh,
|
||||||
child: FutureBuilder<List<Device>>(
|
child: FutureBuilder<List<Device>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
@@ -58,7 +92,23 @@ class _DevicesTabState extends State<DevicesTab> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(device.name),
|
title: Text(device.name),
|
||||||
subtitle: Text('${device.zone?.name ?? '—'} · ${device.deviceType?.code ?? device.protocol}'),
|
subtitle: Text('${device.zone?.name ?? '—'} · ${device.deviceType?.code ?? device.protocol}'),
|
||||||
trailing: StatusBadge(status: device.status),
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
StatusBadge(status: device.status),
|
||||||
|
if (isOwner)
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'edit') _openForm(device: device);
|
||||||
|
if (value == 'delete') _delete(device);
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem(value: 'edit', child: Text('Редактировать')),
|
||||||
|
PopupMenuItem(value: 'delete', child: Text('Удалить')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => DeviceDetailScreen(deviceId: device.id)),
|
MaterialPageRoute(builder: (_) => DeviceDetailScreen(deviceId: device.id)),
|
||||||
),
|
),
|
||||||
@@ -67,6 +117,10 @@ class _DevicesTabState extends State<DevicesTab> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: isOwner
|
||||||
|
? FloatingActionButton(onPressed: () => _openForm(), child: const Icon(Icons.add))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../core/api_client.dart';
|
||||||
|
import '../core/api_service.dart';
|
||||||
|
import '../models/zone.dart';
|
||||||
|
|
||||||
|
/// Create/edit zone form. Pass an existing [zone] to edit it in place;
|
||||||
|
/// omit it to create a new one. Pops with `true` on success so the caller
|
||||||
|
/// knows to refresh its list.
|
||||||
|
class ZoneFormScreen extends StatefulWidget {
|
||||||
|
const ZoneFormScreen({super.key, this.zone});
|
||||||
|
|
||||||
|
final Zone? zone;
|
||||||
|
|
||||||
|
bool get isEditing => zone != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ZoneFormScreen> createState() => _ZoneFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ZoneFormScreenState extends State<ZoneFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late final _nameController = TextEditingController(text: widget.zone?.name);
|
||||||
|
late final _descriptionController = TextEditingController(text: widget.zone?.description);
|
||||||
|
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
Map<String, List<String>> _fieldErrors = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
_fieldErrors = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
final apiService = context.read<ApiService>();
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
final description = _descriptionController.text.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (widget.isEditing) {
|
||||||
|
await apiService.updateZone(widget.zone!.id, name: name, description: description);
|
||||||
|
} else {
|
||||||
|
await apiService.createZone(name: name, description: description);
|
||||||
|
}
|
||||||
|
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: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: InputDecoration(labelText: 'Название', errorText: _fieldErrors['name']?.first),
|
||||||
|
validator: (value) => (value == null || value.trim().isEmpty) ? 'Введите название' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: InputDecoration(labelText: 'Описание', errorText: _fieldErrors['description']?.first),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
child: _isSubmitting
|
||||||
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: Text(widget.isEditing ? 'Сохранить' : 'Создать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../core/api_service.dart';
|
import '../core/api_service.dart';
|
||||||
|
import '../core/auth_service.dart';
|
||||||
import '../models/zone.dart';
|
import '../models/zone.dart';
|
||||||
|
import 'zone_form_screen.dart';
|
||||||
|
|
||||||
class ZonesTab extends StatefulWidget {
|
class ZonesTab extends StatefulWidget {
|
||||||
const ZonesTab({super.key});
|
const ZonesTab({super.key});
|
||||||
@@ -28,9 +30,42 @@ class _ZonesTabState extends State<ZonesTab> {
|
|||||||
await future;
|
await future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openForm({Zone? zone}) async {
|
||||||
|
final saved = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(builder: (_) => ZoneFormScreen(zone: zone)),
|
||||||
|
);
|
||||||
|
if (saved == true) _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmDelete(Zone zone) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить зону?'),
|
||||||
|
content: Text('«${zone.name}» будет удалена без возможности восстановления.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.read<ApiService>().deleteZone(zone.id);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
final isOwner = context.watch<AuthService>().user?.isOwner ?? false;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: RefreshIndicator(
|
||||||
onRefresh: _refresh,
|
onRefresh: _refresh,
|
||||||
child: FutureBuilder<List<Zone>>(
|
child: FutureBuilder<List<Zone>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
@@ -57,15 +92,35 @@ class _ZonesTabState extends State<ZonesTab> {
|
|||||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final zone = zones[index];
|
final zone = zones[index];
|
||||||
return ListTile(
|
final tile = ListTile(
|
||||||
title: Text(zone.name),
|
title: Text(zone.name),
|
||||||
subtitle: zone.description != null ? Text(zone.description!) : null,
|
subtitle: zone.description != null ? Text(zone.description!) : null,
|
||||||
trailing: Text('${zone.devicesCount ?? 0} устр.'),
|
trailing: Text('${zone.devicesCount ?? 0} устр.'),
|
||||||
|
onTap: isOwner ? () => _openForm(zone: zone) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOwner) return tile;
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(zone.id),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: const Icon(Icons.delete, color: Colors.white),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) => _confirmDelete(zone),
|
||||||
|
child: tile,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: isOwner
|
||||||
|
? FloatingActionButton(onPressed: () => _openForm(), child: const Icon(Icons.add))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user