Files
plezy/test/widgets/mobile_navigation_rail_test.dart
T
edde746 691d10b3c6 feat(navigation): landscape rail for the mobile shell and phone rotation
Phones were locked to portrait outside the video player, and the mobile
shell had no answer for a wide, short viewport: car head units (which use
the mobile layout) and tablets in landscape spent their scarce height on
a bottom navigation bar and a 500dp hero.

In landscape the mobile shell now puts the bottom bar's destinations on a
leading Material NavigationRail (MobileNavigationRail) and keeps every
other mobile layout decision as it is. The rail keeps the bottom bar's
extras: the offline reconnect affordance as its leading action and the
long-press library quick picker on the Libraries destination. Labels
follow the existing nav-bar-labels setting but give way automatically
when the destinations cannot fit (a landscape phone is ~410dp tall), and
the rail scrolls as a last resort. The content beside it drops the leading
system inset the rail already absorbs.

Phones now allow every orientation; OrientationHelper.restoreDefaultOrientations
is context-free and the player exit path uses it instead of its own copy
of the phone lock. The Discover hero fills the viewport in mobile landscape
and compacts its logo and bottom offset when short, so its content stays
below the top bar.

The music mini-player learns a start inset (MiniPlayerInsetController.setNavInsets)
so it floats beside the rail in landscape and above the bar in portrait;
route suspension zeroes both.

Known follow-up: the media detail header is not yet laid out for a
landscape phone (logo runs under the status bar and back button).
2026-09-03 20:29:08 +02:00

221 lines
6.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/navigation/navigation_tabs.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/mobile_navigation_rail.dart';
void main() {
setUp(() {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugReset();
addTearDown(TvDetectionService.debugReset);
});
final tabs = allNavigationTabs.where((tab) => tab.id != NavigationTabId.settings).toList();
Widget wrap(Widget rail, {TextDirection direction = TextDirection.ltr}) {
return MaterialApp(
home: Directionality(
textDirection: direction,
child: Scaffold(
body: Row(
children: [
rail,
const Expanded(child: SizedBox()),
],
),
),
),
);
}
testWidgets('tapping a destination reports its tab', (tester) async {
NavigationTabId? selected;
await tester.pumpWidget(
wrap(
MobileNavigationRail(
tabs: tabs,
selectedTab: NavigationTabId.discover,
showLabels: true,
onDestinationSelected: (id) => selected = id,
),
),
);
await tester.tap(find.text(t.common.search));
expect(selected, NavigationTabId.search);
});
testWidgets('labels follow the nav bar label setting', (tester) async {
await tester.pumpWidget(
wrap(
MobileNavigationRail(
tabs: tabs,
selectedTab: NavigationTabId.discover,
showLabels: false,
onDestinationSelected: (_) {},
),
),
);
// The rail keeps the label for semantics, but gives it no space.
expect(tester.getSize(find.text(t.navigation.libraries)), Size.zero);
await tester.pumpWidget(
wrap(
MobileNavigationRail(
tabs: tabs,
selectedTab: NavigationTabId.discover,
showLabels: true,
onDestinationSelected: (_) {},
),
),
);
expect(tester.getSize(find.text(t.navigation.libraries)).width, greaterThan(0));
});
testWidgets('labels give way on a viewport too short to show every destination', (tester) async {
// A landscape phone: ~410dp tall, six labelled destinations need ~430.
tester.view.physicalSize = const Size(900, 410);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
wrap(
MobileNavigationRail(
tabs: tabs,
selectedTab: NavigationTabId.discover,
showLabels: true,
onDestinationSelected: (_) {},
),
),
);
expect(tester.getSize(find.text(t.navigation.libraries)), Size.zero);
expect(tester.takeException(), isNull);
// Every destination is still reachable without scrolling.
expect(tester.getBottomLeft(find.byType(NavigationRail)).dy, lessThanOrEqualTo(410));
expect(MobileNavigationRail.labelsFit(destinations: 6, hasLeading: false, height: 410), isFalse);
expect(MobileNavigationRail.labelsFit(destinations: 6, hasLeading: false, height: 600), isTrue);
expect(MobileNavigationRail.labelsFit(destinations: 7, hasLeading: true, height: 500), isFalse);
});
testWidgets('long-pressing Libraries opens the quick picker; a tap still selects it', (tester) async {
NavigationTabId? selected;
var quickPicks = 0;
await tester.pumpWidget(
wrap(
MobileNavigationRail(
tabs: tabs,
selectedTab: NavigationTabId.discover,
showLabels: true,
onDestinationSelected: (id) => selected = id,
onLibrariesLongPress: () => quickPicks++,
),
),
);
final librariesTab = tabs.firstWhere((tab) => tab.id == NavigationTabId.libraries);
final icon = find.byWidgetPredicate((w) => w is Icon && w.icon == librariesTab.icon);
await tester.longPress(icon);
await tester.pump();
expect(quickPicks, 1);
expect(selected, isNull);
await tester.tap(icon);
expect(selected, NavigationTabId.libraries);
expect(quickPicks, 1);
});
testWidgets('offline shows a reconnect action that is inert while reconnecting', (tester) async {
var reconnects = 0;
await tester.pumpWidget(
wrap(
MobileNavigationRail(
tabs: tabs,
selectedTab: NavigationTabId.downloads,
showLabels: true,
onDestinationSelected: (_) {},
isOffline: true,
onReconnect: () => reconnects++,
),
),
);
final reconnect = find.byTooltip(t.common.reconnect);
expect(reconnect, findsOneWidget);
await tester.tap(reconnect);
expect(reconnects, 1);
await tester.pumpWidget(
wrap(
MobileNavigationRail(
tabs: tabs,
selectedTab: NavigationTabId.downloads,
showLabels: true,
onDestinationSelected: (_) {},
isOffline: true,
isReconnecting: true,
onReconnect: () => reconnects++,
),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.tap(find.byTooltip(t.common.reconnect), warnIfMissed: false);
expect(reconnects, 1);
await tester.pumpWidget(
wrap(
MobileNavigationRail(
tabs: tabs,
selectedTab: NavigationTabId.discover,
showLabels: true,
onDestinationSelected: (_) {},
),
),
);
expect(find.byTooltip(t.common.reconnect), findsNothing);
});
group('shouldUseLandscapeNavigationRail', () {
Future<bool> evaluate(
WidgetTester tester, {
required Size size,
TargetPlatform platform = TargetPlatform.android,
}) async {
late bool result;
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: platform),
home: Builder(
builder: (context) {
result = PlatformDetector.shouldUseLandscapeNavigationRail(context);
return const SizedBox();
},
),
),
);
return result;
}
testWidgets('a landscape phone or head unit uses the rail', (tester) async {
expect(await evaluate(tester, size: const Size(1080, 600)), isTrue);
});
testWidgets('portrait keeps the bottom bar', (tester) async {
expect(await evaluate(tester, size: const Size(600, 1080)), isFalse);
});
testWidgets('desktop keeps its own sidebar', (tester) async {
expect(await evaluate(tester, size: const Size(1080, 600), platform: TargetPlatform.macOS), isFalse);
});
testWidgets('TV keeps its own sidebar', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
expect(await evaluate(tester, size: const Size(1080, 600)), isFalse);
});
});
}