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)
|
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],
|
* 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`
|
* converts HTTP failures into [ApiException]s carrying the server's `detail`
|
||||||
@@ -297,18 +303,30 @@ class CalendarRepository @Inject constructor(
|
|||||||
|
|
||||||
// ---- Events ----
|
// ---- 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))
|
val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
|
||||||
resp.ensureSuccess()
|
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 root = JSONObject(raw)
|
||||||
val arr = root.optJSONArray("events") ?: return@withContext emptyList()
|
val arr = root.optJSONArray("events")
|
||||||
buildList {
|
val events = buildList {
|
||||||
for (i in 0 until arr.length()) {
|
if (arr != null) for (i in 0 until arr.length()) {
|
||||||
val obj = arr.optJSONObject(i) ?: continue
|
val obj = arr.optJSONObject(i) ?: continue
|
||||||
CalEvent.fromJson(obj)?.let { add(it) }
|
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(
|
suspend fun createLocalEvent(
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ object L10n {
|
|||||||
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
|
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
|
||||||
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
|
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
|
||||||
"filter.button" to "Kalender ein-/ausblenden",
|
"filter.button" to "Kalender ein-/ausblenden",
|
||||||
|
"filter.sync_error" to "Synchronisierung fehlgeschlagen",
|
||||||
"caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
|
"caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
|
||||||
"caldav.username" to "Benutzername", "caldav.password" to "Passwort",
|
"caldav.username" to "Benutzername", "caldav.password" to "Passwort",
|
||||||
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto",
|
"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.title" to "Calendars", "filter.empty" to "No calendars available",
|
||||||
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
|
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
|
||||||
"filter.button" to "Show/hide calendars",
|
"filter.button" to "Show/hide calendars",
|
||||||
|
"filter.sync_error" to "Sync failed",
|
||||||
"caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL",
|
"caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL",
|
||||||
"caldav.username" to "Username", "caldav.password" to "Password",
|
"caldav.username" to "Username", "caldav.password" to "Password",
|
||||||
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account",
|
"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.Icons
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.NotificationsOff
|
import androidx.compose.material.icons.filled.NotificationsOff
|
||||||
|
import androidx.compose.material.icons.filled.WarningAmber
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -83,11 +84,24 @@ fun CalendarFilterSheet(
|
|||||||
}
|
}
|
||||||
rows.forEach { entry ->
|
rows.forEach { entry ->
|
||||||
val visible = entry.key !in hiddenSet
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color)))
|
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(
|
Text(
|
||||||
entry.name,
|
entry.name,
|
||||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ fun CalendarScreen(
|
|||||||
state.error?.let { err ->
|
state.error?.let { err ->
|
||||||
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
|
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
|
||||||
}
|
}
|
||||||
|
if (state.syncErrors.isNotEmpty()) {
|
||||||
|
SyncErrorBanner(state.syncErrors, onDismiss = vm::clearSyncErrors)
|
||||||
|
}
|
||||||
state.activeGroup?.let { g ->
|
state.activeGroup?.let { g ->
|
||||||
GroupBanner(group = g, onExit = { vm.switchGroup(null) })
|
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
|
@Composable
|
||||||
private fun loadingPlaceholder() {
|
private fun loadingPlaceholder() {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.scarriffle.calendarr.data.CalendarRepository
|
import com.scarriffle.calendarr.data.CalendarRepository
|
||||||
import com.scarriffle.calendarr.data.SettingsStore
|
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.CalEvent
|
||||||
import com.scarriffle.calendarr.domain.model.CalViewType
|
import com.scarriffle.calendarr.domain.model.CalViewType
|
||||||
import com.scarriffle.calendarr.domain.model.Group
|
import com.scarriffle.calendarr.domain.model.Group
|
||||||
@@ -36,6 +37,10 @@ data class CalendarUiState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isBackgroundCaching: Boolean = false,
|
val isBackgroundCaching: Boolean = false,
|
||||||
val error: String? = null,
|
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 weekStartsOnMonday: Boolean = true,
|
||||||
val writableCalendars: List<WritableCalendar> = emptyList(),
|
val writableCalendars: List<WritableCalendar> = emptyList(),
|
||||||
val hiddenKeys: Set<String> = emptySet(),
|
val hiddenKeys: Set<String> = emptySet(),
|
||||||
@@ -232,10 +237,14 @@ class CalendarViewModel @Inject constructor(
|
|||||||
val flag = if (background) "bg" else "fg"
|
val flag = if (background) "bg" else "fg"
|
||||||
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
|
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
|
||||||
runCatching {
|
runCatching {
|
||||||
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end))
|
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) to emptyList<SyncError>()
|
||||||
else repository.fetchEvents(start, end)
|
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) } }
|
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
|
||||||
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
|
_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 clearError() = _state.update { it.copy(error = null) }
|
||||||
|
|
||||||
|
fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) }
|
||||||
|
|
||||||
// ---- Visibility filters ----
|
// ---- Visibility filters ----
|
||||||
|
|
||||||
fun setCalendarHidden(key: String, hidden: Boolean) {
|
fun setCalendarHidden(key: String, hidden: Boolean) {
|
||||||
|
|||||||
Reference in New Issue
Block a user