diff --git a/lib/screens/automation_rule_form_screen.dart b/lib/screens/automation_rule_form_screen.dart new file mode 100644 index 0000000..39a10d9 --- /dev/null +++ b/lib/screens/automation_rule_form_screen.dart @@ -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 createState() => _AutomationRuleFormScreenState(); +} + +class _AutomationRuleFormScreenState extends State { + final _formKey = GlobalKey(); + late final _conditionValueController = + TextEditingController(text: widget.rule?.conditionValue.toString()); + late final _levelController = TextEditingController(text: widget.rule?.level?.toString()); + + late Future<(List, List, List)> _optionsFuture; + + int? _zoneId; + int? _targetDeviceId; + int? _conditionSourceDeviceId; + String? _conditionSensorType; + late String _conditionOperator; + String? _actionType; + late bool _isActive; + + bool _isSubmitting = false; + Map> _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(); + _optionsFuture = (apiService.zones(), apiService.devices(), apiService.deviceTypes()).wait; + } + + @override + void dispose() { + _conditionValueController.dispose(); + _levelController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + final missing = >{ + 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 = { + '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(); + + 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, List, List)>( + 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( + 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( + 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( + 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( + 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( + 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( + 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 ? 'Сохранить' : 'Создать'), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/automation_rules_tab.dart b/lib/screens/automation_rules_tab.dart index 5ffbf9f..a575007 100644 --- a/lib/screens/automation_rules_tab.dart +++ b/lib/screens/automation_rules_tab.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../core/api_service.dart'; +import '../core/auth_service.dart'; import '../models/automation_rule.dart'; +import 'automation_rule_form_screen.dart'; class AutomationRulesTab extends StatefulWidget { const AutomationRulesTab({super.key}); @@ -28,63 +30,116 @@ class _AutomationRulesTabState extends State { await future; } + Future _openForm({AutomationRule? rule}) async { + final saved = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AutomationRuleFormScreen(rule: rule)), + ); + if (saved == true) _refresh(); + } + + Future _confirmDelete(AutomationRule rule) async { + final confirmed = await showDialog( + 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().deleteAutomationRule(rule.id); + return true; + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + return false; + } + } + @override Widget build(BuildContext context) { - return RefreshIndicator( - onRefresh: _refresh, - child: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - Padding( - padding: const EdgeInsets.all(24), - child: Text('${snapshot.error}', textAlign: TextAlign.center), - ), - ], - ); - } + final isOwner = context.watch().user?.isOwner ?? false; - final rules = snapshot.data!; - if (rules.isEmpty) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - Padding( - padding: EdgeInsets.all(24), - child: Text('Правил пока нет.', textAlign: TextAlign.center), - ), - ], - ); - } - - return ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: rules.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final rule = rules[index]; - final condition = - '${rule.conditionSourceDevice?.name ?? '?'}: ${rule.conditionSensorType} ${rule.conditionOperator} ${rule.conditionValue}'; - final action = rule.level != null - ? '${rule.actionType} (${rule.level}) → ${rule.targetDevice?.name ?? '?'}' - : '${rule.actionType} → ${rule.targetDevice?.name ?? '?'}'; - - return ListTile( - leading: Icon(rule.isActive ? Icons.bolt : Icons.bolt_outlined), - title: Text('ЕСЛИ $condition'), - subtitle: Text('ТО $action'), - isThreeLine: true, + return Scaffold( + body: RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Text('${snapshot.error}', textAlign: TextAlign.center), + ), + ], ); - }, - ); - }, + } + + final rules = snapshot.data!; + if (rules.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + Padding( + padding: EdgeInsets.all(24), + child: Text('Правил пока нет.', textAlign: TextAlign.center), + ), + ], + ); + } + + return ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: rules.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final rule = rules[index]; + final condition = + '${rule.conditionSourceDevice?.name ?? '?'}: ${rule.conditionSensorType} ${rule.conditionOperator} ${rule.conditionValue}'; + final action = rule.level != null + ? '${rule.actionType} (${rule.level}) → ${rule.targetDevice?.name ?? '?'}' + : '${rule.actionType} → ${rule.targetDevice?.name ?? '?'}'; + + final tile = ListTile( + leading: Icon(rule.isActive ? Icons.bolt : Icons.bolt_outlined), + title: Text('ЕСЛИ $condition'), + subtitle: Text('ТО $action'), + 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, ); } } diff --git a/lib/screens/device_form_screen.dart b/lib/screens/device_form_screen.dart new file mode 100644 index 0000000..17e9101 --- /dev/null +++ b/lib/screens/device_form_screen.dart @@ -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 createState() => _DeviceFormScreenState(); +} + +class _DeviceFormScreenState extends State { + final _formKey = GlobalKey(); + 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, List)> _optionsFuture; + int? _zoneId; + int? _deviceTypeId; + + bool _isSubmitting = false; + Map> _fieldErrors = {}; + + @override + void initState() { + super.initState(); + _zoneId = widget.device?.zone?.id; + _deviceTypeId = widget.device?.deviceType?.id; + final apiService = context.read(); + _optionsFuture = (apiService.zones(), apiService.deviceTypes()).wait; + } + + @override + void dispose() { + _nameController.dispose(); + _externalIdController.dispose(); + _protocolController.dispose(); + super.dispose(); + } + + Future _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(); + 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, List)>( + 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( + 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( + 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 ? 'Сохранить' : 'Создать'), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/devices_tab.dart b/lib/screens/devices_tab.dart index 545ecb6..0d06337 100644 --- a/lib/screens/devices_tab.dart +++ b/lib/screens/devices_tab.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../core/api_service.dart'; +import '../core/auth_service.dart'; import '../models/device.dart'; import '../widgets/status_badge.dart'; import 'device_detail_screen.dart'; +import 'device_form_screen.dart'; class DevicesTab extends StatefulWidget { const DevicesTab({super.key}); @@ -30,43 +32,95 @@ class _DevicesTabState extends State { await future; } + Future _openForm({Device? device}) async { + final saved = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DeviceFormScreen(device: device)), + ); + if (saved == true) _refresh(); + } + + Future _delete(Device device) async { + final confirmed = await showDialog( + 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().deleteDevice(device.id); + _refresh(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } + } + @override Widget build(BuildContext context) { - return RefreshIndicator( - onRefresh: _refresh, - child: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return _ErrorList(message: '${snapshot.error}', onRetry: _refresh); - } + final isOwner = context.watch().user?.isOwner ?? false; - final devices = snapshot.data!; - if (devices.isEmpty) { - return _ErrorList(message: 'Устройств пока нет.', onRetry: _refresh); - } + return Scaffold( + body: RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return _ErrorList(message: '${snapshot.error}', onRetry: _refresh); + } - return ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: devices.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final device = devices[index]; - return ListTile( - title: Text(device.name), - subtitle: Text('${device.zone?.name ?? '—'} · ${device.deviceType?.code ?? device.protocol}'), - trailing: StatusBadge(status: device.status), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DeviceDetailScreen(deviceId: device.id)), - ), - ); - }, - ); - }, + final devices = snapshot.data!; + if (devices.isEmpty) { + return _ErrorList(message: 'Устройств пока нет.', onRetry: _refresh); + } + + return ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: devices.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final device = devices[index]; + return ListTile( + title: Text(device.name), + subtitle: Text('${device.zone?.name ?? '—'} · ${device.deviceType?.code ?? device.protocol}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StatusBadge(status: device.status), + if (isOwner) + PopupMenuButton( + 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( + MaterialPageRoute(builder: (_) => DeviceDetailScreen(deviceId: device.id)), + ), + ); + }, + ); + }, + ), ), + floatingActionButton: isOwner + ? FloatingActionButton(onPressed: () => _openForm(), child: const Icon(Icons.add)) + : null, ); } } diff --git a/lib/screens/zone_form_screen.dart b/lib/screens/zone_form_screen.dart new file mode 100644 index 0000000..0d268a4 --- /dev/null +++ b/lib/screens/zone_form_screen.dart @@ -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 createState() => _ZoneFormScreenState(); +} + +class _ZoneFormScreenState extends State { + final _formKey = GlobalKey(); + late final _nameController = TextEditingController(text: widget.zone?.name); + late final _descriptionController = TextEditingController(text: widget.zone?.description); + + bool _isSubmitting = false; + Map> _fieldErrors = {}; + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isSubmitting = true; + _fieldErrors = {}; + }); + + final apiService = context.read(); + 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 ? 'Сохранить' : 'Создать'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/zones_tab.dart b/lib/screens/zones_tab.dart index 75f803b..82b456a 100644 --- a/lib/screens/zones_tab.dart +++ b/lib/screens/zones_tab.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../core/api_service.dart'; +import '../core/auth_service.dart'; import '../models/zone.dart'; +import 'zone_form_screen.dart'; class ZonesTab extends StatefulWidget { const ZonesTab({super.key}); @@ -28,44 +30,97 @@ class _ZonesTabState extends State { await future; } + Future _openForm({Zone? zone}) async { + final saved = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ZoneFormScreen(zone: zone)), + ); + if (saved == true) _refresh(); + } + + Future _confirmDelete(Zone zone) async { + final confirmed = await showDialog( + 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().deleteZone(zone.id); + return true; + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + return false; + } + } + @override Widget build(BuildContext context) { - return RefreshIndicator( - onRefresh: _refresh, - child: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - Padding( - padding: const EdgeInsets.all(24), - child: Text('${snapshot.error}', textAlign: TextAlign.center), - ), - ], - ); - } + final isOwner = context.watch().user?.isOwner ?? false; - final zones = snapshot.data!; - return ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: zones.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final zone = zones[index]; - return ListTile( - title: Text(zone.name), - subtitle: zone.description != null ? Text(zone.description!) : null, - trailing: Text('${zone.devicesCount ?? 0} устр.'), + return Scaffold( + body: RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Text('${snapshot.error}', textAlign: TextAlign.center), + ), + ], ); - }, - ); - }, + } + + final zones = snapshot.data!; + return ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: zones.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final zone = zones[index]; + final tile = ListTile( + title: Text(zone.name), + subtitle: zone.description != null ? Text(zone.description!) : null, + 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, ); } }