feat(android): Phase 10 Commit C — Hidden tab (quarantine list + unflag)

Replaces the Library Hidden-tab ComingSoonTab placeholder with the
real screen. Mirrors `flutter_client/lib/library/library_screen.dart`
`_HiddenTab` / `_QuarantineTile`.

New:
  - models/wire/QuarantineWire.kt — @Serializable QuarantineMineWire
    matching `/api/quarantine/mine`.
  - models/Quarantine.kt — QuarantineRef domain; `reasonLabel` maps
    the raw reason key ("bad_rip" / "wrong_file" / "wrong_tags" /
    "duplicate" / other) to its display string.
  - api/endpoints/QuarantineApi.kt — Retrofit listMine + flag + unflag
    plus a FlagRequest @Serializable body for POST. The unflag
    endpoint is idempotent server-side, which is what makes the
    queued-replay path safe.
  - quarantine/data/QuarantineRepository.kt — listMine + offline-first
    unflag that returns UnflagOutcome (ACCEPTED vs. QUEUED).
    `feedback_offline_first_for_server_writes` rationale identical
    to LikesRepository.toggleLike.
  - quarantine/ui/HiddenTabViewModel.kt — VM + UiState in its own
    file (preempts TooManyFunctions). Optimistic-remove on unflag
    with no rollback; the queue replays on failure.
  - quarantine/ui/HiddenTab.kt — composable. List of rows showing
    track title + "artist · album" subtitle + reason pill + Unhide
    icon button (Lucide.ArchiveRestore).

Modified:
  - cache/mutations/MutationQueue.kt — adds MutationKind.QUARANTINE_UNFLAG
    + QuarantineUnflagPayload + enqueueQuarantineUnflag helper.
  - library/ui/LibraryScreen.kt — TAB_HIDDEN dispatch swaps from
    ComingSoonTab to `HiddenTab()`. Drops the now-unreferenced
    ComingSoonTab helper.

