Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754745586d | ||
|
|
1a1df07dc3 | ||
|
|
75315d92a7 | ||
|
|
95ad3a30cf | ||
|
|
8ce4831efa |
No files matched your search
@@ -318,13 +318,16 @@ 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.
|
||||
# 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.
|
||||
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,18 +54,6 @@ 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,8 +1024,9 @@ 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.
|
||||
if (remindersNeedRewrite(original, updated)) {
|
||||
// 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()) {
|
||||
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||
}
|
||||
// Same untouched-set guard for guests: only reconcile when the form's
|
||||
@@ -1784,11 +1785,3 @@ 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,7 +2,6 @@ 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
|
||||
@@ -649,8 +648,7 @@ 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", wholeDays = true)
|
||||
val defaultAllDayReminderMinutes: Pref<List<Int>> = reminderListPref("default_allday_reminder_minutes")
|
||||
|
||||
/**
|
||||
* Wall-clock time, as minutes from local midnight, at which **all-day**
|
||||
@@ -683,7 +681,7 @@ object SettingsKeys {
|
||||
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes], as [perCalendarReminderOverride]. */
|
||||
val perCalendarAllDayReminderOverride: Pref<Map<Long, List<Int>>> =
|
||||
reminderOverridePref("per_calendar_allday_reminder_override", wholeDays = true)
|
||||
reminderOverridePref("per_calendar_allday_reminder_override")
|
||||
|
||||
/** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */
|
||||
val autoBackupEnabled: Pref<Boolean> = booleanPref("auto_backup_enabled", false)
|
||||
@@ -787,24 +785,11 @@ 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() })
|
||||
|
||||
/**
|
||||
* [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 reminderListPref(name: String): Pref<List<Int>> =
|
||||
nullableStringPref(name).map({ it.toReminderList() }, { it.toStoredReminders() })
|
||||
|
||||
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 fun reminderOverridePref(name: String): Pref<Map<Long, List<Int>>> =
|
||||
nullableStringPref(name).map(reminderOverrideCodec::parse, reminderOverrideCodec::serialize)
|
||||
|
||||
private suspend fun PrefStore.applyOverride(
|
||||
pref: Pref<Map<Long, List<Int>>>,
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -39,8 +38,8 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
* lead time (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)
|
||||
@@ -60,7 +59,7 @@ import kotlin.time.Clock
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
title: String,
|
||||
isAllDay: Boolean,
|
||||
presets: List<Int>,
|
||||
selected: ReminderOverride,
|
||||
allowInherit: Boolean,
|
||||
onSelect: (ReminderOverride) -> Unit,
|
||||
@@ -88,17 +87,14 @@ 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
|
||||
val rows = presets + seenCustom.sorted()
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var pickedUnit by rememberSaveable { mutableStateOf(units.first()) }
|
||||
val unit = pickedUnit.takeIf { it in units } ?: units.first()
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
fun apply(override: ReminderOverride) {
|
||||
userEdited = true
|
||||
@@ -129,66 +125,37 @@ fun ReminderDefaultPicker(
|
||||
onClick = { apply(ReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
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)) }
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!isLast) Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
trailing(if (groups.isEmpty()) Position.Alone else Position.Bottom)
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +170,6 @@ internal fun GroupedLeadTimeRows(
|
||||
private fun CustomReminderEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
units: List<ReminderUnit>,
|
||||
unit: ReminderUnit,
|
||||
onUnitChange: (ReminderUnit) -> Unit,
|
||||
onConfirm: (Int) -> Unit,
|
||||
@@ -212,10 +178,9 @@ private fun CustomReminderEditor(
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||
placeholder = customReminderPlaceholder(unit),
|
||||
selectedUnit = units.indexOf(unit),
|
||||
onUnitChange = { onUnitChange(units[it]) },
|
||||
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = unit.ordinal,
|
||||
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
||||
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
|
||||
@@ -8,46 +8,8 @@ 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) + 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() }
|
||||
}
|
||||
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||
|
||||
@StringRes
|
||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||
@@ -73,10 +35,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 % WEEK == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_weeks, minutes / WEEK, minutes / WEEK)
|
||||
minutes % DAY == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_days, minutes / DAY, minutes / DAY)
|
||||
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 % 60 == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||
|
||||
@@ -165,12 +165,9 @@ 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.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.REMINDER_PRESETS
|
||||
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
|
||||
@@ -877,7 +874,7 @@ private fun EventEditContent(
|
||||
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
RemindersSection(
|
||||
reminders = form.firingReminders(),
|
||||
reminders = form.reminders,
|
||||
onRemove = viewModel::removeReminder,
|
||||
onAdd = { sheet = EditSheet.Reminder },
|
||||
)
|
||||
@@ -1491,8 +1488,7 @@ private fun EditSheetHost(
|
||||
)
|
||||
}
|
||||
EditSheet.Reminder -> ReminderPickerDialog(
|
||||
isAllDay = form.isAllDay,
|
||||
alreadyChosen = form.firingReminders(),
|
||||
alreadyChosen = form.reminders,
|
||||
onSelect = { minutes ->
|
||||
viewModel.addReminder(minutes)
|
||||
onClose()
|
||||
@@ -1584,16 +1580,13 @@ 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("") }
|
||||
val units = customReminderUnits(isAllDay)
|
||||
var pickedUnit by rememberSaveable { mutableStateOf(units.first()) }
|
||||
val unit = pickedUnit.takeIf { it in units } ?: units.first()
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
val customMinutes = amountText.toIntOrNull()
|
||||
?.takeIf { it in 1..999 }
|
||||
?.let { it * unit.minutesFactor }
|
||||
@@ -1612,24 +1605,19 @@ private fun ReminderPickerDialog(
|
||||
},
|
||||
) {
|
||||
if (!customMode) {
|
||||
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 },
|
||||
)
|
||||
},
|
||||
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 },
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
@@ -1639,13 +1627,13 @@ private fun ReminderPickerDialog(
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = { amountText = it },
|
||||
placeholder = customReminderPlaceholder(unit),
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(
|
||||
label = stringResource(reminderUnitLabel(unit)),
|
||||
entries = units.map { stringResource(reminderUnitLabel(it)) },
|
||||
onPick = { pickedUnit = units[it] },
|
||||
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||
onPick = { unit = ReminderUnit.entries[it] },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ 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
|
||||
@@ -443,7 +441,7 @@ class EventEditViewModel @Inject constructor(
|
||||
ImportSource.File -> {
|
||||
// Respect the file's own reminders; never silently overwrite them.
|
||||
_remindersTouched.value = true
|
||||
maybePromptImportedReminderDefault()
|
||||
maybePromptImportedReminderDefault(form)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,21 +527,18 @@ 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() {
|
||||
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
||||
val opened = session
|
||||
viewModelScope.launch {
|
||||
val targetId = resolvedCalendarId.first()
|
||||
val defaults = reminderDefaults()
|
||||
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||
// 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
|
||||
// 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
|
||||
if (default.isEmpty() || default == current.reminders) return@launch
|
||||
_importReminderPrompt.value = ImportReminderPrompt(
|
||||
currentReminderCount = firing.size,
|
||||
currentReminderCount = current.reminders.size,
|
||||
defaultReminders = default,
|
||||
)
|
||||
}
|
||||
@@ -699,20 +694,9 @@ 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 { form ->
|
||||
val removed = if (form.isAllDay) {
|
||||
form.reminders.filter { wholeDayLeadMinutes(it) == wholeDayLeadMinutes(minutes) }
|
||||
} else {
|
||||
listOf(minutes)
|
||||
}
|
||||
form.copy(reminders = form.reminders - removed.toSet())
|
||||
}
|
||||
edit { it.copy(reminders = it.reminders - minutes) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -966,11 +950,3 @@ 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,6 +33,7 @@ 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
|
||||
@@ -243,7 +244,7 @@ internal fun NotificationsScreen(
|
||||
if (showDefaultReminder) {
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
isAllDay = false,
|
||||
presets = REMINDER_PRESETS,
|
||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.minutesOrEmpty()) },
|
||||
@@ -253,7 +254,7 @@ internal fun NotificationsScreen(
|
||||
if (showAllDayReminder) {
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
isAllDay = true,
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.minutesOrEmpty()) },
|
||||
@@ -288,7 +289,7 @@ internal fun NotificationsScreen(
|
||||
R.string.settings_default_reminder
|
||||
},
|
||||
),
|
||||
isAllDay = target.isAllDay,
|
||||
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||
selected = map.reminderOverrideFor(target.calendarId),
|
||||
allowInherit = true,
|
||||
onSelect = {
|
||||
|
||||
@@ -72,6 +72,9 @@ 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),
|
||||
isAllDay = true,
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.reminderChoices[type] ?: ReminderOverride.None,
|
||||
// Managed calendars own their reminders outright — no "inherit global".
|
||||
allowInherit = false,
|
||||
|
||||
+1
-19
@@ -48,29 +48,11 @@ 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, 4_320, 5_760, 7_200, 10_080, 20_160, 30_240)) {
|
||||
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
|
||||
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
|
||||
assertThat(fromProviderAllDayMinutes(raw, date, berlin, time))
|
||||
.isEqualTo(semantic)
|
||||
|
||||
@@ -33,14 +33,6 @@ 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,18 +501,6 @@ 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)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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,31 +305,6 @@ 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