From 48de720514f6ccf4e3c09da057157eb6e661b00c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 12:35:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20#618=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20search=20falls=20back=20to=20cached=20entities=20when=20offl?= =?UTF-8?q?ine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../minstrel/cache/db/dao/CachedAlbumDao.kt | 8 ++++ .../minstrel/cache/db/dao/CachedArtistDao.kt | 8 ++++ .../minstrel/cache/db/dao/CachedTrackDao.kt | 8 ++++ .../minstrel/search/data/SearchRepository.kt | 45 +++++++++++++++++-- .../minstrel/search/ui/SearchScreen.kt | 43 +++++++++++++----- .../minstrel/search/ui/SearchViewModel.kt | 16 +++++-- 6 files changed, 112 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt index 267b25d8..da68891b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt @@ -24,6 +24,14 @@ interface CachedAlbumDao { @Query("SELECT * FROM cached_albums WHERE id = :id") fun observeById(id: String): Flow + @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 + @Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit") suspend fun idsStaleBefore(before: Long, limit: Int): List diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt index 450053b1..d5127a8b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt @@ -18,6 +18,14 @@ interface CachedArtistDao { @Query("SELECT * FROM cached_artists WHERE id = :id") fun observeById(id: String): Flow + @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 + @Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit") suspend fun idsStaleBefore(before: Long, limit: Int): List diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt index b07095a6..a65fa09e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt @@ -24,6 +24,14 @@ interface CachedTrackDao { @Query("SELECT * FROM cached_tracks WHERE id IN (:ids)") suspend fun getByIds(ids: List): List + @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 + @Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit") suspend fun idsStaleBefore(before: Long, limit: Int): List diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt index c310a496..5c6b5527 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt index 4cb90dbb..6e3050c9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt @@ -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) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt index 86860f09..830fb501 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt @@ -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, ) {