Admin slices (admin requests / quarantine / users) are still Commit D.
Flag-from-track-row UI lands once Album/Artist/Playlist detail screens
expose per-track action menus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:52:09 -04:00
parent 2b4226ee6a
commit 75bdb635d0
8 changed files with 367 additions and 10 deletions
@@ -0,0 +1,39 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.QuarantineMineWire
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
*
* Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate
* `/api/admin/quarantine` route (Phase 10 Commit D).
*/
interface QuarantineApi {
@GET("api/quarantine/mine")
suspend fun listMine(): List<QuarantineMineWire>
@POST("api/quarantine")
suspend fun flag(@Body body: FlagRequest)
@DELETE("api/quarantine/{trackId}")
suspend fun unflag(@Path("trackId") trackId: String)
}
/**
* POST /api/quarantine body. `reason` is one of bad_rip / wrong_file
* / wrong_tags / duplicate / other; `notes` is the free-text comment.
*/
@kotlinx.serialization.Serializable
data class FlagRequest(
@kotlinx.serialization.SerialName("track_id") val trackId: String,
val reason: String,
val notes: String = "",
)
@@ -16,6 +16,7 @@ import javax.inject.Singleton
object MutationKind {
const val LIKE_TOGGLE: String = "like_toggle"
const val REQUEST_CREATE: String = "request_create"
const val QUARANTINE_UNFLAG: String = "quarantine_unflag"
}
/**
@@ -85,4 +86,22 @@ class MutationQueue @Inject constructor(
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
),
)
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.QUARANTINE_UNFLAG,
payload = json.encodeToString(
QuarantineUnflagPayload.serializer(),
QuarantineUnflagPayload(trackId),
),
),
)
}
/**
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
* hiccup. The replayer just re-fires the DELETE; idempotent server-side.
*/
@Serializable
data class QuarantineUnflagPayload(val trackId: String)
@@ -32,6 +32,7 @@ import com.fabledsword.minstrel.history.ui.HistoryTab
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.likes.ui.LikedTab
import com.fabledsword.minstrel.quarantine.ui.HiddenTab
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.AlbumDetail
@@ -97,11 +98,7 @@ fun LibraryScreen(
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_HISTORY -> HistoryTab()
TAB_LIKED -> LikedTab(navController = navController)
TAB_HIDDEN -> ComingSoonTab(
title = "Hidden",
body = "Tracks you've flagged as bad rips, wrong tags, or " +
"duplicates will appear here in Phase 10.",
)
TAB_HIDDEN -> HiddenTab()
}
}
}
@@ -192,11 +189,6 @@ private fun AlbumsGrid(
}
}
@Composable
private fun ComingSoonTab(title: String, body: String) {
EmptyState(title = title, body = body, modifier = Modifier.fillMaxSize())
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.models
/**
* One quarantined track owned by the caller. Mirrors the Flutter
* `QuarantineMineRow` (web `LidarrQuarantineMineRow`).
*
* `reasonLabel` provides the display string mapped from the raw
* server-side reason key — UI renders it as a pill on the row.
*/
data class QuarantineRef(
val trackId: String,
val reason: String,
val notes: String? = null,
val createdAt: String = "",
val trackTitle: String = "",
val trackDurationMs: Int = 0,
val albumId: String = "",
val albumTitle: String = "",
val albumCoverArtPath: String? = null,
val artistId: String = "",
val artistName: String = "",
) {
val reasonLabel: String
get() = when (reason) {
"bad_rip" -> "Bad rip"
"wrong_file" -> "Wrong file"
"wrong_tags" -> "Wrong tags"
"duplicate" -> "Duplicate"
else -> "Other"
}
}
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One row of `GET /api/quarantine/mine`. Mirrors
* `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow`
* (web `LidarrQuarantineMineRow`).
*
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
* / `other`. Stored as a plain string on the wire; the UI does the
* display-label mapping inline rather than via an enum to match
* Flutter's switch-on-string pattern.
*/
@Serializable
data class QuarantineMineWire(
@SerialName("track_id") val trackId: String = "",
val reason: String = "other",
val notes: String? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("track_title") val trackTitle: String = "",
@SerialName("track_duration_ms") val trackDurationMs: Int = 0,
@SerialName("album_id") val albumId: String = "",
@SerialName("album_title") val albumTitle: String = "",
@SerialName("album_cover_art_path") val albumCoverArtPath: String? = null,
@SerialName("artist_id") val artistId: String = "",
@SerialName("artist_name") val artistName: String = "",
)
@@ -0,0 +1,64 @@
package com.fabledsword.minstrel.quarantine.data
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.models.wire.QuarantineMineWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Outcome of an `unflag` call — mirrors LikesRepository.toggleLike's
* RequestOutcome flavor. ACCEPTED = server confirmed; QUEUED = the
* call landed in the MutationQueue for later replay.
*/
enum class UnflagOutcome { 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.
*/
@Singleton
class QuarantineRepository @Inject constructor(
private val mutationQueue: MutationQueue,
retrofit: Retrofit,
) {
private val api: QuarantineApi = retrofit.create()
suspend fun listMine(): List<QuarantineRef> = api.listMine().map { it.toDomain() }
suspend fun unflag(trackId: String): UnflagOutcome = try {
api.unflag(trackId)
UnflagOutcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: queue the unhide for later replay; the
// UI has already removed the row optimistically. Same rationale
// as LikesRepository.toggleLike.
mutationQueue.enqueueQuarantineUnflag(trackId)
UnflagOutcome.QUEUED
}
}
// ── Mappers (internal — wire stays out of UI) ──
private fun QuarantineMineWire.toDomain(): QuarantineRef = QuarantineRef(
trackId = trackId,
reason = reason,
notes = notes,
createdAt = createdAt,
trackTitle = trackTitle,
trackDurationMs = trackDurationMs,
albumId = albumId,
albumTitle = albumTitle,
albumCoverArtPath = albumCoverArtPath,
artistId = artistId,
artistName = artistName,
)
@@ -0,0 +1,112 @@
package com.fabledsword.minstrel.quarantine.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.ArchiveRestore
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.widgets.EmptyState
@Composable
fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
HiddenTabUiState.Loading -> LoadingCentered()
HiddenTabUiState.Empty -> EmptyState(
title = "No hidden tracks",
body = "Flag a track from its menu (bad rip, wrong tags, etc.) " +
"and it'll show up here. The track stays out of system " +
"playlists until you unhide it.",
)
is HiddenTabUiState.Error -> EmptyState(
title = "Couldn't load hidden tracks",
body = s.message,
)
is HiddenTabUiState.Success -> HiddenList(
rows = s.rows,
onUnflag = viewModel::unflag,
)
}
}
}
@Composable
private fun HiddenList(rows: List<QuarantineRef>, onUnflag: (String) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.trackId }) { row ->
HiddenRow(row = row, onUnflag = { onUnflag(row.trackId) })
HorizontalDivider()
}
}
}
@Composable
private fun HiddenRow(row: QuarantineRef, onUnflag: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.trackTitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${row.artistName} · ${row.albumTitle}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AssistChip(
onClick = {},
enabled = false,
label = { Text(row.reasonLabel) },
modifier = Modifier.padding(top = 4.dp),
)
}
IconButton(onClick = onUnflag) {
Icon(
Lucide.ArchiveRestore,
contentDescription = "Unhide",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,71 @@
package com.fabledsword.minstrel.quarantine.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface HiddenTabUiState {
data object Loading : HiddenTabUiState
data object Empty : HiddenTabUiState
data class Success(val rows: List<QuarantineRef>) : HiddenTabUiState
data class Error(val message: String) : HiddenTabUiState
}
@HiltViewModel
class HiddenTabViewModel @Inject constructor(
private val repository: QuarantineRepository,
) : ViewModel() {
private val internal = MutableStateFlow<HiddenTabUiState>(HiddenTabUiState.Loading)
val uiState: StateFlow<HiddenTabUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.value = HiddenTabUiState.Loading
try {
val rows = repository.listMine()
internal.value = if (rows.isEmpty()) {
HiddenTabUiState.Empty
} else {
HiddenTabUiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = HiddenTabUiState.Error(e.message ?: "Hidden load failed")
}
}
}
/**
* Optimistic unhide: drop the row from local state immediately,
* call the server, refetch on success or rollback-via-refetch on
* failure. The repository handles the offline-queue fallback so
* the user's intent persists across connectivity loss.
*/
fun unflag(trackId: String) {
val before = internal.value
if (before is HiddenTabUiState.Success) {
val filtered = before.rows.filterNot { it.trackId == trackId }
internal.value = if (filtered.isEmpty()) {
HiddenTabUiState.Empty
} else {
HiddenTabUiState.Success(filtered)
}
}
viewModelScope.launch {
runCatching { repository.unflag(trackId) }
}
}
}