feat(android): nav graph + bottom bar (M8 phase 5.5 — closes Phase 5)
Last task of Phase 5. The app now has the full bottom-bar shell with
NavHost wiring.
- nav/Routes.kt — @Serializable destinations: top-level tabs
(Home/Library/Search/Settings), detail screens with id args
(AlbumDetail, ArtistDetail, PlaylistDetail), overlays
(NowPlaying, Queue, Login).
- nav/MinstrelNavGraph.kt — NavHost with composable<RouteType>()
destinations. Library wires to the real LibraryScreen; everything
else uses the shared EmptyState as a "Coming soon" placeholder.
- MainActivity — Scaffold with NavigationBar bottom bar.
Selected-tab tracking via currentBackStackEntryAsState +
NavDestination.hasRoute(KClass) (type-safe routes API in
nav-compose 2.8+).
- Library cards' onArtistClick / onAlbumClick now navigate to
ArtistDetail(id) / AlbumDetail(id) — stubs for now, lit up when
those detail screens land.
Bottom-bar icons (Lucide CMP):
- House, LibraryBig, Search, Settings
startDestination = Library so the new UI is the cold-start landing
spot until the Home screen lands in Phase 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,15 +4,34 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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 com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||
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.Search as SearchRoute
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -27,12 +46,44 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun App() {
|
||||
// Bottom-bar nav-graph wiring lands in Phase 5.5. For now render
|
||||
// LibraryScreen directly so the new UI is reachable end-to-end.
|
||||
// onArtistClick / onAlbumClick are no-op until the nav graph lands.
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { inner ->
|
||||
Box(modifier = Modifier.padding(inner)) {
|
||||
LibraryScreen()
|
||||
val navController = rememberNavController()
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = { 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.fabledsword.minstrel.nav
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Stub composables call EmptyState — the real screens land in their
|
||||
* respective feature phases.
|
||||
*/
|
||||
@Composable
|
||||
fun MinstrelNavGraph(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Library,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
// ── Top-level tabs ────────────────────────────────────────────────
|
||||
|
||||
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<Search> { ComingSoon(title = "Search", body = "Lands in a later phase.") }
|
||||
|
||||
composable<Settings> { ComingSoon(title = "Settings", body = "Lands in Phase 11.") }
|
||||
|
||||
// ── Detail / overlay screens ──────────────────────────────────────
|
||||
|
||||
composable<AlbumDetail> { backStackEntry ->
|
||||
val route: AlbumDetail = backStackEntry.toRoute()
|
||||
ComingSoon(
|
||||
title = "Album",
|
||||
body = "Album detail for ${route.id} — lands in a 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.",
|
||||
)
|
||||
}
|
||||
|
||||
composable<PlaylistDetail> { backStackEntry ->
|
||||
val route: PlaylistDetail = backStackEntry.toRoute()
|
||||
ComingSoon(
|
||||
title = "Playlist",
|
||||
body = "Playlist detail for ${route.id} — lands in Phase 7.",
|
||||
)
|
||||
}
|
||||
|
||||
composable<NowPlaying> { ComingSoon(title = "Now playing", body = "Lands in Phase 6.4.") }
|
||||
composable<Queue> { ComingSoon(title = "Queue", body = "Lands in Phase 6.") }
|
||||
composable<Login> { ComingSoon(title = "Sign in", body = "Lands in a later phase.") }
|
||||
}
|
||||
}
|
||||
|
||||
@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(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.fabledsword.minstrel.nav
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Top-level destinations (bottom-bar tabs).
|
||||
|
||||
@Serializable data object Home
|
||||
@Serializable data object Library
|
||||
@Serializable data object Search
|
||||
@Serializable data object Settings
|
||||
|
||||
// Push-on-top destinations (no bottom-bar entry).
|
||||
|
||||
@Serializable data class AlbumDetail(val id: String)
|
||||
@Serializable data class ArtistDetail(val id: String)
|
||||
@Serializable data class PlaylistDetail(val id: String)
|
||||
|
||||
@Serializable data object NowPlaying
|
||||
@Serializable data object Queue
|
||||
@Serializable data object Login
|
||||
Reference in New Issue
Block a user