feat(android): Phase 10 Commit D2 — Admin Quarantine screen

Replaces the AdminQuarantine-route ComingSoon stub with the real
admin-side queue. Mirrors `flutter_client/lib/admin/admin_quarantine_screen.dart`
+ `AdminQuarantineController`.

New:
  - models/wire/AdminQuarantineWire.kt — AdminQuarantineReportWire +
    AdminQuarantineItemWire (per-user report and the aggregated row
    that collapses reports into per-reason counts).
  - models/AdminQuarantineItem.kt — domain refs.
    `topReasonSummary` returns the most-cited reason with a "(+N more)"
    suffix when other distinct reasons exist.
  - api/endpoints/AdminQuarantineApi.kt — Retrofit list + the three
    resolution endpoints (resolve / delete-file / delete-via-lidarr).
  - admin/data/AdminQuarantineRepository.kt — list + three actions
    with internal mappers; same point-and-shoot pattern as
    AdminRequestsRepository (no offline queue).
  - admin/ui/AdminQuarantineViewModel.kt — VM + UiState in sibling
    file. `act()` collapses the three resolution paths into one
    optimistic-remove-then-refetch-on-failure helper.
  - admin/ui/AdminQuarantineScreen.kt — Scaffold + TopAppBar with
    back button + MainAppBarActions. Each row shows track title +
    "artist · album" subtitle + report-count pill + top-reason pill +
    three action buttons (Resolve / Delete file / Delete + Lidarr).

Modified:
  - nav/MinstrelNavGraph.kt — AdminQuarantine route now renders
    `AdminQuarantineScreen(navController = navController)` inside
    ShellScaffold.

