feat(web+android+backend): Konzerte gefolgter Kuenstler als zweiter Reiter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1366ec3832
commit
17a677a5a5
13 files changed
+682
-14
No files matched your search
@@ -2,6 +2,8 @@ package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistReleasesRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteResponse
|
||||
import com.novasonic.app.api.model.ArtistReleasesResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
@@ -36,4 +38,13 @@ object ReleasesClient {
|
||||
setBody(ArtistReleasesRequest(artists, days))
|
||||
}.body()
|
||||
}
|
||||
|
||||
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
|
||||
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistKonzerteResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistKonzerteRequest(artists, days))
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,31 @@ data class ArtistRef(val id: String, val name: String)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
|
||||
|
||||
/** Ein anstehendes Konzert eines gefolgten Künstlers. */
|
||||
@Serializable
|
||||
data class ArtistKonzert(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val eventId: String = "",
|
||||
val titel: String = "",
|
||||
/** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */
|
||||
val datum: String = "",
|
||||
val ort: String = "",
|
||||
val stadt: String = "",
|
||||
val land: String = "",
|
||||
val url: String = "",
|
||||
val abgesagt: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteResponse(
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
/** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */
|
||||
val ohneTermine: List<String> = emptyList(),
|
||||
/** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */
|
||||
val keinSchluessel: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteRequest(val artists: List<ArtistRef>, val days: Int)
|
||||
@@ -30,8 +30,13 @@ import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistNewsViewModel
|
||||
import com.novasonic.app.viewmodel.NewsReiter
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
/**
|
||||
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
|
||||
@@ -55,19 +60,48 @@ fun ArtistNewsScreen(
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text("Neu von deinen Künstlern", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
val konzerte = state.reiter == NewsReiter.KONZERTE
|
||||
Text(
|
||||
if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe
|
||||
// Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen
|
||||
// hinter, Konzerte vor dem heutigen Tag.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||
) {
|
||||
listOf(
|
||||
NewsReiter.RELEASES to "Neu erschienen",
|
||||
NewsReiter.KONZERTE to "Konzerte",
|
||||
).forEach { (r, label) ->
|
||||
FilterChip(
|
||||
selected = state.reiter == r,
|
||||
onClick = { vm.zeigeReiter(r) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 10.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
|
||||
) {
|
||||
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
|
||||
// Bei den Konzerten zeigt dasselbe Fenster nach VORN, dort sind 1 Jahr die Vorgabe.
|
||||
val aktuell = if (state.reiter == NewsReiter.KONZERTE) state.tageKonzerte else state.tage
|
||||
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
|
||||
FilterChip(
|
||||
selected = state.tage == tage,
|
||||
onClick = { vm.laden(tage) },
|
||||
selected = aktuell == tage,
|
||||
onClick = { vm.setzeTage(tage) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
@@ -87,6 +121,30 @@ fun ArtistNewsScreen(
|
||||
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
|
||||
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
|
||||
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
|
||||
|
||||
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
|
||||
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
|
||||
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
|
||||
"Keine Termine in den nächsten ${state.tageKonzerte} Tagen",
|
||||
"Gesucht wird weltweit über den Künstlernamen.")
|
||||
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
var letzterMonat = ""
|
||||
state.konzerte.forEach { k ->
|
||||
val monat = monatVon(k.datum)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "km_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "k_${k.eventId}_${k.artistId}") {
|
||||
KonzertZeile(k) { onArtistClick(k.artistId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.releases.isEmpty() -> Hinweis("🌙",
|
||||
"Nichts Neues in den letzten ${state.tage} Tagen", "")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
@@ -215,3 +273,76 @@ private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
|
||||
|
||||
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
|
||||
// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00".
|
||||
private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN)
|
||||
private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN)
|
||||
private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN)
|
||||
private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN)
|
||||
|
||||
private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull()
|
||||
|
||||
/**
|
||||
* Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild,
|
||||
* aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft.
|
||||
*/
|
||||
@Composable
|
||||
private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) {
|
||||
val uri = LocalUriHandler.current
|
||||
val d = zeitVon(k.datum)
|
||||
val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp))
|
||||
.alpha(if (k.abgesagt) 0.45f else 1f),
|
||||
) {
|
||||
Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold, fontSize = 17.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit,
|
||||
fontSize = 10.sp, color = NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
k.titel.ifBlank { k.artistName },
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" })
|
||||
if (rest.isNotEmpty()) {
|
||||
Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (k.abgesagt) {
|
||||
Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
} else if (k.url.isNotBlank()) {
|
||||
TextButton(onClick = { uri.openUri(k.url) }) {
|
||||
Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ReleasesClient
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.app
|
||||
@@ -18,8 +19,16 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */
|
||||
enum class NewsReiter { RELEASES, KONZERTE }
|
||||
|
||||
data class ArtistNewsUiState(
|
||||
val reiter: NewsReiter = NewsReiter.RELEASES,
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
/** Zeitfenster der Konzerte — nach VORN, deshalb ein eigener Wert. */
|
||||
val tageKonzerte: Int = 365,
|
||||
val keinSchluessel: Boolean = false,
|
||||
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
|
||||
val vorhanden: Set<String> = emptySet(),
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
@@ -44,9 +53,45 @@ class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
init { laden(90) }
|
||||
|
||||
/** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */
|
||||
fun zeigeReiter(r: NewsReiter) {
|
||||
_state.value = _state.value.copy(reiter = r)
|
||||
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
|
||||
}
|
||||
|
||||
/** Zeitfenster setzen — es gilt je Reiter, weil es in verschiedene Richtungen zeigt. */
|
||||
fun setzeTage(tage: Int) {
|
||||
if (_state.value.reiter == NewsReiter.KONZERTE) ladeKonzerte(tage) else laden(tage)
|
||||
}
|
||||
|
||||
fun ladeKonzerte(tage: Int = _state.value.tageKonzerte) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, tageKonzerte = tage, error = null,
|
||||
keineGefolgten = false, keinBackend = false, keinSchluessel = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler, tage)
|
||||
_state.value = _state.value.copy(
|
||||
konzerte = antwort.konzerte,
|
||||
keinSchluessel = antwort.keinSchluessel,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
|
||||
keineGefolgten = false, keinBackend = false)
|
||||
reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
|
||||
Reference in new issue
Block a user