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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -36,14 +40,17 @@ import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
/** /**
* Library tab. Currently a transitional shape — Artists row + Albums * Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
* row — until sub-task 4 of the design-drift correction replaces the * Hidden) matching `flutter_client/lib/library/library_screen.dart`.
* body with the proper 5-tab TabBar (Artists / Albums / History /
* Liked / Hidden) matching Flutter.
* *
* This commit's sub-task wires the AppBar + MainAppBarActions row so * Artists + Albums are wired against the existing LibraryViewModel
* the screen is reachable via the icon row and detail-screen taps * (cache-first reads of cached_artists / cached_albums). The other
* route through the navController. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -51,99 +58,148 @@ fun LibraryScreen(
navController: NavHostController, navController: NavHostController,
viewModel: LibraryViewModel = hiltViewModel(), viewModel: LibraryViewModel = hiltViewModel(),
) { ) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = LIBRARY_TABS
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
TopAppBar( Column {
title = { Text("Library") }, TopAppBar(
actions = { title = { Text("Library") },
MainAppBarActions( actions = {
navController = navController, MainAppBarActions(
currentRouteName = Library::class.qualifiedName, 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 -> ) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize().padding(inner)) { Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) { when (selectedTab) {
LibraryUiState.Loading -> LoadingCentered() 0 -> ArtistsTab(viewModel = viewModel, navController = navController)
LibraryUiState.Empty -> EmptyState( 1 -> AlbumsTab(viewModel = viewModel, navController = navController)
title = "Nothing here yet", 2 -> ComingSoonTab(
body = "Scan a folder in your server settings to populate the library.", title = "History",
body = "Recent plays will appear here once /api/me/history " +
"wiring lands in Phase 9.",
) )
is LibraryUiState.Error -> ErrorRetry( 3 -> ComingSoonTab(
message = s.message, title = "Liked",
onRetry = { /* Wired in Phase 12 sync controller. */ }, body = "Liked artists, albums, and tracks will appear here " +
"once the like-write path lands in Phase 8.",
) )
is LibraryUiState.Success -> LibrarySuccessContent( 4 -> ComingSoonTab(
state = s, title = "Hidden",
onArtistClick = { artist -> body = "Tracks you've flagged as bad rips, wrong tags, or " +
navController.navigate(ArtistDetail(artist.id)) "duplicates will appear here in Phase 10.",
},
onAlbumClick = { album ->
navController.navigate(AlbumDetail(album.id))
},
) )
} }
} }
} }
} }
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 @Composable
private fun LoadingCentered() { private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) 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,
)
}