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