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>
This commit is contained in:
rzuasti
2026-06-06 16:20:29 -04:00
co-authored by Claude Opus 4.8
parent cf7dd96415
commit f0d939f685
4 changed files with 122 additions and 32 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
## Frontend ## Frontend
- [x] When the user goes to the / URI in a production server redirect him to /web - [x] When the user goes to the / URI in a production server redirect him to /web
- [ ] Add a link to the API docs (/api/docs) in the navigation (new window - only visible in wide) - [x] Add a link to the API docs (/api/docs) in the navigation (new window - only visible in wide)
## Improve engine ## Improve engine
+84 -31
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:frontend/about/about.dart'; import 'package:frontend/about/about.dart';
import 'package:frontend/settings/settings.dart'; import 'package:frontend/settings/settings.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'devices/device_detail.dart'; import 'devices/device_detail.dart';
import 'devices/device_list.dart'; import 'devices/device_list.dart';
import 'home/home_screen.dart'; import 'home/home_screen.dart';
@@ -12,28 +13,75 @@ import 'widgets/offline_banner.dart';
import 'widgets/pagination_progress.dart'; import 'widgets/pagination_progress.dart';
import 'routes.dart'; import 'routes.dart';
typedef _NavDest = ({IconData icon, IconData activeIcon, String label}); // 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 = [ const List<_NavDest> _destinations = [
(icon: Icons.home_outlined, activeIcon: Icons.home, label: 'Home'), (
icon: Icons.home_outlined,
activeIcon: Icons.home,
label: 'Home',
route: Routes.home,
externalUrl: null,
wideOnly: false,
),
( (
icon: Icons.devices_other_outlined, icon: Icons.devices_other_outlined,
activeIcon: Icons.devices_other, activeIcon: Icons.devices_other,
label: 'Devices', label: 'Devices',
route: Routes.devices,
externalUrl: null,
wideOnly: false,
), ),
( (
icon: Icons.monitor_heart_outlined, icon: Icons.monitor_heart_outlined,
activeIcon: Icons.monitor_heart, activeIcon: Icons.monitor_heart,
label: 'Status', label: 'Status',
route: Routes.status,
externalUrl: null,
wideOnly: false,
), ),
( (
icon: Icons.settings_outlined, icon: Icons.settings_outlined,
activeIcon: Icons.settings, activeIcon: Icons.settings,
label: '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,
), ),
(icon: Icons.info_outline, activeIcon: Icons.info, label: 'About'),
]; ];
// 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 // Observer used to notify subscribed routes when another route is pushed
// on top of or popped from them, so they can refresh stale data. // on top of or popped from them, so they can refresh stale data.
final RouteObserver<ModalRoute<void>> routeObserver = final RouteObserver<ModalRoute<void>> routeObserver =
@@ -127,7 +175,7 @@ class _MainShellState extends State<MainShell> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final selectedIndex = _calculateSelectedIndex(context); final selectedIndex = _selectedIndex(_destinations, context);
final width = constraints.maxWidth; final width = constraints.maxWidth;
if (width < Breakpoints.medium) { if (width < Breakpoints.medium) {
@@ -149,10 +197,10 @@ class _MainShellState extends State<MainShell> {
), ),
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex, selectedIndex: _selectedIndex(_barDestinations, context),
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_onDestinationSelected(index, context), _onDestinationSelected(_barDestinations[index], context),
destinations: _destinations destinations: _barDestinations
.map( .map(
(d) => NavigationDestination( (d) => NavigationDestination(
icon: Icon(d.icon), icon: Icon(d.icon),
@@ -195,7 +243,7 @@ class _MainShellState extends State<MainShell> {
.toList(), .toList(),
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_onDestinationSelected(index, context), _onDestinationSelected(_destinations[index], context),
), ),
), ),
Expanded( Expanded(
@@ -298,32 +346,37 @@ String? _redirectToSettings() {
: null; : null;
} }
int _calculateSelectedIndex(BuildContext context) { // 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; final location = GoRouterState.of(context).uri.path;
if (location == '/') return 0; for (var i = 0; i < destinations.length; i++) {
if (location.startsWith('/devices')) return 1; final route = destinations[i].route;
if (location.startsWith('/status')) return 2; if (route == null) continue;
if (location.startsWith('/settings')) return 3; if (route == Routes.home) {
if (location.startsWith('/about')) return 4; if (location == Routes.home) return i;
} else if (location.startsWith(route)) {
return i;
}
}
return 0; return 0;
} }
void _onDestinationSelected(int index, BuildContext context) { void _onDestinationSelected(_NavDest destination, BuildContext context) {
switch (index) { final route = destination.route;
case 0: if (route != null) {
context.go(Routes.home); context.go(route);
break; } else if (destination.externalUrl != null) {
case 1: _openExternal(destination.externalUrl!);
context.go(Routes.devices); }
break; }
case 2:
context.go(Routes.status); // Opens an external link in a new tab. Origin-relative paths (e.g. the API docs)
break; // resolve against the current host, since the backend serves both the front-end
case 3: // and the API docs from the same origin.
context.go(Routes.settings); Future<void> _openExternal(String url) async {
break; final uri = Uri.parse(url);
case 4: if (await canLaunchUrl(uri)) {
context.go(Routes.about); await launchUrl(uri, mode: LaunchMode.externalApplication);
break;
} }
} }
+4
View File
@@ -7,6 +7,10 @@ abstract final class Routes {
static const String settings = '/settings'; static const String settings = '/settings';
static const String about = '/about'; static const String about = '/about';
/// Backend-served API documentation. This is not an in-app route: it is opened
/// in a new tab, resolved against the configured backend origin.
static const String apiDocs = '/api/docs';
/// Path segment for the device-detail route, nested under [devices]. /// Path segment for the device-detail route, nested under [devices].
static const String deviceDetailSegment = ':macAddress'; static const String deviceDetailSegment = ':macAddress';
@@ -86,5 +86,38 @@ void main() {
expect(find.byTooltip('Collapse menu'), findsNothing); expect(find.byTooltip('Collapse menu'), findsNothing);
expect(find.byTooltip('Expand menu'), findsNothing); expect(find.byTooltip('Expand menu'), findsNothing);
}); });
testWidgets('shows API Docs in the rail, just before About', (
tester,
) async {
await pumpShell(tester, size: const Size(1200, 900));
final labels = rail(
tester,
).destinations.map((d) => (d.label as Text).data).toList();
expect(labels, contains('API Docs'));
expect(labels.indexOf('API Docs'), labels.indexOf('About') - 1);
});
});
group('compact navigation bar', () {
setUp(() async {
await setUpBackendForTest();
});
testWidgets('omits the wide-only API Docs entry', (tester) async {
// Narrow width (< 600): the compact bottom NavigationBar is used.
await pumpShell(tester, size: const Size(400, 800));
final bar = tester.widget<NavigationBar>(find.byType(NavigationBar));
final labels = bar.destinations
.cast<NavigationDestination>()
.map((d) => d.label)
.toList();
expect(labels, contains('About'));
expect(labels, isNot(contains('API Docs')));
});
}); });
} }