feat(android): QuarantineRepository.flag + HideTrackSheet

Adds the hide-track capability. Repository wraps QuarantineApi.flag
with the QUARANTINE_FLAG mutation-queue fallback mirroring the
existing unflag pattern. Sheet collects reason (FilterChip row) +
optional notes (OutlinedTextField); reason vocabulary matches the
server wire values (bad_rip / wrong_file / wrong_tags / duplicate /
other) exactly.

Not yet reachable from a user surface — TrackActionsSheet wires it
in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:16:00 -04:00
parent dd42bd5121
commit 55aba8f916
2 changed files with 137 additions and 5 deletions
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.quarantine.data
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.models.QuarantineRef
@@ -16,13 +17,17 @@ import javax.inject.Singleton
*/
enum class UnflagOutcome { ACCEPTED, QUEUED }
/**
* Outcome of a `flag` call. Same semantics as [UnflagOutcome] —
* ACCEPTED = server confirmed; QUEUED = the call landed in the
* MutationQueue for later replay.
*/
enum class FlagOutcome { ACCEPTED, QUEUED }
/**
* Read-through accessor for the caller's quarantine list + offline-
* first unflag write. No Room caching for v1 (same shape as Requests/
* History — offline-snapshot mirror lands with Phase 13). Flag is
* deferred to the AlbumDetail/ArtistDetail/PlaylistDetail per-track
* actions phase since there's no surface to flag from in the current
* screens.
* first flag / unflag writes. No Room caching for v1 (same shape as
* Requests/History — offline-snapshot mirror lands with Phase 13).
*/
@Singleton
class QuarantineRepository @Inject constructor(
@@ -33,6 +38,24 @@ class QuarantineRepository @Inject constructor(
suspend fun listMine(): List<QuarantineRef> = api.listMine().map { it.toDomain() }
/**
* Flag a track for quarantine with [reason] (one of bad_rip,
* wrong_file, wrong_tags, duplicate, other) and an optional
* [notes] string visible to admins. On transport failure the
* call is enqueued via MutationQueue for later replay; server-
* side re-flagging the same (user, track) pair is idempotent.
*/
suspend fun flag(trackId: String, reason: String, notes: String): FlagOutcome = try {
api.flag(FlagRequest(trackId = trackId, reason = reason, notes = notes))
FlagOutcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: queue the flag for later replay.
mutationQueue.enqueueQuarantineFlag(trackId, reason, notes)
FlagOutcome.QUEUED
}
suspend fun unflag(trackId: String): UnflagOutcome = try {
api.unflag(trackId)
UnflagOutcome.ACCEPTED
@@ -0,0 +1,109 @@
package com.fabledsword.minstrel.shared.widgets.track_actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
private data class HideReason(val wire: String, val label: String)
private val HIDE_REASONS = listOf(
HideReason("bad_rip", "Bad rip"),
HideReason("wrong_file", "Wrong file"),
HideReason("wrong_tags", "Wrong tags"),
HideReason("duplicate", "Duplicate"),
HideReason("other", "Other"),
)
/**
* ModalBottomSheet that collects a hide reason + optional notes and
* pops back via [onSubmit]. Mirrors Flutter's `HideTrackSheet`. The
* reason vocabulary matches the server wire values exactly.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun HideTrackSheet(
onSubmit: (reason: String, notes: String) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var reason by remember { mutableStateOf(HIDE_REASONS.first().wire) }
var notes by remember { mutableStateOf("") }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
Text(
text = "Hide this track",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Pick a reason. Optional notes are visible to admins.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
HIDE_REASONS.forEach { r ->
FilterChip(
selected = reason == r.wire,
onClick = { reason = r.wire },
label = { Text(r.label) },
)
}
}
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (optional)") },
modifier = Modifier.fillMaxWidth(),
maxLines = 2,
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text("Cancel") }
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = { onSubmit(reason, notes.trim()) }) {
Text("Hide")
}
}
Spacer(Modifier.height(8.dp))
}
}
}