feat(android): Phase 13 — Search screen + /api/search wiring

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 18:23:09 -04:00
parent de1175c478
commit d96f835b5b
7 changed files with 486 additions and 1 deletions
@@ -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
}
}
@@ -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<ArtistRef>,
val albums: List<AlbumRef>,
val tracks: List<TrackRef>,
) {
val isEmpty: Boolean
get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty()
}
@@ -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<ArtistWire> = emptyList(),
val total: Int = 0,
val limit: Int = 0,
val offset: Int = 0,
)
@Serializable
data class PagedAlbumsWire(
val items: List<AlbumWire> = emptyList(),
val total: Int = 0,
val limit: Int = 0,
val offset: Int = 0,
)
@Serializable
data class PagedTracksWire(
val items: List<TrackWire> = 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(),
)
@@ -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<Search> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Search", "Lands in a later phase.")
SearchScreen(navController = navController)
}
}
composable<Discover> {
@@ -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() },
)
}
}
@@ -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<ArtistRef>,
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<AlbumRef>,
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<TrackRef>,
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)
}
}
@@ -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<SearchState> = 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"))
}
}
}
}