mobile-music/lib/shell/app_shell.dart
2026-07-29 16:22:00 -04:00

249 lines
8 KiB
Dart

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<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
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<NavigatorState>();
final _browseNavKey = GlobalKey<NavigatorState>();
GlobalKey<NavigatorState>? _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<NavigatorState> 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<String> tabs;
final int index;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final children = <Widget>[];
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(
const Text('|', style: TextStyle(color: TimbreColors.border)),
);
}
}
return Container(
decoration: const 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(
height: 22,
color: TimbreColors.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: 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: const 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)),
],
),
),
),
],
),
);
}
}