Compare commits

..
Author SHA1 Message Date
makiolaj f102835c46 Collapse the split grid to the rows its month spans (#162) 2026-09-20 23:01:58 +02:00
18 changed files with 135 additions and 842 deletions
@@ -253,13 +253,6 @@ enum class FailureReason {
PermissionRevoked, PermissionRevoked,
NoCalendarsConfigured, NoCalendarsConfigured,
AllCalendarsHidden, 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, ProviderUnavailable,
EventNotFound, EventNotFound,
Unknown, Unknown,
@@ -166,19 +166,14 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
} }
when { when {
component != null -> { 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 end = indexOfEnd(lines, i + 1, component)
val body = indexOfNextComponent(lines, i + 1, end)
parseComponent( parseComponent(
body = lines.subList(i + 1, body), body = lines.subList(i + 1, end),
fileCalendarName = calendarName, fileCalendarName = calendarName,
warnings = warnings, warnings = warnings,
isTask = component == "VTODO", isTask = component == "VTODO",
)?.let(events::add) )?.let(events::add)
i = if (body < end) body else end + 1 i = end + 1
} }
line.isBegin("VTIMEZONE") -> { line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database. // Skipped wholesale; TZIDs resolve against the OS tz database.
@@ -450,23 +445,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
} }
/** Index of the matching `END:<component>` at/after [from], or list end. */ /** Index of the matching `END:<component>` 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<String>, 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<String>, from: Int, component: String): Int { fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from var i = from
while (i < lines.size) { while (i < lines.size) {
@@ -197,21 +197,10 @@ fun CalendarHost(
// is "restore a backup", not "add this one event". An externally opened .ics // is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form. // keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) } 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) { LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) { if (requestedImportUri != null) {
importUri = requestedImportUri importUri = requestedImportUri
importForceMany = false importForceMany = false
importSession++
onImportConsumed() onImportConsumed()
} }
} }
@@ -521,28 +510,11 @@ fun CalendarHost(
importUri?.let { uri -> importUri?.let { uri ->
ImportScreen( ImportScreen(
uri = uri, uri = uri,
session = importSession,
forceMany = importForceMany, forceMany = importForceMany,
onClose = { onClose = { importUri = null },
importUri = null onManageCalendars = { showCalendars = true },
// 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 -> onOpenSingle = { form ->
importUri = null importUri = null
calendarsAfterImport = false
backupAfterImport = false
importFormSource = ImportSource.File importFormSource = ImportSource.File
importForm = form importForm = form
}, },
@@ -578,33 +550,18 @@ fun CalendarHost(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
// Settings can open this screen without the manager underneath, so BackupScreen(
// its failure states' "Manage calendars" way out has to open the onBack = { showBackup = false },
// manager rather than just pop — popping alone lands on Settings // Restore runs the normal .ics import, and both this screen and
// (#304). Coming from the manager the flag is already set, so this // the manager that can have opened it are declared above the
// is the same pop-back there. // import overlays — so both have to step aside.
CompositionLocalProvider( onImport = {
LocalManageCalendars provides { importUri = it
showCalendars = true importForceMany = true
showBackup = false 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
},
)
}
} }
} }
} }
@@ -4,11 +4,9 @@ import android.net.Uri
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -20,7 +18,6 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -50,10 +47,9 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource 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.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar 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.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.floret.components.DialogUnitDropdown
@@ -84,13 +80,18 @@ fun BackupScreen(
onImport: (Uri) -> Unit, onImport: (Uri) -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.backupState.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } 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 // Exports everything eligible (null); the per-calendar selector owns its
// own launcher. // own launcher.
val createBackup = rememberLauncherForActivityResult( val createBackup = rememberLauncherForActivityResult(
@@ -131,42 +132,78 @@ fun BackupScreen(
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_backup), title = stringResource(R.string.settings_section_backup),
onBack = onBack, 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) }, snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true, predictiveBack = true,
) { ) {
when (val s = state) { HintText(stringResource(R.string.calendars_backup_hint))
BackupUiState.Loading -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack) if (exportable.isNotEmpty()) {
is BackupUiState.Ready -> BackupContent( GroupedRow(
exportable = s.exportable, title = stringResource(R.string.calendars_backup_action),
canImport = s.canImport, position = Position.Top,
autoBackup = autoBackup, leading = { LeadingAvatar(Icons.Default.FileDownload) },
viewModel = viewModel, onClick = {
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, // A single exportable calendar skips the selector.
onPickFolder = { runCatching { pickFolder.launch(null) } }, if (exportable.size == 1) {
onExportAll = { runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
runCatching { createBackup.launch(defaultBackupName()) } } else {
showExportPicker = true
}
}, },
onExportPick = { showExportPicker = true }, )
onEditInterval = { showInterval = true }, 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) } },
) )
} }
} }
// Gated on Ready rather than resetting the flag when the state leaves it: if (showExportPicker) {
// flipping it here would be a write during composition. ExportCalendarPicker(
(state as? BackupUiState.Ready)?.let { ready -> calendars = exportable,
if (showExportPicker) { onExport = viewModel::exportBackup,
ExportCalendarPicker( onDismiss = { showExportPicker = false },
calendars = ready.exportable, )
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
} }
if (showInterval) { if (showInterval) {
BackupIntervalDialog( BackupIntervalDialog(
@@ -177,103 +214,6 @@ 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<CalendarSource>,
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 * 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 * to all selected; the Export action opens the SAF save dialog and hands back
@@ -1,53 +0,0 @@
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<CalendarSource>,
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<CalendarSource>): 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)
}
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,7 +24,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -58,22 +56,6 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(), 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<BackupUiState> =
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. */ /** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine( val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled, settingsPrefs.autoBackupEnabled,
@@ -4,36 +4,19 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.CalendarContract import android.provider.CalendarContract
import android.provider.Settings import android.provider.Settings
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -43,150 +26,52 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
/** /**
* Full-screen failure state shared by every calendar screen (spec §7). * Full-screen failure state shared by every calendar screen (spec §7).
* A tonal icon, one headline, one supporting line and one recovery action * One explanation line + one recovery action, never a toast.
* never a toast.
*/ */
@Composable @Composable
fun CalendarFailure( fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
reason: FailureReason,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current val context = LocalContext.current
val manageCalendars = LocalManageCalendars.current val manageCalendars = LocalManageCalendars.current
val copy = failureCopy(reason) 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 onAction: () -> Unit = when (reason) { val onAction: () -> Unit = when (reason) {
FailureReason.NoCalendarsConfigured -> { FailureReason.NoCalendarsConfigured -> {
{ context.startCalendarSetup() } { context.startCalendarSetup() }
} }
FailureReason.AllCalendarsHidden, FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry
FailureReason.NoImportTarget,
-> manageCalendars ?: onRetry
else -> onRetry else -> onRetry
} }
Column( Column(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 32.dp, vertical = 24.dp), .padding(32.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
FailureIcon(icon = copy.icon, isError = copy.isError)
Spacer(Modifier.height(24.dp))
Text( Text(
text = stringResource(copy.title), text = stringResource(titleRes),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
) )
copy.body?.let { body -> Spacer(Modifier.height(24.dp))
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) { FilledTonalButton(onClick = onAction) {
Text(stringResource(copy.action)) Text(stringResource(actionRes))
} }
} }
} }
/** 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 * Opens Settings → Calendars, the only screen that can switch a calendar back
* on. Null outside the calendar host — screens without it never raise the * on. Null outside the calendar host — screens without it never raise the
@@ -83,8 +83,10 @@ fun calendarSlideTransition(
initialContentExit = initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec), slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which // AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no // shears the pages against the viewport edge as they pass. Left off even
// size change here to contain — both pages are the same grid. // where the two pages differ in height — the split grid stands as many
// rows as its month spans (#162) — since a page sliding out over the row
// below it reads as travel, and the shear reads as a fault.
sizeTransform = SizeTransform(clip = false), sizeTransform = SizeTransform(clip = false),
) )
} }
@@ -302,7 +302,6 @@ fun EventDetailScreen(
is EventDetailUiState.Failure -> CalendarFailure( is EventDetailUiState.Failure -> CalendarFailure(
reason = s.reason, reason = s.reason,
onRetry = viewModel::retry, onRetry = viewModel::retry,
modifier = contentModifier,
) )
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
} }
@@ -73,19 +73,19 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
@Composable @Composable
fun ImportScreen( fun ImportScreen(
uri: Uri, uri: Uri,
session: Int,
onClose: () -> Unit, onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit, onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false, forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null, onManageCalendars: (() -> Unit)? = null,
viewModel: ImportViewModel = hiltViewModel(), // 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()),
) { ) {
// hiltViewModel() resolves to the Activity's store and is retained across LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
// 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() val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review. // A single event isn't shown here — it opens the create form for review.
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget import de.jeanlucmakiola.calendula.domain.isEventTarget
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -70,26 +69,18 @@ class ImportViewModel @Inject constructor(
private val parser = IcsParser() private val parser = IcsParser()
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading) private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
val state: StateFlow<ImportUiState> = _state.asStateFlow() val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var loadedSession: Int? = null private var started = false
private var loadJob: Job? = null
/** /**
* Read + parse [uri] once per [session]; recomposition (and a rotation, * Read + parse [uri] once; subsequent calls (recomposition) are ignored.
* 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 * 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 — * through the bulk picker + summary rather than the prefilled create form —
* a restore is "bring back a backup", not "add this one event". * a restore is "bring back a backup", not "add this one event".
*/ */
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) { fun load(uri: Uri, forceMany: Boolean = false) {
if (loadedSession == session) return if (started) return
loadedSession = session started = true
loadJob?.cancel() viewModelScope.launch {
_state.value = ImportUiState.Loading
loadJob = viewModelScope.launch {
val parsed = withContext(io) { val parsed = withContext(io) {
importer.readText(uri)?.let(parser::parse) importer.readText(uri)?.let(parser::parse)
} }
@@ -1141,10 +1141,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
* lists whatever day is selected — and a downward drag trades the pane away for * lists whatever day is selected — and a downward drag trades the pane away for
* the full paged grid, an upward one brings it back (#53). * the full paged grid, an upward one brings it back (#53).
* *
* The grid slides between months like the paged style, which it can only do * The grid slides between months like the paged style, and stands only as many
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it * rows tall as its own month spans (#162). A swipe between a five-row month and
* stood 46 rows tall, so every swipe shunted the pane up or down by a row on * a six-row one therefore moves the pane by a row as well as swapping the grid;
* top of swapping the grid — the pane now holds still and only the grid moves. * the row it hands back is worth more to the pane than a still edge is.
* *
* Expansion is deliberately **not** a stored preference. It is a way to look at * Expansion is deliberately **not** a stored preference. It is a way to look at
* the month you are on, not a fourth style; persisted, someone would expand it * the month you are on, not a fourth style; persisted, someone would expand it
@@ -1441,13 +1441,6 @@ private val SPLIT_DOT_SIZE = 5.dp
// the paged grid's lanes, so the two caps have to be the same number or a dot // the paged grid's lanes, so the two caps have to be the same number or a dot
// would have no bar to become (#53). // would have no bar to become (#53).
/**
* Rows the split grid always reserves — the most any month needs. A month that
* fits in fewer pads the remainder with blank rows rather than shrinking, which
* is what lets the pane below hold still from month to month.
*/
private const val SPLIT_GRID_ROWS = 6
/** /**
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a * The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
* comfortable tap target on its own. * comfortable tap target on its own.
@@ -1460,6 +1453,11 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
* The split style's grid (#53): the month compressed to day numbers and event * The split style's grid (#53): the month compressed to day numbers and event
* dots, with the selected day listed underneath by [SplitDayPane]. * dots, with the selected day listed underneath by [SplitDayPane].
* *
* Only the rows the month actually spans. It used to pad every month out to six
* so the pane below held still from page to page, but a row is a sixth of the
* grid and a third of what the pane gets to show — too much to leave blank on
* the months that don't need it (#162).
*
* Tapping selects rather than drilling into the Day view — the pane is the * Tapping selects rather than drilling into the Day view — the pane is the
* answer to "what's on this day", so opening a whole screen for it would defeat * answer to "what's on this day", so opening a whole screen for it would defeat
* the layout. The full Day view stays one tap away on the pane's date header. * the layout. The full Day view stays one tap away on the pane's date header.
@@ -1518,12 +1516,6 @@ internal fun SplitMonthGrid(
} }
} }
} }
// Hold the grid at a constant height whatever shape the month is, so the
// pane beneath it doesn't move as you page and one month can slide over
// another without a height change under it.
repeat(SPLIT_GRID_ROWS - state.weeks.size) {
Spacer(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT))
}
} }
} }
-9
View File
@@ -11,20 +11,12 @@
<string name="state_retry">Retry</string> <string name="state_retry">Retry</string>
<string name="state_failure_unknown">Something went wrong.</string> <string name="state_failure_unknown">Something went wrong.</string>
<string name="state_failure_permission">Calendar access is required.</string> <string name="state_failure_permission">Calendar access is required.</string>
<string name="state_failure_permission_body">Calendula reads your events straight from the system calendar, so it needs calendar access to show anything.</string>
<string name="state_failure_permission_action">Grant access</string> <string name="state_failure_permission_action">Grant access</string>
<string name="state_failure_no_calendars">No calendars configured.</string> <string name="state_failure_no_calendars">No calendars configured.</string>
<string name="state_failure_no_calendars_body">Add a calendar account to this device and its calendars show up here.</string>
<string name="state_failure_no_calendars_action">Open system calendar settings</string> <string name="state_failure_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_all_hidden">All your calendars are switched off.</string> <string name="state_failure_all_hidden">All your calendars are switched off.</string>
<string name="state_failure_all_hidden_body">Switch at least one calendar back on to see its events.</string>
<string name="state_failure_all_hidden_action">Manage calendars</string> <string name="state_failure_all_hidden_action">Manage calendars</string>
<string name="state_failure_no_import_target">No calendar can take new events.</string>
<string name="state_failure_no_import_target_body">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.</string>
<string name="state_failure_event_not_found">This event is no longer there.</string>
<string name="state_failure_event_not_found_body">It may have been deleted, or moved to a calendar that is switched off.</string>
<string name="state_failure_provider">Could not read the calendar.</string> <string name="state_failure_provider">Could not read the calendar.</string>
<string name="state_failure_provider_body">The system calendar did not answer. Try again in a moment.</string>
<!-- Long-press a field to copy it (#195) --> <!-- Long-press a field to copy it (#195) -->
<string name="field_copy_action">Copy</string> <string name="field_copy_action">Copy</string>
@@ -687,7 +679,6 @@
<string name="calendars_restore_header">Restore</string> <string name="calendars_restore_header">Restore</string>
<string name="calendars_restore_action">Restore from .ics file</string> <string name="calendars_restore_action">Restore from .ics file</string>
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string> <string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
<string name="calendars_auto_backup">Automatic backup</string> <string name="calendars_auto_backup">Automatic backup</string>
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string> <string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
<string name="calendars_auto_backup_folder">Backup folder</string> <string name="calendars_auto_backup_folder">Backup folder</string>
@@ -36,13 +36,12 @@ class ModelsTest {
} }
@Test @Test
fun `FailureReason enum has all seven variants`() { fun `FailureReason enum has all six variants`() {
assertThat(FailureReason.values().toSet()).isEqualTo( assertThat(FailureReason.values().toSet()).isEqualTo(
setOf( setOf(
FailureReason.PermissionRevoked, FailureReason.PermissionRevoked,
FailureReason.NoCalendarsConfigured, FailureReason.NoCalendarsConfigured,
FailureReason.AllCalendarsHidden, FailureReason.AllCalendarsHidden,
FailureReason.NoImportTarget,
FailureReason.ProviderUnavailable, FailureReason.ProviderUnavailable,
FailureReason.EventNotFound, FailureReason.EventNotFound,
FailureReason.Unknown, FailureReason.Unknown,
@@ -1,116 +0,0 @@
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)
}
}
@@ -1,66 +0,0 @@
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<IcsParseWarning>(), 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 },
)
}
}
@@ -1,90 +0,0 @@
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()
}
}
@@ -1,91 +0,0 @@
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