feat(android): Hidden tab — cover thumb + relative-time stamp

Audit v2 #12. Hidden rows were text-only ("title", "artist · album",
reason chip); Flutter shows the album cover thumbnail and a "3h ago"
relative-time stamp next to the reason. Both added to QuarantineRef
(coverUrl computed from albumId) and HiddenRow renders them.

Time helper is duplicated from HistoryTab inline rather than
hoisted — both copies are small and screen-private; a shared
RelativeTime widget can land later if a third caller surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:09:27 -04:00
parent c29d1e0b3d
commit a7f1160b7e
2 changed files with 61 additions and 5 deletions
@@ -28,4 +28,13 @@ data class QuarantineRef(
"duplicate" -> "Duplicate"
else -> "Other"
}
/**
* Cover URL derived from the parent album's
* `/api/albums/{id}/cover` endpoint. Uses the placeholder host
* that `BaseUrlInterceptor` rewrites to the live server URL.
* Empty when the row has no album binding (rare).
*/
val coverUrl: String
get() = if (albumId.isEmpty()) "" else "http://placeholder.invalid/api/albums/$albumId/cover"
}
@@ -29,6 +29,7 @@ import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@Composable
fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
@@ -73,6 +74,7 @@ private fun HiddenRow(row: QuarantineRef, onUnflag: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TrackCoverThumb(coverUrl = row.coverUrl, contentDescription = row.trackTitle)
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.trackTitle,
@@ -88,12 +90,24 @@ private fun HiddenRow(row: QuarantineRef, onUnflag: () -> Unit) {
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AssistChip(
onClick = {},
enabled = false,
label = { Text(row.reasonLabel) },
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 4.dp),
)
) {
AssistChip(
onClick = {},
enabled = false,
label = { Text(row.reasonLabel) },
)
if (row.createdAt.isNotEmpty()) {
Text(
text = relativeTime(row.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
IconButton(onClick = onUnflag) {
Icon(
@@ -111,3 +125,36 @@ private fun LoadingCentered() {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
// ─── Time helper (duplicated from HistoryTab to keep the widget local) ──
private const val MINUTES_PER_HOUR = 60L
private const val HOURS_PER_DAY = 24L
private const val DAYS_PER_WEEK = 7L
private fun relativeTime(iso: String): String {
val parsed = runCatching { java.time.OffsetDateTime.parse(iso) }.getOrNull()
?: return iso
val local = parsed.atZoneSameInstant(java.time.ZoneId.systemDefault())
val now = java.time.OffsetDateTime.now(java.time.ZoneId.systemDefault())
val minutes = java.time.temporal.ChronoUnit.MINUTES.between(local, now)
val hours = java.time.temporal.ChronoUnit.HOURS.between(local, now)
val days = java.time.temporal.ChronoUnit.DAYS.between(local, now)
return when {
minutes < MINUTES_PER_HOUR -> "${minutes.coerceAtLeast(1)}m ago"
hours < HOURS_PER_DAY -> "${hours}h ago"
days < DAYS_PER_WEEK -> {
val dow = local.dayOfWeek.getDisplayName(
java.time.format.TextStyle.SHORT,
java.util.Locale.getDefault(),
)
val time = local.toLocalTime()
.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"))
"$dow $time"
}
else -> {
val pattern = if (local.year == now.year) "MMM d" else "MMM d, yyyy"
local.toLocalDate().format(java.time.format.DateTimeFormatter.ofPattern(pattern))
}
}
}