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