Compare commits

..
Author SHA1 Message Date
makiolaj cd3fb7378f Fix widget review findings for 2.24
Week strip lost its weekend to Glance's 10-child row limit, widget taps
shared PendingIntents across kinds, Month + day kept a stale selection,
all-day events ended at UTC midnight, countdown missed edited and split
occurrences, Today never redrew when its last event ended, and redraw
receivers could crash on removed widgets. Plus redraw scoping, caching
and locale cleanups.
2026-10-28 18:03:22 +01:00
37 changed files with 209 additions and 822 deletions

No files matched your search

@@ -38,8 +38,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
ACTION_DISMISS -> notifier.cancel(alert)
ACTION_SNOOZE -> {
// Tapped on a stale view: the reminder that replaced it stays put.
if (notifier.isSuperseded(alert)) return
// Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work.
notifier.cancel(alert)
@@ -52,8 +50,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
ACTION_SHOW -> {
goAsync {
// A later reminder posted while this one was snoozed has taken its place.
if (notifier.isSuperseded(alert)) return@goAsync
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
@@ -86,14 +82,12 @@ class ReminderActionReceiver : BroadcastReceiver() {
/**
* An explicit intent to this receiver carrying [alert] as extras. The
* data URI duplicates no information but is what keeps two reminders'
* `PendingIntent`s apart — `filterEquals` never compares extras. The
* snooze alarm is the exception: one per occurrence, so a later snooze
* replaces an earlier one and a newer reminder can cancel it.
* `PendingIntent`s apart — `filterEquals` never compares extras.
*/
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action
data = "calendula://reminder/${identity(alert, action)}".toUri()
data = "calendula://reminder/${alert.key}".toUri()
putExtra(EXTRA_ALERT_KEY, alert.key)
putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
@@ -107,7 +101,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
/**
* A stable request code per (alert, action), so one notification's
* PendingIntents stay distinct. The shift keeps the action slot intact;
* the top bits it drops are separated by [intent]'s URI.
* the top bits it drops are separated by [intent]'s per-reminder URI.
*/
fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) {
@@ -117,12 +111,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
ACTION_OPEN -> 4
else -> 0
}
return (identity(alert, action).toInt() shl 3) + actionOffset
return (alert.key.toInt() shl 3) + actionOffset
}
private fun identity(alert: ReminderAlert, action: String): Long =
if (action == ACTION_SHOW) alert.occurrenceKey else alert.key
private fun alertFrom(intent: Intent): ReminderAlert? {
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
return ReminderAlert(
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
import de.jeanlucmakiola.calendula.domain.reminders.occurrenceKey
/**
* One reminder as the notification layer needs it: what to show, and the stable
@@ -21,13 +20,6 @@ data class ReminderAlert(
val isAllDay: Boolean,
)
/**
* The occurrence [ReminderAlert] belongs to. Keys the notification and the
* snooze alarm, so a later reminder for the same occurrence replaces the earlier
* one instead of stacking beside it (#463).
*/
val ReminderAlert.occurrenceKey: Long get() = occurrenceKey(eventId, beginMillis)
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
key = key,
eventId = instance.eventId,
@@ -6,7 +6,6 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -29,13 +28,13 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts one notification per occurrence on a dedicated channel. Tapping opens
* Posts one notification per due reminder on a dedicated channel. Tapping opens
* the event's detail screen.
*
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
* the one before it in place (#463). A different reminder alerts afresh; the
* same one posted again — a catch-up pass overlapping the alarm that already
* fired — replaces its notification silently.
* The tag is the reminder's stable key, so a scan that posts the same reminder
* again — a catch-up pass overlapping the alarm that already fired — replaces
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
* instead of stacking a second one.
*/
@Singleton
class ReminderNotifier @Inject constructor(
@@ -66,7 +65,7 @@ class ReminderNotifier @Inject constructor(
suspend fun post(alert: ReminderAlert) {
if (isSilenced(alert.calendarId)) return
ensureChannel()
val title = displayTitle(alert)
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val zone = ZoneId.systemDefault()
@@ -97,8 +96,7 @@ class ReminderNotifier @Inject constructor(
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setOnlyAlertOnce(shownKey(alert) == alert.key)
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
.setOnlyAlertOnce(true)
.setContentIntent(detailIntent(alert))
.addAction(
R.drawable.ic_notification_snooze,
@@ -111,58 +109,20 @@ class ReminderNotifier @Inject constructor(
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
)
.build()
val manager = NotificationManagerCompat.from(context)
cancelLegacy(alert)
try {
manager.notify(alert.occurrenceKey.toString(), NOTIFICATION_ID, notification)
NotificationManagerCompat.from(context)
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
// POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
}
}
/**
* Remove [alert]'s notification (snooze re-shows it later; dismiss is final),
* unless a later reminder has replaced it — an action tapped on a stale view
* mustn't take the newer one with it.
*/
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
fun cancel(alert: ReminderAlert) {
val manager = NotificationManagerCompat.from(context)
if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
// Posted before reminders shared a notification, under the reminder's own key.
manager.cancel(alert.key.toString(), NOTIFICATION_ID)
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
}
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key }
/** The key of the reminder showing for [alert]'s occurrence, if any. */
private fun shownKey(alert: ReminderAlert): Long? {
val tag = alert.occurrenceKey.toString()
return NotificationManagerCompat.from(context).activeNotifications
.firstOrNull { it.tag == tag && it.id == NOTIFICATION_ID }
?.notification?.extras
?.takeIf { it.containsKey(EXTRA_REMINDER_KEY) }
?.getLong(EXTRA_REMINDER_KEY)
}
/**
* Remove a reminder for the same occurrence posted before reminders shared a
* notification. It carries no reminder key; its time and title identify it.
*/
private fun cancelLegacy(alert: ReminderAlert) {
val manager = NotificationManagerCompat.from(context)
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
.filter { it.notification.`when` == alert.beginMillis }
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
.forEach { manager.cancel(it.tag, it.id) }
}
private fun displayTitle(alert: ReminderAlert): String =
alert.title.ifBlank { context.getString(R.string.event_untitled) }
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
PendingIntent.getBroadcast(
context,
@@ -197,10 +157,8 @@ class ReminderNotifier @Inject constructor(
const val TAG = "ReminderNotifier"
const val CHANNEL_ID = "reminders"
// One id, distinct tags: the tag (occurrence) already keys the
// One id, distinct tags: the tag (alert id) already keys the
// notification, so a fixed id keeps cancellation/replacement simple.
const val NOTIFICATION_ID = 1
const val EXTRA_REMINDER_KEY = "de.jeanlucmakiola.calendula.reminder_key"
}
}
@@ -43,7 +43,6 @@ class ReminderScanner @Inject constructor(
private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier,
private val snoozes: ReminderSnoozeScheduler,
private val alarms: ReminderAlarmScheduler,
private val state: ReminderStatePrefs,
private val settingsPrefs: SettingsPrefs,
@@ -105,11 +104,7 @@ class ReminderScanner @Inject constructor(
)
if (notifier.canPost()) {
schedule.due.forEach {
val alert = it.toAlert()
snoozes.cancel(alert)
notifier.post(alert)
}
schedule.due.forEach { notifier.post(it.toAlert()) }
}
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog.
@@ -33,16 +33,4 @@ class ReminderSnoozeScheduler @Inject constructor(
)
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
}
/** Drop a snooze still pending for [alert]'s occurrence, so it can't re-show over a newer reminder. */
fun cancel(alert: ReminderAlert) {
val pendingIntent = PendingIntent.getBroadcast(
context,
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
) ?: return
context.getSystemService<AlarmManager>()?.cancel(pendingIntent)
pendingIntent.cancel()
}
}
@@ -32,8 +32,7 @@ import java.util.Locale
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
*
* All-day instances store UTC midnights with an exclusive end, so they are read
* in UTC and the end day is the last *covered* day (issue #308). A timed event
* from local midnight to local midnight reads like one too, in [zone] (#296).
* in UTC and the end day is the last *covered* day (issue #308).
*/
fun reminderTimeText(
beginMillis: Long,
@@ -48,14 +47,11 @@ fun reminderTimeText(
tomorrowLabel: String,
yesterdayLabel: String,
): String {
val wholeDays = !isAllDay && endMillis > beginMillis &&
isStartOfDay(beginMillis, zone) && isStartOfDay(endMillis, zone)
if (isAllDay || wholeDays) {
val dayZone = if (isAllDay) ZoneOffset.UTC else zone
if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate()
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
val start = relativeDayPrefix(
@@ -138,8 +134,3 @@ private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
}
private const val RANGE = " – "
private fun isStartOfDay(millis: Long, zone: ZoneId): Boolean {
val at = Instant.ofEpochMilli(millis).atZone(zone)
return at.toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() == millis
}
@@ -6,20 +6,13 @@ import kotlinx.datetime.TimeZone
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
isDeclined -> 3
spansMultipleDays(zone) -> 0
coversWholeDays(zone) -> 1
isAllDay -> 1
else -> 2
}
/**
* Shared order for agenda and split day lists. Grid bars add their lane geometry.
* Each event's priority is worked out once, not on every comparison.
*/
fun List<EventInstance>.sortedForDisplay(zone: TimeZone): List<EventInstance> =
map { it to it.displayPriority(zone) }
.sortedWith(
compareBy<Pair<EventInstance, Int>> { it.second }
.thenBy { it.first.start }
.thenBy { it.first.title }
.thenBy { it.first.instanceId },
)
.map { it.first }
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
@@ -137,17 +137,6 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
/**
* Whether this event fills whole days in [zone], so the grids and lists lay it
* out with the all-day events: an all-day event, or a timed one running from
* local midnight to local midnight (#296). The event itself stays timed.
*/
fun EventInstance.coversWholeDays(zone: TimeZone): Boolean =
isAllDay || (end > start && start.isStartOfDay(zone) && end.isStartOfDay(zone))
private fun Instant.isStartOfDay(zone: TimeZone): Boolean =
this == toLocalDateTime(zone).date.atStartOfDayIn(zone)
data class EventDetail(
val instance: EventInstance,
val description: String?,
@@ -32,26 +32,19 @@ data class PlannedReminder(
val minutes: Int,
val alarmMillis: Long,
) {
/** The occurrence this reminder belongs to; it shares one notification with its siblings (#463). */
val occurrenceKey: Long = occurrenceKey(instance.eventId, instance.beginMillis)
/**
* Stable identity of this one reminder, keying the snooze/dismiss
* Stable identity, keying the notification tag and the snooze/dismiss
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
*/
val key: Long = mix64(occurrenceKey + minutes)
}
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
/** Stable identity of one occurrence, shared by every reminder on it. */
fun occurrenceKey(eventId: Long, beginMillis: Long): Long =
mix64(mix64(eventId) + beginMillis)
/** SplitMix64's finaliser: spreads close inputs across all 64 bits. */
private fun mix64(value: Long): Long {
var z = value + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
private companion object {
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
var h = eventId * 1_000_003L
h = (h xor beginMillis) * 31L
return h + minutes
}
}
}
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
@@ -153,8 +146,7 @@ private fun allDayAlarmMillis(
*
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
* running twice cannot post twice while a late one still catches up. Reminders
* whose event has ended are dropped ([isStillRelevant]), and of several due on
* one occurrence only the latest is kept — they'd replace each other anyway (#463). [nextAlarmMillis] is
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
*/
fun scheduleReminders(
@@ -166,8 +158,7 @@ fun scheduleReminders(
val due = planned
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
.filter { it.instance.isStillRelevant(nowMillis) }
.groupBy { it.occurrenceKey }
.values.map { siblings -> siblings.maxBy { it.alarmMillis } }
.distinctBy { it.key }
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
val nextPending = planned
.filter { it.alarmMillis > nowMillis }
@@ -1,8 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -19,7 +18,7 @@ import kotlin.time.Instant
* surface only formats the instants into its own locale/24h string.
*/
sealed interface AgendaTimeLabel {
/** An event filling whole days (#296), or a whole in-between day of a multi-day span. */
/** An all-day event, or a whole in-between day of a multi-day span. */
data object AllDay : AgendaTimeLabel
/** The first day of a multi-day timed event: when it begins. */
data class Starts(val start: Instant) : AgendaTimeLabel
@@ -31,7 +30,7 @@ sealed interface AgendaTimeLabel {
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
if (event.coversWholeDays(zone)) return AgendaTimeLabel.AllDay
if (event.isAllDay) return AgendaTimeLabel.AllDay
val firstDay = event.spanFirstDay(zone)
val lastDay = event.spanLastDay(zone)
return when {
@@ -88,7 +87,7 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedForDisplay(zone),
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
)
}
}
@@ -30,9 +30,7 @@ fun CalendarDatePickerDialog(
onDismiss: () -> Unit,
) {
val state = rememberDatePickerState(
initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY)
.toEpochDays() * MILLIS_PER_DAY,
yearRange = CalendarSpan.years,
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
)
DatePickerDialog(
onDismissRequest = onDismiss,
@@ -1,14 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.datetime.LocalDate
/**
* The dates every view can show and every date picker offers. One span for all
* of them, so no view can hand another a date it has no page for (#457).
*/
object CalendarSpan {
val FIRST_DAY: LocalDate = LocalDate(1600, 1, 1)
val LAST_DAY: LocalDate = LocalDate(2399, 12, 31)
val years: IntRange get() = FIRST_DAY.year..LAST_DAY.year
}
@@ -1,26 +1,22 @@
package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
/**
* One shared state per pager page — a week, a day, a month — so a page and the
* screen's anchor read the same query, and the pages either side stay loaded
* across a swipe.
*
* An entry queries only while something collects it, stopping [STOP_TIMEOUT_MILLIS]
* after the last collector leaves, and starts again when one comes back. Letting
* an entry go only forgets it: a page still holding it keeps a working state
* however far the pager composes or prefetches, and however long the screen sat
* stopped (#440, #454). A forgotten entry nobody collects holds no coroutine, so
* paging through a year doesn't keep a year of queries around. Main thread only.
* Every entry shares on its own job, cancelled when [keep] lets it go: a
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
* until the ViewModel itself was cleared. Main thread only.
*
* @param keep whether a cached key is still worth holding once [get] asks for another.
*/
@@ -30,54 +26,28 @@ class PageStateCache<K, S>(
private val keep: (cached: K, requested: K) -> Boolean,
private val load: (K) -> Flow<S>,
) {
private val entries = HashMap<K, Entry>()
private class Entry<S>(val state: StateFlow<S>, val job: Job)
private val entries = HashMap<K, Entry<S>>()
/** The number of entries held, for tests. */
internal val size: Int get() = entries.size
fun get(key: K): StateFlow<S> {
// One still collected stays, so the anchor asking for it again shares its query.
entries.entries.removeAll { (cached, entry) ->
cached != key && !keep(cached, key) && !entry.isCollected
}
return entries.getOrPut(key) { Entry(key) }
}
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
private inner class Entry(private val key: K) : StateFlow<S> {
private val state = MutableStateFlow(initial)
private var collectors = 0
private var loading: Job? = null
private var stopping: Job? = null
val isCollected: Boolean get() = collectors > 0
override val value: S get() = state.value
override val replayCache: List<S> get() = state.replayCache
override suspend fun collect(collector: FlowCollector<S>): Nothing {
collectors++
stopping?.cancel()
stopping = null
if (loading?.isActive != true) {
loading = scope.launch { load(key).collect { state.value = it } }
}
try {
state.collect(collector)
} finally {
if (--collectors == 0) {
stopping = scope.launch {
delay(STOP_TIMEOUT_MILLIS)
loading?.cancel()
loading = null
}
}
val iterator = entries.iterator()
while (iterator.hasNext()) {
val (cached, entry) = iterator.next()
if (cached != key && !keep(cached, key)) {
entry.job.cancel()
iterator.remove()
}
}
}
private companion object {
/** Long enough to ride out a configuration change, as `stateWhileSubscribed` does. */
const val STOP_TIMEOUT_MILLIS = 5_000L
return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job])
Entry(
state = load(key).stateWhileSubscribed(scope + job, initial),
job = job,
)
}.state
}
}
@@ -755,21 +755,12 @@ class TimelineDragController {
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
// Landed on midnight to midnight, the event moves to the all-day strip
// (#296): no block will seat its copy or carry the selection.
val leavesGrid = landed.eventSpanMin > 0 &&
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
landed.eventSpanMin % MINUTES_PER_DAY == 0
if (leavesGrid && !landed.event.isDraft && selection?.event?.instanceId == landed.event.instanceId) {
selection = null
selectionCovered = false
}
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so
// [noteGrid] can never seat it and it would sit at the top of today for
// the whole settle timeout, long after the event moved off-screen.
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
@@ -4,7 +4,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone
@@ -12,8 +11,8 @@ import java.util.Locale
/**
* The start time a chip shows before its title (#219), or null when it has none
* to show: a chip filling whole days never carries a time (#296), and neither
* does a bar carried in from an earlier week, whose start is not in this segment.
* to show: an all-day chip never carries a time, and neither does a bar carried
* in from an earlier week, whose start is not in this segment.
*/
internal fun monthChipTime(
event: EventInstance,
@@ -22,7 +21,7 @@ internal fun monthChipTime(
is24Hour: Boolean,
locale: Locale,
): String? {
if (event.coversWholeDays(zone) || continuesLeft) return null
if (event.isAllDay || continuesLeft) return null
return formatTimeOfDay(event.start, zone, is24Hour, locale)
}
@@ -11,6 +11,7 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
@@ -277,7 +278,6 @@ fun MonthScreen(
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
@@ -363,7 +363,7 @@ fun MonthScreen(
if (!scrolling) return@LaunchedEffect
snapshotFlow {
// Empty before the first layout pass — and reporting a default of 0
// for it would claim the span's first month is on screen and drag the whole
// for it would claim January 1900 is on screen and drag the whole
// window back there.
val visible = listState.layoutInfo.visibleItemsInfo
when {
@@ -448,8 +448,7 @@ fun MonthScreen(
}
}
// Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { date ->
val target = coerceToMonthRange(date)
val jumpToDate: (LocalDate) -> Unit = { target ->
if (scrolling) {
val targetMonth = target.yearMonth
pendingScrollMonth = targetMonth
@@ -544,7 +543,6 @@ fun MonthScreen(
weekStart = weekStart,
monthState = viewModel::month,
showWeekNumbers = showWeekNumbers,
showToday = showToday,
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
onClearSelection = viewModel::clearSelection,
@@ -553,10 +551,8 @@ fun MonthScreen(
// month, pager and all — straight away, or a swipe
// still settling would land and take the tap back.
onSelectDay = { date ->
if (isInMonthRange(date)) {
viewModel.selectDate(date)
jumpToMonth(date.yearMonth)
}
viewModel.selectDate(date)
jumpToMonth(date.yearMonth)
},
onOpenDay = onOpenDay,
onEventClick = onEventClick,
@@ -1202,7 +1198,6 @@ private fun SplitMonthContent(
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean,
showToday: Boolean,
onPaneSwipeNext: () -> Unit,
onPaneSwipePrev: () -> Unit,
onClearSelection: () -> Unit,
@@ -1341,7 +1336,6 @@ private fun SplitMonthContent(
paneReady = paneReady,
selected = pageSelected,
showWeekNumbers = showWeekNumbers,
showToday = showToday,
expandDrag = expandDrag,
onSelectDay = onSelectDay,
onPaneSwipeNext = {
@@ -1384,7 +1378,6 @@ private fun SplitMonthCollapsed(
paneReady: Boolean,
selected: LocalDate?,
showWeekNumbers: Boolean,
showToday: Boolean,
expandDrag: Modifier,
onSelectDay: (LocalDate) -> Unit,
onPaneSwipeNext: () -> Unit,
@@ -1417,7 +1410,6 @@ private fun SplitMonthCollapsed(
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
SplitMonthAgendaPane(
state = state,
showToday = showToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
@@ -1867,33 +1859,22 @@ private fun SplitDots(
@Composable
private fun SplitMonthAgendaPane(
state: MonthUiState.Success,
showToday: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
) {
val days = remember(state.month, state.today, state.instancesByDay, showToday) {
splitAgendaDays(state.month, state.today, state.instancesByDay, showToday)
}
val start = agendaStartForMonth(state.month, state.today)
val days = state.instancesByDay.keys
.filter { it >= start && it.year == state.month.year && it.month == state.month.month }
.filter { state.instancesByDay[it].orEmpty().isNotEmpty() }
.toMutableList()
.apply { if (start !in this) add(start) }
.sorted()
val dimCutoff = LocalDimCutoff.current
val listState = key(state.month) { rememberLazyListState() }
LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 96.dp)) {
// A fixed first item, so a day added above the first listed one shows
// up rather than landing above a list that holds its scroll position.
item(key = "top") { Spacer(Modifier.height(8.dp)) }
if (days.isEmpty()) {
val start = agendaStartForMonth(state.month, state.today)
item(key = "empty") {
AgendaEmptyDayRow(
text = stringResource(
if (start == state.today) R.string.month_split_no_more_events else R.string.month_split_no_events_month,
),
onClick = { onCreateEvent(start) },
)
}
}
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
agendaDays(
days = days,
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
today = state.today,
zone = state.zone,
emptyText = R.string.month_split_no_events,
@@ -13,15 +13,11 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -85,13 +81,6 @@ class MonthViewModel @Inject constructor(
initialValue = false,
)
/** Whether the split agenda keeps a free today, as the Agenda view does (#35). */
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = true,
)
/** How the grid is laid out and navigated (#38, #53). */
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
.stateWhileSubscribed(
@@ -106,7 +95,7 @@ class MonthViewModel @Inject constructor(
val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth)))
private val _month = MutableStateFlow(todayDate.yearMonth)
val month: StateFlow<YearMonth> = _month
/**
@@ -160,7 +149,7 @@ class MonthViewModel @Inject constructor(
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
if (monthIndexOf(_month.value) == page) return
setMonth(yearMonthForIndex(page))
_month.value = yearMonthForIndex(page)
// Moving the grid to another month restores its full agenda.
_selectedDate.value = null
}
@@ -309,11 +298,9 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at.
*/
fun selectDate(date: LocalDate) {
// A greyed-out day past either end of the grid has no month to follow to.
if (!isInMonthRange(date)) return
val target = date.yearMonth
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) setMonth(target)
if (target != _month.value) _month.value = target
}
fun clearSelection() { _selectedDate.value = null }
@@ -322,16 +309,16 @@ class MonthViewModel @Inject constructor(
fun stepSplitPane(days: Int) {
val selected = _selectedDate.value
if (selected == null) {
setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days))
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
} else {
val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY))
val next = selected.plus(days, DateTimeUnit.DAY)
_selectedDate.value = next
setMonth(next.yearMonth)
_month.value = next.yearMonth
}
}
fun goToToday() {
setMonth(todayDate.yearMonth)
_month.value = todayDate.yearMonth
_selectedDate.value = null
}
@@ -343,7 +330,7 @@ class MonthViewModel @Inject constructor(
private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return
if (_month.value != old.yearMonth) return
setMonth(new.yearMonth)
_month.value = new.yearMonth
_selectedDate.value = null
}
@@ -357,24 +344,14 @@ class MonthViewModel @Inject constructor(
*/
fun syncScrollMonth(ym: YearMonth) {
if (_month.value == ym) return
setMonth(ym)
_month.value = ym
_selectedDate.value = null
}
/**
* Jump to the month containing [date] (drawer jump-to-date, or the focus
* handed over by another view). The focus itself keeps a date outside the
* grid's span, so the day and week views return to it unchanged.
*/
/** Jump to the month containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
val target = coerceToMonthRange(date)
setMonth(target.yearMonth)
_selectedDate.value = target
}
/** Every write to [_month] goes through here, so it can't leave the grid's span (#457). */
private fun setMonth(ym: YearMonth) {
_month.value = yearMonthForIndex(monthIndexOf(ym))
_month.value = date.yearMonth
_selectedDate.value = date
}
private fun buildState(
@@ -492,7 +469,7 @@ internal fun layoutCalendarWeek(
// crossing midnight into the next week row covers a single day in each, and
// counting per row drew it as two unrelated pills (#253).
val (bars, singles) = weekEvents.partition { ev ->
ev.coversWholeDays(zone) || ev.spansMultipleDays(zone)
ev.isAllDay || ev.spansMultipleDays(zone)
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(
@@ -532,7 +509,7 @@ internal fun instancesByDay(
days.associateWith { day ->
instances
.filter { it.coversDay(day, zone) }
.sortedForDisplay(zone)
.sortedWith(eventDisplayComparator(zone))
}
/**
@@ -579,25 +556,6 @@ internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate
LocalDate(month.year, month.month, 1)
}
/**
* The days the split agenda lists for [month]: those from [agendaStartForMonth]
* on that have events, plus a free today when [showToday] is set, as in the
* Agenda view (#142).
*/
internal fun splitAgendaDays(
month: YearMonth,
today: LocalDate,
instancesByDay: Map<LocalDate, List<EventInstance>>,
showToday: Boolean,
): List<AgendaDay> {
val start = agendaStartForMonth(month, today)
val days = instancesByDay.keys
.filter { it >= start && it.yearMonth == month && instancesByDay.getValue(it).isNotEmpty() }
.sorted()
.map { AgendaDay(it, instancesByDay.getValue(it)) }
return anchorTodayIfMissing(days, today, enabled = showToday && start == today)
}
/**
* The window to load for a visible range, or null to keep the current one.
*
@@ -620,39 +578,24 @@ internal fun nextLoadWindow(
/**
* The continuous grid addresses months by an absolute index, so the list has one
* stable, gap-free coordinate space to scroll through and key its items by.
* Index 0 is the first month of [CalendarSpan]; the list runs to [continuousMonthCount].
* Index 0 is January 1900; the list runs to [continuousMonthCount].
*
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
* the week-start preference — changing it reflows the rows *inside* a month
* block but never moves the block, so a scroll position stays put.
*/
private val MONTH_INDEX_EPOCH_YEAR = CalendarSpan.FIRST_DAY.year
private val MONTH_INDEX_END_YEAR = CalendarSpan.LAST_DAY.year
/** The first and last day the month and Dense grids can show. */
private val GRID_FIRST_DAY = LocalDate(MONTH_INDEX_EPOCH_YEAR, 1, 1)
private val GRID_LAST_DAY = LocalDate(MONTH_INDEX_END_YEAR, 12, 31)
private const val MONTH_INDEX_EPOCH_YEAR = 1900
private const val MONTH_INDEX_END_YEAR = 2100
internal fun monthIndexOf(ym: YearMonth): Int =
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
/** The month at [index], held to the grid's span, so a stray index can't throw (#457). */
internal fun yearMonthForIndex(index: Int): YearMonth {
val held = clampMonthIndex(index)
return YearMonth(MONTH_INDEX_EPOCH_YEAR + held / 12, Month.entries[held % 12])
}
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
MONTH_INDEX_EPOCH_YEAR + index / 12,
Month.entries[index % 12],
)
internal fun clampMonthIndex(index: Int): Int = index.coerceIn(0, continuousMonthCount() - 1)
/**
* Hold [date] inside the span the month grid can show. Every view shares
* [CalendarSpan], but another app can open Calendula on any date (#457).
*/
internal fun coerceToMonthRange(date: LocalDate): LocalDate = date.coerceIn(GRID_FIRST_DAY, GRID_LAST_DAY)
internal fun isInMonthRange(date: LocalDate): Boolean = date in GRID_FIRST_DAY..GRID_LAST_DAY
/** Total months the continuous grid scrolls through. */
/** Total months the continuous grid scrolls through (1900 → 2100). */
internal fun continuousMonthCount(): Int =
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
@@ -668,23 +611,25 @@ internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
/**
* Hold a load window inside the months that actually exist. The pad
* [nextLoadWindow] adds can otherwise push an index past either end of the
* span, and [yearMonthForIndex] has no month to hand back for one.
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
*/
internal fun clampMonthWindow(window: IntRange): IntRange =
clampMonthIndex(window.first)..clampMonthIndex(window.last)
internal fun clampMonthWindow(window: IntRange): IntRange {
val last = continuousMonthCount() - 1
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
}
/**
* The Dense style addresses *weeks* by absolute index instead: it has no month
* blocks to key by, and a row's identity has to survive the months around it
* scrolling past. Index 0 is the span's first week under the active week-start,
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
* so — like the month space — every index is non-negative and the LazyColumn's
* item indices and week indices are the same number.
*
* Unlike month indices these *do* shift with the week-start preference, which is
* why the list state is keyed on it.
*/
private val WEEK_INDEX_EPOCH = GRID_FIRST_DAY
private val WEEK_INDEX_END = GRID_LAST_DAY
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
@@ -694,7 +639,7 @@ internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the Dense grid scrolls through. */
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
@@ -8,7 +7,16 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** A fixed day every grid's pages are counted from. */
/** Pages the week pager spans — a little under four centuries. */
internal const val WEEK_PAGE_COUNT: Int = 20_000
/** Pages the multi-day pager spans — over three centuries at the widest page. */
internal const val DAYS_PAGE_COUNT: Int = 40_000
/** Pages the day pager spans — a little under four centuries. */
internal const val DAY_PAGE_COUNT: Int = 140_000
/** A fixed day every grid's pages are counted from, at its middle page. */
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
/** The week the week pager's middle page shows; any date works, it only has to stay fixed. */
@@ -16,19 +24,13 @@ private val WEEK_EPOCH = LocalDate(2000, 1, 3)
/**
* How a timeline pager cuts the calendar: runs of [dayCount] days, offset
* [phase] days from a fixed epoch, over as many pages as cover [CalendarSpan].
* The multi-day view's phase (#193) stays put while paging and only changes when
* a jump moves another date to the first column.
* [phase] days from a fixed epoch, over [pageCount] pages centred on it. The
* multi-day view's phase (#193) stays put while paging and only changes when a
* jump moves another date to the first column.
*/
data class DaysGrid(val dayCount: Int, val phase: Int) {
data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) {
/** The page showing the epoch; page 0 is the one holding [CalendarSpan.FIRST_DAY]. */
private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY)
val pageCount: Int get() = epochPage + pagesFromEpoch(CalendarSpan.LAST_DAY) + 1
private fun pagesFromEpoch(date: LocalDate): Int =
Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)
private val epochPage: Int get() = pageCount / 2
/** First day shown on pager [page]. */
fun startForPage(page: Int): LocalDate =
@@ -36,16 +38,18 @@ data class DaysGrid(val dayCount: Int, val phase: Int) {
/** The pager page showing [date]. */
fun pageFor(date: LocalDate): Int =
(epochPage + pagesFromEpoch(date)).coerceIn(0, pageCount - 1)
(epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
.coerceIn(0, pageCount - 1)
companion object {
/** Single days: every date starts a page of its own. */
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0)
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT)
/** Whole weeks, starting on [firstDay]. */
fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid(
dayCount = 7,
phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)),
pageCount = WEEK_PAGE_COUNT,
)
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
@@ -207,7 +206,8 @@ abstract class PagedTimelineViewModel(
): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
val (allDay, timed) = instances.partition { it.coversWholeDays(zone) }
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return WeekUiState.Success(
weekStart = span.start,
today = today,
@@ -292,17 +292,17 @@ internal fun layoutAllDay(
days: List<LocalDate>,
zone: TimeZone,
): List<AllDaySpan> {
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int)
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
val raw = events
.mapNotNull { ev ->
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last(), ev.displayPriority(zone))
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
}
// Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230).
.sortedWith(
compareBy<Raw> { it.priority }
compareBy<Raw> { it.event.displayPriority(zone) }
.thenBy { it.startCol }
.thenBy { it.endCol }
.thenBy { it.event.start }
@@ -444,7 +444,7 @@ internal fun layoutDay(
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
val raw = events.asSequence()
.filterNot { it.coversWholeDays(zone) }
.filterNot { it.isAllDay }
.mapNotNull { ev ->
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -81,7 +80,7 @@ private fun sampleWeekState(
weekStart = start,
today = today,
days = days,
allDaySpans = layoutAllDay(events.filter { it.coversWholeDays(zone) }, days, zone),
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timedByDay = days.associateWith { layoutDay(events, it, zone) },
)
}
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
@@ -75,13 +74,12 @@ fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant
}
/**
* The next-event mode's pick: timed and not filling whole days (#296), not
* declined, not over; of those, the one whose countdown changes soonest — a
* running event's end, else a start.
* The next-event mode's pick: timed, not declined, not over; of those, the one
* whose countdown changes soonest — a running event's end, else a start.
*/
fun pickNextEvent(instances: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
instances
.filter { !it.coversWholeDays(zone) && !it.isDeclined && it.end > now }
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
.minByOrNull { target(it, now) }
/** A pinned event's occurrence to count to: the running or next one. */
@@ -75,11 +75,7 @@ internal suspend fun Context.loadCountdown(
val repository = widgetEntryPoint().calendarRepository()
val event = when (options.mode) {
CountdownMode.NEXT ->
pickNextEvent(
repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars),
now,
systemZone(),
)
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
CountdownMode.PINNED -> options.eventId?.let { id ->
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
}
@@ -40,7 +40,6 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
@@ -211,13 +210,12 @@ private fun whenText(context: Context, event: EventInstance, data: CountdownData
val at = if (running) counted.end else counted.start
val date = at.toLocalDateTime(systemZone()).date
val day = relativeDayLabel(context, date, data.today)
val wholeDays = event.coversWholeDays(systemZone())
return when {
wholeDays && running -> context.getString(
event.isAllDay && running -> context.getString(
R.string.widget_countdown_until,
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
)
wholeDays -> "$day · ${context.getString(R.string.event_detail_all_day)}"
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
}
-2
View File
@@ -677,8 +677,6 @@
<string name="month_style_split">Split</string>
<string name="month_style_split_summary">A compact grid marks the days that have events, and the day you tap is listed underneath.</string>
<string name="month_split_no_events">Nothing scheduled</string>
<string name="month_split_no_events_month">Nothing scheduled this month</string>
<string name="month_split_no_more_events">Nothing else scheduled this month</string>
<string name="month_split_expand">Show the whole month</string>
<string name="month_split_collapse">Show the day\'s events</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
@@ -1,39 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import org.junit.jupiter.api.Test
class ReminderAlertTest {
private fun instance(eventId: Long = 1L, beginMillis: Long = 1_800_000_000_000L) = ReminderEventInstance(
eventId = eventId,
calendarId = 1L,
beginMillis = beginMillis,
endMillis = beginMillis + 3_600_000L,
title = "Dentist",
location = null,
isAllDay = false,
)
private fun alert(instance: ReminderEventInstance, minutes: Int) =
PlannedReminder(instance, minutes, instance.beginMillis - minutes * 60_000L).toAlert()
@Test
fun `two reminders on one occurrence share a notification`() {
val dayBefore = alert(instance(), 1_440)
val hourBefore = alert(instance(), 60)
assertThat(dayBefore.key).isNotEqualTo(hourBefore.key)
assertThat(dayBefore.occurrenceKey).isEqualTo(hourBefore.occurrenceKey)
}
@Test
fun `occurrences of one series keep their own notifications`() {
val first = alert(instance(beginMillis = 1_800_000_000_000L), 60)
val second = alert(instance(beginMillis = 1_800_086_400_000L), 60)
assertThat(first.occurrenceKey).isNotEqualTo(second.occurrenceKey)
}
}
@@ -212,30 +212,4 @@ class ReminderTimeTextTest {
)
assertThat(text).isEqualTo("Today")
}
@Test
fun `a timed event from local midnight to midnight reads like an all-day one`() {
val oneDay = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(oneDay).isEqualTo("Tomorrow")
val twoDays = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
)
assertThat(twoDays).startsWith("Today – ")
}
@Test
fun `midnight is read in the device zone`() {
// Midnight in Berlin is 22:00 the day before in UTC, so it is timed there.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
zone = ZoneOffset.UTC,
)
assertThat(text).contains("22:00")
}
}
@@ -82,39 +82,4 @@ class EventInstanceSpanTest {
val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false)
assertThat(timed.dateZone(newYork)).isEqualTo(newYork)
}
@Test
fun `a timed event from local midnight to local midnight covers whole days`() {
// Stored as 22:00Z, the shape ICSx5 gives a date-only feed event (#296).
val oneDay = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
val twoDays = instance(utc(2026, 7, 18, 22), utc(2026, 7, 20, 22), isAllDay = false)
assertThat(oneDay.coversWholeDays(berlin)).isTrue()
assertThat(twoDays.coversWholeDays(berlin)).isTrue()
}
@Test
fun `local midnight is read in the device zone`() {
val event = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
assertThat(event.coversWholeDays(newYork)).isFalse()
}
@Test
fun `a timed event off midnight at either end does not cover whole days`() {
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19, 23), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
assertThat(instance(utc(2026, 7, 19, 1), utc(2026, 7, 20), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
}
@Test
fun `a zero-length event at midnight does not cover whole days`() {
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
}
@Test
fun `an all-day event covers whole days in any zone`() {
assertThat(allDayJul19().coversWholeDays(newYork)).isTrue()
assertThat(allDayJul19().coversWholeDays(berlin)).isTrue()
}
}
@@ -419,30 +419,6 @@ class ReminderPlanTest {
assertThat(schedule.due).isEmpty()
}
@Test
fun `of several reminders due on one occurrence only the latest is posted`() {
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 20 * minute)),
minutesByEvent = mapOf(1L to listOf(60, 30)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due.map { it.minutes }).containsExactly(30)
}
@Test
fun `two reminders on one occurrence share an occurrence key`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = mapOf(1L to listOf(10, 30)),
)
assertThat(planned[0].occurrenceKey).isEqualTo(planned[1].occurrenceKey)
}
@Test
fun `due reminders come out in occurrence order`() {
val planned = plan(
@@ -65,14 +65,4 @@ class AgendaTimeLabelTest {
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
}
@Test
fun `a timed event from midnight to midnight is all-day`() {
val oneDay = event(at(2026, 7, 2), at(2026, 7, 3))
val twoDays = event(at(2026, 7, 2), at(2026, 7, 4))
assertThat(labelOn(2026, 7, 2, oneDay)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 2, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 3, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
}
}
@@ -5,9 +5,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.math.abs
@@ -15,10 +13,16 @@ import kotlin.math.abs
@OptIn(ExperimentalCoroutinesApi::class)
class PageStateCacheTest {
private var running = 0
@Test
fun `the same key shares one state`() = runTest {
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } }
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
}
private fun TestScope.trackingCache() =
PageStateCache<Int, Int>(backgroundScope, -1, { a, b -> abs(a - b) <= 1 }) { key ->
@Test
fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) {
var running = 0
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key ->
flow {
running++
try {
@@ -29,11 +33,13 @@ class PageStateCacheTest {
}
}
}
backgroundScope.launch { cache.get(0).collect {} }
assertThat(running).isEqualTo(1)
@Test
fun `the same key shares one state`() = runTest {
val cache = trackingCache()
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
// Far enough away that 0 is let go, even though it is still collected.
cache.get(10)
assertThat(running).isEqualTo(0)
assertThat(cache.size).isEqualTo(1)
}
@Test
@@ -44,63 +50,4 @@ class PageStateCacheTest {
cache.get(6)
assertThat(cache.size).isEqualTo(3)
}
@Test
fun `an entry loads only while collected`() = runTest(UnconfinedTestDispatcher()) {
val cache = trackingCache()
val state = cache.get(0)
assertThat(running).isEqualTo(0)
val page = backgroundScope.launch { state.collect {} }
assertThat(running).isEqualTo(1)
assertThat(state.value).isEqualTo(0)
page.cancel()
advanceTimeBy(5_001)
assertThat(running).isEqualTo(0)
}
@Test
fun `an unwatched entry far away is forgotten`() = runTest(UnconfinedTestDispatcher()) {
val cache = trackingCache()
cache.get(0)
cache.get(10)
assertThat(cache.size).isEqualTo(1)
}
@Test
fun `an entry a page still collects is kept however far away`() = runTest(UnconfinedTestDispatcher()) {
val cache = trackingCache()
backgroundScope.launch { cache.get(0).collect {} }
cache.get(10)
assertThat(running).isEqualTo(1)
assertThat(cache.size).isEqualTo(2)
}
@Test
fun `a forgotten entry a page holds still loads once collected`() = runTest(UnconfinedTestDispatcher()) {
val cache = trackingCache()
// The page took its state but hasn't started collecting when 0 is let go.
val held = cache.get(0)
cache.get(10)
val values = mutableListOf<Int>()
backgroundScope.launch { held.collect { values += it } }
assertThat(running).isEqualTo(1)
assertThat(values.last()).isEqualTo(0)
}
@Test
fun `an entry restarts after the screen comes back`() = runTest(UnconfinedTestDispatcher()) {
val cache = trackingCache()
val held = cache.get(0)
backgroundScope.launch { held.collect {} }.cancel()
advanceTimeBy(5_001)
cache.get(10)
assertThat(running).isEqualTo(0)
backgroundScope.launch { held.collect {} }
assertThat(running).isEqualTo(1)
}
}
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit
@@ -12,7 +10,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.yearMonth
import org.junit.jupiter.api.Test
/**
@@ -37,22 +34,22 @@ class ContinuousMonthIndexTest {
@Test
fun `index round-trips back to its month`() {
listOf(
YearMonth(1600, Month.JANUARY),
YearMonth(1900, Month.JANUARY),
jun26,
YearMonth(2026, Month.DECEMBER),
YearMonth(2399, Month.DECEMBER),
YearMonth(2100, Month.DECEMBER),
).forEach { ym ->
assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym)
}
}
@Test
fun `indices are non-negative and span 1600 through 2399`() {
assertThat(monthIndexOf(YearMonth(1600, Month.JANUARY))).isEqualTo(0)
fun `indices are non-negative and span 1900 through 2100`() {
assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0)
assertThat(monthIndexOf(jun26)).isGreaterThan(0)
assertThat(monthIndexOf(YearMonth(2399, Month.DECEMBER)))
assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER)))
.isEqualTo(continuousMonthCount() - 1)
assertThat(continuousMonthCount()).isEqualTo(800 * 12)
assertThat(continuousMonthCount()).isEqualTo(201 * 12)
}
@Test
@@ -157,9 +154,9 @@ class ContinuousMonthIndexTest {
DayOfWeek.entries.forEach { ws ->
val index = weekIndexOf(wed, ws)
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfWeek(ws))
assertThat(weekIndexOf(LocalDate(1600, 1, 1), ws)).isAtLeast(0)
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
assertThat(index).isLessThan(continuousWeekCount(ws))
assertThat(weekIndexOf(LocalDate(2399, 12, 31), ws))
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
.isLessThan(continuousWeekCount(ws))
}
}
@@ -239,68 +236,4 @@ class ContinuousMonthIndexTest {
assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1))
}
private fun days(month: YearMonth, today: LocalDate, byDay: Map<LocalDate, List<EventInstance>>, showToday: Boolean = true) =
splitAgendaDays(month, today, byDay, showToday).map { it.date }
@Test
fun `the split agenda skips a free first day of another month`() {
val today = LocalDate(2026, 6, 10)
val byDay = mapOf(
LocalDate(2026, 7, 1) to emptyList(),
LocalDate(2026, 7, 3) to listOf(testEvent()),
)
assertThat(days(YearMonth(2026, Month.JULY), today, byDay)).containsExactly(LocalDate(2026, 7, 3))
}
@Test
fun `the split agenda keeps a free today`() {
val today = LocalDate(2026, 6, 10)
val byDay = mapOf(LocalDate(2026, 6, 12) to listOf(testEvent()))
assertThat(days(jun26, today, byDay)).containsExactly(today, LocalDate(2026, 6, 12)).inOrder()
}
@Test
fun `the split agenda drops a free today when the agenda would`() {
val today = LocalDate(2026, 6, 10)
val byDay = mapOf(LocalDate(2026, 6, 12) to listOf(testEvent()))
assertThat(days(jun26, today, byDay, showToday = false)).containsExactly(LocalDate(2026, 6, 12))
}
@Test
fun `the split agenda leaves out past days and the grid's other months`() {
val today = LocalDate(2026, 6, 10)
val byDay = mapOf(
LocalDate(2026, 5, 31) to listOf(testEvent()),
LocalDate(2026, 6, 9) to listOf(testEvent()),
LocalDate(2026, 6, 10) to listOf(testEvent()),
LocalDate(2026, 7, 1) to listOf(testEvent()),
)
assertThat(days(jun26, today, byDay)).containsExactly(today)
assertThat(days(YearMonth(2026, Month.JULY), today, byDay)).containsExactly(LocalDate(2026, 7, 1))
}
@Test
fun `an empty month lists no days`() {
assertThat(days(YearMonth(2026, Month.JULY), LocalDate(2026, 6, 10), emptyMap())).isEmpty()
}
@Test
fun `a date before 1600 is held at the first day of the grid`() {
val held = coerceToMonthRange(LocalDate(1599, 12, 31))
assertThat(held).isEqualTo(LocalDate(1600, 1, 1))
assertThat(monthIndexOf(held.yearMonth)).isEqualTo(0)
}
@Test
fun `a date after 2399 is held at the last day of the grid`() {
val held = coerceToMonthRange(LocalDate(2400, 1, 1))
assertThat(held).isEqualTo(LocalDate(2399, 12, 31))
assertThat(monthIndexOf(held.yearMonth)).isEqualTo(continuousMonthCount() - 1)
}
@Test
fun `a date inside the span is left alone`() {
assertThat(coerceToMonthRange(LocalDate(2026, 6, 10))).isEqualTo(LocalDate(2026, 6, 10))
}
}
@@ -321,18 +321,4 @@ class MonthLayoutTest {
private fun declinedTimed(date: LocalDate) =
timed(date, 7, 8, id = 4L, title = "Declined early")
.copy(response = EventResponse.Declined)
@Test
fun `a timed event from midnight to midnight is a bar, not a timed chip`() {
val ev = timedEvent(
LocalDate(2026, 6, 10), 0, 0, zone = zone, id = 3L,
endDate = LocalDate(2026, 6, 11),
)
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
assertThat(week.spans.map { it.event }).containsExactly(ev)
assertThat(week.spans.single().startCol).isEqualTo(2)
assertThat(week.spans.single().endCol).isEqualTo(2)
assertThat(week.timedByDay.values.flatten()).isEmpty()
}
}
@@ -1,75 +0,0 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.MainDispatcherExtension
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.YearMonth
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** The month view never leaves the span its pager can show (#457). */
@OptIn(ExperimentalCoroutinesApi::class)
class MonthViewModelRangeTest {
@JvmField @RegisterExtension
val mainDispatcher = MainDispatcherExtension()
private val dispatcher = mainDispatcher.dispatcher
private fun viewModel(tempDir: Path): MonthViewModel {
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
val repo = testRepository(
FakeCalendarDataSource(), tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = settings,
)
val today = object : TodaySource {
override val today = flowOf(LocalDate(2026, 11, 2))
}
return MonthViewModel(repo, settings, today, dispatcher)
}
@Test
fun `a focus before 1600 opens the first month`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir)
vm.goToDate(LocalDate(1599, 12, 31))
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1))
}
@Test
fun `a focus after 2399 opens the last month`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir)
vm.goToDate(LocalDate(2400, 1, 1))
assertThat(vm.month.value).isEqualTo(YearMonth(2399, Month.DECEMBER))
}
@Test
fun `a tap on a day past the span is ignored`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir)
vm.goToDate(LocalDate(1600, 1, 1))
vm.selectDate(LocalDate(1599, 12, 31))
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1))
}
@Test
fun `the split pane stops at the first month`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir)
vm.goToDate(LocalDate(1600, 1, 15))
vm.clearSelection()
vm.stepSplitPane(-1)
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
}
}
@@ -10,9 +10,8 @@ import kotlinx.datetime.plus
import org.junit.jupiter.api.Test
/**
* Pins each grid's page numbering: a pager restores its page by number, so a
* change here moves the user to another date. Page 0 holds the first day of
* the shared span, and the last page holds its last day (#457).
* Pins each grid's page numbering to the formulas it shipped with: a pager
* restores its page by number, so a change here moves the user to another date.
*/
class PageNumberingTest {
@@ -27,7 +26,6 @@ class PageNumberingTest {
add(e)
e = e.plus(1, DateTimeUnit.DAY)
}
addAll(listOf(FIRST, FIRST.minus(1, DateTimeUnit.DAY), LAST, LAST.plus(1, DateTimeUnit.DAY)))
}
private fun pages(count: Int): List<Int> =
@@ -38,14 +36,13 @@ class PageNumberingTest {
fun `week pages keep their numbering for every first day`() {
DayOfWeek.entries.forEach { firstDay ->
val grid = DaysGrid.weeks(firstDay)
val phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.weekStart(firstDay))
assertThat(grid.dayCount).isEqualTo(7)
assertThat(grid.pageCount).isEqualTo(expectedCount(7, phase))
pages(grid.pageCount).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, 7, phase))
assertThat(grid.pageCount).isEqualTo(LEGACY_WEEK_COUNT)
pages(LEGACY_WEEK_COUNT).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(legacyWeekStart(page, firstDay))
}
dates.forEach { date ->
assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, 7, phase))
assertThat(grid.pageFor(date)).isEqualTo(legacyWeekPage(date, firstDay))
}
}
}
@@ -55,12 +52,12 @@ class PageNumberingTest {
(1..7).forEach { count ->
(-count..count * 2).forEach { phase ->
val grid = DaysGrid(count, phase)
assertThat(grid.pageCount).isEqualTo(expectedCount(count, phase))
pages(grid.pageCount).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, count, phase))
assertThat(grid.pageCount).isEqualTo(LEGACY_DAYS_COUNT)
pages(LEGACY_DAYS_COUNT).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(legacyDaysStart(page, count, phase))
}
dates.forEach { date ->
assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, count, phase))
assertThat(grid.pageFor(date)).isEqualTo(legacyDaysPage(date, count, phase))
}
}
dates.filter { it.year in 1950..2050 }.forEach { date ->
@@ -74,21 +71,12 @@ class PageNumberingTest {
fun `day pages keep their numbering`() {
val grid = DaysGrid.days
assertThat(grid.dayCount).isEqualTo(1)
assertThat(grid.pageCount).isEqualTo(FIRST.daysUntil(LAST) + 1)
pages(grid.pageCount).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(FIRST.plus(page, DateTimeUnit.DAY))
assertThat(grid.pageCount).isEqualTo(LEGACY_DAY_COUNT)
pages(LEGACY_DAY_COUNT).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(legacyDayStart(page))
}
dates.forEach { date ->
assertThat(grid.pageFor(date)).isEqualTo(FIRST.daysUntil(date).coerceIn(0, grid.pageCount - 1))
}
}
@Test
fun `every grid reaches both ends of the span`() {
(DayOfWeek.entries.map { DaysGrid.weeks(it) } + DaysGrid.days + (1..7).map { DaysGrid(it, 0) }).forEach { grid ->
assertThat(grid.startForPage(grid.pageFor(FIRST))).isAtMost(FIRST)
val lastStart = grid.startForPage(grid.pageFor(LAST))
assertThat(lastStart.plus(grid.dayCount - 1, DateTimeUnit.DAY)).isAtLeast(LAST)
assertThat(grid.pageFor(date)).isEqualTo(legacyDayPage(date))
}
}
@@ -116,28 +104,34 @@ class PageNumberingTest {
}
private companion object {
val FIRST = LocalDate(1600, 1, 1)
val LAST = LocalDate(2399, 12, 31)
const val LEGACY_WEEK_COUNT = 20_000
const val LEGACY_DAYS_COUNT = 40_000
const val LEGACY_DAY_COUNT = 140_000
val WEEK_EPOCH = LocalDate(2000, 1, 3)
val DAYS_EPOCH = LocalDate(2000, 1, 1)
fun LocalDate.weekStart(firstDay: DayOfWeek): LocalDate =
minus(((dayOfWeek.ordinal - firstDay.ordinal) + 7) % 7, DateTimeUnit.DAY)
/** Page 0 starts on the page boundary at or before [FIRST]. */
fun firstStart(count: Int, phase: Int): LocalDate {
val origin = DAYS_EPOCH.plus(phase, DateTimeUnit.DAY)
return origin.plus(Math.floorDiv(origin.daysUntil(FIRST), count) * count, DateTimeUnit.DAY)
fun legacyWeekStart(page: Int, firstDay: DayOfWeek): LocalDate =
WEEK_EPOCH.weekStart(firstDay).plus((page - LEGACY_WEEK_COUNT / 2) * 7, DateTimeUnit.DAY)
fun legacyWeekPage(date: LocalDate, firstDay: DayOfWeek): Int {
val days = WEEK_EPOCH.weekStart(firstDay).daysUntil(date.weekStart(firstDay))
return (LEGACY_WEEK_COUNT / 2 + days / 7).coerceIn(0, LEGACY_WEEK_COUNT - 1)
}
fun expectedStart(page: Int, count: Int, phase: Int): LocalDate =
firstStart(count, phase).plus(page * count, DateTimeUnit.DAY)
fun legacyDaysStart(page: Int, count: Int, phase: Int): LocalDate =
DAYS_EPOCH.plus(phase + (page - LEGACY_DAYS_COUNT / 2) * count, DateTimeUnit.DAY)
fun expectedCount(count: Int, phase: Int): Int =
Math.floorDiv(firstStart(count, phase).daysUntil(LAST), count) + 1
fun legacyDaysPage(date: LocalDate, count: Int, phase: Int): Int =
(LEGACY_DAYS_COUNT / 2 + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, count))
.coerceIn(0, LEGACY_DAYS_COUNT - 1)
fun expectedPage(date: LocalDate, count: Int, phase: Int): Int =
Math.floorDiv(firstStart(count, phase).daysUntil(date), count)
.coerceIn(0, expectedCount(count, phase) - 1)
fun legacyDayStart(page: Int): LocalDate =
DAYS_EPOCH.plus(page - LEGACY_DAY_COUNT / 2, DateTimeUnit.DAY)
fun legacyDayPage(date: LocalDate): Int =
(LEGACY_DAY_COUNT / 2 + DAYS_EPOCH.daysUntil(date)).coerceIn(0, LEGACY_DAY_COUNT - 1)
}
}
@@ -300,11 +300,4 @@ class WeekLayoutTest {
val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z"))
assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40)
}
@Test
fun `a timed event from midnight to midnight stays out of the time grid`() {
val ev = testEvent(start = at(wed, 0), end = at(wed.plus(1, DateTimeUnit.DAY), 0))
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
}
}
@@ -68,16 +68,9 @@ class CountdownTest {
val over = event(now - 2.hours, now - 1.hours, id = 3)
val later = event(now + 3.hours, id = 4)
val running = event(now - 1.hours, now + 2.hours, id = 5)
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now, zone)?.eventId).isEqualTo(4)
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
// A running event ending before the next one starts comes first.
assertThat(pickNextEvent(listOf(later, running), now, zone)?.eventId).isEqualTo(5)
}
@Test
fun `next-event mode skips a timed event filling whole days`() {
val wholeDay = event(Instant.parse("2026-10-22T00:00:00Z"), Instant.parse("2026-10-23T00:00:00Z"), id = 1)
val later = event(now + 30.hours, id = 2)
assertThat(pickNextEvent(listOf(wholeDay, later), now, zone)?.eventId).isEqualTo(2)
assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
}
@Test
+1 -1
View File
@@ -333,7 +333,7 @@ sequenceDiagram
Src-->>Sc: Instances ⋈ Reminders
Sc->>P: planReminders / scheduleReminders(watermark, now)
P-->>Sc: due + next alarm
Sc->>N: post(alert) — tag = occurrence, so a later reminder replaces it
Sc->>N: post(alert) — tag = reminder key
Sc->>A: scheduleScan(next)
```