Compare commits

..
Author SHA1 Message Date
makiolaj 754745586d Run the Play job on trixie for fastlane's Ruby 3.2 requirement (#509) 2026-11-13 19:17:46 +01:00
Jean-Luc Makiola 1a1df07dc3 Release v2.24.2 (#511)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Renovate / renovate (push) Successful in 1m53s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m38s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 34s
- Queued drops: moves made while an earlier one is still saving are queued instead of dropped, with messages for stale and skipped moves (#494)
- Day view title names the month (#501)
- Dependency updates: kotlinx-datetime, AppCompat, fastlane, Renovate image (#507–#510)
- Version 2.24.2 (2240299), CHANGELOG, What's New in all store locales

#494 stays open until the reporter answers; the fix covers the ways a quick move could get lost, the original report isn't reproduced.

Refs #494
Closes #501
2026-11-13 18:56:38 +01:00
makiolaj 75315d92a7 Merge main into release/2.24.2
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-13 16:10:53 +01:00
95ad3a30cf Release v2.24.1 (#504)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Renovate / renovate (push) Successful in 1m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m58s
- Agenda widget: Cards / Classic style option, opacity slider for all Solid/Tinted widgets (#489)
- Month widget fits events into short week rows (#491)
- Week numbers follow the locale's rules and the row's first day (#493)
- Readable dimmed events in dark widgets, pinned Light/Dark theme matches system colours (#475)
- Dropped unused WorkManager permissions: network state, wake lock, foreground service (#448)
- Weblate translations (#434), incl. the Arabic string from after the 2.24.0 squash
- Version 2.24.1 (2240199), CHANGELOG, What's New in all store locales

#489 deviates from the issue: the classic look comes back as a style option instead of replacing the new design.

Closes #489
Closes #491
Closes #493
Closes #475
Closes #448

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Louis Leblanc <sandraev@posteo.dk>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/504
2026-11-13 11:17:48 +01:00
Jean-Luc Makiola 8ce4831efa Translations update from Weblate (#434)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m59s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/434
2026-11-13 11:07:42 +01:00
37 changed files with 153 additions and 2542 deletions

No files matched your search

+4 -1
View File
@@ -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 }}
@@ -67,10 +67,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
@@ -623,20 +620,6 @@ class MainActivity : AppCompatActivity() {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/**
* Open the day view on [date] over the default home view — the daily
* summary's tap (#459). Spoken as the system's own "view time" intent,
* at the day's local noon, so it lands on the path a clock tap takes.
*/
fun openDayIntent(context: Context, date: LocalDate): Intent {
val noon = LocalDateTime(date, LocalTime(12, 0))
.toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
return Intent(Intent.ACTION_VIEW, "content://$CALENDAR_PROVIDER_HOST/time/$noon".toUri())
.setClass(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {
@@ -35,7 +35,6 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import de.jeanlucmakiola.calendula.domain.fixedReminderOffsets
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
@@ -920,15 +919,11 @@ class AndroidCalendarDataSource @Inject constructor(
minutes
}
/**
* [form]'s reminders as the distinct raw provider offsets to store, its
* fixed-time ones (#459) measured from the start it is saved with.
*/
/** [form]'s reminders as the distinct raw provider offsets to store. */
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
(
form.reminders.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) } +
form.fixedReminderOffsets(TimeZone.currentSystemDefault())
).distinct()
form.reminders
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
.distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
// A globally-unique UID so a later .ics backup/restore can identify
@@ -1031,11 +1026,7 @@ class AndroidCalendarDataSource @Inject constructor(
// 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.
// A fixed-time reminder also changes when the start's day or time does.
val zone = TimeZone.currentSystemDefault()
if (updated.reminders.toSet() != original.reminders.toSet() ||
updated.fixedReminderOffsets(zone).toSet() != original.fixedReminderOffsets(zone).toSet()
) {
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
// Same untouched-set guard for guests: only reconcile when the form's
@@ -1236,9 +1227,8 @@ class AndroidCalendarDataSource @Inject constructor(
)
}
// Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways. The
// exception is a one-off, so a fixed-time reminder takes real minutes.
reconcileReminders(exceptionId, encodedReminders(form.toDetachedOccurrence(), allDayReminderTimeMinutes))
// business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
reconcileAttendees(exceptionId, form.attendees)
return exceptionId
}
@@ -81,7 +81,7 @@ class CalendarPrefs @Inject constructor(
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
@@ -4,8 +4,6 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
@@ -29,20 +27,7 @@ class ReminderStatePrefs @Inject constructor(
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
/**
* The day the last daily summary listed (#459): recorded at once, so neither
* a pass failing after posting nor a later time picked the same evening
* lists it twice.
*/
suspend fun lastDigestDay(): LocalDate? =
prefs.get(LAST_DIGEST_DAY)?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
suspend fun setLastDigestDay(day: LocalDate) = prefs.set(LAST_DIGEST_DAY, day.toString())
suspend fun clearLastDigestDay() = prefs.set(LAST_DIGEST_DAY, null)
private companion object {
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
val LAST_DIGEST_DAY = nullableStringPref("reminder_last_digest_day")
}
}
@@ -1,15 +1,10 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.reminders.DEFAULT_DIGEST_TIME_MINUTES
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
@@ -171,7 +166,6 @@ class SettingsPrefs @Inject constructor(
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
val mutedReminderCalendarIds: Flow<Set<Long>> = prefs.flow(SettingsKeys.mutedReminderCalendarIds)
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
@@ -182,20 +176,6 @@ class SettingsPrefs @Inject constructor(
prefs.flow(SettingsKeys.perCalendarReminderOverride)
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> =
prefs.flow(SettingsKeys.perCalendarAllDayReminderOverride)
val defaultFixedTimeReminder: Flow<FixedTimeReminder?> = prefs.flow(SettingsKeys.defaultFixedTimeReminder)
val perCalendarFixedTimeReminderOverride: Flow<Map<Long, FixedTimeReminder?>> =
prefs.flow(SettingsKeys.perCalendarFixedTimeReminderOverride)
/**
* Every "N days before at HH:MM" the defaults name, so an offset landing on
* one reads back as that reminder rather than a lead time (#459).
*/
val knownFixedTimeReminders: Flow<Set<FixedTimeReminder>> = prefs.snapshot {
setOfNotNull(it[SettingsKeys.defaultFixedTimeReminder]) +
it[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull() +
it[SettingsKeys.recentFixedTimeReminders]
}
val dailyDigest: Flow<DailyDigestSettings> = prefs.snapshot { it.dailyDigest }
val autoBackupEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.autoBackupEnabled)
val autoBackupIntervalMinutes: Flow<Long> = prefs.flow(SettingsKeys.autoBackupIntervalMinutes)
val autoBackupFolderUri: Flow<String?> = prefs.flow(SettingsKeys.autoBackupFolderUri)
@@ -293,10 +273,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
}
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
@@ -348,77 +324,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) =
prefs.applyOverride(SettingsKeys.perCalendarAllDayReminderOverride, calendarId, override)
/** The replaced default stays a known pattern, so events that got it keep reading as it. */
suspend fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) {
prefs.edit {
val old = it[SettingsKeys.defaultFixedTimeReminder]
if (old != reminder) retireFixedTimeReminder(it, old)
it[SettingsKeys.defaultFixedTimeReminder] = reminder
}
}
/** [override] [FixedTimeOverride.Inherit] drops the calendar's entry; a replaced value stays known. */
suspend fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) {
prefs.edit {
val current = it[SettingsKeys.perCalendarFixedTimeReminderOverride]
val old = current[calendarId]
if (old != (override as? FixedTimeOverride.At)?.reminder) retireFixedTimeReminder(it, old)
it[SettingsKeys.perCalendarFixedTimeReminderOverride] = when (override) {
FixedTimeOverride.Inherit -> current - calendarId
FixedTimeOverride.Off -> current + (calendarId to null)
is FixedTimeOverride.At -> current + (calendarId to override.reminder)
}
}
}
/**
* Remember fixed-time reminders saved by hand (#459), so their rows read
* back as them; the defaults are known anyway and take no slot.
*/
suspend fun rememberFixedTimeReminders(reminders: Collection<FixedTimeReminder>) {
prefs.edit {
val defaults = setOfNotNull(it[SettingsKeys.defaultFixedTimeReminder]) +
it[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull()
val fresh = reminders.filterNot { reminder -> reminder in defaults }
if (fresh.isNotEmpty()) {
it[SettingsKeys.recentFixedTimeReminders] = (fresh + it[SettingsKeys.recentFixedTimeReminders]).distinct()
}
}
}
/** Keep a default being replaced known, unless it still is a default elsewhere. */
private fun retireFixedTimeReminder(preferences: MutablePreferences, reminder: FixedTimeReminder?) {
if (reminder == null) return
val defaults = listOfNotNull(preferences[SettingsKeys.defaultFixedTimeReminder]) +
preferences[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull()
if (defaults.count { it == reminder } > 1) return
preferences[SettingsKeys.recentFixedTimeReminders] =
(listOf(reminder) + preferences[SettingsKeys.recentFixedTimeReminders]).distinct()
}
suspend fun setDailyDigestEnabled(enabled: Boolean) = editDigest { it[SettingsKeys.dailyDigestEnabled] = enabled }
suspend fun setDailyDigestTimeMinutes(minutesOfDay: Int) =
editDigest { it[SettingsKeys.dailyDigestTimeMinutes] = minutesOfDay }
suspend fun setDailyDigestDay(day: DigestDay) = editDigest { it[SettingsKeys.dailyDigestDay] = day }
/**
* Change when the summary arrives, stamping the moment: a summary whose time
* had already passed today is not owed by the change itself.
*/
private suspend fun editDigest(change: (MutablePreferences) -> Unit) {
prefs.edit {
change(it)
it[SettingsKeys.dailyDigestArmedAt] = System.currentTimeMillis()
}
}
suspend fun setCalendarInDailyDigest(calendarId: Long, included: Boolean) {
prefs.update(SettingsKeys.digestExcludedCalendarIds) { if (included) it - calendarId else it + calendarId }
}
suspend fun setAutoBackupEnabled(enabled: Boolean) = prefs.set(SettingsKeys.autoBackupEnabled, enabled)
suspend fun setAutoBackupIntervalMinutes(minutes: Long) =
@@ -468,7 +373,6 @@ class SettingsPrefs @Inject constructor(
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
internal const val MINUTES_PER_DAY = 1_440
internal const val MAX_RECENT_FIXED_TIME_REMINDERS = 12
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
const val DEFAULT_MULTI_DAY_COUNT = 3
@@ -690,12 +594,6 @@ object SettingsKeys {
*/
val remindersEnabled: Pref<Boolean> = booleanPref("reminders_enabled", true)
/**
* Calendars whose reminders Calendula doesn't post (#272), independent of
* `Calendars.VISIBLE`: the events still show, only the notifications stop.
*/
val mutedReminderCalendarIds: Pref<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
/**
* Whether to offer a custom event colour on calendars that publish no colour
* palette; synced ones without a palette (some CalDAV) may drop or overwrite
@@ -785,48 +683,6 @@ object SettingsKeys {
val perCalendarAllDayReminderOverride: Pref<Map<Long, List<Int>>> =
reminderOverridePref("per_calendar_allday_reminder_override")
/**
* The "N days before at HH:MM" reminder added to new **timed** events
* (#459), or null for none — the factory default. Stored as `days@minute`.
*/
val defaultFixedTimeReminder: Pref<FixedTimeReminder?> =
nullableStringPref("default_fixed_time_reminder").map(FixedTimeReminder::parse) { it?.toToken() }
/**
* Per-calendar overrides of [defaultFixedTimeReminder]: present with a
* value replaces it, present as null turns it off, absent inherits.
* Serialised as `id=days@minute` or `id=none`, joined by `;`.
*/
val perCalendarFixedTimeReminderOverride: Pref<Map<Long, FixedTimeReminder?>> =
nullableStringPref("per_calendar_fixed_time_reminder").map(::parseFixedTimeOverrides) {
it.toStoredFixedTimeOverrides()
}
/** The daily summary notification (#459): opt-in, and which day it lists at what time. */
val dailyDigestEnabled: Pref<Boolean> = booleanPref("daily_digest_enabled", false)
val dailyDigestTimeMinutes: Pref<Int> =
intPref("daily_digest_time_minutes", DEFAULT_DIGEST_TIME_MINUTES).coerced {
it.coerceIn(0, SettingsPrefs.MINUTES_PER_DAY - 1)
}
val dailyDigestDay: Pref<DigestDay> = enumPref("daily_digest_day", DigestDay.Tomorrow)
/** Calendars left out of the daily summary; every other one is in. */
val digestExcludedCalendarIds: Pref<Set<Long>> = idSetPref("digest_excluded_calendar_ids")
/** When the summary's switch, time or day last changed; a moment before it is never owed. */
val dailyDigestArmedAt: Pref<Long> = longPref("daily_digest_armed_at", 0L)
/**
* Fixed-time reminders last saved by hand, and defaults since replaced,
* newest first — with the current defaults, the patterns a stored offset is
* recognised as. Capped, so the oldest falls out of recognition.
*/
val recentFixedTimeReminders: Pref<List<FixedTimeReminder>> =
nullableStringPref("recent_fixed_time_reminders").map(
{ stored -> stored?.split(LIST_SEP).orEmpty().mapNotNull(FixedTimeReminder::parse).distinct() },
{ reminders -> reminders.take(SettingsPrefs.MAX_RECENT_FIXED_TIME_REMINDERS).joinToString(LIST_SEP) { it.toToken() } },
)
/** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */
val autoBackupEnabled: Pref<Boolean> = booleanPref("auto_backup_enabled", false)
@@ -1023,57 +879,6 @@ data class BackupStatus(
val consecutiveFailures: Int,
)
/** The daily summary's settings, read together. */
internal val Preferences.dailyDigest: DailyDigestSettings
get() = DailyDigestSettings(
enabled = this[SettingsKeys.dailyDigestEnabled],
timeMinutes = this[SettingsKeys.dailyDigestTimeMinutes],
day = this[SettingsKeys.dailyDigestDay],
excludedCalendarIds = this[SettingsKeys.digestExcludedCalendarIds],
armedAtMillis = this[SettingsKeys.dailyDigestArmedAt],
)
/** A calendar's choice for the fixed-time default, as a picker returns it. */
sealed interface FixedTimeOverride {
data object Inherit : FixedTimeOverride
data object Off : FixedTimeOverride
data class At(val reminder: FixedTimeReminder) : FixedTimeOverride
}
/** [calendarId]'s entry read as a [FixedTimeOverride]. */
fun Map<Long, FixedTimeReminder?>.fixedTimeOverrideFor(calendarId: Long): FixedTimeOverride = when {
!containsKey(calendarId) -> FixedTimeOverride.Inherit
else -> getValue(calendarId)?.let { FixedTimeOverride.At(it) } ?: FixedTimeOverride.Off
}
/** The fixed-time reminder a new timed event on [calendarId] gets, or null for none. */
fun resolveDefaultFixedTimeReminder(
global: FixedTimeReminder?,
overrides: Map<Long, FixedTimeReminder?>,
calendarId: Long?,
): FixedTimeReminder? =
if (calendarId != null && overrides.containsKey(calendarId)) overrides[calendarId] else global
private fun parseFixedTimeOverrides(stored: String?): Map<Long, FixedTimeReminder?> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val value = parts[1].trim()
if (value == NONE) return@mapNotNull calendarId to null
val reminder = FixedTimeReminder.parse(value) ?: return@mapNotNull null
calendarId to reminder
}.toMap()
private fun Map<Long, FixedTimeReminder?>.toStoredFixedTimeOverrides(): String? =
if (isEmpty()) {
null
} else {
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, reminder) ->
"$id$KEY_VALUE_SEP${reminder?.toToken() ?: NONE}"
}
}
/**
* The lead times to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for
@@ -1,152 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toKotlinLocalDate
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts the daily summary (#459): one notification listing a day's events, on
* its own channel, replacing the one before it. Tapping opens that day.
*/
@Singleton
class DailyDigestNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs,
) {
/**
* Post the summary of [day] listing [listed], or withdraw the last one when
* there is nothing to list — yesterday's would otherwise stand in for it.
*/
suspend fun post(day: LocalDate, listed: List<ReminderEventInstance>) {
val manager = NotificationManagerCompat.from(context)
if (listed.isEmpty()) {
manager.cancel(TAG_DIGEST, NOTIFICATION_ID)
return
}
ensureChannel()
val zone = ZoneId.systemDefault()
val locale = Locale.getDefault()
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val today = LocalDate.now(zone)
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
// The date rides along: the summary stays up into the day it lists,
// when "Tomorrow" alone would point at the wrong one.
val date = DateTimeFormatter.ofPattern(
android.text.format.DateFormat.getBestDateTimePattern(locale, "EEEdMMM"),
locale,
).format(day)
val dayLabel = when (day) {
today -> context.getString(R.string.digest_day_relative, context.getString(R.string.reminder_day_today), date)
today.plusDays(1) ->
context.getString(R.string.digest_day_relative, context.getString(R.string.reminder_day_tomorrow), date)
else -> date
}
val allDay = context.getString(R.string.event_detail_all_day)
val lines = listed.map { entry ->
// Read against the listed day, so a timed event on it shows its bare
// range and one crossing midnight its dates.
val time = if (entry.isAllDay) allDay else reminderTimeText(
beginMillis = entry.beginMillis,
endMillis = entry.endMillis,
isAllDay = entry.isAllDay,
zone = zone,
locale = locale,
is24Hour = is24Hour,
today = day,
firstDayOfWeek = firstDayOfWeek,
todayLabel = allDay,
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
context.getString(R.string.digest_line, time, entry.title.ifBlank { context.getString(R.string.event_untitled) })
}
val title = context.resources.getQuantityString(R.plurals.digest_title, listed.size, dayLabel, listed.size)
val style = NotificationCompat.InboxStyle().setBigContentTitle(title)
lines.take(MAX_LINES).forEach(style::addLine)
if (lines.size > MAX_LINES) {
val more = lines.size - MAX_LINES
style.setSummaryText(context.resources.getQuantityString(R.plurals.digest_more, more, more))
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(listed.joinToString(", ") { it.title.ifBlank { context.getString(R.string.event_untitled) } })
.setStyle(style)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setAutoCancel(true)
// Gone once its day is over; "Tomorrow" would read wrong by then anyway.
.setTimeoutAfter(
(day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - System.currentTimeMillis())
.coerceAtLeast(1L),
)
.setContentIntent(openDay(day))
.build()
try {
manager.notify(TAG_DIGEST, NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
Log.w(TAG, "Could not post the daily summary", e)
}
}
/** Withdraw the summary showing, if any. */
fun cancel() {
NotificationManagerCompat.from(context).cancel(TAG_DIGEST, NOTIFICATION_ID)
}
/** The summary's channel id, created if missing — for the settings deep-link. */
fun channelId(): String {
ensureChannel()
return CHANNEL_ID
}
/** Creates the channel, or renames it after a language change; the user's settings on it stay. */
private fun ensureChannel() {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.digest_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = context.getString(R.string.digest_channel_description) },
)
}
private fun openDay(day: LocalDate): PendingIntent = PendingIntent.getActivity(
context,
REQUEST_CODE,
MainActivity.openDayIntent(context, day.toKotlinLocalDate()),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private companion object {
const val TAG = "DailyDigestNotifier"
const val CHANNEL_ID = "daily_digest"
const val TAG_DIGEST = "daily_digest"
const val NOTIFICATION_ID = 2
const val REQUEST_CODE = 0xD16E
const val MAX_LINES = 6
}
}
@@ -1,130 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
/** The single channel every reminder posted on before they moved per calendar (#145). */
const val LEGACY_REMINDER_CHANNEL_ID = "reminders"
private const val CHANNEL_PREFIX = "reminders.calendar."
private const val GROUP_PREFIX = "reminders.account."
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
/** The notification channel [calendarId]'s reminders post on. */
fun reminderChannelId(calendarId: Long): String = CHANNEL_PREFIX + calendarId
/** Whether [channelId] is one of the per-calendar reminder channels. */
fun isReminderChannelId(channelId: String): Boolean = channelId.startsWith(CHANNEL_PREFIX)
/** Whether [groupId] is one of the per-account reminder channel groups. */
fun isReminderGroupId(groupId: String): Boolean = groupId.startsWith(GROUP_PREFIX)
/**
* One account's channel group, labelled as the calendar manager labels it. The
* app's own device calendars share a single group ([isLocal]); [ambiguous]
* marks a [label] another group shows too, so it should name the source app.
*/
data class ReminderChannelGroupSpec(
val id: String,
val label: String,
val accountType: String,
val isLocal: Boolean,
val ambiguous: Boolean,
)
data class ReminderChannelSpec(
val id: String,
val calendarId: Long,
val name: String,
/** Null only for a channel created before its calendar could be looked up. */
val groupId: String?,
/**
* Whether a sync creates it up front. A calendar that syncs no events never
* fires, so its channel only appears once something posts on it.
*/
val upFront: Boolean = true,
)
/** What a channel already looks like in the system, as far as the plan compares it. */
data class ExistingReminderChannel(val id: String, val name: String, val description: String?, val groupId: String?)
data class ReminderChannelPlan(
val groups: List<ReminderChannelGroupSpec>,
val channels: List<ReminderChannelSpec>,
/**
* The [channels] a sync writes: missing up-front ones, and existing ones
* whose name or (localized) description changed, or that still lack a group.
*/
val channelsToWrite: List<ReminderChannelSpec>,
/** The [groups] holding a channel that exists or is about to be written. */
val groupsInUse: List<ReminderChannelGroupSpec>,
val staleChannelIds: List<String>,
val staleGroupIds: List<String>,
)
/**
* What the reminder channels should look like for [calendars]: one channel per
* calendar inside a group per account, which of them need writing, and the
* reminder channels and groups in [existing] / [existingGroupIds] no calendar
* claims any more. Ids outside the reminder namespace (the backup channel, the
* legacy channel) are never touched.
*/
fun planReminderChannels(
calendars: List<CalendarSource>,
existing: Collection<ExistingReminderChannel>,
existingGroupIds: Collection<String>,
unnamed: String,
description: String,
): ReminderChannelPlan {
val (local, synced) = calendars.partition { it.isLocal }
val groups = buildList {
if (local.isNotEmpty()) {
add(ReminderChannelGroupSpec(LOCAL_GROUP_ID, "", local.first().accountType, isLocal = true, ambiguous = false))
}
synced.groupByAccount().forEach { group ->
add(
ReminderChannelGroupSpec(
id = accountGroupId(group.key.type, group.key.name),
label = group.label,
accountType = group.accountType,
isLocal = false,
ambiguous = group.ambiguous,
),
)
}
}
val channels = calendars.map { calendar ->
ReminderChannelSpec(
id = reminderChannelId(calendar.id),
calendarId = calendar.id,
name = calendar.displayName.ifBlank { unnamed },
groupId = if (calendar.isLocal) LOCAL_GROUP_ID else accountGroupId(calendar.accountType, calendar.accountName),
upFront = calendar.isLocal || calendar.syncsEvents,
)
}
val existingById = existing.associateBy { it.id }
val wantedChannels = channels.mapTo(HashSet()) { it.id }
val wantedGroups = groups.mapTo(HashSet()) { it.id }
val toWrite = channels.filter { spec ->
val current = existingById[spec.id]
if (current == null) {
spec.upFront
} else {
current.name != spec.name || current.description != description ||
(current.groupId == null && spec.groupId != null)
}
}
val usedGroupIds = channels
.filter { it.id in existingById || it in toWrite }
.mapNotNullTo(HashSet()) { it.groupId }
return ReminderChannelPlan(
groups = groups,
channels = channels,
channelsToWrite = toWrite,
groupsInUse = groups.filter { it.id in usedGroupIds },
staleChannelIds = existingById.keys.filter { isReminderChannelId(it) && it !in wantedChannels },
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups },
)
}
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
@@ -1,192 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* The reminder notification channels: one per calendar, grouped by account
* (#145), created up front so each calendar can be muted or given its own sound
* in the system settings before it ever fires.
*
* A channel the user has touched keeps their settings; [sync] only creates the
* missing ones, renames changed ones, and deletes those whose calendar is gone.
*/
@Singleton
class ReminderChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val calendarDataSource: CalendarDataSource,
) {
private val manager: NotificationManager
get() = context.getSystemService(NotificationManager::class.java)
/** What the last [sync] saw, so an unchanged provider costs no channel writes. */
@Volatile
private var lastSync: Any? = null
/**
* Bring the channels in line with the provider's calendars. An empty
* snapshot changes nothing: that is a provider hiccup far more often than
* every calendar gone at once.
*/
@Synchronized
fun sync() {
val calendars = calendarDataSource.calendars()
if (calendars.isEmpty()) return
val manager = manager
val existing = manager.notificationChannels
val existingIds = existing.mapTo(HashSet()) { it.id }
if (syncKey(calendars, existingIds) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
val groups = manager.notificationChannelGroups
val plan = plan(calendars, existing, groups.map { it.id })
plan.staleChannelIds.forEach(manager::deleteNotificationChannel)
plan.staleGroupIds.forEach(manager::deleteNotificationChannelGroup)
val groupNames = groups.associate { it.id to it.name?.toString() }
write(
groups = plan.groupsInUse.filter { groupNames[it.id] != groupLabel(it) },
channels = plan.channelsToWrite,
existing = existing,
)
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
lastSync = syncKey(calendars, if (retireLegacyChannel()) ids - LEGACY_REMINDER_CHANNEL_ID else ids)
}
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>): Any = listOf(
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
Locale.getDefault(),
channelIds,
)
/**
* The channel to post [calendarId]'s reminder on, created first if a sync
* has not got to it yet. Null when the calendar no longer exists. Should the
* calendar lookup fail or come back empty, the channel is still created,
* plainly named and ungrouped, for the next sync to name and file — a
* reminder is never lost to its channel.
*/
@Synchronized
fun channelFor(calendarId: Long): String? {
val id = reminderChannelId(calendarId)
val manager = manager
if (manager.getNotificationChannel(id) != null) return id
val existing = manager.notificationChannels
val calendars = try {
calendarDataSource.calendars()
} catch (e: Exception) {
Log.w(TAG, "Could not look up calendar $calendarId for its channel", e)
emptyList()
}
if (calendars.isEmpty()) {
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
write(emptyList(), listOf(fallback), existing)
return id
}
val plan = plan(calendars, existing, emptyList())
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
val knownGroups = manager.notificationChannelGroups.mapTo(HashSet()) { it.id }
write(
groups = plan.groups.filter { it.id == spec.groupId && it.id !in knownGroups },
channels = listOf(spec),
existing = existing,
)
return id
}
private fun plan(
calendars: List<CalendarSource>,
existing: List<NotificationChannel>,
existingGroupIds: List<String>,
): ReminderChannelPlan = planReminderChannels(
calendars = calendars,
existing = existing.map { ExistingReminderChannel(it.id, it.name.toString(), it.description, it.group) },
existingGroupIds = existingGroupIds,
unnamed = context.getString(R.string.calendar_unnamed),
description = context.getString(R.string.reminder_channel_description),
)
private fun write(
groups: List<ReminderChannelGroupSpec>,
channels: List<ReminderChannelSpec>,
existing: List<NotificationChannel>,
) {
val manager = manager
if (groups.isNotEmpty()) {
manager.createNotificationChannelGroups(groups.map { NotificationChannelGroup(it.id, groupLabel(it)) })
}
if (channels.isEmpty()) return
val byId = existing.associateBy { it.id }
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
// Only a channel's first creation sets its behaviour; later calls just
// rename it, so an existing one passes its own importance back.
manager.createNotificationChannels(
channels.map { spec ->
val current = byId[spec.id]
NotificationChannel(
spec.id,
spec.name,
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.reminder_channel_description)
spec.groupId?.let { group = it }
if (current == null && template != null) inheritFrom(template)
}
},
)
}
/**
* Carry the old single channel's sound, vibration and lock-screen choices
* over to a new per-calendar one, so an upgrade keeps how reminders sounded.
*/
private fun NotificationChannel.inheritFrom(template: NotificationChannel) {
setSound(template.sound, template.audioAttributes)
// The pattern setter also flips vibration on or off, so it goes first.
vibrationPattern = template.vibrationPattern
enableVibration(template.shouldVibrate())
enableLights(template.shouldShowLights())
lightColor = template.lightColor
lockscreenVisibility = template.lockscreenVisibility
setShowBadge(template.canShowBadge())
// Only honoured with Do Not Disturb access, so usually left to the user.
setBypassDnd(template.canBypassDnd())
}
/**
* Delete the pre-#145 channel once the per-calendar ones carry its settings,
* but not while a reminder posted on it is still on screen — deleting the
* channel would take that notification with it. Returns whether it is gone.
*/
private fun retireLegacyChannel(): Boolean {
val manager = manager
if (manager.getNotificationChannel(LEGACY_REMINDER_CHANNEL_ID) == null) return true
val showing = runCatching {
manager.activeNotifications.any { it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
}.getOrElse { e ->
Log.w(TAG, "Could not read active notifications", e)
true
}
if (!showing) manager.deleteNotificationChannel(LEGACY_REMINDER_CHANNEL_ID)
return !showing
}
private fun groupLabel(group: ReminderChannelGroupSpec): String {
if (group.isLocal) return context.getString(R.string.calendars_local_header)
val name = group.label.ifBlank { context.getString(R.string.calendar_unnamed) }
if (!group.ambiguous) return name
return context.getString(R.string.calendars_account_from_source, name, sourceAppLabel(context, group.accountType))
}
private companion object {
const val TAG = "ReminderChannels"
}
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
@@ -27,8 +29,8 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts one notification per occurrence on its calendar's channel
* ([ReminderChannels], #145). Tapping opens the event's detail screen.
* Posts one notification per occurrence on a dedicated channel. Tapping opens
* the event's detail screen.
*
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
* the one before it in place (#463). A different reminder alerts afresh; the
@@ -41,7 +43,6 @@ class ReminderNotifier @Inject constructor(
private val settingsPrefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val calendarDataSource: CalendarDataSource,
private val channels: ReminderChannels,
) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -52,25 +53,19 @@ class ReminderNotifier @Inject constructor(
}
/**
* The single choke point for "this calendar posts no reminders": switched
* off (also covering a snooze armed before the switch-off, and a read-only
* install whose switch lives in [CalendarPrefs]), or its notifications
* turned off in Calendula's own settings (#272).
* The single choke point for "this calendar is switched off", covering the
* two paths that reach [post] around the scan's own filter: a snooze armed
* before the switch-off, and a read-only install whose switch lives in
* [CalendarPrefs].
*/
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in settingsPrefs.mutedReminderCalendarIds.first() ||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false
/** Post [alert], unless its calendar is silenced or gone. */
/** Post [alert], unless its calendar is switched off. */
suspend fun post(alert: ReminderAlert) {
if (isSilenced(alert.calendarId)) return
val channelId = try {
channels.channelFor(alert.calendarId)
} catch (e: Exception) {
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
null
} ?: return
ensureChannel()
val title = displayTitle(alert)
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
@@ -93,7 +88,7 @@ class ReminderNotifier @Inject constructor(
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, channelId)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
@@ -158,7 +153,7 @@ class ReminderNotifier @Inject constructor(
private fun cancelLegacy(alert: ReminderAlert) {
val manager = NotificationManagerCompat.from(context)
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
.filter { it.notification.`when` == alert.beginMillis }
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
@@ -186,8 +181,21 @@ class ReminderNotifier @Inject constructor(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Channel creation is idempotent; re-running refreshes the localized name. */
private fun ensureChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reminder_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.reminder_channel_description)
}
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
private companion object {
const val TAG = "ReminderNotifier"
const val CHANNEL_ID = "reminders"
// One id, distinct tags: the tag (occurrence) already keys the
// notification, so a fixed id keeps cancellation/replacement simple.
@@ -6,13 +6,9 @@ import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.digestEntries
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.scheduleDigest
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
@@ -47,12 +43,9 @@ class ReminderScanner @Inject constructor(
private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier,
private val digestNotifier: DailyDigestNotifier,
private val channels: ReminderChannels,
private val snoozes: ReminderSnoozeScheduler,
private val alarms: ReminderAlarmScheduler,
private val state: ReminderStatePrefs,
private val calendarPrefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
) {
@@ -84,37 +77,18 @@ class ReminderScanner @Inject constructor(
val now = System.currentTimeMillis()
if (!hasReadCalendar()) return
val lastFired = reminderWatermark(state.lastScanMillis(), now)
val horizon = now + MAX_ALARM_INTERVAL_MILLIS
val digestSettings = settingsPrefs.dailyDigest.first()
val nextDigest = if (digestSettings.enabled) postDigest(digestSettings, lastFired, now) else null
val nextReminder = try {
if (settingsPrefs.remindersEnabled.first()) reminders(lastFired, now, horizon) else null
} catch (e: Exception) {
// The watermark stays, so the next pass owes these reminders again;
// keep that pass, and the summary's next moment, armed.
alarms.scheduleScan(minOf(nextDigest ?: horizon, now + RETRY_AFTER_FAILURE_MILLIS))
throw e
if (!settingsPrefs.remindersEnabled.first()) {
// Reminders off: drop the wake-up, but keep the watermark moving
// so switching them back on doesn't replay the backlog.
alarms.cancelScan()
state.setLastScanMillis(now)
return
}
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog — and while both are off, so switching one
// back on doesn't replay it.
state.setLastScanMillis(now)
val next = listOfNotNull(nextReminder, nextDigest).minOrNull()
if (next == null) alarms.cancelScan() else alarms.scheduleScan(minOf(next, horizon))
}
/** Post the event reminders that are due; returns the next wake-up they need. */
private suspend fun reminders(lastFired: Long, now: Long, horizon: Long): Long {
syncChannels()
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// Reach into the past too: an all-day "at time of event" encodes to a
// negative offset, and a catch-up pass needs the occurrences it missed.
// Calendars muted in-app (#272) plan nothing, so they arm no wake-ups.
val muted = settingsPrefs.mutedReminderCalendarIds.first()
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
.filter { it.calendarId !in muted }
val planned = planReminders(
instances = occurrences,
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
@@ -125,9 +99,9 @@ class ReminderScanner @Inject constructor(
val schedule = scheduleReminders(
planned = planned,
lastFiredMillis = lastFired,
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
nowMillis = now,
horizonMillis = horizon,
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
)
if (notifier.canPost()) {
@@ -137,60 +111,10 @@ class ReminderScanner @Inject constructor(
notifier.post(alert)
}
}
return schedule.nextAlarmMillis
}
/**
* Post the daily summary if it is owed (#459); returns its next moment. It
* leaves out calendars excluded from it, not those muted for reminders —
* a calendar too noisy to ping per event can still belong in the overview.
*/
private suspend fun postDigest(settings: DailyDigestSettings, lastFired: Long, now: Long): Long {
val zone = ZoneId.systemDefault()
val listed = try {
state.lastDigestDay()
} catch (e: Exception) {
Log.w(TAG, "Could not read the last daily summary", e)
null
}
val schedule = scheduleDigest(
timeMinutes = settings.timeMinutes,
day = settings.day,
zone = zone,
// A moment that passed before the summary was last changed is not owed.
lastFiredMillis = maxOf(lastFired, settings.armedAtMillis),
nowMillis = now,
lastListedDay = listed,
)
val day = schedule.dueDay
if (day != null && notifier.canPost()) {
// A failed summary must not cost the event reminders this pass owes.
try {
// A day either side: all-day occurrences sit at UTC midnight, which
// can fall outside the local day they stand for.
val from = day.minusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val to = day.plusDays(2).atStartOfDay(zone).toInstant().toEpochMilli()
val left = settings.excludedCalendarIds + calendarPrefs.pendingDisabledCalendarIds.first()
digestNotifier.post(day, digestEntries(source.occurrences(from, to), day, zone, left))
state.setLastDigestDay(day)
} catch (e: Exception) {
Log.w(TAG, "Daily summary failed", e)
}
}
return schedule.nextAlarmMillis
}
/**
* A provider change re-scans, so a calendar added, renamed or removed keeps
* its channel in step here. Skipped while reminders are off, when the
* channels have nothing to do. A failure never costs the reminders.
*/
private fun syncChannels() {
try {
channels.sync()
} catch (e: Exception) {
Log.w(TAG, "Reminder channel sync failed", e)
}
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
}
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
@@ -234,8 +158,5 @@ class ReminderScanner @Inject constructor(
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
/** How soon a pass whose reminders failed is tried again. */
const val RETRY_AFTER_FAILURE_MILLIS = 15L * 60 * 1000
}
}
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.domain
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.reminders.splitTimedReminders
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -42,12 +40,6 @@ data class EventForm(
val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */
val reminders: List<Int> = emptyList(),
/**
* "N days before at HH:MM" reminders of a timed event (#459), written as lead
* times measured from [start] at save time. Ignored while [isAllDay], so a
* round trip through all-day keeps them.
*/
val fixedReminders: List<FixedTimeReminder> = emptyList(),
val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default,
/** Confirmed / Tentative / Cancelled (#388). */
@@ -294,7 +286,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description)
if (timezone != null) add(EventFormField.Timezone)
if (reminders.isNotEmpty() || (!isAllDay && fixedReminders.isNotEmpty())) add(EventFormField.Reminders)
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability)
if (status != EventStatus.Confirmed) add(EventFormField.Status)
@@ -332,37 +324,13 @@ fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
*/
fun EventDetail.toDuplicateForm(
canModify: Boolean,
zone: TimeZone,
knownFixed: Set<FixedTimeReminder> = emptySet(),
): EventForm =
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).withFixedTimeRemindersRead(knownFixed, zone).copy(
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
status = status.forNewEvent(),
)
/**
* This form with its timed reminders read back into lead times and
* [EventForm.fixedReminders] ([splitTimedReminders]), the wall clock taken in
* [deviceZone]. All-day forms come back untouched.
*/
fun EventForm.withFixedTimeRemindersRead(known: Set<FixedTimeReminder>, deviceZone: TimeZone): EventForm {
if (isAllDay || reminders.isEmpty()) return this
val split = splitTimedReminders(
reminders, startInstant(resolvedZone(deviceZone)), deviceZone, known, wallClock = rrule != null,
)
return copy(reminders = split.leadMinutes, fixedReminders = (fixedReminders + split.fixed).distinct())
}
/** The offsets [EventForm.fixedReminders] store for this form's start; empty for an all-day form. */
fun EventForm.fixedReminderOffsets(deviceZone: TimeZone): List<Int> {
if (isAllDay || fixedReminders.isEmpty()) return emptyList()
val start = startInstant(resolvedZone(deviceZone))
return fixedReminders.map { it.offsetMinutes(start, deviceZone, wallClock = rrule != null) }
}
@@ -1,87 +0,0 @@
package de.jeanlucmakiola.calendula.domain.reminders
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
/** Which day the daily summary (#459) lists, counted from the day it arrives. */
enum class DigestDay(val daysAhead: Long) { Today(0), Tomorrow(1) }
/** 20:00, when the daily summary arrives until changed. */
const val DEFAULT_DIGEST_TIME_MINUTES = 1_200
/**
* The daily summary's settings. Calendars in [excludedCalendarIds] are left out
* of it; [armedAtMillis] is when its switch, time or day last changed.
*/
data class DailyDigestSettings(
val enabled: Boolean = false,
val timeMinutes: Int = DEFAULT_DIGEST_TIME_MINUTES,
val day: DigestDay = DigestDay.Tomorrow,
val excludedCalendarIds: Set<Long> = emptySet(),
val armedAtMillis: Long = 0L,
)
/**
* What one scan concluded for the summary: list [dueDay] now (null when nothing
* is owed), and wake up again at [nextAlarmMillis].
*/
data class DigestSchedule(
val dueDay: LocalDate?,
val nextAlarmMillis: Long,
)
/**
* When the summary is owed: a moment in `(lastFiredMillis, nowMillis]` is due.
* Only the latest such moment counts — a phone off for three evenings gets one
* summary, not three — and only while the day it lists has not passed and is
* not [lastListedDay]: each day is listed once.
*/
fun scheduleDigest(
timeMinutes: Int,
day: DigestDay,
zone: ZoneId,
lastFiredMillis: Long,
nowMillis: Long,
lastListedDay: LocalDate? = null,
): DigestSchedule {
val time = LocalTime.of(timeMinutes / 60, timeMinutes % 60)
val fireAt = { date: LocalDate -> date.atTime(time).atZone(zone).toInstant().toEpochMilli() }
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
val latestDate = if (fireAt(today) <= nowMillis) today else today.minusDays(1)
val listed = latestDate.plusDays(day.daysAhead)
val dueAt = fireAt(latestDate)
val dueDay = listed.takeIf { dueAt > lastFiredMillis && !it.isBefore(today) && it != lastListedDay }
return DigestSchedule(dueDay = dueDay, nextAlarmMillis = fireAt(latestDate.plusDays(1)))
}
/**
* The occurrences the summary for [day] lists: all-day ones covering it (their
* UTC dates), then timed ones overlapping it in [zone], each group in start
* order. Calendars in [excludedCalendarIds] are left out.
*/
fun digestEntries(
instances: List<ReminderEventInstance>,
day: LocalDate,
zone: ZoneId,
excludedCalendarIds: Set<Long>,
): List<ReminderEventInstance> {
val dayStart = day.atStartOfDay(zone).toInstant().toEpochMilli()
val dayEnd = day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val utcDay = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
return instances
.asSequence()
.filter { it.calendarId !in excludedCalendarIds }
.filter {
if (it.isAllDay) {
it.beginMillis <= utcDay && maxOf(it.endMillis, it.beginMillis + 1) > utcDay
} else {
it.beginMillis < dayEnd && maxOf(it.endMillis, it.beginMillis + 1) > dayStart
}
}
.distinctBy { occurrenceKey(it.eventId, it.beginMillis) }
.sortedWith(compareBy({ !it.isAllDay }, { it.beginMillis }, { it.title }))
.toList()
}
@@ -1,142 +0,0 @@
package de.jeanlucmakiola.calendula.domain.reminders
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* A timed event's reminder "[daysBefore] days before, at [minuteOfDay]" (#459).
*
* The provider only knows lead times, so it is stored as an ordinary `Reminders`
* row whose offset is the distance from that wall-clock moment to the start
* ([offsetMinutes]) — it fires on every device and in every app. The pattern is
* only known to Calendula while it can be read back ([classifyTimedReminder]); the
* editor then re-derives the offset whenever the start moves.
*/
data class FixedTimeReminder(val daysBefore: Int, val minuteOfDay: Int) {
init {
require(daysBefore in 1..MAX_DAYS_BEFORE) { "daysBefore out of range: $daysBefore" }
require(minuteOfDay in 0 until MINUTES_PER_DAY) { "minuteOfDay out of range: $minuteOfDay" }
}
/** The wall clock this reminder names for an event starting at [start], read in [zone]. */
private fun alarmWallClock(start: Instant, zone: TimeZone): LocalDateTime {
val day = start.toLocalDateTime(zone).date.minus(daysBefore, DateTimeUnit.DAY)
return LocalDateTime(day, LocalTime(minuteOfDay / 60, minuteOfDay % 60))
}
/**
* The provider offset to store for an event starting at [start]: the real
* minutes to that moment, or with [wallClock] the minutes on the clock face.
* A recurring series stores one offset for every occurrence, so it takes the
* wall-clock reading — otherwise an occurrence opened next to a DST change
* would bake that hour into all the others.
*/
fun offsetMinutes(start: Instant, zone: TimeZone, wallClock: Boolean = false): Int {
val alarm = alarmWallClock(start, zone)
return if (wallClock) {
(start.toLocalDateTime(zone).toInstant(TimeZone.UTC) - alarm.toInstant(TimeZone.UTC)).inWholeMinutes.toInt()
} else {
(start - alarm.toInstant(zone)).inWholeMinutes.toInt()
}
}
/** The stored form, `days@minuteOfDay`. */
fun toToken(): String = "$daysBefore$TOKEN_SEP$minuteOfDay"
companion object {
/** Two weeks, well inside the four weeks Google Calendar takes for a reminder. */
const val MAX_DAYS_BEFORE = 14
private const val TOKEN_SEP = "@"
/** [toToken] read back; null for anything malformed or out of range. */
fun parse(token: String?): FixedTimeReminder? {
val parts = token?.trim()?.split(TOKEN_SEP) ?: return null
if (parts.size != 2) return null
val days = parts[0].toIntOrNull() ?: return null
val minute = parts[1].toIntOrNull() ?: return null
if (days !in 1..MAX_DAYS_BEFORE || minute !in 0 until MINUTES_PER_DAY) return null
return FixedTimeReminder(days, minute)
}
}
}
/**
* The "N days before at HH:MM" a raw offset lands on for an event starting at
* [start], or null when it fires on the start's own day (or after it), or
* further back than [FixedTimeReminder.MAX_DAYS_BEFORE]. [wallClock] reads the
* offset as [FixedTimeReminder.offsetMinutes] wrote it.
*/
fun fixedTimeReminderAt(
offsetMinutes: Int,
start: Instant,
zone: TimeZone,
wallClock: Boolean = false,
): FixedTimeReminder? {
if (offsetMinutes <= 0) return null
val startLocal = start.toLocalDateTime(zone)
val alarm = if (wallClock) {
(startLocal.toInstant(TimeZone.UTC) - offsetMinutes.minutes).toLocalDateTime(TimeZone.UTC)
} else {
(start - offsetMinutes.minutes).toLocalDateTime(zone)
}
val startDate = startLocal.date
val days = startDate.toEpochDays() - alarm.date.toEpochDays()
if (days !in 1..FixedTimeReminder.MAX_DAYS_BEFORE) return null
return FixedTimeReminder(days.toInt(), alarm.hour * 60 + alarm.minute)
}
/** A timed event's reminders, read back: plain lead times and the fixed-time ones. */
data class TimedReminders(
val leadMinutes: List<Int>,
val fixed: List<FixedTimeReminder>,
)
/**
* The fixed-time reminder [offsetMinutes] stands for on an event starting at
* [start], or null when it reads as a plain lead time. The provider can't say
* which an offset was meant as, so only the patterns in [known] — the configured
* defaults, past and present, and the ones saved by hand on this device — are
* recognised: guessing from the offset's shape alone would turn ordinary leads
* ("12 hours before") into fixed-time ones. Where a lead and a known pattern
* coincide ("1 day before" on an event at the default's own time) the pattern
* wins. A wrong reading fires at the same moment; only a later change of the
* start's time of day then treats the reminder the other way.
*/
fun classifyTimedReminder(
offsetMinutes: Int,
start: Instant,
zone: TimeZone,
known: Set<FixedTimeReminder>,
wallClock: Boolean = false,
): FixedTimeReminder? = fixedTimeReminderAt(offsetMinutes, start, zone, wallClock)?.takeIf { it in known }
/** Sort a timed event's raw offsets into lead times and fixed-time reminders ([classifyTimedReminder]). */
fun splitTimedReminders(
offsets: List<Int>,
start: Instant,
zone: TimeZone,
known: Set<FixedTimeReminder>,
wallClock: Boolean = false,
): TimedReminders {
val leads = mutableListOf<Int>()
val fixed = mutableListOf<FixedTimeReminder>()
offsets.distinct().forEach { offset ->
val pattern = classifyTimedReminder(offset, start, zone, known, wallClock)
if (pattern != null) fixed += pattern else leads += offset
}
return TimedReminders(leads.sorted(), fixed.distinct().sortedWith(FIXED_ORDER))
}
/** Most days before first, then by time — the order they fire in. */
val FIXED_ORDER: Comparator<FixedTimeReminder> =
compareByDescending<FixedTimeReminder> { it.daysBefore }.thenBy { it.minuteOfDay }
private const val MINUTES_PER_DAY = 1_440
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -73,13 +72,11 @@ fun accountGroupTitle(group: CalendarAccountGroup): String {
@Composable
fun sourceAppName(accountType: String): String {
val context = LocalContext.current
return remember(accountType) { sourceAppLabel(context, accountType) }
}
/** [sourceAppName] outside composition, e.g. for notification channel groups. */
fun sourceAppLabel(context: Context, accountType: String): String {
val pm = context.packageManager
return sourceAppPackages(context, accountType).firstNotNullOfOrNull { pkg ->
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
} ?: accountType
return remember(accountType) {
val pm = context.packageManager
val packages = sourceAppPackages(context, accountType)
packages.firstNotNullOfOrNull { pkg ->
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
} ?: accountType
}
}
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -44,7 +43,6 @@ class EventActionsViewModel @Inject constructor(
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
@@ -67,7 +65,7 @@ class EventActionsViewModel @Inject constructor(
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, zoneSource.current(), settingsPrefs.knownFixedTimeReminders.first())
return detail.toDuplicateForm(canModify, zoneSource.current())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
@@ -1,216 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.components.CustomExpandRow
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.datetime.LocalTime
/** "1 day before at 20:00", in the app's 12/24-hour convention. */
@Composable
fun fixedTimeReminderLabel(reminder: FixedTimeReminder): String = stringResource(
R.string.reminder_fixed_time,
pluralStringResource(R.plurals.reminder_days, reminder.daysBefore, reminder.daysBefore),
formatMinuteOfDay(reminder.minuteOfDay, LocalUse24HourFormat.current, currentLocale()),
)
/**
* The "N days before at HH:MM" editor (#459): a day count and a time, read as
* one sentence. Seeded from [initial]; [onValueChange] follows every edit (null
* while the day count is out of range). With a [confirmLabel] it carries its own
* confirm, reporting through [onConfirm]; without one the host picker commits
* from its actions slot. [connected] gives it the shape that meets a row above it.
*/
@Composable
fun FixedTimeReminderEditor(
initial: FixedTimeReminder,
modifier: Modifier = Modifier,
confirmLabel: String? = null,
onConfirm: (FixedTimeReminder) -> Unit = {},
onValueChange: (FixedTimeReminder?) -> Unit = {},
connected: Boolean = true,
) {
var daysText by rememberSaveable { mutableStateOf(initial.daysBefore.toString()) }
var minuteOfDay by rememberSaveable { mutableStateOf(initial.minuteOfDay) }
var pickingTime by rememberSaveable { mutableStateOf(false) }
val days = daysText.toIntOrNull()?.takeIf { it in 1..FixedTimeReminder.MAX_DAYS_BEFORE }
val value = days?.let { FixedTimeReminder(it, minuteOfDay) }
LaunchedEffect(value) { onValueChange(value) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = if (connected) {
RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp)
} else {
RoundedCornerShape(22.dp)
},
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = daysText, onValueChange = { daysText = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.reminder_fixed_days_before_at),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(12.dp))
FilledTonalButton(onClick = { pickingTime = true }) {
Text(formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current, currentLocale()))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = value?.let { fixedTimeReminderLabel(it) }
?: stringResource(R.string.reminder_fixed_days_range, FixedTimeReminder.MAX_DAYS_BEFORE),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (confirmLabel != null) {
Spacer(Modifier.width(16.dp))
FilledTonalButton(
enabled = value != null,
onClick = { value?.let(onConfirm) },
) { Text(confirmLabel) }
}
}
}
}
if (pickingTime) {
TimePickerAlert(
initial = LocalTime(minuteOfDay / 60, minuteOfDay % 60),
onConfirm = {
minuteOfDay = it.hour * 60 + it.minute
pickingTime = false
},
onDismiss = { pickingTime = false },
)
}
}
/**
* Picker for a fixed-time default (#459), full-screen: off, or a reminder at a
* set time edited in place — and, on a per-calendar picker ([inheritedLabel]
* non-null), deferring to the global default, [inherited], which also seeds the
* editor. Applies live; the user leaves via back.
*/
@Composable
fun FixedTimeReminderPicker(
title: String,
selected: FixedTimeOverride,
onSelect: (FixedTimeOverride) -> Unit,
onDismiss: () -> Unit,
inheritedLabel: String? = null,
inherited: FixedTimeReminder? = null,
) {
var current by rememberSaveable(stateSaver = FixedTimeOverrideSaver) { mutableStateOf(selected) }
var editing by rememberSaveable { mutableStateOf(false) }
// Mirrors [selected] until the first pick, as ReminderDefaultPicker does.
var userEdited by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selected) {
if (!userEdited) current = selected
}
fun apply(choice: FixedTimeOverride) {
userEdited = true
current = choice
onSelect(choice)
}
val chosen = (current as? FixedTimeOverride.At)?.reminder
FullScreenPicker(title = title, onDismiss = onDismiss) {
GroupedHint(text = stringResource(R.string.settings_fixed_reminder_hint))
Spacer(Modifier.height(8.dp))
val rowCount = if (inheritedLabel != null) 3 else 2
if (inheritedLabel != null) {
GroupedRow(
title = stringResource(R.string.reminder_use_default),
summary = inheritedLabel,
position = Position.Top,
checked = current is FixedTimeOverride.Inherit,
onClick = { apply(FixedTimeOverride.Inherit) },
)
}
GroupedRow(
title = stringResource(R.string.reminder_none),
position = if (rowCount == 3) Position.Middle else Position.Top,
checked = current is FixedTimeOverride.Off,
onClick = { apply(FixedTimeOverride.Off) },
)
CustomExpandRow(
title = stringResource(R.string.reminder_fixed_at_set_time),
summary = chosen?.let { fixedTimeReminderLabel(it) },
position = Position.Bottom,
expanded = editing,
onExpandedChange = { editing = it },
checked = chosen != null,
) {
FixedTimeReminderEditor(
initial = chosen ?: inherited ?: DEFAULT_FIXED_TIME_REMINDER,
confirmLabel = stringResource(R.string.reminder_custom_set),
onConfirm = {
apply(FixedTimeOverride.At(it))
editing = false
},
)
}
}
}
/** What the editor offers first: the evening before. */
val DEFAULT_FIXED_TIME_REMINDER = FixedTimeReminder(daysBefore = 1, minuteOfDay = 20 * 60)
private val FixedTimeOverrideSaver = Saver<FixedTimeOverride, String>(
save = {
when (it) {
FixedTimeOverride.Inherit -> "inherit"
FixedTimeOverride.Off -> "off"
is FixedTimeOverride.At -> it.reminder.toToken()
}
},
restore = {
when (it) {
"inherit" -> FixedTimeOverride.Inherit
"off" -> FixedTimeOverride.Off
else -> FixedTimeReminder.parse(it)?.let(FixedTimeOverride::At) ?: FixedTimeOverride.Off
}
},
)
@@ -23,12 +23,9 @@ import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -175,7 +172,6 @@ class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
@@ -461,9 +457,7 @@ class RescheduleViewModel @Inject constructor(
dropped
}
// Read fixed-time reminders out, so the move keeps them on their wall clock (#459).
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
.withFixedTimeRemindersRead(settingsPrefs.knownFixedTimeReminders.first(), zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
@@ -108,7 +108,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
@@ -533,8 +532,7 @@ private fun EventDetailContent(
.sortedBy { it.minutes }
.forEach { reminder ->
Text(
text = state.fixedReminders[reminder.minutes]?.let { fixedTimeReminderLabel(it) }
?: reminderLeadTimeLabel(reminder.minutes),
text = reminderLeadTimeLabel(reminder.minutes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 2.dp),
)
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
/**
* UI state for the event-detail screen (spec S4).
@@ -27,10 +26,6 @@ sealed interface EventDetailUiState {
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
/** The description as drawn — HTML rendered (#271); null when there is none. */
val description: RichText? = null,
/** Reminder offsets that read as "N days before at HH:MM" (#459), by raw minutes. */
val fixedReminders: Map<Int, FixedTimeReminder> = emptyMap(),
/** The fixed-time patterns the reminders were read against. */
val knownFixed: Set<FixedTimeReminder> = emptySet(),
) : EventDetailUiState
}
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.classifyTimedReminder
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -48,7 +46,6 @@ class EventDetailViewModel @Inject constructor(
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
@@ -145,7 +142,7 @@ class EventDetailViewModel @Inject constructor(
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current(), loaded.knownFixed)
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -160,17 +157,6 @@ class EventDetailViewModel @Inject constructor(
}
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
val knownFixed = settingsPrefs.knownFixedTimeReminders.first()
val fixedReminders = if (corrected.instance.isAllDay) {
emptyMap()
} else {
val zone = zoneSource.current()
corrected.reminders.map { it.minutes }.distinct().mapNotNull { minutes ->
classifyTimedReminder(
minutes, corrected.instance.start, zone, knownFixed, wallClock = corrected.rrule != null,
)?.let { minutes to it }
}.toMap()
}
val description = corrected.description
?.let(::descriptionRichText)
?.takeIf { it.text.isNotBlank() }
@@ -187,8 +173,6 @@ class EventDetailViewModel @Inject constructor(
description = description?.text,
),
description = description,
fixedReminders = fixedReminders,
knownFixed = knownFixed,
)
} catch (e: CancellationException) {
throw e
@@ -125,10 +125,6 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.common.DEFAULT_FIXED_TIME_REMINDER
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderEditor
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.resolvedZone
@@ -157,7 +153,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
@@ -880,9 +875,7 @@ private fun EventEditContent(
Spacer(Modifier.height(gap))
RemindersSection(
reminders = form.reminders,
fixedReminders = if (form.isAllDay) emptyList() else form.fixedReminders,
onRemove = viewModel::removeReminder,
onRemoveFixed = viewModel::removeFixedReminder,
onAdd = { sheet = EditSheet.Reminder },
)
}
@@ -1291,25 +1284,9 @@ private fun LocationCard(
* connected corners, the last row being "Add reminder".
*/
@Composable
private fun RemindersSection(
reminders: List<Int>,
fixedReminders: List<FixedTimeReminder>,
onRemove: (Int) -> Unit,
onRemoveFixed: (FixedTimeReminder) -> Unit,
onAdd: () -> Unit,
) {
val rows = fixedReminders.size + reminders.size + 1
fixedReminders.forEachIndexed { index, reminder ->
GroupedItemCard(
position = positionOf(index, rows),
icon = Icons.Default.Notifications,
title = fixedTimeReminderLabel(reminder),
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
onRemove = { onRemoveFixed(reminder) },
)
}
reminders.forEachIndexed { i, minutes ->
val index = fixedReminders.size + i
private fun RemindersSection(reminders: List<Int>, onRemove: (Int) -> Unit, onAdd: () -> Unit) {
val rows = reminders.size + 1
reminders.forEachIndexed { index, minutes ->
GroupedItemCard(
position = positionOf(index, rows),
icon = Icons.Default.Notifications,
@@ -1320,7 +1297,7 @@ private fun RemindersSection(
}
AddActionCard(
label = stringResource(R.string.event_edit_add_reminder),
position = positionOf(rows - 1, rows),
position = positionOf(reminders.size, rows),
onClick = onAdd,
)
}
@@ -1512,15 +1489,10 @@ private fun EditSheetHost(
}
EditSheet.Reminder -> ReminderPickerDialog(
alreadyChosen = form.reminders,
offerFixedTime = !form.isAllDay,
onSelect = { minutes ->
viewModel.addReminder(minutes)
onClose()
},
onSelectFixed = { reminder ->
viewModel.addFixedReminder(reminder)
onClose()
},
onDismiss = onClose,
)
EditSheet.Recurrence -> RecurrencePickerDialog(
@@ -1609,14 +1581,10 @@ private fun FieldPickerDialog(
@Composable
private fun ReminderPickerDialog(
alreadyChosen: List<Int>,
offerFixedTime: Boolean,
onSelect: (Int) -> Unit,
onSelectFixed: (FixedTimeReminder) -> Unit,
onDismiss: () -> Unit,
) {
var customMode by rememberSaveable { mutableStateOf(false) }
var fixedMode by rememberSaveable { mutableStateOf(false) }
var pendingFixed by remember { mutableStateOf<FixedTimeReminder?>(null) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
val customMinutes = amountText.toIntOrNull()
@@ -1627,31 +1595,18 @@ private fun ReminderPickerDialog(
title = stringResource(R.string.event_edit_add_reminder),
onDismiss = onDismiss,
actions = {
// The quick-pick list adds on tap; only the custom steps need Add.
// The quick-pick list adds on tap; only the custom step needs Add.
if (customMode) {
TextButton(
enabled = customMinutes != null && customMinutes !in alreadyChosen,
onClick = { customMinutes?.let(onSelect) },
) { Text(stringResource(R.string.event_edit_add)) }
} else if (fixedMode) {
TextButton(
enabled = pendingFixed != null,
onClick = { pendingFixed?.let(onSelectFixed) },
) { Text(stringResource(R.string.event_edit_add)) }
}
},
) {
if (fixedMode) {
GroupedHint(text = stringResource(R.string.event_edit_reminder_fixed_hint))
Spacer(Modifier.height(8.dp))
FixedTimeReminderEditor(
initial = DEFAULT_FIXED_TIME_REMINDER,
onValueChange = { pendingFixed = it },
connected = false,
)
} else if (!customMode) {
if (!customMode) {
val presets = REMINDER_PRESETS.filterNot { it in alreadyChosen }
val rowCount = presets.size + if (offerFixedTime) 2 else 1
val rowCount = presets.size + 1
presets.forEachIndexed { index, minutes ->
GroupedRow(
title = reminderLeadTimeLabel(minutes),
@@ -1659,14 +1614,6 @@ private fun ReminderPickerDialog(
onClick = { onSelect(minutes) },
)
}
if (offerFixedTime) {
GroupedRow(
title = stringResource(R.string.reminder_fixed_at_set_time),
summary = stringResource(R.string.event_edit_reminder_fixed_summary),
position = positionOf(presets.size, rowCount),
onClick = { fixedMode = true },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = positionOf(rowCount - 1, rowCount),
@@ -13,12 +13,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultFixedTimeReminder
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.reminders.FIXED_ORDER
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.fixedReminderOffsets
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
@@ -144,7 +139,6 @@ enum class ImportSource(internal val ownsDuration: Boolean) {
data class ImportReminderPrompt(
val currentReminderCount: Int,
val defaultReminders: List<Int>,
val defaultFixedReminders: List<FixedTimeReminder> = emptyList(),
)
/**
@@ -214,8 +208,6 @@ class EventEditViewModel @Inject constructor(
val zone: TimeZone,
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
val detail: EventDetail,
/** The fixed-time patterns its reminders were read against, so a re-read splits alike. */
val knownFixed: Set<FixedTimeReminder>,
) {
val original: EventForm get() = snapshot.form
}
@@ -233,13 +225,7 @@ class EventEditViewModel @Inject constructor(
val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
val fixed: FixedTimeReminder?,
val fixedOverrides: Map<Long, FixedTimeReminder?>,
) {
/** The fixed-time reminders a new event on [calendarId] gets (#459); none on an all-day one. */
fun resolveFixedFor(calendarId: Long?, isAllDay: Boolean): List<FixedTimeReminder> =
if (isAllDay) emptyList() else listOfNotNull(resolveDefaultFixedTimeReminder(fixed, fixedOverrides, calendarId))
/** The default reminders for an event on [calendarId] of the given kind. */
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
timedGlobal = timed,
@@ -449,7 +435,7 @@ class EventEditViewModel @Inject constructor(
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) {
ImportSource.Insert, ImportSource.InsertOpenEnded ->
if (form.reminders.isNotEmpty() || form.fixedReminders.isNotEmpty()) _remindersTouched.value = true
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
else applyDefaultReminder()
ImportSource.File -> {
@@ -479,35 +465,27 @@ class EventEditViewModel @Inject constructor(
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay)
val fixed = defaults.resolveFixedFor(targetId, form.isAllDay)
// The duration default resolves in parallel, so both must compose
// onto the current form, not a pre-suspend copy.
applyDefault { it.copy(reminders = reminders, fixedReminders = fixed) }
applyDefault { it.copy(reminders = reminders) }
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty() || fixed.isNotEmpty()) {
if (reminders.isNotEmpty()) {
_revealed.value = _revealed.value + EventFormField.Reminders
}
}
}
/** Snapshot the settings-default reminder flows into one value. */
/** Snapshot the four settings-default reminder flows into one value. */
private suspend fun reminderDefaults(): ReminderDefaults = combine(
combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
::Quadruple,
),
settingsPrefs.defaultFixedTimeReminder,
settingsPrefs.perCalendarFixedTimeReminderOverride,
) { (timed, allDay, timedOv, allDayOv), fixed, fixedOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv, fixed, fixedOv)
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
private data class Quadruple<A, B, C, D>(val a: A, val b: B, val c: C, val d: D)
/**
* Stretch a new timed event to the configured default length (#54) — the
* resolved calendar's per-calendar duration, otherwise the global default —
@@ -553,19 +531,15 @@ class EventEditViewModel @Inject constructor(
val opened = session
viewModelScope.launch {
val targetId = resolvedCalendarId.first()
val defaults = reminderDefaults()
val default = defaults.resolveFor(targetId, form.isAllDay)
val fixed = defaults.resolveFixedFor(targetId, form.isAllDay)
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
if (default.isEmpty() && fixed.isEmpty()) return@launch
if (default == current.reminders && fixed == current.fixedReminders) return@launch
if (default.isEmpty() || default == current.reminders) return@launch
_importReminderPrompt.value = ImportReminderPrompt(
currentReminderCount = current.reminders.size + current.fixedReminders.size,
currentReminderCount = current.reminders.size,
defaultReminders = default,
defaultFixedReminders = fixed,
)
}
}
@@ -575,7 +549,7 @@ class EventEditViewModel @Inject constructor(
val prompt = _importReminderPrompt.value ?: return
_importReminderPrompt.value = null
// Already frozen as touched by openImported; this just swaps the values.
edit { it.copy(reminders = prompt.defaultReminders, fixedReminders = prompt.defaultFixedReminders) }
edit { it.copy(reminders = prompt.defaultReminders) }
_revealed.value = _revealed.value + EventFormField.Reminders
}
@@ -605,15 +579,14 @@ class EventEditViewModel @Inject constructor(
if (opened == session) _loadFailed.value = true
return@launch
}
val knownFixed = settingsPrefs.knownFixedTimeReminders.first()
if (opened != session) return@launch
val zone = zoneSource.current()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone).withFixedTimeRemindersRead(knownFixed, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end), knownFixed)
val snapshot = detail.toEditSnapshot(begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end))
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
open(snapshot.form)
@@ -718,11 +691,7 @@ class EventEditViewModel @Inject constructor(
fun addReminder(minutes: Int) {
_remindersTouched.value = true
edit {
// A lead firing with a set-time reminder would be one row once saved.
if (minutes in it.fixedReminderOffsets(zoneSource.current())) it
else it.copy(reminders = (it.reminders + minutes).distinct().sorted())
}
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
}
fun removeReminder(minutes: Int) {
@@ -730,26 +699,6 @@ class EventEditViewModel @Inject constructor(
edit { it.copy(reminders = it.reminders - minutes) }
}
/**
* Add an "N days before at HH:MM" reminder (#459); timed events only. A lead
* time landing on the same moment is replaced by it — the two would be one
* row once saved.
*/
fun addFixedReminder(reminder: FixedTimeReminder) {
_remindersTouched.value = true
edit { form ->
if (form.isAllDay) return@edit form
val withFixed = form.copy(fixedReminders = (form.fixedReminders + reminder).distinct().sortedWith(FIXED_ORDER))
val offsets = withFixed.fixedReminderOffsets(zoneSource.current()).toSet()
withFixed.copy(reminders = form.reminders.filterNot { it in offsets })
}
}
fun removeFixedReminder(reminder: FixedTimeReminder) {
_remindersTouched.value = true
edit { it.copy(fixedReminders = it.fixedReminders - reminder) }
}
/**
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
* address just refreshes it (no duplicate row). A blank email is ignored —
@@ -866,7 +815,6 @@ class EventEditViewModel @Inject constructor(
val fresh = try {
repository.eventDetail(target.eventId)
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
.withFixedTimeRemindersRead(target.knownFixed, target.zone)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
@@ -881,17 +829,6 @@ class EventEditViewModel @Inject constructor(
return@launch
}
}
// Remembered before the write, so the rows read back as these
// reminders from the first re-read on (#459).
if (!form.isAllDay && form.fixedReminders.isNotEmpty()) {
try {
settingsPrefs.rememberFixedTimeReminders(form.fixedReminders)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Could not remember the set-time reminders", e)
}
}
val result = try {
if (target == null) {
repository.createEvent(form)
@@ -1013,7 +950,3 @@ class EventEditViewModel @Inject constructor(
_form.value = _form.value?.let(block)
}
}
/** [EditSnapshot.form] with its timed reminders read back into lead times and fixed-time ones. */
private fun EditSnapshot.withFixedTimeRemindersRead(known: Set<FixedTimeReminder>, zone: TimeZone): EditSnapshot =
copy(form = form.withFixedTimeRemindersRead(known, zone))
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -22,7 +21,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -33,14 +31,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.data.prefs.fixedTimeOverrideFor
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderPicker
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
@@ -49,12 +39,10 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.android.openNotificationSettings
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.ExpandIcon
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.identity.collapseExit
@@ -62,7 +50,6 @@ import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalTime
/**
@@ -82,9 +69,8 @@ internal fun NotificationsScreen(
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
// Turning either kind of notification on asks for the permission if missing.
fun askingPermission(set: (Boolean) -> Unit): (Boolean) -> Unit = { enabled ->
set(enabled)
val toggleReminders: (Boolean) -> Unit = { enabled ->
viewModel.setRemindersEnabled(enabled)
val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
!context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
@@ -92,24 +78,14 @@ internal fun NotificationsScreen(
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
val toggleReminders = askingPermission(viewModel::setRemindersEnabled)
val toggleDigest = askingPermission(viewModel::setDailyDigestEnabled)
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var showFixedDefault by remember { mutableStateOf(false) }
var fixedOverrideFor by remember { mutableStateOf<Long?>(null) }
var showDigestDay by remember { mutableStateOf(false) }
var showDigestTime by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
val scope = rememberCoroutineScope()
val openCalendarSound: (Long) -> Unit = { calendarId ->
scope.launch { context.openNotificationSettings(viewModel.reminderChannelFor(calendarId)) }
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
@@ -124,30 +100,12 @@ internal fun NotificationsScreen(
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = stringResource(R.string.settings_notification_sound_hint),
position = Position.Middle,
trailing = { ExternalLinkIcon() },
onClick = {
scope.launch {
viewModel.syncReminderChannels()
context.openNotificationSettings()
}
},
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.defaultReminderMinutes),
position = Position.Middle,
onClick = { showDefaultReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_fixed_reminder),
summary = fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
position = Position.Middle,
onClick = { showFixedDefault = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
@@ -164,17 +122,6 @@ internal fun NotificationsScreen(
onClick = { showAllDayReminderTime = true },
)
Spacer(Modifier.height(24.dp))
DailyDigestRows(
digest = state.dailyDigest,
onEnable = toggleDigest,
onPickDay = { showDigestDay = true },
onPickTime = { showDigestTime = true },
onOpenSound = {
scope.launch { viewModel.digestChannel()?.let { context.openNotificationSettings(it) } }
},
)
// Reliability is a soft, optional battery-optimisation exemption via
// a system-settings deep-link, shown as live status.
Spacer(Modifier.height(24.dp))
@@ -202,10 +149,10 @@ internal fun NotificationsScreen(
onClick = { showSnooze = true },
)
// Per-calendar settings, folded behind one header: every switched-on
// calendar gets its notification switch and sound (#145, #272); the ones
// you can edit also keep, drop or replace the default reminders.
if (state.reminderCalendars.isNotEmpty()) {
// Per-calendar overrides, folded behind one header. Each writable
// calendar may keep, drop or replace the global default, separately for
// timed and all-day events.
if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_calendar_reminders_title),
@@ -220,13 +167,30 @@ internal fun NotificationsScreen(
exit = collapseExit(),
) {
Column {
state.reminderCalendars.forEach { calendar ->
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
// Special-dates calendars own their reminders in their
// own section — link there instead.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
return@forEach
}
val expanded = calendar.id in expandedCalendars
val notifying = calendar.id !in state.mutedReminderCalendarIds
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = if (notifying) null else stringResource(R.string.settings_calendar_notifications_off),
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = { ExpandIcon(expanded) },
@@ -243,17 +207,22 @@ internal fun NotificationsScreen(
enter = expandEnter(),
exit = collapseExit(),
) {
CalendarNotificationRows(
calendar = calendar,
state = state,
notifying = notifying,
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
onOpenSound = { openCalendarSound(calendar.id) },
onOpenSpecialDates = onOpenSpecialDates,
onEditOverride = { overrideDialog = it },
onEditFixedOverride = { fixedOverrideFor = calendar.id },
onSetInDigest = { viewModel.setCalendarInDailyDigest(calendar.id, it) },
)
Column {
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
)
}
}
}
}
@@ -261,44 +230,6 @@ internal fun NotificationsScreen(
}
}
if (showFixedDefault) {
FixedTimeReminderPicker(
title = stringResource(R.string.settings_fixed_reminder),
selected = state.defaultFixedTimeReminder?.let { FixedTimeOverride.At(it) } ?: FixedTimeOverride.Off,
onSelect = { viewModel.setDefaultFixedTimeReminder((it as? FixedTimeOverride.At)?.reminder) },
onDismiss = { showFixedDefault = false },
)
}
fixedOverrideFor?.let { calendarId ->
FixedTimeReminderPicker(
title = stringResource(R.string.settings_fixed_reminder),
selected = state.perCalendarFixedTimeReminderOverride.fixedTimeOverrideFor(calendarId),
onSelect = { viewModel.setCalendarFixedTimeReminderOverride(calendarId, it) },
onDismiss = { fixedOverrideFor = null },
inheritedLabel = fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
inherited = state.defaultFixedTimeReminder,
)
}
if (showDigestDay) {
OptionPicker(
title = stringResource(R.string.settings_digest_day),
options = DigestDay.entries,
selected = state.dailyDigest.day,
label = { stringResource(digestDayLabel(it)) },
onSelect = viewModel::setDailyDigestDay,
onDismiss = { showDigestDay = false },
)
}
if (showDigestTime) {
TimePickerAlert(
initial = LocalTime(state.dailyDigest.timeMinutes / 60, state.dailyDigest.timeMinutes % 60),
onConfirm = {
viewModel.setDailyDigestTimeMinutes(it.hour * 60 + it.minute)
showDigestTime = false
},
onDismiss = { showDigestTime = false },
)
}
if (showSnooze) {
DurationPicker(
title = stringResource(R.string.settings_snooze_duration),
@@ -378,167 +309,6 @@ internal fun NotificationsScreen(
}
}
/**
* One calendar's expanded rows: its notification switch and channel deep-link
* for every calendar, then the default-reminder overrides where it takes them —
* or, for a special-dates calendar, a link to the section that owns them.
*/
@Composable
private fun CalendarNotificationRows(
calendar: CalendarSource,
state: SettingsUiState,
notifying: Boolean,
onSetNotifying: (Boolean) -> Unit,
onOpenSound: () -> Unit,
onOpenSpecialDates: () -> Unit,
onEditOverride: (OverrideTarget) -> Unit,
onEditFixedOverride: () -> Unit,
onSetInDigest: (Boolean) -> Unit,
) {
val managed = calendar.id in state.managedCalendarIds
val takesOverrides = calendar.canModifyContents && !managed
val digestOn = state.dailyDigest.enabled
Column {
GroupedSwitchRow(
title = stringResource(R.string.settings_calendar_notifications),
checked = notifying,
onCheckedChange = onSetNotifying,
summary = stringResource(R.string.settings_calendar_notifications_hint),
position = Position.Middle,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = stringResource(R.string.settings_calendar_notification_sound_hint),
position = if (managed || takesOverrides || digestOn) Position.Middle else Position.Bottom,
trailing = { ExternalLinkIcon() },
onClick = onOpenSound,
)
if (digestOn) {
GroupedSwitchRow(
title = stringResource(R.string.settings_calendar_in_digest),
checked = calendar.id !in state.dailyDigest.excludedCalendarIds,
onCheckedChange = onSetInDigest,
summary = stringResource(R.string.settings_calendar_in_digest_hint),
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
)
}
if (managed) {
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Bottom,
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
} else if (takesOverrides) {
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = false)) },
)
GroupedRow(
title = stringResource(R.string.settings_fixed_reminder),
summary = when (val fixed = state.perCalendarFixedTimeReminderOverride.fixedTimeOverrideFor(calendar.id)) {
FixedTimeOverride.Inherit -> stringResource(
R.string.settings_calendar_reminder_inherits,
fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
)
FixedTimeOverride.Off -> stringResource(R.string.reminder_none)
is FixedTimeOverride.At -> fixedTimeReminderLabel(fixed.reminder)
},
position = Position.Middle,
onClick = onEditFixedOverride,
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = true)) },
)
}
}
}
/**
* The daily summary (#459): its switch, and once on, which day it lists, when,
* and its channel's system settings.
*/
@Composable
private fun DailyDigestRows(
digest: DailyDigestSettings,
onEnable: (Boolean) -> Unit,
onPickDay: () -> Unit,
onPickTime: () -> Unit,
onOpenSound: () -> Unit,
) {
GroupedSwitchRow(
title = stringResource(R.string.settings_digest),
checked = digest.enabled,
onCheckedChange = onEnable,
summary = if (digest.enabled) {
stringResource(
R.string.settings_digest_summary,
stringResource(digestDayLabel(digest.day)),
settingsTimeOfDay(digest.timeMinutes),
)
} else {
stringResource(R.string.settings_digest_hint)
},
position = if (digest.enabled) Position.Top else Position.Alone,
)
AnimatedVisibility(visible = digest.enabled, enter = expandEnter(), exit = collapseExit()) {
Column {
GroupedRow(
title = stringResource(R.string.settings_digest_day),
summary = stringResource(digestDayLabel(digest.day)),
position = Position.Middle,
onClick = onPickDay,
)
GroupedRow(
title = stringResource(R.string.settings_digest_time),
summary = settingsTimeOfDay(digest.timeMinutes),
position = Position.Middle,
onClick = onPickTime,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = stringResource(R.string.settings_digest_sound_hint),
position = Position.Bottom,
trailing = { ExternalLinkIcon() },
onClick = onOpenSound,
)
}
}
}
private fun digestDayLabel(day: DigestDay): Int = when (day) {
DigestDay.Today -> R.string.settings_digest_day_today
DigestDay.Tomorrow -> R.string.settings_digest_day_tomorrow
}
/** A fixed-time default's label, or "None". */
@Composable
private fun fixedReminderChoiceLabel(reminder: FixedTimeReminder?): String =
reminder?.let { fixedTimeReminderLabel(it) } ?: stringResource(R.string.reminder_none)
@Composable
private fun ExternalLinkIcon() {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -98,18 +96,8 @@ data class SettingsUiState(
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** The "N days before at HH:MM" default for new timed events (#459); null = none. */
val defaultFixedTimeReminder: FixedTimeReminder? = null,
/** Per-calendar overrides of [defaultFixedTimeReminder]: absent inherits, null turns it off. */
val perCalendarFixedTimeReminderOverride: Map<Long, FixedTimeReminder?> = emptyMap(),
/** The daily summary notification (#459). */
val dailyDigest: DailyDigestSettings = DailyDigestSettings(),
/** Switched-on calendars you can edit: the per-calendar duration and reminder-override rows. */
/** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
val reminderCalendars: List<CalendarSource> = emptyList(),
/** Calendars whose reminders Calendula doesn't post (#272). */
val mutedReminderCalendarIds: Set<Long> = emptySet(),
/**
* Whether the event-colour picker is offered on calendars that publish no
* colour palette (the colour may then not survive their next sync).
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -19,12 +18,6 @@ import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.data.prefs.dailyDigest
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.reminders.DailyDigestNotifier
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
@@ -40,7 +33,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -53,7 +45,6 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -76,9 +67,6 @@ class SettingsViewModel @Inject constructor(
specialDatesSpec: SpecialDatesCalendarSpec,
private val launcherNameManager: LauncherNameManager,
private val reminderScanner: ReminderScanner,
private val reminderChannels: ReminderChannels,
private val digestNotifier: DailyDigestNotifier,
private val reminderState: ReminderStatePrefs,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -86,15 +74,16 @@ class SettingsViewModel @Inject constructor(
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/**
* Calendars that are switched on — the only ones that plan reminders at
* all (#75), so the only ones with notification settings.
* Writable calendars that are switched on — the only ones that take a
* per-calendar reminder override, since a switched-off one plans no
* reminders at all (#75).
*/
private val visibleCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.isVisibleInSystem } }
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(prefs.data, visibleCalendars) { stored, calendars -> stored.toUiState(calendars) }
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
@@ -136,12 +125,7 @@ class SettingsViewModel @Inject constructor(
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
defaultFixedTimeReminder = this[SettingsKeys.defaultFixedTimeReminder],
perCalendarFixedTimeReminderOverride = this[SettingsKeys.perCalendarFixedTimeReminderOverride],
dailyDigest = dailyDigest,
writableCalendars = calendars.filter { it.canModifyContents },
reminderCalendars = calendars,
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
writableCalendars = calendars,
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
managedCalendarIds = specialDatesCalendars.values.toSet(),
)
@@ -366,28 +350,10 @@ class SettingsViewModel @Inject constructor(
* A scan follows the write: switching reminders off cancels the scan alarm,
* so switching them back on has to arm a new one (#75).
*/
fun setRemindersEnabled(enabled: Boolean) = saveAndScan { setRemindersEnabled(enabled) }
/** Re-plans, since a muted calendar armed no wake-up for its next reminder. */
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) =
saveAndScan { setCalendarRemindersEnabled(calendarId, enabled) }
/** [calendarId]'s reminder channel, created if needed so its system settings can open (#340). */
suspend fun reminderChannelFor(calendarId: Long): String? = onChannels { channelFor(calendarId) }
/** Create the per-calendar channels before showing the app's channel list, even with reminders off. */
suspend fun syncReminderChannels() {
onChannels { sync() }
}
private suspend fun <T> onChannels(block: ReminderChannels.() -> T): T? = withContext(io) {
try {
reminderChannels.block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("SettingsViewModel", "Reminder channel update failed", e)
null
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch {
prefs.setRemindersEnabled(enabled)
reminderScanner.scan()
}
}
@@ -403,7 +369,12 @@ class SettingsViewModel @Inject constructor(
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = save { setDefaultAllDayReminderMinutes(minutes) }
/** The armed alarm was planned for the old hour, so re-plan against the new one. */
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) = saveAndScan { setAllDayReminderTimeMinutes(minutesOfDay) }
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
viewModelScope.launch {
prefs.setAllDayReminderTimeMinutes(minutesOfDay)
reminderScanner.scan()
}
}
fun setSnoozeMinutes(minutes: Int) = save { setSnoozeMinutes(minutes) }
@@ -413,47 +384,6 @@ class SettingsViewModel @Inject constructor(
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) = save { setAllowColorOnUnsupportedCalendars(enabled) }
fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) = save { setDefaultFixedTimeReminder(reminder) }
fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) =
save { setCalendarFixedTimeReminderOverride(calendarId, override) }
/** Every summary change re-plans, so the next one is armed for its new moment (#459). */
fun setDailyDigestEnabled(enabled: Boolean) {
viewModelScope.launch {
prefs.setDailyDigestEnabled(enabled)
// After the scan, so one already under way can't post past the cancel;
// a summary left showing would go stale with nothing to replace it.
reminderScanner.scan()
if (!enabled) digestNotifier.cancel()
}
}
fun setDailyDigestTimeMinutes(minutesOfDay: Int) = saveAndScan { setDailyDigestTimeMinutes(minutesOfDay) }
/** A new mode lists afresh: from "tomorrow" to "today", the day just listed comes again. */
fun setDailyDigestDay(day: DigestDay) {
viewModelScope.launch {
prefs.setDailyDigestDay(day)
reminderState.clearLastDigestDay()
reminderScanner.scan()
}
}
fun setCalendarInDailyDigest(calendarId: Long, included: Boolean) = save { setCalendarInDailyDigest(calendarId, included) }
/** The summary's channel, created if needed so its system settings can open. */
suspend fun digestChannel(): String? = withContext(io) {
runCatching { digestNotifier.channelId() }.getOrNull()
}
private fun saveAndScan(write: suspend SettingsPrefs.() -> Unit) {
viewModelScope.launch {
prefs.write()
reminderScanner.scan()
}
}
private fun save(write: suspend SettingsPrefs.() -> Unit) {
viewModelScope.launch { prefs.write() }
}
+1 -44
View File
@@ -731,51 +731,8 @@
<string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
<string name="settings_calendar_reminders_hint">Turn notifications off or pick a sound per calendar, and override the default reminder for calendars you can edit.</string>
<string name="settings_calendar_notifications">Notifications</string>
<string name="settings_calendar_notifications_hint">Notify at this calendar\'s reminder times</string>
<string name="settings_calendar_notifications_off">Notifications off</string>
<string name="settings_notification_sound">Notification sound</string>
<string name="settings_notification_sound_hint">Sound, vibration and pop-ups for each calendar, in system settings</string>
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<!-- Reminders at a set time (#459). %1$s is a lead in days ("1 day before"), %2$s a clock time. -->
<string name="reminder_fixed_time">%1$s at %2$s</string>
<string name="reminder_fixed_at_set_time">At a set time</string>
<!-- Read as one sentence with a number field before it and a time after it: "[2] days before, at [20:00]". The full wording shows below it. -->
<string name="reminder_fixed_days_before_at">days before, at</string>
<string name="reminder_fixed_days_range">Pick 1 to %1$d days</string>
<string name="settings_fixed_reminder">Reminder at a set time</string>
<string name="event_edit_reminder_fixed_summary">E.g. the evening before</string>
<string name="event_edit_reminder_fixed_hint">Fires at this time however early or late the event starts, and moves along when you change the event\'s time.</string>
<string name="settings_fixed_reminder_hint">Added to new timed events, e.g. the evening before. It follows the event when you move it to another time.</string>
<!-- Daily summary notification (#459) -->
<string name="settings_digest">Daily summary</string>
<string name="settings_digest_hint">One notification listing a day\'s events</string>
<!-- %1$s is settings_digest_day_today/tomorrow, %2$s a clock time. -->
<string name="settings_digest_summary">%1$s at %2$s</string>
<string name="settings_digest_day">Lists</string>
<string name="settings_digest_day_today">Today\'s events</string>
<string name="settings_digest_day_tomorrow">Tomorrow\'s events</string>
<string name="settings_digest_time">Time</string>
<string name="settings_digest_sound_hint">Sound and pop-up for the summary, in system settings</string>
<string name="settings_calendar_in_digest">In daily summary</string>
<string name="settings_calendar_in_digest_hint">List this calendar\'s events in the daily summary</string>
<string name="digest_channel_name">Daily summary</string>
<string name="digest_channel_description">One notification listing a day\'s events</string>
<!-- %1$s is "Today", "Tomorrow" or a date; %2$d the number of events. -->
<plurals name="digest_title">
<item quantity="one">%1$s: %2$d event</item>
<item quantity="other">%1$s: %2$d events</item>
</plurals>
<!-- %1$s is "Today" or "Tomorrow", %2$s a short date, e.g. "Fri, 14 Nov". -->
<string name="digest_day_relative">%1$s, %2$s</string>
<!-- One event in the summary: %1$s its time ("09:00 – 10:00" or "All day"), %2$s its title. -->
<string name="digest_line">%1$s · %2$s</string>
<plurals name="digest_more">
<item quantity="one">+%d more</item>
<item quantity="other">+%d more</item>
</plurals>
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
@@ -10,9 +10,6 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -604,19 +601,6 @@ class SettingsPrefsTest {
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
}
@Test
fun `calendar notifications default on and toggle per calendar`(
@TempDir tempDir: Path,
) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.mutedReminderCalendarIds.first()).isEmpty()
prefs.setCalendarRemindersEnabled(7L, false)
prefs.setCalendarRemindersEnabled(9L, false)
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(7L, 9L)
prefs.setCalendarRemindersEnabled(7L, true)
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(9L)
}
@Test
fun `garbage per-calendar duration entries are dropped, the rest survive`(
@TempDir tempDir: Path,
@@ -721,62 +705,4 @@ class SettingsPrefsTest {
prefs.recordSpecialDatesRun(2000L, null)
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
}
@Test
fun `fixed-time reminder defaults to off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.defaultFixedTimeReminder.first()).isNull()
val evening = FixedTimeReminder(1, 1_200)
prefs.setDefaultFixedTimeReminder(evening)
assertThat(prefs.defaultFixedTimeReminder.first()).isEqualTo(evening)
prefs.setDefaultFixedTimeReminder(null)
assertThat(prefs.defaultFixedTimeReminder.first()).isNull()
}
@Test
fun `per-calendar fixed-time overrides keep inherit, off and a value apart`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
val morning = FixedTimeReminder(2, 480)
prefs.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200))
prefs.setCalendarFixedTimeReminderOverride(1L, FixedTimeOverride.Off)
prefs.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.At(morning))
val overrides = prefs.perCalendarFixedTimeReminderOverride.first()
assertThat(overrides.fixedTimeOverrideFor(1L)).isEqualTo(FixedTimeOverride.Off)
assertThat(overrides.fixedTimeOverrideFor(2L)).isEqualTo(FixedTimeOverride.At(morning))
assertThat(overrides.fixedTimeOverrideFor(3L)).isEqualTo(FixedTimeOverride.Inherit)
assertThat(resolveDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200), overrides, 1L)).isNull()
assertThat(resolveDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200), overrides, 3L))
.isEqualTo(FixedTimeReminder(1, 1_200))
assertThat(prefs.knownFixedTimeReminders.first()).containsExactly(FixedTimeReminder(1, 1_200), morning)
val picked = FixedTimeReminder(3, 600)
prefs.rememberFixedTimeReminders(listOf(picked, morning))
assertThat(prefs.knownFixedTimeReminders.first()).contains(picked)
// A default already known takes no recents slot.
assertThat(prefs.knownFixedTimeReminders.first()).hasSize(3)
// A replaced default stays known.
prefs.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_140))
assertThat(prefs.knownFixedTimeReminders.first()).contains(FixedTimeReminder(1, 1_200))
prefs.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.Inherit)
assertThat(prefs.perCalendarFixedTimeReminderOverride.first().containsKey(2L)).isFalse()
}
@Test
fun `daily summary is off by default and its settings round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.dailyDigest.first()).isEqualTo(DailyDigestSettings())
prefs.setDailyDigestEnabled(true)
prefs.setDailyDigestTimeMinutes(420)
prefs.setDailyDigestDay(DigestDay.Today)
prefs.setCalendarInDailyDigest(5L, included = false)
val digest = prefs.dailyDigest.first()
assertThat(digest.copy(armedAtMillis = 0L)).isEqualTo(
DailyDigestSettings(enabled = true, timeMinutes = 420, day = DigestDay.Today, excludedCalendarIds = setOf(5L)),
)
assertThat(digest.armedAtMillis).isGreaterThan(0L)
prefs.setCalendarInDailyDigest(5L, included = true)
assertThat(prefs.dailyDigest.first().excludedCalendarIds).isEmpty()
}
}
@@ -1,133 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import org.junit.jupiter.api.Test
class ReminderChannelPlanTest {
private fun calendar(id: Long, account: String, type: String, local: Boolean = false) = CalendarSource(
id = id,
displayName = "Cal $id",
accountName = account,
accountType = type,
color = 0,
isVisibleInSystem = true,
isLocal = local,
)
@Test
fun `one channel per calendar, grouped by account`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "me@example.org", "com.google"),
calendar(2, "me@example.org", "com.google"),
calendar(3, "dav", "bitfire.at.davdroid"),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups).hasSize(2)
assertThat(plan.channels.map { it.id })
.containsExactly(reminderChannelId(1), reminderChannelId(2), reminderChannelId(3))
assertThat(plan.channels[0].groupId).isEqualTo(plan.channels[1].groupId)
assertThat(plan.channels[2].groupId).isNotEqualTo(plan.channels[0].groupId)
assertThat(plan.staleChannelIds).isEmpty()
}
@Test
fun `device calendars share one group`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "Calendula", "LOCAL", local = true),
calendar(2, "Other", "LOCAL", local = true),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups).hasSize(1)
assertThat(plan.groups.single().isLocal).isTrue()
}
@Test
fun `same account name under two types is ambiguous`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "me@example.org", "com.google"),
calendar(2, "me@example.org", "bitfire.at.davdroid"),
calendar(3, "work", "bitfire.at.davdroid"),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups.filter { it.ambiguous }.map { it.accountType })
.containsExactly("com.google", "bitfire.at.davdroid")
assertThat(plan.groups.single { it.label == "work" }.ambiguous).isFalse()
}
@Test
fun `stale reminder channels and groups go, foreign ones stay`() {
val current = calendar(1, "me@example.org", "com.google")
fun plan(calendars: List<CalendarSource>, existing: List<String> = emptyList(), groups: List<String> = emptyList()) =
planReminderChannels(calendars, existing.map { ExistingReminderChannel(it, "Cal ${it.last()}", DESC, null) }, groups, "Unnamed", DESC)
val keptGroup = plan(listOf(current)).groups.single().id
val goneGroup = plan(listOf(calendar(9, "old", "com.google"))).groups.single().id
val result = plan(
calendars = listOf(current),
existing = listOf(reminderChannelId(1), reminderChannelId(9), LEGACY_REMINDER_CHANNEL_ID, "backup"),
groups = listOf(keptGroup, goneGroup, "other"),
)
assertThat(result.staleChannelIds).containsExactly(reminderChannelId(9))
assertThat(result.staleGroupIds).containsExactly(goneGroup)
}
@Test
fun `only missing or renamed channels are written`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "a", "com.google"),
calendar(2, "a", "com.google"),
calendar(3, "a", "com.google").copy(displayName = ""),
calendar(4, "a", "com.google"),
),
existing = listOf(
ExistingReminderChannel(reminderChannelId(1), "Cal 1", DESC, "g"),
ExistingReminderChannel(reminderChannelId(4), "Cal 4", "Old description", "g"),
ExistingReminderChannel(reminderChannelId(2), "Old name", DESC, "g"),
),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.channelsToWrite.map { it.calendarId }).containsExactly(2L, 3L, 4L)
assertThat(plan.channelsToWrite.single { it.calendarId == 3L }.name).isEqualTo("Unnamed")
}
@Test
fun `non-syncing calendars keep a channel but get none up front`() {
val quiet = calendar(2, "a", "com.google").copy(syncsEvents = false)
val fresh = planReminderChannels(listOf(quiet), emptyList(), emptyList(), "Unnamed", DESC)
assertThat(fresh.channelsToWrite).isEmpty()
assertThat(fresh.groupsInUse).isEmpty()
val posted = planReminderChannels(
calendars = listOf(quiet),
existing = listOf(ExistingReminderChannel(reminderChannelId(2), "Cal 2", DESC, null)),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(posted.staleChannelIds).isEmpty()
assertThat(posted.channelsToWrite.map { it.calendarId }).containsExactly(2L)
assertThat(posted.groupsInUse).hasSize(1)
}
private companion object {
const val DESC = "Reminders"
}
}
@@ -1,47 +0,0 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
class FixedTimeReminderFormTest {
private val zone = TimeZone.of("Europe/Berlin")
private val evening = FixedTimeReminder(1, 20 * 60)
private fun form(start: String, reminders: List<Int> = emptyList(), allDay: Boolean = false) = EventForm(
calendarId = 1,
isAllDay = allDay,
start = FormTime(LocalDateTime.parse(start)),
end = FormTime(LocalDateTime.parse(start)),
reminders = reminders,
)
@Test
fun `a moved start re-derives the offset so the wall clock stays`() {
val loaded = form("2026-06-10T10:00", reminders = listOf(10, 840))
.withFixedTimeRemindersRead(known = setOf(evening), deviceZone = zone)
assertThat(loaded.reminders).containsExactly(10)
assertThat(loaded.fixedReminders).containsExactly(evening)
assertThat(loaded.fixedReminderOffsets(zone)).containsExactly(840)
val moved = loaded.copy(start = FormTime(LocalDateTime.parse("2026-06-10T15:30")))
assertThat(moved.fixedReminderOffsets(zone)).containsExactly(19 * 60 + 30)
}
@Test
fun `all-day forms keep their reminders and store no fixed ones`() {
val allDay = form("2026-06-10T00:00", reminders = listOf(840), allDay = true)
assertThat(allDay.withFixedTimeRemindersRead(setOf(evening), zone)).isEqualTo(allDay)
assertThat(allDay.copy(fixedReminders = listOf(evening)).fixedReminderOffsets(zone)).isEmpty()
}
@Test
fun `fixed reminders count as a populated reminders section on a timed event only`() {
val withFixed = form("2026-06-10T10:00").copy(fixedReminders = listOf(evening))
assertThat(withFixed.populatedFields()).contains(EventFormField.Reminders)
assertThat(withFixed.copy(isAllDay = true).populatedFields()).doesNotContain(EventFormField.Reminders)
}
}
@@ -1,89 +0,0 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
class DailyDigestTest {
private val zone = ZoneId.of("Europe/Berlin")
private val minute = 60_000L
private val eight = 20 * 60
private fun millis(text: String): Long = LocalDateTime.parse(text).atZone(zone).toInstant().toEpochMilli()
@Test
fun `due once the moment passes the watermark`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T19:59"), millis("2026-06-10T20:00"))
assertThat(schedule.dueDay).isEqualTo(LocalDate.of(2026, 6, 11))
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-11T20:00"))
}
@Test
fun `a second scan does not post again`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T20:00"), millis("2026-06-10T20:05"))
assertThat(schedule.dueDay).isNull()
}
@Test
fun `a day already listed is not listed again`() {
// Posted at 20:00, then the time moved to 21:00 the same evening.
val schedule = scheduleDigest(
21 * 60, DigestDay.Tomorrow, zone, millis("2026-06-10T20:30"), millis("2026-06-10T21:00"),
lastListedDay = LocalDate.of(2026, 6, 11),
)
assertThat(schedule.dueDay).isNull()
}
@Test
fun `before the moment it only arms the alarm`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T08:00"), millis("2026-06-10T09:00"))
assertThat(schedule.dueDay).isNull()
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-10T20:00"))
}
@Test
fun `a late catch-up still lists a day that has not passed`() {
// Off overnight: last scan before 20:00, back on the next morning.
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T18:00"), millis("2026-06-11T07:00"))
assertThat(schedule.dueDay).isEqualTo(LocalDate.of(2026, 6, 11))
}
@Test
fun `a today summary missed until the next day is dropped`() {
val seven = 7 * 60
val schedule = scheduleDigest(seven, DigestDay.Today, zone, millis("2026-06-10T06:00"), millis("2026-06-11T06:30"))
assertThat(schedule.dueDay).isNull()
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-11T07:00"))
}
@Test
fun `entries list all-day first, keep the day's own, and skip excluded calendars`() {
val day = LocalDate.of(2026, 6, 11)
val utcMidnight = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val allDay = instance(1, utcMidnight, utcMidnight + 24 * 60 * minute, isAllDay = true)
val previousAllDay = instance(2, utcMidnight - 24 * 60 * minute, utcMidnight, isAllDay = true)
val morning = instance(3, millis("2026-06-11T09:00"), millis("2026-06-11T10:00"))
val early = instance(4, millis("2026-06-11T07:00"), millis("2026-06-11T08:00"))
val nextDay = instance(5, millis("2026-06-12T00:00"), millis("2026-06-12T01:00"))
val excluded = instance(6, millis("2026-06-11T12:00"), millis("2026-06-11T13:00"), calendarId = 9)
val entries = digestEntries(
listOf(morning, previousAllDay, nextDay, early, allDay, excluded),
day,
zone,
excludedCalendarIds = setOf(9),
)
assertThat(entries.map { it.eventId }).containsExactly(1L, 4L, 3L).inOrder()
}
private fun instance(
eventId: Long,
begin: Long,
end: Long,
isAllDay: Boolean = false,
calendarId: Long = 1,
) = ReminderEventInstance(eventId, calendarId, begin, end, "Event $eventId", null, isAllDay)
}
@@ -1,100 +0,0 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class FixedTimeReminderTest {
private val berlin = TimeZone.of("Europe/Berlin")
private val eveningBefore = FixedTimeReminder(daysBefore = 1, minuteOfDay = 20 * 60)
private fun at(text: String, zone: TimeZone = berlin): Instant = LocalDateTime.parse(text).toInstant(zone)
@Test
fun `offset is the distance from the named wall clock to the start`() {
assertThat(eveningBefore.offsetMinutes(at("2026-06-10T10:00"), berlin)).isEqualTo(14 * 60)
assertThat(eveningBefore.offsetMinutes(at("2026-06-10T21:30"), berlin)).isEqualTo(25 * 60 + 30)
}
@Test
fun `offset spans a DST change in real minutes`() {
// Clocks go forward on 29 March 2026: 20:00 on the 28th to 10:00 on the 29th is 13 hours.
assertThat(eveningBefore.offsetMinutes(at("2026-03-29T10:00"), berlin)).isEqualTo(13 * 60)
}
@Test
fun `a series offset counts clock minutes, so a DST-side occurrence bakes in no hour`() {
// Opened on the Sunday clocks go back: the real span is 15 hours, the clock face says 14.
val start = at("2026-10-25T10:00")
assertThat(eveningBefore.offsetMinutes(start, berlin)).isEqualTo(15 * 60)
val offset = eveningBefore.offsetMinutes(start, berlin, wallClock = true)
assertThat(offset).isEqualTo(14 * 60)
// Read back the same way on any occurrence of the series.
assertThat(fixedTimeReminderAt(offset, at("2026-11-01T10:00"), berlin, wallClock = true)).isEqualTo(eveningBefore)
assertThat(fixedTimeReminderAt(offset, start, berlin, wallClock = true)).isEqualTo(eveningBefore)
}
@Test
fun `an offset reads back as the pattern it was written from`() {
val start = at("2026-03-29T10:00")
val offset = eveningBefore.offsetMinutes(start, berlin)
assertThat(fixedTimeReminderAt(offset, start, berlin)).isEqualTo(eveningBefore)
}
@Test
fun `a reminder on the start's own day or after it is no pattern`() {
val start = at("2026-06-10T10:00")
assertThat(fixedTimeReminderAt(60, start, berlin)).isNull()
assertThat(fixedTimeReminderAt(0, start, berlin)).isNull()
assertThat(fixedTimeReminderAt(-30, start, berlin)).isNull()
}
@Test
fun `only known patterns read as fixed-time`() {
val start = at("2026-06-10T10:00")
// 840 is "1 day before at 20:00" here, but also a plain "14 hours before".
val unknown = splitTimedReminders(listOf(10, 1_440, 840), start, berlin, known = emptySet())
assertThat(unknown.leadMinutes).containsExactly(10, 840, 1_440).inOrder()
assertThat(unknown.fixed).isEmpty()
val known = splitTimedReminders(listOf(10, 1_440, 840), start, berlin, known = setOf(eveningBefore))
assertThat(known.leadMinutes).containsExactly(10, 1_440).inOrder()
assertThat(known.fixed).containsExactly(eveningBefore)
}
@Test
fun `a whole-day offset landing on a known pattern is fixed-time`() {
// An event at 20:00 with "1 day before at 20:00" stores exactly a day.
val start = at("2026-06-10T20:00")
assertThat(splitTimedReminders(listOf(1_440), start, berlin, known = emptySet()).fixed).isEmpty()
val split = splitTimedReminders(listOf(1_440), start, berlin, known = setOf(eveningBefore))
assertThat(split.fixed).containsExactly(eveningBefore)
assertThat(split.leadMinutes).isEmpty()
}
@Test
fun `fixed reminders sort by when they fire`() {
val start = at("2026-06-10T10:00")
val twoDays = FixedTimeReminder(2, 9 * 60)
val split = splitTimedReminders(
listOf(eveningBefore.offsetMinutes(start, berlin), twoDays.offsetMinutes(start, berlin)),
start,
berlin,
known = setOf(eveningBefore, twoDays),
)
assertThat(split.fixed).containsExactly(twoDays, eveningBefore).inOrder()
}
@Test
fun `token round-trips and rejects garbage`() {
assertThat(FixedTimeReminder.parse(eveningBefore.toToken())).isEqualTo(eveningBefore)
assertThat(FixedTimeReminder.parse("0@600")).isNull()
assertThat(FixedTimeReminder.parse("1@1440")).isNull()
assertThat(FixedTimeReminder.parse("x")).isNull()
assertThat(FixedTimeReminder.parse(null)).isNull()
}
}
@@ -92,13 +92,12 @@ class RescheduleViewModelTest {
io: CoroutineDispatcher = dispatcher,
): RescheduleViewModel {
fake.occurrencesAt(present)
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
val repo = testRepository(
fake, tempDir, io,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = settings,
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
return RescheduleViewModel(repo, io, FakeZoneSource(), settings)
return RescheduleViewModel(repo, io, FakeZoneSource())
}
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
@@ -51,14 +51,13 @@ class EventDetailViewModelTest {
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
val repo = testRepository(
fake, tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = settings,
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource(), settings)
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource())
}
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
@@ -9,11 +9,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
@@ -723,121 +719,4 @@ class EventEditViewModelTest {
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Idle)
job.cancel()
}
@Test
fun `a new timed event gets the fixed-time default, and a calendar can turn it off`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
val evening = FixedTimeReminder(1, 1_200)
s.setDefaultFixedTimeReminder(evening)
s.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.Off)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 600)
advanceUntilIdle()
assertThat(vm.state.value!!.form!!.fixedReminders).containsExactly(evening)
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value!!.form!!.fixedReminders).isEmpty()
job.cancel()
}
@Test
fun `an event's set-time reminder opens as one and saves without a conflict`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val zone = FakeZoneSource().current()
val start = Instant.fromEpochMilliseconds(beginMillis)
val evening = FixedTimeReminder(1, 1_200)
val offset = evening.offsetMinutes(start, zone)
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = {
detail(calendarId = 1L).copy(reminders = listOf(Reminder(offset, ReminderMethod.Alert)))
}
}
val s = settings(tempDir)
s.setDefaultFixedTimeReminder(evening)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.fixedReminders).containsExactly(evening)
assertThat(form.reminders).isEmpty()
assertThat(vm.hasUnsavedChanges.value).isFalse()
vm.setTitle("Renamed")
vm.save()
advanceUntilIdle()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
assertThat(fake.updatedEvents.single().third.fixedReminders).containsExactly(evening)
job.cancel()
}
@Test
fun `a round trip through all-day keeps set-time reminders`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200))
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 600)
advanceUntilIdle()
vm.addFixedReminder(FixedTimeReminder(2, 480))
vm.setAllDay(true)
vm.setAllDay(false)
advanceUntilIdle()
assertThat(vm.state.value!!.form!!.fixedReminders)
.containsExactly(FixedTimeReminder(2, 480), FixedTimeReminder(1, 1_200))
job.cancel()
}
@Test
fun `a set-time reminder replaces a lead firing at the same moment`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setDefaultReminderMinutes(listOf(1_440, 10))
val vm = viewModel(tempDir, fake, s, zones = FakeZoneSource(berlin))
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 20 * 60)
advanceUntilIdle()
vm.addFixedReminder(FixedTimeReminder(1, 20 * 60))
val form = vm.state.value!!.form!!
assertThat(form.reminders).containsExactly(10)
assertThat(form.fixedReminders).containsExactly(FixedTimeReminder(1, 20 * 60))
job.cancel()
}
@Test
fun `a copy carrying only set-time reminders keeps them`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setDefaultReminderMinutes(listOf(15))
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
val copy = importedForm(end = LocalDateTime(2030, 1, 15, 10, 0))
.copy(reminders = emptyList(), fixedReminders = listOf(FixedTimeReminder(1, 1_200)))
vm.openImported(copy, ImportSource.Insert)
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.fixedReminders).containsExactly(FixedTimeReminder(1, 1_200))
assertThat(form.reminders).isEmpty()
job.cancel()
}
}
-58
View File
@@ -391,64 +391,6 @@ gate also covers a snooze re-shown from our own alarm after its calendar was
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders.
**A channel per calendar (#145).** `ReminderChannels` keeps one notification
channel per calendar, grouped by account (one that syncs no events only gets
its channel once something posts on it), so a calendar can be
muted or given its own sound in the system settings before it ever fires. Every
scan with reminders on syncs them (skipped when nothing changed): missing
channels are created, renamed ones updated, and channels of deleted calendars
removed — but never on an empty snapshot, which is a provider hiccup more often
than every calendar gone. Switched-off calendars keep theirs, so toggling one
never cancels a showing reminder or loses its settings. `post` creates a missing
channel itself, so a reminder never waits on a sync. A new channel copies the sound, vibration and
importance of the pre-#145 single `reminders` channel, which is deleted once
nothing showing still sits on it. Settings → Notifications only deep-links into
the system's channel screens (#340); the app never sets a sound itself. Separate
from both, `SettingsPrefs.mutedReminderCalendarIds` is the in-app per-calendar
off switch (#272): the scan plans nothing for those calendars (so they arm no
wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again
for snoozes.
**Reminders at a set time (#459).** "1 day before at 20:00" on a timed event
is an ordinary `Reminders` row whose offset is the distance from that wall-clock
moment (device zone) to the start, so it syncs and fires everywhere — the scan
plans it like any lead time. Only the editor knows it as a pattern
(`EventForm.fixedReminders`), and it re-derives the offset from the start it
saves with, so moving the event, in the form or by drag, keeps the wall clock.
Reading a row back is a guess, made in one place (`classifyTimedReminder`): an
offset is fixed-time only when it lands on a known pattern — a current or
replaced default, or one saved by hand on this device
(`recentFixedTimeReminders`, capped). Guessing from the offset's shape alone
would turn ordinary leads ("12 hours before") into fixed ones. Limits: where a
lead and a known pattern coincide ("1 day before" on an event at the default's
own time) the pattern wins, and a pattern that falls out of the recents reads as
a lead again — either way the alarm fires at the same moment until the start's
time of day changes. A recurring series stores one offset for all occurrences,
so it counts clock minutes rather than real ones (`wallClock`); the occurrence
whose lead spans a DST change then fires an hour off, as in every other app
reading that row. On another device or app the row is simply a lead time.
New timed events get the default (`SettingsKeys.defaultFixedTimeReminder`, with
a per-calendar inherit/off/value override) like the lead-time defaults.
**The daily summary (#459)** rides the same scan and watermark: its moment is
due when it falls in `(lastScan, now]` (`scheduleDigest`), only the latest one
counts after a long gap, and it is dropped once the day it lists has passed.
It is independent of the event-reminder switch, posts on its own channel
(`DailyDigestNotifier`), lists switched-on calendars minus its own exclusions,
and replaces the previous summary — or withdraws it when the day is empty.
Each day is listed once: the day is recorded as soon as it is posted
(`ReminderStatePrefs.lastDigestDay`), so neither a pass failing later nor a later
time picked the same evening lists it twice, and changing the switch, time or
day stamps `dailyDigestArmedAt`, so a moment that passed before the change is
not owed by it. A failing summary is logged and skipped; it never costs the
event reminders of the same pass, and a failing reminder pass still arms a retry
and the summary's next moment. Switching between "today's" and "tomorrow's"
events clears the day last listed, so the first summary of the new mode comes.
The notification names its date and times out when that day ends.
The per-calendar reminder mute does not apply: a calendar too noisy to ping per
event can still belong in the overview, and the summary has its own
per-calendar switch next to it.
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
Keeping both would double-post wherever the provider works, and Etar's way out —
a latch that disables its own scheduling once a real broadcast arrives — cannot