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 04444c67..d9924682 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -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()) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index b617f465..593752ef 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -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)) + }, + ) + } + } } } 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 index b88ca2c0..c1d0a917 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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 { 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 { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Home", "Lands in the next design-drift sub-task.") + } } - composable { ComingSoon(title = "Search", body = "Lands in a later phase.") } + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + LibraryScreen(navController = navController) + } + } - composable { ComingSoon(title = "Settings", body = "Lands in Phase 11.") } + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Search", "Lands in a later phase.") + } + } - // ── Detail / overlay screens ────────────────────────────────────── + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Discover", "Lands in Phase 10 (Lidarr requests).") + } + } + + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Playlists", "Lands in Phase 7.") + } + } + + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Settings", "Lands in Phase 11.") + } + } + + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Admin", "Admin landing — lands in a later phase.") + } + } + + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Requests", "Your Lidarr requests — Phase 10.") + } + } + + // ── In-shell detail screens (push-on-top) ───────────────────── composable { 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 { 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 { 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 { NowPlayingScreen() } - composable { ComingSoon(title = "Queue", body = "Lands in Phase 6.") } - composable { ComingSoon(title = "Sign in", body = "Lands in a later phase.") } + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Admin · Requests", "Lands with admin slice.") + } + } + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Admin · Quarantine", "Lands with admin slice.") + } + } + composable { + ShellScaffold(onExpandPlayer = expandPlayer) { + ComingSoon("Admin · Users", "Lands with admin slice.") + } + } + + // ── Outside-shell full-screen routes ────────────────────────── + + composable( + // 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 { ComingSoon("Queue", "Lands in a later 6.x slice.") } + + composable { + ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.") + } + composable { + 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()) } 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 index 811c789b..24509fe6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt new file mode 100644 index 00000000..a884b808 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/MainAppBarActions.kt @@ -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) }, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt new file mode 100644 index 00000000..87391fa0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt @@ -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) + } +}