Compare commits

..
Author SHA1 Message Date
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
69 changed files with 474 additions and 3945 deletions

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
# directly drops the docker-in-docker requirement entirely.
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
container:
image: ghcr.io/renovatebot/renovate:44.237.3
image: ghcr.io/renovatebot/renovate:43.232.0
steps:
- name: Run Renovate
run: renovate
-12
View File
@@ -7,16 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.24.2] — 2026-11-13
### Fixed
- **Quick moves no longer get lost.** Dropping an event while an earlier move was still saving used to snap it back without a word. Drops now wait in line and are saved in order, and a moved event stays where you dropped it until the calendar shows it there. If an event changed before a drop could be saved, or the line is full, a message says so instead ([#494]).
- **Undo after a run of moves** keeps the first move's Undo, and each failed move gets its own message rather than replacing the last one ([#494]).
### Changed
- **The day view's title names the month**, like the week and 3-day views. Weekday and date already sit in the column header below it ([#501]).
- Library updates (kotlinx-datetime, AppCompat).
## [2.24.1] — 2026-11-12
### Added
@@ -1983,5 +1973,3 @@ automatically, with zero telemetry and no internet permission.
[#489]: https://codeberg.org/jlmakiola/calendula/issues/489
[#491]: https://codeberg.org/jlmakiola/calendula/issues/491
[#493]: https://codeberg.org/jlmakiola/calendula/issues/493
[#494]: https://codeberg.org/jlmakiola/calendula/issues/494
[#501]: https://codeberg.org/jlmakiola/calendula/issues/501
+1 -1
View File
@@ -7,4 +7,4 @@ source "https://rubygems.org"
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
# committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build.
gem "fastlane", "2.241.1"
gem "fastlane", "2.237.0"
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2240299
versionName = "2.24.2"
versionCode = 2240199
versionName = "2.24.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -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,8 +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 notificationCategoryHintDismissed: Flow<Boolean> = prefs.flow(SettingsKeys.notificationCategoryHintDismissed)
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
@@ -183,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)
@@ -294,12 +273,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
suspend fun dismissNotificationCategoryHint() = prefs.set(SettingsKeys.notificationCategoryHintDismissed, true)
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)
@@ -351,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) =
@@ -471,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
@@ -693,18 +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 the one-time hint that some skins (Samsung) start new notification
* categories switched off has been dismissed (#145).
*/
val notificationCategoryHintDismissed: Pref<Boolean> = booleanPref("notification_category_hint_dismissed", false)
/**
* 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
@@ -794,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)
@@ -1032,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,157 +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)
}
}
/** Whether the summary's own channel is switched off in the system settings. */
fun isChannelOff(): Boolean =
context.getSystemService(NotificationManager::class.java)
.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE
/** 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,161 +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"
/** A channel as far as its blocked state goes: switched off itself, and the group it sits in. */
data class ReminderChannelState(val blocked: Boolean, val groupId: String?)
/** Reminder notifications the system blocks, while Calendula would post them. */
data class ReminderChannelStatus(
val appBlocked: Boolean = false,
val blockedCalendarIds: Set<Long> = emptySet(),
/** The blocked ones whose whole account group is off, which only the app's own screen can undo. */
val groupBlockedCalendarIds: Set<Long> = emptySet(),
)
/**
* What the system blocks of [calendarIds]' reminders: all of them while the
* app's notifications are off, otherwise those whose channel, or the group it
* sits in, is switched off. A calendar without a channel yet isn't blocked.
*/
fun reminderChannelStatus(
calendarIds: Collection<Long>,
appEnabled: Boolean,
channels: Map<String, ReminderChannelState>,
blockedGroupIds: Set<String>,
): ReminderChannelStatus {
val groupBlocked = calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.groupId in blockedGroupIds }
val blocked = if (!appEnabled) {
calendarIds.toSet()
} else {
calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.blocked == true } + groupBlocked
}
return ReminderChannelStatus(appBlocked = !appEnabled, blockedCalendarIds = blocked, groupBlockedCalendarIds = groupBlocked)
}
@@ -1,208 +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()
}
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
if (calendars.isEmpty()) {
write(emptyList(), listOf(fallback), existing)
return id
}
val plan = plan(calendars, existing, emptyList())
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
try {
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,
)
} catch (e: RuntimeException) {
Log.w(TAG, "Could not file the channel of calendar $calendarId", e)
write(emptyList(), listOf(fallback), existing)
}
return id
}
/** What the system currently lets through for [calendarIds]' reminders (#145). */
fun status(calendarIds: Collection<Long>): ReminderChannelStatus {
val manager = manager
val appEnabled = manager.areNotificationsEnabled()
val channels = manager.notificationChannels.associate {
it.id to ReminderChannelState(it.importance == NotificationManager.IMPORTANCE_NONE, it.group)
}
val blockedGroups = manager.notificationChannelGroups.filter { it.isBlocked }.mapTo(HashSet()) { it.id }
return reminderChannelStatus(calendarIds, appEnabled, channels, blockedGroups)
}
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 description = context.getString(R.string.reminder_channel_description)
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
// Importance only applies on creation; an existing channel keeps its own.
manager.createNotificationChannels(
channels.map { spec ->
val current = byId[spec.id]
NotificationChannel(
spec.id,
spec.name,
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
).apply {
this.description = description
spec.groupId?.let { group = it }
if (current == null && template != null) inheritFrom(template)
}
},
)
}
/**
* Carry the old single channel's sound, vibration, lights and badge over to
* a new per-calendar one (the system resets lock-screen visibility itself), 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
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,28 +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) ?: run {
Log.i(TAG, "Dropped the reminder of event ${alert.eventId}: calendar ${alert.calendarId} is gone")
return
}
} catch (e: Exception) {
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
return
}
ensureChannel()
val title = displayTitle(alert)
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
@@ -96,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)
@@ -161,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) }
@@ -189,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,61 +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
// A blocked channel would swallow it; the day stays owed until allowed.
if (day != null && notifier.canPost() && !digestNotifier.isChannelOff()) {
// 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)
@@ -235,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
@@ -485,8 +485,9 @@ fun CalendarHost(
movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule,
move = { reschedule.move(it) },
drops = reschedule.drops,
inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent,
)
}
@@ -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
}
}
@@ -8,9 +8,9 @@ import java.util.Locale
* out in [locale]'s own order, with the year shown only when [date] falls outside
* [currentYear].
*
* Pass [skeleton] *without* a year field, e.g. "LLLL" for a month. A skeleton
* is a field list, so wanting the year is just asking for one more field; the
* locale still decides where it lands ("July 2026" vs "2026年7月").
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
* day. A skeleton is a field list, so wanting the year is just asking for one
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
*
* Dropping the year in the current year is the one bit of policy here: the title
* sits directly above a grid that already says which year it is, and the year's
@@ -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. */
@@ -90,9 +90,6 @@ fun EventMoveHost(
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
MoveOutcome.Stale -> stringResource(R.string.event_move_stale)
MoveOutcome.Skipped -> stringResource(R.string.event_move_skipped)
MoveOutcome.QueueFull -> stringResource(R.string.event_move_queue_full)
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series)
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
@@ -1,15 +1,12 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import kotlinx.coroutines.delay
import androidx.compose.animation.core.tween
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
@@ -19,7 +16,6 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.atomic.AtomicLong
/**
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
@@ -41,18 +37,24 @@ class EventMoveScope(
val dragEnabled: Boolean,
/**
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
* before its write resolves, so [drops] — not a return value — is what tells
* a held copy whether anything landed.
* before its write resolves, so [abandoned] — not a return value — is what
* tells a held copy that nothing landed.
*/
val move: (MoveRequest) -> Unit,
/**
* Each drop's progress by its token — see `RescheduleViewModel.drops`. A flow
* rather than a value so this scope stays the same object across a move —
* every visible block reads it as a composition local.
* True while a dropped event is being written, including the time its scope
* dialog is up. A flow rather than a value so this scope stays the same
* object across a move — every visible block reads it as a composition local.
*/
val drops: StateFlow<DropStates>,
/** The undo write that just began — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<UndoStart?>,
val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>,
/**
* Ticks when a drop ends with nothing landing — see
* `RescheduleViewModel.abandoned`. [move] answers before the write is
* resolved, so this is what tells a held copy to stop waiting.
*/
val abandoned: StateFlow<Int>,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
@@ -61,42 +63,30 @@ class EventMoveScope(
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
private val moveTokens = AtomicLong()
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
/** A fresh identity for one drop or undo, unique for the process. */
fun nextMoveToken(): Long = moveTokens.incrementAndGet()
private val NEVER_UNDONE = MutableStateFlow(0)
/** How many abandoned drops [DropStates] remembers — far more than can be on screen at once. */
private const val ABANDONED_KEPT = 32
/** Per-drop progress, by token — see `RescheduleViewModel.drops`. */
@Immutable
data class DropStates(
val pending: Set<Long> = emptySet(),
val abandoned: Set<Long> = emptySet(),
) {
fun started(token: Long): DropStates = copy(pending = pending + token)
fun ended(token: Long, landed: Boolean): DropStates = copy(
pending = pending - token,
// Insertion-ordered, so the oldest go first.
abandoned = if (landed) abandoned else (abandoned + token).let { kept ->
if (kept.size > ABANDONED_KEPT) kept.drop(kept.size - ABANDONED_KEPT).toSet() else kept
},
)
}
private val NO_DROPS = MutableStateFlow(DropStates())
private val NEVER_UNDONE = MutableStateFlow<UndoStart?>(null)
private val NEVER_ABANDONED = MutableStateFlow(0)
/**
* Every drop's progress — empty wherever moving is off. A drag overlay keeps
* the token of its own drop and holds its copy while that token is pending.
* Whether a dropped event is still being written — false wherever moving is off.
* The drag overlays hold a landed block on its target for this window.
*/
@Composable
fun dropStates(): DropStates {
val flow = LocalEventMove.current?.drops ?: NO_DROPS
fun moveInFlight(): Boolean {
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
return flow.collectAsStateWithLifecycle().value
}
/**
* How many drops have ended with nothing landing. Read as a plain count so a
* drag overlay can capture it at the moment of the drop and tell, later, whether
* *its own* drop was one of them.
*/
@Composable
fun abandonedMoves(): Int {
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
return flow.collectAsStateWithLifecycle().value
}
@@ -105,53 +95,17 @@ fun dropStates(): DropStates {
* this composable came on screen.
*/
@Composable
fun OnUndoStarted(onUndo: (UndoStart) -> Unit) {
fun OnUndoStarted(onUndo: () -> Unit) {
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
val start by flow.collectAsStateWithLifecycle()
var seen by remember { mutableStateOf(start) }
LaunchedEffect(start) {
val started = start
if (started == null || started == seen) return@LaunchedEffect
seen = started
onUndo(started)
val tick by flow.collectAsStateWithLifecycle()
var seen by remember { mutableIntStateOf(tick) }
LaunchedEffect(tick) {
if (tick == seen) return@LaunchedEffect
seen = tick
onUndo()
}
}
/**
* The life of a drop parked by a newer pickup (#494): held while its [token]
* is pending, then a beat after the grid [seated] it — or [timeoutMillis] if it
* never does — handed over and faded, then released. Returns the copy's alpha.
* An abandoned drop skips the wait; nothing is coming to seat it.
*/
@Composable
fun rememberHeldSettle(
token: Long,
seated: Boolean,
timeoutMillis: Long,
onHandOver: () -> Unit,
onRelease: () -> Unit,
): Float {
val drops = dropStates()
val pending = token in drops.pending
val abandoned = token in drops.abandoned
val handOver by rememberUpdatedState(onHandOver)
val release by rememberUpdatedState(onRelease)
var fading by remember(token) { mutableStateOf(false) }
LaunchedEffect(token, pending, abandoned, seated) {
if (pending) return@LaunchedEffect
if (!abandoned) delay(if (seated) SETTLE_GRACE_MILLIS else timeoutMillis)
fading = true
handOver()
delay(SETTLE_FADE_MILLIS.toLong())
release()
}
return animateFloatAsState(
targetValue = if (fading) 0f else 1f,
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "held-settle",
).value
}
/** Opacity the source block keeps while its floating copy travels. */
const val GHOST_ALPHA: Float = 0.3f
@@ -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,19 +23,15 @@ 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
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
@@ -44,21 +40,12 @@ import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import javax.inject.Inject
private const val MILLIS_PER_MINUTE = 60_000
private const val MINUTES_PER_DAY = 24 * 60
/** How many drops may wait behind the one in flight before more are refused. */
private const val MAX_QUEUED = 8
/** How long after a write its event's drawn times may still be the old ones. */
private val RECENT_WRITE = 3.seconds
/** Where a dragged event should land. */
sealed interface MoveTarget {
/** A new start instant — a timeline drag, which moves time and day at once. */
@@ -87,10 +74,6 @@ data class MoveRequest(
val beginMillis: Long,
val endMillis: Long,
val target: MoveTarget,
/** Whether the dragged occurrence is all-day, which decides the zone its day is read in. */
val isAllDay: Boolean,
/** The drop's own identity, from [nextMoveToken], which the view holding its copy waits on. */
val token: Long,
)
/**
@@ -109,13 +92,8 @@ data class MoveUndo(
val moved: EventForm,
/** The form as it stood before the move. */
val restored: EventForm,
/** The token of the drop this undoes, so the view that drew it carries the right chip back. */
val dropToken: Long,
)
/** An undo write that just began: its own [token], and the [dropToken] of the drop it undoes. */
data class UndoStart(val token: Long, val dropToken: Long)
sealed interface MoveOutcome {
/**
* Written. [startMillis] is where the dragged occurrence now begins, for the
@@ -137,19 +115,6 @@ sealed interface MoveOutcome {
/** The event vanished (sync, another device) between the read and the write. */
data object Gone : MoveOutcome
/** Drops that waited behind a scope dialog, cancelled rather than run after it (#494). */
data object Skipped : MoveOutcome
/** More drops waiting than the queue holds; this one was not taken. */
data object QueueFull : MoveOutcome
/**
* The drop's recurring occurrence may no longer be where it was picked up —
* its series was just written — and can't be followed there, so it is not
* written (#494).
*/
data object Stale : MoveOutcome
/** The drop would push the series past its own `UNTIL`, generating nothing. */
data object BlockedSeriesEnd : MoveOutcome
@@ -175,7 +140,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)
@@ -188,32 +152,36 @@ class RescheduleViewModel @Inject constructor(
private val _inFlight = MutableStateFlow(false)
/** True while any drop or undo runs or waits — what the confirmation chip holds for. */
/**
* True from the moment a drop is accepted until its write settles — the
* window the dropped block holds its landing position for.
*/
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _drops = MutableStateFlow(DropStates())
private val _abandoned = MutableStateFlow(0)
/**
* Each drop's own state, by its [MoveRequest.token]: pending from the moment
* it is accepted until its write settles, and abandoned once it ends with
* nothing on the target day — refused, failed, or its scope dialog dismissed.
* Per drop, so a queued drop's held copy waits on its own write and is never
* let go by another drop's failure (#253, #494).
* Ticks when a drop ends with nothing on the target day: refused outright,
* refused before the write, written and failed, or its scope dialog
* dismissed. [move] answers before any of that is known, so without this
* signal the view that drew the drop held its copy out for the full settle
* timeout waiting for a grid that was never going to draw it (#253).
*/
val drops: StateFlow<DropStates> = _drops.asStateFlow()
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
private val _undoStarted = MutableStateFlow<UndoStart?>(null)
private val _undoStarted = MutableStateFlow(0)
/**
* An undo write the moment it begins, before the provider has anything to
* Ticks the moment an undo write begins, before the provider has anything to
* re-read, so the view that drew the drop can carry its chip back rather than
* let it reappear on the old day. Its fate then reads off [drops].
* let it reappear on the old day.
*/
val undoStarted: StateFlow<UndoStart?> = _undoStarted.asStateFlow()
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
/**
* Set while a drop is being prepared, waits on its scope dialog, or is
* written. Drops arriving in that window wait in [queue] and run in order.
* Set from the moment a drop is accepted until its write settles. Two drops
* of the same recurring event inside that window would compute their shift
* from the same pre-move occurrence, and the shifts would compound.
*/
private var busy = false
set(value) {
@@ -221,22 +189,6 @@ class RescheduleViewModel @Inject constructor(
_inFlight.value = value
}
/** Whether a move has been announced since the pipeline was last idle — see [report]. */
private var movedThisRun = false
/** Outcomes waiting for the chip, shown one after another as each is consumed. */
private val notices = ArrayDeque<MoveOutcome>()
/**
* When each event's series (by id) or single occurrence (by id and begin)
* was last written. The grid draws the old times until it re-reads, so a
* drop on a recurring event written moments ago may carry stale times (#494).
*/
private val written = mutableMapOf<Pair<Long, Long?>, TimeMark>()
/** Drops that arrived while another was in flight, oldest first (#494). */
private val queue = ArrayDeque<MoveRequest>()
/**
* The calendars whose events may be dragged. Nothing below the UI guards
* this, so a block outside this set registers no drag gesture at all.
@@ -267,50 +219,38 @@ class RescheduleViewModel @Inject constructor(
}
/**
* Take a drop: run it now, or queue it behind the ones in flight. False means
* nothing will be written — the queue is full — and the drop is marked
* abandoned in [drops], so its held copy is let go by the one signal rather
* than by every caller remembering to.
* Take a drop, unless one is already being written. False means nothing will
* be written; [abandoned] ticks for that refusal too, so a held copy is let
* go by the one signal rather than by every caller remembering to.
*
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
* hand it on. It answers before the write is resolved anyway.
* hand it on. A caller that gated on it would be back to releasing its copy
* from two places, and it answers before the write is resolved anyway.
*/
fun move(request: MoveRequest): Boolean {
if (queue.size >= MAX_QUEUED) {
_drops.update { it.ended(request.token, landed = false) }
report(MoveOutcome.QueueFull)
if (busy || _scopePrompt.value != null) {
_abandoned.value += 1
return false
}
if (_scopePrompt.value != null) {
_drops.update { it.ended(request.token, landed = false) }
report(MoveOutcome.Skipped)
return false
}
_drops.update { it.started(request.token) }
if (busy) queue.addLast(request) else run(request)
return true
}
private fun run(request: MoveRequest) {
busy = true
viewModelScope.launch {
val prepared = prepare(request)
if (prepared == null) {
settle(request.token, landed = false)
_abandoned.value += 1
busy = false
return@launch
}
// A cancelled series only moves as a whole (#388).
val seriesOnly = !prepared.original.canSaveApartFromSeries()
if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
settle(request.token, write(prepared, RecurringWriteScope.AllEvents))
write(prepared, RecurringWriteScope.AllEvents)
busy = false
} else if (seriesOnly) {
report(MoveOutcome.BlockedCancelledSeries)
settle(request.token, landed = false)
_outcome.value = MoveOutcome.BlockedCancelledSeries
_abandoned.value += 1
busy = false
} else {
// Still busy: the scope dialog is now the thing in flight. Drops
// waiting behind it are cancelled, not held behind a question
// about another event.
skipQueued()
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
@@ -318,6 +258,7 @@ class RescheduleViewModel @Inject constructor(
)
}
}
return true
}
/** Answer the scope dialog. */
@@ -328,92 +269,35 @@ class RescheduleViewModel @Inject constructor(
pending = null
_scopePrompt.value = null
viewModelScope.launch {
settle(prepared.request.token, write(prepared, scope))
write(prepared, scope)
busy = false
}
}
/** Dismiss the scope dialog without writing. */
fun cancelScope() {
val prepared = pending ?: return
pending = null
busy = false
_scopePrompt.value = null
settle(prepared.request.token, landed = false)
}
/** End the drop [token], then run the next queued one, if any. */
private fun settle(token: Long, landed: Boolean) {
_drops.update { it.ended(token, landed) }
// Straight on to the next, so busy never reads false between two drops.
val next = queue.removeFirstOrNull()
if (next != null) {
run(next)
} else {
busy = false
movedThisRun = false
}
}
private fun skipQueued() {
if (queue.isEmpty()) return
val skipped = queue.map { it.token }
queue.clear()
_drops.update { states -> skipped.fold(states) { acc, token -> acc.ended(token, landed = false) } }
report(MoveOutcome.Skipped)
}
/**
* Queue [outcome] for the chip. While drops run back to back only the first
* move is announced, so its Undo stays on offer; every failure gets its own
* turn after it, since the grid shows what moved but only the chip says what
* didn't. A later write to the announced move's event takes that Undo away,
* as putting the event back would undo the later move too.
*/
private fun report(outcome: MoveOutcome, eventId: Long? = null) {
if (outcome is MoveOutcome.Moved) {
if (movedThisRun) {
dropUndoFor(eventId)
return
}
movedThisRun = true
} else if (outcome == (notices.lastOrNull() ?: _outcome.value)) {
return
}
if (_outcome.value == null) _outcome.value = outcome else notices.addLast(outcome)
}
private fun dropUndoFor(eventId: Long?) {
fun MoveOutcome.withoutUndo(): MoveOutcome =
if (this is MoveOutcome.Moved && undo?.eventId == eventId) copy(undo = null) else this
_outcome.value = _outcome.value?.withoutUndo()
val rest = notices.map { it.withoutUndo() }
notices.clear()
notices.addAll(rest)
}
private fun markWritten(eventId: Long, beginMillis: Long?) {
written[eventId to beginMillis] = TimeSource.Monotonic.markNow()
}
private fun writtenRecently(eventId: Long, beginMillis: Long): Boolean {
written.entries.removeAll { it.value.elapsedNow() > RECENT_WRITE }
return (eventId to null) in written || (eventId to beginMillis) in written
_abandoned.value += 1
}
/**
* Put a completed move back where it came from. The outcome stands until the
* inverse write reports back, so the one chip changes what it says rather
* than closing and reopening. False when another write is already running —
* the chip hides its action for that window, so this is the backstop. An
* undo is a journey of its own: [undoStarted] names its token, and [drops]
* reports whether it landed.
* the chip hides its action for that window, so this is the backstop.
*
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
* has sent the chip back on its journey by then and nothing else will seat it.
* A refusal ticks nothing — it launched no journey of its own, and the counter
* is global, so a tick here would release some other drop's held copy before
* the grid has drawn it.
*/
fun undo(undo: MoveUndo): Boolean {
if (busy) return false
val token = nextMoveToken()
_drops.update { it.started(token) }
busy = true
_undoStarted.value = UndoStart(token, undo.dropToken)
markWritten(undo.eventId, null)
_undoStarted.value += 1
viewModelScope.launch {
_outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
@@ -427,43 +311,34 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
settle(token, landed = _outcome.value is MoveOutcome.Undone)
// Read off the outcome, the way [write] does — no second copy of the
// same fact to drift from it.
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
busy = false
}
return true
}
/** Clear the outcome once the screen has shown it, bringing on the next one waiting. */
/** Clear the outcome once the screen has shown it. */
fun consumeOutcome() {
_outcome.value = notices.removeFirstOrNull()
_outcome.value = null
}
private suspend fun prepare(dropped: MoveRequest): PreparedMove? {
val zone = zoneSource.current()
private suspend fun prepare(request: MoveRequest): PreparedMove? {
val detail = try {
repository.eventDetail(dropped.eventId)
repository.eventDetail(request.eventId)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
report(MoveOutcome.Gone)
_outcome.value = MoveOutcome.Gone
return null
} catch (e: Exception) {
report(MoveOutcome.Failed)
_outcome.value = MoveOutcome.Failed
return null
}
// The drop carries the times the grid drew, which an earlier write may
// have moved on since (#494).
val request = if (detail.rrule == null || detail.isException) {
followed(dropped, detail, zone)
} else if (writtenRecently(dropped.eventId, dropped.beginMillis)) {
report(MoveOutcome.Stale)
return null
} else {
dropped
}
// Read fixed-time reminders out, so the move keeps them on their wall clock (#459).
val zone = zoneSource.current()
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)
@@ -475,7 +350,7 @@ class RescheduleViewModel @Inject constructor(
// The UNTIL check is deferred to the write, which knows how far the move
// reaches and so which date has to clear it.
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
report(MoveOutcome.Failed)
_outcome.value = MoveOutcome.Failed
return null
}
@@ -520,14 +395,14 @@ class RescheduleViewModel @Inject constructor(
)
}
/** Write [prepared] in [scope]; true when it landed. */
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) {
report(MoveOutcome.BlockedSeriesEnd)
return false
_outcome.value = MoveOutcome.BlockedSeriesEnd
_abandoned.value += 1
return
}
val outcome = try {
_outcome.value = try {
when {
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
@@ -562,37 +437,9 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
if (outcome is MoveOutcome.Moved) {
markWritten(request.eventId, request.beginMillis.takeIf { scope == RecurringWriteScope.ThisEvent && prepared.isRecurring })
}
report(outcome, request.eventId)
return outcome is MoveOutcome.Moved
}
/**
* A one-off [dropped] rebased onto where its event now is, so the last drop
* wins: absolute targets stand as they are, and a whole-day shift is re-aimed
* at the day it was dropped on. Unchanged when the event hasn't moved.
*/
private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest {
val now = detail.instance
val target = when (val target = dropped.target) {
is MoveTarget.ByDays -> {
// Each day read the way its own kind anchors it: all-day at UTC.
val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis)
.toLocalDateTime(if (dropped.isAllDay) TimeZone.UTC else zone).date
.plus(target.days, DateTimeUnit.DAY)
val from = now.start.toLocalDateTime(if (now.isAllDay) TimeZone.UTC else zone).date
MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt())
}
else -> target
}
return dropped.copy(
beginMillis = now.start.toEpochMilliseconds(),
endMillis = now.end.toEpochMilliseconds(),
target = target,
isAllDay = now.isAllDay,
)
// Nothing landed, so the copy the view is holding has nothing to fade
// into — let it go now rather than at the end of the settle timeout.
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
}
/**
@@ -659,7 +506,6 @@ class RescheduleViewModel @Inject constructor(
eventId = prepared.request.eventId,
moved = prepared.updated,
restored = prepared.original,
dropToken = prepared.request.token,
)
} else {
null
@@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -61,6 +60,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn
@@ -139,17 +139,9 @@ data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGr
sealed interface StripDrop {
val event: EventInstance
/** The drop's identity, which its held copy waits on. */
val token: Long
data class Days(override val event: EventInstance, val days: Int) : StripDrop
data class Days(override val event: EventInstance, val days: Int, override val token: Long) : StripDrop
data class Timed(
override val event: EventInstance,
val start: Instant,
val end: Instant,
override val token: Long,
) : StripDrop
data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop
}
/** The event a long press left selected (#141). */
@@ -305,9 +297,7 @@ data class TimelineDrop(
val date: LocalDate,
val startMin: Int,
/** Minutes from the event's start to the dragged block's top edge. */
val clipOffsetMin: Int,
/** The drop's identity, which its held copy waits on. */
val token: Long,
val clipOffsetMin: Int = 0,
)
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
@@ -350,37 +340,8 @@ internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone):
/** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom }
/** A drop parked by a newer pickup while its write still runs — see [TimelineDragController.held]. */
data class HeldDrop(
val copy: TimelineDrag,
val token: Long,
val instanceId: Long,
/** The event row and start the drop came from, as [TimelineDragController.ghosts] matches them. */
val origin: Pair<Long, Instant>,
/** A resize's new end, which has to have landed too before the drop counts as seated. */
val end: Instant?,
/** Whether the grid now draws the drop at its landing slot. */
val seated: Boolean = false,
val handedOver: Boolean = false,
) {
fun ghosts(block: TimedBlock): Boolean = block.event.instanceId == instanceId ||
(block.event.eventId == origin.first && block.event.start == origin.second)
}
/** A strip drop parked by a newer one while its write still runs — see [TimelineDragController.heldStrips]. */
data class HeldStrip(val drag: StripDrag, val token: Long, val seated: Boolean = false)
/** An event's new start and end, as a resize leaves them. */
data class ResizedSpan(val start: Instant, val end: Instant)
/** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(
val event: EventInstance,
val start: Instant,
val end: Instant,
/** The drop's identity, which its held copy waits on. */
val token: Long,
)
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
@@ -391,16 +352,17 @@ data class TimelineResizeDrop(
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
* point, so the block settles under the finger.
*/
internal fun resizedSpan(
internal fun resizedDrop(
event: EventInstance,
edge: ResizeEdge?,
date: LocalDate,
startMin: Int,
spanMin: Int,
zone: TimeZone,
): ResizedSpan {
): TimelineResizeDrop {
val drawn = wallClockSpan(event, zone)
return ResizedSpan(
return TimelineResizeDrop(
event = event,
start = if (edge == ResizeEdge.Top) {
event.start.toFormTime(zone).repicked(
wallClockTime(date, startMin),
@@ -551,14 +513,6 @@ class TimelineDragController {
*/
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/**
* Earlier drops still being written when another block was picked up. They
* keep their copy and their ghost until their own token settles, rather than
* vanishing and flashing back at the old slot (#494).
*/
var held: List<HeldDrop> by mutableStateOf(emptyList())
private set
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
@@ -657,8 +611,14 @@ class TimelineDragController {
var settlingSelected: Boolean = false
private set
/** The token of the drop [settling] waits on. */
var settledToken: Long = 0L
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var abandonedTicks: StateFlow<Int>? = null
/** What [abandonedTicks] stood at when the settling drop was let go. */
var settledAbandonedMark: Int = 0
private set
private var source: TimedBlock? = null
@@ -701,12 +661,8 @@ class TimelineDragController {
var stripSeated: Boolean by mutableStateOf(false)
private set
/** Strip drops parked by a newer strip drop while their writes still run (#494). */
var heldStrips: List<HeldStrip> by mutableStateOf(emptyList())
private set
/** The token of the drop [stripSettling] waits on. */
var stripToken: Long = 0L
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
var stripAbandonedMark: Int = 0
private set
/** The shown day the finger picked the strip bar up on. */
@@ -731,7 +687,7 @@ class TimelineDragController {
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
dragKept = zone.repeatedHourOffsetAt(block.event.start)
this.titleLines = titleLines
parkSettling()
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
@@ -770,7 +726,7 @@ class TimelineDragController {
dragDuration = draftMinutes.minutes
dragKept = null
titleLines = 1
parkSettling()
settling = null
isDragging = true
liftedInstanceId = event.instanceId
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
@@ -793,18 +749,16 @@ class TimelineDragController {
val zone = dragZone
cancel()
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
val token = settle(landed)
val start = dropStartInstant(landed.event, landed.date, landed.startMin, clipOffsetMin = 0, zone)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes, token)
settle(landed)
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
}
if (held.overGrid || held.dayShift == 0) return null
// One still settling keeps its place on the strip until its own write lands (#494).
stripSettling?.let { heldStrips = heldStrips + HeldStrip(it, stripToken, stripSeated) }
stripSettling = held
stripSeated = false
stripToken = nextMoveToken()
stripAbandonedMark = abandonedTicks?.value ?: 0
drops++
return StripDrop.Days(held.event, held.dayShift, stripToken)
return StripDrop.Days(held.event, held.dayShift)
}
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
@@ -818,21 +772,6 @@ class TimelineDragController {
if (stripSettling != null) stripSeated = true
}
/** Note that the strip draws the parked strip drop [token] at its new days. */
fun seatHeldStrip(token: Long) {
if (heldStrips.any { it.token == token && !it.seated }) {
heldStrips = heldStrips.map { if (it.token == token) it.copy(seated = true) else it }
}
}
/** Let the parked strip drop [token] go. */
fun releaseHeldStrip(token: Long) {
heldStrips = heldStrips.filterNot { it.token == token }
}
/** Every strip drop still shown at its new days: the parked ones, then the latest. */
fun stripPreviews(): List<StripDrag> = heldStrips.map { it.drag } + listOfNotNull(stripDrag ?: stripSettling)
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
@@ -867,36 +806,12 @@ class TimelineDragController {
* an event that crosses midnight (#253).
*/
fun ghosts(block: TimedBlock): Boolean {
if (held.any { !it.handedOver && it.ghosts(block) }) return true
if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true
val (eventId, start) = settledOrigin ?: return false
return block.event.eventId == eventId && block.event.start == start
}
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
private fun parkSettling() {
val landed = settling
val origin = settledOrigin
if (landed != null && origin != null && !landed.event.isDraft) {
held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin, settledEnd, settledOnGrid)
}
settling = null
settledOnGrid = false
settledOrigin = null
settledEnd = null
}
/** Stop ghosting the held drop [token]'s source while its copy fades. */
fun handOverHeld(token: Long) {
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
}
/** Drop the held copy [token] once it has faded. */
fun releaseHeld(token: Long) {
held = held.filterNot { it.token == token }
}
/**
* What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row
@@ -904,23 +819,16 @@ class TimelineDragController {
* blank title matches nothing: every untitled event has one (#253).
*/
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
if (held.any { !it.seated && drawsDrop(it.copy, it.end, date, blocks) }) {
held = held.map { if (!it.seated && drawsDrop(it.copy, it.end, date, blocks)) it.copy(seated = true) else it }
}
val landed = settling ?: return
if (settledOnGrid) return
settledOnGrid = drawsDrop(landed, settledEnd, date, blocks)
}
/** Whether [blocks], [date]'s column, draws the drop [landed] where it landed. */
private fun drawsDrop(landed: TimelineDrag, end: Instant?, date: LocalDate, blocks: List<TimedBlock>): Boolean {
// Where this day would draw it, which for a drop clipped at midnight is
// the day's own start rather than the edge the finger held (#253) — and
// which for a tail dragged clear of its day is the day before it.
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return false
return blocks.any { block ->
.firstOrNull() ?: return
val end = settledEnd
settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin &&
// A resize may keep its start, so its end has to have landed too.
(end == null || block.event.end == end) &&
@@ -947,15 +855,12 @@ class TimelineDragController {
if (landed == null || landed.slot == origin) return null
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
if (landed.edgeOnly && landed.event.isDraft) return null
val token = settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset, token)
settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/**
* Keep [landed]'s copy on the target as [settling] until [release]; [end] is a
* resize's. Returns the drop's fresh token.
*/
private fun settle(landed: TimelineDrag, end: Instant? = null): Long {
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
// Landed on midnight to midnight, the event moves to the all-day strip (#296).
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
@@ -968,10 +873,9 @@ class TimelineDragController {
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledToken = nextMoveToken()
settledAbandonedMark = abandonedTicks?.value ?: 0
settledEnd = end
drops++
return settledToken
}
/**
@@ -991,7 +895,7 @@ class TimelineDragController {
resize = session
clipOffsetMin = 0
this.titleLines = titleLines
parkSettling()
settling = null
isDragging = true
pointerMoved = true
liftedInstanceId = block.event.instanceId
@@ -1022,10 +926,10 @@ class TimelineDragController {
val event = source?.event ?: return null
val edge = resize?.edge
if (edge != null) {
val drop = resizedSpan(event, edge, date, startMin, spanMin, zone)
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
return event.copy(start = drop.start, end = drop.end)
}
val start = dropStartInstant(event, date, startMin, clipOffsetMin, zone)
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
return event.copy(start = start, end = start + dragDuration)
}
@@ -1039,8 +943,9 @@ class TimelineDragController {
val edge = resize?.edge
cancel()
if (landed == null || landed.slot == origin) return null
val span = resizedSpan(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
return TimelineResizeDrop(landed.event, span.start, span.end, settle(landed, end = span.end))
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settle(landed, end = drop.end)
return drop
}
/**
@@ -1294,6 +1199,7 @@ fun rememberTimelineDragController(): TimelineDragController {
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.slopPx = LocalViewConfiguration.current.touchSlop
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
@@ -1323,16 +1229,7 @@ fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
dropStartInstant(event, date, startMin, clipOffsetMin, zone)
/** Where [event] starts when its block's top edge lands [startMin] past [date]'s midnight — see [TimelineDrop]. */
internal fun dropStartInstant(
event: EventInstance,
date: LocalDate,
startMin: Int,
clipOffsetMin: Int,
zone: TimeZone,
): Instant = wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
/**
* The wall-clock minutes an event lasting [duration] covers when it starts
@@ -1404,7 +1301,6 @@ private const val SETTLE_TIMEOUT_MILLIS = 900L
*/
const val SETTLE_FADE_MILLIS: Int = 250
/**
* The floating block, drawn over the whole calendar so it is free of the day
* column's clip and of the scroll viewport's rounded corners.
@@ -1412,10 +1308,12 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val drops = dropStates()
val moveInFlight = controller.settledToken in drops.pending
val stripInFlight = controller.stripToken in drops.pending
val moveInFlight = moveInFlight()
// Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to.
@@ -1429,11 +1327,12 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// recurring drop's scope dialog is up — and then until the grid draws the
// drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
val abandoned = controller.settledToken in drops.abandoned
val abandoned = abandonedMoves()
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
// An abandoned drop: nothing will land and there is nothing to wait for.
if (!abandoned) {
// Past the mark this drop was let go on, it was abandoned: nothing will
// land and there is nothing to wait for.
if (abandoned == controller.settledAbandonedMark) {
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
handingOver = true
@@ -1443,10 +1342,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
}
// The same hold for a bar moved along the all-day strip.
val stripAbandoned = controller.stripToken in drops.abandoned
LaunchedEffect(controller.stripSettling, stripInFlight, controller.stripSeated, stripAbandoned) {
if (controller.stripSettling == null || stripInFlight) return@LaunchedEffect
if (!stripAbandoned) {
LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) {
if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect
if (abandoned == controller.stripAbandonedMark) {
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
controller.releaseStrip()
@@ -1460,12 +1358,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() },
) {
controller.held.forEach { parked ->
key(parked.token) { HeldTimelineCopy(controller, parked, origin) }
}
controller.heldStrips.forEach { parked ->
key(parked.token) { HeldStripSettle(controller, parked) }
}
val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write runs.
val landed = controller.drag == null
@@ -1479,58 +1371,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover",
)
TimelineCopy(drag, controller.geometry, origin, lift, copyAlpha)
}
}
/**
* [TimelineDragController.ghosts] for [block], derived so the block recomposes
* only when its own answer changes rather than on every lift, park and release.
*/
@Composable
fun TimelineDragController.ghostsDerived(block: TimedBlock): Boolean {
val ghosted by remember(this, block) { derivedStateOf { ghosts(block) } }
return ghosted
}
/** A drop parked by a newer pickup: held at its landing slot until its own write settles. */
@Composable
private fun HeldTimelineCopy(controller: TimelineDragController, parked: HeldDrop, origin: Offset) {
val alpha = rememberHeldSettle(
token = parked.token,
seated = parked.seated,
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
onHandOver = { controller.handOverHeld(parked.token) },
onRelease = { controller.releaseHeld(parked.token) },
)
TimelineCopy(parked.copy, controller.geometry, origin, lift = 0f, copyAlpha = alpha)
}
/** A strip drop parked by a newer one: shown at its new days until its own write settles. */
@Composable
private fun HeldStripSettle(controller: TimelineDragController, parked: HeldStrip) {
rememberHeldSettle(
token = parked.token,
seated = parked.seated,
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
onHandOver = {},
onRelease = { controller.releaseHeldStrip(parked.token) },
)
}
/** [drag]'s pieces drawn over the calendar, clipped to the timeline's viewport. */
@Composable
private fun TimelineCopy(
drag: TimelineDrag,
geometry: TimelineGeometry,
origin: Offset,
lift: Float,
copyAlpha: Float,
) {
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val paint = eventPaint(drag.event, dark)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// Read off the event rather than the held block, so grabbing either half
@@ -1546,7 +1386,7 @@ private fun TimelineCopy(
// so no column clip or rounded corner cuts it, but it still belongs to
// the timeline: a piece a whole day tall reaches far past both ends of
// the viewport, and unclipped it paints over the headers above (#267).
val port = geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
// How much of each piece the viewport actually shows. A multi-day event
// has a piece per day, and the widest one is a full 24 hours — taller
// than the screen, its top at a midnight scrolled out of sight. Picking
@@ -1598,6 +1438,7 @@ private fun TimelineCopy(
)
}
}
}
}
/** One piece of a dragged block, floating over the calendar. */
@@ -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,8 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.nextMoveToken
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate
import kotlin.math.abs
@@ -106,22 +107,11 @@ data class MonthChipDrag(
val time: String? = null,
)
/** A drop parked by a newer pickup while its write still runs — see [MonthDragController.held]. */
data class HeldChip(
val copy: MonthChipDrag,
val token: Long,
val handedOver: Boolean = false,
/** Where the grid now draws the drop, in root coordinates, once a row seats it. */
val seat: Offset? = null,
)
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
data class MonthChipDrop(
val event: EventInstance,
val grabDate: LocalDate,
val targetDate: LocalDate,
/** The drop's identity, which its held copy waits on — see [MonthDragController.settledToken]. */
val token: Long,
)
/**
@@ -177,22 +167,19 @@ class MonthDragController {
private set
/**
* This controller's recent drops by token, kept past [release] for
* [beginUndo] — the confirmation chip carries Undo long after the copy is
* gone, and it may be any of them the chip offers to undo.
* The last drop this controller made, kept past [release] for [beginUndo] —
* the confirmation chip carries Undo long after the copy is gone.
*/
private val undoable = LinkedHashMap<Long, MonthChipDrag>()
private var undoable: MonthChipDrag? = null
/**
* Earlier drops still being written when another chip was picked up. They
* keep their copy and their ghost until their own token settles, rather than
* vanishing and flashing back on the old day (#494).
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var held: List<HeldChip> by mutableStateOf(emptyList())
private set
var abandonedTicks: StateFlow<Int>? = null
/** The token of the drop (or undo) [settling] is waiting on. */
var settledToken: Long = 0L
/** What [abandonedTicks] stood at when the settling chip was let go. */
var settledAbandonedMark: Int = 0
private set
private var event: EventInstance? = null
@@ -221,7 +208,7 @@ class MonthDragController {
this.event = event
this.grabDate = grabDate
this.time = time
parkSettling()
settling = null
isDragging = true
liftedInstanceId = event.instanceId
grab = pointerInRoot - chipInRoot
@@ -247,39 +234,6 @@ class MonthDragController {
settledGhost = false
}
/**
* Whether [event]'s chip belongs to a held drop and must stay a ghost: its
* source, or once a row seats it, the chip the grid draws on its target day.
*/
fun isHeldChip(event: EventInstance, days: List<LocalDate>?): Boolean = held.any { parked ->
!parked.handedOver && (
parked.copy.event.instanceId == event.instanceId ||
(
parked.seat != null && days != null && parked.copy.targetDate in days &&
isSameEvent(event, parked.copy.event)
)
)
}
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
private fun parkSettling() {
val landed = settling
if (landed != null) held = held + HeldChip(landed, settledToken)
settling = null
settledInRoot = null
settledGhost = false
}
/** Stop ghosting the held drop [token]'s source while its copy fades. */
fun handOverHeld(token: Long) {
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
}
/** Drop the held copy [token] once it has faded. */
fun releaseHeld(token: Long) {
held = held.filterNot { it.token == token }
}
/**
* Whether [days] holds the chip the grid has drawn for the settled drop —
* the seat the copy is on its way to, which must ghost until it gets there.
@@ -296,13 +250,6 @@ class MonthDragController {
* nearest where the chip was let go is the one the finger was over.
*/
fun noteSettled(token: Any) {
val row = rows[token]
if (row != null && held.any { it.seat == null }) {
held = held.map { parked ->
val target = parked.copy.targetDate
if (parked.seat != null || target == null) parked else parked.copy(seat = row.seatOf(parked.copy.event, target))
}
}
val landed = settling ?: return
val target = landed.targetDate ?: return
val at = rows[token]?.seatOf(landed.event, target) ?: return
@@ -318,16 +265,16 @@ class MonthDragController {
* there is no drop of this controller's left to undo, or the grid doesn't
* seat the moved event where the chip would have to start from.
*/
fun beginUndo(token: Long, dropToken: Long): Boolean {
val last = undoable.remove(dropToken) ?: return false
parkSettling()
fun beginUndo(): Boolean {
val last = undoable ?: return false
undoable = null
val from = last.targetDate ?: return false
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
liftedInstanceId = last.event.instanceId
settledGhost = true
settledInRoot = null
settledToken = token
settledAbandonedMark = abandonedTicks?.value ?: 0
return true
}
@@ -347,11 +294,10 @@ class MonthDragController {
)
liftedInstanceId = landed.event.instanceId
settledGhost = true
settledToken = nextMoveToken()
undoable[settledToken] = requireNotNull(settling)
if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first())
undoable = settling
settledAbandonedMark = abandonedTicks?.value ?: 0
drops++
return MonthChipDrop(landed.event, landed.grabDate, target, settledToken)
return MonthChipDrop(landed.event, landed.grabDate, target)
}
/**
@@ -409,10 +355,9 @@ class MonthDragController {
@Composable
fun rememberMonthDragController(): MonthDragController {
return remember { MonthDragController() }
val controller = remember { MonthDragController() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
/** How many recent drops a month controller keeps for an undo to carry back. */
private const val UNDOABLE_KEPT = 8
@@ -125,11 +125,11 @@ import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.dropStates
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
import de.jeanlucmakiola.calendula.ui.common.rememberHeldSettle
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
@@ -515,7 +515,7 @@ fun MonthScreen(
val chipDrag = rememberMonthDragController()
// Undo moves the event back, so the chip travels back too. The
// signal comes from the write, not from the chip that offers it.
OnUndoStarted { start -> chipDrag.beginUndo(start.token, start.dropToken) }
OnUndoStarted { chipDrag.beginUndo() }
Box(
modifier = Modifier
.padding(innerPadding)
@@ -591,11 +591,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
@Composable
private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = LocalFloretDarkTheme.current
val density = LocalDensity.current
val timeFit = rememberMonthChipTimeFit(showStartTimes)
val reduceMotion = rememberReduceMotion()
val drops = dropStates()
val moveInFlight = controller.settledToken in drops.pending
val abandoned = controller.settledToken in drops.abandoned
val moveInFlight = moveInFlight()
// Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate, controller.drops)
@@ -608,12 +608,14 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = controller.settledInRoot
val abandoned = abandonedMoves()
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
val landed = controller.settling
if (landed == null || moveInFlight) return@LaunchedEffect
if (seat == null) {
// An abandoned drop has no seat coming.
if (!abandoned) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
// Past the mark this drop was let go on, it was abandoned and no
// seat is ever coming.
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
} else {
if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true
@@ -633,9 +635,6 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() },
) {
controller.held.forEach { parked ->
key(parked.token) { HeldMonthChip(controller, parked, origin, timeFit) }
}
val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write runs.
val lift by animateFloatAsState(
@@ -647,93 +646,42 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "chip-handover",
)
MonthChipCopy(
drag = drag,
// Read at layout, so the glide doesn't recompose every frame.
at = { if (gliding) glide.value else drag.topLeftInRoot },
origin = origin,
lift = lift,
alpha = copyAlpha,
MonthBar(
event = drag.event,
dark = dark,
continuesLeft = false,
continuesRight = false,
// The time the source chip carried, drawn only if this one-column
// cut-out of it has the room — which a slice of a multi-day bar has
// not, however wide the bar was.
time = drag.time,
timeFit = timeFit,
chipWidth = with(density) { drag.sizePx.width.toDp() },
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
val at = if (gliding) glide.value else drag.topLeftInRoot
IntOffset(
(at.x - origin.x).roundToInt(),
(at.y - origin.y).roundToInt(),
)
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
shape = EventChipShape
clip = false
},
)
}
}
/** A drop parked by a newer pickup: held on its landing day until its own write settles. */
@Composable
private fun HeldMonthChip(
controller: MonthDragController,
parked: HeldChip,
origin: Offset,
timeFit: MonthChipTimeFit?,
) {
val reduceMotion = rememberReduceMotion()
val glide = remember { Animatable(parked.copy.topLeftInRoot, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = parked.seat
val landed = parked.token !in dropStates().pending
// Carried onto its seat once the write is in, as the settling chip is.
LaunchedEffect(seat, landed) {
if (seat == null || !landed) return@LaunchedEffect
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
}
val alpha = rememberHeldSettle(
token = parked.token,
seated = seat != null,
timeoutMillis = MONTH_SETTLE_TIMEOUT_MILLIS,
onHandOver = { controller.handOverHeld(parked.token) },
onRelease = { controller.releaseHeld(parked.token) },
)
MonthChipCopy(parked.copy, { glide.value }, origin, lift = 0f, alpha = alpha, timeFit = timeFit)
}
/** [drag]'s chip floating over the grid with its top left at [at], in root coordinates. */
@Composable
private fun MonthChipCopy(
drag: MonthChipDrag,
at: () -> Offset,
origin: Offset,
lift: Float,
alpha: Float,
timeFit: MonthChipTimeFit?,
) {
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
MonthBar(
event = drag.event,
dark = dark,
continuesLeft = false,
continuesRight = false,
// The time the source chip carried, drawn only if this one-column
// cut-out of it has the room — which a slice of a multi-day bar has
// not, however wide the bar was.
time = drag.time,
timeFit = timeFit,
chipWidth = with(density) { drag.sizePx.width.toDp() },
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
val topLeft = at()
IntOffset(
(topLeft.x - origin.x).roundToInt(),
(topLeft.y - origin.y).roundToInt(),
)
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift
this.alpha = alpha
shape = EventChipShape
clip = false
},
)
}
/**
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
* vertical scrolling owns the gesture, and the list never swaps wholesale — it
@@ -2310,7 +2258,7 @@ private fun MonthWeekRow(
// Once the grid holds the settled chip, tell the controller where this row
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
// which is what changes when the re-read lands.
LaunchedEffect(week, dragController?.settling, dragController?.held) {
LaunchedEffect(week, dragController?.settling) {
dragController?.noteSettled(rowToken)
}
@@ -2743,8 +2691,6 @@ private fun monthChipDragModifier(
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.ByDays(delta),
isAllDay = drop.event.isAllDay,
token = drop.token,
),
)
}
@@ -2890,17 +2836,8 @@ private fun MonthBar(
val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels.
val monthDrag = LocalMonthDrag.current
// Derived, so a chip recomposes only when its own answer changes rather than
// on every lift, park and release anywhere in the grid.
val lifted by remember(monthDrag, event, days) {
derivedStateOf {
monthDrag != null && (
monthDrag.liftedInstanceId == event.instanceId ||
monthDrag.isSettledChip(event, days) ||
monthDrag.isHeldChip(event, days)
)
}
}
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
monthDrag?.isSettledChip(event, days) == true
val ghost = ghostAlpha(lifted)
val shape = monthBarShape(continuesLeft, continuesRight)
Box(
@@ -9,28 +9,19 @@ import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -38,18 +29,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
@@ -58,13 +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.IconInfoCard
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.identity.collapseExit
@@ -72,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
/**
@@ -92,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)
@@ -102,31 +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 channelStatus by viewModel.channelStatus.collectAsStateWithLifecycle()
val digestBlocked by viewModel.digestBlocked.collectAsStateWithLifecycle()
val calendarIds = state.reminderCalendars.map { it.id }
LifecycleResumeEffect(calendarIds) {
viewModel.refreshChannelStatus(calendarIds)
onPauseOrDispose { }
}
val openCalendarSound: (Long) -> Unit = { calendarId ->
scope.launch { context.openNotificationSettings(viewModel.soundSettingsChannel(calendarId)) }
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
@@ -134,21 +93,6 @@ internal fun NotificationsScreen(
predictiveBack = true,
visible = visible,
) {
if (IS_SAMSUNG && !state.notificationCategoryHintDismissed) {
NotificationCategoryHint(
onOpen = {
viewModel.dismissNotificationCategoryHint()
scope.launch {
viewModel.syncReminderChannels()
// The summary's category too, so it is on the list to check.
if (state.dailyDigest.enabled) viewModel.digestChannel()
context.openNotificationSettings()
}
},
onDismiss = viewModel::dismissNotificationCategoryHint,
)
Spacer(Modifier.height(16.dp))
}
GroupedSwitchRow(
title = stringResource(R.string.settings_reminders),
checked = state.remindersEnabled,
@@ -156,34 +100,12 @@ internal fun NotificationsScreen(
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = if (channelStatus.appBlocked) {
stringResource(R.string.settings_notifications_blocked_hint)
} else {
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),
@@ -200,21 +122,6 @@ internal fun NotificationsScreen(
onClick = { showAllDayReminderTime = true },
)
Spacer(Modifier.height(24.dp))
DailyDigestRows(
digest = state.dailyDigest,
blocked = channelStatus.appBlocked || digestBlocked,
onEnable = toggleDigest,
onPickDay = { showDigestDay = true },
onPickTime = { showDigestTime = true },
onOpenSound = {
// The app's own page while the app is blocked: the channel's would be greyed out.
scope.launch {
context.openNotificationSettings(viewModel.digestChannel().takeUnless { channelStatus.appBlocked })
}
},
)
// Reliability is a soft, optional battery-optimisation exemption via
// a system-settings deep-link, shown as live status.
Spacer(Modifier.height(24.dp))
@@ -242,8 +149,10 @@ internal fun NotificationsScreen(
onClick = { showSnooze = true },
)
// Per-calendar notifications and reminder overrides (#145, #272).
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),
@@ -258,22 +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
val blocked = notifying && calendar.id in channelStatus.blockedCalendarIds
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = when {
// Still in the summary, so only its reminders are off.
!notifying && state.dailyDigest.enabled &&
calendar.id !in state.dailyDigest.excludedCalendarIds ->
stringResource(R.string.settings_calendar_reminders_off)
!notifying -> stringResource(R.string.settings_calendar_notifications_off)
blocked -> stringResource(R.string.settings_notifications_blocked)
else -> null
},
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = { ExpandIcon(expanded) },
@@ -290,18 +207,22 @@ internal fun NotificationsScreen(
enter = expandEnter(),
exit = collapseExit(),
) {
CalendarNotificationRows(
calendar = calendar,
state = state,
notifying = notifying,
blocked = blocked,
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) },
)
}
}
}
}
@@ -309,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),
@@ -426,199 +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,
blocked: 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 = if (blocked) {
stringResource(R.string.settings_notifications_blocked_hint)
} else {
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,
blocked: Boolean,
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 = if (blocked) {
stringResource(R.string.settings_notifications_blocked_hint)
} else {
stringResource(R.string.settings_digest_sound_hint)
},
position = Position.Bottom,
trailing = { ExternalLinkIcon() },
onClick = onOpenSound,
)
}
}
}
/** Samsung's One UI can start a new notification category switched off (#145). */
private val IS_SAMSUNG = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
/** The one-time Samsung note, with a way into the app's notification settings. */
@Composable
private fun NotificationCategoryHint(onOpen: () -> Unit, onDismiss: () -> Unit) {
IconInfoCard(
icon = Icons.Outlined.Info,
iconContentDescription = null,
iconAlignment = Alignment.Top,
) {
Text(
text = stringResource(R.string.settings_notification_category_hint),
style = MaterialTheme.typography.bodyMedium,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.reminder_action_dismiss)) }
TextButton(onClick = onOpen) { Text(stringResource(R.string.settings_notification_category_hint_open)) }
}
}
}
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,20 +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 event-duration 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 Samsung notification-category hint has been dismissed (#145); hidden until prefs load. */
val notificationCategoryHintDismissed: Boolean = true,
/**
* 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,15 +18,8 @@ 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.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -41,8 +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.ReminderChannelStatus
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
@@ -55,9 +45,7 @@ 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.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -74,15 +62,11 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
calendarPrefs: CalendarPrefs,
repository: CalendarRepository,
private val specialDatesEngine: SpecialDatesSyncEngine,
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() {
@@ -90,16 +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>> =
combine(repository.calendars(), calendarPrefs.pendingDisabledCalendarIds) { calendars, pending ->
calendars.filter { it.isVisibleInSystem && it.id !in pending }
}.catch { emit(emptyList()) }
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),
@@ -141,13 +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],
notificationCategoryHintDismissed = this[SettingsKeys.notificationCategoryHintDismissed],
writableCalendars = calendars,
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
managedCalendarIds = specialDatesCalendars.values.toSet(),
)
@@ -372,73 +350,13 @@ 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.
* Unmuting scans once more before the write, so the watermark starts at the
* unmute and nothing that came due while muted is replayed.
*/
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch {
if (enabled) reminderScanner.scan()
prefs.setCalendarRemindersEnabled(calendarId, enabled)
prefs.setRemindersEnabled(enabled)
reminderScanner.scan()
}
}
private val _channelStatus = MutableStateFlow(ReminderChannelStatus())
/** What the system blocks of the reminders; re-read with [refreshChannelStatus] (#145). */
val channelStatus: StateFlow<ReminderChannelStatus> = _channelStatus.asStateFlow()
private var channelStatusJob: Job? = null
fun refreshChannelStatus(calendarIds: Collection<Long>) {
channelStatusJob?.cancel()
channelStatusJob = viewModelScope.launch {
onChannels { status(calendarIds) }?.let { _channelStatus.value = it }
_digestBlocked.value = withContext(io) { runCatching { digestNotifier.isChannelOff() }.getOrDefault(false) }
}
}
private val _digestBlocked = MutableStateFlow(false)
/**
* Whether the daily summary's own channel is switched off (#459); the app-wide
* block is [channelStatus]'s. Refreshed with [refreshChannelStatus].
*/
val digestBlocked: StateFlow<Boolean> = _digestBlocked.asStateFlow()
fun dismissNotificationCategoryHint() = save { dismissNotificationCategoryHint() }
/**
* The channel whose system screen to open for [calendarId] (#340), created
* if needed; null when only the app's own screen can help — the app or the
* calendar's account group is blocked there, or the channel couldn't be made.
*/
suspend fun soundSettingsChannel(calendarId: Long): String? = onChannels {
val channelId = channelFor(calendarId) ?: return@onChannels null
val status = status(listOf(calendarId))
channelId.takeUnless { status.appBlocked || calendarId in status.groupBlockedCalendarIds }
}
/** 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 setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
@@ -451,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) }
@@ -461,53 +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)
// Created now, so a phone that starts new categories switched off
// shows it blocked here rather than dropping the first summary.
if (enabled) {
digestChannel()
_digestBlocked.value = withContext(io) { runCatching { digestNotifier.isChannelOff() }.getOrDefault(false) }
}
// 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() }
}
@@ -157,7 +157,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.ghostsDerived
import de.jeanlucmakiola.calendula.ui.common.StripDrag
import de.jeanlucmakiola.calendula.ui.common.StripDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
@@ -311,6 +310,14 @@ internal enum class TimelineKind(
// A run of days often straddles two weeks, so no single number fits it.
MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f),
Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f),
;
fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String =
if (this == Day) {
formatDayTitle(start, locale, currentYear, abbreviated)
} else {
formatWeekTitle(start, end, locale, currentYear, abbreviated)
}
}
internal val CalendarView.timelineKind: TimelineKind
@@ -485,10 +492,9 @@ private fun PagedTimelineScreen(
val jumpToToday = { jumpToDate(today) }
val locale = currentLocale()
// The day view names the month too: its header already shows weekday and date (#501).
val (title, shortTitle) = remember(weekStart, weekEnd, locale, today.year) {
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = false) to
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = true)
val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) {
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true)
}
PublishChrome(
ScreenChrome(
@@ -585,9 +591,7 @@ private fun WeekContent(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = MoveTarget.Start(start),
token = drop.token,
),
)
}
@@ -606,9 +610,7 @@ private fun WeekContent(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = MoveTarget.Span(drop.start, drop.end),
token = drop.token,
),
)
}
@@ -620,12 +622,10 @@ private fun WeekContent(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = when (drop) {
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
},
token = drop.token,
),
)
}
@@ -767,7 +767,8 @@ private fun WeekPager(
// Until its days arrive a page shows their frame without events.
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
// A bar held over other days in the strip makes room there before it is dropped.
val held = dragController.stripPreviews().takeIf { page == pagerState.currentPage }.orEmpty()
val held = (dragController.stripDrag ?: dragController.stripSettling)
?.takeIf { page == pagerState.currentPage }
val zone = LocalTimelineZone.current
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
val stripHeight = shown?.allDayStripHeight()
@@ -792,24 +793,17 @@ private fun WeekPager(
}
/**
* [this] with the strip bars [held] sideways laid out at the days they are over,
* so the bars there move aside live. Each bar keeps its own event, and with it
* its key and the gesture on it; one moved off the page keeps a lane it is not
* seen in.
* [this] with the strip bar [held] sideways laid out at the days it is over, so
* the bars there move aside live. The bar keeps its own event, and with it its
* key and the gesture on it; one moved off the page keeps a lane it is not seen in.
*/
internal fun WeekUiState.Success.withStripPreview(held: List<StripDrag>, zone: TimeZone): WeekUiState.Success {
val shifts = held.filter { !it.overGrid && it.dayShift != 0 }.associate { it.event.instanceId to it.dayShift }
if (shifts.isEmpty()) return this
val originals = allDaySpans.filter { it.event.instanceId in shifts }.associateBy { it.event.instanceId }
if (originals.isEmpty()) return this
val events = allDaySpans.map { span ->
shifts[span.event.instanceId]?.let { span.event.shiftedDays(it, zone) } ?: span.event
}
val spans = layoutAllDay(events, days, zone).map { span ->
originals[span.event.instanceId]?.let { span.copy(event = it.event) } ?: span
}
val laidOut = spans.mapTo(HashSet()) { it.event.instanceId }
return copy(allDaySpans = spans + originals.values.filter { it.event.instanceId !in laidOut }.map { it.copy(lane = 0) })
internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success {
if (held == null || held.overGrid || held.dayShift == 0) return this
val id = held.event.instanceId
val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this
val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event }
val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it }
return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0))
}
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
@@ -817,17 +811,6 @@ private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance
return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates))
}
/** Whether these bars draw the strip drop [drop] at the days it was dropped on. */
private fun List<PlacedBar>.drawStripDrop(drop: StripDrag, zone: TimeZone): Boolean {
val target = drop.event.spanFirstDay(zone).plus(drop.dayShift, DateTimeUnit.DAY)
return any { bar ->
bar.event.instanceId != drop.event.instanceId &&
(bar.event.eventId == drop.event.eventId ||
(drop.event.title.isNotBlank() && bar.event.title == drop.event.title)) &&
bar.event.spanFirstDay(zone) == target
}
}
/** A page with no events yet, for one whose days are still loading. */
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
@@ -1210,11 +1193,17 @@ private fun AllDayStrip(
val sideways = held?.takeIf { !it.overGrid }
val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false }
val settling = dragController.stripSettling?.takeIf { active }
val heldStrips = dragController.heldStrips.takeIf { active }.orEmpty()
// Seated once the re-read draws the event at the days it was dropped on.
LaunchedEffect(settling, heldStrips, bars) {
if (settling != null && bars.drawStripDrop(settling, zone)) dragController.seatStrip()
heldStrips.forEach { if (!it.seated && bars.drawStripDrop(it.drag, zone)) dragController.seatHeldStrip(it.token) }
LaunchedEffect(settling, bars) {
if (settling == null) return@LaunchedEffect
val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY)
val landed = bars.any { bar ->
bar.event.instanceId != settling.event.instanceId &&
(bar.event.eventId == settling.event.eventId ||
(settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) &&
bar.event.spanFirstDay(zone) == target
}
if (landed) dragController.seatStrip()
}
Row(
@@ -1599,7 +1588,7 @@ private fun DayColumnCard(
widthFraction = 1f / block.laneCount,
y = top,
height = height,
landing = dragController.ghostsDerived(block),
landing = dragController.ghosts(block),
)
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
@@ -1767,7 +1756,7 @@ private fun EventBlock(
onCancel = dragController::cancel,
onRelease = { dragController.select(block.event, fromLift = draggable) },
)
val lifted = draggable && dragController.ghostsDerived(block)
val lifted = draggable && dragController.ghosts(block)
// Released in place, the block takes over the copy's lift without a dip.
val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted)
@@ -2027,7 +2016,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
formatMinuteOfDay(min, is24Hour, locale)
/**
* Every timeline's title, the day view's included: just the month [weekStart] falls in.
* The week's title: just the month [weekStart] falls in.
*
* The day numbers are already in the column headers directly below, so spelling
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
@@ -2042,7 +2031,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
* "December" with no year would be actively misleading while the reader is
* looking straight at January dates.
*/
private fun formatTimelineTitle(
private fun formatWeekTitle(
weekStart: LocalDate,
weekEnd: LocalDate,
locale: Locale,
@@ -2057,3 +2046,17 @@ private fun formatTimelineTitle(
forceYear = weekEnd.year != weekStart.year,
)
}
/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = date.toJavaLocalDate(),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
)
+1 -52
View File
@@ -110,9 +110,6 @@
<string name="event_move_failed">Couldn\'t move the event</string>
<string name="event_move_write_denied">Calendula needs write access to move events</string>
<string name="event_move_gone">That event no longer exists</string>
<string name="event_move_stale">That event moved before your drop could be saved</string>
<string name="event_move_queue_full">Too many moves at once, so that one wasn\'t saved</string>
<string name="event_move_skipped">Moves made while the question was open weren\'t saved</string>
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
<string name="event_move_blocked_cancelled_series">A cancelled series can only move as a whole, and this would change which days it falls on</string>
@@ -731,56 +728,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_calendar_reminders_off">Reminders 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_notifications_blocked">Blocked in system settings</string>
<string name="settings_notifications_blocked_hint">Blocked in system settings — tap to allow</string>
<string name="settings_notification_category_hint">On Samsung phones, new notification categories can start switched off. If a calendar\'s reminders don\'t arrive, check that its category is on.</string>
<string name="settings_notification_category_hint_open">Open 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,151 +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)
}
@Test
fun `blocked calendars follow app, channel and group`() {
val channels = mapOf(
reminderChannelId(1) to ReminderChannelState(blocked = true, groupId = "g1"),
reminderChannelId(2) to ReminderChannelState(blocked = false, groupId = "g2"),
reminderChannelId(3) to ReminderChannelState(blocked = false, groupId = "g1"),
)
val ids = listOf(1L, 2L, 3L, 4L)
val groupOff = reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = setOf("g2"))
assertThat(groupOff.blockedCalendarIds).containsExactly(1L, 2L)
assertThat(groupOff.groupBlockedCalendarIds).containsExactly(2L)
assertThat(reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = emptySet()).blockedCalendarIds)
.containsExactly(1L)
val appOff = reminderChannelStatus(ids, appEnabled = false, channels, blockedGroupIds = emptySet())
assertThat(appOff.appBlocked).isTrue()
assertThat(appOff.blockedCalendarIds).containsExactly(1L, 2L, 3L, 4L)
}
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()
}
}
@@ -13,12 +13,10 @@ import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDate
@@ -81,44 +79,15 @@ class RescheduleViewModelTest {
status = status,
)
/**
* [present] are the events whose occurrence the provider still reports where
* a drop picked it up; [io] a [StandardTestDispatcher] keeps a drop in flight.
*/
private fun viewModel(
tempDir: Path,
fake: FakeCalendarDataSource,
present: List<Long> = listOf(42L, 7L),
io: CoroutineDispatcher = dispatcher,
): RescheduleViewModel {
fake.occurrencesAt(present)
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
val repo = testRepository(
fake, tempDir, io,
fake, tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = settings,
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
return RescheduleViewModel(repo, io, FakeZoneSource(), settings)
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
}
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
viewModel(tempDir, fake, io = StandardTestDispatcher(dispatcher.scheduler))
/** Every query answers with an occurrence of each of [ids] exactly at the queried span. */
private fun FakeCalendarDataSource.occurrencesAt(ids: List<Long>) {
instancesResult = { begin, end ->
ids.map { id ->
testEvent(
id = id,
start = Instant.fromEpochMilliseconds(begin),
end = Instant.fromEpochMilliseconds(end),
)
}
}
}
private val RescheduleViewModel.abandonedCount: Int get() = drops.value.abandoned.size
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
launch { vm.movableCalendarIds.collect {} }
@@ -128,8 +97,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.ByDays(2),
isAllDay = false,
token = nextMoveToken(),
)
/** A drop an hour later, expressed the way a timeline drag expresses one. */
@@ -138,8 +105,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
isAllDay = false,
token = nextMoveToken(),
)
/** The end dragged an hour later, the start left where it is. */
@@ -151,8 +116,6 @@ class RescheduleViewModelTest {
Instant.fromEpochMilliseconds(beginMillis),
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
),
isAllDay = false,
token = nextMoveToken(),
)
@Test
@@ -296,228 +259,21 @@ class RescheduleViewModelTest {
}
@Test
fun `a drop of another event waits for the one in flight, then lands`(
fun `a second drop is refused while the first is still in flight`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail() }
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = slowViewModel(tempDir, fake)
val first = oneHourLater()
val second = toWednesday().copy(eventId = 7L, token = nextMoveToken())
assertThat(vm.move(first)).isTrue()
assertThat(vm.move(second)).isTrue()
assertThat(vm.drops.value.pending).containsExactly(first.token, second.token)
advanceUntilIdle()
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L, 7L).inOrder()
assertThat(vm.drops.value).isEqualTo(DropStates())
assertThat(vm.inFlight.value).isFalse()
}
@Test
fun `a failed drop lets go of its own copy only, not the one queued behind it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail() }
}
val vm = slowViewModel(tempDir, fake)
// Zero distance: refused in prepare.
val nowhere = oneHourLater().copy(
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
token = nextMoveToken(),
)
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
vm.move(nowhere)
vm.move(queued)
advanceUntilIdle()
assertThat(vm.drops.value.abandoned).containsExactly(nowhere.token)
assertThat(fake.updatedEvents.map { it.first }).containsExactly(7L)
}
@Test
fun `the same event dropped twice ends up where it was dropped last`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val hour = 3_600_000L
val fake = FakeCalendarDataSource()
val vm = slowViewModel(tempDir, fake)
// Once the first drop is written, the event sits an hour later.
fake.eventDetailResult = {
if (fake.updatedEvents.isEmpty()) detail() else detail(anchorMillis = beginMillis + hour)
}
fake.instancesResult = { _, _ ->
val shift = if (fake.updatedEvents.isEmpty()) 0L else hour
listOf(
testEvent(
id = 42L,
start = Instant.fromEpochMilliseconds(beginMillis + shift),
end = Instant.fromEpochMilliseconds(endMillis + shift),
),
)
}
vm.move(oneHourLater())
// Picked up from where the grid still drew it, and dropped two days on.
vm.move(toWednesday())
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(2)
val last = fake.updatedEvents.last().third
assertThat(last.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(13, 0)))
}
@Test
fun `a drop on a series written moments ago is not written`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// A whole-series write shifts every occurrence; the grid still draws the
// old ones, and another occurrence may now sit at the dragged time.
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=DAILY") } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
vm.consumeOutcome()
val again = toWednesday()
vm.move(again)
assertThat(vm.move(toWednesday())).isTrue()
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Stale)
assertThat(vm.drops.value.abandoned).containsExactly(again.token)
assertThat(fake.updatedEvents).hasSize(1)
}
@Test
fun `a single occurrence written leaves the rest of its series movable`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
// Parked on the scope dialog, so the first drop still owns the pipeline.
assertThat(vm.move(oneHourLater())).isFalse()
advanceUntilIdle()
vm.moveWithScope(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
// Another occurrence, a week on.
val week = 7 * 86_400_000L
vm.move(oneHourLater().copy(beginMillis = beginMillis + week, endMillis = endMillis + week, token = nextMoveToken()))
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNotNull()
}
@Test
fun `chip messages wait their turn instead of replacing each other`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { id ->
if (id == 42L) detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE", status = EventStatus.Cancelled) else detail()
}
}
val vm = slowViewModel(tempDir, fake)
vm.move(toWednesday())
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
vm.consumeOutcome()
assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(7L)
vm.consumeOutcome()
assertThat(vm.outcome.value).isNull()
}
@Test
fun `a full queue says so`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = slowViewModel(tempDir, fake)
repeat(9) { i -> vm.move(toWednesday().copy(eventId = 100L + i, token = nextMoveToken())) }
val refused = toWednesday().copy(eventId = 200L, token = nextMoveToken())
assertThat(vm.move(refused)).isFalse()
assertThat(vm.drops.value.abandoned).containsExactly(refused.token)
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.QueueFull)
}
@Test
fun `a deleted event reports itself as gone, not moved`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } }
val vm = viewModel(tempDir, fake, present = emptyList())
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone)
}
@Test
fun `drops waiting when a scope dialog opens are cancelled, not held behind it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { id -> if (id == 42L) detail(rrule = "FREQ=WEEKLY") else detail() }
}
val vm = slowViewModel(tempDir, fake)
vm.move(oneHourLater())
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
vm.move(queued)
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNotNull()
assertThat(vm.drops.value.abandoned).containsExactly(queued.token)
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped)
// One made while the dialog is up is refused the same way.
val during = toWednesday().copy(eventId = 7L, token = nextMoveToken())
assertThat(vm.move(during)).isFalse()
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L)
// The skip has its turn, then the move it waited on, with its Undo.
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped)
vm.consumeOutcome()
assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(42L)
}
@Test
fun `the first move of a run keeps the chip and its undo`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = slowViewModel(tempDir, fake)
vm.move(oneHourLater())
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
advanceUntilIdle()
val shown = vm.outcome.value as MoveOutcome.Moved
assertThat(fake.updatedEvents).hasSize(2)
assertThat(shown.undo?.eventId).isEqualTo(42L)
}
@Test
fun `a later move of the same event takes the first move's undo away`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = slowViewModel(tempDir, fake)
vm.move(oneHourLater())
vm.move(toWednesday())
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(2)
assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull()
assertThat(fake.updatedEvents).isEmpty()
}
@Test
@@ -528,19 +284,19 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
// A second drop, of another event, parks on its scope dialog, which holds the pipeline.
// A second drop parks on its scope dialog, which holds the pipeline.
fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
vm.move(toWednesday())
advanceUntilIdle()
val abandoned = vm.abandonedCount
val abandoned = vm.abandoned.value
assertThat(vm.undo(undo)).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(1)
// Nothing ticks: the refusal started no journey of its own, and the
// counter is global — a tick here would release the copy the drop that
// *is* in flight is holding, before the grid has drawn it.
assertThat(vm.abandonedCount).isEqualTo(abandoned)
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
@@ -555,13 +311,13 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandonedCount
val abandoned = vm.abandoned.value
fake.writeError = SecurityException("permission revoked")
assertThat(vm.undo(undo)).isTrue()
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandonedCount).isEqualTo(abandoned + 1)
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
}
@Test
@@ -574,12 +330,12 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandonedCount
val abandoned = vm.abandoned.value
vm.undo(undo)
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
assertThat(vm.abandonedCount).isEqualTo(abandoned)
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
@@ -659,8 +415,6 @@ class RescheduleViewModelTest {
target = MoveTarget.Start(
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -681,8 +435,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -804,19 +556,17 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
isAllDay = false,
token = nextMoveToken(),
),
)).isTrue()
advanceUntilIdle()
assertThat(vm.abandonedCount).isEqualTo(1)
assertThat(vm.abandoned.value).isEqualTo(1)
// Refused by the provider, after the write was attempted.
fake.writeError = SecurityException("revoked")
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandonedCount).isEqualTo(2)
assertThat(vm.abandoned.value).isEqualTo(2)
}
@Test
@@ -830,11 +580,11 @@ class RescheduleViewModelTest {
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.abandonedCount).isEqualTo(0)
assertThat(vm.abandoned.value).isEqualTo(0)
vm.cancelScope()
advanceUntilIdle()
assertThat(vm.abandonedCount).isEqualTo(1)
assertThat(vm.abandoned.value).isEqualTo(1)
}
@Test
@@ -846,7 +596,7 @@ class RescheduleViewModelTest {
advanceUntilIdle()
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandonedCount).isEqualTo(0)
assertThat(vm.abandoned.value).isEqualTo(0)
}
@Test
@@ -918,8 +668,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -961,7 +709,7 @@ class RescheduleViewModelTest {
assertThat(vm.scopePrompt.value).isNull()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
assertThat(vm.abandonedCount).isEqualTo(1)
assertThat(vm.abandoned.value).isEqualTo(1)
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
@@ -991,8 +739,6 @@ class RescheduleViewModelTest {
beginMillis = dayStart,
endMillis = dayStart + 86_400_000L,
target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)),
isAllDay = true,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -1029,8 +775,6 @@ class RescheduleViewModelTest {
beginMillis = dayStart,
endMillis = dayStart + 86_400_000L,
target = MoveTarget.Span(eight, eight + 1.hours),
isAllDay = true,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -53,7 +53,7 @@ class TimelineDropTest {
@Test
fun `dropping the head asks for exactly where it landed`() {
val target = LocalDate(2026, 9, 2)
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0, token = 0L)
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
}
@@ -63,7 +63,7 @@ class TimelineDropTest {
val tail = blockOn(sun)
val offset = tail.clipOffsetMinutes(sun, zone)
// Dragged three days back, same height in the column: Sun 6 -> Thu 3.
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset, token = 0L)
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset)
// The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 —
// the whole event moved three days, and no time of day was invented.
@@ -75,7 +75,7 @@ class TimelineDropTest {
fun `dragging the tail down the column re-times the event across midnight`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// Tail top pulled from 00:00 to 03:00 on its own day.
val drop = TimelineDrop(overnight, sun, 3 * 60, offset, token = 0L)
val drop = TimelineDrop(overnight, sun, 3 * 60, offset)
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
}
@@ -84,7 +84,7 @@ class TimelineDropTest {
fun `pulling the tail above its own midnight moves the event earlier`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// The tail's top edge dragged an hour above the midnight it rests on.
val drop = TimelineDrop(overnight, sun, -60, offset, token = 0L)
val drop = TimelineDrop(overnight, sun, -60, offset)
// Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier.
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone))
@@ -92,7 +92,7 @@ class TimelineDropTest {
@Test
fun `a top edge dragged past the next midnight resolves onto the day after`() {
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0, token = 0L)
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
}
@@ -130,7 +130,7 @@ class TimelineDropTest {
val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
val offset = tail.clipOffsetMinutes(sun, berlin)
// Dropped back onto its own slot a week earlier, clear of the boundary.
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset, token = 0L)
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset)
assertThat(drop.startInstant(berlin))
.isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
@@ -155,7 +155,7 @@ class TimelineDropTest {
)
val block = layoutDay(listOf(event), fallBack, brussels).single()
assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0)
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0, token = 0L)
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0)
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
}
@@ -168,7 +168,7 @@ class TimelineDropTest {
val tail = layoutDay(listOf(event), fallBack, brussels).single()
val offset = tail.clipOffsetMinutes(fallBack, brussels)
assertThat(offset).isEqualTo(2 * 60)
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset, token = 0L)
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset)
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
}
@@ -189,7 +189,7 @@ class TimelineDropTest {
end = Instant.parse("2026-10-25T02:30:00Z"),
)
val block = layoutDay(listOf(event), fallBack, brussels).single()
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0, token = 0L)
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0)
assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
}
@@ -234,7 +234,7 @@ class TimelineDropTest {
start = Instant.parse("2026-10-25T01:30:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
assertThat(drop.start).isEqualTo(event.start)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
}
@@ -246,7 +246,7 @@ class TimelineDropTest {
start = Instant.parse("2026-10-25T00:30:00Z"),
end = Instant.parse("2026-10-25T01:15:00Z"),
)
val drop = resizedSpan(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z"))
assertThat(drop.end).isEqualTo(event.end)
}
@@ -258,7 +258,7 @@ class TimelineDropTest {
start = Instant.parse("2026-10-25T00:20:00Z"),
end = Instant.parse("2026-10-25T01:10:00Z"),
)
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
}
@@ -270,7 +270,7 @@ class TimelineDropTest {
start = Instant.parse("2026-10-24T23:00:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z"))
}
}
@@ -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()
}
}
@@ -356,12 +356,12 @@ class WeekLayoutTest {
)
assertThat(state.allDaySpans.map { it.lane }).containsExactly(0, 0)
val preview = state.withStripPreview(listOf(StripDrag(held, dayShift = 2)), zone).allDaySpans
val preview = state.withStripPreview(StripDrag(held, dayShift = 2), zone).allDaySpans
val moved = preview.single { it.event.instanceId == held.instanceId }
assertThat(moved.event).isEqualTo(held)
assertThat(moved.startCol).isEqualTo(2)
assertThat(preview.map { it.lane }).containsExactly(0, 1)
assertThat(state.withStripPreview(listOf(StripDrag(held, dayShift = 2, overGrid = true)), zone)).isSameInstanceAs(state)
assertThat(state.withStripPreview(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state)
}
}
-65
View File
@@ -391,71 +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. The settings screen re-reads `ReminderChannels.status` on every
resume and marks a calendar "Blocked in system settings" when the app, its
channel or its account group is switched off there.
**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.
Its channel's blocked state is read separately (`DailyDigestNotifier.isChannelOff`,
with the app-wide block from `ReminderChannels.status`); while it is off the
scan neither posts nor records the day, so allowing it brings the summary back.
The channel is created when the summary is switched on, so a phone that starts
new categories off shows it blocked in settings before the first summary.
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
@@ -1,6 +0,0 @@
الإصلاحات
• لم تعد الأحداث المنقولة بسرعة متتالية تضيع: تُحفظ عمليات النقل بالترتيب.
• تظهر رسالة عند تعذّر حفظ عملية نقل.
التغييرات
• يعرض عنوان عرض اليوم الشهر.
@@ -1,6 +0,0 @@
Выпраўлена
• Падзеі, перанесеныя хутка адна за адной, больш не губляюцца: пераносы захоўваюцца па чарзе.
• Паведамленне паказвае, калі перанос не ўдалося захаваць.
Зменена
• Загаловак дзённага прагляду паказвае месяц.
@@ -1,6 +0,0 @@
Opraveno
• Události rychle přesunuté za sebou se už neztrácí: přesuny se ukládají postupně.
• Zpráva upozorní, když přesun nešel uložit.
Změněno
• Název denního zobrazení ukazuje měsíc.
@@ -1,6 +0,0 @@
Behoben
• Schnell hintereinander verschobene Termine gehen nicht mehr verloren: Verschiebungen werden der Reihe nach gespeichert.
• Eine Meldung sagt, wenn eine Verschiebung nicht gespeichert werden konnte.
Geändert
• Der Titel der Tagesansicht zeigt den Monat.
@@ -1,6 +0,0 @@
Fixed
• Events moved in quick succession are no longer lost: moves are saved in order.
• A message says when a move could not be saved.
Changed
• The day view title shows the month.
@@ -1,6 +0,0 @@
Correcciones
• Los eventos movidos rápidamente uno tras otro ya no se pierden: los cambios se guardan en orden.
• Un mensaje avisa cuando no se pudo guardar un cambio.
Cambios
• El título de la vista diaria muestra el mes.
@@ -1,6 +0,0 @@
Corrections
• Les événements déplacés rapidement à la suite ne sont plus perdus : les déplacements sont enregistrés dans l’ordre.
• Un message indique quand un déplacement n’a pas pu être enregistré.
Modifications
• Le titre de la vue jour affiche le mois.
@@ -1,6 +0,0 @@
Javítva
• A gyors egymásutánban áthelyezett események többé nem vesznek el: az áthelyezések sorban mentődnek.
• Üzenet jelzi, ha egy áthelyezést nem sikerült menteni.
Változások
• A napi nézet címe a hónapot mutatja.
@@ -1,6 +0,0 @@
Correzioni
• Gli eventi spostati rapidamente uno dopo l’altro non vanno più persi: gli spostamenti vengono salvati in ordine.
• Un messaggio avvisa quando uno spostamento non è stato salvato.
Modifiche
• Il titolo della vista giornaliera mostra il mese.
@@ -1,6 +0,0 @@
修正
• 続けてすばやく移動した予定が失われなくなりました。移動は順番に保存されます。
• 移動を保存できなかったときはメッセージが表示されます。
変更
• 日表示のタイトルに月が表示されるようになりました。
@@ -1,6 +0,0 @@
Opgelost
• Snel na elkaar verplaatste afspraken gaan niet meer verloren: verplaatsingen worden op volgorde opgeslagen.
• Een melding laat weten wanneer een verplaatsing niet kon worden opgeslagen.
Gewijzigd
• De titel van de dagweergave toont de maand.
@@ -1,6 +0,0 @@
Poprawki
• Wydarzenia szybko przenoszone jedno po drugim już nie giną: przeniesienia są zapisywane po kolei.
• Komunikat informuje, gdy przeniesienia nie udało się zapisać.
Zmiany
• Tytuł widoku dnia pokazuje miesiąc.
@@ -1,6 +0,0 @@
Correções
• Eventos movidos rapidamente um após o outro não se perdem mais: as mudanças são salvas em ordem.
• Uma mensagem avisa quando uma mudança não pôde ser salva.
Alterações
• O título da visualização diária mostra o mês.
@@ -1,6 +0,0 @@
Correções
• Os eventos movidos rapidamente uns após os outros já não se perdem: as alterações são guardadas por ordem.
• Uma mensagem avisa quando uma alteração não pôde ser guardada.
Alterações
• O título da vista diária mostra o mês.
@@ -1,6 +0,0 @@
Исправлено
• События, быстро перенесённые одно за другим, больше не теряются: переносы сохраняются по очереди.
• Сообщение показывает, если перенос не удалось сохранить.
Изменено
• Заголовок дневного вида показывает месяц.
@@ -1,6 +0,0 @@
Opravené
• Udalosti rýchlo presunuté za sebou sa už nestrácajú: presuny sa ukladajú postupne.
• Správa upozorní, keď sa presun nepodarilo uložiť.
Zmenené
• Názov denného zobrazenia ukazuje mesiac.
@@ -1,6 +0,0 @@
修复
• 快速连续移动的日程不再丢失:移动会按顺序保存。
• 移动无法保存时会显示提示。
变更
• 日视图标题显示月份。
@@ -1,6 +0,0 @@
修正
• 快速連續移動的活動不再遺失:移動會依序儲存。
• 移動無法儲存時會顯示訊息。
變更
• 日檢視標題會顯示月份。
+2 -2
View File
@@ -4,7 +4,7 @@ kotlin = "2.4.20"
ksp = "2.3.11"
hilt = "2.60.1"
coreKtx = "1.19.0"
appcompat = "1.8.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.11.0"
activityCompose = "1.13.0"
composeBom = "2026.09.00"
@@ -18,7 +18,7 @@ junitPlatform = "6.1.3"
truth = "1.4.5"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
kotlinxDatetime = "0.8.0-0.6.x-compat"
kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.11.0"
turbine = "1.2.1"
hiltNavigationCompose = "1.4.0"