From d96f835b5b40ca1527307c6af2de84e93ac3ce1a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 18:23:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2013=20=E2=80=94=20Searc?= =?UTF-8?q?h=20screen=20+=20/api/search=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Search-route ComingSoon stub with the real three-facet search. Mirrors `flutter_client/lib/search/search_screen.dart`. New: - models/wire/SearchWire.kt — three concrete PagedXxxWire types (Artists / Albums / Tracks) wrapping the server's Page[T] envelope, plus SearchResponseWire. Going non-generic on the paged wrapper is fine — three call sites and the explicit types read clearer than a generic with manual type-arg gymnastics at decode time. - models/SearchResponseRef.kt — domain envelope with `isEmpty` for the screen's "no matches" branch. - api/endpoints/SearchApi.kt — Retrofit GET /api/search with q + limit + offset. - search/data/SearchRepository.kt — trivial wire→domain mapper; debouncing intentionally lives in the VM. - search/ui/SearchViewModel.kt — 250ms debounce on the query Flow via debounce() + distinctUntilChanged(). Empty query collapses to Idle without firing a request. `playTrack(track)` queues a single track via PlayerController for the same single-row-play pattern as HistoryTab. - search/ui/SearchScreen.kt — auto-focus the field on entry (the user typed Search to start typing), inline X button to clear. Results render as three sections (Artists / Albums / Tracks), each section omitted when its list is empty. Artist taps → ArtistDetail, album taps → AlbumDetail, track taps → play. Modified: - nav/MinstrelNavGraph.kt — Search route renders the real screen. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/api/endpoints/SearchApi.kt | 24 ++ .../minstrel/models/SearchResponseRef.kt | 17 ++ .../minstrel/models/wire/SearchWire.kt | 48 ++++ .../minstrel/nav/MinstrelNavGraph.kt | 3 +- .../minstrel/search/data/SearchRepository.kt | 29 ++ .../minstrel/search/ui/SearchScreen.kt | 269 ++++++++++++++++++ .../minstrel/search/ui/SearchViewModel.kt | 97 +++++++ 7 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/SearchResponseRef.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/SearchWire.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt new file mode 100644 index 00000000..70fd52fd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt @@ -0,0 +1,24 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.SearchResponseWire +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit interface for `GET /api/search`. Mirrors + * `flutter_client/lib/api/endpoints/search.dart`. Server returns 400 + * on empty/whitespace-only `q` — the caller is responsible for + * guarding. + */ +interface SearchApi { + @GET("api/search") + suspend fun search( + @Query("q") query: String, + @Query("limit") limit: Int = DEFAULT_LIMIT, + @Query("offset") offset: Int = 0, + ): SearchResponseWire + + companion object { + const val DEFAULT_LIMIT: Int = 20 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/SearchResponseRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/SearchResponseRef.kt new file mode 100644 index 00000000..57409613 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/SearchResponseRef.kt @@ -0,0 +1,17 @@ +package com.fabledsword.minstrel.models + +/** + * Three-facet search result. Each list is the first page returned by + * `/api/search`; deeper pages per facet are a future enhancement. + * + * `isEmpty` lets the screen pick between "no matches" and "type to + * search" copy. + */ +data class SearchResponseRef( + val artists: List, + val albums: List, + val tracks: List, +) { + val isEmpty: Boolean + get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SearchWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SearchWire.kt new file mode 100644 index 00000000..4b8e0bc6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SearchWire.kt @@ -0,0 +1,48 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.Serializable + +/** + * Server's `Page[T]` envelope for search results — three concrete + * variants since kotlinx.serialization's generic support is fine but + * the three call sites are easier to read with explicit types. + * + * `total` reflects the full match count for that facet (not just + * what fit in `items` at this offset/limit). The mobile UI only + * walks the first page today; infinite scroll per facet is a future + * enhancement. + */ +@Serializable +data class PagedArtistsWire( + val items: List = emptyList(), + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, +) + +@Serializable +data class PagedAlbumsWire( + val items: List = emptyList(), + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, +) + +@Serializable +data class PagedTracksWire( + val items: List = emptyList(), + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, +) + +/** + * `GET /api/search` response envelope. Three facets keyed by entity + * type, each independently paged. + */ +@Serializable +data class SearchResponseWire( + val artists: PagedArtistsWire = PagedArtistsWire(), + val albums: PagedAlbumsWire = PagedAlbumsWire(), + val tracks: PagedTracksWire = PagedTracksWire(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index 4a6b9e78..8d0c2a37 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -24,6 +24,7 @@ import com.fabledsword.minstrel.player.ui.NowPlayingScreen import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen import com.fabledsword.minstrel.requests.ui.RequestsScreen +import com.fabledsword.minstrel.search.ui.SearchScreen import com.fabledsword.minstrel.settings.ui.SettingsScreen import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.ShellScaffold @@ -70,7 +71,7 @@ private fun NavGraphBuilder.inShellTopLevel( } composable { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Search", "Lands in a later phase.") + SearchScreen(navController = navController) } } composable { 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 new file mode 100644 index 00000000..c310a496 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.search.data + +import com.fabledsword.minstrel.api.endpoints.SearchApi +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.models.SearchResponseRef +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin Retrofit wrapper around `/api/search`. Debouncing lives in + * the ViewModel, not here, so the repository stays trivial. + */ +@Singleton +class SearchRepository @Inject constructor( + retrofit: Retrofit, +) { + private val api: SearchApi = retrofit.create() + + suspend fun search(query: String): SearchResponseRef { + val wire = api.search(query) + return SearchResponseRef( + artists = wire.artists.items.map { it.toDomain() }, + albums = wire.albums.items.map { it.toDomain() }, + tracks = wire.tracks.items.map { it.toDomain() }, + ) + } +} 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 new file mode 100644 index 00000000..b966cf2b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt @@ -0,0 +1,269 @@ +package com.fabledsword.minstrel.search.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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.X +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.SearchResponseRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + navController: NavHostController, + viewModel: SearchViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + SearchField( + query = state.query, + onQueryChange = viewModel::setQuery, + focusRequester = focusRequester, + ) + }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + }, + actions = { + if (state.query.isNotEmpty()) { + IconButton(onClick = { viewModel.clear() }) { + Icon(Lucide.X, contentDescription = "Clear") + } + } + }, + ) + }, + ) { inner -> + Box(modifier = Modifier.fillMaxSize().padding(inner)) { + ResultsPane( + state = state.results, + onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + onTrackPlay = viewModel::playTrack, + ) + } + } +} + +@Composable +private fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + focusRequester: FocusRequester, +) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text("Search artists, albums, tracks") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) +} + +@Composable +private fun ResultsPane( + state: SearchResultsState, + onArtistClick: (String) -> Unit, + onAlbumClick: (String) -> Unit, + onTrackPlay: (TrackRef) -> Unit, +) { + when (state) { + SearchResultsState.Idle -> CenteredHint("Type to search your library.") + SearchResultsState.Loading -> LoadingCentered() + is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}") + is SearchResultsState.Loaded -> { + if (state.response.isEmpty) { + CenteredHint("No matches for that query.") + } else { + ResultsList(state.response, onArtistClick, onAlbumClick, onTrackPlay) + } + } + } +} + +@Composable +private fun ResultsList( + response: SearchResponseRef, + onArtistClick: (String) -> Unit, + onAlbumClick: (String) -> Unit, + onTrackPlay: (TrackRef) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + artistsSection(response.artists, onArtistClick) + albumsSection(response.albums, onAlbumClick) + tracksSection(response.tracks, onTrackPlay) + } +} + +private fun LazyListScope.artistsSection( + artists: List, + onArtistClick: (String) -> Unit, +) { + if (artists.isEmpty()) return + item { SectionHeader("Artists") } + items(items = artists, key = { "art-${it.id}" }) { artist -> + TextRow( + primary = artist.name, + secondary = null, + onClick = { onArtistClick(artist.id) }, + ) + HorizontalDivider() + } +} + +private fun LazyListScope.albumsSection( + albums: List, + onAlbumClick: (String) -> Unit, +) { + if (albums.isEmpty()) return + item { SectionHeader("Albums") } + items(items = albums, key = { "alb-${it.id}" }) { album -> + TextRow( + primary = album.title, + secondary = album.artistName.takeIf { it.isNotEmpty() }, + onClick = { onAlbumClick(album.id) }, + ) + HorizontalDivider() + } +} + +private fun LazyListScope.tracksSection( + tracks: List, + onTrackPlay: (TrackRef) -> Unit, +) { + if (tracks.isEmpty()) return + item { SectionHeader("Tracks") } + items(items = tracks, key = { "trk-${it.id}" }) { track -> + TextRow( + primary = track.title, + secondary = track.artistName.takeIf { it.isNotEmpty() } + ?: track.albumTitle.takeIf { it.isNotEmpty() }, + onClick = { onTrackPlay(track) }, + ) + HorizontalDivider() + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +@Composable +private fun TextRow(primary: String, secondary: String?, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = primary, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!secondary.isNullOrEmpty()) { + Text( + text = secondary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun CenteredHint(text: String) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun LoadingCentered() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} 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 new file mode 100644 index 00000000..673c685f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchViewModel.kt @@ -0,0 +1,97 @@ +package com.fabledsword.minstrel.search.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.models.SearchResponseRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.search.data.SearchRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val DEBOUNCE_MS = 250L + +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 Error(val message: String) : SearchResultsState +} + +data class SearchState( + val query: String = "", + val results: SearchResultsState = SearchResultsState.Idle, +) + +@OptIn(FlowPreview::class) +@HiltViewModel +class SearchViewModel @Inject constructor( + private val repository: SearchRepository, + private val player: PlayerController, +) : ViewModel() { + + private val queryFlow = MutableStateFlow("") + private val internal = MutableStateFlow(SearchState()) + val state: StateFlow = internal.asStateFlow() + + init { + viewModelScope.launch { + queryFlow + .map { it.trim() } + .distinctUntilChanged() + .onEach { q -> + // Empty query collapses results without firing the + // network call below. + if (q.isEmpty()) { + internal.update { + it.copy(results = SearchResultsState.Idle) + } + } + } + .debounce(DEBOUNCE_MS) + .collect { q -> + if (q.isEmpty()) return@collect + runSearch(q) + } + } + } + + fun setQuery(value: String) { + internal.update { it.copy(query = value) } + queryFlow.value = value + } + + fun clear() { + setQuery("") + } + + fun playTrack(track: TrackRef) { + if (track.streamUrl.isEmpty()) return + player.setQueue(listOf(track), initialIndex = 0, source = "search") + } + + 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)) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy(results = SearchResultsState.Error(e.message ?: "Search failed")) + } + } + } +}