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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.PlayArrow
import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.ui.graphics.vector.ImageVector 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.api.model.Song
import com.novasonic.app.player.PlayerState import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.AlbumCard 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.ItunesDownloadSheet
import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
@@ -58,6 +60,18 @@ fun ArtistDetailScreen(
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() } BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() } 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 // Download sheet
state.downloadSheetItem?.let { item -> state.downloadSheetItem?.let { item ->
ItunesDownloadSheet( ItunesDownloadSheet(
@@ -195,6 +209,11 @@ fun ArtistDetailScreen(
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") { ArtistActionPill(Icons.Default.Shuffle, "Shuffle") {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs.shuffled(), 0) 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.AndroidViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.EventsClient
import com.novasonic.app.api.ImporterClient import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ItunesClient import com.novasonic.app.api.ItunesClient
import com.novasonic.app.api.model.Artist 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.ArtistInfo
import com.novasonic.app.api.model.DownloadRequest import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem import com.novasonic.app.api.model.ItunesItem
@@ -33,6 +35,9 @@ data class ArtistUiState(
val subAlbumTitle: String = "", val subAlbumTitle: String = "",
// Partial album detail (tapped incomplete album — shows downloaded + missing tracks) // Partial album detail (tapped incomplete album — shows downloaded + missing tracks)
val partialAlbum: PartialAlbumState? = null, val partialAlbum: PartialAlbumState? = null,
// Concerts
val events: List<ConcertEvent> = emptyList(),
val showEventsSheet: Boolean = false,
// Preview + download // Preview + download
val previewingId: Long? = null, val previewingId: Long? = null,
val downloadSheetItem: ItunesItem? = null, val downloadSheetItem: ItunesItem? = null,
@@ -75,8 +80,13 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
isLoading = false, itunesLoading = true, 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 { 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) { } catch (e: Exception) {
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString()) _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 ─────────────────────────────────────────────────────── // ── Download sheet ───────────────────────────────────────────────────────
fun openEventsSheet() { _state.value = _state.value.copy(showEventsSheet = true) }
fun dismissEventsSheet() { _state.value = _state.value.copy(showEventsSheet = false) }
fun showDownloadSheet(item: ItunesItem) { fun showDownloadSheet(item: ItunesItem) {
_state.value = _state.value.copy(downloadSheetItem = item) _state.value = _state.value.copy(downloadSheetItem = item)
} }