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.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
|
||||||
|
|||||||
+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