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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user