import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../screens/browser_screen.dart'; import '../screens/connect_sheet.dart'; import '../screens/home_screen.dart'; import '../screens/now_playing_screen.dart'; import '../screens/settings_screen.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; import '../widgets/mini_player.dart'; /// Top-level shell: the three Ratune tabs (Home / Browse / Now Playing) with a /// bottom tab bar and a status bar, mirroring the terminal layout. class AppShell extends ConsumerStatefulWidget { const AppShell({super.key}); @override ConsumerState createState() => _AppShellState(); } class _AppShellState extends ConsumerState { static const _tabs = ['Home', 'Browse', 'Now Playing']; // Home and Browse push detail screens, so each owns a nested Navigator whose // routes render *inside* the tab, beneath the persistent mini-player/tab bar. // Now Playing never pushes, so it needs none. final _homeNavKey = GlobalKey(); final _browseNavKey = GlobalKey(); GlobalKey? _navKeyForTab(int tab) => switch (tab) { 0 => _homeNavKey, 1 => _browseNavKey, _ => null, }; /// Android system-back: pop the active tab's nested stack first, then fall /// back to Home, and only exit the app from the Home root. void _handleBack(int tab) { final nav = _navKeyForTab(tab)?.currentState; if (nav != null && nav.canPop()) { nav.pop(); } else if (tab != 0) { ref.read(selectedTabProvider.notifier).state = 0; } else { SystemNavigator.pop(); } } @override Widget build(BuildContext context) { // Keep favorites alive from launch so its connect/disconnect listener runs // and hydrates stars/ratings as soon as a server connects. ref.watch(favoritesProvider); // Instantiate the download + playlist stores at launch too, so their // per-server manifests load from disk before the user can trigger // playback — otherwise the first play right after a cold (offline) boot // races the async manifest load and misses a downloaded file. ref.watch(downloadManagerProvider); ref.watch(playlistsProvider); final index = ref.watch(selectedTabProvider); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) _handleBack(index); }, child: Scaffold( body: SafeArea( bottom: false, child: IndexedStack( index: index, children: [ _TabNavigator(navigatorKey: _homeNavKey, child: const HomeScreen()), _TabNavigator( navigatorKey: _browseNavKey, child: const BrowserScreen()), const NowPlayingScreen(), ], ), ), bottomNavigationBar: SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ const MiniPlayer(), _TabBar( tabs: _tabs, index: index, onSelect: (i) => ref.read(selectedTabProvider.notifier).state = i, ), const _StatusBar(), ], ), ), ), ); } } /// Wraps a tab root in its own [Navigator] so `Navigator.of(context).push` /// calls from within the tab resolve here (nested) rather than the root /// navigator — keeping the mini-player and tab bar on screen. class _TabNavigator extends StatelessWidget { const _TabNavigator({required this.navigatorKey, required this.child}); final GlobalKey navigatorKey; final Widget child; @override Widget build(BuildContext context) { return Navigator( key: navigatorKey, onGenerateRoute: (settings) => MaterialPageRoute(builder: (_) => child, settings: settings), ); } } class _TabBar extends StatelessWidget { const _TabBar({ required this.tabs, required this.index, required this.onSelect, }); final List tabs; final int index; final ValueChanged onSelect; @override Widget build(BuildContext context) { final accent = Theme.of(context).colorScheme.primary; final children = []; for (var i = 0; i < tabs.length; i++) { final active = i == index; children.add( InkWell( onTap: () => onSelect(i), child: Container( constraints: const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget), padding: const EdgeInsets.symmetric( horizontal: RatuneSpacing.md, vertical: RatuneSpacing.md, ), child: Center( child: Text( tabs[i], style: TextStyle( color: active ? RatuneColors.foreground : RatuneColors.dimmed, fontWeight: active ? FontWeight.w700 : FontWeight.w400, decoration: active ? TextDecoration.underline : TextDecoration.none, decorationColor: accent, decorationThickness: 2, ), ), ), ), ), ); if (i < tabs.length - 1) { children.add( const Text('|', style: TextStyle(color: RatuneColors.border)), ); } } return Container( decoration: const BoxDecoration( border: Border(top: BorderSide(color: RatuneColors.border)), ), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: children), ); } } class _StatusBar extends ConsumerWidget { const _StatusBar(); @override Widget build(BuildContext context, WidgetRef ref) { final conn = ref.watch(connectionProvider); final accent = Theme.of(context).colorScheme.primary; final (glyph, label, color) = switch (conn.status) { ConnStatus.online => ( '●', conn.credentials?.display ?? 'online', accent, ), ConnStatus.connecting => ('◐', 'connecting…', RatuneColors.dimmed), ConnStatus.error => ('○', 'offline', const Color(0xFFE06C75)), ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed), }; return Container( height: 22, color: RatuneColors.surface, child: Row( children: [ // Left: connection status — tap to open the connect sheet. Expanded( child: InkWell( onTap: () => showConnectSheet(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg), child: Row( children: [ Text('$glyph ', style: TextStyle(color: color)), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: color), ), ), ], ), ), ), ), // Right: settings — pushes over the whole shell (root navigator). InkWell( onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SettingsScreen()), ), child: const Padding( padding: EdgeInsets.symmetric(horizontal: RatuneSpacing.lg), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.settings, size: 12, color: RatuneColors.dimmed), SizedBox(width: RatuneSpacing.xs), Text('settings', style: TextStyle(color: RatuneColors.dimmed)), ], ), ), ), ], ), ); } }