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)) + } + } +}