160 lines
5.7 KiB
Dart
160 lines
5.7 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 '../../../core/theme/status_colors.dart';
|
||
|
|
import '../../../core/theme/theme_x.dart';
|
||
|
|
import '../../../core/widgets/status_pill.dart';
|
||
|
|
import '../../../data/repositories/node_repository.dart';
|
||
|
|
import '../../dashboard/providers/dashboard_summary_provider.dart';
|
||
|
|
|
||
|
|
/// Edits `NodeInfo.hostname`/`description`/`resticPassword` (`PUT /node/`).
|
||
|
|
/// Takes the already-loaded [node] rather than watching the provider
|
||
|
|
/// itself, so the text fields have a stable initial value to build
|
||
|
|
/// controllers from — the parent only renders this once
|
||
|
|
/// `currentNodeProvider` has data.
|
||
|
|
class NodeIdentityForm extends ConsumerStatefulWidget {
|
||
|
|
const NodeIdentityForm({super.key, required this.node});
|
||
|
|
|
||
|
|
final NodeInfo node;
|
||
|
|
|
||
|
|
@override
|
||
|
|
ConsumerState<NodeIdentityForm> createState() => _NodeIdentityFormState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _NodeIdentityFormState extends ConsumerState<NodeIdentityForm> {
|
||
|
|
late final _hostnameController = TextEditingController(text: widget.node.hostname);
|
||
|
|
late final _descriptionController = TextEditingController(text: widget.node.description);
|
||
|
|
final _passwordController = TextEditingController();
|
||
|
|
bool _obscurePassword = true;
|
||
|
|
bool _saving = false;
|
||
|
|
String? _error;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_hostnameController.dispose();
|
||
|
|
_descriptionController.dispose();
|
||
|
|
_passwordController.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _save() async {
|
||
|
|
setState(() {
|
||
|
|
_saving = true;
|
||
|
|
_error = null;
|
||
|
|
});
|
||
|
|
|
||
|
|
final repo = ref.read(nodeRepositoryProvider);
|
||
|
|
if (repo == null) {
|
||
|
|
setState(() {
|
||
|
|
_saving = false;
|
||
|
|
_error = 'No active connection.';
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
final newPassword = _passwordController.text;
|
||
|
|
final draft = widget.node.copyWith(
|
||
|
|
hostname: _hostnameController.text.trim(),
|
||
|
|
description: _descriptionController.text.trim(),
|
||
|
|
resticPassword: newPassword.isEmpty ? null : newPassword,
|
||
|
|
);
|
||
|
|
|
||
|
|
try {
|
||
|
|
await repo.updateNode(draft);
|
||
|
|
// The password field is write-only — never echoed back by the API —
|
||
|
|
// so clear it locally once it's been sent rather than leaving the
|
||
|
|
// plaintext sitting in the form.
|
||
|
|
_passwordController.clear();
|
||
|
|
ref.invalidate(currentNodeProvider);
|
||
|
|
ref.invalidate(dashboardSummaryProvider);
|
||
|
|
if (mounted) {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Saved')));
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
setState(() {
|
||
|
|
_error = e is ApiException ? e.userMessage : e.toString();
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
if (mounted) setState(() => _saving = false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: TextField(
|
||
|
|
controller: _hostnameController,
|
||
|
|
decoration: const InputDecoration(labelText: 'Hostname'),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 14),
|
||
|
|
Expanded(
|
||
|
|
child: TextField(
|
||
|
|
controller: _descriptionController,
|
||
|
|
decoration: const InputDecoration(labelText: 'Description'),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 18),
|
||
|
|
Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: TextField(
|
||
|
|
controller: _passwordController,
|
||
|
|
obscureText: _obscurePassword,
|
||
|
|
decoration: InputDecoration(
|
||
|
|
labelText: widget.node.resticPasswordSet ? 'Change restic password' : 'Set restic password',
|
||
|
|
hintText: widget.node.resticPasswordSet ? '••••••••' : 'Required to run restic backup targets',
|
||
|
|
suffixIcon: IconButton(
|
||
|
|
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
|
||
|
|
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 14),
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.only(top: 16),
|
||
|
|
child: StatusPill(
|
||
|
|
label: widget.node.resticPasswordSet ? 'Password set' : 'Not configured',
|
||
|
|
severity: widget.node.resticPasswordSet ? Severity.good : Severity.warning,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 6),
|
||
|
|
Text(
|
||
|
|
'Encrypts/decrypts every restic backup target on this node. Leave blank to keep the current '
|
||
|
|
'password — the API never sends it back, so this field always shows empty. There is no way to '
|
||
|
|
'unset it once configured.',
|
||
|
|
style: context.text.bodySmall?.copyWith(color: context.colors.onSurfaceVariant),
|
||
|
|
),
|
||
|
|
if (_error != null) ...[
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||
|
|
],
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
FilledButton(
|
||
|
|
onPressed: _saving ? null : _save,
|
||
|
|
child: _saving
|
||
|
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||
|
|
: const Text('Save changes'),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|