diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/EventsClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/EventsClient.kt new file mode 100644 index 0000000..1b2bbff --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/EventsClient.kt @@ -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 { + 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() + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/EventModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/EventModels.kt new file mode 100644 index 0000000..c7615c2 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/EventModels.kt @@ -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 = 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, +) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/ConcertsSheet.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/ConcertsSheet.kt new file mode 100644 index 0000000..41bcb9a --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/ConcertsSheet.kt @@ -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, + 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 { + 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 "" + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt index cc58189..1bb96af 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt @@ -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() + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt index 1062637..e1e0edc 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt @@ -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 = 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) }