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:
2026-05-23 11:04:44 -04:00
parent 18bca6c2fd
commit 0742b45e3d
3 changed files with 167 additions and 8 deletions
@@ -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