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:
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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user