import 'package:flutter/material.dart'; import 'package:frontend/about/about.dart'; import 'package:frontend/settings/settings.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'devices/device_detail.dart'; import 'devices/device_list.dart'; import 'home/home_screen.dart'; import 'status/status_screen.dart'; import 'theme/dimens.dart'; import 'utils/pref_utils.dart'; import 'widgets/offline_banner.dart'; import 'widgets/pagination_progress.dart'; import 'routes.dart'; // A navigation entry. Most entries point to an in-app [route]; an entry with a // null route is an external link (see [externalUrl]) that opens in a new tab and // is never marked as selected. [wideOnly] entries appear solely in the wide-mode // navigation rail, not in the compact bottom navigation bar. typedef _NavDest = ({ IconData icon, IconData activeIcon, String label, String? route, String? externalUrl, bool wideOnly, }); const List<_NavDest> _destinations = [ ( icon: Icons.home_outlined, activeIcon: Icons.home, label: 'Home', route: Routes.home, externalUrl: null, wideOnly: false, ), ( icon: Icons.devices_other_outlined, activeIcon: Icons.devices_other, label: 'Devices', route: Routes.devices, externalUrl: null, wideOnly: false, ), ( icon: Icons.monitor_heart_outlined, activeIcon: Icons.monitor_heart, label: 'Status', route: Routes.status, externalUrl: null, wideOnly: false, ), ( icon: Icons.settings_outlined, activeIcon: Icons.settings, label: 'Settings', route: Routes.settings, externalUrl: null, wideOnly: false, ), ( icon: Icons.menu_book_outlined, activeIcon: Icons.menu_book, label: 'API Docs', route: null, externalUrl: Routes.apiDocs, wideOnly: true, ), ( icon: Icons.info_outline, activeIcon: Icons.info, label: 'About', route: Routes.about, externalUrl: null, wideOnly: false, ), ]; // The compact bottom navigation bar omits wide-only entries (e.g. API Docs). final List<_NavDest> _barDestinations = _destinations .where((d) => !d.wideOnly) .toList(); // Observer used to notify subscribed routes when another route is pushed // on top of or popped from them, so they can refresh stale data. final RouteObserver> routeObserver = RouteObserver>(); // Routes definitions final GoRouter router = GoRouter( initialLocation: Routes.home, routes: [ ShellRoute( observers: [routeObserver], builder: (context, state, child) { return MainShell(child: child); }, routes: [ GoRoute( path: Routes.home, name: 'home', builder: (context, state) => const HomeScreen(), redirect: (context, state) => _redirectToSettings(), ), GoRoute( path: Routes.devices, name: 'devices', builder: (context, state) => const DeviceList(), redirect: (context, state) => _redirectToSettings(), routes: [ GoRoute( path: Routes.deviceDetailSegment, name: 'deviceDetail', builder: (context, state) { final mac = state.pathParameters['macAddress']!; return DeviceDetail(macAddress: mac); }, ), ], ), GoRoute( path: Routes.status, name: 'status', builder: (context, state) => const StatusScreen(), redirect: (context, state) => _redirectToSettings(), ), GoRoute( path: Routes.settings, name: 'settings', builder: (context, state) => Settings(), ), GoRoute( path: Routes.about, name: 'about', builder: (context, state) => const About(), ), ], ), ], ); // Preference key controlling whether the wide-mode navigation rail shows // labels (extended) or collapses to an icons-only compact view. const String _kNavRailExtendedPref = 'nav_rail_extended'; // Fixed rail widths (M3 NavigationRail defaults), set explicitly so the // collapse/expand toggle can be positioned exactly within the rail. const double _kRailCompactWidth = 80; const double _kRailExtendedWidth = 256; // Application shell and navigation class MainShell extends StatefulWidget { final Widget child; const MainShell({super.key, required this.child}); @override State createState() => _MainShellState(); } class _MainShellState extends State { // User preference: when on a wide screen, keep the rail extended (labels) or // collapse it to icons only to reclaim horizontal space. Defaults to true so // existing installs keep the labelled rail they had before. bool _navRailExtended = PrefUtil.getValue(_kNavRailExtendedPref, true) as bool; void _toggleNavRailExtended() { setState(() => _navRailExtended = !_navRailExtended); PrefUtil.setValue(_kNavRailExtendedPref, _navRailExtended); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final selectedIndex = _selectedIndex(_destinations, context); final width = constraints.maxWidth; if (width < Breakpoints.medium) { return Scaffold( appBar: _buildAppBar(context, selectedIndex), // The overlay pins the pagination progress bar flush against the // bottom of the body, i.e. the top of the navigation bar below. body: PaginationProgressOverlay( child: Column( children: [ const OfflineBanner(), Expanded( child: Padding( padding: const EdgeInsets.all(Insets.lg), child: widget.child, ), ), ], ), ), bottomNavigationBar: NavigationBar( selectedIndex: _selectedIndex(_barDestinations, context), onDestinationSelected: (index) => _onDestinationSelected(_barDestinations[index], context), destinations: _barDestinations .map( (d) => NavigationDestination( icon: Icon(d.icon), selectedIcon: Icon(d.activeIcon), label: d.label, ), ) .toList(), ), ); } final mediaPadding = MediaQuery.paddingOf(context); return Scaffold( appBar: _buildAppBar(context, selectedIndex), // The body is a Stack so the collapse/expand toggle can be positioned // freely within the rail (the rail's own slots give it an unbounded // width, which prevents reliable horizontal alignment). body: Stack( children: [ Row( children: [ SafeArea( child: NavigationRail( backgroundColor: Theme.of( context, ).colorScheme.surfaceContainerLow, minWidth: _kRailCompactWidth, minExtendedWidth: _kRailExtendedWidth, extended: width >= Breakpoints.expanded && _navRailExtended, destinations: _destinations .map( (d) => NavigationRailDestination( icon: Icon(d.icon), selectedIcon: Icon(d.activeIcon), label: Text(d.label), ), ) .toList(), selectedIndex: selectedIndex, onDestinationSelected: (index) => _onDestinationSelected(_destinations[index], context), ), ), Expanded( child: Container( color: Theme.of(context).colorScheme.surface, // The overlay pins the pagination progress bar flush // against the very bottom of the content region (the // screen bottom). child: PaginationProgressOverlay( child: Column( children: [ const OfflineBanner(), Expanded( child: Padding( padding: const EdgeInsets.all(Insets.lg), child: widget.child, ), ), ], ), ), ), ), ], ), // The collapse/expand toggle, pinned to the bottom of the rail. // It sits centred like the other icons when the rail is compact // and slides to the rail's right side when it is extended. The // double chevron points the way the rail will move: inward («) to // collapse, outward (») to expand. if (width >= Breakpoints.expanded) AnimatedPositioned( // Match the rail's own extend/collapse animation so the toggle // slides with the edge instead of jumping after it settles. duration: kThemeAnimationDuration, curve: Curves.easeInOut, bottom: mediaPadding.bottom + Insets.md, left: _navRailExtended ? mediaPadding.left + _kRailExtendedWidth - kMinInteractiveDimension - Insets.sm : mediaPadding.left + (_kRailCompactWidth - kMinInteractiveDimension) / 2, child: IconButton( icon: Icon( _navRailExtended ? Icons.keyboard_double_arrow_left : Icons.keyboard_double_arrow_right, ), tooltip: _navRailExtended ? 'Collapse menu' : 'Expand menu', onPressed: _toggleNavRailExtended, ), ), ], ), ); }, ); } AppBar _buildAppBar(BuildContext context, int selectedIndex) { final theme = Theme.of(context); return AppBar( titleSpacing: Insets.lg, title: Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), decoration: BoxDecoration( color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(10), ), child: Text( 'OOTT', style: theme.textTheme.headlineSmall?.copyWith( color: theme.colorScheme.onPrimary, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: Insets.md), Flexible( child: Text( _destinations[selectedIndex].label, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleLarge, ), ), ], ), backgroundColor: theme.colorScheme.surfaceContainerLowest, ); } } String? _redirectToSettings() { return (PrefUtil.getValue("base_url", "") as String == "") ? '/settings' : null; } // Index of the [destinations] entry matching the current location, defaulting to // the first entry (Home). External-link entries have no route and never match. int _selectedIndex(List<_NavDest> destinations, BuildContext context) { final location = GoRouterState.of(context).uri.path; for (var i = 0; i < destinations.length; i++) { final route = destinations[i].route; if (route == null) continue; if (route == Routes.home) { if (location == Routes.home) return i; } else if (location.startsWith(route)) { return i; } } return 0; } void _onDestinationSelected(_NavDest destination, BuildContext context) { final route = destination.route; if (route != null) { context.go(route); } else if (destination.externalUrl != null) { _openExternal(destination.externalUrl!); } } // Opens an external link in a new tab. Origin-relative paths (e.g. the API docs) // resolve against the current host, since the backend serves both the front-end // and the API docs from the same origin. Future _openExternal(String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } }