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,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),
);
}
}