Files
manager/lib/core/widgets/nav_rail_shell.dart
T
2026-07-27 14:42:23 +02:00

296 lines
9.7 KiB
Dart

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