initial commit
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/theme/theme_x.dart';
|
||||
import '../../data/repositories/node_repository.dart';
|
||||
import 'widgets/connection_form_dialog.dart';
|
||||
import 'widgets/connections_list.dart';
|
||||
import 'widgets/no_auth_callout.dart';
|
||||
import 'widgets/node_identity_form.dart';
|
||||
import 'widgets/theme_toggle.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
_Block(
|
||||
title: 'Connections',
|
||||
trailing: TextButton.icon(
|
||||
onPressed: () => showConnectionFormDialog(context),
|
||||
icon: const Icon(Icons.add, size: 17),
|
||||
label: const Text('Add connection'),
|
||||
),
|
||||
child: const ConnectionsList(),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
_Block(title: 'Node identity', child: const _NodeIdentitySection()),
|
||||
const SizedBox(height: 22),
|
||||
_Block(
|
||||
title: 'Appearance',
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Theme'),
|
||||
ThemeToggle(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
const NoAuthCallout(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NodeIdentitySection extends ConsumerWidget {
|
||||
const _NodeIdentitySection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final nodeAsync = ref.watch(currentNodeProvider);
|
||||
|
||||
return nodeAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
SizedBox(width: 10),
|
||||
Text('Contacting node…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
error: (error, stackTrace) {
|
||||
final message = error is ApiException ? error.userMessage : error.toString();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 18, color: context.status.critical),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(message, style: context.text.bodyMedium?.copyWith(color: context.status.critical)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
data: (node) {
|
||||
if (node == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('No connection selected.'),
|
||||
);
|
||||
}
|
||||
return NodeIdentityForm(node: node);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Block extends StatelessWidget {
|
||||
const _Block({required this.title, required this.child, this.trailing});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title, style: context.text.titleMedium),
|
||||
trailing ?? const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Card(child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/connections/connection.dart';
|
||||
import '../../../core/connections/connection_providers.dart';
|
||||
|
||||
/// Add/edit dialog for a saved [Connection]. Passing [existing] switches it
|
||||
/// to edit mode; omitting it creates a new connection and makes it active
|
||||
/// (the common "just point me at a node" first-run flow).
|
||||
Future<void> showConnectionFormDialog(
|
||||
BuildContext context, {
|
||||
Connection? existing,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _ConnectionFormDialog(existing: existing),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConnectionFormDialog extends ConsumerStatefulWidget {
|
||||
const _ConnectionFormDialog({this.existing});
|
||||
|
||||
final Connection? existing;
|
||||
|
||||
@override
|
||||
ConsumerState<_ConnectionFormDialog> createState() => _ConnectionFormDialogState();
|
||||
}
|
||||
|
||||
class _ConnectionFormDialogState extends ConsumerState<_ConnectionFormDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final _nameController = TextEditingController(text: widget.existing?.name);
|
||||
late final _urlController = TextEditingController(text: widget.existing?.baseUrl);
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_urlController.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-01.local:8080';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _saving = true);
|
||||
|
||||
final name = _nameController.text.trim();
|
||||
var url = _urlController.text.trim();
|
||||
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
||||
|
||||
final existing = widget.existing;
|
||||
if (existing != null) {
|
||||
await ref
|
||||
.read(savedConnectionsProvider.notifier)
|
||||
.update(existing.copyWith(name: name, baseUrl: url));
|
||||
} else {
|
||||
final connection = Connection(
|
||||
id: 'conn_${DateTime.now().microsecondsSinceEpoch}',
|
||||
name: name,
|
||||
baseUrl: url,
|
||||
);
|
||||
await ref.read(savedConnectionsProvider.notifier).add(connection);
|
||||
await ref.read(activeConnectionIdProvider.notifier).setActive(connection.id);
|
||||
}
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.existing == null ? 'Add connection' : 'Edit connection'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: SizedBox(
|
||||
width: 380,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Base URL',
|
||||
hintText: 'https://edge-01.local:8080',
|
||||
),
|
||||
validator: _validateUrl,
|
||||
keyboardType: TextInputType.url,
|
||||
onFieldSubmitted: (_) => _save(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/connections/connection.dart';
|
||||
import '../../../core/connections/connection_providers.dart';
|
||||
import '../../../core/theme/theme_x.dart';
|
||||
import 'connection_form_dialog.dart';
|
||||
|
||||
class ConnectionsList extends ConsumerWidget {
|
||||
const ConnectionsList({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final connections = ref.watch(savedConnectionsProvider);
|
||||
final activeId = ref.watch(activeConnectionIdProvider);
|
||||
|
||||
if (connections.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'No connections saved yet.',
|
||||
style: context.text.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"Add one to start managing a NodeMaster host.",
|
||||
style: context.text.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final connection in connections)
|
||||
_ConnectionRow(
|
||||
connection: connection,
|
||||
active: connection.id == activeId,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionRow extends ConsumerWidget {
|
||||
const _ConnectionRow({required this.connection, required this.active});
|
||||
|
||||
final Connection connection;
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return InkWell(
|
||||
onTap: active
|
||||
? null
|
||||
: () => ref.read(activeConnectionIdProvider.notifier).setActive(connection.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active ? context.status.good : context.colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
active ? '${connection.name} · active' : connection.name,
|
||||
style: context.text.labelLarge,
|
||||
),
|
||||
Text(connection.baseUrl, style: context.dataStyles.dataMonoSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => showConnectionFormDialog(context, existing: connection),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Remove',
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: () => ref.read(savedConnectionsProvider.notifier).remove(connection.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/theme_x.dart';
|
||||
|
||||
/// Static warning — NodeMaster's REST API has no built-in authentication.
|
||||
/// Anyone who can reach a node's port can start/stop services or trigger
|
||||
/// backups on it. Not dismissable: this is a standing property of the
|
||||
/// backend, not a one-time notice.
|
||||
class NoAuthCallout extends StatelessWidget {
|
||||
const NoAuthCallout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.alphaBlend(context.status.warning.withValues(alpha: 0.10), context.colors.surface),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: context.status.warning.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 18, color: context.status.warning),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: context.text.bodyMedium,
|
||||
children: [
|
||||
TextSpan(text: 'No API authentication yet. ', style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
const TextSpan(
|
||||
text: "NodeMaster's REST API has no built-in auth — anyone who can reach a node's "
|
||||
'port can start, stop, or trigger backups on it. Until token auth ships, keep it '
|
||||
'behind a VPN or a reverse proxy with its own access control, and treat saved '
|
||||
'connection URLs as sensitive.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/theme_mode_provider.dart';
|
||||
|
||||
/// The full three-way Light/Dark/System control. The top bar's quick
|
||||
/// sun/moon icon (`NavRailShell`) only ever toggles between explicit
|
||||
/// light/dark — this is the one place "follow system" can be chosen.
|
||||
class ThemeToggle extends ConsumerWidget {
|
||||
const ThemeToggle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mode = ref.watch(themeModeControllerProvider);
|
||||
|
||||
return SegmentedButton<ThemeMode>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: ThemeMode.light, label: Text('Light'), icon: Icon(Icons.light_mode_outlined, size: 16)),
|
||||
ButtonSegment(value: ThemeMode.dark, label: Text('Dark'), icon: Icon(Icons.dark_mode_outlined, size: 16)),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
label: Text('System'),
|
||||
icon: Icon(Icons.brightness_auto_outlined, size: 16),
|
||||
),
|
||||
],
|
||||
selected: {mode},
|
||||
onSelectionChanged: (selected) =>
|
||||
ref.read(themeModeControllerProvider.notifier).setThemeMode(selected.first),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user