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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user