Files
oott/frontend/lib/navigation.dart
T
rzuastiandClaude Opus 4.8 f0d939f685 Add API Docs link to the wide navigation rail
Add an "API Docs" navigation entry that opens /api/docs in a new tab.
It is shown only in the wide-mode navigation rail (not the compact
bottom bar) and sits just before About.

Generalises the navigation destination model so an entry can be an
in-app route or an external link, and can be restricted to wide layouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:20:29 -04:00

383 lines
13 KiB
Dart

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<ModalRoute<void>> routeObserver =
RouteObserver<ModalRoute<void>>();
// 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<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
// 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<void> _openExternal(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}