feat(scrobble stats): navigator lower bound, clickable rows, android button to top
- Navigator can't step before logging began: the endpoint returns firstPlayedAt (MIN(played_at) over all the user's plays, unfiltered by window); web + android disable the back arrow once the shown year/month is at/before that first play. - Top artists/tracks/albums are now clickable → open the artist / album page (web navigates and closes the overlay; android navigates via nav routes). Tracks open their album. - Android: moved the 'Meine Statistiken' button to the top of Settings (was at the bottom near logout). SW cache -> ns-v70. Android compileDebugKotlin + web build green; firstPlayedAt verified server-side. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
86eb893f40
commit
3129333f9a
8 files changed
+93
-26
No files matched your search
@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
|
||||
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
|
||||
@Serializable
|
||||
data class ScrobbleStats(
|
||||
val firstPlayedAt: Long? = null,
|
||||
val totalPlays: Int = 0,
|
||||
val totalSeconds: Long = 0,
|
||||
val topArtists: List<StatArtist> = emptyList(),
|
||||
|
||||
@@ -392,8 +392,10 @@ fun AppNavigation() {
|
||||
}
|
||||
composable("stats") {
|
||||
StatsScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onBack = { navController.popBackStack() },
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
|
||||
color = NsOnSurface,
|
||||
)
|
||||
|
||||
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
|
||||
OutlinedButton(
|
||||
onClick = onOpenStats,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||
|
||||
@@ -228,15 +236,6 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onOpenStats,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { showLogoutDialog = true },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
|
||||
@@ -41,6 +41,8 @@ private val MONTHS = listOf(
|
||||
fun StatsScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
vm: StatsViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
@@ -82,7 +84,7 @@ fun StatsScreen(
|
||||
}
|
||||
if (state.view != StatsView.ALL) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
StepArrow("‹", enabled = true) { vm.step(-1) }
|
||||
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
|
||||
Text(
|
||||
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
|
||||
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
|
||||
@@ -101,13 +103,18 @@ fun StatsScreen(
|
||||
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
|
||||
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
|
||||
)
|
||||
else -> StatsContent(state.stats!!, coverUrl)
|
||||
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
|
||||
private fun StatsContent(
|
||||
d: ScrobbleStats,
|
||||
coverUrl: (String?) -> String?,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
@@ -128,7 +135,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
|
||||
if (d.topArtists.isNotEmpty()) {
|
||||
SectionHeader("Top-Künstler")
|
||||
d.topArtists.forEachIndexed { i, a ->
|
||||
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true)
|
||||
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
|
||||
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
@@ -136,7 +144,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
|
||||
if (d.topTracks.isNotEmpty()) {
|
||||
SectionHeader("Top-Songs")
|
||||
d.topTracks.forEachIndexed { i, t ->
|
||||
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays)
|
||||
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
|
||||
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
@@ -144,7 +153,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
|
||||
if (d.topAlbums.isNotEmpty()) {
|
||||
SectionHeader("Top-Alben")
|
||||
d.topAlbums.forEachIndexed { i, a ->
|
||||
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays)
|
||||
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
|
||||
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
@@ -193,10 +203,13 @@ private fun SectionHeader(t: String) {
|
||||
@Composable
|
||||
private fun TopRow(
|
||||
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
|
||||
@@ -21,6 +21,7 @@ data class StatsUiState(
|
||||
val view: StatsView = StatsView.ALL,
|
||||
val year: Int = Calendar.getInstance().get(Calendar.YEAR),
|
||||
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
|
||||
val firstPlayedAt: Long? = null, // earliest logged play → navigator lower bound
|
||||
val stats: ScrobbleStats? = null,
|
||||
)
|
||||
|
||||
@@ -67,6 +68,20 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the shown window is at/before the first logged play — caps stepping backwards. */
|
||||
fun atStart(): Boolean {
|
||||
val s = _state.value
|
||||
val first = s.firstPlayedAt ?: return false
|
||||
val c = Calendar.getInstance().apply { timeInMillis = first * 1000 }
|
||||
val fy = c.get(Calendar.YEAR)
|
||||
val fm = c.get(Calendar.MONTH)
|
||||
return when (s.view) {
|
||||
StatsView.YEAR -> s.year <= fy
|
||||
StatsView.MONTH -> s.year < fy || (s.year == fy && s.month <= fm)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun startOf(year: Int, month: Int, day: Int): Long {
|
||||
val c = Calendar.getInstance().apply {
|
||||
clear()
|
||||
@@ -99,7 +114,11 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = if (stats == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(loading = false, configured = true, stats = stats)
|
||||
_state.value.copy(
|
||||
loading = false, configured = true, stats = stats,
|
||||
// Keep the earliest-play bound once known (a windowed query may return null for it).
|
||||
firstPlayedAt = stats.firstPlayedAt ?: _state.value.firstPlayedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user