From 0742b45e3de78339fec832d20558e26a16f9a292 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 11:04:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20nav=20graph=20+=20bottom=20bar?= =?UTF-8?q?=20(M8=20phase=205.5=20=E2=80=94=20closes=20Phase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() 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) --- .../com/fabledsword/minstrel/MainActivity.kt | 67 ++++++++++++-- .../minstrel/nav/MinstrelNavGraph.kt | 88 +++++++++++++++++++ .../com/fabledsword/minstrel/nav/Routes.kt | 20 +++++ 3 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index 5136b613..9fe8be9f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -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) }, + ) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt new file mode 100644 index 00000000..398f8f73 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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 { ComingSoon(title = "Home", body = "Lands in Phase 6.") } + + composable { + LibraryScreen( + onArtistClick = { artist -> navController.navigate(ArtistDetail(artist.id)) }, + onAlbumClick = { album -> navController.navigate(AlbumDetail(album.id)) }, + ) + } + + composable { ComingSoon(title = "Search", body = "Lands in a later phase.") } + + composable { ComingSoon(title = "Settings", body = "Lands in Phase 11.") } + + // ── Detail / overlay screens ────────────────────────────────────── + + composable { backStackEntry -> + val route: AlbumDetail = backStackEntry.toRoute() + ComingSoon( + title = "Album", + body = "Album detail for ${route.id} — lands in a later 5.x slice.", + ) + } + + composable { backStackEntry -> + val route: ArtistDetail = backStackEntry.toRoute() + ComingSoon( + title = "Artist", + body = "Artist detail for ${route.id} — lands in a later 5.x slice.", + ) + } + + composable { backStackEntry -> + val route: PlaylistDetail = backStackEntry.toRoute() + ComingSoon( + title = "Playlist", + body = "Playlist detail for ${route.id} — lands in Phase 7.", + ) + } + + composable { ComingSoon(title = "Now playing", body = "Lands in Phase 6.4.") } + composable { ComingSoon(title = "Queue", body = "Lands in Phase 6.") } + composable { 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(), + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt new file mode 100644 index 00000000..811c789b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt @@ -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