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.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
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.getValue
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 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.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 dagger.hilt.android.AndroidEntryPoint
import kotlin.reflect.KClass
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -50,51 +25,11 @@ class MainActivity : ComponentActivity() {
@Composable
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()
Scaffold(
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) },
)
}
}
MinstrelNavGraph(navController = navController, modifier = Modifier.fillMaxSize())
}
@@ -11,8 +11,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -20,44 +23,71 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef
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.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
/**
* Top-level Library tab. Renders horizontally-scrolling rows of
* Artists and Albums driven by `LibraryViewModel`'s UiState.
* Library tab. Currently a transitional shape — Artists row + Albums
* 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
* widgets; Success renders the lists. Detail-screen navigation
* (`onArtistClick` / `onAlbumClick`) is wired by the caller — Phase 5.5
* adds the nav graph + routes.
* This commit's sub-task wires the AppBar + MainAppBarActions row so
* the screen is reachable via the icon row and detail-screen taps
* route through the navController.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(
onArtistClick: (ArtistRef) -> Unit = {},
onAlbumClick: (AlbumRef) -> Unit = {},
navController: NavHostController,
viewModel: LibraryViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
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 5.5 — for now, surface only. */ },
)
is LibraryUiState.Success -> LibrarySuccessContent(
state = s,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
)
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Library") },
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Library::class.qualifiedName,
)
},
)
},
) { inner ->
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
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -10,80 +12,142 @@ import androidx.navigation.toRoute
import com.fabledsword.minstrel.library.ui.LibraryScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
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 /
* Search / Settings) are direct destinations; detail screens push on
* top with their argument types passed via the @Serializable route
* classes' fields.
* App NavHost. Mirrors the Flutter split between shell routes (wrapped
* by `_ShellWithPlayerBar` → banners + screen + MiniPlayer) and
* top-level non-shell routes (NowPlaying / Queue / unauthenticated).
*
* Stub composables call EmptyState — the real screens land in their
* respective feature phases.
* Per-route transition: only NowPlaying gets the modal slide-up; the
* rest use the default fade.
*/
@Composable
fun MinstrelNavGraph(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
val expandPlayer = { navController.navigate(NowPlaying) }
NavHost(
navController = navController,
startDestination = Library,
modifier = modifier.fillMaxSize(),
) {
// ── Top-level tabs ────────────────────────────────────────────────
// ── In-shell top-level destinations ───────────────────────────
composable<Home> { ComingSoon(title = "Home", body = "Lands in Phase 6.") }
composable<Library> {
LibraryScreen(
onArtistClick = { artist -> navController.navigate(ArtistDetail(artist.id)) },
onAlbumClick = { album -> navController.navigate(AlbumDetail(album.id)) },
)
composable<Home> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Home", "Lands in the next design-drift sub-task.")
}
}
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 ->
val route: AlbumDetail = backStackEntry.toRoute()
ComingSoon(
title = "Album",
body = "Album detail for ${route.id} — lands in a later 5.x slice.",
)
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Album", "Album detail for ${route.id} — later 5.x slice.")
}
}
composable<ArtistDetail> { backStackEntry ->
val route: ArtistDetail = backStackEntry.toRoute()
ComingSoon(
title = "Artist",
body = "Artist detail for ${route.id} — lands in a later 5.x slice.",
)
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Artist", "Artist detail for ${route.id} — later 5.x slice.")
}
}
composable<PlaylistDetail> { backStackEntry ->
val route: PlaylistDetail = backStackEntry.toRoute()
ComingSoon(
title = "Playlist",
body = "Playlist detail for ${route.id} — lands in Phase 7.",
)
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Playlist", "Playlist detail for ${route.id} — Phase 7.")
}
}
composable<NowPlaying> { NowPlayingScreen() }
composable<Queue> { ComingSoon(title = "Queue", body = "Lands in Phase 6.") }
composable<Login> { ComingSoon(title = "Sign in", body = "Lands in a later phase.") }
composable<AdminRequests> {
ShellScaffold(onExpandPlayer = expandPlayer) {
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
private fun ComingSoon(title: String, body: String) {
// Reuse EmptyState as a generic placeholder for unbuilt screens —
// keeps the design system intact rather than ad-hoc Text.
EmptyState(
title = title,
body = body,
modifier = Modifier.fillMaxSize(),
)
EmptyState(title = title, body = body, modifier = Modifier.fillMaxSize())
}
@@ -2,19 +2,30 @@ package com.fabledsword.minstrel.nav
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 Library
@Serializable data object Search
@Serializable data object Discover
@Serializable data object Playlists
@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 ArtistDetail(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
@Serializable data object Queue
@Serializable data object Login
// ── Outside-shell full-screen routes ──────────────────────────────────
@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)
}
}