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:
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user