Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0345b6a713 | ||
|
|
f1554fbcf3 | ||
|
|
6a384b03e9 | ||
|
|
738a28fc0a | ||
|
|
1e2ed15e82 | ||
|
|
08d2452c51 | ||
|
|
126f1d51ef | ||
|
|
9ed6bbbe8e | ||
|
|
a6ff2c06ef | ||
|
|
b1e03db27c | ||
|
|
2c71b7da4a | ||
|
|
2dbc4c49b7 | ||
|
|
d05af5d765 | ||
|
|
2269c2648b | ||
|
|
5e7918f105 | ||
|
|
2a452125eb |
No files matched your search
@@ -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,
|
||||
|
||||
+19
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user