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 '../settings/settings_store.dart'; import '../state/providers.dart'; import '../state/remote_providers.dart'; import '../theme/tokens.dart'; import '../widgets/mini_player.dart'; /// Top-level shell: the three Timbre 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 with WidgetsBindingObserver { 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(); // Seed the Browse view from the persisted default exactly once, when settings // finish loading. Guarded so a later change to the default (or a manual browse // selection) is never overridden mid-session. bool _browseSeeded = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); ref.listenManual(settingsProvider, (prev, next) { if (_browseSeeded) return; _browseSeeded = true; ref.read(browseModeProvider.notifier).state = next.defaultBrowseMode; }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // Returning to the foreground: re-read authoritative position/duration from // the player, which can go stale while backgrounded or across an audio // interruption and otherwise leaves the playhead frozen (see // PlaybackController.resyncFromPlayer). if (state == AppLifecycleState.resumed) { ref.read(playbackCommandsProvider).resyncFromPlayer(); // If we were controlling a remote device, the socket likely died while // locked/backgrounded — reconnect at once instead of waiting out the // session's backoff (bug-fixes #5). ref.read(remoteControlProvider.notifier).onResume(); } } GlobalKey? _navKeyForTab(int tab) => switch (tab) { 0 => _homeNavKey, 1 => _browseNavKey, _ => null, }; /// Tap on the bottom tab bar. Selecting a different tab just switches; tapping /// the already-active tab resets it to its root — popping the nested detail /// stack (e.g. Album → Browse home) so the tab button doubles as "go back to /// the top", instead of being a no-op. void _selectTab(int tapped, int current) { if (tapped == current) { _navKeyForTab(tapped)?.currentState?.popUntil((r) => r.isFirst); } else { ref.read(selectedTabProvider.notifier).state = tapped; } } /// 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, // Bottom inset is handled inside _StatusBar so its background paints // flush to the screen edge (under the home indicator) instead of // leaving a dead gap there. bottom: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ const MiniPlayer(), _TabBar( tabs: _tabs, index: index, onSelect: (i) => _selectTab(i, index), ), 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: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.md, vertical: TimbreSpacing.md, ), child: Center( child: Text( tabs[i], style: TextStyle( color: active ? TimbreColors.foreground : TimbreColors.dimmed, fontWeight: active ? FontWeight.w700 : FontWeight.w400, decoration: active ? TextDecoration.underline : TextDecoration.none, decorationColor: accent, decorationThickness: 2, ), ), ), ), ), ); if (i < tabs.length - 1) { children.add( Text('|', style: TextStyle(color: TimbreColors.border)), ); } } return Container( decoration: BoxDecoration( border: Border(top: BorderSide(color: TimbreColors.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…', TimbreColors.dimmed), ConnStatus.error => ('○', 'offline', const Color(0xFFE06C75)), ConnStatus.disconnected => ('○', 'tap to connect', TimbreColors.dimmed), }; return Container( color: TimbreColors.surface, // Pad by the device's bottom safe-area inset so the surface color fills // down to the physical edge while the 22px content row sits above the // home indicator. padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), height: 22 + MediaQuery.of(context).padding.bottom, child: Row( children: [ // Left: connection status — tap to open the server switcher. Expanded( child: InkWell( onTap: () => showServerSheet(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.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: Padding( padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.lg), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.settings, size: 12, color: TimbreColors.dimmed), SizedBox(width: TimbreSpacing.xs), Text('settings', style: TextStyle(color: TimbreColors.dimmed)), ], ), ), ), ], ), ); } }