refactor(android): drop bottom nav for AppBar+actions+kebab; shell wrapper (sub-task 2)

Aligns with Flutter — there is no bottom nav, drawer, or rail. Every
in-shell screen carries `MainAppBarActions` in its AppBar:

  [Home] [Library] [Search] [ ⋮ Playlists / Discover / Settings / Admin ]

The icon for the current screen is suppressed so the action set looks
contextual.

  - shared/widgets/MainAppBarActions.kt — NEW. The icon row + kebab.
    Takes navController + currentRouteName (FQN) so each screen tells
    it which icon to hide. Admin kebab entry gated on `isAdmin` for
    later; defaults false.
  - shared/widgets/ShellScaffold.kt — NEW. Wraps any in-shell screen
    with the MiniPlayer pinned at the bottom (auto-hides on no-track,
    matches Flutter `_ShellWithPlayerBar`). Banners slot reserved for
    later (VersionTooOld / UpdateBanner).
  - nav/Routes.kt — added Discover, Playlists, Requests, Admin,
    AdminRequests, AdminQuarantine, AdminUsers, ServerUrl. Grouped
    by shell-vs-full-screen comments.
  - nav/MinstrelNavGraph.kt — every in-shell route wrapped in
    ShellScaffold. NowPlaying becomes a full-screen route with
    slideInVertically/slideOutVertically transitions (mirrors
    Flutter's CustomTransitionPage modal). Queue / ServerUrl / Login
    are also outside the shell.
  - MainActivity.kt — drops Scaffold + NavigationBar + the BottomBarTab
    list. Just hosts the NavHost now. The MiniPlayer moves into
    ShellScaffold per the Flutter pattern.
  - library/ui/LibraryScreen.kt — wraps itself in Scaffold + TopAppBar
    + MainAppBarActions (currentRouteName = Library FQN). Accepts
    navController; album/artist taps navigate via the controller
    instead of via callbacks. Body content still transitional — the
    proper 5-tab restructure lands in sub-task 4.

Routes-without-real-screens (Home, Search, Discover, Playlists,
Settings, Admin, Requests, AdminRequests/Quarantine/Users, Queue,
ServerUrl, Login) all render ComingSoon stubs via EmptyState so the
nav graph is fully reachable end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:55:44 -04:00
parent 2ab20e3958
commit ab05b7ab7b
6 changed files with 315 additions and 139 deletions
@@ -4,38 +4,13 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.composables.icons.lucide.House
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.composables.icons.lucide.Settings
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.Library
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.nav.Search as SearchRoute
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.theme.MinstrelTheme import com.fabledsword.minstrel.theme.MinstrelTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlin.reflect.KClass
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -50,51 +25,11 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
private fun App() { private fun App() {
// No bottom nav, no drawer — navigation is per-screen via the
// AppBar actions row + kebab (`MainAppBarActions`). The MiniPlayer
// is included by each in-shell route's `ShellScaffold` wrap;
// full-screen routes (NowPlaying / Queue / unauthenticated) bypass
// the shell entirely.
val navController = rememberNavController() val navController = rememberNavController()
Scaffold( MinstrelNavGraph(navController = navController, modifier = Modifier.fillMaxSize())
modifier = Modifier.fillMaxSize(),
bottomBar = {
// MiniPlayer sits above the bottom nav. It hides itself
// (Composable returns nothing) when no track is loaded.
Column(modifier = Modifier.fillMaxWidth()) {
MiniPlayer(onExpandClick = { navController.navigate(NowPlaying) })
MinstrelBottomBar(navController)
}
},
) { inner ->
MinstrelNavGraph(
navController = navController,
modifier = Modifier.padding(inner),
)
}
}
private data class BottomBarTab(
val label: String,
val icon: ImageVector,
val routeClass: KClass<*>,
val navigate: NavHostController.() -> Unit,
)
@Composable
private fun MinstrelBottomBar(navController: NavHostController) {
val tabs = listOf(
BottomBarTab("Home", Lucide.House, Home::class) { navigate(Home) },
BottomBarTab("Library", Lucide.LibraryBig, Library::class) { navigate(Library) },
BottomBarTab("Search", Lucide.Search, SearchRoute::class) { navigate(SearchRoute) },
BottomBarTab("Settings", Lucide.Settings, SettingsRoute::class) { navigate(SettingsRoute) },
)
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
NavigationBar {
tabs.forEach { tab ->
val selected = currentDestination?.hasRoute(tab.routeClass) == true
NavigationBarItem(
selected = selected,
onClick = { tab.navigate(navController) },
icon = { Icon(tab.icon, contentDescription = tab.label) },
label = { Text(tab.label) },
)
}
}
} }
@@ -11,8 +11,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -20,44 +23,71 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Library
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
/** /**
* Top-level Library tab. Renders horizontally-scrolling rows of * Library tab. Currently a transitional shape — Artists row + Albums
* Artists and Albums driven by `LibraryViewModel`'s UiState. * row — until sub-task 4 of the design-drift correction replaces the
* body with the proper 5-tab TabBar (Artists / Albums / History /
* Liked / Hidden) matching Flutter.
* *
* Loading shows a centered spinner; Empty / Error use the shared * This commit's sub-task wires the AppBar + MainAppBarActions row so
* widgets; Success renders the lists. Detail-screen navigation * the screen is reachable via the icon row and detail-screen taps
* (`onArtistClick` / `onAlbumClick`) is wired by the caller — Phase 5.5 * route through the navController.
* adds the nav graph + routes.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
onArtistClick: (ArtistRef) -> Unit = {}, navController: NavHostController,
onAlbumClick: (AlbumRef) -> Unit = {},
viewModel: LibraryViewModel = hiltViewModel(), viewModel: LibraryViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() Scaffold(
when (val s = state) { modifier = Modifier.fillMaxSize(),
LibraryUiState.Loading -> LoadingCentered() topBar = {
LibraryUiState.Empty -> EmptyState( TopAppBar(
title = "Nothing here yet", title = { Text("Library") },
body = "Scan a folder in your server settings to populate the library.", actions = {
) MainAppBarActions(
is LibraryUiState.Error -> ErrorRetry( navController = navController,
message = s.message, currentRouteName = Library::class.qualifiedName,
onRetry = { /* Wired in Phase 5.5 — for now, surface only. */ }, )
) },
is LibraryUiState.Success -> LibrarySuccessContent( )
state = s, },
onArtistClick = onArtistClick, ) { inner ->
onAlbumClick = onAlbumClick, val state by viewModel.uiState.collectAsStateWithLifecycle()
) Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) {
LibraryUiState.Loading -> LoadingCentered()
LibraryUiState.Empty -> EmptyState(
title = "Nothing here yet",
body = "Scan a folder in your server settings to populate the library.",
)
is LibraryUiState.Error -> ErrorRetry(
message = s.message,
onRetry = { /* Wired in Phase 12 sync controller. */ },
)
is LibraryUiState.Success -> LibrarySuccessContent(
state = s,
onArtistClick = { artist ->
navController.navigate(ArtistDetail(artist.id))
},
onAlbumClick = { album ->
navController.navigate(AlbumDetail(album.id))
},
)
}
}
} }
} }
@@ -1,5 +1,7 @@
package com.fabledsword.minstrel.nav package com.fabledsword.minstrel.nav
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -10,80 +12,142 @@ import androidx.navigation.toRoute
import com.fabledsword.minstrel.library.ui.LibraryScreen import com.fabledsword.minstrel.library.ui.LibraryScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen import com.fabledsword.minstrel.player.ui.NowPlayingScreen
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
/** /**
* Single NavHost for the whole app. Top-level tabs (Home / Library / * App NavHost. Mirrors the Flutter split between shell routes (wrapped
* Search / Settings) are direct destinations; detail screens push on * by `_ShellWithPlayerBar` → banners + screen + MiniPlayer) and
* top with their argument types passed via the @Serializable route * top-level non-shell routes (NowPlaying / Queue / unauthenticated).
* classes' fields.
* *
* Stub composables call EmptyState — the real screens land in their * Per-route transition: only NowPlaying gets the modal slide-up; the
* respective feature phases. * rest use the default fade.
*/ */
@Composable @Composable
fun MinstrelNavGraph( fun MinstrelNavGraph(
navController: NavHostController, navController: NavHostController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val expandPlayer = { navController.navigate(NowPlaying) }
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Library, startDestination = Library,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) { ) {
// ── Top-level tabs ──────────────────────────────────────────────── // ── In-shell top-level destinations ───────────────────────────
composable<Home> { ComingSoon(title = "Home", body = "Lands in Phase 6.") } composable<Home> {
ShellScaffold(onExpandPlayer = expandPlayer) {
composable<Library> { ComingSoon("Home", "Lands in the next design-drift sub-task.")
LibraryScreen( }
onArtistClick = { artist -> navController.navigate(ArtistDetail(artist.id)) },
onAlbumClick = { album -> navController.navigate(AlbumDetail(album.id)) },
)
} }
composable<Search> { ComingSoon(title = "Search", body = "Lands in a later phase.") } composable<Library> {
ShellScaffold(onExpandPlayer = expandPlayer) {
LibraryScreen(navController = navController)
}
}
composable<Settings> { ComingSoon(title = "Settings", body = "Lands in Phase 11.") } composable<Search> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Search", "Lands in a later phase.")
}
}
// ── Detail / overlay screens ────────────────────────────────────── composable<Discover> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Discover", "Lands in Phase 10 (Lidarr requests).")
}
}
composable<Playlists> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Playlists", "Lands in Phase 7.")
}
}
composable<Settings> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Settings", "Lands in Phase 11.")
}
}
composable<Admin> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Admin", "Admin landing — lands in a later phase.")
}
}
composable<Requests> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Requests", "Your Lidarr requests — Phase 10.")
}
}
// ── In-shell detail screens (push-on-top) ─────────────────────
composable<AlbumDetail> { backStackEntry -> composable<AlbumDetail> { backStackEntry ->
val route: AlbumDetail = backStackEntry.toRoute() val route: AlbumDetail = backStackEntry.toRoute()
ComingSoon( ShellScaffold(onExpandPlayer = expandPlayer) {
title = "Album", ComingSoon("Album", "Album detail for ${route.id} — later 5.x slice.")
body = "Album detail for ${route.id} — lands in a later 5.x slice.", }
)
} }
composable<ArtistDetail> { backStackEntry -> composable<ArtistDetail> { backStackEntry ->
val route: ArtistDetail = backStackEntry.toRoute() val route: ArtistDetail = backStackEntry.toRoute()
ComingSoon( ShellScaffold(onExpandPlayer = expandPlayer) {
title = "Artist", ComingSoon("Artist", "Artist detail for ${route.id} — later 5.x slice.")
body = "Artist detail for ${route.id} — lands in a later 5.x slice.", }
)
} }
composable<PlaylistDetail> { backStackEntry -> composable<PlaylistDetail> { backStackEntry ->
val route: PlaylistDetail = backStackEntry.toRoute() val route: PlaylistDetail = backStackEntry.toRoute()
ComingSoon( ShellScaffold(onExpandPlayer = expandPlayer) {
title = "Playlist", ComingSoon("Playlist", "Playlist detail for ${route.id} — Phase 7.")
body = "Playlist detail for ${route.id} — lands in Phase 7.", }
)
} }
composable<NowPlaying> { NowPlayingScreen() } composable<AdminRequests> {
composable<Queue> { ComingSoon(title = "Queue", body = "Lands in Phase 6.") } ShellScaffold(onExpandPlayer = expandPlayer) {
composable<Login> { ComingSoon(title = "Sign in", body = "Lands in a later phase.") } ComingSoon("Admin · Requests", "Lands with admin slice.")
}
}
composable<AdminQuarantine> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Admin · Quarantine", "Lands with admin slice.")
}
}
composable<AdminUsers> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Admin · Users", "Lands with admin slice.")
}
}
// ── Outside-shell full-screen routes ──────────────────────────
composable<NowPlaying>(
// Slide up from the bottom on enter; back down on dismiss.
// Mirrors the Flutter CustomTransitionPage (280ms easeOutCubic).
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { slideOutVertically(targetOffsetY = { it }) },
popEnterTransition = { slideInVertically(initialOffsetY = { it }) },
popExitTransition = { slideOutVertically(targetOffsetY = { it }) },
) {
NowPlayingScreen()
}
composable<Queue> { ComingSoon("Queue", "Lands in a later 6.x slice.") }
composable<ServerUrl> {
ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.")
}
composable<Login> {
ComingSoon("Sign in", "Login flow — Phase 11.")
}
} }
} }
@Composable @Composable
private fun ComingSoon(title: String, body: String) { private fun ComingSoon(title: String, body: String) {
// Reuse EmptyState as a generic placeholder for unbuilt screens — EmptyState(title = title, body = body, modifier = Modifier.fillMaxSize())
// keeps the design system intact rather than ad-hoc Text.
EmptyState(
title = title,
body = body,
modifier = Modifier.fillMaxSize(),
)
} }
@@ -2,19 +2,30 @@ package com.fabledsword.minstrel.nav
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
// Top-level destinations (bottom-bar tabs). // ── In-shell top-level destinations (reachable from MainAppBarActions
// icons + kebab) ──────────────────────────────────────────────────────
@Serializable data object Home @Serializable data object Home
@Serializable data object Library @Serializable data object Library
@Serializable data object Search @Serializable data object Search
@Serializable data object Discover
@Serializable data object Playlists
@Serializable data object Settings @Serializable data object Settings
@Serializable data object Admin
@Serializable data object Requests
// Push-on-top destinations (no bottom-bar entry). // ── In-shell detail / push-on-top destinations ────────────────────────
@Serializable data class AlbumDetail(val id: String) @Serializable data class AlbumDetail(val id: String)
@Serializable data class ArtistDetail(val id: String) @Serializable data class ArtistDetail(val id: String)
@Serializable data class PlaylistDetail(val id: String) @Serializable data class PlaylistDetail(val id: String)
@Serializable data object AdminRequests
@Serializable data object AdminQuarantine
@Serializable data object AdminUsers
@Serializable data object NowPlaying // ── Outside-shell full-screen routes ──────────────────────────────────
@Serializable data object Queue
@Serializable data object Login @Serializable data object NowPlaying // slide-up modal transition
@Serializable data object Queue // standard push
@Serializable data object ServerUrl // unauthenticated layer
@Serializable data object Login // unauthenticated layer
@@ -0,0 +1,94 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.navigation.NavHostController
import com.composables.icons.lucide.House
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.MoreVertical
import com.composables.icons.lucide.Search as SearchIcon
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.Library
import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.nav.Search as SearchRoute
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
/**
* The universal navigation surface for every shell screen — Flutter's
* `MainAppBarActions`. There is no bottom nav, drawer, or rail; every
* top-level AppBar carries this row, and the kebab covers the rest.
*
* [Home] [Library] [Search] [ ⋮ Playlists / Discover / Settings / Admin ]
*
* The icon for the screen the user is currently on is suppressed so
* the action set looks contextual.
*
* `currentRouteName` is a soft string match against the destination
* route name (route classes serialize to their FQN by default in
* nav-compose); pass `Home::class.qualifiedName` etc. from the screen.
*/
@Composable
fun MainAppBarActions(
navController: NavHostController,
currentRouteName: String?,
isAdmin: Boolean = false,
) {
Row {
if (currentRouteName != Home::class.qualifiedName) {
IconButton(onClick = { navController.navigate(Home) }) {
Icon(Lucide.House, contentDescription = "Home")
}
}
if (currentRouteName != Library::class.qualifiedName) {
IconButton(onClick = { navController.navigate(Library) }) {
Icon(Lucide.LibraryBig, contentDescription = "Library")
}
}
if (currentRouteName != SearchRoute::class.qualifiedName) {
IconButton(onClick = { navController.navigate(SearchRoute) }) {
Icon(Lucide.SearchIcon, contentDescription = "Search")
}
}
OverflowMenu(navController = navController, isAdmin = isAdmin)
}
}
@Composable
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
Icon(Lucide.MoreVertical, contentDescription = "More")
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Playlists") },
onClick = { expanded = false; navController.navigate(Playlists) },
)
DropdownMenuItem(
text = { Text("Discover") },
onClick = { expanded = false; navController.navigate(Discover) },
)
DropdownMenuItem(
text = { Text("Settings") },
onClick = { expanded = false; navController.navigate(SettingsRoute) },
)
if (isAdmin) {
DropdownMenuItem(
text = { Text("Admin") },
onClick = { expanded = false; navController.navigate(Admin) },
)
}
}
}
@@ -0,0 +1,42 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.fabledsword.minstrel.player.ui.MiniPlayer
/**
* Outer shell wrapper for "in-app" routes — banners on top
* (conditional, none implemented yet), routed screen filling the
* middle, MiniPlayer pinned at the bottom (auto-hides when no track).
*
* Mirrors the Flutter `_ShellWithPlayerBar` (lib/shared/routing.dart).
*
* Top-level routes (Home / Library / Search / Discover / Playlists /
* Settings / Admin / detail screens) wrap themselves with this.
* Full-screen routes (NowPlaying / Queue / ServerUrl / Login)
* intentionally do NOT — they fill the entire screen, often with a
* back-only AppBar.
*
* `onExpandPlayer` is the navigate-to-NowPlaying callback; passed
* through to MiniPlayer so it doesn't have to know the nav graph.
*/
@Composable
fun ShellScaffold(
onExpandPlayer: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(modifier = modifier.fillMaxSize()) {
// Banners (VersionTooOld, UpdateBanner) live here once those
// mechanisms exist; for now the slot is empty so the layout
// matches the Flutter structure 1:1.
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
content()
}
MiniPlayer(onExpandClick = onExpandPlayer)
}
}