Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd3fb7378f |
No files matched your search
+4
-13
@@ -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.
|
||||
|
||||
-12
@@ -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)}"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user