feat: Konzerte button on artist page (Android)

- EventModels: ConcertEvent / ConcertVenue / ConcertOffer (Kotlinx Serializable)
- EventsClient: Ktor client fetching backendUrl/api/events/{artist}
- ArtistViewModel: fetches events in parallel with iTunes data on load;
  openEventsSheet / dismissEventsSheet
- ConcertsSheet: ModalBottomSheet with event rows (date, venue, city,
  Tickets → button that opens browser, Ausverkauft state)
- ArtistDetailScreen: Konzerte pill in action bar showing event count,
  tapping opens ConcertsSheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 00:17:55 +02:00
1 parent b5aa484c20
commit 13e9ec9d9a
5 files changed
+251 -1

No files matched your search

@@ -0,0 +1,26 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.ConcertEvent
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
object EventsClient {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
}
suspend fun fetchEvents(backendUrl: String, artistName: String): List<ConcertEvent> {
if (backendUrl.isBlank()) return emptyList()
val base = backendUrl.trimEnd('/')
val encoded = java.net.URLEncoder.encode(artistName, "UTF-8")
return http.get("$base/api/events/$encoded").body()
}
}
@@ -0,0 +1,23 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
@Serializable
data class ConcertEvent(
val datetime: String = "",
val venue: ConcertVenue? = null,
val offers: List<ConcertOffer> = emptyList(),
)
@Serializable
data class ConcertVenue(
val name: String? = null,
val city: String? = null,
val country: String? = null,
)
@Serializable
data class ConcertOffer(
val url: String? = null,
val status: String? = null,
)
@@ -0,0 +1,169 @@
package com.novasonic.app.ui.component
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.ui.theme.*
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcertsSheet(
artistName: String,
events: List<ConcertEvent>,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
) {
Text(
"Konzerte · $artistName",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 16.sp,
color = NsOnSurface,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
)
if (events.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
) {
Text(
"Keine Konzerte angekündigt",
fontFamily = Outfit, fontSize = 14.sp,
color = NsOnSurface2,
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(events) { event ->
EventRow(event = event, onTicketClick = { url ->
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
})
}
}
}
}
}
@Composable
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
val (dateStr, timeStr) = formatEventDateTime(event.datetime)
val venue = event.venue?.name.orEmpty()
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
val soldOut = offer?.status == "unavailable"
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface2)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
// Date block
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(52.dp),
) {
Text(
dateStr,
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 13.sp, color = NsAccent,
)
if (timeStr.isNotBlank()) {
Text(
timeStr,
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
)
}
}
Spacer(Modifier.width(14.dp))
Box(Modifier.width(1.dp).height(36.dp).background(NsBorder))
Spacer(Modifier.width(14.dp))
// Venue
Column(Modifier.weight(1f)) {
if (venue.isNotBlank()) {
Text(
venue, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface,
maxLines = 1,
)
}
if (city.isNotBlank()) {
Text(
city, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1,
)
}
}
// Ticket button
if (offer?.url != null) {
Spacer(Modifier.width(10.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.then(
if (soldOut) Modifier.border(1.dp, NsOnSurface2.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
else Modifier.background(NsAccent)
)
.clickable(enabled = !soldOut) { onTicketClick(offer.url) }
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
if (soldOut) "Ausverkauft" else "Tickets →",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
color = if (soldOut) NsOnSurface2 else Color.White,
)
}
}
}
}
private fun formatEventDateTime(datetime: String): Pair<String, String> {
return try {
val zdt = ZonedDateTime.parse(datetime)
val date = zdt.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN))
val time = if (zdt.hour == 0 && zdt.minute == 0) ""
else zdt.format(DateTimeFormatter.ofPattern("HH:mm"))
date to time
} catch (_: Exception) {
datetime.take(10) to ""
}
}
@@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.ui.graphics.vector.ImageVector
@@ -36,6 +37,7 @@ import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.component.ConcertsSheet
import com.novasonic.app.ui.component.ItunesDownloadSheet
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
@@ -58,6 +60,18 @@ fun ArtistDetailScreen(
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
// Concerts sheet
if (state.showEventsSheet) {
val artist = state.artist
if (artist != null) {
ConcertsSheet(
artistName = artist.name,
events = state.events,
onDismiss = vm::dismissEventsSheet,
)
}
}
// Download sheet
state.downloadSheetItem?.let { item ->
ItunesDownloadSheet(
@@ -195,6 +209,11 @@ fun ArtistDetailScreen(
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs.shuffled(), 0)
}
val eventCount = state.events.size
val konzertLabel = if (eventCount > 0) "Konzerte ($eventCount)" else "Konzerte"
ArtistActionPill(Icons.Default.Event, konzertLabel) {
vm.openEventsSheet()
}
}
}
@@ -5,9 +5,11 @@ import android.media.MediaPlayer
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.EventsClient
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ItunesClient
import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.api.model.ArtistInfo
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem
@@ -33,6 +35,9 @@ data class ArtistUiState(
val subAlbumTitle: String = "",
// Partial album detail (tapped incomplete album — shows downloaded + missing tracks)
val partialAlbum: PartialAlbumState? = null,
// Concerts
val events: List<ConcertEvent> = emptyList(),
val showEventsSheet: Boolean = false,
// Preview + download
val previewingId: Long? = null,
val downloadSheetItem: ItunesItem? = null,
@@ -75,8 +80,13 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
isLoading = false, itunesLoading = true,
)
// Fetch iTunes data without blocking the main screen
// Fetch iTunes + events in parallel without blocking the main screen
launch { loadItunesData(artist.name) }
launch {
val backendUrl = repo.getBackendUrl()
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
_state.value = _state.value.copy(events = ev)
}
} catch (e: Exception) {
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
}
@@ -163,6 +173,9 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
// ── Download sheet ───────────────────────────────────────────────────────
fun openEventsSheet() { _state.value = _state.value.copy(showEventsSheet = true) }
fun dismissEventsSheet() { _state.value = _state.value.copy(showEventsSheet = false) }
fun showDownloadSheet(item: ItunesItem) {
_state.value = _state.value.copy(downloadSheetItem = item)
}