AdminUsers + AdminLanding + Invites still ComingSoon — D3 lands them
together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 01:32:16 -04:00
parent 2a55e6a647
commit 9d17636a39
7 changed files with 433 additions and 1 deletions
@@ -0,0 +1,57 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminQuarantineApi
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.models.AdminQuarantineReportRef
import com.fabledsword.minstrel.models.wire.AdminQuarantineItemWire
import com.fabledsword.minstrel.models.wire.AdminQuarantineReportWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the admin quarantine queue. Mirrors the
* Flutter `AdminQuarantineController`. No Room cache — same rationale
* as AdminRequests. Three resolution actions all delete the row from
* the queue once the server accepts them.
*/
@Singleton
class AdminQuarantineRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminQuarantineApi = retrofit.create()
suspend fun list(): List<AdminQuarantineItemRef> = api.list().map { it.toDomain() }
suspend fun resolve(trackId: String) = api.resolve(trackId)
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId)
}
// ── Mappers ──
private fun AdminQuarantineReportWire.toDomain(): AdminQuarantineReportRef =
AdminQuarantineReportRef(
userId = userId,
username = username,
reason = reason,
notes = notes,
createdAt = createdAt,
)
private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef =
AdminQuarantineItemRef(
trackId = trackId,
trackTitle = trackTitle,
artistName = artistName,
albumTitle = albumTitle,
albumId = albumId,
lidarrAlbumMbid = lidarrAlbumMbid,
reportCount = reportCount,
latestAt = latestAt,
reasonCounts = reasonCounts,
reports = reports.map { it.toDomain() },
)
@@ -0,0 +1,190 @@
package com.fabledsword.minstrel.admin.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.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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 androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminQuarantineScreen(
navController: NavHostController,
viewModel: AdminQuarantineViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Admin · Quarantine") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = AdminQuarantine::class.qualifiedName,
)
},
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) {
AdminQuarantineUiState.Loading -> LoadingCentered()
AdminQuarantineUiState.Empty -> EmptyState(
title = "Queue is empty",
body = "When users flag tracks as bad rips, wrong tags, or " +
"duplicates, their reports get aggregated and surfaced here.",
)
is AdminQuarantineUiState.Error -> EmptyState(
title = "Couldn't load queue",
body = s.message,
)
is AdminQuarantineUiState.Success -> QueueList(
rows = s.rows,
onResolve = viewModel::resolve,
onDeleteFile = viewModel::deleteFile,
onDeleteViaLidarr = viewModel::deleteViaLidarr,
)
}
}
}
}
@Composable
private fun QueueList(
rows: List<AdminQuarantineItemRef>,
onResolve: (String) -> Unit,
onDeleteFile: (String) -> Unit,
onDeleteViaLidarr: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.trackId }) { row ->
QuarantineRow(
row = row,
onResolve = { onResolve(row.trackId) },
onDeleteFile = { onDeleteFile(row.trackId) },
onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) },
)
HorizontalDivider()
}
}
}
@Composable
private fun QuarantineRow(
row: AdminQuarantineItemRef,
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
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,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AssistChip(
onClick = {},
enabled = false,
label = {
Text(
text = "${row.reportCount} reports",
style = MaterialTheme.typography.labelSmall,
)
},
)
if (row.topReasonSummary.isNotEmpty()) {
AssistChip(
onClick = {},
enabled = false,
label = {
Text(
text = row.topReasonSummary,
style = MaterialTheme.typography.labelSmall,
)
},
)
}
}
ActionButtons(
onResolve = onResolve,
onDeleteFile = onDeleteFile,
onDeleteViaLidarr = onDeleteViaLidarr,
)
}
}
@Composable
private fun ActionButtons(
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
TextButton(onClick = onResolve) { Text("Resolve") }
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") }
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,77 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
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 AdminQuarantineUiState {
data object Loading : AdminQuarantineUiState
data object Empty : AdminQuarantineUiState
data class Success(val rows: List<AdminQuarantineItemRef>) : AdminQuarantineUiState
data class Error(val message: String) : AdminQuarantineUiState
}
@HiltViewModel
class AdminQuarantineViewModel @Inject constructor(
private val repository: AdminQuarantineRepository,
) : ViewModel() {
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.value = AdminQuarantineUiState.Loading
try {
val rows = repository.list()
internal.value = if (rows.isEmpty()) {
AdminQuarantineUiState.Empty
} else {
AdminQuarantineUiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminQuarantineUiState.Error(
e.message ?: "Admin quarantine load failed",
)
}
}
}
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) }
private fun act(trackId: String, action: suspend (String) -> Unit) {
val before = internal.value
if (before is AdminQuarantineUiState.Success) {
val filtered = before.rows.filterNot { it.trackId == trackId }
internal.value = if (filtered.isEmpty()) {
AdminQuarantineUiState.Empty
} else {
AdminQuarantineUiState.Success(filtered)
}
}
viewModelScope.launch {
try {
action(trackId)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
}
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminQuarantineItemWire
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/quarantine`. Mirrors
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
*
* Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
* - `delete-file` → drop the local file; Lidarr keeps the track.
* - `delete-via-lidarr` → ask Lidarr to remove the track upstream too.
*/
interface AdminQuarantineApi {
@GET("api/admin/quarantine")
suspend fun list(): List<AdminQuarantineItemWire>
@POST("api/admin/quarantine/{trackId}/resolve")
suspend fun resolve(@Path("trackId") trackId: String)
@POST("api/admin/quarantine/{trackId}/delete-file")
suspend fun deleteFile(@Path("trackId") trackId: String)
@POST("api/admin/quarantine/{trackId}/delete-via-lidarr")
suspend fun deleteViaLidarr(@Path("trackId") trackId: String)
}
@@ -0,0 +1,41 @@
package com.fabledsword.minstrel.models
/**
* One user's quarantine report under an aggregated row.
*/
data class AdminQuarantineReportRef(
val userId: String,
val username: String,
val reason: String,
val notes: String? = null,
val createdAt: String = "",
)
/**
* Aggregated quarantine row in the admin queue — all reports against a
* single track collapsed into per-reason counts plus the underlying
* per-user reports. Mirrors Flutter's `AdminQuarantineItem`.
*
* `topReasonSummary` returns the most-cited reason, with a "(+N more)"
* suffix when other distinct reasons exist.
*/
data class AdminQuarantineItemRef(
val trackId: String,
val trackTitle: String,
val artistName: String,
val albumTitle: String,
val albumId: String,
val lidarrAlbumMbid: String? = null,
val reportCount: Int = 0,
val latestAt: String = "",
val reasonCounts: Map<String, Int> = emptyMap(),
val reports: List<AdminQuarantineReportRef> = emptyList(),
) {
val topReasonSummary: String
get() {
if (reasonCounts.isEmpty()) return ""
val sorted = reasonCounts.entries.sortedByDescending { it.value }
val top = sorted.first().key
return if (sorted.size > 1) "$top (+${sorted.size - 1} more)" else top
}
}
@@ -0,0 +1,37 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One user's individual report under an aggregated quarantine row.
* Mirrors Flutter's `AdminQuarantineReport` /
* `internal/api/admin_quarantine.go` reportView.
*/
@Serializable
data class AdminQuarantineReportWire(
@SerialName("user_id") val userId: String = "",
val username: String = "",
val reason: String = "",
val notes: String? = null,
@SerialName("created_at") val createdAt: String = "",
)
/**
* One row of `GET /api/admin/quarantine` — all reports against a
* single track collapsed into per-reason counts plus the underlying
* per-user reports. Mirrors `adminQueueRowView`.
*/
@Serializable
data class AdminQuarantineItemWire(
@SerialName("track_id") val trackId: String = "",
@SerialName("track_title") val trackTitle: String = "",
@SerialName("artist_name") val artistName: String = "",
@SerialName("album_title") val albumTitle: String = "",
@SerialName("album_id") val albumId: String = "",
@SerialName("lidarr_album_mbid") val lidarrAlbumMbid: String? = null,
@SerialName("report_count") val reportCount: Int = 0,
@SerialName("latest_at") val latestAt: String = "",
@SerialName("reason_counts") val reasonCounts: Map<String, Int> = emptyMap(),
val reports: List<AdminQuarantineReportWire> = emptyList(),
)
@@ -10,6 +10,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
import com.fabledsword.minstrel.home.ui.HomeScreen
@@ -120,7 +121,7 @@ private fun NavGraphBuilder.inShellDetail(
}
composable<AdminQuarantine> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Admin · Quarantine", "Lands with admin slice.")
AdminQuarantineScreen(navController = navController)
}
}
composable<AdminUsers> {