From 55aba8f916028aea4f803cc1483fb703efaac10b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 12:16:00 -0400 Subject: [PATCH] feat(android): QuarantineRepository.flag + HideTrackSheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../quarantine/data/QuarantineRepository.kt | 33 +++++- .../widgets/track_actions/HideTrackSheet.kt | 109 ++++++++++++++++++ 2 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/HideTrackSheet.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt index 2d8cfafc..0af392c7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt @@ -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 = 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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/HideTrackSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/HideTrackSheet.kt new file mode 100644 index 00000000..9fece751 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/HideTrackSheet.kt @@ -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)) + } + } +}