diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4accf..ad9c46b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.21.0] — 2026-09-23 + +### Added +- **Setup makes sure you have a calendar to write to.** A fresh install could + finish onboarding and land on an empty grid with nothing saying why — either + because the device had no calendar at all, or because every calendar it had + was switched off. Setup now covers both: it offers to create a calendar on + this device or to add an account that syncs one, and it lists the calendars + that exist but are switched off so you can turn them back on ([#287]). + +### Changed +- **The month grid gives its days more room.** Week numbers are set smaller and + their gutter is measured from the widest number it has to seat instead of + holding a fixed 40dp; event chips spend less of their width on padding; and + the labels in both are tracked more tightly, which is worth roughly a + character per chip ([#189], [#190], [#212], [#213]). +- **A month day shows as many events as it has height for.** The limit was a + flat three at every size, so a tall week row threw away space it could have + filled, while a cramped one drew a third chip its band could not hold and put + the "more" marker below the clip, where nothing showed the day held more at + all. It is measured per week row now, and a tablet or a landscape phone seats + more of them. +- **Split view no longer pads a short month out to six rows.** A five-row month + was handing the day pane a blank row and its gap — a sixth of the grid. The + grid now draws only the rows its month spans and the pane grows into the rest + ([#162]). + +### Fixed +- **Short events show their title in week and day view.** A block needed 20dp + of height before it drew any title at all, which at fit-the-whole-day put the + cutoff around the half hour. The padding now tapers as the block shrinks, so + a short event keeps its name ([#289]). +- **A fully zoomed-out timeline sits still.** Rounding its height up left it as + much as a pixel per hour taller than the viewport those hours are meant to + fill — some 24px of leftover scroll, enough to bounce off Android's + overscroll stretch ([#290]). +- **Backup & restore says what is going on instead of coming up blank.** It + built its rows straight from the calendar list, so loading, a provider error, + and a device with nothing to export all rendered as an empty page with no + buttons on it ([#304]). +- **An event with no title reads in your language.** It was labelled "(Ohne + Titel)" on every screen but the event's own, whatever language the phone was + set to: the German text was chosen before the translated one could be + ([#321]). +- **A calendar the system gives no name reads in your language.** The same + cause, in the calendar lists and pickers — and because the calendar editor + starts from that name, saving such a calendar wrote the German text into it + for real ([#329]). + ## [2.20.4] — 2026-09-20 ### Fixed @@ -1714,3 +1763,14 @@ automatically, with zero telemetry and no internet permission. [#309]: https://codeberg.org/jlmakiola/calendula/issues/309 [#305]: https://codeberg.org/jlmakiola/calendula/issues/305 [#294]: https://codeberg.org/jlmakiola/calendula/issues/294 +[#287]: https://codeberg.org/jlmakiola/calendula/issues/287 +[#189]: https://codeberg.org/jlmakiola/calendula/issues/189 +[#190]: https://codeberg.org/jlmakiola/calendula/issues/190 +[#212]: https://codeberg.org/jlmakiola/calendula/issues/212 +[#213]: https://codeberg.org/jlmakiola/calendula/issues/213 +[#162]: https://codeberg.org/jlmakiola/calendula/issues/162 +[#289]: https://codeberg.org/jlmakiola/calendula/issues/289 +[#290]: https://codeberg.org/jlmakiola/calendula/issues/290 +[#304]: https://codeberg.org/jlmakiola/calendula/issues/304 +[#321]: https://codeberg.org/jlmakiola/calendula/issues/321 +[#329]: https://codeberg.org/jlmakiola/calendula/issues/329 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c25434a..15ac126 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 22004 - versionName = "2.20.4" + versionCode = 22100 + versionName = "2.21.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt index 58b9af1..8860fff 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarColorPalette.kt @@ -1,22 +1,35 @@ package de.jeanlucmakiola.calendula.data.calendar /** - * Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / - * `EVENT_COLOR`. The named entries exist for callers that need one specific - * hue (the managed special-dates calendars), so they can't drift from the - * swatches offered in the colour picker. + * The colours offered when creating or editing a calendar; ARGB ints for a raw + * `CALENDAR_COLOR` / `EVENT_COLOR`. + * + * Hues rather than final fills: every surface that draws a calendar colour runs + * it through the app's own tone pass first, so these are chosen to stay + * distinguishable after it. Shared with Agendula, whose list palette these are — + * the two apps are the same family and a calendar and a task list picked from + * different-looking sets read as different products. + * + * The named entries exist for callers that need one specific hue (the managed + * special-dates calendars), so they can't drift from the swatches on offer. */ object CalendarColorPalette { - val Red = 0xFFD50000.toInt() - val Orange = 0xFFE67C00.toInt() - val Amber = 0xFFF6BF26.toInt() - val Green = 0xFF33B679.toInt() - val DarkGreen = 0xFF0B8043.toInt() - val Blue = 0xFF039BE5.toInt() - val Indigo = 0xFF3F51B5.toInt() - val Purple = 0xFF8E24AA.toInt() - val Graphite = 0xFF616161.toInt() + val Mauve = 0xFF7A5C6B.toInt() + val Red = 0xFFD7484A.toInt() + val Orange = 0xFFE8743B.toInt() + val Amber = 0xFFE0A32E.toInt() + val Olive = 0xFF7CA83E.toInt() + val Green = 0xFF35A06A.toInt() + val Teal = 0xFF19938C.toInt() + val Cyan = 0xFF2A9BC4.toInt() + val Blue = 0xFF3C74C8.toInt() + val Indigo = 0xFF6A5CC0.toInt() + val Purple = 0xFF9455B8.toInt() + val Pink = 0xFFC94F8E.toInt() - /** The full palette, in swatch-row order. */ - val all: List = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite) + /** The full palette, in swatch-grid order. */ + val all: List = listOf( + Mauve, Red, Orange, Amber, Olive, Green, + Teal, Cyan, Blue, Indigo, Purple, Pink, + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 9417d96..fd3283d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -376,7 +376,7 @@ class AndroidCalendarDataSource @Inject constructor( .build() override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long { - val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR } + val name = displayName.trim() val values = ContentValues().apply { put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME) put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) @@ -400,7 +400,7 @@ class AndroidCalendarDataSource @Inject constructor( } override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) { - val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR } + val name = displayName.trim() val values = ContentValues().apply { put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name) put(CalendarContract.Calendars.NAME, name) @@ -464,7 +464,7 @@ class AndroidCalendarDataSource @Inject constructor( PackageManager.PERMISSION_GRANTED override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { - val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR } + val name = displayName.trim() val values = ContentValues().apply { put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME) put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt index edfbd4a..fb95227 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt @@ -8,8 +8,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource { val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL return CalendarSource( id = getLong(CalendarProjection.IDX_ID), - displayName = getString(CalendarProjection.IDX_DISPLAY_NAME) - ?: Fallbacks.UNNAMED_CALENDAR, + // Blank when the provider row has no name: the placeholder is a display + // string and belongs to the UI, where it can be localised (#329). + displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(), accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(), accountType = accountType, color = getInt(CalendarProjection.IDX_COLOR), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index 1fc7645..a1a9a18 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -21,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? { return null } - val rawTitle = getString(InstanceProjection.IDX_TITLE) - val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle + // Left blank when the event has no title: the placeholder is a display + // string, so it belongs to the UI layer where it can be localised (#321). + val title = getString(InstanceProjection.IDX_TITLE).orEmpty() val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) { getInt(InstanceProjection.IDX_CALENDAR_COLOR) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index b535aee..e9e5a46 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -312,8 +312,3 @@ internal object ReminderProjection { const val IDX_MINUTES = 0 const val IDX_METHOD = 1 } - -internal object Fallbacks { - const val UNNAMED_CALENDAR = "(Unbenannter Kalender)" - const val UNTITLED_EVENT = "(Ohne Titel)" -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index 23ee661..441f8c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -21,8 +21,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? { else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION)) }.coerceAtLeast(dtStart) - val rawTitle = getString(SearchProjection.IDX_TITLE) - val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle + val title = getString(SearchProjection.IDX_TITLE).orEmpty() val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) { getInt(SearchProjection.IDX_CALENDAR_COLOR) } else { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 9d8d7fa..71eaad3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -586,6 +586,24 @@ class SettingsPrefs @Inject constructor( } } + /** Whether the wizard's calendar step has been answered (or skipped). */ + val onboardingCalendarsDone: Flow = store.data.map { prefs -> + prefs[ONBOARDING_CALENDARS_KEY] ?: false + } + + suspend fun setOnboardingCalendarsDone(done: Boolean = true) { + store.edit { it[ONBOARDING_CALENDARS_KEY] = done } + } + + /** Whether the wizard's hidden-calendars step has been answered (or skipped). */ + val onboardingHiddenCalendarsDone: Flow = store.data.map { prefs -> + prefs[ONBOARDING_HIDDEN_KEY] ?: false + } + + suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) { + store.edit { it[ONBOARDING_HIDDEN_KEY] = done } + } + /** Whether the wizard's backup step has been answered (or skipped). */ val onboardingBackupDone: Flow = store.data.map { prefs -> prefs[ONBOARDING_BACKUP_KEY] ?: false @@ -977,6 +995,10 @@ class SettingsPrefs @Inject constructor( internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed") + internal val ONBOARDING_CALENDARS_KEY = + booleanPreferencesKey("onboarding_calendars_done") + internal val ONBOARDING_HIDDEN_KEY = + booleanPreferencesKey("onboarding_hidden_calendars_done") internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done") internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done") internal val ONBOARDING_MONTH_STYLE_KEY = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 7fba181..89f086a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -253,6 +253,13 @@ enum class FailureReason { PermissionRevoked, NoCalendarsConfigured, AllCalendarsHidden, + + /** + * Calendars exist and are switched on, but none can receive an event: every + * one is read-only, app-managed, or not synced to this device. Distinct from + * [AllCalendarsHidden], which a visibility switch fixes. + */ + NoImportTarget, ProviderUnavailable, EventNotFound, Unknown, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index 9c5693a..0c95821 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault } when { component != null -> { + // A component whose END line never arrives ends at the next + // one's BEGIN, not at that one's END: reading to the far END + // would fold two events into one body, where the later + // properties overwrite the earlier and one event is lost. val end = indexOfEnd(lines, i + 1, component) + val body = indexOfNextComponent(lines, i + 1, end) parseComponent( - body = lines.subList(i + 1, end), + body = lines.subList(i + 1, body), fileCalendarName = calendarName, warnings = warnings, isTask = component == "VTODO", )?.let(events::add) - i = end + 1 + i = if (body < end) body else end + 1 } line.isBegin("VTIMEZONE") -> { // Skipped wholesale; TZIDs resolve against the OS tz database. @@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault } /** Index of the matching `END:` at/after [from], or list end. */ + /** + * Where an unterminated component's body has to stop: the next top-level + * `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A + * nested `VALARM` is part of the body and is not a boundary. + */ + fun indexOfNextComponent(lines: List, from: Int, end: Int): Int { + var i = from + while (i < end) { + val line = parseContentLine(lines[i]) + if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) { + return i + } + i++ + } + return end + } + fun indexOfEnd(lines: List, from: Int, component: String): Int { var i = from while (i < lines.size) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 0141d94..dc4d5a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -197,10 +197,21 @@ fun CalendarHost( // is "restore a backup", not "add this one event". An externally opened .ics // keeps routing a single event straight into the prefilled create form. var importForceMany by remember { mutableStateOf(false) } + // Where a restore came from, so closing the import puts it back: the import + // overlays are declared under Backup & restore and the manager, so starting + // one has to close them — without this, finishing a restore drops you on the + // calendar and the next file means walking in through Settings again. + var backupAfterImport by rememberSaveable { mutableStateOf(false) } + var calendarsAfterImport by rememberSaveable { mutableStateOf(false) } + // One import run. The import VM lives in the Activity's store, so this is + // what tells it a *re-import of the same file* is new work and not the run + // it already finished; a rotation keeps the number and keeps the result. + var importSession by rememberSaveable { mutableStateOf(0) } LaunchedEffect(requestedImportUri) { if (requestedImportUri != null) { importUri = requestedImportUri importForceMany = false + importSession++ onImportConsumed() } } @@ -510,11 +521,28 @@ fun CalendarHost( importUri?.let { uri -> ImportScreen( uri = uri, + session = importSession, forceMany = importForceMany, - onClose = { importUri = null }, - onManageCalendars = { showCalendars = true }, + onClose = { + importUri = null + // Back to the surface the restore started from, ready for + // the next file. + showCalendars = calendarsAfterImport + showBackup = backupAfterImport + calendarsAfterImport = false + backupAfterImport = false + }, + onManageCalendars = { + // The manager is where this leads, so it must not be + // reopened underneath on close. + calendarsAfterImport = false + backupAfterImport = false + showCalendars = true + }, onOpenSingle = { form -> importUri = null + calendarsAfterImport = false + backupAfterImport = false importFormSource = ImportSource.File importForm = form }, @@ -550,18 +578,33 @@ fun CalendarHost( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), ) { - BackupScreen( - onBack = { showBackup = false }, - // Restore runs the normal .ics import, and both this screen and - // the manager that can have opened it are declared above the - // import overlays — so both have to step aside. - onImport = { - importUri = it - importForceMany = true + // Settings can open this screen without the manager underneath, so + // its failure states' "Manage calendars" way out has to open the + // manager rather than just pop — popping alone lands on Settings + // (#304). Coming from the manager the flag is already set, so this + // is the same pop-back there. + CompositionLocalProvider( + LocalManageCalendars provides { + showCalendars = true showBackup = false - showCalendars = false }, - ) + ) { + BackupScreen( + onBack = { showBackup = false }, + // Restore runs the normal .ics import, and both this screen + // and the manager that can have opened it are declared above + // the import overlays — so both have to step aside. + onImport = { + importUri = it + importForceMany = true + importSession++ + backupAfterImport = true + calendarsAfterImport = showCalendars + showBackup = false + showCalendars = false + }, + ) + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index f32663f..7a3af49 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -4,9 +4,11 @@ import android.net.Uri import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -47,9 +50,10 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource -import de.jeanlucmakiola.calendula.domain.isEventTarget import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar +import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogUnitDropdown @@ -80,18 +84,13 @@ fun BackupScreen( onImport: (Uri) -> Unit, viewModel: CalendarsViewModel = hiltViewModel(), ) { - val calendars by viewModel.calendars.collectAsStateWithLifecycle() + val state by viewModel.backupState.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } - // Export covers local calendars only; managed special-dates mirrors are - // rebuilt from contacts. Restore can target anything the import picker offers. - val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged } - val canImport = calendars.any { it.isEventTarget } - // Exports everything eligible (null); the per-calendar selector owns its // own launcher. val createBackup = rememberLauncherForActivityResult( @@ -132,78 +131,42 @@ fun BackupScreen( CollapsingScaffold( title = stringResource(R.string.settings_section_backup), onBack = onBack, + // Loading and failure fill the screen and centre themselves, which they + // can only do in an unscrolled column — the scrolling one measures them + // against an unbounded height and leaves them hanging under the header. + scrollable = state is BackupUiState.Ready, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, ) { - HintText(stringResource(R.string.calendars_backup_hint)) - - if (exportable.isNotEmpty()) { - GroupedRow( - title = stringResource(R.string.calendars_backup_action), - position = Position.Top, - leading = { LeadingAvatar(Icons.Default.FileDownload) }, - onClick = { - // A single exportable calendar skips the selector. - if (exportable.size == 1) { - runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } - } else { - showExportPicker = true - } + when (val s = state) { + BackupUiState.Loading -> BackupLoading() + is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack) + is BackupUiState.Ready -> BackupContent( + exportable = s.exportable, + canImport = s.canImport, + autoBackup = autoBackup, + viewModel = viewModel, + onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, + onPickFolder = { runCatching { pickFolder.launch(null) } }, + onExportAll = { + runCatching { createBackup.launch(defaultBackupName()) } }, - ) - GroupedRow( - title = stringResource(R.string.calendars_restore_action), - summary = stringResource(R.string.calendars_restore_hint), - position = Position.Middle, - leading = { LeadingAvatar(Icons.Default.FileUpload) }, - onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, - ) - GroupedRow( - title = stringResource(R.string.calendars_auto_backup), - summary = stringResource(R.string.calendars_auto_backup_hint), - position = if (autoBackup.enabled) Position.Middle else Position.Bottom, - leading = { LeadingAvatar(Icons.Default.Schedule) }, - trailing = { - Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled) - }, - onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) }, - ) - if (autoBackup.enabled) { - GroupedRow( - title = stringResource(R.string.calendars_auto_backup_folder), - summary = rememberFolderName(autoBackup.folderUri) - ?: stringResource(R.string.calendars_auto_backup_folder_unset), - position = Position.Middle, - onClick = { runCatching { pickFolder.launch(null) } }, - ) - GroupedRow( - title = stringResource(R.string.calendars_auto_backup_interval), - summary = backupIntervalLabel(autoBackup.intervalMinutes), - position = Position.Bottom, - onClick = { showInterval = true }, - ) - HintText(backupStatusText(autoBackup.status)) - } - } else if (canImport) { - // Nothing to back up, but restore is still possible — don't hide - // it behind export eligibility. - SectionHeader(stringResource(R.string.calendars_restore_header)) - HintText(stringResource(R.string.calendars_restore_hint)) - GroupedRow( - title = stringResource(R.string.calendars_restore_action), - position = Position.Alone, - leading = { LeadingAvatar(Icons.Default.FileUpload) }, - onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, + onExportPick = { showExportPicker = true }, + onEditInterval = { showInterval = true }, ) } } - if (showExportPicker) { - ExportCalendarPicker( - calendars = exportable, - onExport = viewModel::exportBackup, - onDismiss = { showExportPicker = false }, - ) + // Gated on Ready rather than resetting the flag when the state leaves it: + // flipping it here would be a write during composition. + (state as? BackupUiState.Ready)?.let { ready -> + if (showExportPicker) { + ExportCalendarPicker( + calendars = ready.exportable, + onExport = viewModel::exportBackup, + onDismiss = { showExportPicker = false }, + ) + } } if (showInterval) { BackupIntervalDialog( @@ -214,6 +177,103 @@ fun BackupScreen( } } +/** The screen's loading state, centred in the scaffold's content column. */ +@Composable +private fun BackupLoading() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +/** Default file name for a one-shot export. */ +private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics" + +/** + * The working screen: export (when there are local calendars to export), restore, + * and the automatic-backup block. Restore is always on screen — when nothing can + * receive the events it says so and routes to the calendar manager rather than + * disappearing, which is what #304 reported as an invisible button. + */ +@Composable +private fun BackupContent( + exportable: List, + canImport: Boolean, + autoBackup: AutoBackupUiState, + viewModel: CalendarsViewModel, + onRestore: () -> Unit, + onPickFolder: () -> Unit, + onExportAll: () -> Unit, + onExportPick: () -> Unit, + onEditInterval: () -> Unit, +) { + val manageCalendars = LocalManageCalendars.current + // Restore never depends on export eligibility, and never disappears: when no + // calendar can receive events it explains that and offers the way to fix it. + val restoreSummary = if (canImport) { + stringResource(R.string.calendars_restore_hint) + } else { + stringResource(R.string.calendars_restore_unavailable) + } + val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore) + + if (exportable.isEmpty()) { + SectionHeader(stringResource(R.string.calendars_restore_header)) + HintText(restoreSummary) + GroupedRow( + title = stringResource(R.string.calendars_restore_action), + position = Position.Alone, + leading = { LeadingAvatar(Icons.Default.FileUpload) }, + onClick = onRestoreClick, + ) + return + } + + HintText(stringResource(R.string.calendars_backup_hint)) + GroupedRow( + title = stringResource(R.string.calendars_backup_action), + position = Position.Top, + leading = { LeadingAvatar(Icons.Default.FileDownload) }, + // A single exportable calendar skips the selector. + onClick = if (exportable.size == 1) onExportAll else onExportPick, + ) + GroupedRow( + title = stringResource(R.string.calendars_restore_action), + summary = restoreSummary, + position = Position.Middle, + leading = { LeadingAvatar(Icons.Default.FileUpload) }, + onClick = onRestoreClick, + ) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup), + summary = stringResource(R.string.calendars_auto_backup_hint), + position = if (autoBackup.enabled) Position.Middle else Position.Bottom, + leading = { LeadingAvatar(Icons.Default.Schedule) }, + trailing = { + Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled) + }, + onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) }, + ) + if (autoBackup.enabled) { + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_folder), + summary = rememberFolderName(autoBackup.folderUri) + ?: stringResource(R.string.calendars_auto_backup_folder_unset), + position = Position.Middle, + onClick = onPickFolder, + ) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_interval), + summary = backupIntervalLabel(autoBackup.intervalMinutes), + position = Position.Bottom, + onClick = onEditInterval, + ) + HintText(backupStatusText(autoBackup.status)) + } +} + /** * Choose which local calendars to include in a one-time `.ics` export. Defaults * to all selected; the Export action opens the SAF save dialog and hands back @@ -252,7 +312,7 @@ private fun ExportCalendarPicker( calendars.forEachIndexed { index, calendar -> val isSelected = calendar.id in selected GroupedRow( - title = calendar.displayName, + title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, summary = calendar.description, position = positionOf(index, calendars.size), leading = { CalendarColorChip(calendar.color) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt new file mode 100644 index 0000000..3be7c6b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.calendula.ui.calendars + +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.calendarListFailure +import de.jeanlucmakiola.calendula.domain.isEventTarget + +/** + * State of the Backup & restore screen (#69). Three states, because the calendar + * list it is derived from has all three: it arrives empty, it can throw, and + * "loaded but nothing to offer" is a real outcome the screen used to render as a + * blank page (#304). + */ +sealed interface BackupUiState { + data object Loading : BackupUiState + data class Failure(val reason: FailureReason) : BackupUiState + + /** + * At least one half of the screen works. [exportable] may be empty (restore + * only) and [canImport] may be false (export only), but never both — that is + * a [Failure]. + */ + data class Ready( + val exportable: List, + val canImport: Boolean, + ) : BackupUiState +} + +/** + * What the screen can offer for this calendar list. + * + * Export covers the app's own local calendars; managed special-dates mirrors are + * rebuilt from contacts, so they are excluded. Restore can target anything the + * import picker offers ([isEventTarget]). + * + * A failure is raised only when *neither* is possible — deliberately not + * [calendarListFailure] on its own, which would call an all-hidden device a + * failure while its local calendars are still perfectly exportable. When both + * halves are dead the list itself usually says why (no calendars, everything + * switched off); [FailureReason.NoImportTarget] covers the remaining case, where + * calendars exist and are visible but every one of them is read-only, managed or + * not synced to the device. + */ +fun backupUiState(calendars: List): BackupUiState { + val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged } + val canImport = calendars.any { it.isEventTarget } + if (exportable.isEmpty() && !canImport) { + return BackupUiState.Failure( + calendarListFailure(calendars) ?: FailureReason.NoImportTarget, + ) + } + return BackupUiState.Ready(exportable = exportable, canImport = canImport) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 8886ad2..7d00c20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage @@ -97,11 +98,25 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.predictiveBack -import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.heightIn +import androidx.annotation.StringRes +import androidx.compose.foundation.border +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.filled.Check +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedSurface /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ private const val NEW_CALENDAR_ID = Long.MIN_VALUE @@ -231,7 +246,7 @@ private fun CalendarsList( local.forEachIndexed { index, calendar -> val disabled = !calendar.isVisibleInSystem GroupedRow( - title = calendar.displayName, + title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, summary = calendarRowSummary(calendar), position = if (index == local.lastIndex) Position.Bottom else Position.Middle, container = MaterialTheme.colorScheme.surfaceContainerHighest, @@ -239,7 +254,7 @@ private fun CalendarsList( leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, trailing = { EnableSwitch( - calendarName = calendar.displayName, + calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, enabled = !disabled, onToggle = { enabled -> onSetVisible(calendar.id, enabled) }, ) @@ -316,7 +331,7 @@ private fun CalendarsList( ordered.forEachIndexed { index, calendar -> val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced GroupedRow( - title = calendar.displayName, + title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, summary = calendarRowSummary(calendar), position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle, container = MaterialTheme.colorScheme.surfaceContainerHighest, @@ -325,7 +340,7 @@ private fun CalendarsList( trailing = if (calendar.hasVisibilitySwitch) { { EnableSwitch( - calendarName = calendar.displayName, + calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, enabled = calendar.isVisibleInSystem, onToggle = { enabled -> onSetVisible(calendar.id, enabled) @@ -343,9 +358,15 @@ private fun CalendarsList( } -@OptIn(ExperimentalMaterial3Api::class) +private const val SWATCHES_PER_ROW = 6 + +/** + * Create or edit a local calendar: a name field over the palette of calendar + * colours, on the family's full-screen sheet with the commit in its title bar. + * The same sheet Agendula edits a task list in, so the two read as one product. + */ @Composable -private fun CalendarEditor( +internal fun CalendarEditor( sessionKey: Int, isNew: Boolean, initialName: String, @@ -360,121 +381,50 @@ private fun CalendarEditor( var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) } var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) } var confirmDelete by remember { mutableStateOf(false) } - val dark = isSystemInDarkTheme() - val soften = LocalSoftenColors.current + val focusRequester = remember { FocusRequester() } - Scaffold( - modifier = Modifier - .predictiveBack(onBack = onClose) - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - topBar = { - TopAppBar( - title = { - Text( - stringResource( - if (isNew) R.string.calendars_new_title - else R.string.calendars_edit_title, - ), - ) - }, - navigationIcon = { - IconButton(onClick = onClose) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.event_edit_close), - ) - } - }, - actions = { - if (!isNew) { - // Disabled rather than hidden while the special-dates - // sync owns this calendar; the card below says why. - IconButton( - onClick = { confirmDelete = true }, - enabled = !deleteLocked, - ) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.event_detail_delete), - tint = if (deleteLocked) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - } else { - MaterialTheme.colorScheme.error - }, - ) - } - } - // Filled save button, matching the event editor's top bar. - Button( - onClick = { - onSave(name.trim(), color, description.trim().ifEmpty { null }) - }, - enabled = name.isNotBlank(), - modifier = Modifier.padding(end = 12.dp), - ) { - Text(stringResource(R.string.event_edit_save)) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) + val valid = name.isNotBlank() + val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) } + + FullScreenPicker( + title = stringResource( + if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title, + ), + onDismiss = onClose, + predictiveBack = true, + actions = { + Button( + onClick = commit, + enabled = valid, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.event_edit_save)) } }, - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (deleteLocked) { - EditorCard( - icon = Icons.Default.Info, - iconTint = MaterialTheme.colorScheme.onSurfaceVariant, - iconAtTop = true, - ) { - Text( - text = stringResource(R.string.calendars_managed_delete_locked), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) { - InlineTextField( - value = name, - onValueChange = { name = it }, - placeholder = stringResource(R.string.calendars_name_label), - textStyle = MaterialTheme.typography.titleLarge, - capitalization = KeyboardCapitalization.Sentences, - ) - } - EditorCard( - icon = Icons.Default.Palette, - iconTint = MaterialTheme.colorScheme.onSurfaceVariant, - iconAtTop = true, - ) { - Text( - text = stringResource(R.string.calendars_color_label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) - ColorSwatchRow( - colors = CalendarColorPalette.all, - selected = color, - onSelect = { color = it }, - dark = dark, - ) - } - EditorCard( - icon = Icons.AutoMirrored.Filled.Notes, - iconTint = MaterialTheme.colorScheme.onSurfaceVariant, - iconAtTop = true, - ) { + ) { + if (deleteLocked) { + ManagedNote() + Spacer(Modifier.height(20.dp)) + } + + CalendarNameField( + name = name, + color = color, + focusRequester = focusRequester, + onNameChange = { name = it }, + onImeAction = commit, + ) + // Inside the picker: it is a Dialog, so a request made from the caller's + // composition can run before this field's node exists. + // A new calendar opens with the keyboard up: naming it is the whole task. + LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() } + + Spacer(Modifier.height(20.dp)) + EditorSectionLabel(stringResource(R.string.calendars_color_label)) + CalendarColorGrid(selected = color, onSelect = { color = it }) + + Spacer(Modifier.height(20.dp)) + EditorSectionLabel(stringResource(R.string.calendars_description_label)) + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) { InlineTextField( value = description, onValueChange = { description = it }, @@ -485,20 +435,21 @@ private fun CalendarEditor( ) } } + + if (!isNew && !deleteLocked) { + Spacer(Modifier.height(24.dp)) + DeleteCalendarRow(onClick = { confirmDelete = true }) + } + Spacer(Modifier.height(24.dp)) } if (confirmDelete) { AlertDialog( onDismissRequest = { confirmDelete = false }, title = { Text(stringResource(R.string.calendars_delete_confirm_title)) }, - text = { - Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) - }, + text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) }, confirmButton = { - TextButton(onClick = { - confirmDelete = false - onDelete() - }) { + TextButton(onClick = { confirmDelete = false; onDelete() }) { Text( stringResource(R.string.event_detail_delete), color = MaterialTheme.colorScheme.error, @@ -514,6 +465,205 @@ private fun CalendarEditor( } } +/** The name, with the chosen colour beside it so the two read as one thing. */ +@Composable +private fun CalendarNameField( + name: String, + color: Int, + focusRequester: FocusRequester, + onNameChange: (String) -> Unit, + onImeAction: () -> Unit, +) { + val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.CalendarMonth, + contentDescription = null, + tint = eventAccent(color, dark, soften), + modifier = Modifier.size(22.dp), + ) + } + InlineTextField( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.calendars_name_label), + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Done, + onImeAction = onImeAction, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) + } + } +} + +/** + * The palette as two rows of round swatches; the chosen one carries a check. + * + * Drawn through the app's own tone pass rather than raw, so a swatch is the + * colour the calendar's events will actually appear in — the user's "soften + * colours" preference included. + */ +@Composable +private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) { + val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 16.dp) + .selectableGroup(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEach { swatch -> + ColorSwatch( + fill = eventFill(swatch, dark, soften), + label = stringResource(swatchLabel(swatch)), + selected = swatch == selected, + onClick = { onSelect(swatch) }, + modifier = Modifier.weight(1f), + ) + } + // Keeps a short final row's swatches the size a full row's + // are, rather than stretching them across the width. + repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } +} + +@Composable +private fun ColorSwatch( + fill: Color, + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .aspectRatio(1f) + .clip(CircleShape) + .background(fill) + .then( + if (selected) { + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + } else { + Modifier + }, + ) + // selectable, not clickable: the swatch carries its chosen state in + // semantics too, so the check is not the only cue. + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = eventInk(fill, alpha = 0.7f), + modifier = Modifier.size(22.dp), + ) + } + } +} + +/** Names the swatch for a screen reader — twelve circles are otherwise one label. */ +@StringRes +private fun swatchLabel(argb: Int): Int = when (argb) { + CalendarColorPalette.Mauve -> R.string.color_name_mauve + CalendarColorPalette.Red -> R.string.color_name_red + CalendarColorPalette.Orange -> R.string.color_name_orange + CalendarColorPalette.Amber -> R.string.color_name_amber + CalendarColorPalette.Olive -> R.string.color_name_olive + CalendarColorPalette.Green -> R.string.color_name_green + CalendarColorPalette.Teal -> R.string.color_name_teal + CalendarColorPalette.Cyan -> R.string.color_name_cyan + CalendarColorPalette.Blue -> R.string.color_name_blue + CalendarColorPalette.Indigo -> R.string.color_name_indigo + CalendarColorPalette.Purple -> R.string.color_name_purple + else -> R.string.color_name_pink +} + +/** Said in the sheet rather than on a disabled button: the sync owns this one. */ +@Composable +private fun ManagedNote() { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon(Icons.Default.Info, contentDescription = null) + Text( + text = stringResource(R.string.calendars_managed_delete_locked), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DeleteCalendarRow(onClick: () -> Unit) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onClick, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.event_detail_delete), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} + +@Composable +private fun EditorSectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp), + ) +} + /** * The row's supporting line: the states that make this calendar behave unlike a * plain writable one (#76), then its own description. @@ -556,36 +706,6 @@ private fun EnableSwitch( private fun dimIf(disabled: Boolean): Modifier = if (disabled) Modifier.alpha(0.38f) else Modifier -/** Tonal field card matching the event editor's design (icon + content). */ -@Composable -private fun EditorCard( - icon: ImageVector, - iconTint: Color, - iconAtTop: Boolean = false, - content: @Composable () -> Unit, -) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint, - modifier = Modifier - .padding(top = if (iconAtTop) 2.dp else 0.dp) - .size(24.dp), - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { content() } - } - } -} /** * One collapsible calendar group rendered as a connected card. The header row is diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 49576df..38eadec 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow @@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor( initialValue = emptyList(), ) + /** + * The Backup & restore screen's own view of that list, with the loading and + * failure states [calendars] flattens away — it starts empty and catches to + * empty, which that screen used to render as a blank page (#304). + */ + val backupState: StateFlow = + repository.calendars() + .map { backupUiState(it) } + .catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = BackupUiState.Loading, + ) + /** Automatic-backup settings + last-run status, for the Backup section UI. */ val autoBackup: StateFlow = combine( settingsPrefs.autoBackupEnabled, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt index 5d01a7d..7ec3dfd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt @@ -56,12 +56,14 @@ fun List.groupByAccount(): List { * account comes from when another account shares the name (#77). */ @Composable -fun accountGroupTitle(group: CalendarAccountGroup): String = - if (!group.ambiguous) { - group.label +fun accountGroupTitle(group: CalendarAccountGroup): String { + val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) } + return if (!group.ambiguous) { + label } else { - stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType)) + stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType)) } +} /** * The human name of the app backing [accountType], falling back to the raw diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index 5605e13..4b1fcf7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt index 8eee97a..3db5b96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt @@ -4,19 +4,36 @@ import android.content.Context import android.content.Intent import android.provider.CalendarContract import android.provider.Settings +import androidx.annotation.StringRes +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.EditOff +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.EventBusy +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.SyncProblem +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -26,52 +43,150 @@ import de.jeanlucmakiola.calendula.domain.FailureReason /** * Full-screen failure state shared by every calendar screen (spec §7). - * One explanation line + one recovery action, never a toast. + * A tonal icon, one headline, one supporting line and one recovery action — + * never a toast. */ @Composable -fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { +fun CalendarFailure( + reason: FailureReason, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { val context = LocalContext.current val manageCalendars = LocalManageCalendars.current - val titleRes = when (reason) { - FailureReason.PermissionRevoked -> R.string.state_failure_permission - FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars - FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden - FailureReason.ProviderUnavailable -> R.string.state_failure_provider - FailureReason.Unknown, - FailureReason.EventNotFound -> R.string.state_failure_unknown - } - val actionRes = when (reason) { - FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action - FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action - FailureReason.PermissionRevoked -> R.string.state_failure_permission_action - else -> R.string.state_retry - } + val copy = failureCopy(reason) val onAction: () -> Unit = when (reason) { FailureReason.NoCalendarsConfigured -> { { context.startCalendarSetup() } } - FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry + FailureReason.AllCalendarsHidden, + FailureReason.NoImportTarget, + -> manageCalendars ?: onRetry else -> onRetry } Column( - modifier = Modifier + modifier = modifier .fillMaxSize() - .padding(32.dp), + .padding(horizontal = 32.dp, vertical = 24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + FailureIcon(icon = copy.icon, isError = copy.isError) + Spacer(Modifier.height(24.dp)) Text( - text = stringResource(titleRes), + text = stringResource(copy.title), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH), ) - Spacer(Modifier.height(24.dp)) + copy.body?.let { body -> + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH), + ) + } + Spacer(Modifier.height(32.dp)) FilledTonalButton(onClick = onAction) { - Text(stringResource(actionRes)) + Text(stringResource(copy.action)) } } } +/** The state's mark: the reason's icon in a tonal circle above the headline. */ +@Composable +private fun FailureIcon(icon: ImageVector, isError: Boolean) { + val container: Color + val content: Color + if (isError) { + container = MaterialTheme.colorScheme.errorContainer + content = MaterialTheme.colorScheme.onErrorContainer + } else { + container = MaterialTheme.colorScheme.secondaryContainer + content = MaterialTheme.colorScheme.onSecondaryContainer + } + Box( + modifier = Modifier + .size(72.dp) + .background(container, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = content, + modifier = Modifier.size(36.dp), + ) + } +} + +/** + * What a reason says and offers. Headline and supporting line are separate so + * the explanation reads as body text instead of a headline that runs over three + * lines; [isError] tints the mark for the two reasons that are an actual fault + * rather than a calendar the user can switch back on. + */ +private data class FailureCopy( + val icon: ImageVector, + @StringRes val title: Int, + @StringRes val body: Int?, + @StringRes val action: Int, + val isError: Boolean = false, +) + +private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) { + FailureReason.PermissionRevoked -> FailureCopy( + icon = Icons.Outlined.Lock, + title = R.string.state_failure_permission, + body = R.string.state_failure_permission_body, + action = R.string.state_failure_permission_action, + ) + FailureReason.NoCalendarsConfigured -> FailureCopy( + icon = Icons.Outlined.CalendarMonth, + title = R.string.state_failure_no_calendars, + body = R.string.state_failure_no_calendars_body, + action = R.string.state_failure_no_calendars_action, + ) + FailureReason.AllCalendarsHidden -> FailureCopy( + icon = Icons.Outlined.VisibilityOff, + title = R.string.state_failure_all_hidden, + body = R.string.state_failure_all_hidden_body, + action = R.string.state_failure_all_hidden_action, + ) + FailureReason.NoImportTarget -> FailureCopy( + icon = Icons.Outlined.EditOff, + title = R.string.state_failure_no_import_target, + body = R.string.state_failure_no_import_target_body, + action = R.string.state_failure_all_hidden_action, + ) + FailureReason.EventNotFound -> FailureCopy( + icon = Icons.Outlined.EventBusy, + title = R.string.state_failure_event_not_found, + body = R.string.state_failure_event_not_found_body, + action = R.string.state_retry, + ) + FailureReason.ProviderUnavailable -> FailureCopy( + icon = Icons.Outlined.SyncProblem, + title = R.string.state_failure_provider, + body = R.string.state_failure_provider_body, + action = R.string.state_retry, + isError = true, + ) + FailureReason.Unknown -> FailureCopy( + icon = Icons.Outlined.ErrorOutline, + title = R.string.state_failure_unknown, + body = null, + action = R.string.state_retry, + isError = true, + ) +} + +/** Keeps the headline and its supporting line at a readable measure. */ +private val TEXT_MAX_WIDTH = 320.dp + /** * Opens Settings → Calendars, the only screen that can switch a calendar back * on. Null outside the calendar host — screens without it never raise the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt index 61c073e..802bbb8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -116,7 +116,7 @@ private fun CalendarPickerGroup( calendars.forEachIndexed { index, calendar -> val isSelected = calendar.id == selectedId GroupedRow( - title = calendar.displayName, + title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle, selected = isSelected, leading = { CalendarColorChip(calendar.color) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt new file mode 100644 index 0000000..cec1580 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.Dp + +/** + * The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in + * a 16sp line box — room a label wants when it stands on its own, and close to + * a fifth of an event row when it doesn't. The outer edges only, so a wrapped + * title keeps its interior line spacing (#190). + * + * Trimmed rather than set to a smaller line height: the font picker can load a + * serif, a monospace or a file of the user's own, and a line height under a + * face's own ascent and descent overlaps its lines. There is nothing to trim + * below that, so this is safe whatever font is chosen. + */ +private val TrimmedLines = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, +) + +/** Ascenders and descenders both, so a line is measured at its full extent. */ +private const val LINE_SAMPLE = "Ag" + +/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */ +fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines) + +/** + * [style] with its wrapped lines packed onto the face's own extent instead of + * Material's line box. + * + * [trimmedLines] takes the leading off the outer edges of a run of text; this + * takes it from between the lines as well, which is the half a wrapped event + * title pays for twice over. The line height is *measured* from the font rather + * than picked, so it lands exactly on the face's ascent-plus-descent and can + * never be short enough to overlap — whatever the font picker has loaded (#190). + */ +@Composable +fun rememberPackedLines(style: TextStyle): TextStyle { + val line = rememberTrimmedLineHeight(style) + return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) } +} + +/** What one trimmed line of [style] actually draws in. */ +@Composable +fun rememberTrimmedLineHeight(style: TextStyle): Dp { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + return remember(style, density, measurer) { + with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index cf57422..dba9535 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -83,8 +83,10 @@ fun calendarSlideTransition( initialContentExit = slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec), // AnimatedContent clips to the animating container by default, which - // shears the pages against the viewport edge as they pass. There is no - // size change here to contain — both pages are the same grid. + // shears the pages against the viewport edge as they pass. Left off even + // 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), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index e5ac2f2..5ee2863 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt index a336a1b..69527f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import kotlin.math.abs -import kotlin.math.ceil import kotlin.math.floor import kotlin.math.roundToInt @@ -197,18 +196,26 @@ fun rememberTimelinePinchZoom( * names, jumping the whole column as a pinch drifts across each half pixel. * Pinning the hour to whole pixels keeps every part of the timeline on one grid. * - * The bounds themselves are pulled onto that grid too, each in the direction - * that keeps its own promise — up for the fill floor, so no dead space opens - * under midnight, down for the ceiling. A fractional bound would be a height the - * pinch can be held against but never actually land on, and the difference feeds - * the focal anchor a scroll correction on every frame the fingers sit still. + * The ceiling is pulled onto that grid too, downwards, so it stays a height the + * pinch can actually land on. + * + * [fillPx] is deliberately *not* rounded (#290). It is the one height the whole + * day exactly fills the viewport at, and it is the same value + * [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel + * that 24 hours don't divide the viewport into leaves the timeline a pixel per + * hour taller than its own viewport, so a pinched-all-the-way-out day still + * scrolls a hair and bounces off Android's overscroll stretch, while the + * identical FitDay preset sits still. Being the clamp result rather than a bound + * the gesture is merely held against, it is a height the pinch does land on: the + * next frame reads it back unchanged and the focal anchor is handed nothing to + * correct. */ internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float = // Filling the viewport wins over the ceiling: on a screen tall enough for // the two to disagree, dead space is the worse of the two failures. target.roundToInt().toFloat() .coerceAtMost(floor(maxPx)) - .coerceAtLeast(ceil(fillPx)) + .coerceAtLeast(fillPx) /** * The scroll offset that keeps the moment under [centroidY] under it after the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index ba70d12..49d6678 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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 @@ -115,6 +115,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.EventChipShape +import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -140,7 +141,8 @@ import kotlin.time.Clock import java.util.Locale import kotlin.math.roundToInt -private val ALL_DAY_ROW_HEIGHT = 24.dp +/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ +private val ALL_DAY_ROW_HEIGHT = 20.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp /** Total all-day strip height for the day (0 when there are no all-day events). */ @@ -529,7 +531,7 @@ private fun AllDayBar( val titleOverflow = eventTitleOverflow() Text( text = title, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelSmall.trimmedLines(), maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, @@ -763,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() } @@ -827,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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 12a32ee..2360edd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -302,6 +302,7 @@ fun EventDetailScreen( is EventDetailUiState.Failure -> CalendarFailure( reason = s.reason, onRetry = viewModel::retry, + modifier = contentModifier, ) is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) } @@ -526,7 +527,8 @@ private fun EventDetailContent( iconContentDescription = stringResource(R.string.event_detail_calendar), ) { Text( - text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown), + text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) } + ?: stringResource(R.string.event_detail_calendar_unknown), style = MaterialTheme.typography.titleMedium, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 6b9336d..f591797 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -620,7 +620,9 @@ private fun EventEditContent( Text( text = stringResource( R.string.event_edit_managed_hint, - selectedCalendar?.displayName.orEmpty(), + selectedCalendar?.displayName + ?.ifBlank { stringResource(R.string.calendar_unnamed) } + .orEmpty(), ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -719,6 +721,7 @@ private fun EventEditContent( ) { Text( text = selectedCalendar?.displayName + ?.ifBlank { stringResource(R.string.calendar_unnamed) } ?: stringResource(R.string.event_edit_error_no_calendar), style = MaterialTheme.typography.titleMedium, color = if (selectedCalendar == null) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt index 7bf8480..9b37431 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt @@ -62,15 +62,16 @@ private fun FilterList( ) { Column(modifier = modifier.fillMaxWidth()) { groups.forEach { group -> + val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) } Text( text = if (group.ambiguous) { stringResource( R.string.calendars_account_from_source, - group.account, + account, sourceAppName(group.accountType), ) } else { - group.account + account }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -83,7 +84,7 @@ private fun FilterList( ) group.calendars.forEachIndexed { index, cal -> GroupedRow( - title = cal.displayName, + title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, position = positionOf(index, group.calendars.size), minHeight = 56.dp, leading = { CalendarColorChip(cal.color) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index a3b7690..fcf33d3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -73,19 +73,19 @@ import de.jeanlucmakiola.floret.identity.predictiveBack @Composable fun ImportScreen( uri: Uri, + session: Int, onClose: () -> Unit, onOpenSingle: (EventForm) -> Unit, forceMany: Boolean = false, onManageCalendars: (() -> Unit)? = null, - // Key the VM by the file uri. This screen has no nav backstack, so an - // unkeyed hiltViewModel() resolves to the Activity's store and is retained - // across imports — its one-shot `load` guard would then show the *previous* - // file's parsed state on the next import (a second restore, export→restore, - // etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading - // state), while the same uri (rotation) reuses it and holds the result. - viewModel: ImportViewModel = hiltViewModel(key = uri.toString()), + viewModel: ImportViewModel = hiltViewModel(), ) { - LaunchedEffect(uri) { viewModel.load(uri, forceMany) } + // hiltViewModel() resolves to the Activity's store and is retained across + // imports, so the reload is driven by [session] rather than by a fresh VM: + // keying per uri looked right but handed a *re-import of the same file* the + // previous run's finished state, importing nothing (#304). A rotation keeps + // the session and so keeps the result. + LaunchedEffect(session) { viewModel.load(uri, forceMany, session) } val state by viewModel.state.collectAsStateWithLifecycle() // A single event isn't shown here — it opens the create form for review. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt index 46a18a2..8dceb1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt @@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.toEventForm import de.jeanlucmakiola.calendula.domain.isEventTarget import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor( private val parser = IcsParser() private val _state = MutableStateFlow(ImportUiState.Loading) val state: StateFlow = _state.asStateFlow() - private var started = false + private var loadedSession: Int? = null + private var loadJob: Job? = null /** - * Read + parse [uri] once; subsequent calls (recomposition) are ignored. + * Read + parse [uri] once per [session]; recomposition (and a rotation, + * which keeps the session) re-calls this and keeps the result. A new import + * of the *same* file is a new session, and has to parse and run again — + * keying on the uri alone showed the previous run's summary and imported + * nothing (#304). + * * When [forceMany] is set (an in-app restore), a single-event file still goes * through the bulk picker + summary rather than the prefilled create form — * a restore is "bring back a backup", not "add this one event". */ - fun load(uri: Uri, forceMany: Boolean = false) { - if (started) return - started = true - viewModelScope.launch { + fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) { + if (loadedSession == session) return + loadedSession = session + loadJob?.cancel() + _state.value = ImportUiState.Loading + loadJob = viewModelScope.launch { val parsed = withContext(io) { importer.readText(uri)?.let(parser::parse) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipGeometry.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipGeometry.kt index 83c4dbe..417ee66 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipGeometry.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipGeometry.kt @@ -10,10 +10,16 @@ import androidx.compose.ui.unit.dp internal val CELL_GAP = 2.dp /** Padding between a month chip's edge and its text. */ -internal val MONTH_CHIP_TEXT_PADDING = 4.dp +internal val MONTH_CHIP_TEXT_PADDING = 3.dp -/** A chip's own inset inside its day cell, on top of the cell's gap. */ -internal val MONTH_CHIP_INSET = CELL_GAP + 1.dp +/** + * A chip's own inset inside its day cell. The cell's gap and no more: a day + * column on a phone is around fifty dp, and the chip was spending a quarter of + * it on chrome before a single glyph. The cells keep their full separation from + * each other — what the grid reads as breathing room — and only the chip inside + * one takes the width back (#212). + */ +internal val MONTH_CHIP_INSET = CELL_GAP /** Horizontal space a chip spends on chrome rather than on text, both sides. */ internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 4b5d54b..9ea8905 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -123,6 +123,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -153,6 +154,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventChipShape +import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill @@ -744,7 +746,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), ) { // Reserve the gutter so the weekday labels stay over their day columns. - if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER)) + if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter())) days.forEach { dow -> val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) @@ -762,9 +764,34 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { private val EVENT_ROW_HEIGHT = 20.dp private val DAY_NUMBER_HEIGHT = 22.dp -/** Width of the optional left calendar-week gutter (#25); narrow, since it only - * seats a one- or two-digit week number in a full-height tonal pill. */ -private val WEEK_NUMBER_GUTTER = 40.dp +/** Padding between the week-number pill's edge and the number inside it. */ +private val WEEK_NUMBER_PADDING = 6.dp + +/** The widest week number an ISO year reaches; digits are tabular, so one + * measurement of it prices every week in the grid. */ +private const val WEEK_NUMBER_SAMPLE = "53" + +/** + * Width of the optional left calendar-week gutter (#25), measured rather than + * fixed: it is sized to the number it seats at the style the pill draws it in, + * so it follows the font scale instead of reserving slack for it, and spends + * nothing more on a column the grid would rather hand to the seven days (#213). + */ +@Composable +private fun rememberWeekNumberGutter(): Dp { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val style = weekNumberStyle() + return remember(style, density, measurer) { + val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() } + text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2 + } +} + +/** The week number's own style — a step down from the day numbers beside it. */ +@Composable +private fun weekNumberStyle() = + MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold) private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp /** Named separately because the split style's selection outline draws its own @@ -774,14 +801,79 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER) /** Width of the split style's selected-day outline. */ private val SPLIT_SELECTION_STROKE = 1.5.dp -/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */ -internal const val MAX_EVENT_ROWS = 3 +/** + * Dots a split-style cell draws. Fixed, unlike the paged grid's measured cap: + * the split rows are a fixed height whatever the screen is. + * + * Dot *i* is lane *i*, so a dot morphs into the bar the expanded grid draws + * there (#53) — [MonthWeek.laneEvents] seats the same events in the same order + * at any cap, so a larger one only appends. Where the expanded grid seats fewer + * lanes than this — a six-row month in a short landscape viewport — the dots + * past its cap have no bar to become and simply fade instead of travelling. + */ +internal const val SPLIT_DOT_LANES = 3 + +/** Diameter of a single overflow dot. */ +private val OVERFLOW_DOT_SIZE = 6.dp + +/** + * Height of the overflow row: the "+N" beside the dots, measured at the style it + * is drawn in. + * + * It was taking a whole event lane, which is more than a dot and a label line + * need and one lane fewer for the chips. Measured rather than picked, so it + * still holds the label at a large font scale — a fixed height clipped the "+N" + * at anything above the default. + */ +@Composable +private fun rememberOverflowRowHeight(): Dp { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val style = MaterialTheme.typography.labelSmall + return remember(style, density, measurer) { + with(density) { measurer.measure(OVERFLOW_SAMPLE, style).size.height.toDp() } + .coerceAtLeast(OVERFLOW_DOT_SIZE) + } +} + +/** The tallest the counter gets; digits are tabular, so one is as wide as any. */ +private const val OVERFLOW_SAMPLE = "+9" + +/** + * Lanes of chips this week's cells draw in a row [rowHeight] tall, before the + * rest become dots. + * + * The cap used to be a flat three at every size, so a tall five-row month threw + * away lanes it had the room for while a cramped six-row one drew a third chip + * its band could not hold and put the dots below the clip, where nothing showed + * that the day held more at all. + * + * Measured instead, with no ceiling: a cell spends whatever height it was given. + * The overflow row is only charged for when some day in the week actually + * overflows — a week that fits gets that space as another lane rather than + * reserving room for a marker it will not draw. + */ +internal fun MonthWeek.laneCapFor(rowHeight: Dp, overflowRow: Dp): Int { + val band = monthBandHeight(rowHeight) + val full = (band / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1) + if (!overflowsAt(full)) return full + return ((band - overflowRow) / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1) +} + +/** What a row [rowHeight] tall leaves for chips once the day number is drawn. */ +internal fun monthBandHeight(rowHeight: Dp): Dp = + rowHeight - CELL_TOP_PADDING - DAY_NUMBER_HEIGHT - DAY_NUMBER_GAP + +/** Whether any day in this week holds more than [lanes] lanes can seat. */ +private fun MonthWeek.overflowsAt(lanes: Int): Boolean = + days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() } /** * Row height in the continuous grid. The paged grid divides the viewport between * however many rows the month has; a scrolling stream has no such bound, so it - * fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows — - * close to what a five-row month gets on a typical phone. + * fixes one — close to what a five-row month gets on a typical phone. How many + * chips that seats is [MonthWeek.laneCapFor]'s answer like anywhere else, so a + * week that does not overflow fills the band rather than holding a lane back. */ private val CONTINUOUS_ROW_HEIGHT = 112.dp @@ -793,6 +885,11 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp */ private val CONTINUOUS_MONTH_GAP = 20.dp +/** The paged grid's own vertical padding, and the gap between its week rows — + * named because [monthLaneCap] has to take them off the viewport first. */ +private val GRID_VERTICAL_PADDING = 4.dp +private val GRID_ROW_GAP = 2.dp + /** Gap between the weekday header and the seamless stream's first week row. */ private val DENSE_HEADER_GAP = 4.dp @@ -805,34 +902,43 @@ internal fun MonthGrid( /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ selected: LocalDate? = null, ) { - Column( - modifier = Modifier - .fillMaxSize() - // Match the weekday header's inset so day cells sit under their - // labels, and so the week-number gutter's centre lines up with the - // top bar's hamburger (4dp bar inset + 24dp half icon button). - .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - val month = state.month - // Once per grid: the value depends on the typography, the density, the - // locale and the 24-hour setting, none of which vary by row (#219). - val timeChipWidth = rememberMonthTimeChipWidth() - state.weeks.forEach { week -> - MonthWeekRow( - week = week, - today = state.today, - zone = state.zone, - timeChipWidth = timeChipWidth, - inMonth = { it.month == month.month && it.year == month.year }, - showWeekNumbers = showWeekNumbers, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - selected = selected, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) + BoxWithConstraints(Modifier.fillMaxSize()) { + // The rows divide whatever the viewport leaves once the Column's own + // padding and the gaps between them are paid, so how many chips a cell + // can seat is only knowable here. + val rows = state.weeks.size.coerceAtLeast(1) + val rowHeight = + (maxHeight - GRID_VERTICAL_PADDING * 2 - GRID_ROW_GAP * (rows - 1)) / rows + Column( + modifier = Modifier + .fillMaxSize() + // Match the weekday header's inset so day cells sit under their + // labels, and so the week-number gutter's centre lines up with the + // top bar's hamburger (4dp bar inset + 24dp half icon button). + .padding(horizontal = AppBarSpacing.Inset, vertical = GRID_VERTICAL_PADDING), + verticalArrangement = Arrangement.spacedBy(GRID_ROW_GAP), + ) { + val month = state.month + // Once per grid: the value depends on the typography, the density, the + // locale and the 24-hour setting, none of which vary by row (#219). + val timeChipWidth = rememberMonthTimeChipWidth() + state.weeks.forEach { week -> + MonthWeekRow( + week = week, + today = state.today, + zone = state.zone, + timeChipWidth = timeChipWidth, + rowHeight = rowHeight, + inMonth = { it.month == month.month && it.year == month.year }, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + selected = selected, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + } } } } @@ -930,6 +1036,7 @@ private fun ContinuousMonthBlock( today = today, zone = zone, timeChipWidth = timeChipWidth, + rowHeight = CONTINUOUS_ROW_HEIGHT, inMonth = { it.month == month.month && it.year == month.year }, // The block owns its month alone: a day from either // neighbour is left out entirely rather than dimmed. @@ -1024,6 +1131,7 @@ internal fun DenseMonthGrid( today = state.today, zone = state.zone, timeChipWidth = timeChipWidth, + rowHeight = CONTINUOUS_ROW_HEIGHT, // Every day in the stream belongs to a month equally — there // is no "other month" to recede here. inMonth = { true }, @@ -1141,10 +1249,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp * 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 grid slides between months like the paged style, which it can only do - * because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it - * stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on - * top of swapping the grid — the pane now holds still and only the grid moves. + * The grid slides between months like the paged style, and stands only as many + * rows tall as its own month spans (#162). A swipe between a five-row month and + * a six-row one therefore moves the pane by a row as well as swapping the grid; + * 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 * the month you are on, not a fourth style; persisted, someone would expand it @@ -1437,17 +1545,10 @@ private fun SplitExpandHandle( */ private val SPLIT_ROW_HEIGHT = 46.dp private val SPLIT_DOT_SIZE = 5.dp -// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for +// Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for // 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). -/** - * 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 * comfortable tap target on its own. @@ -1460,6 +1561,11 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp * The split style's grid (#53): the month compressed to day numbers and event * 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 * 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. @@ -1486,7 +1592,7 @@ internal fun SplitMonthGrid( WeekNumberGutter( weekStart = week.days.first(), modifier = Modifier - .width(WEEK_NUMBER_GUTTER) + .width(rememberWeekNumberGutter()) .fillMaxHeight(), ) } @@ -1494,11 +1600,11 @@ internal fun SplitMonthGrid( val inMonth = day.month == month.month && day.year == month.year // Seated by lane rather than gathered by colour, so each dot // is the event the expanded grid draws in that same lane. - val seated = week.laneEvents(col, day, MAX_EVENT_ROWS) + val seated = week.laneEvents(col, day, SPLIT_DOT_LANES) SplitDayCell( date = day, events = seated, - hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS), + hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES), isToday = day == state.today, // A page marks only the days its own month owns. Paging // moves the selection before this month's replacement @@ -1518,17 +1624,11 @@ 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)) - } } } /** - * One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots. + * One compact day: its number over up to [SPLIT_DOT_LANES] lane-seated event dots. * * Selection and today are deliberately different signals — a tinted, outlined * cell versus the filled circle the other views already use for today — so the @@ -1902,7 +2002,7 @@ private fun rememberSkeletonPulse(): Float { * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so * a multi-day event is one connected bar across the columns; single-day timed * events sit beneath them as filled pills in their own cell. The cap is - * [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day. + * [SPLIT_DOT_LANES] rows of bars+pills, then a "+N" dot indicator per day. * A transparent per-day layer on top turns a tap into "open that day". */ @Composable @@ -1913,6 +2013,8 @@ private fun MonthWeekRow( zone: TimeZone, /** The narrowest chip that may carry a start time, measured once per grid (#219). */ timeChipWidth: Dp, + /** The height this row was given, which decides its lanes — see [laneCapFor]. */ + rowHeight: Dp, inMonth: (LocalDate) -> Boolean, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, @@ -1929,8 +2031,10 @@ private fun MonthWeekRow( selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() + val overflowRow = rememberOverflowRowHeight() + val laneCap = week.laneCapFor(rowHeight, overflowRow) val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 - val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) + val shownLanes = laneCount.coerceAtMost(laneCap) val morphing = morphInFlight() // Every chip's start time for this row at once, and only when the row's own // inputs change: formatting is a parsed pattern per call, and the dim cutoff @@ -1983,9 +2087,9 @@ private fun MonthWeekRow( band = bandCoordinates[0], columnWidthPx = cell.size.width / 7f, laneHeightPx = rowHeightPx, - laneCount = MAX_EVENT_ROWS, + laneCount = laneCap, isRtl = isRtl, - chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) }, + chipAt = { col, lane -> week.chipAt(col, lane, laneCap) }, chipStart = { col, lane -> week.chipStartCol(col, lane) }, ), ) @@ -2007,7 +2111,7 @@ private fun MonthWeekRow( WeekNumberGutter( weekStart = week.days.first(), modifier = Modifier - .width(WEEK_NUMBER_GUTTER) + .width(rememberWeekNumberGutter()) .fillMaxHeight(), ) } @@ -2026,6 +2130,7 @@ private fun MonthWeekRow( controller = dragController, band = bandCoordinates, rowHeightPx = rowHeightPx, + laneCap = laneCap, isRtl = isRtl, chipTimes = chipTimes, ), @@ -2034,6 +2139,11 @@ private fun MonthWeekRow( // What a chip has to spend, against the [timeChipWidth] a start time // costs it (#219). val colW = maxWidth / 7 + // Held here rather than read at the offset: the dots are placed + // inside a plain lambda, which is no longer in this scope. The box + // is the whole row, so the day number's share comes off it — the + // dots are positioned inside the band, not inside this. + val bandHeight = monthBandHeight(maxHeight) // Per-day background pills — same surfaceContainer rounded surface the // week/day views use, so the three views share one visual language. @@ -2176,7 +2286,7 @@ private fun MonthWeekRow( .filter { it.lane < shownLanes && col in it.startCol..it.endCol } .map { it.lane } .toSet() - val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied } + val freeSlots = (0 until laneCap).filter { it !in occupied } val pillsShown = timed.take(freeSlots.size) pillsShown.forEachIndexed { i, ev -> MonthBar( @@ -2210,8 +2320,20 @@ private fun MonthWeekRow( events = hiddenEvents, total = hidden, dark = dark, + rowHeight = overflowRow, modifier = Modifier - .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) + // After the chip lanes, but never past the + // band: on a row too short for the lanes it + // is holding, dots placed below it are + // clipped away entirely and the day looks + // like it has nothing more to show. + .offset( + x = colW * col, + y = minOf( + EVENT_ROW_HEIGHT * laneCap, + bandHeight - overflowRow, + ), + ) .morphBounds(MonthMorphKey.Overflow(d)) .width(colW) .padding(horizontal = 3.dp), @@ -2286,6 +2408,7 @@ private fun MonthWeekRow( bandCoordinates, ), rowHeightPx = rowHeightPx, + laneCap = laneCap, ) if (chip != null) onEventClick(chip) else onOpenDay(d) }, @@ -2327,11 +2450,12 @@ internal fun MonthWeek.chipAtCellY( cellY: Float, bandTopInCell: Float?, rowHeightPx: Float, + laneCap: Int, ): EventInstance? { if (bandTopInCell == null || rowHeightPx <= 0f) return null val bandY = cellY - bandTopInCell if (bandY < 0f) return null - return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS) + return chipAt(col, (bandY / rowHeightPx).toInt(), laneCap) } /** @@ -2346,6 +2470,7 @@ private fun monthChipDragModifier( controller: MonthDragController?, band: Array, rowHeightPx: Float, + laneCap: Int, isRtl: Boolean, /** The row's formatted chip times by instance id, so the copy carries the * one its source chip had rather than deriving another (#219). */ @@ -2364,7 +2489,7 @@ private fun monthChipDragModifier( val event = if (bandY < 0f || columnPx <= 0f) { null } else { - week.chipAt(dayIndex, lane, MAX_EVENT_ROWS) + week.chipAt(dayIndex, lane, laneCap) } if (event == null || moveScope?.allows(event) != true) { false @@ -2422,8 +2547,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier ) { Text( text = weekNumber.toString(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, + style = weekNumberStyle(), color = MaterialTheme.colorScheme.onSecondaryContainer, ) } @@ -2544,7 +2668,7 @@ private fun MonthBar( val titleOverflow = eventTitleOverflow() Text( text = label, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelSmall.trimmedLines(), maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, @@ -2565,6 +2689,7 @@ private fun OverflowDots( events: List, total: Int, dark: Boolean, + rowHeight: Dp, modifier: Modifier = Modifier, ) { val soften = LocalSoftenColors.current @@ -2572,14 +2697,14 @@ private fun OverflowDots( val byColor = events.groupBy { it.color } val dots = byColor.keys.take(3) Row( - modifier = modifier.height(EVENT_ROW_HEIGHT), + modifier = modifier.height(rowHeight), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { dots.forEach { argb -> Box( modifier = Modifier - .size(6.dp) + .size(OVERFLOW_DOT_SIZE) .alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f) .background(eventAccent(argb, dark, soften), CircleShape), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt new file mode 100644 index 0000000..97984eb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt @@ -0,0 +1,224 @@ +package de.jeanlucmakiola.calendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette +import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor +import de.jeanlucmakiola.calendula.ui.settings.openUrl +import de.jeanlucmakiola.floret.components.BenefitRow +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** Where the sync branch sends someone who needs a CalDAV client (#287). */ +private const val DAVX5_URL = "https://www.davx5.com/" + +/** + * Wizard step shown only when the device has no calendar at all (#287): the app + * would otherwise finish onboarding onto an empty grid with nothing saying why. + * + * Two ways out. Calendula writes to the system calendar provider and syncs + * nothing itself, so anything beyond a device-only calendar means pointing at + * whatever does the syncing. + * + * [creation] switches the step to the outcome of its own create: confirming + * the calendar — the one place to say that more of them live in Settings — or + * reporting that the provider refused it. + */ +@Composable +internal fun CalendarsStep( + onCreateLocal: (name: String, color: Int, description: String?) -> Unit, + onSkip: () -> Unit, + creation: CalendarCreation?, + onFinished: () -> Unit, + onClearCreation: () -> Unit, + modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, +) { + val context = LocalContext.current + // The same editor Settings opens, rather than a calendar conjured out of + // nowhere: "create a calendar" should let you name and colour it, and the + // step is the first place anyone meets one. + var creating by rememberSaveable { mutableStateOf(false) } + // The insert is a suspend write: the editor stays up until it lands, so the + // step can't flash its "create" button back for a second press. + var submitted by rememberSaveable(creating) { mutableStateOf(false) } + LaunchedEffect(creation) { if (creation is CalendarCreation.Failed) creating = false } + + if (creation is CalendarCreation.Created) { + CalendarCreated( + name = creation.name, + onContinue = onFinished, + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + ) + return + } + + if (creating) { + CalendarEditor( + sessionKey = 0, + isNew = true, + // Empty, not a suggestion: a name already in the field reads as one + // the app has chosen, and the first thing to do is clear it. + initialName = "", + initialColor = CalendarColorPalette.all.first(), + initialDescription = "", + onSave = { name, color, description -> + if (!submitted) { + submitted = true + onCreateLocal(name, color, description) + } + }, + onDelete = {}, + onClose = { creating = false }, + ) + return + } + + OnboardingScaffold( + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + topSpacing = OnboardingSpace.lg, + hero = { IconHero(Icons.Filled.CalendarMonth) }, + actions = { + Button( + onClick = { + onClearCreation() + creating = true + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_calendars_local_button), + style = MaterialTheme.typography.titleMedium, + ) + } + OutlinedButton( + onClick = { openUrl(context, DAVX5_URL) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text(stringResource(R.string.onboarding_calendars_caldav_button)) + } + TextButton( + onClick = onSkip, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.onboarding_calendars_skip_button)) + } + }, + ) { + Text( + text = stringResource(R.string.onboarding_calendars_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onboarding_calendars_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + if (creation is CalendarCreation.Failed) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onboarding_calendars_create_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(OnboardingSpace.lg)) + + BenefitRow( + icon = Icons.Filled.PhoneAndroid, + title = stringResource(R.string.onboarding_calendars_benefit_local_title), + body = stringResource(R.string.onboarding_calendars_benefit_local_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Filled.CloudSync, + title = stringResource(R.string.onboarding_calendars_benefit_sync_title), + body = stringResource(R.string.onboarding_calendars_benefit_sync_body), + ) + } +} + +/** What the step shows once the calendar exists: that it does, and where the next one comes from. */ +@Composable +private fun CalendarCreated( + name: String, + onContinue: () -> Unit, + modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, +) { + OnboardingScaffold( + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + topSpacing = OnboardingSpace.lg, + hero = { IconHero(Icons.Filled.CheckCircle) }, + actions = { + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_calendars_created_continue), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + Text( + text = stringResource(R.string.onboarding_calendars_created_title, name), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onboarding_calendars_created_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(OnboardingSpace.lg)) + + BenefitRow( + icon = Icons.Filled.Settings, + title = stringResource(R.string.onboarding_calendars_created_more_title), + body = stringResource(R.string.onboarding_calendars_created_more_body), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt new file mode 100644 index 0000000..472d336 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt @@ -0,0 +1,173 @@ +package de.jeanlucmakiola.calendula.ui.onboarding + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace +import de.jeanlucmakiola.floret.components.Position + +/** + * Wizard step for a device whose every calendar is switched off (#287), which + * would otherwise open the app on a blank grid with nothing saying why. + * + * The calendars are listed rather than summarised: which ones exist and whose + * account they came from is the whole of what there is to decide, and switching + * them on shows them in every other calendar app too — not a choice to make on + * the app's word. Each row carries the same switch Settings does; the button + * above them does all of it at once. + * + * Declining leads into the calendar step behind this one, which offers to make a + * calendar instead — the same offer a device with none at all gets. + */ +@Composable +internal fun HiddenCalendarsStep( + calendars: List, + onSetVisible: (id: Long, visible: Boolean) -> Unit, + onSwitchAllOn: () -> Unit, + onFinished: () -> Unit, + writeFailed: Boolean, + modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, +) { + val anyVisible = calendars.any { it.isVisibleInSystem } + OnboardingScaffold( + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + topSpacing = OnboardingSpace.sm, + contentPadding = 0.dp, + // The list is as long as the device's calendars; pinned actions would + // eat the room it needs and hide that there is more below. + scrollingActions = true, + hero = { IconHero(Icons.Filled.VisibilityOff) }, + actions = { + Button( + onClick = if (anyVisible) onFinished else onSwitchAllOn, + modifier = Modifier + .padding(horizontal = GroupedListInset) + .fillMaxWidth() + .height(56.dp), + ) { + Text( + text = stringResource( + if (anyVisible) { + R.string.onboarding_view_continue_button + } else { + R.string.onboarding_hidden_switch_all_button + }, + ), + style = MaterialTheme.typography.titleMedium, + ) + } + // Only while nothing is on: once something is, the step has been + // answered and the one button left to press says so. + if (!anyVisible) { + TextButton( + onClick = onFinished, + modifier = Modifier + .padding(horizontal = GroupedListInset) + .fillMaxWidth(), + ) { + Text(stringResource(R.string.onboarding_hidden_skip_button)) + } + } + }, + ) { + Column(Modifier.padding(horizontal = GroupedListInset)) { + Text( + text = stringResource(R.string.onboarding_hidden_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onboarding_hidden_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + if (writeFailed) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onboarding_hidden_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Spacer(Modifier.height(OnboardingSpace.md)) + + calendars.forEachIndexed { index, calendar -> + val off = !calendar.isVisibleInSystem + GroupedRow( + title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + summary = calendar.accountName.takeIf { it.isNotBlank() }, + position = when { + calendars.size == 1 -> Position.Alone + index == 0 -> Position.Top + index == calendars.lastIndex -> Position.Bottom + else -> Position.Middle + }, + dimmed = off, + trailing = if (calendar.hasVisibilitySwitch) { + { + CalendarSwitch( + calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, + enabled = !off, + onToggle = { on -> onSetVisible(calendar.id, on) }, + ) + } + } else { + null + }, + leading = { CalendarColorChip(calendar.color) }, + ) + } + + Spacer(Modifier.height(OnboardingSpace.md)) + } +} + +/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */ +@Composable +private fun CalendarSwitch( + calendarName: String, + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + val label = stringResource(R.string.calendars_visibility_a11y, calendarName) + Switch( + checked = enabled, + onCheckedChange = onToggle, + modifier = Modifier.semantics { contentDescription = label }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt index ae820fe..dd3c68f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt @@ -5,6 +5,19 @@ enum class OnboardingStep { /** Required: nothing works without the calendar grant. */ Permission, + /** + * Only when every calendar on the device is switched off, so the app would + * open on a blank grid: lists them and offers to switch them back on (#287). + */ + HiddenCalendars, + + /** + * Only when the device has nothing to put an event in — no calendar at all, + * or none left showing after the step above. The app can make a local one, + * or send you to whatever would do the syncing (#287). + */ + Calendars, + /** Whether Calendula delivers reminder notifications itself. */ Reminders, @@ -67,6 +80,18 @@ data class OnboardingPlan( * otherwise, so the flow can only ever get shorter — never sprout a step the * counter had not accounted for. * + * [calendarsApplies] carries the same contract for the calendar step, with one + * wrinkle: for the backup step an empty list means "could not read", but here + * an empty list *is* the condition. The two are indistinguishable at this + * layer and both want the step, so it turns false only once a calendar the app + * could actually show has arrived. + * + * [hiddenApplies] is the step in front of it (#287): the device has calendars + * but every one of them is switched off. It comes first because switching one + * back on is the lighter remedy — and where it is declined, the calendar step + * behind it still offers to make one, the same offer a device with no calendars + * at all gets. + * * The month-style step is *not* conditional on Month being the chosen view: * Month is reachable from the drawer whatever opens first, and a step that came * and went as the view is picked would move the counter under the user on the @@ -75,7 +100,9 @@ data class OnboardingPlan( * [visibilityArmed] carries the one-time notice that the device is hiding some * calendars (#75) — the only optional step an *existing* install can be given, * which is why it is not gated on `fresh`, and on its own it earns no closing - * screen. + * screen. It stays a pure announcement: the all-hidden *state* is + * [hiddenApplies]'s business, up at the front of the flow where something can + * still be done about it. */ fun onboardingPlan( hasPermission: Boolean, @@ -85,6 +112,10 @@ fun onboardingPlan( viewDone: Boolean, monthStyleDone: Boolean, backupApplies: Boolean?, + hiddenDone: Boolean = false, + hiddenApplies: Boolean? = false, + calendarsDone: Boolean = false, + calendarsApplies: Boolean? = null, visibilityArmed: Boolean = false, visibilityDone: Boolean = false, doneShown: Boolean = false, @@ -92,6 +123,8 @@ fun onboardingPlan( val fresh = wizardArmed || (!hasPermission && !remindersDone) val steps = buildList { if (!hasPermission || fresh) add(OnboardingStep.Permission) + if (fresh && hiddenApplies != false) add(OnboardingStep.HiddenCalendars) + if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars) if (!remindersDone || fresh) add(OnboardingStep.Reminders) if (fresh && backupApplies != false) add(OnboardingStep.Backup) if (fresh) { @@ -104,6 +137,8 @@ fun onboardingPlan( val current = steps.firstOrNull { step -> when (step) { OnboardingStep.Permission -> !hasPermission + OnboardingStep.HiddenCalendars -> !hiddenDone + OnboardingStep.Calendars -> !calendarsDone OnboardingStep.Reminders -> !remindersDone OnboardingStep.Backup -> !backupDone OnboardingStep.View -> !viewDone diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt index 2fecbb9..0b37b7b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt @@ -28,6 +28,9 @@ fun OnboardingSteps( // Collected for every step, not just the two that draw a preview: starting // it when the picker composes would leave that step blank as it slides in. val choice by viewModel.viewChoice.collectAsStateWithLifecycle() + val creation by viewModel.creation.collectAsStateWithLifecycle() + val hiddenCalendars by viewModel.hiddenCalendars.collectAsStateWithLifecycle() + val hiddenWriteFailed by viewModel.hiddenWriteFailed.collectAsStateWithLifecycle() // Coerced because the outgoing half of a transition may be a step the live // plan has since dropped — the backup step goes once the calendars say it @@ -67,6 +70,26 @@ fun OnboardingSteps( modifier = modifier, progress = progress, ) + OnboardingStep.HiddenCalendars -> HiddenCalendarsStep( + calendars = hiddenCalendars, + onSetVisible = viewModel::setHiddenCalendarVisible, + onSwitchAllOn = viewModel::switchAllCalendarsOn, + onFinished = viewModel::finishHiddenCalendars, + writeFailed = hiddenWriteFailed, + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + ) + OnboardingStep.Calendars -> CalendarsStep( + onCreateLocal = viewModel::createLocalCalendar, + onSkip = viewModel::skipCalendars, + creation = creation, + onFinished = viewModel::finishCalendars, + onClearCreation = viewModel::clearCalendarCreation, + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + ) OnboardingStep.Reminders -> ReminderStep( onFinished = viewModel::finishReminders, modifier = modifier, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt index 06e6472..e6cbf3e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch +import de.jeanlucmakiola.calendula.domain.orderedForManager import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.CoroutineDispatcher @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first @@ -74,6 +77,27 @@ class OnboardingViewModel @Inject constructor( initialValue = null, ) + /** + * The calendar list every derived step reads, collected once: one per step + * would put a provider query each on every visibility tick, and let the + * steps judge different snapshots of the same moment. Null is "no list to + * judge" — before the grant, and on a read that failed. + */ + private val calendars: StateFlow?> = hasPermission + .flatMapLatest { granted -> + if (granted != true) { + flowOf(null) + } else { + val list: Flow?> = repository.calendars() + list.catch { emit(null) }.flowOn(io) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = null, + ) + /** * Whether the backup step applies: something of yours is worth exporting and * nothing you write to is synced anywhere. Null while the calendar list @@ -81,17 +105,90 @@ class OnboardingViewModel @Inject constructor( * an empty list is also how a null cursor arrives, and guessing false there * would drop the step for the whole session. */ - private val backupApplies: Flow = hasPermission.flatMapLatest { granted -> - if (granted != true) { - flowOf(null) - } else { - repository.calendars() - .map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() } - .catch { emit(null) } - .flowOn(io) - } + private val backupApplies: Flow = calendars.map { list -> + if (list.isNullOrEmpty()) null else list.backupApplies() } + /** How the step's own create attempt ended, while it is still saying so (#287). */ + private val _creation = MutableStateFlow(null) + val creation: StateFlow = _creation.asStateFlow() + + /** + * Whether the step made a calendar here. Latched for the session and never + * cleared: the new calendar answers [calendarsApplies] with false, which + * would drop the step out of the plan the moment it is used — first from + * under the message that it worked, and then from the step count, which + * would fall by one just as the user pressed Continue. + */ + private val calendarCreatedHere = MutableStateFlow(false) + + /** + * Whether the calendar step applies: the device has nothing to show and so + * nothing to put events in. Unlike [backupApplies] an empty list is the + * condition itself, not a failed read — the two look identical here and both + * want the step, so this only resolves once a calendar the app can actually + * show has arrived. Calendars that exist but are all switched off leave it + * unresolved too: the step in front (#287) offers to switch them on, and + * declining that lands here, on the same offer a device with none gets. + */ + private val calendarsApplies: Flow = combine( + calendars.map { list -> if (list?.any { it.isVisibleInSystem } == true) false else null }, + calendarCreatedHere, + ) { applies, latched -> if (latched) null else applies } + + /** + * Set when the step starts a write of its own, and never cleared: from that + * moment the step holds its place in the plan until it is answered. Set + * *before* the write rather than after it, because a successful one answers + * [hiddenApplies] with false — the step would drop out between the provider + * taking the change and the latch seeing it. + */ + private val hiddenTouched = MutableStateFlow(false) + + /** A visibility write the provider did not take, while the step says so (#287). */ + private val _hiddenWriteFailed = MutableStateFlow(false) + val hiddenWriteFailed: StateFlow = _hiddenWriteFailed.asStateFlow() + + /** + * Whether the device is showing nothing at all right now: calendars exist, + * none is visible, and at least one of them has a switch the step can flip + * (#287). Null only while there is no list to judge — before the grant, and + * on a read that failed — which keeps the step in the plan until the answer + * arrives, the same shrink-only contract [backupApplies] keeps. + * + * An empty list answers false rather than null, unlike [calendarsApplies]: + * a phone with no calendars at all is the next step's business, and this one + * would be listing calendars it cannot see. [hiddenTouched] holds it at null + * from the step's first write onwards. + */ + private val hiddenApplies: Flow = combine( + calendars.map { list -> list?.allHidden() }, + hiddenTouched, + ) { hidden, touched -> if (touched) null else hidden } + + /** Both calendar steps' inputs: what each applies to, and whether it is answered. */ + private val calendarsFlags: Flow = combine( + calendarsApplies, + prefs.onboardingCalendarsDone, + hiddenApplies, + prefs.onboardingHiddenCalendarsDone, + ::CalendarsFlags, + ) + + /** + * The calendars the hidden-calendars step lists, so the user can judge which + * to switch on rather than take the app's word that there are some. Live: a + * row follows its own switch, and the ones without one stay listed anyway — + * being told a calendar exists is half the point. + */ + val hiddenCalendars: StateFlow> = calendars + .map { it.orEmpty().orderedForManager() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptyList(), + ) + private val flags: Flow = combine( prefs.reminderOnboardingDone, prefs.onboardingWizardArmed, @@ -110,7 +207,13 @@ class OnboardingViewModel @Inject constructor( ) val plan: StateFlow = - combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup -> + combine( + hasPermission, + flags, + closingFlags, + backupApplies, + calendarsFlags, + ) { granted, stored, closing, backup, calendars -> granted?.let { onboardingPlan( hasPermission = it, @@ -120,6 +223,10 @@ class OnboardingViewModel @Inject constructor( viewDone = stored.viewDone, monthStyleDone = stored.monthStyleDone, backupApplies = backup, + hiddenDone = calendars.hiddenDone, + hiddenApplies = calendars.hiddenApplies, + calendarsDone = calendars.done, + calendarsApplies = calendars.applies, visibilityArmed = closing.visibilityArmed, visibilityDone = closing.visibilityDone, doneShown = closing.doneShown, @@ -186,6 +293,8 @@ class OnboardingViewModel @Inject constructor( fun goBack() { viewModelScope.launch { when (plan.value?.previous) { + OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false) + OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false) OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false) OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false) OnboardingStep.View -> prefs.setOnboardingViewDone(false) @@ -202,6 +311,53 @@ class OnboardingViewModel @Inject constructor( viewModelScope.launch { prefs.setOnboardingBackupDone() } } + /** + * Make the device-only calendar the step's editor just described. The step + * stays up on [creation] either way — to confirm the calendar and say where + * to make more, or to say the provider refused it. Answering the step for + * them would leave them where the step exists to stop them being: past it, + * with nowhere to put an event. + */ + fun createLocalCalendar(displayName: String, color: Int, description: String?) { + viewModelScope.launch { + val name = displayName.trim() + _creation.value = runCatching { + repository.createLocalCalendar( + displayName = name, + color = color, + description = description, + ) + }.fold( + onSuccess = { + calendarCreatedHere.value = true + CalendarCreation.Created(name) + }, + onFailure = { CalendarCreation.Failed }, + ) + } + } + + /** Close the calendar step once its confirmation has been read. */ + fun finishCalendars() { + viewModelScope.launch { + prefs.setOnboardingCalendarsDone() + _creation.value = null + } + } + + /** Drop the failed attempt so the step offers itself again. */ + fun clearCalendarCreation() { + _creation.value = null + } + + /** + * Close the calendar step without making one. An empty app is a legitimate + * choice, and so is going off to set up an account and coming back. + */ + fun skipCalendars() { + viewModelScope.launch { prefs.setOnboardingCalendarsDone() } + } + /** * Turn automatic backup on, writing to the folder the user just picked * (taking a durable write grant so background runs can keep writing), and @@ -246,6 +402,52 @@ class OnboardingViewModel @Inject constructor( viewModelScope.launch { prefs.setOnboardingMonthStyleDone() } } + /** Switch one listed calendar on or off, from the hidden-calendars step (#287). */ + fun setHiddenCalendarVisible(id: Long, visible: Boolean) = visibilityWrite { + repository.setCalendarsVisible(listOf(id), visible) + calendarList().any { it.id == id && it.isVisibleInSystem == visible } + } + + /** Switch every hidden calendar back on — the step's one-tap way out (#287). */ + fun switchAllCalendarsOn() = visibilityWrite { + val hidden = calendarList().filter { it.hasVisibilitySwitch && !it.isVisibleInSystem } + // Nothing to write is not a write that landed: a list that came back + // empty (a failed read) would otherwise report success and leave the + // step sitting there with no rows and no explanation. + if (hidden.isEmpty()) return@visibilityWrite false + repository.setCalendarsVisible(hidden.map { it.id }, true) + calendarList().any { it.isVisibleInSystem } + } + + /** + * Run one of the step's visibility writes, whose lambda answers whether it + * took. Checked rather than assumed: with read-only calendar access the + * choice is kept app-side (#75) and nothing the grid shows would change. + */ + private fun visibilityWrite(landed: suspend () -> Boolean) { + viewModelScope.launch { + hiddenTouched.value = true + _hiddenWriteFailed.value = !runCatching { landed() }.getOrDefault(false) + } + } + + private suspend fun calendarList(): List = repository.calendars().first() + + /** Close the hidden-calendars step, switched on or left as they were (#287). */ + fun finishHiddenCalendars() { + viewModelScope.launch { + prefs.setOnboardingHiddenCalendarsDone() + // The error belongs to the attempt, not to the step: stepping back + // into it later must not find a refusal nothing has asked for yet. + _hiddenWriteFailed.value = false + val list = calendarList() + // Switching them all back on answers the one-time notice too (#75) — + // it would otherwise announce, at the end of this same flow, that the + // device is hiding calendars it no longer hides. + if (list.isNotEmpty() && list.all { it.isVisibleInSystem }) retireVisibilityNotice() + } + } + /** Acknowledge the calendar-visibility notice (#75). */ fun finishVisibility() { viewModelScope.launch { @@ -295,6 +497,20 @@ private data class OnboardingFlags( val monthStyleDone: Boolean, ) +/** How the calendar step's create attempt ended (#287). */ +sealed interface CalendarCreation { + data class Created(val name: String) : CalendarCreation + data object Failed : CalendarCreation +} + +/** Both calendar steps' inputs (#287). */ +private data class CalendarsFlags( + val applies: Boolean?, + val done: Boolean, + val hiddenApplies: Boolean?, + val hiddenDone: Boolean, +) + /** The tail of the flow: the visibility notice and the closing screen. */ private data class ClosingFlags( val visibilityArmed: Boolean, @@ -309,6 +525,15 @@ data class ViewChoice( val weekStart: WeekStartPref, ) +/** + * True when this device would show nothing and the step could change that: no + * calendar is visible, and at least one of them has a switch worth flipping — + * a non-syncing calendar holds no events on the device, so switching it on + * leaves the grid exactly as empty (#287). + */ +private fun List.allHidden(): Boolean = + any { it.hasVisibilitySwitch } && none { it.isVisibleInSystem } + /** * True when this device holds events that live nowhere else: at least one local * calendar worth exporting, and no writable synced calendar to carry them. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index e3333c8..2dde1ab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -509,6 +509,9 @@ private fun SearchResultRow( onLongClick: (() -> Unit)? = null, ) { val event = hit.event + // Spans are empty for a blank title — the query never matches the + // placeholder — so marking the substitute is safe. + val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dark = isSystemInDarkTheme() val soften = LocalSoftenColors.current // On a picked row the headline is already recoloured for the secondary @@ -521,7 +524,7 @@ private fun SearchResultRow( // Faded like a past event anywhere else in the app — search reaches back // through the whole history. modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, - title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined), + title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined), summary = searchSummary(hit, highlight), position = position, minHeight = 64.dp, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt index 61ffe0a..a9674aa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -143,7 +143,7 @@ internal fun EventFormScreen( durationCalendars.forEachIndexed { index, calendar -> val override = state.perCalendarEventDuration[calendar.id] GroupedRow( - title = calendar.displayName, + title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, summary = override?.let { durationLabel(it) } ?: stringResource( R.string.settings_calendar_duration_inherits, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index 0b62f48..cc77805 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -181,7 +181,7 @@ internal fun NotificationsScreen( // own section — link there instead. if (calendar.id in state.managedCalendarIds) { GroupedRow( - title = calendar.displayName, + title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, summary = stringResource(R.string.settings_calendar_reminders_managed_hint), position = Position.Alone, leading = { CalendarColorChip(calendar.color) }, @@ -198,7 +198,7 @@ internal fun NotificationsScreen( } val expanded = calendar.id in expandedCalendars GroupedRow( - title = calendar.displayName, + title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, position = if (expanded) Position.Top else Position.Alone, leading = { CalendarColorChip(calendar.color) }, trailing = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt index 239374b..cc3e18e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt @@ -1,10 +1,24 @@ package de.jeanlucmakiola.calendula.ui.theme import androidx.compose.material3.Typography +import androidx.compose.ui.unit.sp /** - * Default Material 3 Expressive typography. Custom font + tuned scale will - * land in a later UI-design iteration; the defaults are intentional for V1 - * scaffolding to keep the foundation lean. + * Tracking the two label roles the calendar grids are set in. Material gives + * both 0.5sp, tuned for isolated UI labels with room around them; a month chip + * is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a + * character on spacing alone. 0.1sp is what Material itself sets labelLarge to, + * so the label family stays coherent (#190). */ -val CalendulaTypography = Typography() +private val LabelTracking = 0.1.sp + +/** + * Material 3 Expressive typography with the label roles' tracking tightened. + * Everything else is the default scale. + */ +val CalendulaTypography: Typography = Typography().let { base -> + base.copy( + labelMedium = base.labelMedium.copy(letterSpacing = LabelTracking), + labelSmall = base.labelSmall.copy(letterSpacing = LabelTracking), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 7c55ce1..1867c93 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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 @@ -118,6 +118,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.EventChipShape +import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill @@ -155,7 +156,8 @@ import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale -private val ALL_DAY_ROW_HEIGHT = 24.dp +/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ +private val ALL_DAY_ROW_HEIGHT = 20.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp /** Gap between day columns; part of the column pitch a drag maps positions through. */ private val COLUMN_GAP = 2.dp @@ -571,7 +573,7 @@ private fun WeekDayHeader( } /** Calendar-week badge shown in the header gutter, deliberately set apart with a - * filled box and bold number. */ + * filled box and bold number — at the month grid's size, so the two agree (#213). */ @Composable private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { val label = stringResource(R.string.week_number_label) @@ -583,9 +585,9 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { ) { Text( text = weekNumber.toString(), - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), ) } } @@ -664,7 +666,7 @@ private fun AllDayBar( val titleOverflow = eventTitleOverflow() Text( text = title, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelSmall.trimmedLines(), maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, @@ -909,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 @@ -923,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 @@ -948,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) @@ -991,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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index edaed06..53c34eb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -167,7 +167,7 @@ internal fun layoutAllDay( // in non-decreasing start order, which the declined-last rule above breaks: // a Monday bar seated after a Wednesday one would be refused a lane it is // nowhere near, and each wasted lane costs the all-day strip a whole row and - // pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns + // pushes a bar closer to the month grid's lane cap. Seven columns // and a handful of bars, so the scan is cheaper than the sort above it. val laneCols = ArrayList>() return raw.map { r -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bdc610f..8ef8b99 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,12 +11,20 @@ Retry Something went wrong. Calendar access is required. + Calendula reads your events straight from the system calendar, so it needs calendar access to show anything. Grant access No calendars configured. + Add a calendar account to this device and its calendars show up here. Open system calendar settings All your calendars are switched off. + Switch at least one calendar back on to see its events. Manage calendars + No calendar can take new events. + The calendars that are switched on are read-only, managed by another app, or not synced to this device. Switch on a calendar that can take events, or add a local one. + This event is no longer there. + It may have been deleted, or moved to a calendar that is switched off. Could not read the calendar. + The system calendar did not answer. Try again in a moment. Copy @@ -282,6 +290,9 @@ (No title) + + (No name) + Step %1$d of %2$d Your events live only here @@ -290,6 +301,26 @@ Backups are plain .ics files — put them somewhere that syncs, or on an SD card. Once a day, by itself Calendula exports your local calendars in the background. Change how often in Settings. + Somewhere to put your events + This device has no calendar yet. Calendula writes to the calendars already on your phone — it needs at least one. + Create a calendar on this device + Sync with a CalDAV server + Not now + Stays on this phone + A device calendar syncs nowhere, so back it up if the events matter. + Or sync from an account + Calendula shows what your accounts already sync. CalDAV servers need DAVx\u2075 to do the syncing. + “%1$s” is ready + It lives on this device and new events go into it unless you pick another calendar. + Room for more + Settings → Calendars makes as many as you need, and renames or recolours the ones you have. + Continue + Your calendars are switched off + Calendula shows the calendars this phone has switched on, and right now that is none of them. Switch on the ones you want to see — your other calendar apps show them again too. + Switch them all on + Not now + Couldn\'t switch that on. Settings → Calendars has the same switches. + Couldn\'t create the calendar. Your phone\'s calendar storage refused it. Choose folder and back up Not now What should open first? @@ -665,7 +696,20 @@ Edit calendar Name Color + Description Add a description + Mauve + Red + Orange + Amber + Olive + Green + Teal + Cyan + Blue + Indigo + Purple + Pink Delete calendar? \"%1$s\" and all of its events will be permanently removed from this device. Couldn\'t save the change. @@ -679,6 +723,7 @@ Restore Restore from .ics file Import events from a backup or another calendar app. + No calendar can take the imported events yet. Automatic backup Periodically export your local calendars to a folder as an .ics file. Backup folder diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt index 1645692..2507f9f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt @@ -64,9 +64,9 @@ class CalendarMapperTest { } @Test - fun `null displayName falls back to placeholder`() { + fun `null displayName is left blank for the UI to fill in`() { val src = reader(displayName = null).toCalendarSource() - assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR) + assertThat(src.displayName).isEmpty() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt index e52f384..2c58954 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt @@ -61,15 +61,15 @@ class InstanceMapperTest { } @Test - fun `null title falls back to placeholder`() { + fun `null title is left blank for the UI to fill in`() { val inst = reader(title = null).toEventInstance() - assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT) + assertThat(inst!!.title).isEmpty() } @Test - fun `empty title falls back to placeholder`() { + fun `empty title is left blank for the UI to fill in`() { val inst = reader(title = "").toEventInstance() - assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT) + assertThat(inst!!.title).isEmpty() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt index 710bab7..0ebae1f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt @@ -242,8 +242,8 @@ class EventSearchTest { } @Test - fun `an untitled event's placeholder is not something the query can match`() { - val untitled = candidate("(Ohne Titel)", description = "Titel folgt noch").copy(title = null) + fun `an untitled event's blank title is not something the query can match`() { + val untitled = candidate("", description = "Titel folgt noch").copy(title = null) val results = search(untitled, query = "titel") diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt index 324acf6..25d4503 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt @@ -36,12 +36,13 @@ class ModelsTest { } @Test - fun `FailureReason enum has all six variants`() { + fun `FailureReason enum has all seven variants`() { assertThat(FailureReason.values().toSet()).isEqualTo( setOf( FailureReason.PermissionRevoked, FailureReason.NoCalendarsConfigured, FailureReason.AllCalendarsHidden, + FailureReason.NoImportTarget, FailureReason.ProviderUnavailable, FailureReason.EventNotFound, FailureReason.Unknown, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt new file mode 100644 index 0000000..df3dd5a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.Availability +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test + +/** + * Reading a Google Calendar export (Codeberg #304), whose dialect the parser had + * never been held to: `VTIMEZONE` blocks with `X-LIC-LOCATION`, `TZID`-qualified + * `DTSTART`/`EXDATE`, folded `DESCRIPTION` and `ATTENDEE` lines, `RECURRENCE-ID` + * overrides written as separate `VEVENT`s, and `@google.com` UIDs. + * + * The fixture is a trimmed copy of what "Settings → Import & export → Export" + * produces, CRLF and all. Note that Google hands that export out as a **zip** + * containing one `.ics` per calendar — the file here is one member of it. + */ +class IcsGoogleImportTest { + + private val parser = IcsParser(TimeZone.of("Europe/Berlin")) + + private val result: IcsParseResult = parser.parse( + checkNotNull( + javaClass.classLoader?.getResourceAsStream("ics/google-calendar-export.ics"), + ).use { it.readBytes().toString(Charsets.UTF_8) }, + ) + + private fun event(summary: String) = result.events.single { it.summary == summary } + + @Test + fun `every master event imports and the VTIMEZONE block is not mistaken for one`() { + assertThat(result.events.map { it.summary }) + .containsExactly("Team standup", "Day off", "Christmas break") + } + + @Test + fun `X-WR-CALNAME names the calendar for all of them`() { + assertThat(result.events.map { it.calendarName }.distinct()) + .containsExactly("jane@example.com") + } + + @Test + fun `a TZID-qualified start resolves against the device tz database`() { + val standup = event("Team standup") + assertThat(standup.isAllDay).isFalse() + assertThat(standup.zoneId).isEqualTo("Europe/Berlin") + // 10:00 CEST is 08:00 UTC. + assertThat(standup.start.toString()).isEqualTo("2026-09-15T08:00:00Z") + assertThat((standup.end - standup.start).inWholeMinutes).isEqualTo(90) + } + + @Test + fun `the recurrence rule and its TZID-qualified EXDATE survive`() { + val standup = event("Team standup") + assertThat(standup.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=TU") + assertThat(standup.exDates).containsExactly("20260929T080000Z") + } + + @Test + fun `the moved occurrence is skipped rather than imported as a duplicate`() { + // Google writes a RECURRENCE-ID override as its own VEVENT carrying the + // master's UID. Importing it would put a second "standup" in the + // calendar; Calendula models no overrides, so it is reported instead. + assertThat(result.events.map { it.summary }).doesNotContain("Team standup (moved)") + assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped) + } + + @Test + fun `a folded DESCRIPTION is unfolded and unescaped`() { + assertThat(event("Team standup").description).isEqualTo( + "Weekly sync with the team.\nAgenda lives in the shared doc, see the link below.", + ) + } + + @Test + fun `an escaped comma in LOCATION comes back as a comma`() { + assertThat(event("Team standup").location).isEqualTo("Meeting room 2, 3rd floor") + } + + @Test + fun `attendees are reported rather than silently dropped`() { + assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored) + } + + @Test + fun `a timed VALARM becomes its lead time in minutes`() { + assertThat(event("Team standup").semanticReminderMinutes()).containsExactly(30) + } + + @Test + fun `an all-day event keeps the single day the file gives it`() { + val dayOff = event("Day off") + assertThat(dayOff.isAllDay).isTrue() + assertThat((dayOff.end - dayOff.start).inWholeDays).isEqualTo(1) + assertThat(dayOff.availability).isEqualTo(Availability.Free) + } + + @Test + fun `an all-day alarm comes back as whole days before`() { + // Google writes an all-day reminder as a whole-day offset from the + // event's UTC midnight; a day has to survive as a day. + assertThat(event("Day off").semanticReminderMinutes()).containsExactly(1440) + } + + @Test + fun `a multi-day all-day event keeps its exclusive DTEND span`() { + val christmas = event("Christmas break") + assertThat(christmas.isAllDay).isTrue() + assertThat((christmas.end - christmas.start).inWholeDays).isEqualTo(3) + } + + @Test + fun `no recurrence rule is repaired - Google writes them well-formed`() { + assertThat(result.warnings).doesNotContain(IcsParseWarning.RecurrenceRuleRepaired) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt new file mode 100644 index 0000000..c46821b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * A component whose `END:` line never arrives (a download cut short, a producer + * that dropped a line). `indexOfEnd` falls back to the end of the file, so the + * block is read as if it had been closed. + */ +class IcsUnterminatedComponentTest { + + private val parser = IcsParser(TimeZone.of("Europe/Berlin")) + + @Test + fun `file truncated mid-property keeps the event it had started`() { + val result = parser.parse( + """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:truncated-1@fixture + SUMMARY:Cut off mid-file + DTSTART:20260924T100000Z + DTEND:20260924T1 + """.trimIndent(), + ) + assertEquals(1, result.events.size) + val event = result.events.single() + assertEquals("Cut off mid-file", event.summary) + // The truncated DTEND does not parse, so the event is zero length. + assertEquals(event.start, event.end) + assertEquals(emptySet(), result.warnings) + } + + @Test + fun `a missing END between two events does not merge them`() { + val result = parser.parse( + """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:first@fixture + SUMMARY:First event + DTSTART:20260924T100000Z + DTEND:20260924T110000Z + BEGIN:VEVENT + UID:second@fixture + SUMMARY:Second event + DTSTART:20260925T100000Z + DTEND:20260925T110000Z + END:VEVENT + END:VCALENDAR + """.trimIndent(), + ) + assertEquals( + listOf("First event", "Second event"), + result.events.map { it.summary }, + ) + assertEquals( + listOf("first@fixture", "second@fixture"), + result.events.map { it.uid }, + ) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt new file mode 100644 index 0000000..6206ea3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt @@ -0,0 +1,98 @@ +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`() { + // This is #304's device. The screenshots on the issue show Calendula's + // own calendar list with no local calendar and an empty "synced + // calendars" section — the app saw nothing at all, so both halves of + // Backup & restore had nothing to draw and the screen came up blank. + // The reporter's "it's synced, I can see it in settings" meant Android's + // settings, not this app's. + 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 `an account that has stopped syncing is no import target`() { + // A calendar the provider no longer keeps events for: nothing to export + // and nowhere to import to, which used to render a blank screen. Not + // #304's device — that one had no calendars whatsoever — but the same + // dead end, and reachable on its own. + assertThat(failure(backupUiState(listOf(cal(1L, syncs = false))))) + .isEqualTo(FailureReason.NoImportTarget) + } + + @Test + fun `a read-only subscription is no import target either`() { + assertThat(failure(backupUiState(listOf(cal(1L, writable = false))))) + .isEqualTo(FailureReason.NoImportTarget) + } + + @Test + fun `everything switched off is reported as hidden, not as a missing target`() { + // The remedy differs: a visibility switch fixes this one. + assertThat(failure(backupUiState(listOf(cal(1L, visible = false))))) + .isEqualTo(FailureReason.AllCalendarsHidden) + } + + @Test + fun `a hidden local calendar still exports`() { + // Export reads the provider directly, so system visibility is irrelevant + // to it — calling this a failure would hide a working action. + val state = ready(backupUiState(listOf(cal(1L, local = true, visible = false)))) + assertThat(state.exportable).hasSize(1) + assertThat(state.canImport).isFalse() + } + + @Test + fun `a managed special-dates mirror is neither exportable nor a target`() { + assertThat(failure(backupUiState(listOf(cal(1L, local = true, managed = true))))) + .isEqualTo(FailureReason.NoImportTarget) + } + + @Test + fun `a local calendar alongside a synced one offers both halves`() { + val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L)))) + assertThat(state.exportable.map { it.id }).containsExactly(1L) + assertThat(state.canImport).isTrue() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTextMetricsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTextMetricsTest.kt new file mode 100644 index 0000000..8516b28 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTextMetricsTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoomTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoomTest.kt index b804a37..346dc8c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoomTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoomTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test @@ -75,17 +76,16 @@ class TimelineZoomTest { } @Test - fun `a pinch held against a fractional bound stays put`() { - // A bound that is not a whole pixel is a height the pinch can be pushed - // against but never land on, so every frame of a held gesture would look - // like a scale change and hand the focal anchor a scroll correction. + fun `a pinch held against either bound stays put`() { val fillPx = 62.083f val maxPx = 616.5f val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx) val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx) - assertThat(floor).isEqualTo(63f) + // The ceiling is pulled onto the pixel grid so it stays landable; the + // fill floor is landable as it is, being the clamp result itself. + assertThat(floor).isEqualTo(fillPx) assertThat(ceiling).isEqualTo(616f) // Landing there and being pushed further must not move them again. assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor) @@ -100,6 +100,35 @@ class TimelineZoomTest { assertThat(floor * 24).isAtLeast(viewport) } + @Test + fun `pinching all the way out leaves nothing to scroll`() { + // #290: rounding the fill floor up to a whole pixel made the day one + // pixel per hour taller than the viewport it was supposed to fill, so a + // fully zoomed-out timeline still scrolled a hair and bounced off + // Android's overscroll stretch -- while FitDay, at the same zoom, sat + // still. 1490 is deliberately not divisible by 24. + val viewport = 1490f + val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f) + assertThat(floor * 24).isWithin(0.01f).of(viewport) + } + + @Test + fun `the pinch floor is the height FitDay resolves to`() { + // The inconsistency the issue is about: the two ways to reach "the whole + // day on one screen" have to arrive at the same height. + val density = Density(2.5f) + val viewport = 596.dp + with(density) { + val fitDay = TimelineScale.FitDay.hourHeight(viewport).toPx() + val pinched = pinchedHourHeightPx( + target = 1f, + fillPx = fillHourHeight(viewport).toPx(), + maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(), + ) + assertThat(pinched).isWithin(0.01f).of(fitDay) + } + } + @Test fun `a settled pinch is what gets persisted`() { var persisted: TimelineScale? = null diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt index 2f1ee4c..727ad29 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt @@ -56,7 +56,13 @@ class ChipAtCellYTest { ) private fun MonthWeek.chipAt(col: Int, cellY: Float) = - chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight) + chipAtCellY( + col = col, + cellY = cellY, + bandTopInCell = bandTop, + rowHeightPx = laneHeight, + laneCap = SPLIT_DOT_LANES, + ) @Test fun `a tap on a lane resolves to the chip seated there`() { @@ -98,13 +104,13 @@ class ChipAtCellYTest { @Test fun `a tap on the overflow row opens the day rather than a hidden event`() { - val events = (1..MAX_EVENT_ROWS + 2).map { + val events = (1..SPLIT_DOT_LANES + 2).map { timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong()) } val week = rowOfJuly6(events) // The dots sit one lane below the last one the row draws. - val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f + val overflowY = bandTop + laneHeight * SPLIT_DOT_LANES + 2f assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull() } @@ -113,10 +119,22 @@ class ChipAtCellYTest { val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L))) assertThat( - week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight), + week.chipAtCellY( + col = 1, + cellY = 45f, + bandTopInCell = null, + rowHeightPx = laneHeight, + laneCap = SPLIT_DOT_LANES, + ), ).isNull() assertThat( - week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f), + week.chipAtCellY( + col = 1, + cellY = 45f, + bandTopInCell = bandTop, + rowHeightPx = 0f, + laneCap = SPLIT_DOT_LANES, + ), ).isNull() } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLaneCapTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLaneCapTest.kt new file mode 100644 index 0000000..0d7a207 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLaneCapTest.kt @@ -0,0 +1,93 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * How many chip lanes a week row seats at a given height (#190) — the cap that + * used to be a flat three whatever the device had. + */ +class MonthLaneCapTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + private val monday = LocalDate(2026, 7, 6) + + /** Cell chrome above the band: 6 + 22 + 4. */ + private val header = 32.dp + + private fun timed(day: LocalDate, hour: Int, id: Long) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = day.atTime(hour, 0).toInstant(zone), + end = day.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = 0xFFF44336.toInt(), + location = null, + ) + + /** Jul 6-12, wholly inside July 2026. */ + private fun week(eventsOnMonday: Int) = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + (1..eventsOnMonday).map { timed(monday, hour = it, id = it.toLong()) }, + zone, + )[1] + + private fun rowFor(bandHeight: Int) = header + bandHeight.dp + + /** A labelSmall line at font scale 1 — what the overflow row measures to. */ + private val overflowRow = 16.dp + + private fun MonthWeek.capAt(rowHeight: androidx.compose.ui.unit.Dp) = + laneCapFor(rowHeight, overflowRow) + + @Test + fun `a week that fits spends the overflow row on another lane`() { + // 80dp of band is four 20dp lanes. Nothing overflows at four, so no + // room is set aside for a marker that would never be drawn. + assertThat(week(eventsOnMonday = 4).capAt(rowFor(80))).isEqualTo(4) + } + + @Test + fun `a week that overflows pays for the dots out of its own lanes`() { + // The same 80dp, but a fifth event means the dots have to be drawn, and + // their 14dp comes off the band before it is divided. + assertThat(week(eventsOnMonday = 5).capAt(rowFor(80))).isEqualTo(3) + } + + @Test + fun `a taller row seats more, with no ceiling`() { + assertThat(week(eventsOnMonday = 20).capAt(rowFor(120))).isEqualTo(5) + assertThat(week(eventsOnMonday = 20).capAt(rowFor(220))).isEqualTo(10) + } + + @Test + fun `a cramped row seats fewer rather than drawing past its band`() { + // 58dp was three clipped lanes and dots nobody could see. It is two + // lanes and a visible marker. + assertThat(week(eventsOnMonday = 6).capAt(rowFor(58))).isEqualTo(2) + } + + @Test + fun `a row with no height to give still seats one lane`() { + assertThat(week(eventsOnMonday = 6).capAt(rowFor(0))).isEqualTo(1) + assertThat(week(eventsOnMonday = 6).capAt(10.dp)).isEqualTo(1) + } + + @Test + fun `an empty week never charges itself for dots`() { + assertThat(week(eventsOnMonday = 0).capAt(rowFor(80))).isEqualTo(4) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt index fe0a521..0dfae8e 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt @@ -18,6 +18,10 @@ class OnboardingPlanTest { viewDone: Boolean = false, monthStyleDone: Boolean = false, backupApplies: Boolean? = null, + hiddenDone: Boolean = false, + hiddenApplies: Boolean? = false, + calendarsDone: Boolean = false, + calendarsApplies: Boolean? = false, visibilityArmed: Boolean = false, visibilityDone: Boolean = false, doneShown: Boolean = false, @@ -29,6 +33,10 @@ class OnboardingPlanTest { viewDone = viewDone, monthStyleDone = monthStyleDone, backupApplies = backupApplies, + hiddenDone = hiddenDone, + hiddenApplies = hiddenApplies, + calendarsDone = calendarsDone, + calendarsApplies = calendarsApplies, visibilityArmed = visibilityArmed, visibilityDone = visibilityDone, doneShown = doneShown, @@ -243,4 +251,144 @@ class OnboardingPlanTest { ) assertThat(read.current).isNull() } + + // --- the calendar step (#287) ----------------------------------------- + + @Test + fun `a device with no calendar is offered one, right after the grant`() { + val fresh = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null) + assertThat(fresh.steps).contains(OnboardingStep.Calendars) + assertThat(fresh.current).isEqualTo(OnboardingStep.Calendars) + // Before reminders: there is no point asking about notifications for + // events that have nowhere to live. + assertThat(fresh.steps.indexOf(OnboardingStep.Calendars)) + .isLessThan(fresh.steps.indexOf(OnboardingStep.Reminders)) + } + + @Test + fun `a device that already has a calendar never sees the step`() { + val fresh = plan(hasPermission = true, wizardArmed = true, calendarsApplies = false) + assertThat(fresh.steps).doesNotContain(OnboardingStep.Calendars) + } + + @Test + fun `the step is assumed to apply until a calendar list proves otherwise`() { + // Same contract the backup step keeps: an unknown answer keeps the step, + // so the plan can only ever get shorter and the counter never grows. + val unknown = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null) + val known = plan(hasPermission = true, wizardArmed = true, calendarsApplies = false) + assertThat(unknown.steps).contains(OnboardingStep.Calendars) + assertThat(known.total).isLessThan(unknown.total) + } + + @Test + fun `answering the step moves on without renumbering the flow`() { + val before = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null) + val after = before.let { + plan( + hasPermission = true, + wizardArmed = true, + calendarsApplies = null, + calendarsDone = true, + ) + } + assertThat(after.current).isEqualTo(OnboardingStep.Reminders) + assertThat(after.steps).isEqualTo(before.steps) + assertThat(after.total).isEqualTo(before.total) + } + + @Test + fun `an existing install is never given the calendar step`() { + // Not gated on `fresh` would re-onboard everyone whose read momentarily + // came back empty. + val existing = plan( + hasPermission = true, + remindersDone = true, + calendarsApplies = null, + ) + assertThat(existing.steps).doesNotContain(OnboardingStep.Calendars) + } + + // --- every calendar switched off (#287) ------------------------------- + + @Test + fun `a fresh install whose calendars are all off is shown them first`() { + // Nothing visible leaves the calendar step unresolved too, which is what + // puts the two in the same flow. + val hidden = plan( + hasPermission = true, + wizardArmed = true, + hiddenApplies = true, + calendarsApplies = null, + ) + assertThat(hidden.current).isEqualTo(OnboardingStep.HiddenCalendars) + // Switching one back on is the lighter remedy, so it comes before the + // offer to make a calendar. + assertThat(hidden.steps.indexOf(OnboardingStep.HiddenCalendars)) + .isLessThan(hidden.steps.indexOf(OnboardingStep.Calendars)) + } + + @Test + fun `declining them leaves the offer to make a calendar standing`() { + // The whole point of the order: someone who will not switch their + // calendars on still gets the offer a device with none at all gets. + val declined = plan( + hasPermission = true, + wizardArmed = true, + hiddenApplies = true, + hiddenDone = true, + calendarsApplies = null, + ) + assertThat(declined.current).isEqualTo(OnboardingStep.Calendars) + } + + @Test + fun `switching one on drops the offer to make a calendar`() { + // Something is showing now, so there is somewhere to put an event; the + // step the user is standing on is held in place by the view model. + val fixed = plan( + hasPermission = true, + wizardArmed = true, + hiddenApplies = null, + calendarsApplies = false, + ) + assertThat(fixed.steps).contains(OnboardingStep.HiddenCalendars) + assertThat(fixed.steps).doesNotContain(OnboardingStep.Calendars) + } + + @Test + fun `a fresh install with something visible never sees the step`() { + val visible = plan(hasPermission = true, wizardArmed = true, hiddenApplies = false) + assertThat(visible.steps).doesNotContain(OnboardingStep.HiddenCalendars) + } + + @Test + fun `the step is assumed until the calendar list proves something is visible`() { + // The unreadable answer keeps the step, like the ones around it: the flow + // shrinks when the list arrives, and the counter never grows. + val unknown = plan(hasPermission = true, wizardArmed = true, hiddenApplies = null) + val known = plan(hasPermission = true, wizardArmed = true, hiddenApplies = false) + assertThat(unknown.steps).contains(OnboardingStep.HiddenCalendars) + assertThat(known.total).isLessThan(unknown.total) + } + + @Test + fun `an existing install that switched its own calendars off is left alone`() { + // Hiding every calendar from Settings is a deliberate act with an empty + // state of its own (#239); it must not reopen the wizard. + val existing = plan(hasPermission = true, remindersDone = true, hiddenApplies = true) + assertThat(existing.steps).isEmpty() + assertThat(existing.current).isNull() + } + + @Test + fun `the upgrade notice stays a notice at the end of the flow`() { + val armed = plan( + hasPermission = true, + remindersDone = true, + visibilityArmed = true, + hiddenApplies = true, + ) + assertThat(armed.steps).containsExactly(OnboardingStep.Visibility) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt index ab84941..8b0766f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.theme +import androidx.compose.ui.unit.sp import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test @@ -48,6 +49,19 @@ class FontsTest { assertThat(typography.labelSmall.fontFamily).isEqualTo(plain) } + @Test + fun `picking a font keeps the tightened label tracking`() { + // The font picker only swaps the family; the tracking the grids are laid + // out against has to survive it (#190). + val typography = calendulaTypography( + brand = BundledFont.Lora.family, + plain = BundledFont.AtkinsonHyperlegible.family, + ) + + assertThat(typography.labelSmall.letterSpacing).isEqualTo(0.1.sp) + assertThat(typography.labelMedium.letterSpacing).isEqualTo(0.1.sp) + } + @Test fun `a null role keeps that role's default family while the other is applied`() { val plain = BundledFont.Lora.family diff --git a/app/src/test/resources/ics/google-calendar-export.ics b/app/src/test/resources/ics/google-calendar-export.ics new file mode 100644 index 0000000..8c7dcd1 --- /dev/null +++ b/app/src/test/resources/ics/google-calendar-export.ics @@ -0,0 +1,91 @@ +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:jane@example.com +X-WR-TIMEZONE:Europe/Berlin +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260915T100000 +DTEND;TZID=Europe/Berlin:20260915T113000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE;TZID=Europe/Berlin:20260929T100000 +DTSTAMP:20260912T183000Z +UID:0abcdef1234567890abcdef12345@google.com +CREATED:20260901T120000Z +DESCRIPTION:Weekly sync with the team.\nAgenda lives in the shared doc\, see + the link below. +LAST-MODIFIED:20260902T081500Z +LOCATION:Meeting room 2\, 3rd floor +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Team standup +TRANSP:OPAQUE +ORGANIZER;CN=Jane Doe:mailto:jane@example.com +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=John:m + ailto:john@example.com +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-PT30M +END:VALARM +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260929T140000 +DTEND;TZID=Europe/Berlin:20260929T153000 +DTSTAMP:20260912T183000Z +UID:0abcdef1234567890abcdef12345@google.com +RECURRENCE-ID;TZID=Europe/Berlin:20260929T100000 +CREATED:20260901T120000Z +LAST-MODIFIED:20260910T091500Z +SEQUENCE:1 +STATUS:CONFIRMED +SUMMARY:Team standup (moved) +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261024 +DTEND;VALUE=DATE:20261025 +DTSTAMP:20260912T183000Z +UID:1bcdef01234567890abcdef23456@google.com +CREATED:20260820T101500Z +LAST-MODIFIED:20260820T101500Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Day off +TRANSP:TRANSPARENT +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-P1D +END:VALARM +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261224 +DTEND;VALUE=DATE:20261227 +DTSTAMP:20260912T183000Z +UID:2cdef012345678901abcdef34567@google.com +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Christmas break +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 780f683..ed3afde 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -113,6 +113,19 @@ notice (#75) is a step in front of it rather than a dialog over the app — it is the only optional step an *already onboarded* install can be given, which is why it alone is not gated on the wizard being armed. +"Every calendar is switched off" is a *state*, not that announcement, so it +is a step of its own at the front of the flow (#287) rather than another face +of the notice: the notice is settled once and for good, while the state is +derived from the live calendar list on every launch. It lists the calendars +with their own switches — switching one on un-hides it in every other calendar +app too, which is not a choice to make on the app's word — and it sits *in +front of* the calendar step, so declining it falls through to the offer to +make a calendar that a device with none at all gets. That is also why +`calendarsApplies` resolves on "something is visible" rather than "the list is +non-empty". Being derived, it is gated on the wizard being armed: an install +that switched its own calendars off from Settings meant it, and has the in-app +empty state (#239) instead. + ## Recurring writes The provider's invariants drive the design (learned the hard way, verified diff --git a/fastlane/metadata/android/en-US/changelogs/22100.txt b/fastlane/metadata/android/en-US/changelogs/22100.txt new file mode 100644 index 0000000..d1dddce --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/22100.txt @@ -0,0 +1,11 @@ +New +• Setup now makes sure you have a calendar to write to — create one, add an account, or switch a hidden one back on. + +Changed +• The month grid gives its days more room: smaller week numbers, a measured gutter, tighter chips. +• A month day shows as many events as it has height for, instead of a flat three. +• Split view no longer pads a short month out to six rows. + +Fixed +• Short events show their title in week and day view. +• Untitled events and unnamed calendars read in your language.