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
- [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
+84 -31
View File
@@ -2,6 +2,7 @@ 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';
@@ -12,28 +13,75 @@ import 'widgets/offline_banner.dart';
import 'widgets/pagination_progress.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 = [
(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,
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,
),
(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
// on top of or popped from them, so they can refresh stale data.
final RouteObserver<ModalRoute<void>> routeObserver =
@@ -127,7 +175,7 @@ class _MainShellState extends State<MainShell> {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final selectedIndex = _calculateSelectedIndex(context);
final selectedIndex = _selectedIndex(_destinations, context);
final width = constraints.maxWidth;
if (width < Breakpoints.medium) {
@@ -149,10 +197,10 @@ class _MainShellState extends State<MainShell> {
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
selectedIndex: _selectedIndex(_barDestinations, context),
onDestinationSelected: (index) =>
_onDestinationSelected(index, context),
destinations: _destinations
_onDestinationSelected(_barDestinations[index], context),
destinations: _barDestinations
.map(
(d) => NavigationDestination(
icon: Icon(d.icon),
@@ -195,7 +243,7 @@ class _MainShellState extends State<MainShell> {
.toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (index) =>
_onDestinationSelected(index, context),
_onDestinationSelected(_destinations[index], context),
),
),
Expanded(
@@ -298,32 +346,37 @@ String? _redirectToSettings() {
: 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;
if (location == '/') return 0;
if (location.startsWith('/devices')) return 1;
if (location.startsWith('/status')) return 2;
if (location.startsWith('/settings')) return 3;
if (location.startsWith('/about')) return 4;
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(int index, BuildContext context) {
switch (index) {
case 0:
context.go(Routes.home);
break;
case 1:
context.go(Routes.devices);
break;
case 2:
context.go(Routes.status);
break;
case 3:
context.go(Routes.settings);
break;
case 4:
context.go(Routes.about);
break;
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);
}
}
+4
View File
@@ -7,6 +7,10 @@ abstract final class Routes {
static const String settings = '/settings';
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].
static const String deviceDetailSegment = ':macAddress';
@@ -86,5 +86,38 @@ void main() {
expect(find.byTooltip('Collapse 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')));
});
});
}