test: stop wiping admin user during integration tests #29
@@ -0,0 +1,33 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for Discover / Lidarr search / request creation.
|
||||||
|
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
|
||||||
|
*
|
||||||
|
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
|
||||||
|
* of the same query are cheap.
|
||||||
|
*
|
||||||
|
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
|
||||||
|
* Commit B), not here.
|
||||||
|
*/
|
||||||
|
interface DiscoverApi {
|
||||||
|
@GET("api/discover/suggestions")
|
||||||
|
suspend fun listSuggestions(): List<ArtistSuggestionWire>
|
||||||
|
|
||||||
|
@GET("api/lidarr/search")
|
||||||
|
suspend fun search(
|
||||||
|
@Query("q") query: String,
|
||||||
|
@Query("kind") kind: String,
|
||||||
|
): List<LidarrSearchResultWire>
|
||||||
|
|
||||||
|
@POST("api/requests")
|
||||||
|
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||||
|
}
|
||||||
+25
@@ -15,6 +15,7 @@ import javax.inject.Singleton
|
|||||||
*/
|
*/
|
||||||
object MutationKind {
|
object MutationKind {
|
||||||
const val LIKE_TOGGLE: String = "like_toggle"
|
const val LIKE_TOGGLE: String = "like_toggle"
|
||||||
|
const val REQUEST_CREATE: String = "request_create"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +31,23 @@ data class LikeTogglePayload(
|
|||||||
val desiredState: Boolean,
|
val desiredState: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
|
||||||
|
* Lidarr request that landed during a connectivity hiccup). Optional
|
||||||
|
* fields stay null when not applicable; the replayer rebuilds the
|
||||||
|
* `CreateRequestBody` from these.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RequestCreatePayload(
|
||||||
|
val kind: String,
|
||||||
|
val artistMbid: String,
|
||||||
|
val artistName: String,
|
||||||
|
val albumMbid: String? = null,
|
||||||
|
val albumTitle: String? = null,
|
||||||
|
val trackMbid: String? = null,
|
||||||
|
val trackTitle: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal write-side of the offline mutation queue. Phase 8 v1 only
|
* Minimal write-side of the offline mutation queue. Phase 8 v1 only
|
||||||
* enqueues — the connectivity-aware replayer that drains the queue
|
* enqueues — the connectivity-aware replayer that drains the queue
|
||||||
@@ -60,4 +78,11 @@ class MutationQueue @Inject constructor(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert(
|
||||||
|
CachedMutationEntity(
|
||||||
|
kind = MutationKind.REQUEST_CREATE,
|
||||||
|
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package com.fabledsword.minstrel.discover.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
|
||||||
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||||
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a `createRequest` call — discriminates "server accepted"
|
||||||
|
* from "network down, queued for later replay". The UI uses this to
|
||||||
|
* pick between "Requested" and "Queued" snackbars.
|
||||||
|
*/
|
||||||
|
enum class RequestOutcome { ACCEPTED, QUEUED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through Discover surface: Lidarr search, suggestion feed, and
|
||||||
|
* the request-creation write path. Mirrors the Flutter
|
||||||
|
* `DiscoverApi` + `mutationQueueProvider` integration —
|
||||||
|
* `feedback_offline_first_for_server_writes` says writes never go
|
||||||
|
* fire-and-forget, so `createRequest` falls back to the
|
||||||
|
* MutationQueue on transport failure.
|
||||||
|
*
|
||||||
|
* Reads are not cached locally yet — discovery results are inherently
|
||||||
|
* transient (60s server-side LRU); the cache wins are minor and
|
||||||
|
* unblock no UX. Phase 13's offline pass adds an offline scrollback
|
||||||
|
* for the suggestion feed only.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DiscoverRepository @Inject constructor(
|
||||||
|
private val mutationQueue: MutationQueue,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: DiscoverApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||||
|
api.listSuggestions().map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||||
|
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires `POST /api/requests`; on transport failure enqueues a
|
||||||
|
* `RequestCreatePayload` for the Phase-12.2 replayer to drain.
|
||||||
|
* Returns [RequestOutcome.ACCEPTED] when the server responded
|
||||||
|
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
|
||||||
|
*/
|
||||||
|
suspend fun createRequest(
|
||||||
|
kind: LidarrRequestKind,
|
||||||
|
artistMbid: String,
|
||||||
|
artistName: String,
|
||||||
|
albumMbid: String? = null,
|
||||||
|
albumTitle: String? = null,
|
||||||
|
trackMbid: String? = null,
|
||||||
|
trackTitle: String? = null,
|
||||||
|
): RequestOutcome {
|
||||||
|
val payload = RequestCreatePayload(
|
||||||
|
kind = kind.wire,
|
||||||
|
artistMbid = artistMbid,
|
||||||
|
artistName = artistName,
|
||||||
|
albumMbid = albumMbid?.ifEmpty { null },
|
||||||
|
albumTitle = albumTitle?.ifEmpty { null },
|
||||||
|
trackMbid = trackMbid?.ifEmpty { null },
|
||||||
|
trackTitle = trackTitle?.ifEmpty { null },
|
||||||
|
)
|
||||||
|
return try {
|
||||||
|
api.createRequest(payload.toBody())
|
||||||
|
RequestOutcome.ACCEPTED
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow — see LikesRepository.toggleLike for
|
||||||
|
// the same offline-first rationale.
|
||||||
|
mutationQueue.enqueueRequestCreate(payload)
|
||||||
|
RequestOutcome.QUEUED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mappers (internal — wire types stay out of UI) ──
|
||||||
|
|
||||||
|
private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
secondaryText = secondaryText,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
artistMbid = artistMbid,
|
||||||
|
albumMbid = albumMbid,
|
||||||
|
inLibrary = inLibrary,
|
||||||
|
requested = requested,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggestionRef(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
attribution = attribution.map { it.toDomain() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
||||||
|
name = name,
|
||||||
|
isLiked = isLiked,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||||
|
kind = kind,
|
||||||
|
artistMbid = artistMbid,
|
||||||
|
artistName = artistName,
|
||||||
|
albumMbid = albumMbid,
|
||||||
|
albumTitle = albumTitle,
|
||||||
|
trackMbid = trackMbid,
|
||||||
|
trackTitle = trackTitle,
|
||||||
|
)
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
package com.fabledsword.minstrel.discover.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
|
import com.composables.icons.lucide.Disc3
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Search as LucideSearch
|
||||||
|
import com.composables.icons.lucide.User
|
||||||
|
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||||
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class DiscoverState(
|
||||||
|
val query: String = "",
|
||||||
|
val kind: LidarrRequestKind = LidarrRequestKind.ARTIST,
|
||||||
|
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||||
|
val results: ResultsState = ResultsState.Idle,
|
||||||
|
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface SuggestionState {
|
||||||
|
data object Loading : SuggestionState
|
||||||
|
data class Loaded(val items: List<ArtistSuggestionRef>) : SuggestionState
|
||||||
|
data class Error(val message: String) : SuggestionState
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ResultsState {
|
||||||
|
/** No active search — the screen shows the suggestion feed. */
|
||||||
|
data object Idle : ResultsState
|
||||||
|
data object Loading : ResultsState
|
||||||
|
data class Loaded(val items: List<LidarrSearchResultRef>) : ResultsState
|
||||||
|
data class Error(val message: String) : ResultsState
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DiscoverViewModel @Inject constructor(
|
||||||
|
private val repository: DiscoverRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow(DiscoverState())
|
||||||
|
val state: StateFlow<DiscoverState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadSuggestions()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setQuery(value: String) {
|
||||||
|
internal.update {
|
||||||
|
// Clearing the box returns to the suggestion feed.
|
||||||
|
val newResults = if (value.isBlank()) ResultsState.Idle else it.results
|
||||||
|
it.copy(query = value, results = newResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setKind(kind: LidarrRequestKind) {
|
||||||
|
internal.update { it.copy(kind = kind) }
|
||||||
|
if (internal.value.query.isNotBlank()) runSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSuggestions() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.update { it.copy(suggestions = SuggestionState.Loading) }
|
||||||
|
try {
|
||||||
|
val items = repository.listSuggestions()
|
||||||
|
internal.update { it.copy(suggestions = SuggestionState.Loaded(items)) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(
|
||||||
|
suggestions = SuggestionState.Error(
|
||||||
|
e.message ?: "Suggestions failed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runSearch() {
|
||||||
|
val q = internal.value.query.trim()
|
||||||
|
if (q.isEmpty()) {
|
||||||
|
internal.update { it.copy(results = ResultsState.Idle) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.update { it.copy(results = ResultsState.Loading) }
|
||||||
|
try {
|
||||||
|
val rows = repository.search(q, internal.value.kind)
|
||||||
|
internal.update { it.copy(results = ResultsState.Loaded(rows)) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(results = ResultsState.Error(e.message ?: "Search failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a search-result row. Returns the outcome (ACCEPTED vs
|
||||||
|
* QUEUED) so the screen can pick the snackbar wording and update
|
||||||
|
* the local "requested" overlay.
|
||||||
|
*/
|
||||||
|
suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome {
|
||||||
|
val kind = internal.value.kind
|
||||||
|
val outcome = repository.createRequest(
|
||||||
|
kind = kind,
|
||||||
|
artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid,
|
||||||
|
artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText,
|
||||||
|
albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null,
|
||||||
|
albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null,
|
||||||
|
)
|
||||||
|
markRequested(row.mbid)
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
|
||||||
|
val outcome = repository.createRequest(
|
||||||
|
kind = LidarrRequestKind.ARTIST,
|
||||||
|
artistMbid = s.mbid,
|
||||||
|
artistName = s.name,
|
||||||
|
)
|
||||||
|
markRequested(s.mbid)
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markRequested(mbid: String) {
|
||||||
|
internal.update { it.copy(locallyRequestedMbids = it.locallyRequestedMbids + mbid) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DiscoverScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: DiscoverViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Discover") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
MainAppBarActions(
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = Discover::class.qualifiedName,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
) { inner ->
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||||
|
SearchBar(
|
||||||
|
query = state.query,
|
||||||
|
onQueryChange = viewModel::setQuery,
|
||||||
|
onSubmit = viewModel::runSearch,
|
||||||
|
)
|
||||||
|
KindChips(kind = state.kind, onChange = viewModel::setKind)
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
when (val r = state.results) {
|
||||||
|
ResultsState.Idle -> SuggestionsPane(
|
||||||
|
state = state.suggestions,
|
||||||
|
locallyRequestedMbids = state.locallyRequestedMbids,
|
||||||
|
onRequest = { s ->
|
||||||
|
scope.launch {
|
||||||
|
val outcome = viewModel.requestSuggestion(s)
|
||||||
|
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ResultsState.Loading -> LoadingCentered()
|
||||||
|
is ResultsState.Loaded -> ResultsList(
|
||||||
|
rows = r.items,
|
||||||
|
locallyRequestedMbids = state.locallyRequestedMbids,
|
||||||
|
onRequest = { row ->
|
||||||
|
scope.launch {
|
||||||
|
val outcome = viewModel.requestRow(row)
|
||||||
|
snackbar.showSnackbar(snackbarFor(outcome, row.name))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchBar(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
singleLine = true,
|
||||||
|
placeholder = { Text("Search Lidarr for new music") },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = onSubmit) {
|
||||||
|
Icon(Lucide.LucideSearch, contentDescription = "Search")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = kind == LidarrRequestKind.ARTIST,
|
||||||
|
onClick = { onChange(LidarrRequestKind.ARTIST) },
|
||||||
|
label = { Text("Artists") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = kind == LidarrRequestKind.ALBUM,
|
||||||
|
onClick = { onChange(LidarrRequestKind.ALBUM) },
|
||||||
|
label = { Text("Albums") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SuggestionsPane(
|
||||||
|
state: SuggestionState,
|
||||||
|
locallyRequestedMbids: Set<String>,
|
||||||
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
SuggestionState.Loading -> LoadingCentered()
|
||||||
|
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
|
||||||
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
|
onRequest = onRequest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SuggestionsList(
|
||||||
|
items: List<ArtistSuggestionRef>,
|
||||||
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
item { SuggestionsHeader() }
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
item { CenteredMessage("Listen to or like an artist to fill this in.") }
|
||||||
|
} else {
|
||||||
|
items(items = items, key = { it.mbid }) { s ->
|
||||||
|
SuggestionTile(s = s, onRequest = { onRequest(s) })
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SuggestionsHeader() {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Suggested for you",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Out-of-library artists drawn from what you've liked and played.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultsList(
|
||||||
|
rows: List<LidarrSearchResultRef>,
|
||||||
|
locallyRequestedMbids: Set<String>,
|
||||||
|
onRequest: (LidarrSearchResultRef) -> Unit,
|
||||||
|
) {
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
CenteredMessage("Nothing to add for that search yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(items = rows, key = { it.mbid }) { row ->
|
||||||
|
ResultTile(
|
||||||
|
row = row,
|
||||||
|
locallyRequested = row.mbid in locallyRequestedMbids,
|
||||||
|
onRequest = { onRequest(row) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Avatar(imageUrl = s.imageUrl, fallback = Lucide.User, shape = AvatarShape.CIRCLE)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = s.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (s.attributionText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = s.attributionText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(onClick = onRequest) { Text("Request") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultTile(
|
||||||
|
row: LidarrSearchResultRef,
|
||||||
|
locallyRequested: Boolean,
|
||||||
|
onRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
val effectivelyRequested = row.requested || locallyRequested
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Avatar(imageUrl = row.imageUrl, fallback = Lucide.Disc3, shape = AvatarShape.ROUNDED)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = row.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (row.secondaryText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = row.secondaryText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
row.inLibrary -> AssistChip(onClick = {}, enabled = false, label = { Text("In library") })
|
||||||
|
effectivelyRequested -> AssistChip(onClick = {}, enabled = false, label = { Text("Requested") })
|
||||||
|
else -> Button(onClick = onRequest) { Text("Request") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class AvatarShape { CIRCLE, ROUNDED }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Avatar(
|
||||||
|
imageUrl: String,
|
||||||
|
fallback: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
shape: AvatarShape,
|
||||||
|
) {
|
||||||
|
val clip = when (shape) {
|
||||||
|
AvatarShape.CIRCLE -> CircleShape
|
||||||
|
AvatarShape.ROUNDED -> RoundedCornerShape(6.dp)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(clip)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (imageUrl.isEmpty()) {
|
||||||
|
Icon(
|
||||||
|
imageVector = fallback,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingCentered() {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(text: String) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snackbarFor(outcome: RequestOutcome, name: String): String = when (outcome) {
|
||||||
|
RequestOutcome.ACCEPTED -> "Requested: $name"
|
||||||
|
RequestOutcome.QUEUED -> "Request queued: $name"
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||||
|
* enum name; the helper [wire] keeps that mapping in one place.
|
||||||
|
*/
|
||||||
|
enum class LidarrRequestKind {
|
||||||
|
ARTIST, ALBUM, TRACK;
|
||||||
|
|
||||||
|
val wire: String get() = name.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
|
||||||
|
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
|
||||||
|
* and `albumMbid` are filled when the row is an album/track and the
|
||||||
|
* UI needs the parent IDs to build the request.
|
||||||
|
*
|
||||||
|
* `inLibrary` and `requested` together cover the "already-handled"
|
||||||
|
* cases — UI greys the row + swaps the Request button for a pill.
|
||||||
|
*/
|
||||||
|
data class LidarrSearchResultRef(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val secondaryText: String = "",
|
||||||
|
val imageUrl: String = "",
|
||||||
|
val artistMbid: String = "",
|
||||||
|
val albumMbid: String = "",
|
||||||
|
val inLibrary: Boolean = false,
|
||||||
|
val requested: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-seed attribution string for a suggestion — "you liked X" /
|
||||||
|
* "you played Y". `isLiked = true` discriminates the verb.
|
||||||
|
*/
|
||||||
|
data class SeedContributionRef(
|
||||||
|
val name: String,
|
||||||
|
val isLiked: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Out-of-library artist suggestion from `GET /api/discover/suggestions`.
|
||||||
|
* `attributionText` mirrors Flutter's SuggestionFeed.attributionText
|
||||||
|
* (Oxford-comma, max 3 seeds) so the subtitle reads naturally.
|
||||||
|
*/
|
||||||
|
data class ArtistSuggestionRef(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val imageUrl: String = "",
|
||||||
|
val attribution: List<SeedContributionRef> = emptyList(),
|
||||||
|
) {
|
||||||
|
val attributionText: String
|
||||||
|
get() {
|
||||||
|
if (attribution.isEmpty()) return ""
|
||||||
|
val phrases = attribution
|
||||||
|
.take(MAX_ATTRIBUTION_PHRASES)
|
||||||
|
.map { "${if (it.isLiked) "liked" else "played"} ${it.name}" }
|
||||||
|
return when (phrases.size) {
|
||||||
|
1 -> "Because you ${phrases[0]}."
|
||||||
|
2 -> "Because you ${phrases[0]} and ${phrases[1]}."
|
||||||
|
else -> "Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/lidarr/search`. Mirrors
|
||||||
|
* `web/src/lib/api/types.ts LidarrSearchResult` /
|
||||||
|
* `flutter_client/lib/models/lidarr.dart LidarrSearchResult`.
|
||||||
|
*
|
||||||
|
* `inLibrary` and `requested` let the UI greyout rows the user can't
|
||||||
|
* act on (already imported / already awaiting review). All defaults
|
||||||
|
* empty so missing keys don't throw on decode.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class LidarrSearchResultWire(
|
||||||
|
val mbid: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("secondary_text") val secondaryText: String = "",
|
||||||
|
@SerialName("image_url") val imageUrl: String = "",
|
||||||
|
@SerialName("artist_mbid") val artistMbid: String = "",
|
||||||
|
@SerialName("album_mbid") val albumMbid: String = "",
|
||||||
|
@SerialName("in_library") val inLibrary: Boolean = false,
|
||||||
|
val requested: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/discover/suggestions` row — an out-of-library artist
|
||||||
|
* surfaced from ListenBrainz-derived seeds. `imageUrl` is resolved
|
||||||
|
* on-demand from Lidarr server-side and may be empty.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ArtistSuggestionWire(
|
||||||
|
val mbid: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("image_url") val imageUrl: String = "",
|
||||||
|
val attribution: List<SeedContributionWire> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-seed attribution for an ArtistSuggestion — "because you liked X"
|
||||||
|
* / "because you played Y". `isLiked = true` means the seed came from
|
||||||
|
* the user's likes; false means a played-but-not-liked play.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SeedContributionWire(
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("is_liked") val isLiked: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||||
|
* payload shape. Optional fields are emitted only when non-null
|
||||||
|
* server-side; matching that here keeps the wire identical.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CreateRequestBody(
|
||||||
|
val kind: String,
|
||||||
|
@SerialName("lidarr_artist_mbid") val artistMbid: String,
|
||||||
|
@SerialName("artist_name") val artistName: String,
|
||||||
|
@SerialName("lidarr_album_mbid") val albumMbid: String? = null,
|
||||||
|
@SerialName("album_title") val albumTitle: String? = null,
|
||||||
|
@SerialName("lidarr_track_mbid") val trackMbid: String? = null,
|
||||||
|
@SerialName("track_title") val trackTitle: String? = null,
|
||||||
|
)
|
||||||
@@ -10,6 +10,7 @@ import androidx.navigation.NavHostController
|
|||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
|
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
|
||||||
import com.fabledsword.minstrel.home.ui.HomeScreen
|
import com.fabledsword.minstrel.home.ui.HomeScreen
|
||||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
||||||
@@ -64,7 +65,7 @@ private fun NavGraphBuilder.inShellTopLevel(
|
|||||||
}
|
}
|
||||||
composable<Discover> {
|
composable<Discover> {
|
||||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
ComingSoon("Discover", "Lands in Phase 10 (Lidarr requests).")
|
DiscoverScreen(navController = navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<Playlists> {
|
composable<Playlists> {
|
||||||
|
|||||||
Reference in New Issue
Block a user