feat(android): Library 5-tab restructure (sub-task 4)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 20:40:41 -04:00
parent 3123e4350e
commit 6062233c63
@@ -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<ArtistRef>,
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<AlbumRef>,
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,
)
}