249 lines
8 KiB
Dart
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)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|