feat(android): Phase 9 — history tab + /api/me/history wiring

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<T>` 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 23:14:33 -04:00
parent c26c2bde10
commit 9d63030961
5 changed files with 314 additions and 5 deletions
@@ -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
}
}
@@ -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<HistoryEntry>,
val hasMore: Boolean,
)
@@ -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<HistoryEntry>) : 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>(HistoryUiState.Loading)
val uiState: StateFlow<HistoryUiState> = 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<HistoryEntry>, 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))
}
@@ -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",
@@ -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<T>` shape
* — history is timestamp-keyed so `total` isn't meaningful, only the
* "more pages exist below" hint.
*/
@Serializable
data class HistoryPageWire(
val events: List<HistoryEventWire> = emptyList(),
@SerialName("has_more") val hasMore: Boolean = false,
)