From a7f1160b7e7dbed5b0e9a05f5c50d35cf0493e77 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 11:09:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Hidden=20tab=20=E2=80=94=20cov?= =?UTF-8?q?er=20thumb=20+=20relative-time=20stamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/models/QuarantineRef.kt | 9 +++ .../minstrel/quarantine/ui/HiddenTab.kt | 57 +++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt index e976feac..80635aaa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt @@ -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" } 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 index 64afa193..ccae3eca 100644 --- 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 @@ -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)) + } + } +}