feat(#459): reminders at a set time and a daily summary notification
This commit is contained in:
1 parent
fa999f8cf0
commit
c28582ae6f
27 files changed
+1518
-50
No files matched your search
@@ -67,7 +67,10 @@ 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
|
||||
@@ -620,6 +623,20 @@ 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,6 +35,7 @@ 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
|
||||
@@ -919,11 +920,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
minutes
|
||||
}
|
||||
|
||||
/** [form]'s reminders as the distinct raw provider offsets to store. */
|
||||
/**
|
||||
* [form]'s reminders as the distinct raw provider offsets to store, its
|
||||
* fixed-time ones (#459) measured from the start it is saved with.
|
||||
*/
|
||||
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
|
||||
form.reminders
|
||||
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
|
||||
.distinct()
|
||||
(
|
||||
form.reminders.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) } +
|
||||
form.fixedReminderOffsets(TimeZone.currentSystemDefault())
|
||||
).distinct()
|
||||
|
||||
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||
// A globally-unique UID so a later .ics backup/restore can identify
|
||||
@@ -1026,7 +1031,11 @@ 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.
|
||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||
// 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()
|
||||
) {
|
||||
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||
}
|
||||
// Same untouched-set guard for guests: only reconcile when the form's
|
||||
|
||||
@@ -5,6 +5,9 @@ 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.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
|
||||
@@ -177,6 +180,19 @@ 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()
|
||||
}
|
||||
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)
|
||||
@@ -329,6 +345,31 @@ class SettingsPrefs @Inject constructor(
|
||||
suspend fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) =
|
||||
prefs.applyOverride(SettingsKeys.perCalendarAllDayReminderOverride, calendarId, override)
|
||||
|
||||
suspend fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) =
|
||||
prefs.set(SettingsKeys.defaultFixedTimeReminder, reminder)
|
||||
|
||||
/** [override] [FixedTimeOverride.Inherit] drops the calendar's entry. */
|
||||
suspend fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) {
|
||||
prefs.update(SettingsKeys.perCalendarFixedTimeReminderOverride) { current ->
|
||||
when (override) {
|
||||
FixedTimeOverride.Inherit -> current - calendarId
|
||||
FixedTimeOverride.Off -> current + (calendarId to null)
|
||||
is FixedTimeOverride.At -> current + (calendarId to override.reminder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDailyDigestEnabled(enabled: Boolean) = prefs.set(SettingsKeys.dailyDigestEnabled, enabled)
|
||||
|
||||
suspend fun setDailyDigestTimeMinutes(minutesOfDay: Int) =
|
||||
prefs.set(SettingsKeys.dailyDigestTimeMinutes, minutesOfDay)
|
||||
|
||||
suspend fun setDailyDigestDay(day: DigestDay) = prefs.set(SettingsKeys.dailyDigestDay, day)
|
||||
|
||||
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) =
|
||||
@@ -378,6 +419,8 @@ 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
|
||||
/** 20:00, when the daily summary arrives until changed. */
|
||||
internal const val DEFAULT_DIGEST_TIME = 1_200
|
||||
/** Default snooze delay for the notification "Snooze" action. */
|
||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||
const val DEFAULT_MULTI_DAY_COUNT = 3
|
||||
@@ -694,6 +737,34 @@ 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", SettingsPrefs.DEFAULT_DIGEST_TIME).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")
|
||||
|
||||
/** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */
|
||||
val autoBackupEnabled: Pref<Boolean> = booleanPref("auto_backup_enabled", false)
|
||||
|
||||
@@ -890,6 +961,56 @@ 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],
|
||||
)
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
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.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.toKotlinLocalDate
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
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,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
fun canPost(): Boolean {
|
||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
/**
|
||||
* Post the summary of [day] listing [entries], or withdraw the last one when
|
||||
* there is nothing to list — yesterday's would otherwise stand in for it.
|
||||
*/
|
||||
suspend fun post(day: LocalDate, entries: List<ReminderEventInstance>) {
|
||||
val switchedOff = calendarPrefs.pendingDisabledCalendarIds.first()
|
||||
val listed = entries.filter { it.calendarId !in switchedOff }
|
||||
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 dayLabel = when (day) {
|
||||
today -> context.getString(R.string.reminder_day_today)
|
||||
today.plusDays(1) -> context.getString(R.string.reminder_day_tomorrow)
|
||||
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(day)
|
||||
}
|
||||
val lines = listed.map { entry ->
|
||||
val time = reminderTimeText(
|
||||
beginMillis = entry.beginMillis,
|
||||
endMillis = entry.endMillis,
|
||||
isAllDay = entry.isAllDay,
|
||||
zone = zone,
|
||||
locale = locale,
|
||||
is24Hour = is24Hour,
|
||||
today = day,
|
||||
// Only cross-day spans name a date, so the week start never shows.
|
||||
firstDayOfWeek = DayOfWeek.MONDAY,
|
||||
todayLabel = context.getString(R.string.event_detail_all_day),
|
||||
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)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
/** The summary's channel id, created if missing — for the settings deep-link. */
|
||||
fun channelId(): String {
|
||||
ensureChannel()
|
||||
return CHANNEL_ID
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
||||
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
|
||||
@@ -43,6 +46,7 @@ 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,
|
||||
@@ -78,14 +82,22 @@ class ReminderScanner @Inject constructor(
|
||||
val now = System.currentTimeMillis()
|
||||
if (!hasReadCalendar()) return
|
||||
|
||||
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
|
||||
}
|
||||
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 = if (settingsPrefs.remindersEnabled.first()) reminders(lastFired, now, horizon) else null
|
||||
|
||||
// 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
|
||||
@@ -104,9 +116,9 @@ class ReminderScanner @Inject constructor(
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned = planned,
|
||||
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||
lastFiredMillis = lastFired,
|
||||
nowMillis = now,
|
||||
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||
horizonMillis = horizon,
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
@@ -116,10 +128,28 @@ class ReminderScanner @Inject constructor(
|
||||
notifier.post(alert)
|
||||
}
|
||||
}
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog.
|
||||
state.setLastScanMillis(now)
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
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 schedule = scheduleDigest(settings.timeMinutes, settings.day, zone, lastFired, now)
|
||||
val day = schedule.dueDay ?: return schedule.nextAlarmMillis
|
||||
// A failed summary must not cost the event reminders this pass owes.
|
||||
if (digestNotifier.canPost()) runCatching {
|
||||
// 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 entries = digestEntries(source.occurrences(from, to), day, zone, settings.excludedCalendarIds)
|
||||
digestNotifier.post(day, entries)
|
||||
}.onFailure { Log.w(TAG, "Daily summary failed", it) }
|
||||
return schedule.nextAlarmMillis
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -40,6 +42,11 @@ 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. Always empty on an all-day event.
|
||||
*/
|
||||
val fixedReminders: List<FixedTimeReminder> = emptyList(),
|
||||
val availability: Availability = Availability.Busy,
|
||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||
/** Confirmed / Tentative / Cancelled (#388). */
|
||||
@@ -286,7 +293,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()) add(EventFormField.Reminders)
|
||||
if (reminders.isNotEmpty() || fixedReminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (status != EventStatus.Confirmed) add(EventFormField.Status)
|
||||
@@ -334,3 +341,21 @@ fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
|
||||
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)
|
||||
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) }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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) }
|
||||
|
||||
/** The daily summary's settings. Calendars in [excludedCalendarIds] are left out of it. */
|
||||
data class DailyDigestSettings(
|
||||
val enabled: Boolean = false,
|
||||
val timeMinutes: Int = 1_200,
|
||||
val day: DigestDay = DigestDay.Tomorrow,
|
||||
val excludedCalendarIds: Set<Long> = emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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, measured against the same watermark as the event
|
||||
* reminders: 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.
|
||||
*/
|
||||
fun scheduleDigest(
|
||||
timeMinutes: Int,
|
||||
day: DigestDay,
|
||||
zone: ZoneId,
|
||||
lastFiredMillis: Long,
|
||||
nowMillis: Long,
|
||||
): 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 dueDay = listed.takeIf { fireAt(latestDate) > lastFiredMillis && !it.isBefore(today) }
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 ([splitTimedReminders]); 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 moment this reminder names for an event starting at [start], read in [zone]. */
|
||||
fun alarmAt(start: Instant, zone: TimeZone): Instant {
|
||||
val day = start.toLocalDateTime(zone).date.minus(daysBefore, DateTimeUnit.DAY)
|
||||
return LocalDateTime(day, LocalTime(minuteOfDay / 60, minuteOfDay % 60)).toInstant(zone)
|
||||
}
|
||||
|
||||
/** The provider offset to store for an event starting at [start]. */
|
||||
fun offsetMinutes(start: Instant, zone: TimeZone): Int =
|
||||
(start - alarmAt(start, zone)).inWholeMinutes.toInt()
|
||||
|
||||
/** The stored form, `days@minuteOfDay`. */
|
||||
fun toToken(): String = "$daysBefore$TOKEN_SEP$minuteOfDay"
|
||||
|
||||
companion object {
|
||||
const val MAX_DAYS_BEFORE = 28
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
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].
|
||||
*/
|
||||
fun fixedTimeReminderAt(offsetMinutes: Int, start: Instant, zone: TimeZone): FixedTimeReminder? {
|
||||
if (offsetMinutes <= 0) return null
|
||||
val alarm = (start - offsetMinutes.minutes).toLocalDateTime(zone)
|
||||
val startDate = start.toLocalDateTime(zone).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>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Sort a timed event's raw offsets into lead times and "N days before at HH:MM"
|
||||
* reminders. The provider can't say which an offset was meant as, so:
|
||||
*
|
||||
* - one landing on a pattern in [known] (the configured defaults) is fixed-time;
|
||||
* - otherwise one reaching back past the start's own day is fixed-time, unless it
|
||||
* is a whole number of days or one of [plainLeadMinutes] — those read as the
|
||||
* lead time they look like.
|
||||
*
|
||||
* The cost of guessing wrong is small: the reminder fires at the same moment
|
||||
* either way, and only a later change of the start's time of day treats it
|
||||
* differently. A hand-entered "3 hours before" on an event at 01:00 does read as
|
||||
* "1 day before at 22:00".
|
||||
*/
|
||||
fun splitTimedReminders(
|
||||
offsets: List<Int>,
|
||||
start: Instant,
|
||||
zone: TimeZone,
|
||||
known: Set<FixedTimeReminder>,
|
||||
plainLeadMinutes: Collection<Int> = PLAIN_LEAD_MINUTES,
|
||||
): TimedReminders {
|
||||
val leads = mutableListOf<Int>()
|
||||
val fixed = mutableListOf<FixedTimeReminder>()
|
||||
offsets.distinct().forEach { offset ->
|
||||
val pattern = fixedTimeReminderAt(offset, start, zone)
|
||||
val isFixed = pattern != null && (
|
||||
pattern in known ||
|
||||
(offset % MINUTES_PER_DAY != 0 && offset !in plainLeadMinutes)
|
||||
)
|
||||
if (isFixed) 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 }
|
||||
|
||||
/** Lead times offered as presets: always read as what they look like. */
|
||||
private val PLAIN_LEAD_MINUTES = setOf(5, 10, 15, 30, 45, 60, 90, 120)
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
@@ -0,0 +1,208 @@
|
||||
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, plus a confirm. Seeded from [initial]; [onConfirm] gets the
|
||||
* finished reminder. [connected] gives it the shape that meets a row above it.
|
||||
*/
|
||||
@Composable
|
||||
fun FixedTimeReminderEditor(
|
||||
initial: FixedTimeReminder,
|
||||
confirmLabel: String,
|
||||
onConfirm: (FixedTimeReminder) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
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 }
|
||||
|
||||
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 = pluralStringResource(R.plurals.reminder_fixed_days_before_at, days ?: 1),
|
||||
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 = days?.let { fixedTimeReminderLabel(FixedTimeReminder(it, minuteOfDay)) }
|
||||
?: stringResource(R.string.reminder_fixed_days_range, FixedTimeReminder.MAX_DAYS_BEFORE),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
enabled = days != null,
|
||||
onClick = { days?.let { onConfirm(FixedTimeReminder(it, minuteOfDay)) } },
|
||||
) { 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. Applies live; the user leaves via
|
||||
* back.
|
||||
*/
|
||||
@Composable
|
||||
fun FixedTimeReminderPicker(
|
||||
title: String,
|
||||
selected: FixedTimeOverride,
|
||||
onSelect: (FixedTimeOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
inheritedLabel: String? = 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 ?: 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,9 +23,12 @@ 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
|
||||
@@ -172,6 +175,7 @@ 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)
|
||||
@@ -457,7 +461,9 @@ class RescheduleViewModel @Inject constructor(
|
||||
dropped
|
||||
}
|
||||
|
||||
// Read fixed-time reminders out, so the move keeps them on their wall clock (#459).
|
||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||
.withFixedTimeRemindersRead(settingsPrefs.knownFixedTimeReminders.first(), zone)
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
||||
|
||||
@@ -108,6 +108,7 @@ 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
|
||||
@@ -532,7 +533,8 @@ private fun EventDetailContent(
|
||||
.sortedBy { it.minutes }
|
||||
.forEach { reminder ->
|
||||
Text(
|
||||
text = reminderLeadTimeLabel(reminder.minutes),
|
||||
text = state.fixedReminders[reminder.minutes]?.let { fixedTimeReminderLabel(it) }
|
||||
?: reminderLeadTimeLabel(reminder.minutes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ 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).
|
||||
@@ -26,6 +27,10 @@ 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,5 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.splitTimedReminders
|
||||
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -46,6 +49,7 @@ 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)
|
||||
@@ -142,7 +146,9 @@ class EventDetailViewModel @Inject constructor(
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
|
||||
val zone = zoneSource.current()
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, zone)
|
||||
.withFixedTimeRemindersRead(loaded.knownFixed, zone)
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
@@ -157,6 +163,16 @@ 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 ->
|
||||
splitTimedReminders(listOf(minutes), corrected.instance.start, zone, knownFixed)
|
||||
.fixed.firstOrNull()?.let { minutes to it }
|
||||
}.toMap()
|
||||
}
|
||||
val description = corrected.description
|
||||
?.let(::descriptionRichText)
|
||||
?.takeIf { it.text.isNotBlank() }
|
||||
@@ -173,6 +189,8 @@ class EventDetailViewModel @Inject constructor(
|
||||
description = description?.text,
|
||||
),
|
||||
description = description,
|
||||
fixedReminders = fixedReminders,
|
||||
knownFixed = knownFixed,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -125,6 +125,10 @@ 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
|
||||
@@ -153,6 +157,7 @@ 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
|
||||
@@ -875,7 +880,9 @@ private fun EventEditContent(
|
||||
Spacer(Modifier.height(gap))
|
||||
RemindersSection(
|
||||
reminders = form.reminders,
|
||||
fixedReminders = form.fixedReminders,
|
||||
onRemove = viewModel::removeReminder,
|
||||
onRemoveFixed = viewModel::removeFixedReminder,
|
||||
onAdd = { sheet = EditSheet.Reminder },
|
||||
)
|
||||
}
|
||||
@@ -1284,9 +1291,25 @@ private fun LocationCard(
|
||||
* connected corners, the last row being "Add reminder".
|
||||
*/
|
||||
@Composable
|
||||
private fun RemindersSection(reminders: List<Int>, onRemove: (Int) -> Unit, onAdd: () -> Unit) {
|
||||
val rows = reminders.size + 1
|
||||
reminders.forEachIndexed { index, minutes ->
|
||||
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
|
||||
GroupedItemCard(
|
||||
position = positionOf(index, rows),
|
||||
icon = Icons.Default.Notifications,
|
||||
@@ -1297,7 +1320,7 @@ private fun RemindersSection(reminders: List<Int>, onRemove: (Int) -> Unit, onAd
|
||||
}
|
||||
AddActionCard(
|
||||
label = stringResource(R.string.event_edit_add_reminder),
|
||||
position = positionOf(reminders.size, rows),
|
||||
position = positionOf(rows - 1, rows),
|
||||
onClick = onAdd,
|
||||
)
|
||||
}
|
||||
@@ -1489,10 +1512,15 @@ 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(
|
||||
@@ -1581,10 +1609,13 @@ 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 amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
val customMinutes = amountText.toIntOrNull()
|
||||
@@ -1604,9 +1635,18 @@ private fun ReminderPickerDialog(
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (!customMode) {
|
||||
if (fixedMode) {
|
||||
GroupedHint(text = stringResource(R.string.event_edit_reminder_fixed_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FixedTimeReminderEditor(
|
||||
initial = DEFAULT_FIXED_TIME_REMINDER,
|
||||
confirmLabel = stringResource(R.string.event_edit_add),
|
||||
onConfirm = onSelectFixed,
|
||||
connected = false,
|
||||
)
|
||||
} else if (!customMode) {
|
||||
val presets = REMINDER_PRESETS.filterNot { it in alreadyChosen }
|
||||
val rowCount = presets.size + 1
|
||||
val rowCount = presets.size + if (offerFixedTime) 2 else 1
|
||||
presets.forEachIndexed { index, minutes ->
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minutes),
|
||||
@@ -1614,6 +1654,14 @@ 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,7 +13,11 @@ 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.withFixedTimeRemindersRead
|
||||
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
@@ -139,6 +143,7 @@ enum class ImportSource(internal val ownsDuration: Boolean) {
|
||||
data class ImportReminderPrompt(
|
||||
val currentReminderCount: Int,
|
||||
val defaultReminders: List<Int>,
|
||||
val defaultFixedReminders: List<FixedTimeReminder> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -208,6 +213,8 @@ 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
|
||||
}
|
||||
@@ -225,7 +232,13 @@ 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,
|
||||
@@ -465,26 +478,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) }
|
||||
applyDefault { it.copy(reminders = reminders, fixedReminders = fixed) }
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
if (reminders.isNotEmpty()) {
|
||||
if (reminders.isNotEmpty() || fixed.isNotEmpty()) {
|
||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot the four settings-default reminder flows into one value. */
|
||||
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
||||
settingsPrefs.defaultReminderMinutes,
|
||||
settingsPrefs.defaultAllDayReminderMinutes,
|
||||
settingsPrefs.perCalendarReminderOverride,
|
||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||
) { timed, allDay, timedOv, allDayOv ->
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||
}.first()
|
||||
/** Snapshot the settings-default reminder flows into one value. */
|
||||
private suspend fun reminderDefaults(): ReminderDefaults = ReminderDefaults(
|
||||
timed = settingsPrefs.defaultReminderMinutes.first(),
|
||||
allDay = settingsPrefs.defaultAllDayReminderMinutes.first(),
|
||||
timedOverrides = settingsPrefs.perCalendarReminderOverride.first(),
|
||||
allDayOverrides = settingsPrefs.perCalendarAllDayReminderOverride.first(),
|
||||
fixed = settingsPrefs.defaultFixedTimeReminder.first(),
|
||||
fixedOverrides = settingsPrefs.perCalendarFixedTimeReminderOverride.first(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Stretch a new timed event to the configured default length (#54) — the
|
||||
@@ -531,15 +545,19 @@ class EventEditViewModel @Inject constructor(
|
||||
val opened = session
|
||||
viewModelScope.launch {
|
||||
val targetId = resolvedCalendarId.first()
|
||||
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||
val defaults = reminderDefaults()
|
||||
val default = defaults.resolveFor(targetId, form.isAllDay)
|
||||
val fixed = defaults.resolveFixedFor(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() || default == current.reminders) return@launch
|
||||
if (default.isEmpty() && fixed.isEmpty()) return@launch
|
||||
if (default == current.reminders && fixed == current.fixedReminders) return@launch
|
||||
_importReminderPrompt.value = ImportReminderPrompt(
|
||||
currentReminderCount = current.reminders.size,
|
||||
currentReminderCount = current.reminders.size + current.fixedReminders.size,
|
||||
defaultReminders = default,
|
||||
defaultFixedReminders = fixed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -549,7 +567,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) }
|
||||
edit { it.copy(reminders = prompt.defaultReminders, fixedReminders = prompt.defaultFixedReminders) }
|
||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||
}
|
||||
|
||||
@@ -579,14 +597,15 @@ 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)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end))
|
||||
val snapshot = detail.toEditSnapshot(begin, end, zone).withFixedTimeRemindersRead(knownFixed, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end), knownFixed)
|
||||
// Sections holding data must show even when not in the defaults.
|
||||
_revealed.value = snapshot.form.populatedFields()
|
||||
open(snapshot.form)
|
||||
@@ -639,7 +658,11 @@ class EventEditViewModel @Inject constructor(
|
||||
// A kept pass of a repeated hour stays, so switching back out of all-day
|
||||
// lands where the event was — unless dropping the pin changed the zone.
|
||||
edit {
|
||||
if (value) it.withTimezone(null, zoneSource.current()).copy(isAllDay = true) else it.copy(isAllDay = false)
|
||||
if (value) {
|
||||
it.withTimezone(null, zoneSource.current()).copy(isAllDay = true, fixedReminders = emptyList())
|
||||
} else {
|
||||
it.copy(isAllDay = false)
|
||||
}
|
||||
}
|
||||
// The default reminder differs for all-day vs timed; re-apply the
|
||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||
@@ -699,6 +722,19 @@ class EventEditViewModel @Inject constructor(
|
||||
edit { it.copy(reminders = it.reminders - minutes) }
|
||||
}
|
||||
|
||||
/** Add an "N days before at HH:MM" reminder (#459); timed events only. */
|
||||
fun addFixedReminder(reminder: FixedTimeReminder) {
|
||||
_remindersTouched.value = true
|
||||
edit {
|
||||
if (it.isAllDay) it else it.copy(fixedReminders = (it.fixedReminders + reminder).distinct().sortedWith(FIXED_ORDER))
|
||||
}
|
||||
}
|
||||
|
||||
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 —
|
||||
@@ -815,6 +851,7 @@ 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) {
|
||||
@@ -950,3 +987,7 @@ 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))
|
||||
@@ -33,7 +33,14 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.fixedTimeOverrideFor
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
@@ -47,6 +54,7 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.ExpandIcon
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
@@ -84,10 +92,24 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val toggleDigest: (Boolean) -> Unit = { enabled ->
|
||||
viewModel.setDailyDigestEnabled(enabled)
|
||||
val needsPermission = enabled &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
!context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (needsPermission) {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
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>()) }
|
||||
@@ -127,6 +149,12 @@ internal fun NotificationsScreen(
|
||||
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),
|
||||
@@ -143,6 +171,19 @@ internal fun NotificationsScreen(
|
||||
onClick = { showAllDayReminderTime = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
DailyDigestRows(
|
||||
digest = state.dailyDigest,
|
||||
onEnable = { enabled ->
|
||||
toggleDigest(enabled)
|
||||
},
|
||||
onPickDay = { showDigestDay = true },
|
||||
onPickTime = { showDigestTime = true },
|
||||
onOpenSound = {
|
||||
scope.launch { viewModel.digestChannel()?.let { context.openNotificationSettings(it) } }
|
||||
},
|
||||
)
|
||||
|
||||
// Reliability is a soft, optional battery-optimisation exemption via
|
||||
// a system-settings deep-link, shown as live status.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
@@ -219,6 +260,8 @@ internal fun NotificationsScreen(
|
||||
onOpenSound = { openCalendarSound(calendar.id) },
|
||||
onOpenSpecialDates = onOpenSpecialDates,
|
||||
onEditOverride = { overrideDialog = it },
|
||||
onEditFixedOverride = { fixedOverrideFor = calendar.id },
|
||||
onSetInDigest = { viewModel.setCalendarInDailyDigest(calendar.id, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -227,6 +270,43 @@ 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),
|
||||
)
|
||||
}
|
||||
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),
|
||||
@@ -320,9 +400,12 @@ private fun CalendarNotificationRows(
|
||||
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),
|
||||
@@ -334,10 +417,19 @@ private fun CalendarNotificationRows(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = stringResource(R.string.settings_calendar_notification_sound_hint),
|
||||
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
|
||||
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),
|
||||
@@ -360,6 +452,19 @@ private fun CalendarNotificationRows(
|
||||
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),
|
||||
@@ -371,6 +476,68 @@ private fun CalendarNotificationRows(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The daily summary (#459): its switch, and once on, which day it lists, when,
|
||||
* and its channel's system settings.
|
||||
*/
|
||||
@Composable
|
||||
private fun DailyDigestRows(
|
||||
digest: DailyDigestSettings,
|
||||
onEnable: (Boolean) -> Unit,
|
||||
onPickDay: () -> Unit,
|
||||
onPickTime: () -> Unit,
|
||||
onOpenSound: () -> Unit,
|
||||
) {
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_digest),
|
||||
checked = digest.enabled,
|
||||
onCheckedChange = onEnable,
|
||||
summary = if (digest.enabled) {
|
||||
stringResource(
|
||||
R.string.settings_digest_summary,
|
||||
stringResource(digestDayLabel(digest.day)),
|
||||
settingsTimeOfDay(digest.timeMinutes),
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.settings_digest_hint)
|
||||
},
|
||||
position = if (digest.enabled) Position.Top else Position.Alone,
|
||||
)
|
||||
AnimatedVisibility(visible = digest.enabled, enter = expandEnter(), exit = collapseExit()) {
|
||||
Column {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_digest_day),
|
||||
summary = stringResource(digestDayLabel(digest.day)),
|
||||
position = Position.Middle,
|
||||
onClick = onPickDay,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_digest_time),
|
||||
summary = settingsTimeOfDay(digest.timeMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = onPickTime,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = stringResource(R.string.settings_digest_sound_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSound,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun digestDayLabel(day: DigestDay): Int = when (day) {
|
||||
DigestDay.Today -> R.string.settings_digest_day_today
|
||||
DigestDay.Tomorrow -> R.string.settings_digest_day_tomorrow
|
||||
}
|
||||
|
||||
/** A fixed-time default's label, or "None". */
|
||||
@Composable
|
||||
private fun fixedReminderChoiceLabel(reminder: FixedTimeReminder?): String =
|
||||
reminder?.let { fixedTimeReminderLabel(it) } ?: stringResource(R.string.reminder_none)
|
||||
|
||||
@Composable
|
||||
private fun ExternalLinkIcon() {
|
||||
Icon(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -96,6 +98,12 @@ data class SettingsUiState(
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** The "N days before at HH:MM" default for new timed events (#459); null = none. */
|
||||
val defaultFixedTimeReminder: FixedTimeReminder? = null,
|
||||
/** Per-calendar overrides of [defaultFixedTimeReminder]: absent inherits, null turns it off. */
|
||||
val perCalendarFixedTimeReminderOverride: Map<Long, FixedTimeReminder?> = emptyMap(),
|
||||
/** The daily summary notification (#459). */
|
||||
val dailyDigest: DailyDigestSettings = DailyDigestSettings(),
|
||||
/** Switched-on calendars you can edit: the per-calendar duration and reminder-override rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
|
||||
|
||||
@@ -19,6 +19,11 @@ 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.reminders.DailyDigestNotifier
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
@@ -71,6 +76,7 @@ class SettingsViewModel @Inject constructor(
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
private val reminderScanner: ReminderScanner,
|
||||
private val reminderChannels: ReminderChannels,
|
||||
private val digestNotifier: DailyDigestNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
@@ -128,6 +134,9 @@ 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],
|
||||
@@ -416,6 +425,32 @@ 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) = saveAndScan { setDailyDigestEnabled(enabled) }
|
||||
|
||||
fun setDailyDigestTimeMinutes(minutesOfDay: Int) = saveAndScan { setDailyDigestTimeMinutes(minutesOfDay) }
|
||||
|
||||
fun setDailyDigestDay(day: DigestDay) = saveAndScan { setDailyDigestDay(day) }
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -739,6 +739,44 @@
|
||||
<string name="settings_notification_sound_hint">Sound, vibration and pop-ups for each calendar, in system settings</string>
|
||||
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
|
||||
<string name="settings_calendar_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 the number field before it and a time after it: "[1] day before, at [20:00]". -->
|
||||
<plurals name="reminder_fixed_days_before_at">
|
||||
<item quantity="one">day before, at</item>
|
||||
<item quantity="other">days before, at</item>
|
||||
</plurals>
|
||||
<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>
|
||||
<!-- 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,6 +10,9 @@ 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
|
||||
@@ -718,4 +721,51 @@ 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)
|
||||
|
||||
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)
|
||||
assertThat(prefs.dailyDigest.first()).isEqualTo(
|
||||
DailyDigestSettings(enabled = true, timeMinutes = 420, day = DigestDay.Today, excludedCalendarIds = setOf(5L)),
|
||||
)
|
||||
prefs.setCalendarInDailyDigest(5L, included = true)
|
||||
assertThat(prefs.dailyDigest.first().excludedCalendarIds).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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 = emptySet(), 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(emptySet(), zone)).isEqualTo(allDay)
|
||||
assertThat(allDay.copy(fixedReminders = listOf(evening)).fixedReminderOffsets(zone)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fixed reminders count as a populated reminders section`() {
|
||||
val withFixed = form("2026-06-10T10:00").copy(fixedReminders = listOf(evening))
|
||||
assertThat(withFixed.populatedFields()).contains(EventFormField.Reminders)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 `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)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
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 `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 `split keeps presets and whole days as lead times`() {
|
||||
val start = at("2026-06-10T10:00")
|
||||
val split = splitTimedReminders(listOf(10, 1_440, 840, 2 * 1_440), start, berlin, known = emptySet())
|
||||
assertThat(split.leadMinutes).containsExactly(10, 1_440, 2_880).inOrder()
|
||||
assertThat(split.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 = emptySet(),
|
||||
)
|
||||
assertThat(split.fixed).containsExactly(twoDays, eveningBefore).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token round-trips and rejects garbage`() {
|
||||
assertThat(FixedTimeReminder.parse(eveningBefore.toToken())).isEqualTo(eveningBefore)
|
||||
assertThat(FixedTimeReminder.parse("0@600")).isNull()
|
||||
assertThat(FixedTimeReminder.parse("1@1440")).isNull()
|
||||
assertThat(FixedTimeReminder.parse("x")).isNull()
|
||||
assertThat(FixedTimeReminder.parse(null)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,13 @@ class RescheduleViewModelTest {
|
||||
io: CoroutineDispatcher = dispatcher,
|
||||
): RescheduleViewModel {
|
||||
fake.occurrencesAt(present)
|
||||
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
|
||||
val repo = testRepository(
|
||||
fake, tempDir, io,
|
||||
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = settings,
|
||||
)
|
||||
return RescheduleViewModel(repo, io, FakeZoneSource())
|
||||
return RescheduleViewModel(repo, io, FakeZoneSource(), settings)
|
||||
}
|
||||
|
||||
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
|
||||
|
||||
@@ -51,13 +51,14 @@ 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 = testSettings(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = settings,
|
||||
)
|
||||
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource())
|
||||
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource(), settings)
|
||||
}
|
||||
|
||||
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
|
||||
|
||||
@@ -9,7 +9,11 @@ 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
|
||||
@@ -719,4 +723,61 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +409,32 @@ off switch (#272): the scan plans nothing for those calendars (so they arm no
|
||||
wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again
|
||||
for snoozes.
|
||||
|
||||
**Reminders at a set time (#459).** "1 day before at 20:00" on a timed event
|
||||
is an ordinary `Reminders` row whose offset is the distance from that wall-clock
|
||||
moment (device zone) to the start, so it syncs and fires everywhere — the scan
|
||||
plans it like any lead time. Only the editor knows it as a pattern
|
||||
(`EventForm.fixedReminders`), and it re-derives the offset from the start it
|
||||
saves with, so moving the event, in the form or by drag, keeps the wall clock.
|
||||
Reading a row back is a guess, made in one place (`splitTimedReminders`): an
|
||||
offset landing on a configured default's pattern is fixed-time; otherwise one
|
||||
reaching back past the start's day is, unless it is a whole number of days or a
|
||||
short preset. Guessing wrong never moves an alarm, only how a later time change
|
||||
treats it. Limits: a recurring series stores one offset, so an occurrence whose
|
||||
lead spans a DST change fires an hour off the named time (as in every other
|
||||
app reading that row), and on another device or app the row is just 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.
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user