M5–M7 sweep + offline cache + in-app updates + DRY pass #35
@@ -64,11 +64,12 @@ fun AlbumDetailWire.toAlbumEntity(): CachedAlbumEntity =
|
|||||||
// source of truth for cache-first reads; mapping to a domain type here
|
// source of truth for cache-first reads; mapping to a domain type here
|
||||||
// keeps Room types out of the UI layer.
|
// keeps Room types out of the UI layer.
|
||||||
|
|
||||||
fun CachedArtistEntity.toDomain(): ArtistRef =
|
fun CachedArtistEntity.toDomain(coverAlbumId: String = ""): ArtistRef =
|
||||||
ArtistRef(
|
ArtistRef(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
sortName = sortName,
|
sortName = sortName,
|
||||||
|
coverAlbumId = coverAlbumId,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef =
|
fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef =
|
||||||
|
|||||||
+11
-1
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef
|
|||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
@@ -43,8 +44,17 @@ class LibraryRepository @Inject constructor(
|
|||||||
|
|
||||||
// ---- Reads (Flow, cache-first; the cache is the source of truth) ----
|
// ---- Reads (Flow, cache-first; the cache is the source of truth) ----
|
||||||
|
|
||||||
|
// Joins each artist to its first cached album (by sort title) so the
|
||||||
|
// tile cover derives from /api/albums/{id}/cover — cached_artists
|
||||||
|
// stores no cover and sync carries none. Mirrors Flutter's
|
||||||
|
// artistTileProvider JOIN.
|
||||||
fun observeArtists(): Flow<List<ArtistRef>> =
|
fun observeArtists(): Flow<List<ArtistRef>> =
|
||||||
artistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums ->
|
||||||
|
val firstAlbumByArtist = albums
|
||||||
|
.groupBy { it.artistId }
|
||||||
|
.mapValues { (_, list) -> list.minByOrNull { it.sortTitle }?.id ?: "" }
|
||||||
|
artists.map { it.toDomain(coverAlbumId = firstAlbumByArtist[it.id] ?: "") }
|
||||||
|
}
|
||||||
|
|
||||||
fun observeAlbums(): Flow<List<AlbumRef>> =
|
fun observeAlbums(): Flow<List<AlbumRef>> =
|
||||||
albumDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
albumDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||||
|
|||||||
@@ -19,15 +19,14 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One artist tile. Circular cover; name centered beneath. Tap fires
|
* One artist tile. Circular cover; name centered beneath. Tap fires
|
||||||
@@ -61,20 +60,17 @@ fun ArtistCard(
|
|||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (artist.coverUrl.isEmpty()) {
|
ServerImage(
|
||||||
|
url = artist.displayCoverUrl,
|
||||||
|
contentDescription = artist.name,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Lucide.User,
|
imageVector = Lucide.User,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
AsyncImage(
|
|
||||||
model = artist.coverUrl,
|
|
||||||
contentDescription = artist.name,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -70,9 +71,12 @@ class MetadataProvider @Inject constructor(
|
|||||||
.onEach { row -> if (row == null) fetchAlbum(id) }
|
.onEach { row -> if (row == null) fetchAlbum(id) }
|
||||||
|
|
||||||
fun observeArtist(id: String): Flow<ArtistRef?> =
|
fun observeArtist(id: String): Flow<ArtistRef?> =
|
||||||
artistDao.observeById(id)
|
combine(
|
||||||
.map { it?.toDomain() }
|
artistDao.observeById(id),
|
||||||
.onEach { row -> if (row == null) fetchArtist(id) }
|
albumDao.observeByArtist(id),
|
||||||
|
) { artist, albums ->
|
||||||
|
artist?.toDomain(coverAlbumId = albums.minByOrNull { it.sortTitle }?.id ?: "")
|
||||||
|
}.onEach { row -> if (row == null) fetchArtist(id) }
|
||||||
|
|
||||||
fun observeTrack(id: String): Flow<TrackRef?> =
|
fun observeTrack(id: String): Flow<TrackRef?> =
|
||||||
trackDao.observeById(id)
|
trackDao.observeById(id)
|
||||||
|
|||||||
@@ -14,4 +14,23 @@ data class ArtistRef(
|
|||||||
val sortName: String = "",
|
val sortName: String = "",
|
||||||
val albumCount: Int = 0,
|
val albumCount: Int = 0,
|
||||||
val coverUrl: String = "",
|
val coverUrl: String = "",
|
||||||
)
|
val coverAlbumId: String = "",
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Cover URL the UI should render, or null when the artist has no
|
||||||
|
* representative cover. Prefers the server-provided `coverUrl`
|
||||||
|
* (relative, e.g. `/api/artists` ships it); otherwise derives the
|
||||||
|
* first cached album's `/api/albums/{id}/cover` via [coverAlbumId].
|
||||||
|
*
|
||||||
|
* The fallback matters because cached_artists stores no cover and
|
||||||
|
* the sync payload carries none — so for cache-sourced artist tiles
|
||||||
|
* we mirror Flutter's `artistTileProvider`, which JOINs the first
|
||||||
|
* album to supply the cover. Resolve through `ServerImage`.
|
||||||
|
*/
|
||||||
|
val displayCoverUrl: String?
|
||||||
|
get() = when {
|
||||||
|
coverUrl.isNotEmpty() -> coverUrl
|
||||||
|
coverAlbumId.isNotEmpty() -> "/api/albums/$coverAlbumId/cover"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
@@ -58,6 +58,9 @@ class LibraryRepositoryTest {
|
|||||||
CachedArtistEntity(id = "a1", name = "Boards of Canada", sortName = "Boards of Canada"),
|
CachedArtistEntity(id = "a1", name = "Boards of Canada", sortName = "Boards of Canada"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// observeArtists now combines with albums (to derive the tile
|
||||||
|
// cover from the first album); stub it so combine can emit.
|
||||||
|
every { albumDao.observeAll() } returns flowOf(emptyList())
|
||||||
val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit)
|
val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit)
|
||||||
|
|
||||||
repo.observeArtists().test {
|
repo.observeArtists().test {
|
||||||
|
|||||||
Reference in New Issue
Block a user