feat(android): real Home screen with /api/home/index sections (sub-task 3)
Replaces the ComingSoon stub with the actual Home matching the Flutter
client's `home_screen.dart` shape — vertical Column of named horizontal
sections, each row a labeled `HorizontalScrollRow`:
Playlists (placeholder header — Phase 7)
Recently added → AlbumCards
Rediscover → AlbumCards (and/or ArtistCards)
Most played → CompactTrackTile
Last played → ArtistCards
Wire path: `GET /api/home/index` → `HomeRepository.refreshIndex()`
upserts five sections into `cached_home_index`. Per-section Flows
observe that table and hydrate each ID against the existing
album/artist/track DAOs — rows that haven't been pulled into Room yet
are silently dropped from emission (the per-tile hydration queue from
Phase 7+ will fill those gaps).
New files:
- models/wire/HomeIndexWire.kt — @Serializable wire shape
- api/endpoints/HomeApi.kt — Retrofit `GET /api/home/index`
- home/data/HomeRepository.kt — observe/refresh + section constants
- home/ui/HomeScreen.kt — composable + HomeViewModel + HomeUiState
+ inline CompactTrackTile (proper CompactTrackCard lands in Phase 7
alongside player play-by-ID and per-track cover hydration)
- shared/widgets/HorizontalScrollRow.kt — labeled LazyRow wrapper
that takes a LazyListScope block (keeps row items lazy)
Modified:
- nav/MinstrelNavGraph.kt — startDestination = Home (was Library;
matches Flutter's `redirect '/' → '/home'`); Home composable now
renders the real `HomeScreen(navController)` inside `ShellScaffold`
CompactTrackTile uses a Lucide.Music placeholder for the cover because
TrackRef doesn't carry coverUrl — matching the Flutter tile-provider
hydration is a Phase-7 follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.HomeIndexWire
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* Retrofit interface for the Home discovery endpoint. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
|
||||
* `/api/home/index` variant. The Flutter port has a heavier
|
||||
* `/api/home` (full embedded payload) too; we don't use it because
|
||||
* the per-item hydration path (sync controller → Room → Flow) is
|
||||
* the only one the native client needs.
|
||||
*/
|
||||
interface HomeApi {
|
||||
@GET("api/home/index")
|
||||
suspend fun getHomeIndex(): HomeIndexWire
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.fabledsword.minstrel.home.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.HomeApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Cache-first reads for the Home screen. Per-section ID lists live in
|
||||
* `cached_home_index`; entity rows live in `cached_albums` /
|
||||
* `cached_artists` / `cached_tracks` and are observed via the
|
||||
* Library DAOs.
|
||||
*
|
||||
* Sections are exposed as `Flow<List<DomainRef>>`. Each emission joins
|
||||
* the ID list against the per-entity DAO; rows that haven't been
|
||||
* hydrated into Room yet are silently dropped from the emitted list
|
||||
* (Phase 7+ adds a per-tile hydration queue that fills the gaps).
|
||||
*
|
||||
* `refreshIndex()` pulls `GET /api/home/index` and replaces each
|
||||
* section in-place (delete-then-insert), so the Flow emissions
|
||||
* propagate automatically. Errors surface to the caller.
|
||||
*/
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(
|
||||
private val homeIndexDao: CachedHomeIndexDao,
|
||||
private val albumDao: CachedAlbumDao,
|
||||
private val artistDao: CachedArtistDao,
|
||||
private val trackDao: CachedTrackDao,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: HomeApi = retrofit.create()
|
||||
|
||||
fun observeRecentlyAddedAlbums(): Flow<List<AlbumRef>> =
|
||||
homeIndexDao.observeBySection(SECTION_RECENTLY_ADDED_ALBUMS).map { hydrateAlbums(it) }
|
||||
|
||||
fun observeRediscoverAlbums(): Flow<List<AlbumRef>> =
|
||||
homeIndexDao.observeBySection(SECTION_REDISCOVER_ALBUMS).map { hydrateAlbums(it) }
|
||||
|
||||
fun observeRediscoverArtists(): Flow<List<ArtistRef>> =
|
||||
homeIndexDao.observeBySection(SECTION_REDISCOVER_ARTISTS).map { hydrateArtists(it) }
|
||||
|
||||
fun observeMostPlayedTracks(): Flow<List<TrackRef>> =
|
||||
homeIndexDao.observeBySection(SECTION_MOST_PLAYED_TRACKS).map { hydrateTracks(it) }
|
||||
|
||||
fun observeLastPlayedArtists(): Flow<List<ArtistRef>> =
|
||||
homeIndexDao.observeBySection(SECTION_LAST_PLAYED_ARTISTS).map { hydrateArtists(it) }
|
||||
|
||||
/**
|
||||
* Pulls /api/home/index and replaces each section in cached_home_index.
|
||||
* Caller (ViewModel) is responsible for catching network errors.
|
||||
*/
|
||||
suspend fun refreshIndex() {
|
||||
val wire = api.getHomeIndex()
|
||||
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
|
||||
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
|
||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
||||
}
|
||||
|
||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
||||
homeIndexDao.deleteBySection(section)
|
||||
if (ids.isEmpty()) return
|
||||
homeIndexDao.upsertAll(
|
||||
ids.mapIndexed { index, id ->
|
||||
CachedHomeIndexEntity(
|
||||
section = section,
|
||||
position = index,
|
||||
entityType = entityType,
|
||||
entityId = id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun hydrateAlbums(rows: List<CachedHomeIndexEntity>): List<AlbumRef> =
|
||||
rows.mapNotNull { albumDao.getById(it.entityId)?.toDomain() }
|
||||
|
||||
private suspend fun hydrateArtists(rows: List<CachedHomeIndexEntity>): List<ArtistRef> =
|
||||
rows.mapNotNull { artistDao.getById(it.entityId)?.toDomain() }
|
||||
|
||||
private suspend fun hydrateTracks(rows: List<CachedHomeIndexEntity>): List<TrackRef> =
|
||||
rows.mapNotNull { trackDao.getById(it.entityId)?.toDomain() }
|
||||
|
||||
companion object {
|
||||
const val SECTION_RECENTLY_ADDED_ALBUMS = "recently_added_albums"
|
||||
const val SECTION_REDISCOVER_ALBUMS = "rediscover_albums"
|
||||
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
|
||||
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.fabledsword.minstrel.home.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Home
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
data class HomeSections(
|
||||
val recentlyAddedAlbums: List<AlbumRef> = emptyList(),
|
||||
val rediscoverAlbums: List<AlbumRef> = emptyList(),
|
||||
val rediscoverArtists: List<ArtistRef> = emptyList(),
|
||||
val mostPlayedTracks: List<TrackRef> = emptyList(),
|
||||
val lastPlayedArtists: List<ArtistRef> = emptyList(),
|
||||
) {
|
||||
val isAllEmpty: Boolean
|
||||
get() = recentlyAddedAlbums.isEmpty() &&
|
||||
rediscoverAlbums.isEmpty() &&
|
||||
rediscoverArtists.isEmpty() &&
|
||||
mostPlayedTracks.isEmpty() &&
|
||||
lastPlayedArtists.isEmpty()
|
||||
}
|
||||
|
||||
sealed interface HomeUiState {
|
||||
data object Loading : HomeUiState
|
||||
data object Empty : HomeUiState
|
||||
data class Success(val sections: HomeSections) : HomeUiState
|
||||
data class Error(val message: String) : HomeUiState
|
||||
}
|
||||
|
||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val repository: HomeRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
init {
|
||||
// Fire-and-forget initial pull; the screen renders cached sections
|
||||
// immediately and refreshes as the IDs land. Errors map to the
|
||||
// Error state via the combine() chain's catch below.
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.refreshIndex() }
|
||||
}
|
||||
}
|
||||
|
||||
val uiState: StateFlow<HomeUiState> =
|
||||
combine(
|
||||
repository.observeRecentlyAddedAlbums(),
|
||||
repository.observeRediscoverAlbums(),
|
||||
repository.observeRediscoverArtists(),
|
||||
repository.observeMostPlayedTracks(),
|
||||
repository.observeLastPlayedArtists(),
|
||||
) { recentAlbums, rediscoverAlbums, rediscoverArtists, mostPlayedTracks, lastPlayedArtists ->
|
||||
val sections = HomeSections(
|
||||
recentlyAddedAlbums = recentAlbums,
|
||||
rediscoverAlbums = rediscoverAlbums,
|
||||
rediscoverArtists = rediscoverArtists,
|
||||
mostPlayedTracks = mostPlayedTracks,
|
||||
lastPlayedArtists = lastPlayedArtists,
|
||||
)
|
||||
if (sections.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(sections)
|
||||
}
|
||||
.catch { e -> emit(HomeUiState.Error(e.message ?: "Home load failed")) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = HomeUiState.Loading,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Screen ──────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Minstrel") },
|
||||
actions = {
|
||||
MainAppBarActions(
|
||||
navController = navController,
|
||||
currentRouteName = Home::class.qualifiedName,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||
when (val s = state) {
|
||||
HomeUiState.Loading -> LoadingCentered()
|
||||
HomeUiState.Empty -> EmptyState(
|
||||
title = "Welcome to Minstrel",
|
||||
body = "Nothing to show yet — scan a folder in your server settings, " +
|
||||
"then come back here for system playlists and recommendations.",
|
||||
)
|
||||
is HomeUiState.Error -> EmptyState(
|
||||
title = "Couldn't load home",
|
||||
body = s.message,
|
||||
)
|
||||
is HomeUiState.Success -> HomeSuccessContent(
|
||||
sections = s.sections,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingCentered() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSuccessContent(
|
||||
sections: HomeSections,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
// Playlists section is a Phase-7 deliverable; placeholder header
|
||||
// keeps the row visible so users see what's coming.
|
||||
item {
|
||||
EmptySectionHeader(
|
||||
title = "Playlists",
|
||||
body = "System playlists land with Phase 7.",
|
||||
)
|
||||
}
|
||||
if (sections.recentlyAddedAlbums.isNotEmpty()) {
|
||||
item {
|
||||
AlbumsRow("Recently added", sections.recentlyAddedAlbums, onAlbumClick)
|
||||
}
|
||||
}
|
||||
if (sections.rediscoverAlbums.isNotEmpty() || sections.rediscoverArtists.isNotEmpty()) {
|
||||
item {
|
||||
RediscoverBlock(
|
||||
albums = sections.rediscoverAlbums,
|
||||
artists = sections.rediscoverArtists,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sections.mostPlayedTracks.isNotEmpty()) {
|
||||
item { MostPlayedRow(sections.mostPlayedTracks, onAlbumClick) }
|
||||
}
|
||||
if (sections.lastPlayedArtists.isNotEmpty()) {
|
||||
item { ArtistsRow("Last played", sections.lastPlayedArtists, onArtistClick) }
|
||||
}
|
||||
item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rediscover may have both an albums sub-row and an artists sub-row. When
|
||||
* both are present the artist row gets an empty title so the section
|
||||
* reads as a single visual block — matches the Flutter behavior.
|
||||
*/
|
||||
@Composable
|
||||
private fun RediscoverBlock(
|
||||
albums: List<AlbumRef>,
|
||||
artists: List<ArtistRef>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
if (albums.isNotEmpty()) {
|
||||
AlbumsRow("Rediscover", albums, onAlbumClick)
|
||||
}
|
||||
if (artists.isNotEmpty()) {
|
||||
val title = if (albums.isEmpty()) "Rediscover" else ""
|
||||
ArtistsRow(title, artists, onArtistClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumsRow(
|
||||
title: String,
|
||||
albums: List<AlbumRef>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = title) {
|
||||
items(items = albums, key = { it.id }) { album ->
|
||||
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistsRow(
|
||||
title: String,
|
||||
artists: List<ArtistRef>,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = title) {
|
||||
items(items = artists, key = { it.id }) { artist ->
|
||||
ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Most-played section. Renders compact track tiles (cover + title + artist).
|
||||
* Tapping a tile navigates to that track's album for now; the proper
|
||||
* "play the section as a queue" wiring lands once the player API
|
||||
* exposes a play-list-from-IDs entry point in Phase 7.
|
||||
*/
|
||||
@Composable
|
||||
private fun MostPlayedRow(
|
||||
tracks: List<TrackRef>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Most played") {
|
||||
items(items = tracks, key = { it.id }) { track ->
|
||||
CompactTrackTile(track = track, onClick = { onAlbumClick(track.albumId) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactTrackTile(
|
||||
track: TrackRef,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(140.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// No coverUrl on TrackRef — show the music glyph placeholder.
|
||||
// The Flutter port snipes the album's cover via tile-provider
|
||||
// hydration; matching that here is Phase 7+ work.
|
||||
Icon(
|
||||
imageVector = Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = track.artistName.ifEmpty { " " },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder header used for sections whose data layer hasn't landed yet
|
||||
* (currently just Playlists). Smaller than EmptyState — the surrounding
|
||||
* sections still have content, so this is more of a soft "TBD" marker.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptySectionHeader(title: String, body: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape of `GET /api/home/index` — five flat slices of entity-ID
|
||||
* strings, one per Home section. Mirrors
|
||||
* `flutter_client/lib/models/home_index.dart` (and the server's
|
||||
* `internal/api/types.go HomeIndexPayload`).
|
||||
*
|
||||
* Section name implies entity type; no per-entry type tag is needed:
|
||||
* - recentlyAddedAlbums → album
|
||||
* - rediscoverAlbums → album
|
||||
* - rediscoverArtists → artist
|
||||
* - mostPlayedTracks → track
|
||||
* - lastPlayedArtists → artist
|
||||
*
|
||||
* All slices default to empty so missing keys don't throw on decode.
|
||||
*/
|
||||
@Serializable
|
||||
data class HomeIndexWire(
|
||||
@SerialName("recently_added_albums") val recentlyAddedAlbums: List<String> = emptyList(),
|
||||
@SerialName("rediscover_albums") val rediscoverAlbums: List<String> = emptyList(),
|
||||
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
||||
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
||||
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
||||
)
|
||||
@@ -10,6 +10,7 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.fabledsword.minstrel.home.ui.HomeScreen
|
||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
@@ -31,7 +32,7 @@ fun MinstrelNavGraph(
|
||||
val expandPlayer = { navController.navigate(NowPlaying) }
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Library,
|
||||
startDestination = Home,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
inShellTopLevel(navController, expandPlayer)
|
||||
@@ -46,7 +47,7 @@ private fun NavGraphBuilder.inShellTopLevel(
|
||||
) {
|
||||
composable<Home> {
|
||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||
ComingSoon("Home", "Lands in the next design-drift sub-task.")
|
||||
HomeScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
composable<Library> {
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Labeled horizontal-scroll section used throughout the Home screen.
|
||||
* Mirrors `flutter_client/lib/library/widgets/horizontal_scroll_row.dart`
|
||||
* — a Fraunces section title at 16dp gutter, then a horizontal LazyRow
|
||||
* with the same gutter and 8dp inter-item spacing.
|
||||
*
|
||||
* Pass empty `title` to render a continuation row directly under a
|
||||
* previously-titled one (used by Rediscover when it has both album and
|
||||
* artist sub-rows, and by chunked sections like Recently-added).
|
||||
*
|
||||
* The `content` lambda receives a `LazyListScope` so callers use
|
||||
* `items(list, key = ...) { ... }` — preserves lazy item rendering.
|
||||
*/
|
||||
@Composable
|
||||
fun HorizontalScrollRow(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
if (title.isNotEmpty()) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user