From 6062233c63e0dc9ed9da4bf33b49fcd29ec9d227 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 20:40:41 -0400 Subject: [PATCH] feat(android): Library 5-tab restructure (sub-task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the transitional two-LazyRow shape with the proper 5-tab Library matching `flutter_client/lib/library/library_screen.dart`: [Artists] [Albums] [History] [Liked] [Hidden] - Artists / Albums — real adaptive 3+-up LazyVerticalGrid backed by the existing LibraryViewModel (cache-first reads of cached_artists / cached_albums). GridCells.Adaptive sizes from card width (ArtistCard 144dp, AlbumCard 176dp), so phones get 3 cols and tablets pack more. - History / Liked / Hidden — EmptyState placeholders with phase pointers (Phase 9 / 8 / 10 respectively). The data-layer plumbing for each lands with its umbrella phase, not as a Home/Library polish patch. TabBar is `PrimaryScrollableTabRow` (Material3) so on narrower screens the row scrolls horizontally without truncating labels — same behavior as Flutter's `TabBar(isScrollable: true)`. The Library tab is now reachable both as the in-shell route AND through MainAppBarActions; the icon row suppresses the Library icon when this screen is current (per `currentRouteName = Library::class.qualifiedName`). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/library/ui/LibraryScreen.kt | 232 +++++++++++------- 1 file changed, 144 insertions(+), 88 deletions(-) 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 593752ef..c6f758e2 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 @@ -2,22 +2,26 @@ package com.fabledsword.minstrel.library.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -36,14 +40,17 @@ import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.MainAppBarActions /** - * 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. + * Library tab. Five-tab TabBar (Artists / Albums / History / Liked / + * Hidden) matching `flutter_client/lib/library/library_screen.dart`. * - * 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. + * Artists + Albums are wired against the existing LibraryViewModel + * (cache-first reads of cached_artists / cached_albums). The other + * three tabs render placeholder EmptyStates pointing at the upcoming + * data-layer phases: + * - History → Phase 9 (server-side /api/me/history wiring) + * - Liked → Phase 8 (likes write path + cached_likes hydration) + * - Hidden → Phase 10 (quarantine flag/unflag UI lives in Admin slice + * but the user-side "my hidden tracks" view comes here) */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -51,99 +58,148 @@ fun LibraryScreen( navController: NavHostController, viewModel: LibraryViewModel = hiltViewModel(), ) { + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = LIBRARY_TABS + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - TopAppBar( - title = { Text("Library") }, - actions = { - MainAppBarActions( - navController = navController, - currentRouteName = Library::class.qualifiedName, - ) - }, - ) + Column { + TopAppBar( + title = { Text("Library") }, + actions = { + MainAppBarActions( + navController = navController, + currentRouteName = Library::class.qualifiedName, + ) + }, + ) + PrimaryScrollableTabRow( + selectedTabIndex = selectedTab, + edgePadding = 16.dp, + ) { + tabs.forEachIndexed { index, label -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(label) }, + ) + } + } + } }, ) { 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.", + when (selectedTab) { + 0 -> ArtistsTab(viewModel = viewModel, navController = navController) + 1 -> AlbumsTab(viewModel = viewModel, navController = navController) + 2 -> ComingSoonTab( + title = "History", + body = "Recent plays will appear here once /api/me/history " + + "wiring lands in Phase 9.", ) - is LibraryUiState.Error -> ErrorRetry( - message = s.message, - onRetry = { /* Wired in Phase 12 sync controller. */ }, + 3 -> ComingSoonTab( + title = "Liked", + body = "Liked artists, albums, and tracks will appear here " + + "once the like-write path lands in Phase 8.", ) - is LibraryUiState.Success -> LibrarySuccessContent( - state = s, - onArtistClick = { artist -> - navController.navigate(ArtistDetail(artist.id)) - }, - onAlbumClick = { album -> - navController.navigate(AlbumDetail(album.id)) - }, + 4 -> ComingSoonTab( + title = "Hidden", + body = "Tracks you've flagged as bad rips, wrong tags, or " + + "duplicates will appear here in Phase 10.", ) } } } } +private val LIBRARY_TABS = listOf("Artists", "Albums", "History", "Liked", "Hidden") + +@Composable +private fun ArtistsTab( + viewModel: LibraryViewModel, + navController: NavHostController, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (val s = state) { + LibraryUiState.Loading -> LoadingCentered() + LibraryUiState.Empty -> EmptyState( + title = "No artists yet", + body = "Scan a folder in your server settings to populate the library.", + ) + is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {}) + is LibraryUiState.Success -> ArtistsGrid( + artists = s.artists, + onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, + ) + } +} + +@Composable +private fun AlbumsTab( + viewModel: LibraryViewModel, + navController: NavHostController, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (val s = state) { + LibraryUiState.Loading -> LoadingCentered() + LibraryUiState.Empty -> EmptyState( + title = "No albums yet", + body = "Scan a folder in your server settings to populate the library.", + ) + is LibraryUiState.Error -> ErrorRetry(message = s.message, onRetry = {}) + is LibraryUiState.Success -> AlbumsGrid( + albums = s.albums, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + ) + } +} + +@Composable +private fun ArtistsGrid( + artists: List, + onArtistClick: (String) -> Unit, +) { + LazyVerticalGrid( + // ArtistCard is 144dp wide; let the grid pack as many per row as + // the device allows (3 on a typical phone, 5+ on tablets). + columns = GridCells.Adaptive(minSize = 144.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = artists, key = { it.id }) { artist -> + ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) }) + } + } +} + +@Composable +private fun AlbumsGrid( + albums: List, + onAlbumClick: (String) -> Unit, +) { + LazyVerticalGrid( + // AlbumCard is 176dp wide (144dp cover + 8dp gutters). + columns = GridCells.Adaptive(minSize = 176.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = albums, key = { it.id }) { album -> + AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) + } + } +} + +@Composable +private fun ComingSoonTab(title: String, body: String) { + EmptyState(title = title, body = body, modifier = Modifier.fillMaxSize()) +} + @Composable private fun LoadingCentered() { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } } - -@Composable -private fun LibrarySuccessContent( - state: LibraryUiState.Success, - onArtistClick: (ArtistRef) -> Unit, - onAlbumClick: (AlbumRef) -> Unit, -) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 16.dp), - ) { - if (state.artists.isNotEmpty()) { - item { SectionHeader(text = "Artists") } - item { - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(items = state.artists, key = { it.id }) { artist -> - ArtistCard(artist = artist, onClick = { onArtistClick(artist) }) - } - } - } - item { Spacer(Modifier.height(24.dp)) } - } - if (state.albums.isNotEmpty()) { - item { SectionHeader(text = "Albums") } - item { - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(items = state.albums, key = { it.id }) { album -> - AlbumCard(album = album, onClick = { onAlbumClick(album) }) - } - } - } - } - } -} - -@Composable -private fun SectionHeader(text: String) { - Text( - text = text, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - ) -}