fix(android): surface concert-fetch errors instead of swallowing them

Events were fetched with runCatching{}.getOrDefault(emptyList()), so any
network/deserialization failure looked identical to "no concerts". The
backend + Ticketmaster work (verified), so the failure is client-side.

- EventsClient: expectSuccess + timeouts so a non-2xx (e.g. an SSO/HTML
  redirect) becomes a real exception instead of an obscure body() failure.
- ArtistViewModel: capture the failure into eventsError (+ Logcat) rather
  than defaulting to empty.
- ConcertsSheet: show "Konzerte konnten nicht geladen werden" + the reason
  when the fetch failed, vs. "Keine Konzerte angekündigt" when genuinely none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-21 19:46:16 +02:00
1 parent 7b4599c50a
commit f27eaf9c82
4 files changed
+29 -9

No files matched your search

@@ -4,6 +4,7 @@ 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.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
@@ -12,6 +13,10 @@ import kotlinx.serialization.json.Json
object EventsClient {
private val http = HttpClient(Android) {
// Surface non-2xx as a real exception (e.g. an SSO redirect / HTML login page) instead of
// letting .body() fail obscurely — the ViewModel turns it into a visible "load failed".
expectSuccess = true
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 10_000 }
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
@@ -31,6 +31,7 @@ fun ConcertsSheet(
artistName: String,
events: List<ConcertEvent>,
onDismiss: () -> Unit,
eventsError: String? = null,
) {
val context = LocalContext.current
@@ -47,17 +48,25 @@ fun ConcertsSheet(
)
if (events.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
.padding(horizontal = 20.dp, vertical = 40.dp),
) {
Text(
"Keine Konzerte angekündigt",
if (eventsError != null) "Konzerte konnten nicht geladen werden" else "Keine Konzerte angekündigt",
fontFamily = Outfit, fontSize = 14.sp,
color = NsOnSurface2,
)
if (eventsError != null) {
Spacer(Modifier.height(6.dp))
Text(
eventsError,
fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2.copy(alpha = 0.6f),
)
}
}
} else {
LazyColumn(
@@ -92,9 +92,10 @@ fun ArtistDetailScreen(
val artist = state.artist
if (artist != null) {
ConcertsSheet(
artistName = artist.name,
events = state.events,
onDismiss = vm::dismissEventsSheet,
artistName = artist.name,
events = state.events,
onDismiss = vm::dismissEventsSheet,
eventsError = state.eventsError,
)
}
}
@@ -41,6 +41,7 @@ data class ArtistUiState(
val partialAlbum: PartialAlbumState? = null,
// Concerts
val events: List<ConcertEvent> = emptyList(),
val eventsError: String? = null, // non-null → the fetch failed (vs. genuinely no concerts)
val showEventsSheet: Boolean = false,
// Deezer fan count of the matched artist (shown under the name), null if unknown
val fanCount: Long? = null,
@@ -113,8 +114,12 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
launch { loadItunesData(artist.name, pin) }
launch {
val backendUrl = repo.getBackendUrl()
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
_state.value = _state.value.copy(events = ev)
runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }
.onSuccess { _state.value = _state.value.copy(events = it, eventsError = null) }
.onFailure { e ->
android.util.Log.w("NovaSonic", "Konzert-Fetch fehlgeschlagen für ${artist.name}", e)
_state.value = _state.value.copy(events = emptyList(), eventsError = e.message ?: e.toString())
}
}
} catch (e: Exception) {
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())