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:
@@ -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
|
||||
|
||||
+94
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user