diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index bf5a404..b0a8987 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -35,6 +35,12 @@ data class LoginResult(val token: String, val username: String, val isAdmin: Boo data class TotpSetup(val secret: String, val qrUrl: String) +/** A single calendar's sync failure, surfaced alongside a (still-successful) events fetch. */ +data class SyncError(val source: String, val name: String, val message: String) + +/** Result of [CalendarRepository.fetchEvents]: the merged events plus any per-calendar sync failures. */ +data class EventsResult(val events: List, val errors: List) + /** * Single entry point for all server interaction. Wraps [com.scarriffle.calendarr.data.remote.CalendarrApi], * converts HTTP failures into [ApiException]s carrying the server's `detail` @@ -297,18 +303,30 @@ class CalendarRepository @Inject constructor( // ---- Events ---- - suspend fun fetchEvents(start: Instant, end: Instant): List = withContext(Dispatchers.IO) { + suspend fun fetchEvents(start: Instant, end: Instant): EventsResult = withContext(Dispatchers.IO) { val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end)) resp.ensureSuccess() - val raw = resp.body()?.string() ?: return@withContext emptyList() + val raw = resp.body()?.string() ?: return@withContext EventsResult(emptyList(), emptyList()) val root = JSONObject(raw) - val arr = root.optJSONArray("events") ?: return@withContext emptyList() - buildList { - for (i in 0 until arr.length()) { + val arr = root.optJSONArray("events") + val events = buildList { + if (arr != null) for (i in 0 until arr.length()) { val obj = arr.optJSONObject(i) ?: continue CalEvent.fromJson(obj)?.let { add(it) } } } + val errArr = root.optJSONArray("errors") + val errors = buildList { + if (errArr != null) for (i in 0 until errArr.length()) { + val obj = errArr.optJSONObject(i) ?: continue + add(SyncError( + source = obj.optString("source"), + name = obj.optString("name"), + message = obj.optString("message"), + )) + } + } + EventsResult(events, errors) } suspend fun createLocalEvent( diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 2b17014..1d4b224 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -122,6 +122,7 @@ object L10n { "filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden", "filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden", "filter.button" to "Kalender ein-/ausblenden", + "filter.sync_error" to "Synchronisierung fehlgeschlagen", "caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL", "caldav.username" to "Benutzername", "caldav.password" to "Passwort", "caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", @@ -260,6 +261,7 @@ object L10n { "filter.title" to "Calendars", "filter.empty" to "No calendars available", "filter.show_all" to "Show all", "filter.hide_all" to "Hide all", "filter.button" to "Show/hide calendars", + "filter.sync_error" to "Sync failed", "caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL", "caldav.username" to "Username", "caldav.password" to "Password", "caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt index 4629fb9..275a64d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -83,11 +84,24 @@ fun CalendarFilterSheet( } rows.forEach { entry -> val visible = entry.key !in hiddenSet + // Best-effort match: server error `name` is "", + // so a suffix match on this calendar's own name is reliable enough. + val hasSyncError = !groupMode && state.syncErrors.any { err -> + err.source == entry.source && err.name.endsWith(entry.name) + } Row( Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color))) + if (hasSyncError) { + Icon( + Icons.Filled.WarningAmber, + contentDescription = tr("filter.sync_error"), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp).size(16.dp), + ) + } Text( entry.name, modifier = Modifier.weight(1f).padding(start = 12.dp), diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index 7d6a036..8487fa3 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -172,6 +172,9 @@ fun CalendarScreen( state.error?.let { err -> ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) } + if (state.syncErrors.isNotEmpty()) { + SyncErrorBanner(state.syncErrors, onDismiss = vm::clearSyncErrors) + } state.activeGroup?.let { g -> GroupBanner(group = g, onExit = { vm.switchGroup(null) }) } @@ -344,6 +347,33 @@ private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> U } } +/** + * Additive to [ErrorBanner]: the fetch as a whole succeeded, but one or more + * enabled calendars failed to sync (e.g. expired credentials) and are showing + * zero events with no other indication. Same visual language, no retry button + * (retrying the whole range wouldn't target just the broken calendar). + */ +@Composable +private fun SyncErrorBanner(errors: List, onDismiss: () -> Unit) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(12.dp)) { + errors.forEach { err -> + Text( + "${err.name}: ${err.message}", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + ) + } + androidx.compose.foundation.layout.Row { + TextButton(onClick = onDismiss) { Text(tr("common.close")) } + } + } + } +} + @Composable private fun loadingPlaceholder() { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index f474f55..b19ec09 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.data.SyncError import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.Group @@ -36,6 +37,10 @@ data class CalendarUiState( val isLoading: Boolean = false, val isBackgroundCaching: Boolean = false, val error: String? = null, + // Per-calendar sync failures from the last successful /events fetch (e.g. + // expired CalDAV credentials) — the fetch as a whole succeeded, but one or + // more enabled calendars silently returned nothing. Additive to `error`. + val syncErrors: List = emptyList(), val weekStartsOnMonday: Boolean = true, val writableCalendars: List = emptyList(), val hiddenKeys: Set = emptySet(), @@ -232,10 +237,14 @@ class CalendarViewModel @Inject constructor( val flag = if (background) "bg" else "fg" _state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) } runCatching { - if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) - else repository.fetchEvents(start, end) + if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) to emptyList() + else repository.fetchEvents(start, end).let { it.events to it.errors } } - .onSuccess { mergeIntoCache(it, start, end); refreshFromCache() } + .onSuccess { (events, errors) -> + mergeIntoCache(events, start, end) + refreshFromCache() + _state.update { it.copy(syncErrors = errors) } + } .onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } } _state.update { it.copy(isLoading = false, isBackgroundCaching = false) } } @@ -319,6 +328,8 @@ class CalendarViewModel @Inject constructor( fun clearError() = _state.update { it.copy(error = null) } + fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) } + // ---- Visibility filters ---- fun setCalendarHidden(key: String, hidden: Boolean) {