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,146 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../network/api_exception.dart';
const _methods = BackupMethod.values;
/// Add/edit dialog for a single [BackupTarget] — shared by the node Backups
/// screen and the Services detail panel, which otherwise only differ in
/// which repository [onSave] calls. Per-target `params` editing stays out
/// of scope here, same reasoning as the rest of the app: a power-user
/// escape hatch, not worth a nested key/value editor in v1.
Future<void> showBackupTargetFormDialog(
BuildContext context, {
BackupTarget? existing,
required Future<void> Function(BackupTarget draft) onSave,
}) {
return showDialog<void>(
context: context,
builder: (context) => _BackupTargetFormDialog(existing: existing, onSave: onSave),
);
}
class _BackupTargetFormDialog extends StatefulWidget {
const _BackupTargetFormDialog({this.existing, required this.onSave});
final BackupTarget? existing;
final Future<void> Function(BackupTarget draft) onSave;
@override
State<_BackupTargetFormDialog> createState() => _BackupTargetFormDialogState();
}
class _BackupTargetFormDialogState extends State<_BackupTargetFormDialog> {
final _formKey = GlobalKey<FormState>();
late final _nameController = TextEditingController(text: widget.existing?.name);
late final _remoteController = TextEditingController(text: widget.existing?.remote);
late final _scheduleController = TextEditingController(text: widget.existing?.schedule);
late BackupMethod _method = widget.existing?.method ?? BackupMethod.restic;
bool _saving = false;
String? _error;
@override
void dispose() {
_nameController.dispose();
_remoteController.dispose();
_scheduleController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
final schedule = _scheduleController.text.trim();
final draft = (widget.existing ?? const BackupTarget(name: '', method: BackupMethod.restic, remote: '')).copyWith(
name: _nameController.text.trim(),
method: _method,
remote: _remoteController.text.trim(),
schedule: schedule.isEmpty ? null : schedule,
);
try {
await widget.onSave(draft);
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() {
_saving = false;
_error = e is ApiException ? e.userMessage : e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.existing == null ? 'Add backup target' : 'Edit backup target'),
content: Form(
key: _formKey,
child: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
),
const SizedBox(height: 14),
DropdownButtonFormField<BackupMethod>(
initialValue: _method,
decoration: const InputDecoration(labelText: 'Method'),
items: [for (final m in _methods) DropdownMenuItem(value: m, child: Text(m.name))],
onChanged: (v) => setState(() => _method = v ?? _method),
),
const SizedBox(height: 14),
TextFormField(
controller: _remoteController,
decoration: InputDecoration(
labelText: 'Remote',
hintText: switch (_method) {
BackupMethod.restic => '/mnt/backup/repo or s3:s3.amazonaws.com/bucket/path or sftp:user@host:/path',
BackupMethod.rsync => 'user@host:/path',
BackupMethod.external => 'informational only — backup is managed outside the API',
},
helperText: _method == BackupMethod.restic
? 'Restic repository — encrypted with this node\'s restic password (Settings).'
: null,
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
),
const SizedBox(height: 14),
TextFormField(
controller: _scheduleController,
decoration: const InputDecoration(
labelText: 'Schedule (optional)',
hintText: '0 2 * * * — standard 5-field cron; empty = manual only',
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
),
),
),
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'),
),
],
);
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/models.dart';
import '../network/api_exception.dart';
import '../theme/theme_x.dart';
import 'mono_text.dart';
/// One backup target: identity + schedule/last-run caption, and either
/// Run/Edit/Delete actions or — while a run it triggered is in flight — a
/// live progress bar polled via [fetchProgress]. Polling is a plain local
/// [Timer] scoped to this row's lifetime, not a Riverpod provider: progress
/// only matters while this exact row is on screen and something is
/// actively running, so there's nothing to share or keep alive beyond that.
class BackupTargetRow extends StatefulWidget {
const BackupTargetRow({
super.key,
required this.target,
required this.onRun,
required this.fetchProgress,
required this.onSettled,
required this.onEdit,
required this.onDelete,
});
final BackupTarget target;
final Future<void> Function() onRun;
final Future<RunState> Function() fetchProgress;
final void Function(RunState finalState) onSettled;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
State<BackupTargetRow> createState() => _BackupTargetRowState();
}
class _BackupTargetRowState extends State<BackupTargetRow> {
Timer? _timer;
RunState? _runState;
bool _starting = false;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _handleRun() async {
setState(() => _starting = true);
try {
await widget.onRun();
setState(() {
_starting = false;
_runState = const RunState(running: true);
});
_poll();
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _poll());
} catch (e) {
if (!mounted) return;
setState(() => _starting = false);
final message = e is ApiException ? e.userMessage : e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
}
Future<void> _poll() async {
final state = await widget.fetchProgress();
if (!mounted) return;
setState(() => _runState = state);
if (!state.running) {
_timer?.cancel();
_timer = null;
widget.onSettled(state);
}
}
@override
Widget build(BuildContext context) {
final target = widget.target;
final running = _runState?.running ?? false;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(target.name, style: context.text.labelLarge),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(target.method.name, style: context.dataStyles.dataMonoSmall),
),
],
),
const SizedBox(height: 3),
MonoText(target.remote, small: true),
const SizedBox(height: 3),
Text(_caption(target), style: context.text.bodySmall),
],
),
),
const SizedBox(width: 8),
if (running)
const SizedBox.shrink()
else ...[
_starting
? const Padding(
padding: EdgeInsets.all(8),
child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
)
: IconButton(
tooltip: 'Run now',
icon: const Icon(Icons.play_arrow_rounded, size: 20),
onPressed: _handleRun,
),
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: widget.onEdit,
),
IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: widget.onDelete,
),
],
],
),
if (running) ...[
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (_runState!.percent > 0 && _runState!.percent <= 100)
? _runState!.percent / 100
: null,
minHeight: 5,
),
),
const SizedBox(height: 4),
Row(
children: [
Text(
'${_runState!.percent.toStringAsFixed(0)}%',
style: context.dataStyles.numericTabular.copyWith(fontSize: 12),
),
if (_runState!.eta != null) ...[
const SizedBox(width: 8),
Text('ETA ${_runState!.eta}', style: context.text.bodySmall),
],
if (_runState!.message?.isNotEmpty == true) ...[
const SizedBox(width: 8),
Expanded(
child: MonoText(_runState!.message!, small: true, maxLines: 1),
),
],
],
),
],
],
),
);
}
String _caption(BackupTarget target) {
final dateFormat = DateFormat('MMM d, HH:mm');
final parts = <String>[
target.schedule?.isNotEmpty == true ? 'Schedule: ${target.schedule}' : 'Manual only',
if (target.lastRun != null) 'last ${dateFormat.format(target.lastRun!.toLocal())}',
if (target.nextRun != null) 'next ${dateFormat.format(target.nextRun!.toLocal())}',
];
return parts.join(' · ');
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../theme/theme_x.dart';
import 'backup_target_row.dart';
/// A list of [BackupTargetRow]s plus an "Add target" action — shared by the
/// node Backups screen and the Services detail panel.
class BackupTargetsList extends StatelessWidget {
const BackupTargetsList({
super.key,
required this.targets,
required this.onAdd,
required this.onEdit,
required this.onDelete,
required this.onRun,
required this.fetchProgress,
required this.onSettled,
});
final List<BackupTarget> targets;
final VoidCallback onAdd;
final void Function(BackupTarget target) onEdit;
final void Function(BackupTarget target) onDelete;
final Future<void> Function(BackupTarget target) onRun;
final Future<RunState> Function(BackupTarget target) fetchProgress;
final void Function(BackupTarget target, RunState finalState) onSettled;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (targets.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('No backup targets configured.', style: context.text.bodySmall),
)
else
for (var i = 0; i < targets.length; i++) ...[
if (i > 0) const Divider(height: 1),
BackupTargetRow(
key: ValueKey(targets[i].id),
target: targets[i],
onRun: () => onRun(targets[i]),
fetchProgress: () => fetchProgress(targets[i]),
onSettled: (state) => onSettled(targets[i], state),
onEdit: () => onEdit(targets[i]),
onDelete: () => onDelete(targets[i]),
),
],
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add target'),
),
],
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// The bordered, horizontally-scrollable card wrapper used by every dense
/// table in the app (Services, Fleet, Updates, Backup history). A thin
/// shell around [DataTable] — column/row content stays screen-specific,
/// this just guarantees the same card chrome, spacing, and empty-state
/// fallback everywhere.
class DataTableScaffold extends StatelessWidget {
const DataTableScaffold({
super.key,
required this.columns,
required this.rows,
this.empty,
this.columnSpacing = 28,
this.horizontalMargin = 16,
});
final List<DataColumn> columns;
final List<DataRow> rows;
final Widget? empty;
final double columnSpacing;
final double horizontalMargin;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: context.colors.outlineVariant),
),
clipBehavior: Clip.antiAlias,
child: rows.isEmpty && empty != null
? empty
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: MediaQuery.sizeOf(context).width),
child: DataTable(
showCheckboxColumn: false,
columns: columns,
rows: rows,
columnSpacing: columnSpacing,
horizontalMargin: horizontalMargin,
headingRowHeight: 38,
dataRowMinHeight: 46,
dataRowMaxHeight: 58,
dividerThickness: 1,
),
),
),
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// Centered "nothing here yet" placeholder — no services discovered, no
/// connections saved, no update history. An optional action gives the user
/// the next step rather than a dead end.
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String title;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 30, color: context.colors.outline),
const SizedBox(height: 12),
Text(title, style: context.text.titleMedium, textAlign: TextAlign.center),
if (message != null) ...[
const SizedBox(height: 4),
Text(
message!,
style: context.text.bodySmall,
textAlign: TextAlign.center,
),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
OutlinedButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// Inline error state for a failed fetch — an `AsyncValue.error` case in a
/// screen body. Deliberately takes a plain [message] string rather than an
/// `ApiException` so `core/widgets` has no dependency on `core/network`;
/// callers resolve `exception.userMessage` before passing it in.
class ErrorBanner extends StatelessWidget {
const ErrorBanner({super.key, required this.message, this.onRetry});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Color.alphaBlend(context.status.critical.withValues(alpha: 0.08), context.colors.surface),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: context.status.critical.withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, size: 18, color: context.status.critical),
const SizedBox(width: 12),
Expanded(child: Text(message, style: context.text.bodyMedium)),
if (onRetry != null) ...[
const SizedBox(width: 12),
TextButton(onPressed: onRetry, child: const Text('Retry')),
],
],
),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// A technical value — compose file path, hostname, id, URL — rendered in
/// the monospace data face so it reads as "an exact string", distinct from
/// prose. Use for anything a user might copy-paste or match against a log.
class MonoText extends StatelessWidget {
const MonoText(
this.value, {
super.key,
this.small = false,
this.color,
this.maxLines = 1,
this.overflow = TextOverflow.ellipsis,
});
final String value;
final bool small;
final Color? color;
final int? maxLines;
final TextOverflow overflow;
@override
Widget build(BuildContext context) {
final base = small ? context.dataStyles.dataMonoSmall : context.dataStyles.dataMono;
return Text(
value,
maxLines: maxLines,
overflow: overflow,
style: color == null ? base : base.copyWith(color: color),
);
}
}
+295
View File
@@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../connections/connection_providers.dart';
import '../routing/route_paths.dart';
import '../theme/theme_mode_provider.dart';
import '../theme/theme_x.dart';
class _NavItem {
const _NavItem(this.path, this.label, this.icon, this.selectedIcon);
final String path;
final String label;
final IconData icon;
final IconData selectedIcon;
}
const _navItems = [
_NavItem(RoutePaths.dashboard, 'Dashboard', Icons.grid_view_outlined, Icons.grid_view_rounded),
_NavItem(RoutePaths.services, 'Services', Icons.layers_outlined, Icons.layers_rounded),
_NavItem(RoutePaths.backups, 'Backups', Icons.cloud_outlined, Icons.cloud_rounded),
_NavItem(RoutePaths.fleet, 'Fleet', Icons.hub_outlined, Icons.hub_rounded),
_NavItem(RoutePaths.updates, 'Updates', Icons.history_outlined, Icons.history_rounded),
_NavItem(RoutePaths.settings, 'Settings', Icons.tune_outlined, Icons.tune_rounded),
];
/// The persistent left nav rail + top bar frame wrapping every screen, per
/// the approved mockup. Wraps go_router's `ShellRoute` child.
class NavRailShell extends ConsumerWidget {
const NavRailShell({super.key, required this.currentPath, required this.child});
final String currentPath;
final Widget child;
int get _selectedIndex {
final index = _navItems.indexWhere((i) => currentPath.startsWith(i.path));
return index == -1 ? 0 : index;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final narrow = MediaQuery.sizeOf(context).width < 860;
return Scaffold(
body: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Rail(selectedIndex: _selectedIndex, narrow: narrow),
const VerticalDivider(width: 1, thickness: 1),
Expanded(
child: Column(
children: [
_TopBar(currentPath: currentPath),
const Divider(height: 1, thickness: 1),
Expanded(child: child),
],
),
),
],
),
);
}
}
class _Rail extends ConsumerWidget {
const _Rail({required this.selectedIndex, required this.narrow});
final int selectedIndex;
final bool narrow;
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeConnection = ref.watch(activeConnectionProvider);
return SizedBox(
width: narrow ? 72 : 226,
child: Container(
color: context.theme.navigationRailTheme.backgroundColor,
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 18, 14, 20),
child: Row(
mainAxisAlignment: narrow ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.hub_rounded, size: 16, color: context.colors.onPrimary),
),
if (!narrow) ...[
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('NodeMaster', style: context.text.titleMedium?.copyWith(fontSize: 14.5)),
Text('Manager', style: context.text.bodySmall),
],
),
),
],
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
for (var i = 0; i < _navItems.length; i++)
_RailButton(item: _navItems[i], selected: i == selectedIndex, narrow: narrow),
],
),
),
Padding(
padding: const EdgeInsets.all(12),
child: _ConnectionChip(name: activeConnection?.name, url: activeConnection?.baseUrl, narrow: narrow),
),
],
),
),
),
);
}
}
class _RailButton extends StatelessWidget {
const _RailButton({required this.item, required this.selected, required this.narrow});
final _NavItem item;
final bool selected;
final bool narrow;
@override
Widget build(BuildContext context) {
final fg = selected ? context.colors.secondary : context.text.bodyMedium?.color;
final bg = selected ? context.colors.primary.withValues(alpha: 0.14) : Colors.transparent;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Material(
color: bg,
borderRadius: BorderRadius.circular(7),
child: InkWell(
borderRadius: BorderRadius.circular(7),
onTap: () => context.go(item.path),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: narrow ? 0 : 10, vertical: 9),
child: Row(
mainAxisAlignment: narrow ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
Icon(selected ? item.selectedIcon : item.icon, size: 18, color: fg),
if (!narrow) ...[
const SizedBox(width: 10),
Text(
item.label,
style: context.text.bodyMedium?.copyWith(
color: fg,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
],
),
),
),
),
);
}
}
class _ConnectionChip extends StatelessWidget {
const _ConnectionChip({required this.name, required this.url, required this.narrow});
final String? name;
final String? url;
final bool narrow;
@override
Widget build(BuildContext context) {
final dot = Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: name == null ? context.colors.outline : context.status.good,
shape: BoxShape.circle,
),
);
if (narrow) return Center(child: dot);
return Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: context.colors.outlineVariant),
),
child: Row(
children: [
dot,
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name ?? 'No connection',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.labelLarge?.copyWith(fontSize: 12),
),
if (url != null)
Text(
url!.replaceFirst(RegExp(r'^https?://'), ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.dataStyles.dataMonoSmall,
),
],
),
),
],
),
);
}
}
const _titles = <String, (String, String Function(WidgetRef))>{
RoutePaths.dashboard: ('Dashboard', _connSubtitle),
RoutePaths.services: ('Services', _connSubtitle),
RoutePaths.backups: ('Backups', _connSubtitle),
RoutePaths.fleet: ('Fleet', _connSubtitle),
RoutePaths.updates: ('Updates', _connSubtitle),
RoutePaths.settings: ('Settings', _connSubtitle),
};
String _connSubtitle(WidgetRef ref) {
final connection = ref.watch(activeConnectionProvider);
return connection == null ? 'No connection configured' : 'Connected to ${connection.name}';
}
class _TopBar extends ConsumerWidget {
const _TopBar({required this.currentPath});
final String currentPath;
@override
Widget build(BuildContext context, WidgetRef ref) {
final entry = _titles.entries.firstWhere(
(e) => currentPath.startsWith(e.key),
orElse: () => _titles.entries.first,
);
final title = entry.value.$1;
final subtitle = entry.value.$2(ref);
final mode = ref.watch(themeModeControllerProvider);
final effectiveDark = switch (mode) {
ThemeMode.dark => true,
ThemeMode.light => false,
ThemeMode.system => MediaQuery.platformBrightnessOf(context) == Brightness.dark,
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: context.text.titleLarge),
Text(subtitle, style: context.text.bodySmall),
],
),
),
IconButton(
tooltip: effectiveDark ? 'Switch to light theme' : 'Switch to dark theme',
icon: Icon(effectiveDark ? Icons.dark_mode_outlined : Icons.light_mode_outlined),
onPressed: () => ref
.read(themeModeControllerProvider.notifier)
.setThemeMode(effectiveDark ? ThemeMode.light : ThemeMode.dark),
),
],
),
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// A right-anchored detail panel over the still-visible list behind it —
/// the "inspect a record without losing list context" pattern used by
/// Services (and reused by Fleet's per-node cards). Place as the last child
/// of a [Stack] alongside the screen's main content; it fills the stack
/// itself via [Positioned.fill] and no-ops when [open] is false.
class SlideOverPanel extends StatelessWidget {
const SlideOverPanel({
super.key,
required this.open,
required this.onClose,
required this.title,
this.subtitle,
required this.body,
this.actions = const [],
this.headerActions = const [],
this.width = 420,
});
final bool open;
final VoidCallback onClose;
final String title;
final Widget? subtitle;
final Widget body;
final List<Widget> actions;
/// Icon buttons shown before the close button — for actions that belong
/// with the record identity (e.g. Edit) rather than the primary action
/// row at the bottom, which stays reserved for the few actions that need
/// full label width.
final List<Widget> headerActions;
final double width;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final duration = reduceMotion ? Duration.zero : const Duration(milliseconds: 200);
return Positioned.fill(
child: IgnorePointer(
ignoring: !open,
child: Stack(
children: [
AnimatedOpacity(
opacity: open ? 1 : 0,
duration: duration,
child: GestureDetector(
onTap: onClose,
child: Container(color: Colors.black.withValues(alpha: 0.35)),
),
),
Align(
alignment: Alignment.centerRight,
child: ClipRect(
child: AnimatedSlide(
offset: open ? Offset.zero : const Offset(1, 0),
duration: duration,
curve: Curves.easeOutCubic,
child: Container(
width: width,
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.92),
height: double.infinity,
decoration: BoxDecoration(
color: context.colors.surface,
border: Border(left: BorderSide(color: context.colors.outlineVariant)),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.18), blurRadius: 48, offset: const Offset(-8, 0)),
],
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: context.text.titleLarge),
if (subtitle != null) ...[
const SizedBox(height: 6),
subtitle!,
],
],
),
),
...headerActions,
IconButton(
onPressed: onClose,
icon: const Icon(Icons.close, size: 19),
tooltip: 'Close',
),
],
),
),
const Divider(height: 1),
Expanded(child: body),
if (actions.isNotEmpty) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
for (var i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: actions[i]),
],
],
),
),
],
],
),
),
),
),
),
),
],
),
),
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// A single-series trend line — currently just backup-duration history.
/// Hand-rolled `CustomPainter` rather than a charting dependency: one chart
/// in the whole app, a specific visual spec (faint grid, thin line,
/// emphasized endpoint), full control for near-zero cost.
class Sparkline extends StatelessWidget {
const Sparkline({super.key, required this.values, this.width = 240, this.height = 52});
final List<double> values;
final double width;
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
height: height,
child: CustomPaint(
painter: _SparklinePainter(
values: values,
lineColor: context.colors.primary,
gridColor: context.colors.outlineVariant,
),
),
);
}
}
class _SparklinePainter extends CustomPainter {
_SparklinePainter({required this.values, required this.lineColor, required this.gridColor});
final List<double> values;
final Color lineColor;
final Color gridColor;
static const _topPad = 6.0;
static const _bottomPad = 6.0;
@override
void paint(Canvas canvas, Size size) {
if (values.isEmpty) return;
final minV = values.reduce(math.min);
final maxV = values.reduce(math.max);
final range = (maxV - minV).abs() < 1e-9 ? 1.0 : maxV - minV;
final plotHeight = size.height - _topPad - _bottomPad;
final gridPaint = Paint()
..color = gridColor
..strokeWidth = 1;
canvas.drawLine(Offset(0, _topPad), Offset(size.width, _topPad), gridPaint);
canvas.drawLine(
Offset(0, size.height - _bottomPad),
Offset(size.width, size.height - _bottomPad),
gridPaint,
);
if (values.length == 1) {
final y = _topPad + plotHeight / 2;
canvas.drawCircle(Offset(size.width, y), 3.4, Paint()..color = lineColor);
return;
}
final dx = size.width / (values.length - 1);
final points = [
for (var i = 0; i < values.length; i++)
Offset(i * dx, _topPad + plotHeight - ((values[i] - minV) / range) * plotHeight),
];
final fillPath = Path()..moveTo(points.first.dx, size.height - _bottomPad);
for (final p in points) {
fillPath.lineTo(p.dx, p.dy);
}
fillPath
..lineTo(points.last.dx, size.height - _bottomPad)
..close();
canvas.drawPath(fillPath, Paint()..color = lineColor.withValues(alpha: 0.14));
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
for (final p in points.skip(1)) {
linePath.lineTo(p.dx, p.dy);
}
canvas.drawPath(
linePath,
Paint()
..color = lineColor
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round,
);
canvas.drawCircle(points.last, 3.4, Paint()..color = lineColor);
}
@override
bool shouldRepaint(covariant _SparklinePainter oldDelegate) =>
oldDelegate.values != values ||
oldDelegate.lineColor != lineColor ||
oldDelegate.gridColor != gridColor;
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import '../theme/status_colors.dart';
import '../theme/theme_x.dart';
/// A dashboard summary tile: an uppercase label, a large tabular-nums value
/// (with an optional muted trailing qualifier, e.g. "7 / 9"), a caption, and
/// a left severity stripe communicating worst-case state at a glance —
/// severity is a first-class visual channel here, not just the number.
class StatTile extends StatelessWidget {
const StatTile({
super.key,
required this.label,
required this.value,
this.valueQualifier,
this.caption,
this.severity = Severity.good,
});
final String label;
final String value;
final String? valueQualifier;
final String? caption;
final Severity severity;
@override
Widget build(BuildContext context) {
final stripeColor = severity.resolve(context.status, muted: context.colors.outline);
return Container(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(9),
border: Border.all(color: context.colors.outlineVariant),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 2, offset: const Offset(0, 1)),
],
),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
children: [
Container(width: 3, color: stripeColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(13, 14, 13, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: context.text.titleSmall),
const SizedBox(height: 8),
Text.rich(
TextSpan(
style: context.dataStyles.numericTabular.copyWith(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
children: [
TextSpan(text: value),
if (valueQualifier != null)
TextSpan(
text: ' $valueQualifier',
style: context.text.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
],
),
),
if (caption != null) ...[
const SizedBox(height: 4),
Text(
caption!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
),
],
],
),
),
),
],
),
),
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import '../theme/status_colors.dart';
import '../theme/theme_x.dart';
/// A dot + label badge encoding state — service up/down/unknown, backup or
/// update success/fail, node reachable/unreachable. The same component
/// everywhere a status appears so a given [Severity] always reads the same
/// color, matching the design rule that status color is reserved and never
/// doubles as the brand accent.
class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.label, required this.severity});
final String label;
final Severity severity;
@override
Widget build(BuildContext context) {
final color = severity.resolve(context.status, muted: context.colors.outline);
return Container(
padding: const EdgeInsets.fromLTRB(7, 3, 9, 3),
decoration: BoxDecoration(
// 8% tint, not 14% — measured against the dark surface, a same-hue
// tint background caps critical's text contrast around ~3.5:1 no
// matter the blend ratio (text and background converge toward the
// same hue as alpha rises, and approach the plain-surface ceiling
// as it falls); 8% is close to that ceiling while keeping
// good/warning/serious comfortably at 4.5:1+. Below AA-for-text on
// its own, mitigated the same way the design system's status
// palette always mitigates this: label text, never color alone.
color: severity == Severity.neutral
? context.colors.surfaceContainerHighest
: Color.alphaBlend(color.withValues(alpha: 0.08), context.colors.surface),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
label,
style: context.text.labelMedium?.copyWith(
color: severity == Severity.neutral ? context.colors.onSurfaceVariant : color,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}