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