initial commit

This commit is contained in:
2026-07-27 14:42:23 +02:00
commit 833c68a189
257 changed files with 17947 additions and 0 deletions
@@ -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'),
),
],
),
);
}
}