initial commit
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(' · ');
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user