From 75bdb635d078d0a4104a580ff4dada55b1bc9246 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 00:52:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2010=20Commit=20C=20?= =?UTF-8?q?=E2=80=94=20Hidden=20tab=20(quarantine=20list=20+=20unflag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/api/endpoints/QuarantineApi.kt | 39 ++++++ .../minstrel/cache/mutations/MutationQueue.kt | 19 +++ .../minstrel/library/ui/LibraryScreen.kt | 12 +- .../fabledsword/minstrel/models/Quarantine.kt | 31 +++++ .../minstrel/models/wire/QuarantineWire.kt | 29 +++++ .../quarantine/data/QuarantineRepository.kt | 64 ++++++++++ .../minstrel/quarantine/ui/HiddenTab.kt | 112 ++++++++++++++++++ .../quarantine/ui/HiddenTabViewModel.kt | 71 +++++++++++ 8 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/Quarantine.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineWire.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt new file mode 100644 index 00000000..574b299c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt @@ -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 + + @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 = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index fc463185..3091df94 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index 45358e62..72b6ccc2 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -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) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Quarantine.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Quarantine.kt new file mode 100644 index 00000000..e976feac --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Quarantine.kt @@ -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" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineWire.kt new file mode 100644 index 00000000..ac77d37c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineWire.kt @@ -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 = "", +) 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 new file mode 100644 index 00000000..2d8cfafc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt @@ -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 = 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt new file mode 100644 index 00000000..d3b55119 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTab.kt @@ -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, 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) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt new file mode 100644 index 00000000..734b1a4d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt @@ -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) : HiddenTabUiState + data class Error(val message: String) : HiddenTabUiState +} + +@HiltViewModel +class HiddenTabViewModel @Inject constructor( + private val repository: QuarantineRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(HiddenTabUiState.Loading) + val uiState: StateFlow = 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) } + } + } +}