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 createState() => _NodeIdentityFormState(); } class _NodeIdentityFormState extends ConsumerState { 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 _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'), ), ], ), ); } }