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:
@@ -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 = "",
|
||||
)
|
||||
+19
@@ -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 = "",
|
||||
)
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+71
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user