feat(android): AdminRequests — show requester username

Audit v2 #18. AdminRequestsScreen was showing only the request's
display name, not which user asked for it. Now fetches the admin
users list in parallel with the requests list, builds a
userId → username map, and renders "Requested by <username>"
beneath each row. Falls back to an 8-char userId prefix when the
users list fetch fails (e.g., admin without users-list access on
some future server-side ACL change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:10:50 -04:00
parent a7f1160b7e
commit cd1c054f62
2 changed files with 37 additions and 5 deletions
@@ -81,6 +81,7 @@ fun AdminRequestsScreen(
)
is AdminRequestsUiState.Success -> RequestList(
rows = s.rows,
usernames = s.usernames,
onApprove = viewModel::approve,
onReject = viewModel::reject,
)
@@ -92,12 +93,20 @@ fun AdminRequestsScreen(
@Composable
private fun RequestList(
rows: List<RequestRef>,
usernames: Map<String, String>,
onApprove: (String) -> Unit,
onReject: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { req ->
AdminRequestRow(req = req, onApprove = { onApprove(req.id) }, onReject = { onReject(req.id) })
AdminRequestRow(
req = req,
requester = usernames[req.userId]
?: req.userId.takeIf { it.isNotEmpty() }?.take(8)
?: "unknown",
onApprove = { onApprove(req.id) },
onReject = { onReject(req.id) },
)
HorizontalDivider()
}
}
@@ -106,6 +115,7 @@ private fun RequestList(
@Composable
private fun AdminRequestRow(
req: RequestRef,
requester: String,
onApprove: () -> Unit,
onReject: () -> Unit,
) {
@@ -122,6 +132,13 @@ private fun AdminRequestRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "Requested by $requester",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AssistChip(
onClick = {},
@@ -3,10 +3,13 @@ package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -19,13 +22,17 @@ private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
sealed interface AdminRequestsUiState {
data object Loading : AdminRequestsUiState
data object Empty : AdminRequestsUiState
data class Success(val rows: List<RequestRef>) : AdminRequestsUiState
data class Success(
val rows: List<RequestRef>,
val usernames: Map<String, String>,
) : AdminRequestsUiState
data class Error(val message: String) : AdminRequestsUiState
}
@HiltViewModel
class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository,
private val usersRepository: AdminUsersRepository,
private val eventsStream: EventsStream,
) : ViewModel() {
@@ -44,11 +51,19 @@ class AdminRequestsViewModel @Inject constructor(
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminRequestsUiState.Loading
try {
val rows = repository.list()
val (rows, usernames) = coroutineScope {
val requestsJob = async { repository.list() }
val usernamesJob = async {
runCatching {
usersRepository.list().associate { it.id to it.username }
}.getOrDefault(emptyMap())
}
requestsJob.await() to usernamesJob.await()
}
internal.value = if (rows.isEmpty()) {
AdminRequestsUiState.Empty
} else {
AdminRequestsUiState.Success(rows)
AdminRequestsUiState.Success(rows = rows, usernames = usernames)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
@@ -75,7 +90,7 @@ class AdminRequestsViewModel @Inject constructor(
internal.value = if (filtered.isEmpty()) {
AdminRequestsUiState.Empty
} else {
AdminRequestsUiState.Success(filtered)
AdminRequestsUiState.Success(rows = filtered, usernames = before.usernames)
}
}
viewModelScope.launch {