Compare commits

..
Author SHA1 Message Date
makiolaj 0345b6a713 fix(#514): import prompt offers the default for the current all-day state 2026-11-14 02:45:12 +01:00
makiolaj f1554fbcf3 fix(#514): re-encode reminders when an existing event switches all-day 2026-11-14 02:39:26 +01:00
makiolaj 6a384b03e9 refactor(#514): one firing-reminders view for the editor and the import prompt 2026-11-14 02:33:38 +01:00
makiolaj 738a28fc0a fix(#514): show all-day reminders as the day they fire on 2026-11-14 02:26:39 +01:00
makiolaj 1e2ed15e82 refactor(#365): drop picker-side day rounding, placeholder follows the selected unit 2026-11-14 01:13:15 +01:00
makiolaj 08d2452c51 fix(#365): keep the default-reminder sentinel out of day rounding 2026-11-14 01:08:22 +01:00
makiolaj 126f1d51ef fix(#365): no duplicate day reminders on all-day events, one placeholder rule 2026-11-14 01:06:06 +01:00
makiolaj 9ed6bbbe8e fix(#365): day-sized custom placeholder in all-day default pickers 2026-11-14 01:01:07 +01:00
makiolaj a6ff2c06ef Revert "fix(#365): round reminders to whole days when an event turns all-day"
This reverts commit b1e03db27c.
2026-11-14 00:58:17 +01:00
makiolaj b1e03db27c fix(#365): round reminders to whole days when an event turns all-day 2026-11-14 00:55:16 +01:00
makiolaj 2c71b7da4a fix(#365): day-sized custom placeholder on all-day events 2026-11-14 00:49:48 +01:00
makiolaj 2dbc4c49b7 fix(#365): all-day custom reminders offer only days and weeks 2026-11-14 00:49:32 +01:00
makiolaj d05af5d765 refactor(#365): one rule for reminder grouping and preset choice 2026-11-13 21:26:18 +01:00
makiolaj 2269c2648b fix(#365): stable reminder grouping, keyed rows, shared preset choice 2026-11-13 21:21:17 +01:00
makiolaj 5e7918f105 refactor(#365): share grouped lead-time rows, keep all-day list in one group 2026-11-13 21:16:40 +01:00
makiolaj 2a452125eb feat(#365): more day and week reminder presets 2026-11-13 21:11:19 +01:00
16 changed files with 358 additions and 92 deletions

No files matched your search

+1 -4
View File
@@ -318,16 +318,13 @@ jobs:
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above, from trixie:
# fastlane >= 2.241 needs Ruby 3.2+, and bookworm's apt only has 3.1.
# the same way sshpass, jq and fdroidserver are in the job above.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
container:
image: node:22-trixie
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
@@ -54,6 +54,18 @@ internal fun toProviderAllDayMinutes(
return if (raw == MINUTES_DEFAULT) MINUTES_DEFAULT - 1 else raw
}
/**
* A lead time as the whole days an all-day reminder actually fires at: the
* sub-day part is dropped, matching [toProviderAllDayMinutes]'s day count.
* [MINUTES_DEFAULT] passes through.
*/
internal fun wholeDayLeadMinutes(minutes: Int): Int =
if (minutes == MINUTES_DEFAULT) minutes else minutes / MINUTES_PER_DAY * MINUTES_PER_DAY
/** [wholeDayLeadMinutes] for a reminder list, merging lead times that land on the same day. */
internal fun wholeDayLeadTimes(minutes: List<Int>): List<Int> =
minutes.map(::wholeDayLeadMinutes).distinct().sorted()
/**
* The next date on or after [today] carrying [month]/[day] — the date to sample
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
@@ -1024,9 +1024,8 @@ class AndroidCalendarDataSource @Inject constructor(
updateEventRow(eventId, values.toContentValues(), "update event id=$eventId")
}
// Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about. The diff is on the
// form's semantic minutes; reconcile works in encoded provider minutes.
if (updated.reminders.toSet() != original.reminders.toSet()) {
// disturb provider rows the form never knew about.
if (remindersNeedRewrite(original, updated)) {
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
// Same untouched-set guard for guests: only reconcile when the form's
@@ -1785,3 +1784,11 @@ class AndroidCalendarDataSource @Inject constructor(
const val MAX_SEARCH_TOKENS = 8
}
}
/**
* Whether an update must rewrite the reminder rows. The diff is on the form's
* semantic minutes, but the provider stores encoded minutes, so switching all-day
* on or off re-encodes an otherwise unchanged set.
*/
internal fun remindersNeedRewrite(original: EventForm, updated: EventForm): Boolean =
updated.reminders.toSet() != original.reminders.toSet() || updated.isAllDay != original.isAllDay
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.data.calendar.wholeDayLeadTimes
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
@@ -648,7 +649,8 @@ object SettingsKeys {
* before the start of the day — day-scale ("1 day before"), so separate from
* the timed default.
*/
val defaultAllDayReminderMinutes: Pref<List<Int>> = reminderListPref("default_allday_reminder_minutes")
val defaultAllDayReminderMinutes: Pref<List<Int>> =
reminderListPref("default_allday_reminder_minutes", wholeDays = true)
/**
* Wall-clock time, as minutes from local midnight, at which **all-day**
@@ -681,7 +683,7 @@ object SettingsKeys {
/** Per-calendar overrides of [defaultAllDayReminderMinutes], as [perCalendarReminderOverride]. */
val perCalendarAllDayReminderOverride: Pref<Map<Long, List<Int>>> =
reminderOverridePref("per_calendar_allday_reminder_override")
reminderOverridePref("per_calendar_allday_reminder_override", wholeDays = true)
/** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */
val autoBackupEnabled: Pref<Boolean> = booleanPref("auto_backup_enabled", false)
@@ -785,11 +787,24 @@ private fun <T> Pref<T>.coerced(clamp: (T) -> T): Pref<T> = map(clamp, clamp)
private fun agendaRangePref(name: String): Pref<AgendaRange> =
nullableStringPref(name).map({ parseAgendaRange(it, AgendaRange.Month) }, { it.storageValue() })
private fun reminderListPref(name: String): Pref<List<Int>> =
nullableStringPref(name).map({ it.toReminderList() }, { it.toStoredReminders() })
/**
* [wholeDays] reads an all-day list as the whole days it fires at, so a sub-day
* value stored before the all-day pickers were limited to days shows truthfully.
*/
private fun reminderListPref(name: String, wholeDays: Boolean = false): Pref<List<Int>> =
nullableStringPref(name).map(
{ stored -> stored.toReminderList().let { if (wholeDays) wholeDayLeadTimes(it) else it } },
{ it.toStoredReminders() },
)
private fun reminderOverridePref(name: String): Pref<Map<Long, List<Int>>> =
nullableStringPref(name).map(reminderOverrideCodec::parse, reminderOverrideCodec::serialize)
private fun reminderOverridePref(name: String, wholeDays: Boolean = false): Pref<Map<Long, List<Int>>> =
nullableStringPref(name).map(
{ stored ->
reminderOverrideCodec.parse(stored)
.let { map -> if (wholeDays) map.mapValues { wholeDayLeadTimes(it.value) } else map }
},
reminderOverrideCodec::serialize,
)
private suspend fun PrefStore.applyOverride(
pref: Pref<Map<Long, List<Int>>>,
@@ -6,6 +6,7 @@ import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -38,8 +39,8 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Reminder-default picker, full-screen and **multi-select**: each [presets]
* lead time (plus any chosen custom value) is a checkbox row that toggles
* Reminder-default picker, full-screen and **multi-select**: each preset
* lead time ([reminderPresets] for [isAllDay]) (plus any chosen custom value) is a checkbox row that toggles
* independently, so a default can carry several reminders ("1 week before" *and*
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
* "No reminder" (both pickers, so the empty state stays visible and reachable)
@@ -59,7 +60,7 @@ import kotlin.time.Clock
@Composable
fun ReminderDefaultPicker(
title: String,
presets: List<Int>,
isAllDay: Boolean,
selected: ReminderOverride,
allowInherit: Boolean,
onSelect: (ReminderOverride) -> Unit,
@@ -87,14 +88,17 @@ fun ReminderDefaultPicker(
// Custom (non-preset) lead times seen this session, so unchecking one keeps
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
// which would strand a hand-entered value with no way to re-check it.
val presets = reminderPresets(isAllDay)
val units = customReminderUnits(isAllDay)
val seenCustom = remember { mutableSetOf<Int>() }
seenCustom += selectedMinutes.filter { it !in presets }
// Presets plus every custom value seen this session, each as its own row.
val rows = presets + seenCustom.sorted()
val rows = presets + seenCustom
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
var pickedUnit by rememberSaveable { mutableStateOf(units.first()) }
val unit = pickedUnit.takeIf { it in units } ?: units.first()
fun apply(override: ReminderOverride) {
userEdited = true
@@ -125,39 +129,68 @@ fun ReminderDefaultPicker(
onClick = { apply(ReminderOverride.None) },
)
Spacer(Modifier.height(24.dp))
val rowCount = rows.size + 1 // + the custom row
rows.forEachIndexed { index, minute ->
val checked = minute in selectedMinutes
GroupedRow(
title = reminderLeadTimeLabel(minute),
summary = leadTimeSummary?.invoke(minute),
position = positionOf(index, rowCount),
selected = checked,
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
onClick = { toggle(minute) },
)
}
CustomExpandRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = positionOf(rows.size, rowCount),
expanded = customExpanded,
onExpandedChange = { customExpanded = it },
) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
onConfirm = { minutes ->
emit(selectedMinutes + minutes)
amountText = ""
customExpanded = false
},
)
}
GroupedLeadTimeRows(
minutes = rows,
splitSubDay = splitsSubDay(presets),
row = { minute, position ->
val checked = minute in selectedMinutes
GroupedRow(
title = reminderLeadTimeLabel(minute),
summary = leadTimeSummary?.invoke(minute),
position = position,
selected = checked,
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
onClick = { toggle(minute) },
)
},
trailing = { position ->
CustomExpandRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = position,
expanded = customExpanded,
onExpandedChange = { customExpanded = it },
) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },
units = units,
unit = unit,
onUnitChange = { pickedUnit = it },
onConfirm = { minutes ->
emit(selectedMinutes + minutes)
amountText = ""
customExpanded = false
},
)
}
},
)
}
}
/**
* Lead-time rows laid out as [groupReminderLeadTimes] groups, each a connected
* card, with [trailing] (the Custom row) closing the last one.
*/
@Composable
internal fun GroupedLeadTimeRows(
minutes: Collection<Int>,
splitSubDay: Boolean,
row: @Composable (minute: Int, position: Position) -> Unit,
trailing: @Composable (position: Position) -> Unit,
) {
val groups = groupReminderLeadTimes(minutes, splitSubDay)
groups.forEachIndexed { groupIndex, group ->
val isLast = groupIndex == groups.lastIndex
val rowCount = group.size + if (isLast) 1 else 0
group.forEachIndexed { index, minute ->
key(minute) { row(minute, positionOf(index, rowCount)) }
}
if (!isLast) Spacer(Modifier.height(12.dp))
}
trailing(if (groups.isEmpty()) Position.Alone else Position.Bottom)
}
/**
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
@@ -170,6 +203,7 @@ fun ReminderDefaultPicker(
private fun CustomReminderEditor(
amountText: String,
onAmountChange: (String) -> Unit,
units: List<ReminderUnit>,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
onConfirm: (Int) -> Unit,
@@ -178,9 +212,10 @@ private fun CustomReminderEditor(
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = unit.ordinal,
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
placeholder = customReminderPlaceholder(unit),
selectedUnit = units.indexOf(unit),
onUnitChange = { onUnitChange(units[it]) },
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
setLabel = stringResource(R.string.reminder_custom_set),
@@ -8,8 +8,46 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT
import de.jeanlucmakiola.floret.reminders.ReminderUnit
private val DAY = ReminderUnit.Days.minutesFactor
private val WEEK = ReminderUnit.Weeks.minutesFactor
private val DAY_SCALE_PRESETS =
listOf(DAY, 2 * DAY, 3 * DAY, 4 * DAY, 5 * DAY, WEEK, 2 * WEEK, 3 * WEEK)
/** Common reminder lead times offered as quick picks in the form and settings. */
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
val REMINDER_PRESETS = listOf(0, 10, 30, 60) + DAY_SCALE_PRESETS
/** Day-scale lead times for all-day events, all-day defaults and contact special dates. */
val ALLDAY_REMINDER_PRESETS = listOf(0) + DAY_SCALE_PRESETS
fun reminderPresets(isAllDay: Boolean): List<Int> =
if (isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS
/**
* Units the Custom step offers. All-day reminders fire on whole days at the
* all-day reminder time, so minutes and hours would be dropped on save.
*/
fun customReminderUnits(isAllDay: Boolean): List<ReminderUnit> =
if (isAllDay) listOf(ReminderUnit.Days, ReminderUnit.Weeks) else ReminderUnit.entries
/** Placeholder for the Custom amount field: a typical amount in [unit]. */
fun customReminderPlaceholder(unit: ReminderUnit): String =
if (unit == ReminderUnit.Minutes) "10" else "1"
/** Whether a picker over [presets] splits its rows into sub-day and day-scale groups. */
fun splitsSubDay(presets: Collection<Int>): Boolean = presets.any { it in 1 until DAY }
/**
* Sorts lead times and, when [splitSubDay] (see [splitsSubDay]), splits them into a sub-day group and
* a day-scale group so a long picker list reads as two connected sections.
* Empty groups are dropped.
*/
fun groupReminderLeadTimes(minutes: Collection<Int>, splitSubDay: Boolean): List<List<Int>> {
val sorted = minutes.sorted()
if (!splitSubDay) return listOfNotNull(sorted.takeIf { it.isNotEmpty() })
val (subDay, dayScale) = sorted.partition { it < DAY }
return listOf(subDay, dayScale).filter { it.isNotEmpty() }
}
@StringRes
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
@@ -35,10 +73,10 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
minutes == MINUTES_DEFAULT -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time)
minutes < 0 -> stringResource(R.string.reminder_after, durationLabel(-minutes))
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
minutes % WEEK == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / WEEK, minutes / WEEK)
minutes % DAY == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / DAY, minutes / DAY)
minutes % 60 == 0 ->
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
@@ -165,9 +165,12 @@ import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.reminderPresets
import de.jeanlucmakiola.calendula.ui.common.customReminderUnits
import de.jeanlucmakiola.calendula.ui.common.customReminderPlaceholder
import de.jeanlucmakiola.calendula.ui.common.splitsSubDay
import de.jeanlucmakiola.calendula.ui.common.GroupedLeadTimeRows
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
@@ -874,7 +877,7 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
Spacer(Modifier.height(gap))
RemindersSection(
reminders = form.reminders,
reminders = form.firingReminders(),
onRemove = viewModel::removeReminder,
onAdd = { sheet = EditSheet.Reminder },
)
@@ -1488,7 +1491,8 @@ private fun EditSheetHost(
)
}
EditSheet.Reminder -> ReminderPickerDialog(
alreadyChosen = form.reminders,
isAllDay = form.isAllDay,
alreadyChosen = form.firingReminders(),
onSelect = { minutes ->
viewModel.addReminder(minutes)
onClose()
@@ -1580,13 +1584,16 @@ private fun FieldPickerDialog(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReminderPickerDialog(
isAllDay: Boolean,
alreadyChosen: List<Int>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
var customMode by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
val units = customReminderUnits(isAllDay)
var pickedUnit by rememberSaveable { mutableStateOf(units.first()) }
val unit = pickedUnit.takeIf { it in units } ?: units.first()
val customMinutes = amountText.toIntOrNull()
?.takeIf { it in 1..999 }
?.let { it * unit.minutesFactor }
@@ -1605,19 +1612,24 @@ private fun ReminderPickerDialog(
},
) {
if (!customMode) {
val presets = REMINDER_PRESETS.filterNot { it in alreadyChosen }
val rowCount = presets.size + 1
presets.forEachIndexed { index, minutes ->
GroupedRow(
title = reminderLeadTimeLabel(minutes),
position = positionOf(index, rowCount),
onClick = { onSelect(minutes) },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = positionOf(rowCount - 1, rowCount),
onClick = { customMode = true },
val presets = reminderPresets(isAllDay)
GroupedLeadTimeRows(
minutes = presets.filterNot { it in alreadyChosen },
splitSubDay = splitsSubDay(presets),
row = { minutes, position ->
GroupedRow(
title = reminderLeadTimeLabel(minutes),
position = position,
onClick = { onSelect(minutes) },
)
},
trailing = { position ->
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = position,
onClick = { customMode = true },
)
},
)
} else {
Row(
@@ -1627,13 +1639,13 @@ private fun ReminderPickerDialog(
DialogAmountField(
value = amountText,
onValueChange = { amountText = it },
placeholder = "10",
placeholder = customReminderPlaceholder(unit),
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(reminderUnitLabel(unit)),
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
onPick = { unit = ReminderUnit.entries[it] },
entries = units.map { stringResource(reminderUnitLabel(it)) },
onPick = { pickedUnit = units[it] },
)
}
}
@@ -7,6 +7,8 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.calendar.wholeDayLeadMinutes
import de.jeanlucmakiola.calendula.data.calendar.wholeDayLeadTimes
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -441,7 +443,7 @@ class EventEditViewModel @Inject constructor(
ImportSource.File -> {
// Respect the file's own reminders; never silently overwrite them.
_remindersTouched.value = true
maybePromptImportedReminderDefault(form)
maybePromptImportedReminderDefault()
}
}
}
@@ -527,18 +529,21 @@ class EventEditViewModel @Inject constructor(
* settings default — but only when there's a real choice: a default is
* configured and it isn't already exactly what the file carried.
*/
private fun maybePromptImportedReminderDefault(form: EventForm) {
private fun maybePromptImportedReminderDefault() {
val opened = session
viewModelScope.launch {
val targetId = resolvedCalendarId.first()
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
val defaults = reminderDefaults()
// Bail if the form closed or became an edit while we resolved.
if (opened != session) return@launch
val current = _form.value ?: return@launch
if (_editTarget.value != null) return@launch
if (default.isEmpty() || default == current.reminders) return@launch
// The form may have switched all-day meanwhile; offer that kind's default.
val default = defaults.resolveFor(targetId, current.isAllDay)
val firing = current.firingReminders()
if (default.isEmpty() || default == firing) return@launch
_importReminderPrompt.value = ImportReminderPrompt(
currentReminderCount = current.reminders.size,
currentReminderCount = firing.size,
defaultReminders = default,
)
}
@@ -694,9 +699,20 @@ class EventEditViewModel @Inject constructor(
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
}
/**
* On an all-day event a reminder is shown as the whole day it fires on, so
* removing it drops every stored lead time that lands on that day.
*/
fun removeReminder(minutes: Int) {
_remindersTouched.value = true
edit { it.copy(reminders = it.reminders - minutes) }
edit { form ->
val removed = if (form.isAllDay) {
form.reminders.filter { wholeDayLeadMinutes(it) == wholeDayLeadMinutes(minutes) }
} else {
listOf(minutes)
}
form.copy(reminders = form.reminders - removed.toSet())
}
}
/**
@@ -950,3 +966,11 @@ class EventEditViewModel @Inject constructor(
_form.value = _form.value?.let(block)
}
}
/**
* Reminders as they fire: an all-day event fires on whole days, so a sub-day lead
* time kept from the timed form reads as its day. The form keeps the raw values,
* so switching all-day off again restores any the user did not remove.
*/
internal fun EventForm.firingReminders(): List<Int> =
if (isAllDay) wholeDayLeadTimes(reminders) else reminders
@@ -33,7 +33,6 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.durationLabel
@@ -244,7 +243,7 @@ internal fun NotificationsScreen(
if (showDefaultReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
presets = REMINDER_PRESETS,
isAllDay = false,
selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.minutesOrEmpty()) },
@@ -254,7 +253,7 @@ internal fun NotificationsScreen(
if (showAllDayReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder_allday),
presets = ALLDAY_REMINDER_PRESETS,
isAllDay = true,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.minutesOrEmpty()) },
@@ -289,7 +288,7 @@ internal fun NotificationsScreen(
R.string.settings_default_reminder
},
),
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
isAllDay = target.isAllDay,
selected = map.reminderOverrideFor(target.calendarId),
allowInherit = true,
onSelect = {
@@ -72,9 +72,6 @@ internal fun reminderChoiceLabel(minutes: List<Int>): String {
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
}
/** Day-scale lead times for all-day defaults and contact special dates. */
internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
/** A minute-of-day in the app's own 12/24-hour convention ([LocalUse24HourFormat]). */
@Composable
internal fun settingsTimeOfDay(minutesOfDay: Int): String =
@@ -196,7 +196,7 @@ internal fun SpecialDatesScreen(
reminderPickerType?.let { type ->
ReminderDefaultPicker(
title = stringResource(R.string.settings_special_dates_reminders),
presets = ALLDAY_REMINDER_PRESETS,
isAllDay = true,
selected = state.reminderChoices[type] ?: ReminderOverride.None,
// Managed calendars own their reminders outright — no "inherit global".
allowInherit = false,
@@ -48,11 +48,29 @@ class AllDayReminderEncodingTest {
assertThat(actualFire(raw, summer)).isEqualTo(intendedFire(summer, 0, nineAm))
}
@Test
fun `whole-day lead times match the encoder and keep the default sentinel`() {
assertThat(wholeDayLeadTimes(listOf(2_880, 10, 1_500, 0, MINUTES_DEFAULT)))
.containsExactly(MINUTES_DEFAULT, 0, 1_440, 2_880).inOrder()
for (minutes in listOf(10, 1_500, -30)) {
assertThat(toProviderAllDayMinutes(minutes, summer, berlin, nineAm))
.isEqualTo(toProviderAllDayMinutes(wholeDayLeadMinutes(minutes), summer, berlin, nineAm))
}
}
@Test
fun `three weeks before fires at 9am local across a DST change`() {
val afterDst = LocalDate.of(2026, 4, 10) // CEST; three weeks earlier is CET
val raw = toProviderAllDayMinutes(3 * 10_080, afterDst, berlin, nineAm)
assertThat(actualFire(raw, afterDst)).isEqualTo(intendedFire(afterDst, 21, nineAm))
assertThat(fromProviderAllDayMinutes(raw, afterDst, berlin, nineAm)).isEqualTo(3 * 10_080)
}
@Test
fun `round-trips for whole-day lead times across both seasons`() {
for (date in listOf(summer, winter)) {
for (time in listOf(0, nineAm, 20 * 60)) {
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
for (semantic in listOf(0, 1_440, 2_880, 4_320, 5_760, 7_200, 10_080, 20_160, 30_240)) {
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
assertThat(fromProviderAllDayMinutes(raw, date, berlin, time))
.isEqualTo(semantic)
@@ -33,6 +33,14 @@ class EventWriteMapperTest {
timezone = timezone,
)
@Test
fun `reminders are rewritten when they change or the all-day flag flips`() {
val timed = form().copy(reminders = listOf(10))
assertThat(remindersNeedRewrite(timed, timed.copy(title = "Renamed"))).isFalse()
assertThat(remindersNeedRewrite(timed, timed.copy(reminders = listOf(10, 30)))).isTrue()
assertThat(remindersNeedRewrite(timed, timed.copy(isAllDay = true))).isTrue()
}
@Test
fun `timed event resolves wall clock in the given zone`() {
val times = form().toWriteTimes(berlin)
@@ -501,6 +501,18 @@ class SettingsPrefsTest {
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
}
@Test
fun `sub-day all-day defaults read as the day they fire on`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
prefs.setDefaultAllDayReminderMinutes(listOf(120, 0, 1_500))
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(0, 1_440).inOrder()
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Minutes(listOf(30, 2_880)))
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(0, 2_880))
// The timed default keeps sub-day lead times.
prefs.setDefaultReminderMinutes(listOf(120))
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(120)
}
@Test
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
@@ -0,0 +1,67 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import org.junit.jupiter.api.Test
class ReminderPresetsTest {
@Test
fun `presets are sorted and unique`() {
for (presets in listOf(REMINDER_PRESETS, ALLDAY_REMINDER_PRESETS)) {
assertThat(presets).isInStrictOrder()
}
}
@Test
fun `all-day presets are whole days`() {
assertThat(ALLDAY_REMINDER_PRESETS.all { it % 1_440 == 0 }).isTrue()
}
@Test
fun `offers two to five days and two and three weeks`() {
val extra = listOf(2_880, 4_320, 5_760, 7_200, 20_160, 30_240)
assertThat(REMINDER_PRESETS).containsAtLeastElementsIn(extra)
assertThat(ALLDAY_REMINDER_PRESETS).containsAtLeastElementsIn(extra)
}
@Test
fun `groups split sub-day from day-scale lead times, sorted`() {
assertThat(groupReminderLeadTimes(listOf(2_880, 10, 0, 1_440, 45), splitSubDay = true))
.containsExactly(listOf(0, 10, 45), listOf(1_440, 2_880)).inOrder()
}
@Test
fun `all-day custom offers whole-day units only`() {
assertThat(customReminderUnits(isAllDay = true))
.containsExactly(ReminderUnit.Days, ReminderUnit.Weeks).inOrder()
assertThat(customReminderUnits(isAllDay = false)).containsExactlyElementsIn(ReminderUnit.entries)
}
@Test
fun `custom placeholder fits the selected unit`() {
assertThat(customReminderPlaceholder(ReminderUnit.Minutes)).isEqualTo("10")
assertThat(customReminderPlaceholder(ReminderUnit.Days)).isEqualTo("1")
}
@Test
fun `only the timed list splits`() {
assertThat(splitsSubDay(REMINDER_PRESETS)).isTrue()
assertThat(splitsSubDay(ALLDAY_REMINDER_PRESETS)).isFalse()
}
@Test
fun `unsplit list is one sorted group`() {
assertThat(groupReminderLeadTimes(listOf(2_880, 0, 30), splitSubDay = false))
.containsExactly(listOf(0, 30, 2_880))
}
@Test
fun `empty groups are dropped`() {
assertThat(groupReminderLeadTimes(listOf(0, 1_440), splitSubDay = true))
.containsExactly(listOf(0), listOf(1_440)).inOrder()
assertThat(groupReminderLeadTimes(listOf(2_880), splitSubDay = true)).containsExactly(listOf(2_880))
assertThat(groupReminderLeadTimes(emptyList(), splitSubDay = true)).isEmpty()
assertThat(groupReminderLeadTimes(emptyList(), splitSubDay = false)).isEmpty()
}
}
@@ -305,6 +305,31 @@ class EventEditViewModelTest {
end = FormTime(end),
)
@Test
fun `switching to all-day keeps sub-day reminders, removing one drops its whole day`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
advanceUntilIdle()
vm.addReminder(10)
vm.addReminder(1_440)
vm.setAllDay(true)
vm.setAllDay(false)
advanceUntilIdle()
// Toggling all-day is lossless.
assertThat(vm.state.value?.form?.reminders).containsExactly(10, 1_440).inOrder()
vm.addReminder(0)
vm.setAllDay(true)
vm.removeReminder(0)
assertThat(vm.state.value?.form?.reminders).containsExactly(1_440)
job.cancel()
}
@Test
fun `an all-day event ignores the default duration`(
@TempDir tempDir: Path,