Crossfade between top-level tabs instead of iOS slide

Switching between the top-level destinations went through the default
CupertinoPage slide, which is meant for forward pushes. For a peer
tab-switch it slid the incoming screen in over the outgoing one without
animating the old screen away, leaving it visible in the background.

Give the top-level routes a CustomTransitionPage crossfade; keep the
device-detail route on the default slide since it is a genuine drill-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-07 15:44:48 -04:00
co-authored by Claude Opus 4.8
parent 55eb878590
commit 4a19ab34e2
3 changed files with 77 additions and 7 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
- [x] The app has the default Flutter icon in iOS, use OOTT's icon
- [x] Ask for network access when the app launches, now it asks when you try to test the connection for the first time and it always fails once
- [ ] On iOS when the user navigates between screens the previous screen remains visible on the backgroun while the other one animates in, it looks bad
- [x] On iOS when the user navigates between screens the previous screen remains visible on the backgroun while the other one animates in, it looks bad
- [x] In narrow devices, the "Notifications" title in the home screen is to close to the filter combobox and does not scroll with the page (it should)
- [x] When the user goes to the / URI in a production server redirect him to /web
- [x] Add a link to the API docs (/api/docs) in the navigation (new window - only visible in wide)
+31 -6
View File
@@ -100,18 +100,20 @@ final GoRouter router = GoRouter(
GoRoute(
path: Routes.home,
name: 'home',
builder: (context, state) => const HomeScreen(),
pageBuilder: (context, state) => _fadePage(state, const HomeScreen()),
redirect: (context, state) => _redirectToSettings(),
),
GoRoute(
path: Routes.devices,
name: 'devices',
builder: (context, state) => const DeviceList(),
pageBuilder: (context, state) => _fadePage(state, const DeviceList()),
redirect: (context, state) => _redirectToSettings(),
routes: [
GoRoute(
path: Routes.deviceDetailSegment,
name: 'deviceDetail',
// A genuine forward drill-in: keep the default platform slide,
// which is the correct affordance for pushing a detail screen.
builder: (context, state) {
final mac = state.pathParameters['macAddress']!;
return DeviceDetail(macAddress: mac);
@@ -122,24 +124,46 @@ final GoRouter router = GoRouter(
GoRoute(
path: Routes.status,
name: 'status',
builder: (context, state) => const StatusScreen(),
pageBuilder: (context, state) =>
_fadePage(state, const StatusScreen()),
redirect: (context, state) => _redirectToSettings(),
),
GoRoute(
path: Routes.settings,
name: 'settings',
builder: (context, state) => Settings(),
pageBuilder: (context, state) => _fadePage(state, Settings()),
),
GoRoute(
path: Routes.about,
name: 'about',
builder: (context, state) => const About(),
pageBuilder: (context, state) => _fadePage(state, const About()),
),
],
),
],
);
// Duration of the crossfade between top-level destinations.
const Duration _kTabFadeDuration = Duration(milliseconds: 200);
// Page used when switching between top-level destinations (the tabs). Switching
// peers via [context.go] is a replace, not a forward push, so the default iOS
// slide is wrong here: it slides the incoming screen in over the outgoing one,
// leaving the old screen visible underneath. A crossfade animates both screens
// together (each driven by its own primary animation), so nothing is left
// stranded in the background. Detail screens reached via [context.push] keep
// the default platform slide.
CustomTransitionPage<void> _fadePage(GoRouterState state, Widget child) {
return CustomTransitionPage<void>(
key: state.pageKey,
transitionDuration: _kTabFadeDuration,
reverseTransitionDuration: _kTabFadeDuration,
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
child: child,
);
}
// 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';
@@ -379,7 +403,8 @@ void _onDestinationSelected(_NavDest destination, BuildContext context) {
// the resulting URI is launchable; absolute URLs are returned unchanged.
// Without this, launching a scheme-less URI fails when the app is served from
// the backend (e.g. Docker).
Uri resolveExternalUri(String url, {Uri? base}) => (base ?? Uri.base).resolve(url);
Uri resolveExternalUri(String url, {Uri? base}) =>
(base ?? Uri.base).resolve(url);
Future<void> _openExternal(String url) async {
final uri = resolveExternalUri(url);
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/navigation.dart';
import 'package:go_router/go_router.dart';
void main() {
// The app's real [router] is configured so that switching between top-level
// destinations crossfades (a [CustomTransitionPage]) instead of using the
// default platform slide, which would otherwise leave the outgoing screen
// visible behind the incoming one. Drilling into a detail screen keeps the
// default slide (a plain [GoRoute.builder]).
List<GoRoute> topLevelRoutes() {
final shell = router.configuration.routes.single as ShellRoute;
return shell.routes.cast<GoRoute>();
}
GoRoute routeByName(String name) =>
topLevelRoutes().firstWhere((r) => r.name == name);
group('navigation transitions', () {
test('top-level destinations use a page builder (crossfade)', () {
for (final name in ['home', 'devices', 'status', 'settings', 'about']) {
final route = routeByName(name);
expect(
route.pageBuilder,
isNotNull,
reason: '"$name" should crossfade via a custom page builder',
);
expect(route.builder, isNull, reason: '"$name" should not also build');
}
});
test('the device detail drill-in keeps the default slide builder', () {
final devices = routeByName('devices');
final detail = devices.routes.single as GoRoute;
expect(detail.name, 'deviceDetail');
expect(
detail.builder,
isNotNull,
reason: 'the detail push should keep the default platform slide',
);
expect(detail.pageBuilder, isNull);
});
});
}