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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user