feat(android): #618 Phase 2 — search falls back to cached entities when offline
android / Build + lint + test (push) Has been cancelled
android / Build + lint + test (push) Has been cancelled
When ServerHealthController reports Offline or ServerDown, SearchRepository runs Room LIKE queries against cached_artists / cached_albums / cached_tracks instead of hitting /api/search. The screen draws a one-line hint above the results so the user can tell server matches from on-device-only matches. Adds searchByName / searchByTitle DAO methods; LOCAL_SEARCH_LIMIT=20 matches the server's default page size.
This commit is contained in:
+8
@@ -24,6 +24,14 @@ interface CachedAlbumDao {
|
||||
@Query("SELECT * FROM cached_albums WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedAlbumEntity?>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_albums " +
|
||||
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY sortTitle COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByTitle(q: String, limit: Int): List<CachedAlbumEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
+8
@@ -18,6 +18,14 @@ interface CachedArtistDao {
|
||||
@Query("SELECT * FROM cached_artists WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedArtistEntity?>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_artists " +
|
||||
"WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY sortName COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByName(q: String, limit: Int): List<CachedArtistEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
+8
@@ -24,6 +24,14 @@ interface CachedTrackDao {
|
||||
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
|
||||
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_tracks " +
|
||||
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY title COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByTitle(q: String, limit: Int): List<CachedTrackEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.fabledsword.minstrel.search.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.SearchApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||
import retrofit2.Retrofit
|
||||
@@ -8,17 +13,34 @@ import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val LOCAL_SEARCH_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in
|
||||
* the ViewModel, not here, so the repository stays trivial.
|
||||
* Cache-first when offline. When [ServerHealthController] is Healthy the
|
||||
* repository hits `/api/search` and returns the server's three-facet
|
||||
* paged response. When health is Offline or ServerDown it falls back to
|
||||
* Room LIKE queries against `cached_*` so the user can still find
|
||||
* something to play from what's already on the device; the outcome's
|
||||
* [SearchOutcome.localOnly] flag lets the screen draw an "offline
|
||||
* results" hint instead of pretending the server answered.
|
||||
*/
|
||||
@Singleton
|
||||
class SearchRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val serverHealth: ServerHealthController,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val albumDao: CachedAlbumDao,
|
||||
private val artistDao: CachedArtistDao,
|
||||
) {
|
||||
private val api: SearchApi = retrofit.create()
|
||||
|
||||
suspend fun search(query: String): SearchResponseRef {
|
||||
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
|
||||
ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false)
|
||||
ServerHealth.Offline, ServerHealth.ServerDown ->
|
||||
SearchOutcome(localSearch(query), localOnly = true)
|
||||
}
|
||||
|
||||
private suspend fun remoteSearch(query: String): SearchResponseRef {
|
||||
val wire = api.search(query)
|
||||
return SearchResponseRef(
|
||||
artists = wire.artists.items.map { it.toDomain() },
|
||||
@@ -26,4 +48,21 @@ class SearchRepository @Inject constructor(
|
||||
tracks = wire.tracks.items.map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun localSearch(query: String): SearchResponseRef = SearchResponseRef(
|
||||
artists = artistDao.searchByName(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
albums = albumDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
tracks = trackDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the search response with the signal of whether the result came
|
||||
* from the server or from the local cached entities. The screen renders
|
||||
* the same SearchResponseRef either way; the flag drives the offline
|
||||
* banner copy.
|
||||
*/
|
||||
data class SearchOutcome(
|
||||
val response: SearchResponseRef,
|
||||
val localOnly: Boolean,
|
||||
)
|
||||
|
||||
@@ -158,17 +158,28 @@ private fun ResultsPane(
|
||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
||||
is SearchResultsState.Loaded -> {
|
||||
if (state.response.isEmpty) {
|
||||
CenteredHint("No matches for that query.")
|
||||
} else {
|
||||
ResultsList(
|
||||
response = state.response,
|
||||
playingTrackId = playingTrackId,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onTrackPlay = onTrackPlay,
|
||||
onNavigateToAlbum = onNavigateToAlbum,
|
||||
onNavigateToArtist = onNavigateToArtist,
|
||||
CenteredHint(
|
||||
if (state.localOnly) {
|
||||
"No matches in your on-device library."
|
||||
} else {
|
||||
"No matches for that query."
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.localOnly) {
|
||||
OfflineResultsHint()
|
||||
}
|
||||
ResultsList(
|
||||
response = state.response,
|
||||
playingTrackId = playingTrackId,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onTrackPlay = onTrackPlay,
|
||||
onNavigateToAlbum = onNavigateToAlbum,
|
||||
onNavigateToArtist = onNavigateToArtist,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,6 +319,18 @@ private fun SectionHeader(label: String, count: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineResultsHint() {
|
||||
Text(
|
||||
text = "Showing on-device matches only — the server is unreachable.",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredHint(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -26,7 +26,10 @@ sealed interface SearchResultsState {
|
||||
/** Empty query — screen shows "type to search" hint. */
|
||||
data object Idle : SearchResultsState
|
||||
data object Loading : SearchResultsState
|
||||
data class Loaded(val response: SearchResponseRef) : SearchResultsState
|
||||
data class Loaded(
|
||||
val response: SearchResponseRef,
|
||||
val localOnly: Boolean = false,
|
||||
) : SearchResultsState
|
||||
data class Error(val message: String) : SearchResultsState
|
||||
}
|
||||
|
||||
@@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor(
|
||||
private suspend fun runSearch(q: String) {
|
||||
internal.update { it.copy(results = SearchResultsState.Loading) }
|
||||
try {
|
||||
val response = repository.search(q)
|
||||
internal.update { it.copy(results = SearchResultsState.Loaded(response)) }
|
||||
val outcome = repository.search(q)
|
||||
internal.update {
|
||||
it.copy(
|
||||
results = SearchResultsState.Loaded(
|
||||
response = outcome.response,
|
||||
localOnly = outcome.localOnly,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user