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:
2026-05-24 20:18:48 -04:00
parent 6c8694db2e
commit 9ab862a2e1
6 changed files with 561 additions and 2 deletions
@@ -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> {
@@ -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,
)
}
}