From 9d63030961eb6aa23f213586fed13946a49328c9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:14:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=209=20=E2=80=94=20histor?= =?UTF-8?q?y=20tab=20+=20/api/me/history=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Library History-tab ComingSoon placeholder with a real listening-history view. Tapping a row plays that single track via PlayerController, matching Flutter's `_HistoryTab` behavior. New: - models/wire/HistoryWire.kt — @Serializable HistoryEventWire + HistoryPageWire (`{events, has_more}`, the non-`Page` envelope /api/me/history actually returns). - api/endpoints/HistoryApi.kt — Retrofit `GET /api/me/history` with limit/offset query params (50/0 default). - history/data/HistoryRepository.kt — fetch-only (no Room cache for v1; the offline snapshot mirror lands with Phase 13). Maps the wire shape into a UI-friendly HistoryEntry that carries the converted TrackRef so playback wiring stays consistent with the other tabs. - history/ui/HistoryTab.kt — VM + UiState + composable. List of HistoryRow tiles (title + artist + relative-time stamp); tap-to-play single track via PlayerController.setQueue. The `relativeTime` formatter mirrors Flutter's `library_screen.dart` `_relativeTime` four-window strategy: < 1h → "Nm ago" < 24h → "Nh ago" < 7d → "Tue 14:32" ≥ 7d → "May 1" (or "May 1, 2025" cross-year) Built on `java.time.OffsetDateTime` — fine on minSdk 26 without core-library desugaring. Modified: - library/ui/LibraryScreen.kt — TAB_HISTORY dispatch swaps from ComingSoonTab to `HistoryTab()`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/api/endpoints/HistoryApi.kt | 23 ++ .../history/data/HistoryRepository.kt | 56 +++++ .../minstrel/history/ui/HistoryTab.kt | 206 ++++++++++++++++++ .../minstrel/library/ui/LibraryScreen.kt | 7 +- .../minstrel/models/wire/HistoryWire.kt | 27 +++ 5 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/HistoryWire.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt new file mode 100644 index 00000000..ee4dcbaf --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt @@ -0,0 +1,23 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.HistoryPageWire +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit interface for `/api/me/history`. Mirrors the relevant + * subset of `flutter_client/lib/api/endpoints/me.dart` (only + * `history()`; profile / timezone / quarantine endpoints land with + * their respective phases). + */ +interface HistoryApi { + @GET("api/me/history") + suspend fun getHistory( + @Query("limit") limit: Int = DEFAULT_LIMIT, + @Query("offset") offset: Int = 0, + ): HistoryPageWire + + companion object { + const val DEFAULT_LIMIT: Int = 50 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt new file mode 100644 index 00000000..804fc6ad --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt @@ -0,0 +1,56 @@ +package com.fabledsword.minstrel.history.data + +import com.fabledsword.minstrel.api.endpoints.HistoryApi +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.models.TrackRef +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * One row of listening history mapped into a UI-friendly shape. The + * server's HistoryEventWire pairs `played_at` (RFC3339) with the + * full TrackWire — we keep both, mapping the track through the + * existing wire→TrackRef converter so playback wiring stays + * consistent with the Library reads. + */ +data class HistoryEntry( + val id: String, + val playedAt: String, + val track: TrackRef, +) + +/** + * Read-through accessor for the caller's listening history. + * + * v1 is fetch-on-visit only — no Room caching. The Phase-13 offline + * pass adds a `cached_history_snapshot` JSON-blob mirror so the tab + * paints from disk on cold open and survives connectivity loss + * (matching `flutter_client/lib/library/library_screen.dart`'s + * SWR-style `historyProvider`). + */ +@Singleton +class HistoryRepository @Inject constructor( + retrofit: Retrofit, +) { + private val api: HistoryApi = retrofit.create() + + suspend fun fetchPage( + limit: Int = HistoryApi.DEFAULT_LIMIT, + offset: Int = 0, + ): HistoryPage { + val wire = api.getHistory(limit, offset) + return HistoryPage( + entries = wire.events.map { + HistoryEntry(id = it.id, playedAt = it.playedAt, track = it.track.toDomain()) + }, + hasMore = wire.hasMore, + ) + } +} + +data class HistoryPage( + val entries: List, + val hasMore: Boolean, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt new file mode 100644 index 00000000..9f399d7a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -0,0 +1,206 @@ +package com.fabledsword.minstrel.history.ui + +import androidx.compose.foundation.clickable +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.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.history.data.HistoryEntry +import com.fabledsword.minstrel.history.data.HistoryRepository +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.shared.widgets.EmptyState +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 java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import java.util.Locale +import javax.inject.Inject + +// ─── State ─────────────────────────────────────────────────────────── + +sealed interface HistoryUiState { + data object Loading : HistoryUiState + data object Empty : HistoryUiState + data class Success(val entries: List) : HistoryUiState + data class Error(val message: String) : HistoryUiState +} + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class HistoryTabViewModel @Inject constructor( + private val repository: HistoryRepository, + private val player: PlayerController, +) : ViewModel() { + + private val internal = MutableStateFlow(HistoryUiState.Loading) + val uiState: StateFlow = internal.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + internal.value = HistoryUiState.Loading + try { + val page = repository.fetchPage() + internal.value = if (page.entries.isEmpty()) { + HistoryUiState.Empty + } else { + HistoryUiState.Success(page.entries) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = HistoryUiState.Error(e.message ?: "History load failed") + } + } + } + + /** Single-track play, matching the Flutter history-row behavior. */ + fun playTrack(track: TrackRef) { + if (track.streamUrl.isEmpty()) return + player.setQueue(listOf(track), initialIndex = 0, source = "history") + } +} + +// ─── Composable ────────────────────────────────────────────────────── + +@Composable +fun HistoryTab(viewModel: HistoryTabViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Box(modifier = Modifier.fillMaxSize()) { + when (val s = state) { + HistoryUiState.Loading -> LoadingCentered() + HistoryUiState.Empty -> EmptyState( + title = "No listening history yet", + body = "Play something — your recent plays will show up here.", + ) + is HistoryUiState.Error -> EmptyState( + title = "Couldn't load history", + body = s.message, + ) + is HistoryUiState.Success -> HistoryList( + entries = s.entries, + onPlay = viewModel::playTrack, + ) + } + } +} + +@Composable +private fun HistoryList(entries: List, onPlay: (TrackRef) -> Unit) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = entries, key = { it.id }) { entry -> + HistoryRow(entry = entry, onClick = { onPlay(entry.track) }) + HorizontalDivider() + } + } +} + +@Composable +private fun HistoryRow(entry: HistoryEntry, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = entry.track.streamUrl.isNotEmpty(), onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.track.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = entry.track.artistName.ifEmpty { entry.track.albumTitle }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = relativeTime(entry.playedAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun LoadingCentered() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} + +// ─── Time helpers ──────────────────────────────────────────────────── + +private const val MINUTES_PER_HOUR = 60L +private const val HOURS_PER_DAY = 24L +private const val DAYS_PER_WEEK = 7L + +/** + * Lightweight relative-time formatter mirroring Flutter's + * `library_screen.dart`'s `_relativeTime`: + * + * < 1h → "Nm ago" + * < 24h → "Nh ago" + * < 7d → "Tue 14:32" + * ≥ 7d → "May 1" (or "May 1, 2025" if different year) + * + * Falls back to the raw ISO string on parse failure so we never blow + * up the row over malformed timestamps. + */ +private fun relativeTime(iso: String): String { + val t = runCatching { OffsetDateTime.parse(iso) }.getOrNull() ?: return iso + val local = t.atZoneSameInstant(ZoneId.systemDefault()) + val now = OffsetDateTime.now(ZoneId.systemDefault()) + val minutes = ChronoUnit.MINUTES.between(local, now) + if (minutes < MINUTES_PER_HOUR) { + val m = if (minutes < 1) 1 else minutes + return "${m}m ago" + } + val hours = ChronoUnit.HOURS.between(local, now) + if (hours < HOURS_PER_DAY) return "${hours}h ago" + val days = ChronoUnit.DAYS.between(local, now) + if (days < DAYS_PER_WEEK) { + val dow = local.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) + val time = local.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm")) + return "$dow $time" + } + val pattern = if (local.year == now.year) "MMM d" else "MMM d, yyyy" + return local.toLocalDate().format(DateTimeFormatter.ofPattern(pattern)) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index e9e59657..45358e62 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import com.fabledsword.minstrel.history.ui.HistoryTab import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.ArtistCard import com.fabledsword.minstrel.likes.ui.LikedTab @@ -94,11 +95,7 @@ fun LibraryScreen( when (selectedTab) { TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController) TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController) - TAB_HISTORY -> ComingSoonTab( - title = "History", - body = "Recent plays will appear here once /api/me/history " + - "wiring lands in Phase 9.", - ) + TAB_HISTORY -> HistoryTab() TAB_LIKED -> LikedTab(navController = navController) TAB_HIDDEN -> ComingSoonTab( title = "Hidden", diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HistoryWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HistoryWire.kt new file mode 100644 index 00000000..16f9e676 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HistoryWire.kt @@ -0,0 +1,27 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One row of `GET /api/me/history`. Mirrors `internal/api/me.go`'s + * HistoryEvent — a paired (id, played_at, track) tuple. `playedAt` is + * RFC3339; the UI formats it as a relative timestamp. + */ +@Serializable +data class HistoryEventWire( + val id: String = "", + @SerialName("played_at") val playedAt: String = "", + val track: TrackWire, +) + +/** + * Envelope of `GET /api/me/history`. Not the standard `Page` shape + * — history is timestamp-keyed so `total` isn't meaningful, only the + * "more pages exist below" hint. + */ +@Serializable +data class HistoryPageWire( + val events: List = emptyList(), + @SerialName("has_more") val hasMore: Boolean = false, +)