import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../auth/auth_provider.dart'; import '../auth/login_screen.dart'; import '../auth/server_url_screen.dart'; import '../library/album_detail_screen.dart'; import '../library/artist_detail_screen.dart'; import '../models/album.dart'; import '../models/artist.dart'; import '../models/playlist.dart'; import '../discover/discover_screen.dart'; import '../library/home_screen.dart'; import '../library/library_screen.dart'; import '../player/now_playing_screen.dart'; import '../player/player_bar.dart'; import '../player/queue_screen.dart'; import '../playlists/playlist_detail_screen.dart'; import '../playlists/playlists_list_screen.dart'; import '../requests/requests_screen.dart'; import '../search/search_screen.dart'; import '../settings/settings_screen.dart'; import '../update/client_update_provider.dart'; import '../update/update_banner.dart'; import '../admin/admin_landing_screen.dart'; import '../admin/admin_requests_screen.dart'; import '../admin/admin_quarantine_screen.dart'; import '../admin/admin_users_screen.dart'; import 'widgets/version_gate.dart'; /// Exposed as a Provider so its single argument is a real `Ref` (the /// constructor's redirect closures use `ref.read`). Widgets consume the /// router via `ref.watch(routerProvider)` instead of constructing it /// themselves with a `WidgetRef`, which can't satisfy the `Ref` type. final routerProvider = Provider((ref) => buildRouter(ref)); GoRouter buildRouter(Ref ref) { return GoRouter( initialLocation: '/', redirect: (ctx, state) async { final url = await ref.read(serverUrlProvider.future); if (url == null) return '/server-url'; final user = await ref.read(authControllerProvider.future); final loc = state.matchedLocation; if (user == null && loc != '/login' && loc != '/server-url') { return '/login'; } if (user != null && (loc == '/login' || loc == '/server-url')) { return '/home'; } if (loc.startsWith('/admin') && !user!.isAdmin) { return '/home'; } return null; }, routes: [ GoRoute(path: '/', redirect: (_, __) => '/home'), GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), // /now-playing lives outside the ShellRoute on purpose. The full // player IS the player UI when active, and we don't want the mini // bar from the shell underneath fighting for the bottom strip. // Pushing this route unmounts the shell entirely; the slide-up // transition still feels right because the shell stays painted // for the duration of the animation. GoRoute( path: '/now-playing', pageBuilder: (_, __) => CustomTransitionPage( child: const NowPlayingScreen(), transitionDuration: const Duration(milliseconds: 280), reverseTransitionDuration: const Duration(milliseconds: 240), transitionsBuilder: (_, anim, __, child) { final eased = CurvedAnimation( parent: anim, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); return SlideTransition( position: Tween( begin: const Offset(0, 1), end: Offset.zero, ).animate(eased), child: child, ); }, ), ), // /queue lives outside the ShellRoute too. Why: pushing /queue // from /now-playing (which is also outside the shell) used to // cause go_router to mount a second ShellRoute instance under // the existing one, producing a duplicate page-key assertion // (NavigatorState._debugCheckDuplicatedPageKeys). Top-level // routes can stack on each other freely; shell-children can't // when something on top of the shell is already routing. GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()), ShellRoute( builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)), routes: [ GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute( path: '/artists/:id', // `extra` carries an optional ArtistRef so the detail // header renders immediately while tracks/albums load. builder: (_, s) => ArtistDetailScreen( id: s.pathParameters['id']!, seed: s.extra is ArtistRef ? s.extra as ArtistRef : null, ), ), GoRoute( path: '/albums/:id', builder: (_, s) => AlbumDetailScreen( id: s.pathParameters['id']!, seed: s.extra is AlbumRef ? s.extra as AlbumRef : null, ), ), GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()), GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()), GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()), GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()), GoRoute( path: '/playlists/:id', builder: (_, s) => PlaylistDetailScreen( id: s.pathParameters['id']!, seed: s.extra is Playlist ? s.extra as Playlist : null, ), ), GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()), GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()), GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()), GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()), GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()), ], ), ], ); } class _ShellWithPlayerBar extends ConsumerWidget { const _ShellWithPlayerBar({required this.child}); final Widget child; @override Widget build(BuildContext context, WidgetRef ref) { // The banner sits above the routed child and uses SafeArea to clear // the system status bar. MediaQuery.padding is screen-relative // though, so the child's Scaffold/AppBar would still apply its own // top status-bar inset on top of the banner — doubling the gap. // When the banner is showing, strip that inset from the child so // its AppBar lands directly under the banner. final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null; return Column( children: [ // VersionTooOldBanner above UpdateBanner: a server-rejects-you // soft warning carries more user-relevant urgency than the APK // download prompt below it. The two banners can coexist // (server says you're too old AND a local APK is queued). const VersionTooOldBanner(), const UpdateBanner(), Expanded( child: hasBanner ? MediaQuery.removePadding( context: context, removeTop: true, child: child, ) : child, ), const PlayerBar(), ], ); } }