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:
+28
-5
@@ -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
|
||||
|
||||
+109
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user