initial commit
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user