diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 7fba181..89f086a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -253,6 +253,13 @@ enum class FailureReason { PermissionRevoked, NoCalendarsConfigured, AllCalendarsHidden, + + /** + * Calendars exist and are switched on, but none can receive an event: every + * one is read-only, app-managed, or not synced to this device. Distinct from + * [AllCalendarsHidden], which a visibility switch fixes. + */ + NoImportTarget, ProviderUnavailable, EventNotFound, Unknown, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index 9c5693a..0c95821 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault } when { component != null -> { + // A component whose END line never arrives ends at the next + // one's BEGIN, not at that one's END: reading to the far END + // would fold two events into one body, where the later + // properties overwrite the earlier and one event is lost. val end = indexOfEnd(lines, i + 1, component) + val body = indexOfNextComponent(lines, i + 1, end) parseComponent( - body = lines.subList(i + 1, end), + body = lines.subList(i + 1, body), fileCalendarName = calendarName, warnings = warnings, isTask = component == "VTODO", )?.let(events::add) - i = end + 1 + i = if (body < end) body else end + 1 } line.isBegin("VTIMEZONE") -> { // Skipped wholesale; TZIDs resolve against the OS tz database. @@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault } /** Index of the matching `END:` at/after [from], or list end. */ + /** + * Where an unterminated component's body has to stop: the next top-level + * `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A + * nested `VALARM` is part of the body and is not a boundary. + */ + fun indexOfNextComponent(lines: List, from: Int, end: Int): Int { + var i = from + while (i < end) { + val line = parseContentLine(lines[i]) + if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) { + return i + } + i++ + } + return end + } + fun indexOfEnd(lines: List, from: Int, component: String): Int { var i = from while (i < lines.size) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 0141d94..dc4d5a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -197,10 +197,21 @@ fun CalendarHost( // is "restore a backup", not "add this one event". An externally opened .ics // keeps routing a single event straight into the prefilled create form. var importForceMany by remember { mutableStateOf(false) } + // Where a restore came from, so closing the import puts it back: the import + // overlays are declared under Backup & restore and the manager, so starting + // one has to close them — without this, finishing a restore drops you on the + // calendar and the next file means walking in through Settings again. + var backupAfterImport by rememberSaveable { mutableStateOf(false) } + var calendarsAfterImport by rememberSaveable { mutableStateOf(false) } + // One import run. The import VM lives in the Activity's store, so this is + // what tells it a *re-import of the same file* is new work and not the run + // it already finished; a rotation keeps the number and keeps the result. + var importSession by rememberSaveable { mutableStateOf(0) } LaunchedEffect(requestedImportUri) { if (requestedImportUri != null) { importUri = requestedImportUri importForceMany = false + importSession++ onImportConsumed() } } @@ -510,11 +521,28 @@ fun CalendarHost( importUri?.let { uri -> ImportScreen( uri = uri, + session = importSession, forceMany = importForceMany, - onClose = { importUri = null }, - onManageCalendars = { showCalendars = true }, + onClose = { + importUri = null + // Back to the surface the restore started from, ready for + // the next file. + showCalendars = calendarsAfterImport + showBackup = backupAfterImport + calendarsAfterImport = false + backupAfterImport = false + }, + onManageCalendars = { + // The manager is where this leads, so it must not be + // reopened underneath on close. + calendarsAfterImport = false + backupAfterImport = false + showCalendars = true + }, onOpenSingle = { form -> importUri = null + calendarsAfterImport = false + backupAfterImport = false importFormSource = ImportSource.File importForm = form }, @@ -550,18 +578,33 @@ fun CalendarHost( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), ) { - BackupScreen( - onBack = { showBackup = false }, - // Restore runs the normal .ics import, and both this screen and - // the manager that can have opened it are declared above the - // import overlays — so both have to step aside. - onImport = { - importUri = it - importForceMany = true + // Settings can open this screen without the manager underneath, so + // its failure states' "Manage calendars" way out has to open the + // manager rather than just pop — popping alone lands on Settings + // (#304). Coming from the manager the flag is already set, so this + // is the same pop-back there. + CompositionLocalProvider( + LocalManageCalendars provides { + showCalendars = true showBackup = false - showCalendars = false }, - ) + ) { + BackupScreen( + onBack = { showBackup = false }, + // Restore runs the normal .ics import, and both this screen + // and the manager that can have opened it are declared above + // the import overlays — so both have to step aside. + onImport = { + importUri = it + importForceMany = true + importSession++ + backupAfterImport = true + calendarsAfterImport = showCalendars + showBackup = false + showCalendars = false + }, + ) + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index f32663f..6fc13fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -4,9 +4,11 @@ import android.net.Uri import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -47,9 +50,10 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource -import de.jeanlucmakiola.calendula.domain.isEventTarget import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar +import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogUnitDropdown @@ -80,18 +84,13 @@ fun BackupScreen( onImport: (Uri) -> Unit, viewModel: CalendarsViewModel = hiltViewModel(), ) { - val calendars by viewModel.calendars.collectAsStateWithLifecycle() + val state by viewModel.backupState.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } - // Export covers local calendars only; managed special-dates mirrors are - // rebuilt from contacts. Restore can target anything the import picker offers. - val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged } - val canImport = calendars.any { it.isEventTarget } - // Exports everything eligible (null); the per-calendar selector owns its // own launcher. val createBackup = rememberLauncherForActivityResult( @@ -132,78 +131,42 @@ fun BackupScreen( CollapsingScaffold( title = stringResource(R.string.settings_section_backup), onBack = onBack, + // Loading and failure fill the screen and centre themselves, which they + // can only do in an unscrolled column — the scrolling one measures them + // against an unbounded height and leaves them hanging under the header. + scrollable = state is BackupUiState.Ready, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, ) { - HintText(stringResource(R.string.calendars_backup_hint)) - - if (exportable.isNotEmpty()) { - GroupedRow( - title = stringResource(R.string.calendars_backup_action), - position = Position.Top, - leading = { LeadingAvatar(Icons.Default.FileDownload) }, - onClick = { - // A single exportable calendar skips the selector. - if (exportable.size == 1) { - runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } - } else { - showExportPicker = true - } + when (val s = state) { + BackupUiState.Loading -> BackupLoading() + is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack) + is BackupUiState.Ready -> BackupContent( + exportable = s.exportable, + canImport = s.canImport, + autoBackup = autoBackup, + viewModel = viewModel, + onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, + onPickFolder = { runCatching { pickFolder.launch(null) } }, + onExportAll = { + runCatching { createBackup.launch(defaultBackupName()) } }, - ) - GroupedRow( - title = stringResource(R.string.calendars_restore_action), - summary = stringResource(R.string.calendars_restore_hint), - position = Position.Middle, - leading = { LeadingAvatar(Icons.Default.FileUpload) }, - onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, - ) - GroupedRow( - title = stringResource(R.string.calendars_auto_backup), - summary = stringResource(R.string.calendars_auto_backup_hint), - position = if (autoBackup.enabled) Position.Middle else Position.Bottom, - leading = { LeadingAvatar(Icons.Default.Schedule) }, - trailing = { - Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled) - }, - onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) }, - ) - if (autoBackup.enabled) { - GroupedRow( - title = stringResource(R.string.calendars_auto_backup_folder), - summary = rememberFolderName(autoBackup.folderUri) - ?: stringResource(R.string.calendars_auto_backup_folder_unset), - position = Position.Middle, - onClick = { runCatching { pickFolder.launch(null) } }, - ) - GroupedRow( - title = stringResource(R.string.calendars_auto_backup_interval), - summary = backupIntervalLabel(autoBackup.intervalMinutes), - position = Position.Bottom, - onClick = { showInterval = true }, - ) - HintText(backupStatusText(autoBackup.status)) - } - } else if (canImport) { - // Nothing to back up, but restore is still possible — don't hide - // it behind export eligibility. - SectionHeader(stringResource(R.string.calendars_restore_header)) - HintText(stringResource(R.string.calendars_restore_hint)) - GroupedRow( - title = stringResource(R.string.calendars_restore_action), - position = Position.Alone, - leading = { LeadingAvatar(Icons.Default.FileUpload) }, - onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, + onExportPick = { showExportPicker = true }, + onEditInterval = { showInterval = true }, ) } } - if (showExportPicker) { - ExportCalendarPicker( - calendars = exportable, - onExport = viewModel::exportBackup, - onDismiss = { showExportPicker = false }, - ) + // Gated on Ready rather than resetting the flag when the state leaves it: + // flipping it here would be a write during composition. + (state as? BackupUiState.Ready)?.let { ready -> + if (showExportPicker) { + ExportCalendarPicker( + calendars = ready.exportable, + onExport = viewModel::exportBackup, + onDismiss = { showExportPicker = false }, + ) + } } if (showInterval) { BackupIntervalDialog( @@ -214,6 +177,103 @@ fun BackupScreen( } } +/** The screen's loading state, centred in the scaffold's content column. */ +@Composable +private fun BackupLoading() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +/** Default file name for a one-shot export. */ +private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics" + +/** + * The working screen: export (when there are local calendars to export), restore, + * and the automatic-backup block. Restore is always on screen — when nothing can + * receive the events it says so and routes to the calendar manager rather than + * disappearing, which is what #304 reported as an invisible button. + */ +@Composable +private fun BackupContent( + exportable: List, + canImport: Boolean, + autoBackup: AutoBackupUiState, + viewModel: CalendarsViewModel, + onRestore: () -> Unit, + onPickFolder: () -> Unit, + onExportAll: () -> Unit, + onExportPick: () -> Unit, + onEditInterval: () -> Unit, +) { + val manageCalendars = LocalManageCalendars.current + // Restore never depends on export eligibility, and never disappears: when no + // calendar can receive events it explains that and offers the way to fix it. + val restoreSummary = if (canImport) { + stringResource(R.string.calendars_restore_hint) + } else { + stringResource(R.string.calendars_restore_unavailable) + } + val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore) + + if (exportable.isEmpty()) { + SectionHeader(stringResource(R.string.calendars_restore_header)) + HintText(restoreSummary) + GroupedRow( + title = stringResource(R.string.calendars_restore_action), + position = Position.Alone, + leading = { LeadingAvatar(Icons.Default.FileUpload) }, + onClick = onRestoreClick, + ) + return + } + + HintText(stringResource(R.string.calendars_backup_hint)) + GroupedRow( + title = stringResource(R.string.calendars_backup_action), + position = Position.Top, + leading = { LeadingAvatar(Icons.Default.FileDownload) }, + // A single exportable calendar skips the selector. + onClick = if (exportable.size == 1) onExportAll else onExportPick, + ) + GroupedRow( + title = stringResource(R.string.calendars_restore_action), + summary = restoreSummary, + position = Position.Middle, + leading = { LeadingAvatar(Icons.Default.FileUpload) }, + onClick = onRestoreClick, + ) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup), + summary = stringResource(R.string.calendars_auto_backup_hint), + position = if (autoBackup.enabled) Position.Middle else Position.Bottom, + leading = { LeadingAvatar(Icons.Default.Schedule) }, + trailing = { + Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled) + }, + onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) }, + ) + if (autoBackup.enabled) { + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_folder), + summary = rememberFolderName(autoBackup.folderUri) + ?: stringResource(R.string.calendars_auto_backup_folder_unset), + position = Position.Middle, + onClick = onPickFolder, + ) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_interval), + summary = backupIntervalLabel(autoBackup.intervalMinutes), + position = Position.Bottom, + onClick = onEditInterval, + ) + HintText(backupStatusText(autoBackup.status)) + } +} + /** * Choose which local calendars to include in a one-time `.ics` export. Defaults * to all selected; the Export action opens the SAF save dialog and hands back diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt new file mode 100644 index 0000000..3be7c6b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.calendula.ui.calendars + +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.calendarListFailure +import de.jeanlucmakiola.calendula.domain.isEventTarget + +/** + * State of the Backup & restore screen (#69). Three states, because the calendar + * list it is derived from has all three: it arrives empty, it can throw, and + * "loaded but nothing to offer" is a real outcome the screen used to render as a + * blank page (#304). + */ +sealed interface BackupUiState { + data object Loading : BackupUiState + data class Failure(val reason: FailureReason) : BackupUiState + + /** + * At least one half of the screen works. [exportable] may be empty (restore + * only) and [canImport] may be false (export only), but never both — that is + * a [Failure]. + */ + data class Ready( + val exportable: List, + val canImport: Boolean, + ) : BackupUiState +} + +/** + * What the screen can offer for this calendar list. + * + * Export covers the app's own local calendars; managed special-dates mirrors are + * rebuilt from contacts, so they are excluded. Restore can target anything the + * import picker offers ([isEventTarget]). + * + * A failure is raised only when *neither* is possible — deliberately not + * [calendarListFailure] on its own, which would call an all-hidden device a + * failure while its local calendars are still perfectly exportable. When both + * halves are dead the list itself usually says why (no calendars, everything + * switched off); [FailureReason.NoImportTarget] covers the remaining case, where + * calendars exist and are visible but every one of them is read-only, managed or + * not synced to the device. + */ +fun backupUiState(calendars: List): BackupUiState { + val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged } + val canImport = calendars.any { it.isEventTarget } + if (exportable.isEmpty() && !canImport) { + return BackupUiState.Failure( + calendarListFailure(calendars) ?: FailureReason.NoImportTarget, + ) + } + return BackupUiState.Ready(exportable = exportable, canImport = canImport) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 49576df..38eadec 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow @@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor( initialValue = emptyList(), ) + /** + * The Backup & restore screen's own view of that list, with the loading and + * failure states [calendars] flattens away — it starts empty and catches to + * empty, which that screen used to render as a blank page (#304). + */ + val backupState: StateFlow = + repository.calendars() + .map { backupUiState(it) } + .catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = BackupUiState.Loading, + ) + /** Automatic-backup settings + last-run status, for the Backup section UI. */ val autoBackup: StateFlow = combine( settingsPrefs.autoBackupEnabled, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt index 8eee97a..3db5b96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt @@ -4,19 +4,36 @@ import android.content.Context import android.content.Intent import android.provider.CalendarContract import android.provider.Settings +import androidx.annotation.StringRes +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.EditOff +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.EventBusy +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.SyncProblem +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -26,52 +43,150 @@ import de.jeanlucmakiola.calendula.domain.FailureReason /** * Full-screen failure state shared by every calendar screen (spec §7). - * One explanation line + one recovery action, never a toast. + * A tonal icon, one headline, one supporting line and one recovery action — + * never a toast. */ @Composable -fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { +fun CalendarFailure( + reason: FailureReason, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { val context = LocalContext.current val manageCalendars = LocalManageCalendars.current - val titleRes = when (reason) { - FailureReason.PermissionRevoked -> R.string.state_failure_permission - FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars - FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden - FailureReason.ProviderUnavailable -> R.string.state_failure_provider - FailureReason.Unknown, - FailureReason.EventNotFound -> R.string.state_failure_unknown - } - val actionRes = when (reason) { - FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action - FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action - FailureReason.PermissionRevoked -> R.string.state_failure_permission_action - else -> R.string.state_retry - } + val copy = failureCopy(reason) val onAction: () -> Unit = when (reason) { FailureReason.NoCalendarsConfigured -> { { context.startCalendarSetup() } } - FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry + FailureReason.AllCalendarsHidden, + FailureReason.NoImportTarget, + -> manageCalendars ?: onRetry else -> onRetry } Column( - modifier = Modifier + modifier = modifier .fillMaxSize() - .padding(32.dp), + .padding(horizontal = 32.dp, vertical = 24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + FailureIcon(icon = copy.icon, isError = copy.isError) + Spacer(Modifier.height(24.dp)) Text( - text = stringResource(titleRes), + text = stringResource(copy.title), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH), ) - Spacer(Modifier.height(24.dp)) + copy.body?.let { body -> + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH), + ) + } + Spacer(Modifier.height(32.dp)) FilledTonalButton(onClick = onAction) { - Text(stringResource(actionRes)) + Text(stringResource(copy.action)) } } } +/** The state's mark: the reason's icon in a tonal circle above the headline. */ +@Composable +private fun FailureIcon(icon: ImageVector, isError: Boolean) { + val container: Color + val content: Color + if (isError) { + container = MaterialTheme.colorScheme.errorContainer + content = MaterialTheme.colorScheme.onErrorContainer + } else { + container = MaterialTheme.colorScheme.secondaryContainer + content = MaterialTheme.colorScheme.onSecondaryContainer + } + Box( + modifier = Modifier + .size(72.dp) + .background(container, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = content, + modifier = Modifier.size(36.dp), + ) + } +} + +/** + * What a reason says and offers. Headline and supporting line are separate so + * the explanation reads as body text instead of a headline that runs over three + * lines; [isError] tints the mark for the two reasons that are an actual fault + * rather than a calendar the user can switch back on. + */ +private data class FailureCopy( + val icon: ImageVector, + @StringRes val title: Int, + @StringRes val body: Int?, + @StringRes val action: Int, + val isError: Boolean = false, +) + +private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) { + FailureReason.PermissionRevoked -> FailureCopy( + icon = Icons.Outlined.Lock, + title = R.string.state_failure_permission, + body = R.string.state_failure_permission_body, + action = R.string.state_failure_permission_action, + ) + FailureReason.NoCalendarsConfigured -> FailureCopy( + icon = Icons.Outlined.CalendarMonth, + title = R.string.state_failure_no_calendars, + body = R.string.state_failure_no_calendars_body, + action = R.string.state_failure_no_calendars_action, + ) + FailureReason.AllCalendarsHidden -> FailureCopy( + icon = Icons.Outlined.VisibilityOff, + title = R.string.state_failure_all_hidden, + body = R.string.state_failure_all_hidden_body, + action = R.string.state_failure_all_hidden_action, + ) + FailureReason.NoImportTarget -> FailureCopy( + icon = Icons.Outlined.EditOff, + title = R.string.state_failure_no_import_target, + body = R.string.state_failure_no_import_target_body, + action = R.string.state_failure_all_hidden_action, + ) + FailureReason.EventNotFound -> FailureCopy( + icon = Icons.Outlined.EventBusy, + title = R.string.state_failure_event_not_found, + body = R.string.state_failure_event_not_found_body, + action = R.string.state_retry, + ) + FailureReason.ProviderUnavailable -> FailureCopy( + icon = Icons.Outlined.SyncProblem, + title = R.string.state_failure_provider, + body = R.string.state_failure_provider_body, + action = R.string.state_retry, + isError = true, + ) + FailureReason.Unknown -> FailureCopy( + icon = Icons.Outlined.ErrorOutline, + title = R.string.state_failure_unknown, + body = null, + action = R.string.state_retry, + isError = true, + ) +} + +/** Keeps the headline and its supporting line at a readable measure. */ +private val TEXT_MAX_WIDTH = 320.dp + /** * Opens Settings → Calendars, the only screen that can switch a calendar back * on. Null outside the calendar host — screens without it never raise the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 12a32ee..c183e61 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -302,6 +302,7 @@ fun EventDetailScreen( is EventDetailUiState.Failure -> CalendarFailure( reason = s.reason, onRetry = viewModel::retry, + modifier = contentModifier, ) is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index a3b7690..fcf33d3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -73,19 +73,19 @@ import de.jeanlucmakiola.floret.identity.predictiveBack @Composable fun ImportScreen( uri: Uri, + session: Int, onClose: () -> Unit, onOpenSingle: (EventForm) -> Unit, forceMany: Boolean = false, onManageCalendars: (() -> Unit)? = null, - // Key the VM by the file uri. This screen has no nav backstack, so an - // unkeyed hiltViewModel() resolves to the Activity's store and is retained - // across imports — its one-shot `load` guard would then show the *previous* - // file's parsed state on the next import (a second restore, export→restore, - // etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading - // state), while the same uri (rotation) reuses it and holds the result. - viewModel: ImportViewModel = hiltViewModel(key = uri.toString()), + viewModel: ImportViewModel = hiltViewModel(), ) { - LaunchedEffect(uri) { viewModel.load(uri, forceMany) } + // hiltViewModel() resolves to the Activity's store and is retained across + // imports, so the reload is driven by [session] rather than by a fresh VM: + // keying per uri looked right but handed a *re-import of the same file* the + // previous run's finished state, importing nothing (#304). A rotation keeps + // the session and so keeps the result. + LaunchedEffect(session) { viewModel.load(uri, forceMany, session) } val state by viewModel.state.collectAsStateWithLifecycle() // A single event isn't shown here — it opens the create form for review. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt index 46a18a2..8dceb1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt @@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.toEventForm import de.jeanlucmakiola.calendula.domain.isEventTarget import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor( private val parser = IcsParser() private val _state = MutableStateFlow(ImportUiState.Loading) val state: StateFlow = _state.asStateFlow() - private var started = false + private var loadedSession: Int? = null + private var loadJob: Job? = null /** - * Read + parse [uri] once; subsequent calls (recomposition) are ignored. + * Read + parse [uri] once per [session]; recomposition (and a rotation, + * which keeps the session) re-calls this and keeps the result. A new import + * of the *same* file is a new session, and has to parse and run again — + * keying on the uri alone showed the previous run's summary and imported + * nothing (#304). + * * When [forceMany] is set (an in-app restore), a single-event file still goes * through the bulk picker + summary rather than the prefilled create form — * a restore is "bring back a backup", not "add this one event". */ - fun load(uri: Uri, forceMany: Boolean = false) { - if (started) return - started = true - viewModelScope.launch { + fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) { + if (loadedSession == session) return + loadedSession = session + loadJob?.cancel() + _state.value = ImportUiState.Loading + loadJob = viewModelScope.launch { val parsed = withContext(io) { importer.readText(uri)?.let(parser::parse) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bdc610f..4e18d0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,12 +11,20 @@ Retry Something went wrong. Calendar access is required. + Calendula reads your events straight from the system calendar, so it needs calendar access to show anything. Grant access No calendars configured. + Add a calendar account to this device and its calendars show up here. Open system calendar settings All your calendars are switched off. + Switch at least one calendar back on to see its events. Manage calendars + No calendar can take new events. + The calendars that are switched on are read-only, managed by another app, or not synced to this device. Switch on a calendar that can take events, or add a local one. + This event is no longer there. + It may have been deleted, or moved to a calendar that is switched off. Could not read the calendar. + The system calendar did not answer. Try again in a moment. Copy @@ -679,6 +687,7 @@ Restore Restore from .ics file Import events from a backup or another calendar app. + No calendar can take the imported events yet. Automatic backup Periodically export your local calendars to a folder as an .ics file. Backup folder diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt index 324acf6..25d4503 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt @@ -36,12 +36,13 @@ class ModelsTest { } @Test - fun `FailureReason enum has all six variants`() { + fun `FailureReason enum has all seven variants`() { assertThat(FailureReason.values().toSet()).isEqualTo( setOf( FailureReason.PermissionRevoked, FailureReason.NoCalendarsConfigured, FailureReason.AllCalendarsHidden, + FailureReason.NoImportTarget, FailureReason.ProviderUnavailable, FailureReason.EventNotFound, FailureReason.Unknown, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt new file mode 100644 index 0000000..df3dd5a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.Availability +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test + +/** + * Reading a Google Calendar export (Codeberg #304), whose dialect the parser had + * never been held to: `VTIMEZONE` blocks with `X-LIC-LOCATION`, `TZID`-qualified + * `DTSTART`/`EXDATE`, folded `DESCRIPTION` and `ATTENDEE` lines, `RECURRENCE-ID` + * overrides written as separate `VEVENT`s, and `@google.com` UIDs. + * + * The fixture is a trimmed copy of what "Settings → Import & export → Export" + * produces, CRLF and all. Note that Google hands that export out as a **zip** + * containing one `.ics` per calendar — the file here is one member of it. + */ +class IcsGoogleImportTest { + + private val parser = IcsParser(TimeZone.of("Europe/Berlin")) + + private val result: IcsParseResult = parser.parse( + checkNotNull( + javaClass.classLoader?.getResourceAsStream("ics/google-calendar-export.ics"), + ).use { it.readBytes().toString(Charsets.UTF_8) }, + ) + + private fun event(summary: String) = result.events.single { it.summary == summary } + + @Test + fun `every master event imports and the VTIMEZONE block is not mistaken for one`() { + assertThat(result.events.map { it.summary }) + .containsExactly("Team standup", "Day off", "Christmas break") + } + + @Test + fun `X-WR-CALNAME names the calendar for all of them`() { + assertThat(result.events.map { it.calendarName }.distinct()) + .containsExactly("jane@example.com") + } + + @Test + fun `a TZID-qualified start resolves against the device tz database`() { + val standup = event("Team standup") + assertThat(standup.isAllDay).isFalse() + assertThat(standup.zoneId).isEqualTo("Europe/Berlin") + // 10:00 CEST is 08:00 UTC. + assertThat(standup.start.toString()).isEqualTo("2026-09-15T08:00:00Z") + assertThat((standup.end - standup.start).inWholeMinutes).isEqualTo(90) + } + + @Test + fun `the recurrence rule and its TZID-qualified EXDATE survive`() { + val standup = event("Team standup") + assertThat(standup.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=TU") + assertThat(standup.exDates).containsExactly("20260929T080000Z") + } + + @Test + fun `the moved occurrence is skipped rather than imported as a duplicate`() { + // Google writes a RECURRENCE-ID override as its own VEVENT carrying the + // master's UID. Importing it would put a second "standup" in the + // calendar; Calendula models no overrides, so it is reported instead. + assertThat(result.events.map { it.summary }).doesNotContain("Team standup (moved)") + assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped) + } + + @Test + fun `a folded DESCRIPTION is unfolded and unescaped`() { + assertThat(event("Team standup").description).isEqualTo( + "Weekly sync with the team.\nAgenda lives in the shared doc, see the link below.", + ) + } + + @Test + fun `an escaped comma in LOCATION comes back as a comma`() { + assertThat(event("Team standup").location).isEqualTo("Meeting room 2, 3rd floor") + } + + @Test + fun `attendees are reported rather than silently dropped`() { + assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored) + } + + @Test + fun `a timed VALARM becomes its lead time in minutes`() { + assertThat(event("Team standup").semanticReminderMinutes()).containsExactly(30) + } + + @Test + fun `an all-day event keeps the single day the file gives it`() { + val dayOff = event("Day off") + assertThat(dayOff.isAllDay).isTrue() + assertThat((dayOff.end - dayOff.start).inWholeDays).isEqualTo(1) + assertThat(dayOff.availability).isEqualTo(Availability.Free) + } + + @Test + fun `an all-day alarm comes back as whole days before`() { + // Google writes an all-day reminder as a whole-day offset from the + // event's UTC midnight; a day has to survive as a day. + assertThat(event("Day off").semanticReminderMinutes()).containsExactly(1440) + } + + @Test + fun `a multi-day all-day event keeps its exclusive DTEND span`() { + val christmas = event("Christmas break") + assertThat(christmas.isAllDay).isTrue() + assertThat((christmas.end - christmas.start).inWholeDays).isEqualTo(3) + } + + @Test + fun `no recurrence rule is repaired - Google writes them well-formed`() { + assertThat(result.warnings).doesNotContain(IcsParseWarning.RecurrenceRuleRepaired) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt new file mode 100644 index 0000000..c46821b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * A component whose `END:` line never arrives (a download cut short, a producer + * that dropped a line). `indexOfEnd` falls back to the end of the file, so the + * block is read as if it had been closed. + */ +class IcsUnterminatedComponentTest { + + private val parser = IcsParser(TimeZone.of("Europe/Berlin")) + + @Test + fun `file truncated mid-property keeps the event it had started`() { + val result = parser.parse( + """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:truncated-1@fixture + SUMMARY:Cut off mid-file + DTSTART:20260924T100000Z + DTEND:20260924T1 + """.trimIndent(), + ) + assertEquals(1, result.events.size) + val event = result.events.single() + assertEquals("Cut off mid-file", event.summary) + // The truncated DTEND does not parse, so the event is zero length. + assertEquals(event.start, event.end) + assertEquals(emptySet(), result.warnings) + } + + @Test + fun `a missing END between two events does not merge them`() { + val result = parser.parse( + """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:first@fixture + SUMMARY:First event + DTSTART:20260924T100000Z + DTEND:20260924T110000Z + BEGIN:VEVENT + UID:second@fixture + SUMMARY:Second event + DTSTART:20260925T100000Z + DTEND:20260925T110000Z + END:VEVENT + END:VCALENDAR + """.trimIndent(), + ) + assertEquals( + listOf("First event", "Second event"), + result.events.map { it.summary }, + ) + assertEquals( + listOf("first@fixture", "second@fixture"), + result.events.map { it.uid }, + ) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt new file mode 100644 index 0000000..2d71c97 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt @@ -0,0 +1,90 @@ +package de.jeanlucmakiola.calendula.ui.calendars + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.FailureReason +import org.junit.jupiter.api.Test + +/** What the Backup & restore screen offers for a given calendar list (#304). */ +class BackupUiStateTest { + + private fun cal( + id: Long, + local: Boolean = false, + writable: Boolean = true, + visible: Boolean = true, + managed: Boolean = false, + syncs: Boolean = true, + ) = CalendarSource( + id = id, + displayName = "Calendar $id", + accountName = "acc@example.com", + accountType = if (local) "LOCAL" else "com.google", + color = 0, + isVisibleInSystem = visible, + canModifyContents = writable, + isLocal = local, + isManaged = managed, + syncsEvents = syncs, + ) + + private fun failure(state: BackupUiState) = (state as BackupUiState.Failure).reason + + private fun ready(state: BackupUiState) = state as BackupUiState.Ready + + @Test + fun `no calendars at all reports the empty device`() { + assertThat(failure(backupUiState(emptyList()))) + .isEqualTo(FailureReason.NoCalendarsConfigured) + } + + @Test + fun `a synced writable calendar can receive a restore`() { + val state = ready(backupUiState(listOf(cal(1L)))) + assertThat(state.canImport).isTrue() + assertThat(state.exportable).isEmpty() + } + + @Test + fun `the reported case - only a calendar that cannot take events`() { + // #304: a Google account with calendar sync switched off leaves nothing + // exportable and nothing importable, which used to render a blank screen. + assertThat(failure(backupUiState(listOf(cal(1L, syncs = false))))) + .isEqualTo(FailureReason.NoImportTarget) + } + + @Test + fun `a read-only subscription is no import target either`() { + assertThat(failure(backupUiState(listOf(cal(1L, writable = false))))) + .isEqualTo(FailureReason.NoImportTarget) + } + + @Test + fun `everything switched off is reported as hidden, not as a missing target`() { + // The remedy differs: a visibility switch fixes this one. + assertThat(failure(backupUiState(listOf(cal(1L, visible = false))))) + .isEqualTo(FailureReason.AllCalendarsHidden) + } + + @Test + fun `a hidden local calendar still exports`() { + // Export reads the provider directly, so system visibility is irrelevant + // to it — calling this a failure would hide a working action. + val state = ready(backupUiState(listOf(cal(1L, local = true, visible = false)))) + assertThat(state.exportable).hasSize(1) + assertThat(state.canImport).isFalse() + } + + @Test + fun `a managed special-dates mirror is neither exportable nor a target`() { + assertThat(failure(backupUiState(listOf(cal(1L, local = true, managed = true))))) + .isEqualTo(FailureReason.NoImportTarget) + } + + @Test + fun `a local calendar alongside a synced one offers both halves`() { + val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L)))) + assertThat(state.exportable.map { it.id }).containsExactly(1L) + assertThat(state.canImport).isTrue() + } +} diff --git a/app/src/test/resources/ics/google-calendar-export.ics b/app/src/test/resources/ics/google-calendar-export.ics new file mode 100644 index 0000000..8c7dcd1 --- /dev/null +++ b/app/src/test/resources/ics/google-calendar-export.ics @@ -0,0 +1,91 @@ +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:jane@example.com +X-WR-TIMEZONE:Europe/Berlin +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260915T100000 +DTEND;TZID=Europe/Berlin:20260915T113000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE;TZID=Europe/Berlin:20260929T100000 +DTSTAMP:20260912T183000Z +UID:0abcdef1234567890abcdef12345@google.com +CREATED:20260901T120000Z +DESCRIPTION:Weekly sync with the team.\nAgenda lives in the shared doc\, see + the link below. +LAST-MODIFIED:20260902T081500Z +LOCATION:Meeting room 2\, 3rd floor +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Team standup +TRANSP:OPAQUE +ORGANIZER;CN=Jane Doe:mailto:jane@example.com +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=John:m + ailto:john@example.com +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-PT30M +END:VALARM +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260929T140000 +DTEND;TZID=Europe/Berlin:20260929T153000 +DTSTAMP:20260912T183000Z +UID:0abcdef1234567890abcdef12345@google.com +RECURRENCE-ID;TZID=Europe/Berlin:20260929T100000 +CREATED:20260901T120000Z +LAST-MODIFIED:20260910T091500Z +SEQUENCE:1 +STATUS:CONFIRMED +SUMMARY:Team standup (moved) +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261024 +DTEND;VALUE=DATE:20261025 +DTSTAMP:20260912T183000Z +UID:1bcdef01234567890abcdef23456@google.com +CREATED:20260820T101500Z +LAST-MODIFIED:20260820T101500Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Day off +TRANSP:TRANSPARENT +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-P1D +END:VALARM +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261224 +DTEND;VALUE=DATE:20261227 +DTSTAMP:20260912T183000Z +UID:2cdef012345678901abcdef34567@google.com +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Christmas break +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR