fix: surface per-calendar sync errors from server
/api/caldav/events now returns an "errors" array alongside "events" for calendars that failed to sync (e.g. expired credentials) while still reporting success overall. Previously such failures were silently swallowed, so a calendar could appear enabled/visible while showing zero events with no explanation. - CalendarRepository.fetchEvents now returns EventsResult(events, errors), parsing the new "errors" array the same way importIcs's Triple return handles multi-value results. - CalendarUiState gains syncErrors, populated in loadRange's onSuccess. - CalendarScreen shows a second error-style banner for sync errors, additive to the existing network-failure banner. - CalendarFilterSheet shows a warning icon next to calendar rows whose source+name matches a sync error (best-effort suffix match, since the server's error name is "<account> – <calendar name>").
This commit is contained in:
@@ -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<CalEvent>, val errors: List<SyncError>)
|
||||
|
||||
/**
|
||||
* 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<CalEvent> = 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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 "<account> – <calendar name>",
|
||||
// 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),
|
||||
|
||||
@@ -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<com.scarriffle.calendarr.data.SyncError>, 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) {
|
||||
|
||||
@@ -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<SyncError> = emptyList(),
|
||||
val weekStartsOnMonday: Boolean = true,
|
||||
val writableCalendars: List<WritableCalendar> = emptyList(),
|
||||
val hiddenKeys: Set<String> = 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<SyncError>()
|
||||
else repository.fetchEvents(start, end).let { it.events to it.errors }
|
||||
}
|
||||
.onSuccess { (events, errors) ->
|
||||
mergeIntoCache(events, start, end)
|
||||
refreshFromCache()
|
||||
_state.update { it.copy(syncErrors = errors) }
|
||||
}
|
||||
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
|
||||
.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) {
|
||||
|
||||
Reference in New Issue
Block a user