Files
manager/lib/features/fleet/widgets/node_form_dialog.dart
T
2026-07-27 14:42:23 +02:00

165 lines
5.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/models.dart';
import '../../../core/network/api_exception.dart';
import '../../../data/repositories/nodes_repository.dart';
/// Add/edit dialog for a registered [RemoteNode]. This only edits the
/// registry entry (name/url/description/tags) — reachability is never
/// user-editable, it's always computed live via `/nodes/aggregated`.
Future<void> showNodeFormDialog(BuildContext context, {RemoteNode? existing}) {
return showDialog<void>(
context: context,
builder: (context) => _NodeFormDialog(existing: existing),
);
}
class _NodeFormDialog extends ConsumerStatefulWidget {
const _NodeFormDialog({this.existing});
final RemoteNode? existing;
@override
ConsumerState<_NodeFormDialog> createState() => _NodeFormDialogState();
}
class _NodeFormDialogState extends ConsumerState<_NodeFormDialog> {
final _formKey = GlobalKey<FormState>();
late final _nameController = TextEditingController(text: widget.existing?.name);
late final _urlController = TextEditingController(text: widget.existing?.url);
late final _descriptionController = TextEditingController(text: widget.existing?.description);
late final _tagsController = TextEditingController(text: widget.existing?.tags.join(', '));
bool _saving = false;
String? _error;
@override
void dispose() {
_nameController.dispose();
_urlController.dispose();
_descriptionController.dispose();
_tagsController.dispose();
super.dispose();
}
String? _validateUrl(String? value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) return 'Required.';
final uri = Uri.tryParse(trimmed);
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
return 'Enter a full URL, e.g. https://edge-02.local:8080';
}
return null;
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
final repo = ref.read(nodesRepositoryProvider);
if (repo == null) {
setState(() {
_saving = false;
_error = 'No active connection.';
});
return;
}
var url = _urlController.text.trim();
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
final tags = _tagsController.text
.split(',')
.map((t) => t.trim())
.where((t) => t.isNotEmpty)
.toList();
final description = _descriptionController.text.trim();
final draft = (widget.existing ?? const RemoteNode(name: '', url: '')).copyWith(
name: _nameController.text.trim(),
url: url,
description: description.isEmpty ? null : description,
tags: tags,
);
try {
if (widget.existing == null) {
await repo.add(draft);
} else {
await repo.update(widget.existing!.id, draft);
}
ref.invalidate(remoteNodesListProvider);
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() {
_saving = false;
_error = e is ApiException ? e.userMessage : e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.existing == null ? 'Register node' : 'Edit node'),
content: Form(
key: _formKey,
child: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
),
const SizedBox(height: 14),
TextFormField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'Base URL',
hintText: 'https://edge-02.local:8080',
),
validator: _validateUrl,
keyboardType: TextInputType.url,
),
const SizedBox(height: 14),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(labelText: 'Description (optional)'),
),
const SizedBox(height: 14),
TextFormField(
controller: _tagsController,
decoration: const InputDecoration(
labelText: 'Tags (optional, comma-separated)',
hintText: 'prod, db',
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save'),
),
],
);
}
}