Compare commits

..
Author SHA1 Message Date
makiolaj 7e44425393 Record what #304's device actually looked like (#304) 2026-09-23 17:40:12 +02:00
Jean-Luc Makiola 50ac6d758b Seat as many month chips as the row has height for (#323) 2026-09-23 17:15:35 +02:00
Jean-Luc Makiola 4f74f36bf5 Collapse the split grid to the rows its month spans (#320) 2026-09-23 17:15:27 +02:00
Jean-Luc Makiola a75e1a8c2f Give a month chip back some of its width (#319) 2026-09-23 17:15:19 +02:00
Jean-Luc Makiola 4c560a9aa4 Size the week-number gutter to the number it seats (#318) 2026-09-23 17:15:08 +02:00
Jean-Luc Makiola 2dc22dc480 Show the title on short week and day blocks (#317) 2026-09-23 17:14:29 +02:00
Jean-Luc Makiola 714f45d869 Tighten label tracking so the grids fit more text (#316) 2026-09-23 17:11:10 +02:00
Jean-Luc Makiolaandmakiolaj 2670269776 Give Backup & restore its loading and failure states (#314)
Backup & restore built its rows straight from the calendar list. That list
starts empty, catches to empty on a provider error, and can legitimately load
with nothing exportable and nothing importable — all three rendered as a blank
page with no buttons at all.

- `BackupUiState` (Loading / Failure / Ready) on `CalendarsViewModel`, mirroring
  the `FilterUiState` pattern already used elsewhere.
- The restore row stays on screen whenever the screen shows anything at all.
  When no calendar can receive events it says so and routes to the calendar
  manager — whose row labels already explain per calendar *why* (read-only,
  managed, not synced) — instead of disappearing.
- New `FailureReason.NoImportTarget` for the case the list cannot explain on its
  own: calendars exist and are visible, but none can take an event.
- Export stays available on an all-hidden device. It reads the provider
  directly, so `calendarListFailure` on its own would have hidden a working
  action; the failure is raised only when both halves are dead.
- `LocalManageCalendars` is now provided around `BackupScreen` (it sits directly
  over the manager, so the way out is popping back).

Also adds `IcsGoogleImportTest` over a Google Calendar export fixture:
`VTIMEZONE` with `X-LIC-LOCATION`, `TZID`-qualified `DTSTART`/`EXDATE`, folded
`DESCRIPTION` and `ATTENDEE` lines, `RECURRENCE-ID` overrides as separate
`VEVENT`s, escaped commas, all-day alarms. All 13 pass against the parser
unchanged — the read side needed no fix.

**Does not close #304.** The blank screen this fixes is real and reachable, but
it needs the device to have no usable calendar at all; a working Google calendar
satisfies `isEventTarget`, so that reporter would have seen the restore row. The
actual cause there is still open — questions posted on the issue. This stands on
its own as the missing loading and failure states.

Refs #304

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/314
2026-09-22 10:42:57 +02:00
Jean-Luc Makiolaandmakiolaj bdcf2b3823 Stop a fully zoomed-out timeline from scrolling (#315)
The pinch clamped its lower bound to `ceil(fillPx)`. Rounding a fill height up
by the fraction of a pixel that 24 hours don't divide the viewport into makes
the timeline up to one pixel per hour taller than the viewport those hours are
supposed to fill — roughly 24px of leftover scroll, enough to bounce off
Android's overscroll stretch. `FitDay` resolves to the unrounded height and sits
still, so the two ways of reaching "the whole day on one screen" disagreed.
That inconsistency is what #290 is about.

Clamps to `fillPx` itself. The whole-pixel rounding exists so the hour gutter's
24 stacked boxes share a grid with the lines and blocks drawn at the fractional
height, and it still applies everywhere the pinch is free to move. The floor is
the one height where matching `FitDay` matters more — and because it is the
clamp *result* rather than a bound the gesture is merely held against, the pinch
lands on it exactly, so the next frame reads it back unchanged and the focal
anchor gets no correction to apply.

Week and Day both measure their viewport inside a `BoxWithConstraints` below the
all-day strip, so the strip appearing only changes the height both paths agree
on — that half of the report needed no change.

Tests: the two cases that encoded the old rounding are updated (the dead-space
invariant still holds, now exactly rather than by a pixel), plus one for the
reported symptom and one asserting the pinch floor equals what `FitDay`
resolves to at the same viewport.

Closes #290

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/315
2026-09-20 22:28:35 +02:00
43 changed files with 577 additions and 2287 deletions
-60
View File
@@ -5,55 +5,6 @@ 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
@@ -1763,14 +1714,3 @@ 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
+2 -2
View File
@@ -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 = 22100
versionName = "2.21.0"
versionCode = 22004
versionName = "2.20.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -1,35 +1,22 @@
package de.jeanlucmakiola.calendula.data.calendar
/**
* 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.
* 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.
*/
object CalendarColorPalette {
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()
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()
/** The full palette, in swatch-grid order. */
val all: List<Int> = listOf(
Mauve, Red, Orange, Amber, Olive, Green,
Teal, Cyan, Blue, Indigo, Purple, Pink,
)
/** The full palette, in swatch-row order. */
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
}
@@ -376,7 +376,7 @@ class AndroidCalendarDataSource @Inject constructor(
.build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
val name = displayName.trim()
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
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()
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
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()
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
@@ -8,9 +8,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
return CalendarSource(
id = getLong(CalendarProjection.IDX_ID),
// 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(),
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
?: Fallbacks.UNNAMED_CALENDAR,
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
accountType = accountType,
color = getInt(CalendarProjection.IDX_COLOR),
@@ -21,9 +21,8 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
return null
}
// 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 rawTitle = getString(InstanceProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
@@ -312,3 +312,8 @@ 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)"
}
@@ -21,7 +21,8 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
val rawTitle = getString(SearchProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
@@ -586,24 +586,6 @@ class SettingsPrefs @Inject constructor(
}
}
/** Whether the wizard's calendar step has been answered (or skipped). */
val onboardingCalendarsDone: Flow<Boolean> = 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<Boolean> = 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<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_BACKUP_KEY] ?: false
@@ -995,10 +977,6 @@ 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 =
@@ -312,7 +312,7 @@ private fun ExportCalendarPicker(
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
@@ -90,7 +90,6 @@ 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
@@ -98,25 +97,11 @@ 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
@@ -246,7 +231,7 @@ private fun CalendarsList(
local.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName,
summary = calendarRowSummary(calendar),
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -254,7 +239,7 @@ private fun CalendarsList(
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
@@ -331,7 +316,7 @@ private fun CalendarsList(
ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName,
summary = calendarRowSummary(calendar),
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -340,7 +325,7 @@ private fun CalendarsList(
trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
calendarName = calendar.displayName,
enabled = calendar.isVisibleInSystem,
onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
@@ -358,15 +343,9 @@ private fun CalendarsList(
}
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.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CalendarEditor(
private fun CalendarEditor(
sessionKey: Int,
isNew: Boolean,
initialName: String,
@@ -381,50 +360,121 @@ internal fun CalendarEditor(
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
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)) }
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,
),
)
},
) {
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)) {
) { 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,
) {
InlineTextField(
value = description,
onValueChange = { description = it },
@@ -435,21 +485,20 @@ internal 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,
@@ -465,205 +514,6 @@ internal 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.
@@ -706,6 +556,36 @@ 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
@@ -56,14 +56,12 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
* account comes from when another account shares the name (#77).
*/
@Composable
fun accountGroupTitle(group: CalendarAccountGroup): String {
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
return if (!group.ambiguous) {
label
fun accountGroupTitle(group: CalendarAccountGroup): String =
if (!group.ambiguous) {
group.label
} else {
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
}
}
/**
* The human name of the app backing [accountType], falling back to the raw
@@ -1,55 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor
/**
* One all-day strip height for every page, blended between the two pages a swipe
* sits between so their timelines stay level mid-swipe. It follows the swipe
* directly and only springs when a page's own strip changes at rest. [fallback]
* stands in until the pages have reported theirs, so it doesn't open from zero.
*
* @param heights each composed page's own strip height, by page index.
*/
@Composable
fun pagedStripHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
val currentFallback by rememberUpdatedState(fallback)
val target by remember(pagerState, heights) {
derivedStateOf {
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
val from = floor(position).toInt()
val a = heights[from]
val b = heights[from + 1]
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
}
}
val height = remember { Animatable(target, Dp.VectorConverter) }
LaunchedEffect(height) {
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
if (scrolling) height.snapTo(h) else height.animateTo(h)
}
}
return height.value
}
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
suspend fun PagerState.jumpToPage(
page: Int,
reduceMotion: Boolean,
spec: FiniteAnimationSpec<Float>,
) {
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
}
@@ -116,7 +116,7 @@ private fun CalendarPickerGroup(
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName,
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
@@ -40,12 +40,6 @@ import androidx.compose.ui.unit.IntOffset
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/**
* The effects spec from the active motion scheme, for the opacity half of the
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
@@ -1,53 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
/**
* One shared state per pager page — a week, a day, a month — so a page and the
* screen's anchor read the same query, and the pages either side stay loaded
* across a swipe.
*
* Every entry shares on its own job, cancelled when [keep] lets it go: a
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
* until the ViewModel itself was cleared. Main thread only.
*
* @param keep whether a cached key is still worth holding once [get] asks for another.
*/
class PageStateCache<K, S>(
private val scope: CoroutineScope,
private val initial: S,
private val keep: (cached: K, requested: K) -> Boolean,
private val load: (K) -> Flow<S>,
) {
private class Entry<S>(val state: StateFlow<S>, val job: Job)
private val entries = HashMap<K, Entry<S>>()
/** The number of entries held, for tests. */
internal val size: Int get() = entries.size
fun get(key: K): StateFlow<S> {
val iterator = entries.iterator()
while (iterator.hasNext()) {
val (cached, entry) = iterator.next()
if (cached != key && !keep(cached, key)) {
entry.job.cancel()
iterator.remove()
}
}
return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job])
Entry(
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
job = job,
)
}.state
}
}
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
/** The day shown on pager [page]. */
internal fun dayForPage(page: Int): LocalDate =
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
/** The pager page showing [date]. */
internal fun dayPageFor(date: LocalDate): Int =
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
@@ -1,22 +1,7 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -58,10 +43,12 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -122,11 +109,14 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
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
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
@@ -147,6 +137,7 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.util.Locale
import kotlin.math.roundToInt
@@ -154,9 +145,6 @@ import kotlin.math.roundToInt
private val ALL_DAY_ROW_HEIGHT = 20.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Breathing room between the all-day strip and the scrolling timeline below. */
private val TIMELINE_TOP_GAP = 8.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */
internal fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp
@@ -181,7 +169,7 @@ fun DayScreen(
viewModel: DayViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
val date by viewModel.date.collectAsStateWithLifecycle()
// When opened from the month grid, anchor to the tapped date.
LaunchedEffect(initialDateIso) {
@@ -191,39 +179,36 @@ fun DayScreen(
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// Opens on the tapped date directly, rather than on today and then correcting.
val pagerState = rememberPagerState(
initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage,
) { DAY_PAGE_COUNT }
val pageSpec = rememberCalendarPageSpec()
val reduceMotion = rememberReduceMotion()
// The pager leads and the anchor follows once it settles, so the anchor only
// moves on its own to correct the pager — e.g. re-entry from the month grid
// on another date. Snapped, since that is a correction rather than a move.
LaunchedEffect(anchorPage) {
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
pagerState.scrollToPage(anchorPage)
val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today
else -> true
}
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is DayUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is DayUiState.Success -> if (s.today < s.date) -1 else 1
else -> 0
}
viewModel.goToToday()
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
}
// The day under the finger, so the title turns over mid-swipe, not after it.
val date = dayForPage(pagerState.currentPage)
// Off the ticking clock rather than the loaded day, so it turns over at
// midnight; derived, so the per-minute tick doesn't recompose the screen.
val nowState = rememberCurrentMinute()
val zone = remember { TimeZone.currentSystemDefault() }
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
val isOnToday = state !is DayUiState.Success || date == today
// Straight to the pager: a tap mid-fling must still land, and the anchor
// hasn't caught up with the fling yet to tell the difference.
// Drawer jump-to-date: slide from the side the target lies on.
val jumpToDate: (LocalDate) -> Unit = { target ->
scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) }
slideDir = if (target < date) -1 else 1
viewModel.goToDate(target)
}
val jumpToToday = { jumpToDate(today) }
ModalNavigationDrawer(
drawerState = drawerState,
@@ -255,7 +240,7 @@ fun DayScreen(
topBar = {
DayTopBar(
date = date,
currentYear = today.year,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
@@ -277,9 +262,10 @@ fun DayScreen(
) { innerPadding ->
DayContent(
state = state,
pagerState = pagerState,
day = viewModel::day,
onRetry = viewModel::goToToday,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
@@ -293,16 +279,19 @@ fun DayScreen(
@Composable
private fun DayContent(
state: DayUiState,
pagerState: PagerState,
day: (LocalDate) -> StateFlow<DayUiState>,
slideDir: Int,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Shared by every page and the gutter, so the vertical scroll position
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
// into the day view (i.e. when arriving from the month/week view).
val scrollState = rememberScrollState()
@@ -313,45 +302,59 @@ private fun DayContent(
scrollState.scrollTo(scrollState.maxValue / 2)
}
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// day during a swipe, so the strip slides along but never jumps in height.
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "day-all-day-strip-height",
)
// Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
// Above the AnimatedContent: a page change mid-drag would strand the
// floating block inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
val onDrop: (TimelineDrop) -> Unit = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
}
Box(modifier = modifier) {
// Loading and failure concern the provider as a whole, so they stand in
// for the pager; a page still loading its own day handles that itself.
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize(),
contentKey = { it::class },
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
label = "day-state",
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DayPager(
pagerState = pagerState,
day = day,
today = s.today,
initialStripHeight = s.allDayStripHeight(),
is DayUiState.Success -> DaySuccess(
state = s,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onDrop = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
},
)
}
}
@@ -359,101 +362,6 @@ private fun DayContent(
}
}
/**
* The days as pages that follow the finger and snap once a swipe passes half a
* page or is flung (#336), beside a gutter that stays put.
*/
@Composable
private fun DayPager(
pagerState: PagerState,
day: (LocalDate) -> StateFlow<DayUiState>,
today: LocalDate,
initialStripHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
// The days either side are composed ahead, so a swipe lands on a
// loaded day rather than watching it fill in.
beyondViewportPageCount = 1,
// A held block moves within its day; the page stays put under it.
userScrollEnabled = !dragController.isDragging,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { page ->
val date = dayForPage(page)
val pageState by remember(date) { day(date) }.collectAsStateWithLifecycle()
val loaded = pageState as? DayUiState.Success
// Until its day arrives a page shows the empty column.
val empty = remember(date, today) {
DayUiState.Success(date = date, today = today, allDay = emptyList(), timed = emptyList())
}
val stripHeight = loaded?.allDayStripHeight()
DisposableEffect(page, stripHeight) {
if (stripHeight != null) stripHeights[page] = stripHeight
onDispose { stripHeights.remove(page) }
}
DayPage(
state = loaded ?: empty,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
}
/** What stays put while the days swipe: the hour gutter, with [pages] filling the rest. */
@Composable
private fun DayFrame(
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
pages: @Composable () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
Spacer(
Modifier
.fillMaxWidth()
.height(allDayHeight)
.background(MaterialTheme.colorScheme.surface),
)
Spacer(Modifier.height(TIMELINE_TOP_GAP))
// Resolves the hour height off the same viewport height the pages'
// timelines have, so labels and column agree.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val zoom = LocalTimelineZoom.current
val hourHeight = zoom.scale.hourHeight(maxHeight)
// Start inset so the labels centre on the top bar hamburger,
// matching the week view.
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
}
}
/** A single day in its frame, without the pager — for the Settings preview. */
@Composable
internal fun DaySuccess(
state: DayUiState.Success,
@@ -463,37 +371,6 @@ internal fun DaySuccess(
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
DayPage(
state = state,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = true,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
/**
* One day's page: all-day strip and column.
*
* @param active whether this is the page on screen, the only one that may
* publish the drag geometry.
*/
@Composable
private fun DayPage(
state: DayUiState.Success,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
@@ -512,12 +389,13 @@ private fun DayPage(
.background(MaterialTheme.colorScheme.surface),
)
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
// Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
active = active,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
@@ -605,6 +483,8 @@ private fun AllDayStrip(
end = TIMELINE_CONTENT_END_INSET,
),
) {
// Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH))
// Bars are positioned absolutely by lane (vertical stacking); each spans
// the full day-column width. clipToBounds keeps bars from spilling out
// while the height animates.
@@ -667,7 +547,6 @@ private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
@@ -689,53 +568,61 @@ private fun Timeline(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the
// vertical scroll, and it does that by watching the initial pass, which
// only reaches it if it is the viewport's ancestor.
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
// Scrolls on the same state as the gutter and the other pages, so they
// all stay aligned. A static, rounded-clipped window — the content
// scrolls inside it, so the soft corners are permanent at any scroll
// position.
Box(
modifier = Modifier
.fillMaxSize()
.then(pinch)
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
// Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// Hour gutter (scrolls in sync with the day column). Start inset so the
// labels centre on the top bar hamburger, matching the week view.
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset. Only the page on
// screen publishes it: the ones either side are laid out too.
.onGloballyPositioned { coords ->
if (!active) return@onGloballyPositioned
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
// Day column: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
}
}
}
}
@@ -9,7 +9,6 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
@@ -19,25 +18,21 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
import kotlin.math.abs
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
private const val DAY_CACHE_DAYS = 7
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
@@ -52,53 +47,32 @@ class DayViewModel @Inject constructor(
get() = Clock.System.now().toLocalDateTime(zone).date
private val _date = MutableStateFlow(todayDate)
val date: StateFlow<LocalDate> = _date
/** The pager page the anchor day sits on. */
val anchorPage: StateFlow<Int> = _date
.map { dayPageFor(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = dayPageFor(todayDate),
)
/**
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
* moving the anchor keeps the last result until the new day arrives, since
* the pages draw their own days and this only gates the failure screen.
*/
val state: StateFlow<DayUiState> = _date
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
.flatMapLatest { day ->
val range = dayRange(day, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(day, calendars, instances)
}
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayUiState.Loading,
)
private val days = PageStateCache<LocalDate, DayUiState>(
scope = viewModelScope,
initial = DayUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a month doesn't keep a month of queries around.
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
) { date ->
combine(
repository.calendars(),
repository.instances(dayRange(date, zone)),
) { calendars, instances ->
buildState(date, calendars, instances)
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
fun goToPrev() {
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
}
/** The day [date], shared between its pager page and [state]. Main thread only. */
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
fun goToNext() {
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
}
fun goToToday() {
@@ -527,8 +527,7 @@ private fun EventDetailContent(
iconContentDescription = stringResource(R.string.event_detail_calendar),
) {
Text(
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
?: stringResource(R.string.event_detail_calendar_unknown),
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium,
)
}
@@ -620,9 +620,7 @@ private fun EventEditContent(
Text(
text = stringResource(
R.string.event_edit_managed_hint,
selectedCalendar?.displayName
?.ifBlank { stringResource(R.string.calendar_unnamed) }
.orEmpty(),
selectedCalendar?.displayName.orEmpty(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -721,7 +719,6 @@ 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) {
@@ -62,16 +62,15 @@ 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,
account,
group.account,
sourceAppName(group.accountType),
)
} else {
account
group.account
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -84,7 +83,7 @@ private fun FilterList(
)
group.calendars.forEachIndexed { index, cal ->
GroupedRow(
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = cal.displayName,
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) },
@@ -1,224 +0,0 @@
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),
)
}
}
@@ -1,173 +0,0 @@
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<CalendarSource>,
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 },
)
}
@@ -5,19 +5,6 @@ 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,
@@ -80,18 +67,6 @@ 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
@@ -100,9 +75,7 @@ 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. 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.
* screen.
*/
fun onboardingPlan(
hasPermission: Boolean,
@@ -112,10 +85,6 @@ 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,
@@ -123,8 +92,6 @@ 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) {
@@ -137,8 +104,6 @@ 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
@@ -28,9 +28,6 @@ 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
@@ -70,26 +67,6 @@ 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,
@@ -16,8 +16,6 @@ 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
@@ -26,7 +24,6 @@ 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
@@ -77,27 +74,6 @@ 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<List<CalendarSource>?> = hasPermission
.flatMapLatest { granted ->
if (granted != true) {
flowOf(null)
} else {
val list: Flow<List<CalendarSource>?> = 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
@@ -105,90 +81,17 @@ 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<Boolean?> = calendars.map { list ->
if (list.isNullOrEmpty()) null else list.backupApplies()
private val backupApplies: Flow<Boolean?> = 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)
}
}
/** How the step's own create attempt ended, while it is still saying so (#287). */
private val _creation = MutableStateFlow<CalendarCreation?>(null)
val creation: StateFlow<CalendarCreation?> = _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<Boolean?> = 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<Boolean> = _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<Boolean?> = 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<CalendarsFlags> = 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<List<CalendarSource>> = calendars
.map { it.orEmpty().orderedForManager() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
private val flags: Flow<OnboardingFlags> = combine(
prefs.reminderOnboardingDone,
prefs.onboardingWizardArmed,
@@ -207,13 +110,7 @@ class OnboardingViewModel @Inject constructor(
)
val plan: StateFlow<OnboardingPlan?> =
combine(
hasPermission,
flags,
closingFlags,
backupApplies,
calendarsFlags,
) { granted, stored, closing, backup, calendars ->
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
granted?.let {
onboardingPlan(
hasPermission = it,
@@ -223,10 +120,6 @@ 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,
@@ -293,8 +186,6 @@ 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)
@@ -311,53 +202,6 @@ 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
@@ -402,52 +246,6 @@ 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<CalendarSource> = 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 {
@@ -497,20 +295,6 @@ 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,
@@ -525,15 +309,6 @@ 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<CalendarSource>.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.
@@ -509,9 +509,6 @@ 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
@@ -524,7 +521,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(title, hit.titleSpans, highlight), event.isDeclined),
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
summary = searchSummary(hit, highlight),
position = position,
minHeight = 64.dp,
@@ -143,7 +143,7 @@ internal fun EventFormScreen(
durationCalendars.forEachIndexed { index, calendar ->
val override = state.perCalendarEventDuration[calendar.id]
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName,
summary = override?.let { durationLabel(it) }
?: stringResource(
R.string.settings_calendar_duration_inherits,
@@ -181,7 +181,7 @@ internal fun NotificationsScreen(
// own section — link there instead.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName,
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.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName,
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
@@ -1,25 +0,0 @@
package de.jeanlucmakiola.calendula.ui.week
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val WEEK_PAGE_COUNT: Int = 20_000
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
}
@@ -1,21 +1,7 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.datetime.DayOfWeek
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -62,10 +48,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -81,7 +69,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -135,7 +122,9 @@ 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
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -153,6 +142,7 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first
@@ -162,6 +152,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@@ -171,14 +162,6 @@ 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
/** Breathing room between the header block and the scrolling timeline below. */
private val TIMELINE_TOP_GAP = 8.dp
private val HEADER_TOP_PADDING = 4.dp
private val HEADER_BOTTOM_PADDING = 8.dp
/** The header's date slot, reserved whether or not it holds today's circle. */
private val DATE_SLOT_SIZE = 28.dp
/** Total all-day strip height for a week (0 when there are no all-day events). */
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
if (allDaySpans.isEmpty()) return 0.dp
@@ -203,8 +186,7 @@ fun WeekScreen(
viewModel: WeekViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val firstDay by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
@@ -217,41 +199,39 @@ fun WeekScreen(
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = anchorPage) { WEEK_PAGE_COUNT }
val pageSpec = rememberCalendarPageSpec()
val reduceMotion = rememberReduceMotion()
// The pager leads and the anchor follows once it settles, so the only thing
// left moving the anchor on its own is the first day re-framing the week —
// including the stored preference arriving after the pager opened on the
// Monday default. Snapped, since that is a correction rather than a move.
LaunchedEffect(anchorPage) {
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
pagerState.scrollToPage(anchorPage)
val isOnCurrentWeek = when (val s = state) {
// True when today falls inside the displayed week — independent of which
// weekday the user picked as the first day.
is WeekUiState.Success ->
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
else -> true
}
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is WeekUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
else -> 0
}
viewModel.goToToday()
}
// The page it opened on is skipped: a first-day change landing in between
// would read it as a different week.
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
}
// The week under the finger, so the title turns over mid-swipe, not after it.
val weekStart = weekStartForPage(pagerState.currentPage, firstDay)
// Off the ticking clock rather than the loaded week, so it turns over at
// midnight; derived, so the per-minute tick doesn't recompose the screen.
val zone = remember { TimeZone.currentSystemDefault() }
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
// Independent of which weekday the user picked as the first day.
val isOnCurrentWeek = state !is WeekUiState.Success ||
(today >= weekStart && today <= weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY))
// Straight to the pager: a tap mid-fling must still land, and the anchor
// hasn't caught up with the fling yet to tell the difference.
// Drawer jump-to-date: slide from the side the target week lies on.
val jumpToDate: (LocalDate) -> Unit = { target ->
val page = weekPageFor(target, firstDay)
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
slideDir = if (target < weekStart) -1 else 1
viewModel.goToDate(target)
}
val jumpToToday = { jumpToDate(today) }
ModalNavigationDrawer(
drawerState = drawerState,
@@ -283,7 +263,7 @@ fun WeekScreen(
topBar = {
WeekTopBar(
weekStart = weekStart,
currentYear = today.year,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
@@ -301,6 +281,8 @@ fun WeekScreen(
onToday = jumpToToday,
onCreate = {
// Anchor on today when it's in view, else the week's first day.
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
},
)
@@ -309,10 +291,10 @@ fun WeekScreen(
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
WeekContent(
state = state,
pagerState = pagerState,
firstDay = firstDay,
week = viewModel::week,
onRetry = viewModel::goToToday,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
@@ -328,18 +310,20 @@ fun WeekScreen(
@Composable
private fun WeekContent(
state: WeekUiState,
pagerState: PagerState,
firstDay: DayOfWeek,
week: (LocalDate) -> StateFlow<WeekUiState>,
slideDir: Int,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Shared by every page and the gutter, so the vertical scroll position
// Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
// noon once, on first entry into the week view (i.e. when arriving from the
// month/day view), not on every swipe.
@@ -351,47 +335,62 @@ private fun WeekContent(
scrollState.scrollTo(scrollState.maxValue / 2)
}
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// week during a swipe, so the strip slides along but never jumps in height;
// it just springs smoothly from the old to the new size.
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "all-day-strip-height",
)
// Whole-page horizontal swipe. It sits one level above the timeline's
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
// while a vertical drag is consumed by the inner scroll first — so the two
// gestures coexist without fighting.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
// Above the AnimatedContent: a page change mid-drag would strand the
// floating block inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
val onDrop: (TimelineDrop) -> Unit = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
}
Box(modifier = modifier) {
// Loading and failure concern the provider as a whole, so they stand in
// for the pager; a page still loading its own week handles that itself.
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize(),
contentKey = { it::class },
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
label = "week-state",
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is WeekUiState.Success -> "success-${s.weekStart}"
is WeekUiState.Failure -> "failure-${s.reason}"
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "week-transition",
) { s ->
when (s) {
WeekUiState.Loading -> WeekLoading()
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekPager(
pagerState = pagerState,
firstDay = firstDay,
week = week,
today = s.today,
initialStripHeight = s.allDayStripHeight(),
is WeekUiState.Success -> WeekSuccess(
state = s,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onDrop = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
},
)
}
}
@@ -399,123 +398,6 @@ private fun WeekContent(
}
}
/**
* The weeks as pages that follow the finger and snap once a swipe passes half a
* page or is flung (#131), beside a gutter that stays put.
*/
@Composable
private fun WeekPager(
pagerState: PagerState,
firstDay: DayOfWeek,
week: (LocalDate) -> StateFlow<WeekUiState>,
today: LocalDate,
initialStripHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
WeekFrame(
weekStart = weekStartForPage(pagerState.currentPage, firstDay),
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
// The weeks either side are composed ahead, so a swipe lands on a
// loaded week rather than watching it fill in.
beyondViewportPageCount = 1,
// A held block moves within its week; the page stays put under it.
userScrollEnabled = !dragController.isDragging,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { page ->
val start = weekStartForPage(page, firstDay)
val pageState by remember(start) { week(start) }.collectAsStateWithLifecycle()
val loaded = pageState as? WeekUiState.Success
// Until its week arrives a page shows the week's frame without events.
val empty = remember(start, today) { emptyWeek(start, today) }
val stripHeight = loaded?.allDayStripHeight()
DisposableEffect(page, stripHeight) {
if (stripHeight != null) stripHeights[page] = stripHeight
onDispose { stripHeights.remove(page) }
}
WeekPage(
state = loaded ?: empty,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
}
/** A week with no events yet, for a page whose week is still loading. */
private fun emptyWeek(start: LocalDate, today: LocalDate): WeekUiState.Success {
val days = (0 until 7).map { start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
return WeekUiState.Success(
weekStart = start,
today = today,
days = days,
allDaySpans = emptyList(),
timedByDay = emptyMap(),
)
}
/**
* What stays put while the weeks swipe: the week-number badge and the hour
* gutter, with [pages] filling the rest.
*/
@Composable
private fun WeekFrame(
weekStart: LocalDate,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
pages: @Composable () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
) {
WeekNumberCell(weekStart)
Spacer(Modifier.height(allDayHeight))
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
// Resolves the hour height off the same viewport height the pages'
// timelines have, so labels and columns agree.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val zoom = LocalTimelineZoom.current
val hourHeight = zoom.scale.hourHeight(maxHeight)
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
}
}
/** A single week in its frame, without the pager — for the Settings preview. */
@Composable
internal fun WeekSuccess(
state: WeekUiState.Success,
@@ -526,44 +408,6 @@ internal fun WeekSuccess(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
WeekFrame(
weekStart = state.weekStart,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
) {
WeekPage(
state = state,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = true,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
/**
* One week's page: header, all-day strip and day columns.
*
* @param active whether this is the page on screen, the only one that may
* publish the drag geometry.
*/
@Composable
private fun WeekPage(
state: WeekUiState.Success,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -580,12 +424,13 @@ private fun WeekPage(
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
// Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
active = active,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
@@ -660,11 +505,24 @@ private fun WeekDayHeader(
onOpenDay: (LocalDate) -> Unit,
) {
val locale = currentLocale()
val weekStart = days.first()
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
) {
// Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers. The start inset centres
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
Column(
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(2.dp))
WeekNumberBadge(weekNumber = weekNumber)
}
days.forEach { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
@@ -675,19 +533,16 @@ private fun WeekDayHeader(
.clickable { onOpenDay(date) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Clip,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
// Always reserve the circle slot so the header height is
// Always reserve the 28dp circle slot so the header height is
// identical whether or not the week contains today.
Box(
modifier = Modifier.size(DATE_SLOT_SIZE),
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center,
) {
if (isToday) {
@@ -717,33 +572,6 @@ private fun WeekDayHeader(
}
}
/**
* The header's gutter cell, laid out like a day's (empty weekday line, spacer,
* date slot) so the badge lines up with the date numbers and the cell is exactly
* as tall as the header beside it. The start inset centres the badge on the top
* bar's hamburger (see GUTTER_CONTENT_START_INSET).
*/
@Composable
private fun WeekNumberCell(weekStart: LocalDate) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
start = GUTTER_CONTENT_START_INSET,
top = HEADER_TOP_PADDING,
bottom = HEADER_BOTTOM_PADDING,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(2.dp))
Box(modifier = Modifier.height(DATE_SLOT_SIZE), contentAlignment = Alignment.TopCenter) {
WeekNumberBadge(weekNumber = weekNumber)
}
}
}
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number — at the month grid's size, so the two agree (#213). */
@Composable
@@ -784,6 +612,8 @@ private fun AllDayStrip(
end = TIMELINE_CONTENT_END_INSET,
),
) {
// Keep the gutter-width offset so the bars line up with the day columns.
Spacer(Modifier.width(GUTTER_WIDTH))
// Span bars are positioned absolutely so a multi-day event is one
// connected bar across columns rather than a chip per day. clipToBounds
// keeps bars from spilling out while the height animates.
@@ -852,7 +682,6 @@ private fun Timeline(
state: WeekUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
@@ -874,61 +703,70 @@ private fun Timeline(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the
// vertical scroll, and it does that by watching the initial pass, which
// only reaches it if it is the viewport's ancestor.
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
// Scrolls on the same state as the gutter and the other pages, so they
// all stay aligned. A static, rounded-clipped window — the content
// scrolls inside it, so the soft corners are permanent at any scroll
// position (not just at the day's start/end).
Box(
modifier = Modifier
.fillMaxSize()
.then(pinch)
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
) {
Row(
// Gutter and day columns are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position (not just at the
// day's start/end).
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// Hour gutter (scrolls in sync with the day columns). Same start inset
// as the header badge so the labels sit under it and on the hamburger.
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
)
// Day columns: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset. Only the page on
// screen publishes it: the ones either side are laid out too.
.onGloballyPositioned { coords ->
if (!active) return@onGloballyPositioned
val gap = with(density) { COLUMN_GAP.toPx() }
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
it.isRtl = isRtl
}
},
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
state.days.forEach { day ->
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
val gap = with(density) { COLUMN_GAP.toPx() }
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
it.isRtl = isRtl
}
},
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
) {
state.days.forEach { day ->
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
}
}
}
@@ -12,7 +12,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -21,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -32,7 +30,6 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
@@ -41,13 +38,9 @@ import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
import kotlin.math.abs
const val MINUTES_PER_DAY: Int = 24 * 60
/** How far from the requested week [WeekViewModel.week] keeps other weeks cached. */
private const val WEEK_CACHE_DAYS = 28
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class WeekViewModel @Inject constructor(
@@ -71,7 +64,7 @@ class WeekViewModel @Inject constructor(
get() = Clock.System.now().toLocalDateTime(zone).date
/** First day of the week, from the Settings preference (AUTO → locale). */
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.weekStart
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
.map { it.resolveFirstDay(locale) }
.stateIn(
scope = viewModelScope,
@@ -80,65 +73,55 @@ class WeekViewModel @Inject constructor(
)
// Anchor is a representative day inside the visible week; the actual week
// start is derived against [firstDayOfWeek], so changing the first-day
// preference re-frames the same week instead of jumping.
// start is derived against [weekStart], so changing the first-day preference
// re-frames the same week instead of jumping.
private val _anchor = MutableStateFlow(todayDate)
/** The pager page the anchor's week sits on. */
val anchorPage: StateFlow<Int> =
combine(_anchor, firstDayOfWeek) { anchor, ws -> weekPageFor(anchor, ws) }
val weekStartDate: StateFlow<LocalDate> =
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = weekPageFor(todayDate, DayOfWeek.MONDAY),
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
)
/**
* The anchor week's state. Once loaded it never falls back to [WeekUiState.Loading]:
* moving the anchor keeps the last result until the new week arrives, since
* the pages draw their own weeks and this only gates the failure screen.
*/
val state: StateFlow<WeekUiState> =
combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) }
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
.distinctUntilChanged()
.flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } }
.flatMapLatest { start ->
val range = weekRange(start, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(start, calendars, instances)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = WeekUiState.Loading,
)
private val weeks = PageStateCache<LocalDate, WeekUiState>(
scope = viewModelScope,
initial = WeekUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a year doesn't keep a year of queries around.
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= WEEK_CACHE_DAYS },
) { start ->
combine(
repository.calendars(),
repository.instances(weekRange(start, zone)),
) { calendars, instances ->
buildState(start, calendars, instances)
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
fun goToPrev() {
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
}
/** The week starting at [start], shared between its pager page and [state]. Main thread only. */
fun week(start: LocalDate): StateFlow<WeekUiState> = weeks.get(start)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
val ws = firstDayOfWeek.value
if (weekPageFor(_anchor.value, ws) != page) _anchor.value = weekStartForPage(page, ws)
fun goToNext() {
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
}
fun goToToday() {
_anchor.value = todayDate
}
/** Jump to the week containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_anchor.value = date
}
private fun buildState(
start: LocalDate,
calendars: List<CalendarSource>,
-36
View File
@@ -290,9 +290,6 @@
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>
<!-- Stands in for a calendar the provider gave no name (#329) -->
<string name="calendar_unnamed">(No name)</string>
<!-- First-launch wizard (#163) -->
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
<string name="onboarding_backup_title">Your events live only here</string>
@@ -301,26 +298,6 @@
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
<string name="onboarding_calendars_title">Somewhere to put your events</string>
<string name="onboarding_calendars_body">This device has no calendar yet. Calendula writes to the calendars already on your phone — it needs at least one.</string>
<string name="onboarding_calendars_local_button">Create a calendar on this device</string>
<string name="onboarding_calendars_caldav_button">Sync with a CalDAV server</string>
<string name="onboarding_calendars_skip_button">Not now</string>
<string name="onboarding_calendars_benefit_local_title">Stays on this phone</string>
<string name="onboarding_calendars_benefit_local_body">A device calendar syncs nowhere, so back it up if the events matter.</string>
<string name="onboarding_calendars_benefit_sync_title">Or sync from an account</string>
<string name="onboarding_calendars_benefit_sync_body">Calendula shows what your accounts already sync. CalDAV servers need DAVx\u2075 to do the syncing.</string>
<string name="onboarding_calendars_created_title">“%1$s” is ready</string>
<string name="onboarding_calendars_created_body">It lives on this device and new events go into it unless you pick another calendar.</string>
<string name="onboarding_calendars_created_more_title">Room for more</string>
<string name="onboarding_calendars_created_more_body">Settings → Calendars makes as many as you need, and renames or recolours the ones you have.</string>
<string name="onboarding_calendars_created_continue">Continue</string>
<string name="onboarding_hidden_title">Your calendars are switched off</string>
<string name="onboarding_hidden_body">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.</string>
<string name="onboarding_hidden_switch_all_button">Switch them all on</string>
<string name="onboarding_hidden_skip_button">Not now</string>
<string name="onboarding_hidden_failed">Couldn\'t switch that on. Settings → Calendars has the same switches.</string>
<string name="onboarding_calendars_create_failed">Couldn\'t create the calendar. Your phone\'s calendar storage refused it.</string>
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
<string name="onboarding_backup_skip_button">Not now</string>
<string name="onboarding_view_title">What should open first?</string>
@@ -696,20 +673,7 @@
<string name="calendars_edit_title">Edit calendar</string>
<string name="calendars_name_label">Name</string>
<string name="calendars_color_label">Color</string>
<string name="calendars_description_label">Description</string>
<string name="calendars_description_hint">Add a description</string>
<string name="color_name_mauve">Mauve</string>
<string name="color_name_red">Red</string>
<string name="color_name_orange">Orange</string>
<string name="color_name_amber">Amber</string>
<string name="color_name_olive">Olive</string>
<string name="color_name_green">Green</string>
<string name="color_name_teal">Teal</string>
<string name="color_name_cyan">Cyan</string>
<string name="color_name_blue">Blue</string>
<string name="color_name_indigo">Indigo</string>
<string name="color_name_purple">Purple</string>
<string name="color_name_pink">Pink</string>
<string name="calendars_delete_confirm_title">Delete calendar?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
<string name="calendars_write_error">Couldn\'t save the change.</string>
@@ -64,9 +64,9 @@ class CalendarMapperTest {
}
@Test
fun `null displayName is left blank for the UI to fill in`() {
fun `null displayName falls back to placeholder`() {
val src = reader(displayName = null).toCalendarSource()
assertThat(src.displayName).isEmpty()
assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR)
}
@Test
@@ -61,15 +61,15 @@ class InstanceMapperTest {
}
@Test
fun `null title is left blank for the UI to fill in`() {
fun `null title falls back to placeholder`() {
val inst = reader(title = null).toEventInstance()
assertThat(inst!!.title).isEmpty()
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
}
@Test
fun `empty title is left blank for the UI to fill in`() {
fun `empty title falls back to placeholder`() {
val inst = reader(title = "").toEventInstance()
assertThat(inst!!.title).isEmpty()
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
}
@Test
@@ -242,8 +242,8 @@ class EventSearchTest {
}
@Test
fun `an untitled event's blank title is not something the query can match`() {
val untitled = candidate("", description = "Titel folgt noch").copy(title = null)
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)
val results = search(untitled, query = "titel")
@@ -1,53 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.math.abs
@OptIn(ExperimentalCoroutinesApi::class)
class PageStateCacheTest {
@Test
fun `the same key shares one state`() = runTest {
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } }
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
}
@Test
fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) {
var running = 0
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key ->
flow {
running++
try {
emit(key)
awaitCancellation()
} finally {
running--
}
}
}
backgroundScope.launch { cache.get(0).collect {} }
assertThat(running).isEqualTo(1)
// Far enough away that 0 is let go, even though it is still collected.
cache.get(10)
assertThat(running).isEqualTo(0)
assertThat(cache.size).isEqualTo(1)
}
@Test
fun `nearby entries are kept`() = runTest {
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 2 }) { flow { emit(it) } }
cache.get(4)
cache.get(5)
cache.get(6)
assertThat(cache.size).isEqualTo(3)
}
}
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class DayPagingTest {
@Test
fun `a page and its day round-trip`() {
listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day ->
assertThat(dayForPage(dayPageFor(day))).isEqualTo(day)
}
}
@Test
fun `adjacent pages are adjacent days across a month end`() {
val page = dayPageFor(LocalDate(2026, 2, 28))
assertThat(dayForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1))
assertThat(dayForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27))
}
}
@@ -18,10 +18,6 @@ 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,
@@ -33,10 +29,6 @@ class OnboardingPlanTest {
viewDone = viewDone,
monthStyleDone = monthStyleDone,
backupApplies = backupApplies,
hiddenDone = hiddenDone,
hiddenApplies = hiddenApplies,
calendarsDone = calendarsDone,
calendarsApplies = calendarsApplies,
visibilityArmed = visibilityArmed,
visibilityDone = visibilityDone,
doneShown = doneShown,
@@ -251,144 +243,4 @@ 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)
}
}
@@ -1,47 +0,0 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import org.junit.jupiter.api.Test
class WeekPagingTest {
// 2026-09-27 is a Sunday.
private val sunday = LocalDate(2026, 9, 27)
@Test
fun `a page and its week round-trip for every first day`() {
DayOfWeek.entries.forEach { firstDay ->
val page = weekPageFor(sunday, firstDay)
val start = weekStartForPage(page, firstDay)
assertThat(start).isEqualTo(sunday.startOfWeek(firstDay))
assertThat(weekPageFor(start, firstDay)).isEqualTo(page)
}
}
@Test
fun `every day of a week maps to the same page`() {
val start = sunday.startOfWeek(DayOfWeek.MONDAY)
val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
assertThat(pages.toSet()).hasSize(1)
}
@Test
fun `adjacent pages are adjacent weeks`() {
val page = weekPageFor(sunday, DayOfWeek.SUNDAY)
assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY))
.isEqualTo(LocalDate(2026, 10, 4))
assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY))
.isEqualTo(LocalDate(2026, 9, 20))
}
@Test
fun `dates before the epoch page resolve too`() {
val old = LocalDate(1990, 5, 16)
val page = weekPageFor(old, DayOfWeek.MONDAY)
assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14))
}
}
-13
View File
@@ -113,19 +113,6 @@ 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
@@ -1,11 +0,0 @@
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.