mobile-music/lib/shell/app_shell.dart
2026-07-29 14:14:18 -04:00

216 lines
6.6 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 '../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);
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: 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 InkWell(
onTap: () => showConnectSheet(context),
child: Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
color: RatuneColors.surface,
child: Row(
children: [
Text('$glyph ', style: TextStyle(color: color)),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color),
),
),
const SizedBox(width: RatuneSpacing.md),
const Text('i — help', style: TextStyle(color: RatuneColors.dimmed)),
],
),
),
);
}
}