Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0766f883e9 | ||
|
|
714f45d869 | ||
|
|
2670269776 |
@@ -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,
|
||||
|
||||
@@ -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:<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 {
|
||||
var i = from
|
||||
while (i < lines.size) {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
|
||||
@@ -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<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,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<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. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
|
||||
@@ -32,6 +32,68 @@ val BLOCK_TEXT_PADDING = 4.dp
|
||||
/** The same, above and below — what a block's height has to pay before any text. */
|
||||
val BLOCK_TEXT_INSET = 2.dp
|
||||
|
||||
/**
|
||||
* What a timed block of a given height has to spend on text, and what each line
|
||||
* of it costs.
|
||||
*/
|
||||
@Immutable
|
||||
data class BlockTextMetrics(
|
||||
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
|
||||
val inset: Dp,
|
||||
/** Height left for text once [inset] is paid at both edges. */
|
||||
val available: Dp,
|
||||
/** What the first line of a title draws in. */
|
||||
val titleLine: Dp,
|
||||
/** What every title line after the first adds. */
|
||||
val titleLeading: Dp,
|
||||
/** What the time label's one line draws in. */
|
||||
val timeLine: Dp,
|
||||
) {
|
||||
/** Whether the block can draw a title at all. */
|
||||
val fitsTitle: Boolean get() = available >= titleLine
|
||||
|
||||
/** Height a title of [lines] lines occupies. */
|
||||
fun titleHeight(lines: Int): Dp =
|
||||
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
|
||||
|
||||
/** Title lines that fit [within], which may be none. */
|
||||
fun titleBudget(within: Dp): Int =
|
||||
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* The vertical padding a block [height] tall can afford around a title line of
|
||||
* [titleLine].
|
||||
*
|
||||
* The inset is what the block gives up first: breathing room is worth having
|
||||
* where there is room to breathe, but on a block down to its last few pixels a
|
||||
* bare colour chip where a label would have fit reads as a rendering fault. It
|
||||
* tapers rather than snapping, so a pinch closes the gap gradually instead of
|
||||
* dropping it in one frame (#289).
|
||||
*/
|
||||
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
|
||||
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
|
||||
|
||||
/** Text metrics for a timed block [height] tall. */
|
||||
@Composable
|
||||
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
|
||||
val titleStyle = MaterialTheme.typography.labelMedium
|
||||
val titleLine = rememberTrimmedLineHeight(titleStyle)
|
||||
// Packed, so a second line costs what the first did rather than a whole
|
||||
// Material line box — the gap between two lines of a wrapped title is the
|
||||
// one place a block pays that leading twice (#190).
|
||||
val titleLeading = titleLine
|
||||
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
|
||||
val inset = blockTextInset(height, titleLine)
|
||||
return BlockTextMetrics(
|
||||
inset = inset,
|
||||
available = height - inset * 2,
|
||||
titleLine = titleLine,
|
||||
titleLeading = titleLeading,
|
||||
timeLine = timeLine,
|
||||
)
|
||||
}
|
||||
|
||||
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
||||
const val MAX_TIME_LINES = 2
|
||||
|
||||
@@ -70,9 +132,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
|
||||
*/
|
||||
@Composable
|
||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||
val timeLineHeight = with(LocalDensity.current) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = rememberTrimmedLineHeight(
|
||||
MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
)
|
||||
return if (spare >= timeLineHeight) {
|
||||
blockTextLines(
|
||||
text = label,
|
||||
@@ -108,8 +170,10 @@ fun BlockTitle(
|
||||
Text(
|
||||
text = title,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||
style = rememberPackedLines(
|
||||
MaterialTheme.typography.labelMedium
|
||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||
),
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
@@ -141,6 +205,8 @@ fun BlockTimeLabel(
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
@@ -149,8 +215,7 @@ fun BlockTimeLabel(
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -790,20 +790,15 @@ private fun DragCopy(
|
||||
val width = with(density) { sizePx.width.toDp() }
|
||||
val height = with(density) { sizePx.height.toDp() }
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// The block's size, spent the block's way — text sits at the top as it does
|
||||
// on the block, so the copy hands back to the grid without shifting (#267).
|
||||
// on the block, so the copy hands back to the grid without shifting (#267),
|
||||
// and it squeezes its inset on the same terms so a short block's title does
|
||||
// not vanish the moment it is lifted (#289).
|
||||
// The title is served in full first and the range lives off what is left:
|
||||
// the hour gutter down the side still says where the copy sits, so the
|
||||
// range is the half that can afford to go.
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
|
||||
val allowed = titleLines.coerceAtMost(titleBudget)
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
|
||||
// Re-measured at the copy's own width rather than spent on the source's
|
||||
// count: a block sharing its column with another is a lane wide where the
|
||||
// copy is a whole column, so the source's second line is one the copy never
|
||||
@@ -818,10 +813,10 @@ private fun DragCopy(
|
||||
max = allowed,
|
||||
)
|
||||
}
|
||||
val left = available - titleLineHeight * lines
|
||||
val showTime = label != null && left >= timeLineHeight
|
||||
val left = metrics.available - metrics.titleHeight(lines)
|
||||
val showTime = label != null && left >= metrics.timeLine
|
||||
val timeMaxLines = if (showTime) {
|
||||
blockTimeLines(label!!, textWidth, left - timeLineHeight)
|
||||
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
@@ -857,7 +852,7 @@ private fun DragCopy(
|
||||
clip = false
|
||||
}
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
|
||||
) {
|
||||
Column {
|
||||
if (lines > 0) {
|
||||
|
||||
@@ -83,9 +83,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
@@ -765,30 +765,24 @@ private fun EventBlock(
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
// A block that cannot afford both lines spends its space on the title, and
|
||||
// one too short even for that drops the title rather than serving a sliced one.
|
||||
// Height alone decides: a duration threshold would keep hiding the time on a
|
||||
// half-hour block the user has pinched open to three times the room it needs.
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val showTime = available >= titleLineHeight + timeLineHeight
|
||||
val showTitle = available >= titleLineHeight
|
||||
val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
|
||||
val showTitle = metrics.fitsTitle
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// Only lines the block can actually draw: a block too short for the time is
|
||||
// too short for a second title line too, and asking for one served a sliced
|
||||
// one — as well as handing the drag copy a count it couldn't honour, so the
|
||||
// title re-wrapped the moment the block was lifted (#267).
|
||||
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
|
||||
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
|
||||
// On a day column — wide enough for "09:30–11:00" several times over — the
|
||||
// range never needs the second line, until lanes cut the column down.
|
||||
val spare = available - titleLineHeight * titleMaxLines -
|
||||
if (showTime) timeLineHeight else 0.dp
|
||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
||||
if (showTime) metrics.timeLine else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val paint = eventPaint(block.event, dark)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
@@ -829,7 +823,7 @@ private fun EventBlock(
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>(ImportUiState.Loading)
|
||||
val state: StateFlow<ImportUiState> = _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)
|
||||
}
|
||||
|
||||
@@ -91,11 +91,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
@@ -911,13 +911,7 @@ private fun EventBlock(
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||
@@ -925,19 +919,19 @@ private fun EventBlock(
|
||||
// its own: a duration threshold would keep hiding the time on a half-hour
|
||||
// block the user has pinched open to three times the room it needs.
|
||||
val showTime = block.laneCount == 1 &&
|
||||
available >= titleLineHeight + timeLineHeight
|
||||
metrics.available >= metrics.titleLine + metrics.timeLine
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// A short block drops the title rather than serving a horizontally sliced
|
||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||
// the semantics description carries the full title either way.
|
||||
val showTitle = available >= titleLineHeight
|
||||
val showTitle = metrics.fitsTitle
|
||||
// The title is served first, out of everything the block has left once the
|
||||
// time is down to one line — but only takes the lines it will actually use,
|
||||
// and only wraps at all once a line is wide enough to hold more than a
|
||||
// syllable. Below that the extra lines just stack fragments of the word.
|
||||
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
|
||||
val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
|
||||
val paint = eventPaint(block.event, dark)
|
||||
// Every line the height affords, however narrow the lane: two events side by
|
||||
// side leave columns well under a word wide, and cutting the title to one
|
||||
@@ -950,8 +944,8 @@ private fun EventBlock(
|
||||
textWidth = textWidth,
|
||||
max = titleBudget,
|
||||
)
|
||||
val spare = available - titleLineHeight * titleMaxLines -
|
||||
if (showTime) timeLineHeight else 0.dp
|
||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
||||
if (showTime) metrics.timeLine else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
@@ -993,7 +987,7 @@ private fun EventBlock(
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
|
||||
@@ -11,12 +11,20 @@
|
||||
<string name="state_retry">Retry</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_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_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_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_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_body">The system calendar did not answer. Try again in a moment.</string>
|
||||
|
||||
<!-- Long-press a field to copy it (#195) -->
|
||||
<string name="field_copy_action">Copy</string>
|
||||
@@ -679,6 +687,7 @@
|
||||
<string name="calendars_restore_header">Restore</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_unavailable">No calendar can take the imported events yet.</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_folder">Backup folder</string>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+66
@@ -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<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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BlockTextMetricsTest {
|
||||
|
||||
/** One trimmed labelMedium line at font scale 1: a 12sp glyph, leading off. */
|
||||
private val titleLine = 14.dp
|
||||
|
||||
/** A block with room to spare, for the line arithmetic. */
|
||||
private fun metrics(height: Dp = 100.dp) = BlockTextMetrics(
|
||||
inset = blockTextInset(height, titleLine),
|
||||
available = height - blockTextInset(height, titleLine) * 2,
|
||||
titleLine = titleLine,
|
||||
titleLeading = 16.dp,
|
||||
timeLine = 13.dp,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a block with room keeps the full inset`() {
|
||||
assertThat(blockTextInset(height = 60.dp, titleLine = titleLine))
|
||||
.isEqualTo(BLOCK_TEXT_INSET)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the inset tapers instead of snapping as the block shrinks`() {
|
||||
// Half the inset left over is half the inset kept, so a pinch closes the
|
||||
// gap frame by frame rather than dropping it in one.
|
||||
assertThat(blockTextInset(height = titleLine + 2.dp, titleLine = titleLine))
|
||||
.isEqualTo(1.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block exactly one title tall spends nothing on padding`() {
|
||||
assertThat(blockTextInset(height = titleLine, titleLine = titleLine)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block shorter than a line never insets negatively`() {
|
||||
assertThat(blockTextInset(height = 4.dp, titleLine = titleLine)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title survives a block that used to be too short for it`() {
|
||||
// 18dp is under the old floor — a title line plus 2dp of inset at each
|
||||
// edge — and over the new one, which is the line on its own (#289).
|
||||
val m = metrics(height = 18.dp)
|
||||
assertThat(m.fitsTitle).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block under one line still drops the title`() {
|
||||
assertThat(metrics(height = 10.dp).fitsTitle).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every title line after the first costs a whole line box`() {
|
||||
// The trim reaches the outer edges only, so the leading between two
|
||||
// lines is still there to pay for.
|
||||
val m = metrics()
|
||||
assertThat(m.titleHeight(1)).isEqualTo(14.dp)
|
||||
assertThat(m.titleHeight(2)).isEqualTo(30.dp)
|
||||
assertThat(m.titleHeight(3)).isEqualTo(46.dp)
|
||||
assertThat(m.titleHeight(0)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the budget is what the block can actually draw, not what divides into it`() {
|
||||
val m = metrics()
|
||||
// Two lines cost 30dp: 29 buys one, 30 buys the second.
|
||||
assertThat(m.titleBudget(29.dp)).isEqualTo(1)
|
||||
assertThat(m.titleBudget(30.dp)).isEqualTo(2)
|
||||
assertThat(m.titleBudget(13.dp)).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a budget line is always one the block can pay for`() {
|
||||
val m = metrics()
|
||||
(0..80).forEach { dp ->
|
||||
val within = dp.dp
|
||||
val budget = m.titleBudget(within)
|
||||
assertThat(m.titleHeight(budget)).isAtMost(within)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user