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.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.history.ui.HistoryTab
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
import com.fabledsword.minstrel.likes.ui.LikedTab
|
import com.fabledsword.minstrel.likes.ui.LikedTab
|
||||||
@@ -94,11 +95,7 @@ fun LibraryScreen(
|
|||||||
when (selectedTab) {
|
when (selectedTab) {
|
||||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||||
TAB_HISTORY -> ComingSoonTab(
|
TAB_HISTORY -> HistoryTab()
|
||||||
title = "History",
|
|
||||||
body = "Recent plays will appear here once /api/me/history " +
|
|
||||||
"wiring lands in Phase 9.",
|
|
||||||
)
|
|
||||||
TAB_LIKED -> LikedTab(navController = navController)
|
TAB_LIKED -> LikedTab(navController = navController)
|
||||||
TAB_HIDDEN -> ComingSoonTab(
|
TAB_HIDDEN -> ComingSoonTab(
|
||||||
title = "Hidden",
|
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