feat(alarms): schedule an alarm on a date, pause a repeating one

A one-shot alarm can now ring on a chosen date, and a repeating alarm
can be paused for a range of days. Both are local dates: the date is a
floor and the pause a hole in the occurrence sequence, so DST and zone
changes resolve as for any other alarm. A missed date behaves like a
missed one-shot, and each clears itself once it can no longer change a
candidate. Picking a repeat day clears the date, as in Google Clock;
"skip next" stays the quick version of a pause.

The editor uses the M3 date and date-range pickers, which offer no past
day. The list, the next-alarm widget and the self-check read the
resolver, so they follow. The schema goes to v4 with a migration, and
the backup format to v2, which still imports v1 files. SET_ALARM no
longer re-uses an alarm that carries a date or a pause.

Fixes #21
This commit is contained in:
makiolaj committed 2026-10-21 20:16:34 +02:00
1 parent 3f431a7f32
commit b1a45db54a
43 files changed
+2488 -74

No files matched your search

@@ -13,7 +13,7 @@ import org.junit.Test
import org.junit.runner.RunWith
/**
* The only proof the v1 → v2 migration works. A migration that fails eats the
* The only proof the migrations work. A migration that fails eats the
* user's alarms, so it is validated against the exported schema rather than
* eyeballed. Device only; the build gate compiles it.
*/
@@ -138,6 +138,47 @@ class MigrationTest {
assertThat(timer!!.deleteAfterUse).isFalse()
}
@Test
fun theThirdMigrationValidatesAgainstTheExportedVersionFourSchema() {
helper.createDatabase(DB_NAME, 3).close()
val migrated = helper.runMigrationsAndValidate(DB_NAME, 4, true, Migrations.MIGRATION_3_4)
assertThat(migrated.version).isEqualTo(4)
migrated.close()
}
@Test
fun alarmsWrittenAtVersionThreeSurviveUnscheduledAndUnpaused() {
helper.createDatabase(DB_NAME, 3).apply {
execSQL(insertAlarm)
close()
}
helper.runMigrationsAndValidate(DB_NAME, 4, true, Migrations.MIGRATION_3_4).close()
val alarm = runBlocking { migratedDatabase().alarmDao().findById(1L) }
assertThat(alarm!!.label).isEqualTo("Gym")
assertThat(alarm.repeatDays).isEqualTo(31)
assertThat(alarm.scheduledDate).isNull()
assertThat(alarm.pauseStart).isNull()
assertThat(alarm.pauseEnd).isNull()
}
@Test
fun aDatabaseFromVersionOneMigratesAllTheWayToTheCurrentSchema() {
helper.createDatabase(DB_NAME, 1).apply {
execSQL(insertAlarm)
close()
}
helper.runMigrationsAndValidate(DB_NAME, 4, true, *Migrations.ALL).close()
val alarm = runBlocking { migratedDatabase().alarmDao().findById(1L) }
assertThat(alarm!!.deleteAfterUse).isFalse()
assertThat(alarm.scheduledDate).isNull()
}
private fun ringStateFor(alarmId: Long) = AlarmStateEntity(
alarmId = alarmId,
snoozedUntil = 1_700_000_100_000L,
@@ -325,6 +325,18 @@ class AlarmEngine @Inject constructor(
// A transient alarm is deleted wherever a permanent one would be
// disabled: the cycle has closed either way (M9 D10).
if (resolution.disableAlarm) disableOrDeleteLocked(alarm)
if (resolution.clearScheduledDate || resolution.clearPause) {
// Conditional on the stored value still being the one resolved, so
// an editor that has just picked a new date or pause keeps it.
alarms.edit(alarm.id) { current ->
current.copy(
scheduledDate = current.scheduledDate.takeUnless {
resolution.clearScheduledDate && it == alarm.scheduledDate
},
pause = current.pause.takeUnless { resolution.clearPause && it == alarm.pause },
)
}
}
}
/**
@@ -7,7 +7,8 @@ import androidx.room.PrimaryKey
/**
* The `alarms` row. Primitives only — every enum is stored as [Enum.name], every
* instant as epoch millis and the repeat set as a 7-bit mask, so the whole
* instant as epoch millis, every local date as an epoch day and the repeat set
* as a 7-bit mask, so the whole
* entity/domain translation lives in [AlarmMapper] where it can be tested.
*/
@Entity(tableName = "alarms", indices = [Index("enabled")])
@@ -28,4 +29,9 @@ data class AlarmEntity(
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "delete_after_use", defaultValue = "0") val deleteAfterUse: Boolean = false,
/** v4: `LocalDate.toEpochDay()` — a calendar date, so no zone is baked in. */
@ColumnInfo(name = "scheduled_date") val scheduledDate: Long? = null,
/** v4: both ends inclusive epoch days; one without the other reads as no pause. */
@ColumnInfo(name = "pause_start") val pauseStart: Long? = null,
@ColumnInfo(name = "pause_end") val pauseEnd: Long? = null,
)
@@ -1,35 +1,45 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDates
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.ClockLimits
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.floret.prefs.toEnum
import java.time.LocalDate
import kotlin.time.Instant
/** Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never throws. */
object AlarmMapper {
fun toDomain(entity: AlarmEntity): Alarm = Alarm(
id = entity.id,
time = TimeOfDay.clamped(entity.hour, entity.minute),
label = entity.label,
enabled = entity.enabled,
repeatDays = RepeatDays.fromMask(entity.repeatDays),
skipNextOccurrence = entity.skipNextOccurrence,
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = entity.vibrate,
snoozeMinutes = entity.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
snoozeLimit = entity.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
volumeRampSeconds = entity.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
// An absent challenge is an *override that isn't set*, so it stays null;
// a stored name the app no longer knows degrades to NONE.
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
deleteAfterUse = entity.deleteAfterUse,
)
fun toDomain(entity: AlarmEntity): Alarm {
val repeatDays = RepeatDays.fromMask(entity.repeatDays)
return Alarm(
id = entity.id,
time = TimeOfDay.clamped(entity.hour, entity.minute),
label = entity.label,
enabled = entity.enabled,
repeatDays = repeatDays,
skipNextOccurrence = entity.skipNextOccurrence,
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = entity.vibrate,
snoozeMinutes = entity.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
snoozeLimit = entity.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
volumeRampSeconds = entity.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
// An absent challenge is an *override that isn't set*, so it stays null;
// a stored name the app no longer knows degrades to NONE.
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
deleteAfterUse = entity.deleteAfterUse,
// A date belongs to a one-shot alarm and a pause to a repeating one; the
// other combination is never written, so reading it as absent is safe.
scheduledDate = epochDay(entity.scheduledDate).takeUnless { repeatDays.isRepeating },
pause = pauseOf(entity.pauseStart, entity.pauseEnd).takeIf { repeatDays.isRepeating },
)
}
fun toDomain(entities: List<AlarmEntity>): List<Alarm> = entities.map(::toDomain)
@@ -50,6 +60,9 @@ object AlarmMapper {
createdAt = alarm.createdAt.toEpochMilliseconds(),
updatedAt = alarm.updatedAt.toEpochMilliseconds(),
deleteAfterUse = alarm.deleteAfterUse,
scheduledDate = alarm.scheduledDate?.toEpochDay(),
pauseStart = alarm.pause?.start?.toEpochDay(),
pauseEnd = alarm.pause?.end?.toEpochDay(),
)
/** id = 0, createdAt = updatedAt = [now]. */
@@ -71,4 +84,17 @@ object AlarmMapper {
updatedAt = now.toEpochMilliseconds(),
deleteAfterUse = draft.deleteAfterUse,
)
/** Out of [AlarmDates]' range is corrupt, and reads as no date at all. */
private fun epochDay(value: Long?): LocalDate? {
if (value == null) return null
if (value !in AlarmDates.MIN.toEpochDay()..AlarmDates.MAX.toEpochDay()) return null
return LocalDate.ofEpochDay(value)
}
private fun pauseOf(start: Long?, end: Long?): PauseRange? {
val from = epochDay(start) ?: return null
val until = epochDay(end) ?: return null
return PauseRange.ordered(from, until)
}
}
@@ -1,8 +1,10 @@
package de.jeanlucmakiola.clockula.data.backup
import de.jeanlucmakiola.clockula.domain.AlarmDates
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.ClockLimits
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.Zones
@@ -16,6 +18,8 @@ import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.time.LocalDate
import java.time.format.DateTimeParseException
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
@@ -100,6 +104,9 @@ object BackupCodec {
volumeRampSeconds = volumeRampSeconds,
dismissChallenge = dismissChallenge?.name,
deleteAfterUse = deleteAfterUse,
scheduledDate = scheduledDate?.toString(),
pauseStart = pause?.start?.toString(),
pauseEnd = pause?.end?.toString(),
)
private fun BackupTimer.toDto() = TimerDto(
@@ -136,7 +143,7 @@ object BackupCodec {
if (format != BACKUP_FORMAT) {
return BackupResult.Failure(BackupProblem.NOT_A_BACKUP, "unknown format: '$format'")
}
if (version != BACKUP_VERSION) {
if (version !in BACKUP_MIN_VERSION..BACKUP_VERSION) {
val problem = if (version > BACKUP_VERSION) BackupProblem.NEWER_VERSION else BackupProblem.INVALID_DATA
return BackupResult.Failure(problem, "unsupported backup version: $version")
}
@@ -158,11 +165,17 @@ object BackupCodec {
"alarms[$index]: label or ringtone URI is too long",
)
}
val dates = dto.dates()
?: return BackupResult.Failure(
BackupProblem.INVALID_DATA,
"alarms[$index]: scheduled_date or pause is not a valid date range",
)
val repeatDays = RepeatDays.fromMask(dto.repeatDays)
alarms += BackupAlarm(
time = TimeOfDay(dto.hour, dto.minute),
label = dto.label,
enabled = dto.enabled,
repeatDays = RepeatDays.fromMask(dto.repeatDays),
repeatDays = repeatDays,
skipNextOccurrence = dto.skipNextOccurrence,
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = dto.vibrate,
@@ -173,6 +186,10 @@ object BackupCodec {
volumeRampSeconds = dto.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
dismissChallenge = dto.dismissChallenge?.toEnum(DismissChallenge.NONE),
deleteAfterUse = dto.deleteAfterUse,
// The same exclusivity the mapper reads with: a date belongs
// to a one-shot alarm, a pause to a repeating one.
scheduledDate = dates.scheduledDate.takeUnless { repeatDays.isRepeating },
pause = dates.pause.takeIf { repeatDays.isRepeating },
)
}
@@ -247,4 +264,32 @@ object BackupCodec {
),
)
}
private class AlarmDatesContent(val scheduledDate: LocalDate?, val pause: PauseRange?)
/**
* Null when a date is present but malformed, out of [AlarmDates]' range, or
* a pause has only one end or ends before it starts. Strict, like the
* alarm's own hour and minute: a date is the alarm's meaning, not a
* preference that can degrade to a default.
*/
private fun AlarmDto.dates(): AlarmDatesContent? {
val scheduled = scheduledDate?.let { parseDate(it) ?: return null }
if ((pauseStart == null) != (pauseEnd == null)) return null
val start = pauseStart?.let { parseDate(it) ?: return null }
val end = pauseEnd?.let { parseDate(it) ?: return null }
val pause = if (start != null && end != null) {
if (end.isBefore(start)) return null
PauseRange(start, end)
} else {
null
}
return AlarmDatesContent(scheduled, pause)
}
private fun parseDate(text: String): LocalDate? = try {
LocalDate.parse(text).takeIf(AlarmDates::isValid)
} catch (e: DateTimeParseException) {
null
}
}
@@ -5,7 +5,10 @@ import kotlinx.serialization.Serializable
/** `docs/BACKUP.md`'s top-level constants. */
const val BACKUP_FORMAT: String = "clockula-backup"
const val BACKUP_VERSION: Int = 1
const val BACKUP_VERSION: Int = 2
/** The oldest version this build still reads. v1 is v2 without the alarm dates. */
const val BACKUP_MIN_VERSION: Int = 1
/** The wire shape of `clockula-backup-v1.json`. See `docs/BACKUP.md`. */
@Serializable
@@ -35,6 +38,10 @@ data class AlarmDto(
@SerialName("volume_ramp_seconds") val volumeRampSeconds: Int? = null,
@SerialName("dismiss_challenge") val dismissChallenge: String? = null,
@SerialName("delete_after_use") val deleteAfterUse: Boolean = false,
/** v2: ISO-8601 local dates (`2026-10-28`). */
@SerialName("scheduled_date") val scheduledDate: String? = null,
@SerialName("pause_start") val pauseStart: String? = null,
@SerialName("pause_end") val pauseEnd: String? = null,
)
/** Config only — never state, the running anchors, or `ends_at_wall_clock`. */
@@ -70,6 +70,8 @@ private fun Alarm.toBackup() = BackupAlarm(
volumeRampSeconds = volumeRampSeconds,
dismissChallenge = dismissChallenge,
deleteAfterUse = deleteAfterUse,
scheduledDate = scheduledDate,
pause = pause,
)
private fun Timer.toBackup() = BackupTimer(
@@ -100,6 +102,9 @@ private fun BackupAlarm.toEntity(nowMillis: Long) = AlarmEntity(
createdAt = nowMillis,
updatedAt = nowMillis,
deleteAfterUse = deleteAfterUse,
scheduledDate = scheduledDate?.toEpochDay(),
pauseStart = pause?.start?.toEpochDay(),
pauseEnd = pause?.end?.toEpochDay(),
)
/** Imported timers always land IDLE with the full configured duration (`docs/BACKUP.md`). */
@@ -15,7 +15,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
/**
* Clockula's own storage. Version 3; the schema is exported to `app/schemas/`
* Clockula's own storage. Version 4; the schema is exported to `app/schemas/`
* and committed, so every later migration is reviewable and testable.
*
* No `TypeConverters` — every column is a primitive and the whole translation
@@ -30,7 +30,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
WorldClockEntity::class,
LapEntity::class,
],
version = 3,
version = 4,
exportSchema = true,
)
abstract class ClockulaDatabase : RoomDatabase() {
@@ -44,5 +44,16 @@ object Migrations {
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
/** v3 → v4: `scheduled_date`, `pause_start` and `pause_end` on `alarms` (#21). */
val MIGRATION_3_4: Migration = object : Migration(3, 4) {
override fun migrate(connection: SQLiteConnection) {
// Nullable with no default, as the entity declares them: every alarm
// written before v4 reads as unscheduled and unpaused.
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `scheduled_date` INTEGER")
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `pause_start` INTEGER")
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `pause_end` INTEGER")
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.domain
import java.time.DayOfWeek
import java.time.LocalDate
import kotlin.time.Instant
/** A local time of day. Always valid; use [clamped] at a data boundary. */
@@ -69,6 +70,39 @@ value class RepeatDays private constructor(val mask: Int) {
}
}
/**
* A run of whole local dates a repeating alarm stays quiet on, both ends
* inclusive. Dates, not instants: "paused from the 26th to the 28th" means the
* same three calendar days whatever zone the phone is in when they arrive.
*/
data class PauseRange(val start: LocalDate, val end: LocalDate) {
init {
require(!end.isBefore(start)) { "pause ends before it starts: $start..$end" }
}
operator fun contains(date: LocalDate): Boolean = !date.isBefore(start) && !date.isAfter(end)
companion object {
/** Never throws: the two ends are put in order. */
fun ordered(a: LocalDate, b: LocalDate): PauseRange =
if (b.isBefore(a)) PauseRange(b, a) else PauseRange(a, b)
}
}
/**
* The dates an alarm can be scheduled on or paused for. Anything outside is
* corrupt, never a choice: the pickers offer nothing near either end, and
* keeping clear of `LocalDate`'s own limits means date arithmetic on a stored
* value can never overflow.
*/
object AlarmDates {
val MIN: LocalDate = LocalDate.of(2000, 1, 1)
val MAX: LocalDate = LocalDate.of(9999, 12, 31)
fun isValid(date: LocalDate): Boolean = !date.isBefore(MIN) && !date.isAfter(MAX)
}
/** The optional barrier between a ringing alarm and dismissing it. */
enum class DismissChallenge { NONE, MATH, HOLD }
@@ -93,6 +127,13 @@ data class Alarm(
val updatedAt: Instant,
/** A transient alarm the `AlarmClock` contract asked for: deleted once its cycle closes (M9 D10). */
val deleteAfterUse: Boolean,
/**
* A one-shot alarm's date: it rings on this local date and not before.
* Only ever set on a non-repeating alarm — picking a repeat day clears it.
*/
val scheduledDate: LocalDate? = null,
/** A repeating alarm's holiday: no occurrence on these dates. Only ever set on a repeating alarm. */
val pause: PauseRange? = null,
)
/** Everything needed to create an alarm; the repository supplies id and timestamps. */
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import java.time.LocalDate
@@ -33,6 +34,13 @@ object AlarmOccurrences {
* alarm is just "the next daily candidate", and what makes it one-shot is
* what happens after it rings.
*
* [notBefore] is a scheduled date: no candidate on an earlier local date.
* It is a floor, not a filter, so the sequence stays infinite — a date that
* has already gone by (the phone was off all day) leaves the alarm behaving
* as the plain one-shot it would be without one. [pause] drops every
* candidate whose local date falls inside it; it is jumped, not walked, so
* a corrupt pause centuries long costs nothing.
*
* Two local dates can collapse onto one instant (a skipped calendar day, a
* gap), so a candidate that did not advance is dropped and the caller may
* rely on `drop(1)` meaning "the one after".
@@ -42,13 +50,21 @@ object AlarmOccurrences {
repeatDays: RepeatDays,
after: Instant,
zone: ZoneId,
notBefore: LocalDate? = null,
pause: PauseRange? = null,
): Sequence<Instant> = Sequence {
iterator {
// One day back, because a gap can push a candidate from the previous
// local date past [after].
var date = after.localDate(zone).minusDays(1)
var date = firstDateFor(after, zone).let { first ->
if (notBefore != null && notBefore.isAfter(first)) notBefore else first
}
var previous: Instant? = null
while (true) {
if (pause != null && date in pause) {
date = pause.end.plusDays(1)
continue
}
if (!repeatDays.isRepeating || date.dayOfWeek in repeatDays) {
val candidate = at(date, time, zone)
val last = previous
@@ -67,7 +83,16 @@ object AlarmOccurrences {
repeatDays: RepeatDays,
after: Instant,
zone: ZoneId,
): Instant? = sequence(time, repeatDays, after, zone).firstOrNull()
notBefore: LocalDate? = null,
pause: PauseRange? = null,
): Instant? = sequence(time, repeatDays, after, zone, notBefore, pause).firstOrNull()
/**
* The earliest local date [sequence] looks at for [after]. A scheduled date
* or a pause ending before it can no longer change a single candidate, which
* is what makes it safe to clear one there.
*/
fun firstDateFor(after: Instant, zone: ZoneId): LocalDate = after.localDate(zone).minusDays(1)
private fun Instant.localDate(zone: ZoneId): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
@@ -18,6 +18,10 @@ data class AlarmResolution(
val state: AlarmRingState,
val clearSkipFlag: Boolean = false,
val disableAlarm: Boolean = false,
/** The scheduled date can no longer change a candidate: drop it from the row. */
val clearScheduledDate: Boolean = false,
/** The pause has ended: drop it from the row. */
val clearPause: Boolean = false,
)
/**
@@ -36,6 +40,29 @@ object AlarmResolver {
zone: ZoneId,
autoSilenceAfter: Duration = AlarmRing.AUTO_SILENCE_AFTER,
fireGrace: Duration = AlarmRing.FIRE_GRACE,
): AlarmResolution {
// A date or a pause that lies wholly before the first date the
// candidates are drawn from cannot change one of them, so clearing it
// changes nothing but the row — on any branch, a disabled alarm too.
// Measured from the same `now - fireGrace` the candidates are, so a
// pause ending at midnight cannot let its last day's occurrence ring
// late through the grace window.
val firstDate = AlarmOccurrences.firstDateFor(now - fireGrace, zone)
val scheduledDate = alarm.scheduledDate
val pause = alarm.pause
return resolveRing(alarm, state, now, zone, autoSilenceAfter, fireGrace).copy(
clearScheduledDate = scheduledDate != null && !scheduledDate.isAfter(firstDate),
clearPause = pause != null && pause.end.isBefore(firstDate),
)
}
private fun resolveRing(
alarm: Alarm,
state: AlarmRingState,
now: Instant,
zone: ZoneId,
autoSilenceAfter: Duration,
fireGrace: Duration,
): AlarmResolution {
// 1. Disabled: every volatile field goes, except the dismissal watermark
// — re-enabling an alarm must not un-suppress an occurrence already rung.
@@ -139,7 +166,14 @@ object AlarmResolver {
fireGrace: Duration,
): Sequence<Instant> {
val handled = state.handledOccurrence
return AlarmOccurrences.sequence(alarm.time, alarm.repeatDays, now - fireGrace, zone)
return AlarmOccurrences.sequence(
time = alarm.time,
repeatDays = alarm.repeatDays,
after = now - fireGrace,
zone = zone,
notBefore = alarm.scheduledDate,
pause = alarm.pause,
)
.filterNot { handled != null && it <= handled && it <= now }
}
}
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
/**
* What an alarm's scheduled date and pause mean *right now*, for the surfaces
* that describe an alarm. The resolver decides when it rings; this only keeps
* the words beside it honest. A stored date or pause outlives its usefulness
* by up to a day before the resolver clears it, and in that window it must not
* be shown as though it still applied.
*/
object AlarmSchedule {
/**
* The local date [now] falls on in [zone].
*/
fun today(now: Instant, zone: ZoneId): LocalDate =
java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
/**
* A one-shot alarm's scheduled date while its occurrence is still ahead.
* Null once it has gone by: the alarm then rings like a plain one-shot,
* and saying "Scheduled for" a day in the past would be a lie.
*/
fun upcomingDate(alarm: Alarm, now: Instant, zone: ZoneId): LocalDate? {
if (alarm.repeatDays.isRepeating) return null
val date = alarm.scheduledDate ?: return null
return date.takeIf { isAhead(it, alarm.time, now, zone) }
}
/** Whether [time] on [date] is still to come — the one test a scheduled date must pass. */
fun isAhead(date: LocalDate, time: TimeOfDay, now: Instant, zone: ZoneId): Boolean =
AlarmOccurrences.at(date, time, zone) > now
/** A repeating alarm's pause while any day of it is still to come — today included. */
fun currentPause(alarm: Alarm, now: Instant, zone: ZoneId): PauseRange? {
if (!alarm.repeatDays.isRepeating) return null
val pause = alarm.pause ?: return null
return pause.takeUnless { it.end.isBefore(today(now, zone)) }
}
/**
* The first date a one-shot alarm at [time] can be scheduled on: the date
* of its next occurrence. Today while today's [time] is still ahead,
* tomorrow once it has gone by — so the picker cannot offer a date whose
* alarm would already be in the past.
*/
fun earliestDate(time: TimeOfDay, now: Instant, zone: ZoneId): LocalDate {
// Every day matches RepeatDays.NONE, so the sequence is never empty.
val next = AlarmOccurrences.next(time, RepeatDays.NONE, now, zone) ?: now
return today(next, zone)
}
}
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.clockula.domain.backup
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.floret.prefs.ThemeMode
import java.time.LocalDate
import kotlin.time.Duration
import kotlin.time.Instant
@@ -35,6 +37,8 @@ data class BackupAlarm(
val volumeRampSeconds: Int?,
val dismissChallenge: DismissChallenge?,
val deleteAfterUse: Boolean,
val scheduledDate: LocalDate? = null,
val pause: PauseRange? = null,
)
data class BackupTimer(
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.domain.widget
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Instant
@@ -19,10 +20,19 @@ data class NextAlarmWidgetModel(
val dayOfWeek: DayOfWeek? = null,
val isToday: Boolean = false,
val label: String = "",
/**
* The fire's date, when a weekday alone would be ambiguous: a week or more
* away, as a scheduled date or the end of a pause can put it. Null within
* the coming week, where "Thursday" can only mean one day.
*/
val date: LocalDate? = null,
)
object NextAlarmWidgetPresenter {
/** Today plus the six days after it: the span in which a weekday names one date. */
private const val DAYS_A_WEEKDAY_NAMES = 7L
/** The first [UpcomingAlarm] with a resolved fire time — `AlarmEngine.upcoming()`'s own sort already puts it first. */
fun present(upcoming: List<UpcomingAlarm>, now: Instant, zone: ZoneId): NextAlarmWidgetModel {
val next = upcoming.firstOrNull { it.nextFire != null } ?: return NextAlarmWidgetModel(hasAlarm = false)
@@ -36,6 +46,8 @@ object NextAlarmWidgetPresenter {
dayOfWeek = zonedFire.dayOfWeek,
isToday = zonedFire.toLocalDate() == zonedNow.toLocalDate(),
label = next.alarm.label,
date = zonedFire.toLocalDate()
.takeUnless { it.isBefore(zonedNow.toLocalDate().plusDays(DAYS_A_WEEKDAY_NAMES)) },
)
}
}
@@ -100,9 +100,17 @@ class AlarmClockHandler @Inject constructor(
return if (skipUi) InteropOutcome.Done else InteropOutcome.Show(InteropSurface.ALARMS)
}
/** The five fields the contract's extras can set, and nothing else (D11). */
/**
* The five fields the contract's extras can set, and nothing else (D11) —
* on an alarm with no date and no pause. The contract cannot express
* either, so an alarm carrying one is not "identical": re-using it would
* either leave the caller's alarm silent until the pause ends, or throw
* away a holiday the user set. A new alarm costs neither.
*/
private fun Alarm.matches(spec: AlarmSpec): Boolean =
time == spec.time &&
scheduledDate == null &&
pause == null &&
time == spec.time &&
repeatDays == spec.repeatDays &&
label == spec.label &&
ringtoneUri == spec.ringtoneUri &&
@@ -0,0 +1,188 @@
package de.jeanlucmakiola.clockula.ui.alarms
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDefaults
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.AlarmDates
import de.jeanlucmakiola.clockula.domain.PauseRange
import java.time.LocalDate
import java.util.Formatter
import java.util.Locale
/**
* The M3 pickers speak UTC-midnight millis; the app speaks [LocalDate]. The
* translation is UTC on both sides, so a picked day is the same calendar day
* whatever zone the phone is in.
*/
internal object PickerDates {
private const val MILLIS_PER_DAY = 86_400_000L
fun toMillis(date: LocalDate): Long = date.toEpochDay() * MILLIS_PER_DAY
fun toDate(utcMillis: Long): LocalDate = LocalDate.ofEpochDay(Math.floorDiv(utcMillis, MILLIS_PER_DAY))
/** [earliest] onwards, and never past [AlarmDates.MAX]: nothing in the past can be picked. */
@OptIn(ExperimentalMaterial3Api::class)
fun from(earliest: LocalDate): SelectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val date = toDate(utcTimeMillis)
return !date.isBefore(earliest) && AlarmDates.isValid(date)
}
override fun isSelectableYear(year: Int): Boolean = year >= earliest.year
}
fun yearRange(earliest: LocalDate): IntRange =
earliest.year..maxOf(earliest.year, DatePickerDefaults.YearRange.last)
}
/**
* Picks the one date a one-shot alarm rings on. [earliest] is the date of the
* alarm's next occurrence, so neither a past day nor today-after-the-time-has-
* gone can be chosen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ScheduleDatePickerDialog(
initial: LocalDate?,
earliest: LocalDate,
onConfirm: (LocalDate) -> Unit,
onRemove: (() -> Unit)?,
onDismiss: () -> Unit,
) {
val selectable = remember(earliest) { PickerDates.from(earliest) }
val start = initial?.takeUnless { it.isBefore(earliest) }
val state = rememberDatePickerState(
initialSelectedDateMillis = start?.let(PickerDates::toMillis),
initialDisplayedMonthMillis = PickerDates.toMillis(start ?: earliest),
yearRange = PickerDates.yearRange(earliest),
selectableDates = selectable,
)
val picked = state.selectedDateMillis
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = { picked?.let { onConfirm(PickerDates.toDate(it)) } },
enabled = picked != null,
) { Text(stringResource(R.string.action_ok)) }
},
dismissButton = { DismissButtons(onRemove = onRemove, onDismiss = onDismiss) },
) {
DatePicker(
state = state,
title = {
Text(
text = stringResource(R.string.editor_schedule),
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp),
)
},
)
}
}
/** Picks the days a repeating alarm stays quiet on. A start with no end is a one-day pause. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PauseRangePickerDialog(
initial: PauseRange?,
today: LocalDate,
onConfirm: (PauseRange) -> Unit,
onRemove: (() -> Unit)?,
onDismiss: () -> Unit,
) {
val selectable = remember(today) { PickerDates.from(today) }
val start = initial?.start?.let { maxOf(it, today) }
val end = initial?.end?.takeUnless { it.isBefore(today) }
val state = rememberDateRangePickerState(
initialSelectedStartDateMillis = start?.let(PickerDates::toMillis),
initialSelectedEndDateMillis = end?.let(PickerDates::toMillis),
initialDisplayedMonthMillis = PickerDates.toMillis(start ?: today),
yearRange = PickerDates.yearRange(today),
selectableDates = selectable,
)
val pickedStart = state.selectedStartDateMillis
val pickedEnd = state.selectedEndDateMillis ?: pickedStart
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
if (pickedStart != null && pickedEnd != null) {
onConfirm(PauseRange.ordered(PickerDates.toDate(pickedStart), PickerDates.toDate(pickedEnd)))
}
},
enabled = pickedStart != null,
) { Text(stringResource(R.string.action_ok)) }
},
dismissButton = { DismissButtons(onRemove = onRemove, onDismiss = onDismiss) },
) {
DateRangePicker(
state = state,
modifier = Modifier.weight(1f),
title = {
Text(
text = stringResource(R.string.editor_pause),
modifier = Modifier.padding(start = 64.dp, end = 12.dp, top = 16.dp),
)
},
)
}
}
/** Cancel, and — once there is something to take away — Remove beside it. */
@Composable
private fun DismissButtons(onRemove: (() -> Unit)?, onDismiss: () -> Unit) {
Row {
if (onRemove != null) {
TextButton(onClick = onRemove) { Text(stringResource(R.string.action_remove)) }
}
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
}
/** "Wed, Oct 28" in the reader's locale; the year only when it is not this one. */
@Composable
internal fun scheduledDateText(date: LocalDate): String =
formatDates(date, date, DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_ABBREV_WEEKDAY)
/** "Oct 26 – 28", "Oct 30 – Nov 2" or "Oct 26": the platform's own localised range. */
@Composable
internal fun pauseRangeText(pause: PauseRange): String = formatDates(pause.start, pause.end, 0)
@Composable
private fun formatDates(start: LocalDate, end: LocalDate, extraFlags: Int): String {
val context = LocalContext.current
val configuration = LocalConfiguration.current
return remember(start, end, extraFlags, configuration) {
// Noon, not midnight: an end at exactly midnight reads to the platform
// as "up to the start of that day" and would lose the range's last day.
val noon = DateUtils.HOUR_IN_MILLIS * 12
DateUtils.formatDateRange(
context,
Formatter(StringBuilder(), Locale.getDefault()),
PickerDates.toMillis(start) + noon,
PickerDates.toMillis(end) + noon,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_ABBREV_MONTH or extraFlags,
"UTC",
).toString()
}
}
@@ -41,7 +41,7 @@ import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/** Which override picker is open. Null is "none"; the ringtone picker is the ViewModel's. */
private enum class OpenPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE }
private enum class OpenPicker { SCHEDULE_DATE, PAUSE, VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE }
/**
* The alarm editor: every control writes at once, so there is no Save button,
@@ -150,16 +150,27 @@ private fun AlarmEditorContent(
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
GroupedSectionHeader(stringResource(R.string.editor_repeat))
// The days, then the date a one-shot rings on — or, for a repeating
// alarm, its pause and the skip: one group, so the three ways of saying
// "not that day" sit together.
val repeatRows = if (editing.canSkipNext) 3 else 2
RepeatDaySelector(
repeatDays = editing.repeatDays,
onDayToggled = viewModel::onDayToggled,
position = if (editing.canSkipNext) Position.Top else Position.Alone,
position = positionOf(0, repeatRows),
)
if (editing.canSkipNext) {
GroupedRow(
title = stringResource(R.string.editor_pause),
summary = editing.pause?.let { pauseRangeText(it) }
?: stringResource(R.string.editor_pause_none),
position = positionOf(1, repeatRows),
onClick = { openPicker = OpenPicker.PAUSE },
)
GroupedRow(
title = stringResource(R.string.editor_skip_next),
summary = stringResource(R.string.editor_skip_next_summary),
position = Position.Bottom,
position = positionOf(2, repeatRows),
trailing = {
Switch(
checked = editing.skipNextOccurrence,
@@ -168,6 +179,14 @@ private fun AlarmEditorContent(
},
onClick = { viewModel.onSkipNextChanged(!editing.skipNextOccurrence) },
)
} else {
GroupedRow(
title = stringResource(R.string.editor_schedule),
summary = editing.scheduledDate?.let { scheduledDateText(it) }
?: stringResource(R.string.editor_schedule_none),
position = positionOf(1, repeatRows),
onClick = { openPicker = OpenPicker.SCHEDULE_DATE },
)
}
GroupedSectionHeader(stringResource(R.string.editor_section_sound))
@@ -231,6 +250,36 @@ private fun AlarmEditorContent(
}
when (openPicker) {
null -> Unit
OpenPicker.SCHEDULE_DATE -> ScheduleDatePickerDialog(
initial = editing.scheduledDate,
earliest = editing.earliestScheduleDate,
onConfirm = {
viewModel.onScheduledDateChosen(it)
openPicker = null
},
onRemove = editing.scheduledDate?.let {
{
viewModel.onScheduledDateChosen(null)
openPicker = null
}
},
onDismiss = { openPicker = null },
)
OpenPicker.PAUSE -> PauseRangePickerDialog(
initial = editing.pause,
today = editing.today,
onConfirm = {
viewModel.onPauseChosen(it)
openPicker = null
},
onRemove = editing.pause?.let {
{
viewModel.onPauseChosen(null)
openPicker = null
}
},
onDismiss = { openPicker = null },
)
OpenPicker.VIBRATE -> VibratePicker(
stored = editing.vibrate.value.takeUnless { editing.vibrate.inherited },
default = editing.defaults.vibrate,
@@ -2,8 +2,10 @@ package de.jeanlucmakiola.clockula.ui.alarms
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import java.time.LocalDate
data class OverrideValue<T>(val value: T, val inherited: Boolean)
@@ -42,6 +44,14 @@ sealed interface AlarmEditorUiState {
val skipNextOccurrence: Boolean,
/** False for a one-shot alarm: skipping it would mean disabling it (M5 D14). */
val canSkipNext: Boolean,
/** A one-shot's date while it is still ahead. Only a one-shot can be scheduled. */
val scheduledDate: LocalDate?,
/** The first date the schedule picker offers: the date of the alarm's next occurrence. */
val earliestScheduleDate: LocalDate,
/** A repeating alarm's pause while any day of it is still to come. Only a repeating alarm can pause. */
val pause: PauseRange?,
/** The first date the pause picker offers. */
val today: LocalDate,
val ringtone: RingtoneSetting,
val vibrate: OverrideValue<Boolean>,
val snoozeMinutes: OverrideValue<Int>,
@@ -12,8 +12,12 @@ import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.alarm.AlarmSchedule
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
import de.jeanlucmakiola.floret.di.ApplicationScope
@@ -25,6 +29,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.time.DayOfWeek
import java.time.LocalDate
import javax.inject.Inject
/**
@@ -41,6 +46,8 @@ class AlarmEditorViewModel @Inject internal constructor(
private val engine: AlarmEngine,
private val ringtones: RingtoneCatalog,
private val preview: RingtonePreviewer,
private val wallClock: WallClock,
private val zones: ZoneProvider,
// `@param:` — the qualifier has no explicit @Target, and K2 resolves a bare
// annotation on a constructor `val` to the property instead.
@param:ApplicationScope private val appScope: CoroutineScope,
@@ -97,9 +104,23 @@ class AlarmEditorViewModel @Inject internal constructor(
}
}
/** Clamped, never throws. */
fun onTimePicked(hour: Int, minute: Int) =
edit(reschedule = true) { it.copy(time = TimeOfDay.clamped(hour, minute)) }
/**
* Clamped, never throws. A scheduled date whose day the new time has
* already gone by on — today, with an earlier time — is dropped rather
* than left to quietly become tomorrow's: the row then says "Once", which
* is what the alarm now is.
*/
fun onTimePicked(hour: Int, minute: Int) {
val time = TimeOfDay.clamped(hour, minute)
val now = wallClock.now()
val zone = zones.current()
edit(reschedule = true) { alarm ->
alarm.copy(
time = time,
scheduledDate = alarm.scheduledDate?.takeIf { AlarmSchedule.isAhead(it, time, now, zone) },
)
}
}
fun onDayToggled(day: DayOfWeek) = edit(reschedule = true) { alarm ->
val days = if (day in alarm.repeatDays) alarm.repeatDays - day else alarm.repeatDays + day
@@ -110,9 +131,43 @@ class AlarmEditorViewModel @Inject internal constructor(
// and `canSkipNext` has just gone false, so the editor would have
// hidden the only control that could explain it (D14).
skipNextOccurrence = alarm.skipNextOccurrence && days.isRepeating,
// A date and repeat days are exclusive, as in every clock that has
// both: picking a day clears the date, and a pause has nothing to
// pause once the alarm stops repeating.
scheduledDate = alarm.scheduledDate.takeUnless { days.isRepeating },
pause = alarm.pause.takeIf { days.isRepeating },
)
}
/**
* null removes the date. A date the picker should not have offered — one
* on which this alarm's time is already past — is refused, not stored.
*/
fun onScheduledDateChosen(date: LocalDate?) {
val now = wallClock.now()
val zone = zones.current()
edit(reschedule = true) { alarm ->
when {
alarm.repeatDays.isRepeating -> alarm
date == null -> alarm.copy(scheduledDate = null)
!AlarmSchedule.isAhead(date, alarm.time, now, zone) -> alarm
else -> alarm.copy(scheduledDate = date)
}
}
}
/** null ends the pause. Only a repeating alarm can pause; a pause wholly in the past is refused. */
fun onPauseChosen(pause: PauseRange?) {
val today = AlarmSchedule.today(wallClock.now(), zones.current())
edit(reschedule = true) { alarm ->
when {
!alarm.repeatDays.isRepeating -> alarm
pause != null && pause.end.isBefore(today) -> alarm
else -> alarm.copy(pause = pause)
}
}
}
/** Verbatim, conflated (M5 D24). */
fun onLabelTyped(text: String) {
// Untrimmed: `Alarm.label` is verbatim everywhere else, and trimming as
@@ -246,6 +301,8 @@ class AlarmEditorViewModel @Inject internal constructor(
private suspend fun render(inputs: Inputs): AlarmEditorUiState {
val alarm = inputs.alarm ?: return AlarmEditorUiState.Gone
val now = wallClock.now()
val zone = zones.current()
return AlarmEditorUiState.Editing(
alarmId = alarm.id,
time = alarm.time,
@@ -255,6 +312,10 @@ class AlarmEditorViewModel @Inject internal constructor(
// A one-shot's only occurrence cannot be skipped: the resolver
// answers that by disabling the alarm (D14).
canSkipNext = alarm.repeatDays.isRepeating,
scheduledDate = AlarmSchedule.upcomingDate(alarm, now, zone),
earliestScheduleDate = AlarmSchedule.earliestDate(alarm.time, now, zone),
pause = AlarmSchedule.currentPause(alarm, now, zone),
today = AlarmSchedule.today(now, zone),
ringtone = ringtoneSetting(alarm, inputs.defaults, inputs.accessMayLapse),
vibrate = AlarmOverrides.of(alarm.vibrate, inputs.defaults.vibrate),
snoozeMinutes = AlarmOverrides.of(alarm.snoozeMinutes, inputs.defaults.snoozeMinutes),
@@ -3,18 +3,20 @@ package de.jeanlucmakiola.clockula.ui.alarms
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/** The engine, the ticker and the wall clock, combined into the list's rows. */
/** The engine, the ticker, the wall clock and the zone, combined into the list's rows. */
@Singleton
internal class AlarmListSource @Inject constructor(
private val engine: AlarmEngine,
private val wall: WallClock,
private val ticker: Ticker,
private val zones: ZoneProvider,
) {
/**
* Distinct-until-changed. The cadence is the engine's `refresh` parameter,
@@ -23,6 +25,6 @@ internal class AlarmListSource @Inject constructor(
*/
fun rows(): Flow<List<AlarmRowState>> = engine
.upcoming(ticker.ticks(AlarmsDefaults.NextFireTick))
.map { upcoming -> AlarmListRows.from(upcoming, wall.now()) }
.map { upcoming -> AlarmListRows.from(upcoming, wall.now(), zones.current()) }
.distinctUntilChanged()
}
@@ -25,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.RepeatDaysSummary
import de.jeanlucmakiola.clockula.domain.RepeatSummary
@@ -120,12 +121,20 @@ internal fun AlarmRow(
@Composable
private fun rowSummary(row: AlarmRowState): AnnotatedString {
val primary = MaterialTheme.colorScheme.primary
val repeat = repeatSummaryText(row.repeatDays)
// A scheduled date says more than "Once", so it takes that word's place.
val repeat = row.scheduledDate
?.let { stringResource(R.string.alarm_scheduled_for, scheduledDateText(it)) }
?: repeatSummaryText(row.repeatDays)
val paused = row.pause?.let { pausedText(it, row.pauseBegun) }
val next = nextFireClause(row)
val skipping = stringResource(R.string.alarm_skipping_next)
val separator = stringResource(R.string.alarm_summary_separator)
return buildAnnotatedString {
append(repeat)
if (paused != null) {
append(separator)
append(paused)
}
if (next != null) {
append(separator)
if (row.enabled && row.isNext) {
@@ -141,6 +150,18 @@ private fun rowSummary(row: AlarmRowState): AnnotatedString {
}
}
/**
* "Paused Oct 26 – 28" while the pause is still ahead, "Paused until Oct 28"
* once it has begun: by then its start is history and only the end matters.
*/
@Composable
private fun pausedText(pause: PauseRange, begun: Boolean): String =
if (!begun) {
stringResource(R.string.alarm_paused_dates, pauseRangeText(pause))
} else {
stringResource(R.string.alarm_paused_until, scheduledDateText(pause.end))
}
/** Ringing and snoozed say so; anything else is the countdown, or nothing. */
@Composable
private fun nextFireClause(row: AlarmRowState): String? = when {
@@ -1,11 +1,15 @@
package de.jeanlucmakiola.clockula.ui.alarms
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.alarm.AlarmSchedule
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import de.jeanlucmakiola.clockula.domain.format.NextFireFormat
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
data class AlarmRowState(
@@ -20,6 +24,12 @@ data class AlarmRowState(
val snoozed: Boolean,
val ringing: Boolean,
val skippingNext: Boolean,
/** A one-shot's date while it is still ahead; null once it has gone by. */
val scheduledDate: LocalDate? = null,
/** A repeating alarm's pause while any day of it is still to come. */
val pause: PauseRange? = null,
/** [pause] covers today already, so only its end is news. */
val pauseBegun: Boolean = false,
/** The earliest non-null next fire. Never a ringing alarm. */
val isNext: Boolean,
)
@@ -32,7 +42,7 @@ sealed interface AlarmsUiState {
internal object AlarmListRows {
/** Pure. Orders by time of day then id; marks `isNext`; never throws. */
fun from(upcoming: List<UpcomingAlarm>, now: Instant): List<AlarmRowState> {
fun from(upcoming: List<UpcomingAlarm>, now: Instant, zone: ZoneId): List<AlarmRowState> {
// The mark goes on the earliest *coming* alarm — the engine's own order,
// which is not the list's (M5 D13): a row must not move because a
// countdown crossed another row's.
@@ -45,6 +55,7 @@ internal object AlarmListRows {
.map { entry ->
val alarm = entry.alarm
val ringing = entry.source == FireSource.RESUMED_RING
val pause = AlarmSchedule.currentPause(alarm, now, zone)
AlarmRowState(
id = alarm.id,
time = alarm.time,
@@ -57,6 +68,9 @@ internal object AlarmListRows {
// A one-shot alarm answers a skip by disabling itself, so
// only a repeating alarm can be "skipping next" (M5 D14).
skippingNext = alarm.skipNextOccurrence && alarm.repeatDays.isRepeating,
scheduledDate = AlarmSchedule.upcomingDate(alarm, now, zone),
pause = pause,
pauseBegun = pause != null && !pause.start.isAfter(AlarmSchedule.today(now, zone)),
isNext = !ringing && next?.alarm?.id == alarm.id,
)
}
@@ -139,6 +139,9 @@ private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String {
private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = when {
model.isToday -> context.getString(R.string.widget_next_alarm_today)
model.date != null -> Locale.getDefault().let { locale ->
DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEMMMd"), locale).format(model.date)
}
model.dayOfWeek != null -> model.dayOfWeek.getDisplayName(JavaTextStyle.FULL, Locale.getDefault())
else -> ""
}
+10
View File
@@ -118,6 +118,12 @@
<string name="alarm_repeat_every_day">Every day</string>
<string name="alarm_repeat_weekdays">Weekdays</string>
<string name="alarm_repeat_weekends">Weekends</string>
<!-- %1$s is a date such as "Wed, Oct 28" -->
<string name="alarm_scheduled_for">Scheduled for %1$s</string>
<!-- %1$s is a date range such as "Oct 26 – 28" -->
<string name="alarm_paused_dates">Paused %1$s</string>
<!-- %1$s is a date such as "Oct 28": the pause has already begun -->
<string name="alarm_paused_until">Paused until %1$s</string>
<string name="alarm_summary_separator" translatable="false">" \u00b7 "</string>
<!-- M5: the alarm editor -->
@@ -125,6 +131,10 @@
<string name="editor_repeat">Repeat</string>
<string name="editor_skip_next">Skip next alarm</string>
<string name="editor_skip_next_summary">Ring the one after this</string>
<string name="editor_schedule">Schedule alarm</string>
<string name="editor_schedule_none">Pick a date for this alarm to ring on</string>
<string name="editor_pause">Pause alarm</string>
<string name="editor_pause_none">Keep it quiet for a few days, such as a holiday</string>
<string name="editor_label_placeholder">Alarm name</string>
<string name="editor_section_sound">Sound and vibration</string>
<string name="editor_section_snooze">Snooze and dismiss</string>
@@ -0,0 +1,110 @@
package de.jeanlucmakiola.clockula.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.Registration
import de.jeanlucmakiola.clockula.testing.alarmAt
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.LocalDate
/** A scheduled date and a pause, end to end through the real engine (#21). */
class AlarmEngineScheduleTest {
private val june18 = LocalDate.of(2026, 6, 18)
@Test
fun `a scheduled alarm is registered for its date`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.alarmDao.insert(AlarmMapper.toEntity(alarmAt(hour = 7).copy(scheduledDate = june18)))
harness.engine.reschedule()
assertThat(harness.scheduler.next).isEqualTo(Registration(offsetInstant("2026-06-18T07:00+02:00"), 1L))
}
@Test
fun `a scheduled alarm rings on its date, then switches off and lets the date go`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.alarmDao.insert(AlarmMapper.toEntity(alarmAt(hour = 7).copy(scheduledDate = june18)))
harness.engine.reschedule()
val fireAt = offsetInstant("2026-06-18T07:00+02:00")
harness.givenRinging(alarmId = 1L, at = fireAt)
harness.engine.dismiss(1L)
assertThat(harness.alarms.find(1L)!!.enabled).isFalse()
assertThat(harness.scheduler.next).isNull()
// Two days on, any reschedule clears the spent date from the row.
harness.wallClock.instant = instantAt(BERLIN, "2026-06-20T09:00")
harness.engine.reschedule()
assertThat(harness.alarms.find(1L)!!.scheduledDate).isNull()
}
@Test
fun `a paused alarm is registered for the first day after the pause`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
val pause = PauseRange(LocalDate.of(2026, 6, 10), LocalDate.of(2026, 6, 12))
harness.alarmDao.insert(
AlarmMapper.toEntity(alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY).copy(pause = pause)),
)
harness.engine.reschedule()
assertThat(harness.scheduler.next).isEqualTo(Registration(offsetInstant("2026-06-13T07:00+02:00"), 1L))
}
@Test
fun `an ended pause clears itself and the alarm keeps ringing as before`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-20T05:00"))
val pause = PauseRange(LocalDate.of(2026, 6, 10), LocalDate.of(2026, 6, 12))
harness.alarmDao.insert(
AlarmMapper.toEntity(alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY).copy(pause = pause)),
)
harness.engine.reschedule()
assertThat(harness.alarms.find(1L)!!.pause).isNull()
assertThat(harness.alarms.find(1L)!!.enabled).isTrue()
assertThat(harness.scheduler.next).isEqualTo(Registration(offsetInstant("2026-06-20T07:00+02:00"), 1L))
}
@Test
fun `a pause still running is left alone by a reschedule`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
val pause = PauseRange(LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 12))
harness.alarmDao.insert(
AlarmMapper.toEntity(alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY).copy(pause = pause)),
)
harness.engine.reschedule()
assertThat(harness.alarms.find(1L)!!.pause).isEqualTo(pause)
}
@Test
fun `the list reads the paused alarm's next fire without writing anything`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-20T05:00"))
val pause = PauseRange(LocalDate.of(2026, 6, 10), LocalDate.of(2026, 6, 12))
harness.alarmDao.insert(
AlarmMapper.toEntity(alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY).copy(pause = pause)),
)
val upcoming = harness.engine.upcoming().first().single()
assertThat(upcoming.nextFire).isEqualTo(offsetInstant("2026-06-20T07:00+02:00"))
// A read never writes: the ended pause waits for the next reschedule.
assertThat(harness.alarms.find(1L)!!.pause).isEqualTo(pause)
}
}
@@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
@@ -11,6 +12,7 @@ import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.fullAlarm
import de.jeanlucmakiola.clockula.testing.fullAlarmEntity
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.time.Instant
/**
@@ -159,4 +161,60 @@ class AlarmMapperTest {
assertThat(flags).containsExactly(true, false).inOrder()
}
// --- v4: the scheduled date and the pause (#21) ---
@Test
fun `a scheduled date round-trips as an epoch day`() {
val alarm = fullAlarm().copy(repeatDays = RepeatDays.NONE, scheduledDate = LocalDate.of(2026, 10, 28))
val entity = AlarmMapper.toEntity(alarm)
assertThat(entity.scheduledDate).isEqualTo(LocalDate.of(2026, 10, 28).toEpochDay())
assertThat(AlarmMapper.toDomain(entity)).isEqualTo(alarm)
}
@Test
fun `a pause round-trips as two epoch days`() {
val pause = PauseRange(LocalDate.of(2026, 10, 26), LocalDate.of(2026, 10, 28))
val alarm = fullAlarm().copy(pause = pause)
val entity = AlarmMapper.toEntity(alarm)
assertThat(listOf(entity.pauseStart, entity.pauseEnd))
.containsExactly(pause.start.toEpochDay(), pause.end.toEpochDay()).inOrder()
assertThat(AlarmMapper.toDomain(entity)).isEqualTo(alarm)
}
@Test
fun `a row written before v4 has no date and no pause`() {
val alarm = AlarmMapper.toDomain(fullAlarmEntity())
assertThat(alarm.scheduledDate).isNull()
assertThat(alarm.pause).isNull()
}
@Test
fun `a date on a repeating alarm and a pause on a one-shot read as absent`() {
val day = LocalDate.of(2026, 10, 28).toEpochDay()
val repeating = fullAlarmEntity().copy(repeatDays = 31, scheduledDate = day)
val oneShot = fullAlarmEntity().copy(repeatDays = 0, pauseStart = day, pauseEnd = day)
assertThat(AlarmMapper.toDomain(repeating).scheduledDate).isNull()
assertThat(AlarmMapper.toDomain(oneShot).pause).isNull()
}
@Test
fun `corrupt dates degrade rather than throw`() {
val huge = fullAlarmEntity().copy(repeatDays = 0, scheduledDate = Long.MAX_VALUE)
val halfPause = fullAlarmEntity().copy(pauseStart = LocalDate.of(2026, 10, 26).toEpochDay(), pauseEnd = null)
val backwards = fullAlarmEntity().copy(
pauseStart = LocalDate.of(2026, 10, 28).toEpochDay(),
pauseEnd = LocalDate.of(2026, 10, 26).toEpochDay(),
)
assertThat(AlarmMapper.toDomain(huge).scheduledDate).isNull()
assertThat(AlarmMapper.toDomain(halfPause).pause).isNull()
assertThat(AlarmMapper.toDomain(backwards).pause)
.isEqualTo(PauseRange(LocalDate.of(2026, 10, 26), LocalDate.of(2026, 10, 28)))
}
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.data.backup
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.ThemeMode
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
@@ -147,10 +149,92 @@ class BackupCodecTest {
@Test
fun `a future version rejects the whole file`() {
val decoded = BackupCodec.decode(minimalDocument(version = 2))
val decoded = BackupCodec.decode(minimalDocument(version = 3))
assertThat(decoded).isInstanceOf(BackupResult.Failure::class.java)
assertThat((decoded as BackupResult.Failure).problem).isEqualTo(BackupProblem.NEWER_VERSION)
}
// --- v2: an alarm's scheduled date and pause (#21) ---
@Test
fun `a new export is version 2`() {
assertThat(BackupCodec.encode(sampleContent())).contains("\"version\": 2")
}
@Test
fun `a version 1 file still imports, with no dates on any alarm`() {
val json = minimalDocument(version = 1).replace(
"\"alarms\": []",
"\"alarms\": [{\"hour\":7,\"minute\":0,\"repeat_days\":31}]",
)
val decoded = BackupCodec.decode(json) as BackupResult.Success
val alarm = decoded.content.alarms.single()
assertThat(alarm.scheduledDate).isNull()
assertThat(alarm.pause).isNull()
}
@Test
fun `a scheduled date and a pause round-trip`() {
val base = sampleContent()
val repeating = base.alarms.single().copy(
pause = PauseRange(LocalDate.of(2026, 10, 26), LocalDate.of(2026, 10, 28)),
)
val oneShot = base.alarms.single().copy(
repeatDays = RepeatDays.NONE,
skipNextOccurrence = false,
scheduledDate = LocalDate.of(2026, 10, 29),
)
val original = base.copy(alarms = listOf(repeating, oneShot))
val encoded = BackupCodec.encode(original)
assertThat(encoded).contains("\"scheduled_date\": \"2026-10-29\"")
assertThat(encoded).contains("\"pause_start\": \"2026-10-26\"")
assertThat(BackupCodec.decode(encoded)).isEqualTo(BackupResult.Success(original))
}
@Test
fun `a malformed scheduled date rejects the whole file`() {
assertThat(BackupCodec.decode(alarmDocument("\"scheduled_date\":\"next thursday\"")))
.isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `a date outside the supported range rejects the whole file`() {
assertThat(BackupCodec.decode(alarmDocument("\"scheduled_date\":\"1066-10-14\"")))
.isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `a pause with only one end rejects the whole file`() {
assertThat(BackupCodec.decode(alarmDocument("\"repeat_days\":31,\"pause_start\":\"2026-10-26\"")))
.isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `a pause ending before it starts rejects the whole file`() {
val fields = "\"repeat_days\":31,\"pause_start\":\"2026-10-28\",\"pause_end\":\"2026-10-26\""
assertThat(BackupCodec.decode(alarmDocument(fields))).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `a date on a repeating alarm and a pause on a one-shot are dropped, as the editor would`() {
val datedRepeating = BackupCodec.decode(
alarmDocument("\"repeat_days\":31,\"scheduled_date\":\"2026-10-29\""),
) as BackupResult.Success
val pausedOneShot = BackupCodec.decode(
alarmDocument("\"pause_start\":\"2026-10-26\",\"pause_end\":\"2026-10-28\""),
) as BackupResult.Success
assertThat(datedRepeating.content.alarms.single().scheduledDate).isNull()
assertThat(pausedOneShot.content.alarms.single().pause).isNull()
}
private fun alarmDocument(fields: String): String = minimalDocument(version = 2).replace(
"\"alarms\": []",
"\"alarms\": [{\"hour\":7,\"minute\":0,$fields}]",
)
@Test
fun `an out-of-range hour rejects the whole file`() {
val json = """
@@ -51,11 +51,23 @@ class SchemaExportTest {
assertThat(text).contains("\"tableName\": \"timers\"")
}
@Test
fun `the version 4 schema is exported and committed`() {
val schema = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/4.json")
assertThat(schema.isFile).isTrue()
val text = schema.readText()
assertThat(text).contains("\"version\": 4")
assertThat(text).contains("scheduled_date")
assertThat(text).contains("pause_start")
assertThat(text).contains("pause_end")
}
@Test
fun `the previous version is never regenerated away`() {
val schemas = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase")
assertThat(schemas.list()?.toList()).containsAtLeast("1.json", "2.json", "3.json")
assertThat(schemas.list()?.toList()).containsAtLeast("1.json", "2.json", "3.json", "4.json")
}
@Test
@@ -64,11 +76,12 @@ class SchemaExportTest {
.list()!!.map { it.removeSuffix(".json").toInt() }
val migrations = Migrations.ALL.toList()
assertThat(exported.max()).isEqualTo(3)
assertThat(migrations).hasSize(2)
assertThat(exported.max()).isEqualTo(4)
assertThat(migrations).hasSize(3)
assertThat(migrations.map { it.startVersion to it.endVersion })
.containsExactly(1 to 2, 2 to 3).inOrder()
assertThat(migrations).containsExactly(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3)
.containsExactly(1 to 2, 2 to 3, 3 to 4).inOrder()
assertThat(migrations)
.containsExactly(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4)
.inOrder()
}
}
@@ -0,0 +1,299 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.NEW_YORK
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
/**
* A scheduled date (a floor) and a pause (a hole) on top of the plain
* occurrence sequence (#21). Expectations are absolute instants with the
* offset they should carry, as in [AlarmOccurrencesTest].
*/
class AlarmOccurrencesScheduleTest {
private fun take(
count: Int,
hour: Int,
minute: Int = 0,
repeatDays: RepeatDays = RepeatDays.NONE,
after: Instant,
zone: ZoneId = BERLIN,
notBefore: LocalDate? = null,
pause: PauseRange? = null,
): List<Instant> = AlarmOccurrences
.sequence(TimeOfDay(hour, minute), repeatDays, after, zone, notBefore, pause)
.take(count)
.toList()
private fun next(
hour: Int,
minute: Int = 0,
repeatDays: RepeatDays = RepeatDays.NONE,
after: Instant,
zone: ZoneId = BERLIN,
notBefore: LocalDate? = null,
pause: PauseRange? = null,
): Instant? = take(1, hour, minute, repeatDays, after, zone, notBefore, pause).firstOrNull()
private fun date(iso: String): LocalDate = LocalDate.parse(iso)
private fun pause(from: String, to: String) = PauseRange(date(from), date(to))
// --- the scheduled date ---
@Test
fun `a scheduled date in the future is the first occurrence`() {
val result = next(hour = 7, after = instantAt(BERLIN, "2026-06-10T06:00"), notBefore = date("2026-06-18"))
assertThat(result).isEqualTo(offsetInstant("2026-06-18T07:00+02:00"))
}
@Test
fun `a date scheduled for today rings today while the time is still ahead`() {
val result = next(hour = 7, after = instantAt(BERLIN, "2026-06-10T06:00"), notBefore = date("2026-06-10"))
assertThat(result).isEqualTo(offsetInstant("2026-06-10T07:00+02:00"))
}
@Test
fun `a date that went by unrung behaves as a plain one-shot does`() {
val after = instantAt(BERLIN, "2026-06-12T09:00")
val scheduled = next(hour = 7, after = after, notBefore = date("2026-06-11"))
val plain = next(hour = 7, after = after)
assertThat(scheduled).isEqualTo(offsetInstant("2026-06-13T07:00+02:00"))
assertThat(scheduled).isEqualTo(plain)
}
@Test
fun `the occurrence after the scheduled one is the next day's, so the sequence never runs dry`() {
val result = take(3, hour = 7, after = instantAt(BERLIN, "2026-06-10T06:00"), notBefore = date("2026-06-18"))
assertThat(result).containsExactly(
offsetInstant("2026-06-18T07:00+02:00"),
offsetInstant("2026-06-19T07:00+02:00"),
offsetInstant("2026-06-20T07:00+02:00"),
).inOrder()
}
@Test
fun `a date scheduled on the spring-forward night rings at the end of the gap`() {
val result = next(
hour = 2,
minute = 30,
after = instantAt(BERLIN, "2026-03-20T12:00"),
notBefore = date("2026-03-29"),
)
assertThat(result).isEqualTo(offsetInstant("2026-03-29T03:30+02:00"))
}
@Test
fun `a date scheduled on the fall-back night rings once, at the earlier offset`() {
val result = take(
2,
hour = 2,
minute = 30,
after = instantAt(BERLIN, "2026-10-20T12:00"),
notBefore = date("2026-10-25"),
)
assertThat(result).containsExactly(
offsetInstant("2026-10-25T02:30+02:00"),
offsetInstant("2026-10-26T02:30+01:00"),
).inOrder()
}
@Test
fun `a scheduled date is a local date of the zone the phone is in when it arrives`() {
val after = instantAt(BERLIN, "2026-06-10T06:00")
val inBerlin = next(hour = 7, after = after, zone = BERLIN, notBefore = date("2026-06-18"))
val inNewYork = next(hour = 7, after = after, zone = NEW_YORK, notBefore = date("2026-06-18"))
assertThat(inBerlin).isEqualTo(offsetInstant("2026-06-18T07:00+02:00"))
assertThat(inNewYork).isEqualTo(offsetInstant("2026-06-18T07:00-04:00"))
}
@Test
fun `a late-evening date crossing zones eastward still rings on its own local date`() {
// 23:30 on the 18th in New York is already the 19th in Berlin; flying
// to Berlin on the 15th moves the alarm to Berlin's 18th, 23:30.
val after = instantAt(NEW_YORK, "2026-06-15T12:00")
val result = next(hour = 23, minute = 30, after = after, zone = BERLIN, notBefore = date("2026-06-18"))
assertThat(result).isEqualTo(offsetInstant("2026-06-18T23:30+02:00"))
}
// --- the pause ---
@Test
fun `a pause skips every day inside it and resumes on the day after`() {
val result = take(
3,
hour = 7,
repeatDays = RepeatDays.EVERY_DAY,
after = instantAt(BERLIN, "2026-06-10T06:00"),
pause = pause("2026-06-10", "2026-06-12"),
)
assertThat(result).containsExactly(
offsetInstant("2026-06-13T07:00+02:00"),
offsetInstant("2026-06-14T07:00+02:00"),
offsetInstant("2026-06-15T07:00+02:00"),
).inOrder()
}
@Test
fun `a pause ending on a Friday resumes a weekday alarm on Monday`() {
// 2026-06-12 is a Friday.
val result = next(
hour = 7,
repeatDays = RepeatDays.WEEKDAYS,
after = instantAt(BERLIN, "2026-06-08T06:00"),
pause = pause("2026-06-08", "2026-06-12"),
)
assertThat(result).isEqualTo(offsetInstant("2026-06-15T07:00+02:00"))
}
@Test
fun `a pause still in the future leaves the occurrences before it alone`() {
val result = take(
4,
hour = 7,
repeatDays = RepeatDays.EVERY_DAY,
after = instantAt(BERLIN, "2026-06-10T06:00"),
pause = pause("2026-06-11", "2026-06-12"),
)
assertThat(result).containsExactly(
offsetInstant("2026-06-10T07:00+02:00"),
offsetInstant("2026-06-13T07:00+02:00"),
offsetInstant("2026-06-14T07:00+02:00"),
offsetInstant("2026-06-15T07:00+02:00"),
).inOrder()
}
@Test
fun `a one-day pause skips exactly that day`() {
val result = take(
2,
hour = 7,
repeatDays = RepeatDays.EVERY_DAY,
after = instantAt(BERLIN, "2026-06-10T06:00"),
pause = pause("2026-06-10", "2026-06-10"),
)
assertThat(result).containsExactly(
offsetInstant("2026-06-11T07:00+02:00"),
offsetInstant("2026-06-12T07:00+02:00"),
).inOrder()
}
@Test
fun `a pause that is over changes nothing`() {
val after = instantAt(BERLIN, "2026-06-10T06:00")
val paused = take(3, hour = 7, repeatDays = RepeatDays.EVERY_DAY, after = after, pause = pause("2026-06-01", "2026-06-05"))
val plain = take(3, hour = 7, repeatDays = RepeatDays.EVERY_DAY, after = after)
assertThat(paused).isEqualTo(plain)
}
@Test
fun `a pause over the spring-forward night skips the shifted occurrence too`() {
val result = next(
hour = 2,
minute = 30,
repeatDays = RepeatDays.EVERY_DAY,
after = instantAt(BERLIN, "2026-03-27T12:00"),
pause = pause("2026-03-28", "2026-03-29"),
)
assertThat(result).isEqualTo(offsetInstant("2026-03-30T02:30+02:00"))
}
@Test
fun `a pause ending the day before the spring-forward night resumes in the gap, shifted`() {
val result = next(
hour = 2,
minute = 30,
repeatDays = RepeatDays.EVERY_DAY,
after = instantAt(BERLIN, "2026-03-27T12:00"),
pause = pause("2026-03-27", "2026-03-28"),
)
assertThat(result).isEqualTo(offsetInstant("2026-03-29T03:30+02:00"))
}
@Test
fun `a pause over the fall-back night yields neither of that night's two 02_30s`() {
val result = take(
2,
hour = 2,
minute = 30,
repeatDays = RepeatDays.EVERY_DAY,
after = instantAt(BERLIN, "2026-10-24T12:00"),
pause = pause("2026-10-25", "2026-10-25"),
)
assertThat(result).containsExactly(
offsetInstant("2026-10-26T02:30+01:00"),
offsetInstant("2026-10-27T02:30+01:00"),
).inOrder()
}
@Test
fun `a pause is in local dates, so a zone change moves its edges with the phone`() {
val after = instantAt(BERLIN, "2026-06-10T06:00")
val holiday = pause("2026-06-10", "2026-06-12")
val inNewYork = next(hour = 7, repeatDays = RepeatDays.EVERY_DAY, after = after, zone = NEW_YORK, pause = holiday)
assertThat(inNewYork).isEqualTo(offsetInstant("2026-06-13T07:00-04:00"))
}
@Test
fun `a pause centuries long is jumped, not walked`() {
val result = next(
hour = 7,
repeatDays = RepeatDays.EVERY_DAY,
after = instantAt(BERLIN, "2026-06-10T06:00"),
pause = pause("2026-06-10", "9999-12-30"),
)
assertThat(result).isEqualTo(offsetInstant("9999-12-31T07:00+01:00"))
}
@Test
fun `a pause and a scheduled date together leave the first day that is neither`() {
val result = next(
hour = 7,
after = instantAt(BERLIN, "2026-06-10T06:00"),
notBefore = date("2026-06-12"),
pause = pause("2026-06-11", "2026-06-13"),
)
assertThat(result).isEqualTo(offsetInstant("2026-06-14T07:00+02:00"))
}
@Test
fun `firstDateFor is the day before the one the instant falls on`() {
assertThat(AlarmOccurrences.firstDateFor(instantAt(BERLIN, "2026-06-10T00:30"), BERLIN))
.isEqualTo(date("2026-06-09"))
assertThat(AlarmOccurrences.firstDateFor(instantAt(BERLIN, "2026-06-10T00:30"), NEW_YORK))
.isEqualTo(date("2026-06-08"))
}
}
@@ -0,0 +1,224 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.alarmAt
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
import de.jeanlucmakiola.clockula.testing.ringState
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* The resolver with a scheduled date or a pause on the alarm (#21): when it
* rings, how it interacts with snooze, skip and the watermarks, and exactly
* when each clears itself.
*/
class AlarmResolverScheduleTest {
private fun resolve(
alarm: Alarm,
now: Instant,
state: AlarmRingState = ringState(alarmId = alarm.id),
): AlarmResolution = AlarmResolver.resolve(alarm, state, now, BERLIN)
private fun date(iso: String): LocalDate = LocalDate.parse(iso)
private fun scheduled(iso: String, hour: Int = 7, enabled: Boolean = true, skip: Boolean = false) =
alarmAt(hour = hour, enabled = enabled, skipNextOccurrence = skip).copy(scheduledDate = date(iso))
private fun paused(
from: String,
to: String,
hour: Int = 7,
minute: Int = 0,
repeatDays: RepeatDays = RepeatDays.EVERY_DAY,
enabled: Boolean = true,
skip: Boolean = false,
) = alarmAt(hour = hour, minute = minute, repeatDays = repeatDays, enabled = enabled, skipNextOccurrence = skip)
.copy(pause = PauseRange(date(from), date(to)))
// --- a scheduled one-shot ---
@Test
fun `a scheduled one-shot resolves to its date, not to tomorrow`() {
val resolution = resolve(scheduled("2026-06-18"), now = instantAt(BERLIN, "2026-06-10T06:00"))
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-18T07:00+02:00"))
assertThat(resolution.source).isEqualTo(FireSource.OCCURRENCE)
assertThat(resolution.clearScheduledDate).isFalse()
}
@Test
fun `a scheduled occurrence delivered late still counts, through the grace window`() {
val resolution = resolve(scheduled("2026-06-18"), now = instantAt(BERLIN, "2026-06-18T07:01"))
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-18T07:00+02:00"))
}
@Test
fun `a scheduled date missed while the phone was off rings like a missed one-shot, the next day`() {
val resolution = resolve(scheduled("2026-06-18"), now = instantAt(BERLIN, "2026-06-18T09:00"))
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-19T07:00+02:00"))
assertThat(resolution.disableAlarm).isFalse()
// Still the day itself: the date is kept until it can no longer matter.
assertThat(resolution.clearScheduledDate).isFalse()
}
@Test
fun `a scheduled date clears itself once it lies before every candidate`() {
val dayAfter = resolve(scheduled("2026-06-18"), now = instantAt(BERLIN, "2026-06-19T09:00"))
val twoDaysAfter = resolve(scheduled("2026-06-18"), now = instantAt(BERLIN, "2026-06-20T09:00"))
assertThat(dayAfter.clearScheduledDate).isTrue()
assertThat(twoDaysAfter.clearScheduledDate).isTrue()
assertThat(twoDaysAfter.nextFire).isEqualTo(offsetInstant("2026-06-21T07:00+02:00"))
}
@Test
fun `clearing a date never changes the fire time it resolved to`() {
val now = instantAt(BERLIN, "2026-06-19T00:05")
val alarm = scheduled("2026-06-18", hour = 23)
val withDate = resolve(alarm, now)
val without = resolve(alarm.copy(scheduledDate = null), now)
assertThat(withDate.clearScheduledDate).isTrue()
assertThat(withDate.nextFire).isEqualTo(without.nextFire)
}
@Test
fun `a scheduled date on a disabled alarm still clears itself, and nothing fires`() {
val resolution = resolve(scheduled("2026-06-18", enabled = false), now = instantAt(BERLIN, "2026-06-25T09:00"))
assertThat(resolution.nextFire).isNull()
assertThat(resolution.clearScheduledDate).isTrue()
}
@Test
fun `a future date on a disabled alarm is kept for when it is switched back on`() {
val resolution = resolve(scheduled("2026-06-18", enabled = false), now = instantAt(BERLIN, "2026-06-10T09:00"))
assertThat(resolution.clearScheduledDate).isFalse()
}
@Test
fun `the scheduled occurrence just handled is not re-rung, and the slot moves to the day after`() {
val fired = offsetInstant("2026-06-18T07:00+02:00")
val now = instantAt(BERLIN, "2026-06-18T07:00")
val resolution = resolve(scheduled("2026-06-18"), now, ringState(handledOccurrence = fired))
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-19T07:00+02:00"))
}
@Test
fun `a snoozed scheduled alarm keeps its snooze`() {
val now = instantAt(BERLIN, "2026-06-18T07:05")
val snooze = now + 5.minutes
val state = ringState(
snoozedUntil = snooze,
snoozeCount = 1,
handledOccurrence = offsetInstant("2026-06-18T07:00+02:00"),
)
val resolution = resolve(scheduled("2026-06-18"), now, state)
assertThat(resolution.nextFire).isEqualTo(snooze)
assertThat(resolution.source).isEqualTo(FireSource.SNOOZE)
}
@Test
fun `skipping a scheduled one-shot dismisses it in advance, as for any one-shot`() {
val resolution = resolve(scheduled("2026-06-18", skip = true), now = instantAt(BERLIN, "2026-06-10T06:00"))
assertThat(resolution.nextFire).isNull()
assertThat(resolution.disableAlarm).isTrue()
}
// --- a paused repeating alarm ---
@Test
fun `a paused alarm rings on the first day after the pause`() {
val resolution = resolve(paused("2026-06-10", "2026-06-14"), now = instantAt(BERLIN, "2026-06-10T06:00"))
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-15T07:00+02:00"))
assertThat(resolution.clearPause).isFalse()
}
@Test
fun `a pause still ahead leaves the occurrences before it ringing`() {
val resolution = resolve(paused("2026-06-12", "2026-06-14"), now = instantAt(BERLIN, "2026-06-10T06:00"))
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-10T07:00+02:00"))
}
@Test
fun `skipping the next alarm of a paused one skips the first ring after the pause`() {
val resolution = resolve(
paused("2026-06-10", "2026-06-14", skip = true),
now = instantAt(BERLIN, "2026-06-10T06:00"),
)
assertThat(resolution.state.skippedOccurrence).isEqualTo(offsetInstant("2026-06-15T07:00+02:00"))
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-16T07:00+02:00"))
}
@Test
fun `a pause ending at midnight cannot let its last day ring late through the grace window`() {
// 23:59 on the pause's last day is inside the 2-minute grace at 00:00:30.
val now = offsetInstant("2026-06-15T00:00:30+02:00")
val resolution = resolve(paused("2026-06-10", "2026-06-14", hour = 23, minute = 59), now)
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-15T23:59+02:00"))
assertThat(resolution.clearPause).isFalse()
}
@Test
fun `a pause clears itself only once it lies before every candidate`() {
val alarm = paused("2026-06-10", "2026-06-14")
val dayAfter = resolve(alarm, now = instantAt(BERLIN, "2026-06-15T09:00"))
val twoDaysAfter = resolve(alarm, now = instantAt(BERLIN, "2026-06-16T09:00"))
assertThat(dayAfter.clearPause).isFalse()
assertThat(twoDaysAfter.clearPause).isTrue()
assertThat(twoDaysAfter.nextFire).isEqualTo(offsetInstant("2026-06-17T07:00+02:00"))
}
@Test
fun `an ended pause on a disabled alarm clears itself too`() {
val resolution = resolve(paused("2026-06-01", "2026-06-03", enabled = false), now = instantAt(BERLIN, "2026-06-10T09:00"))
assertThat(resolution.nextFire).isNull()
assertThat(resolution.clearPause).isTrue()
}
@Test
fun `a ringing paused alarm keeps ringing — the pause only shapes future occurrences`() {
val now = instantAt(BERLIN, "2026-06-10T07:02")
val state = ringState(ringingSince = now - 2.minutes, handledOccurrence = now - 2.minutes)
val resolution = resolve(paused("2026-06-10", "2026-06-14"), now, state)
assertThat(resolution.source).isEqualTo(FireSource.RESUMED_RING)
}
@Test
fun `a paused weekday alarm whose pause covers the weekend resumes on Monday`() {
// 2026-06-12 is a Friday; the pause covers Friday to Monday.
val resolution = resolve(
paused("2026-06-12", "2026-06-15", repeatDays = RepeatDays.WEEKDAYS),
now = instantAt(BERLIN, "2026-06-12T06:00"),
)
assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-16T07:00+02:00"))
}
}
@@ -0,0 +1,80 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.NEW_YORK
import de.jeanlucmakiola.clockula.testing.alarmAt
import de.jeanlucmakiola.clockula.testing.instantAt
import org.junit.jupiter.api.Test
import java.time.LocalDate
/** What the surfaces say about a date or a pause, which must never outlive what it describes. */
class AlarmScheduleTest {
private val june18 = LocalDate.of(2026, 6, 18)
@Test
fun `a scheduled date is shown while its occurrence is ahead`() {
val alarm = alarmAt(hour = 7).copy(scheduledDate = june18)
assertThat(AlarmSchedule.upcomingDate(alarm, instantAt(BERLIN, "2026-06-18T06:59"), BERLIN))
.isEqualTo(june18)
}
@Test
fun `a scheduled date is no longer shown once its occurrence has come`() {
val alarm = alarmAt(hour = 7).copy(scheduledDate = june18)
assertThat(AlarmSchedule.upcomingDate(alarm, instantAt(BERLIN, "2026-06-18T07:00"), BERLIN)).isNull()
assertThat(AlarmSchedule.upcomingDate(alarm, instantAt(BERLIN, "2026-06-20T07:00"), BERLIN)).isNull()
}
@Test
fun `a repeating alarm has no scheduled date to show`() {
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.WEEKDAYS).copy(scheduledDate = june18)
assertThat(AlarmSchedule.upcomingDate(alarm, instantAt(BERLIN, "2026-06-10T06:00"), BERLIN)).isNull()
}
@Test
fun `a pause is shown up to and including its last day`() {
val pause = PauseRange(LocalDate.of(2026, 6, 10), june18)
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY).copy(pause = pause)
assertThat(AlarmSchedule.currentPause(alarm, instantAt(BERLIN, "2026-06-01T12:00"), BERLIN)).isEqualTo(pause)
assertThat(AlarmSchedule.currentPause(alarm, instantAt(BERLIN, "2026-06-18T23:59"), BERLIN)).isEqualTo(pause)
assertThat(AlarmSchedule.currentPause(alarm, instantAt(BERLIN, "2026-06-19T00:00"), BERLIN)).isNull()
}
@Test
fun `a one-shot alarm has no pause to show`() {
val alarm = alarmAt(hour = 7).copy(pause = PauseRange(june18, june18))
assertThat(AlarmSchedule.currentPause(alarm, instantAt(BERLIN, "2026-06-10T06:00"), BERLIN)).isNull()
}
@Test
fun `the earliest schedulable date is today while the time is still ahead`() {
val earliest = AlarmSchedule.earliestDate(TimeOfDay(7, 0), instantAt(BERLIN, "2026-06-10T06:00"), BERLIN)
assertThat(earliest).isEqualTo(LocalDate.of(2026, 6, 10))
}
@Test
fun `the earliest schedulable date is tomorrow once today's time has gone by`() {
val earliest = AlarmSchedule.earliestDate(TimeOfDay(7, 0), instantAt(BERLIN, "2026-06-10T07:00"), BERLIN)
assertThat(earliest).isEqualTo(LocalDate.of(2026, 6, 11))
}
@Test
fun `today is the local date in the given zone`() {
val lateInBerlin = instantAt(BERLIN, "2026-06-10T01:00")
assertThat(AlarmSchedule.today(lateInBerlin, BERLIN)).isEqualTo(LocalDate.of(2026, 6, 10))
assertThat(AlarmSchedule.today(lateInBerlin, NEW_YORK)).isEqualTo(LocalDate.of(2026, 6, 9))
}
}
@@ -78,6 +78,25 @@ class NextAlarmWidgetModelTest {
assertThat(model.isToday).isFalse()
}
@Test
fun `a fire time within the coming week names only its weekday`() {
val rows = listOf(upcomingAlarm(nextFire = T0 + 6.days))
val model = NextAlarmWidgetPresenter.present(rows, T0, zone)
assertThat(model.date).isNull()
}
@Test
fun `a fire time a week or more away carries its date, since a weekday would be ambiguous`() {
val fireAt = T0 + 7.days
val rows = listOf(upcomingAlarm(nextFire = fireAt))
val model = NextAlarmWidgetPresenter.present(rows, T0, zone)
assertThat(model.date).isEqualTo(fireAt.toJavaTime(zone).toLocalDate())
}
private fun Instant.toJavaTime(zone: ZoneId): ZonedDateTime =
ZonedDateTime.ofInstant(toJavaInstant(), zone)
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.interop
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
@@ -29,6 +30,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.DayOfWeek
import java.time.LocalDate
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@@ -147,6 +149,37 @@ class AlarmClockHandlerTest {
assertThat(harness.storedAlarms().single().skipNextOccurrence).isFalse()
}
@Test
fun `an alarm with a scheduled date is not re-used, so neither the date nor the caller's alarm is lost`(
@TempDir tempDir: Path,
) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenAlarm(harness.alarmFor(id = 2L).copy(scheduledDate = LocalDate.of(2026, 6, 20)))
harness.handler.handle(setAlarm())
val stored = harness.storedAlarms()
assertThat(stored).hasSize(2)
assertThat(stored.single { it.id == 2L }.scheduledDate).isEqualTo(LocalDate.of(2026, 6, 20))
assertThat(harness.alarmScheduler.next?.fireAt).isEqualTo(sevenThirtyToday)
}
@Test
fun `a paused alarm is not re-used, so the pause survives and the caller's alarm still rings`(
@TempDir tempDir: Path,
) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val pause = PauseRange(LocalDate.of(2026, 6, 10), LocalDate.of(2026, 6, 20))
harness.givenAlarm(harness.alarmFor(id = 2L, repeatDays = RepeatDays.EVERY_DAY).copy(pause = pause))
harness.handler.handle(setAlarm(spec(repeatDays = RepeatDays.EVERY_DAY)))
val stored = harness.storedAlarms()
assertThat(stored).hasSize(2)
assertThat(stored.single { it.id == 2L }.pause).isEqualTo(pause)
assertThat(harness.alarmScheduler.next?.fireAt).isEqualTo(sevenThirtyToday)
}
@Test
fun `a re-used alarm never becomes transient`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
@@ -0,0 +1,228 @@
package de.jeanlucmakiola.clockula.ui.alarms
import androidx.lifecycle.SavedStateHandle
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.Registration
import de.jeanlucmakiola.clockula.testing.alarmAt
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
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
import java.time.DayOfWeek
import java.time.LocalDate
/** The editor's date and pause controls (#21), over the real engine. */
class AlarmEditorScheduleTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private val fiveToday = instantAt(BERLIN, "2026-06-10T05:00")
private val today = LocalDate.of(2026, 6, 10)
private val june18 = LocalDate.of(2026, 6, 18)
private fun TestScope.editor(harness: AlarmEngineHarness) = AlarmEditorViewModel(
savedStateHandle = SavedStateHandle(mapOf(AlarmRoutes.ARG_ALARM_ID to 1L)),
alarms = harness.alarms,
settings = harness.settings,
engine = harness.engine,
ringtones = FakeRingtoneCatalog(),
preview = FakeRingtonePreviewer(),
wallClock = harness.wallClock,
zones = harness.zones,
appScope = this,
)
private fun AlarmEditorViewModel.editing(): AlarmEditorUiState.Editing =
state.value as AlarmEditorUiState.Editing
private suspend fun AlarmEngineHarness.given(alarm: Alarm) {
alarmDao.insert(AlarmMapper.toEntity(alarm))
}
private suspend fun AlarmEngineHarness.stored(): Alarm = alarms.find(1L)!!
@Test
fun `scheduling a date stores it and registers the alarm for that day`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onScheduledDateChosen(june18)
advanceUntilIdle()
assertThat(harness.stored().scheduledDate).isEqualTo(june18)
assertThat(viewModel.editing().scheduledDate).isEqualTo(june18)
assertThat(harness.scheduler.next)
.isEqualTo(Registration(offsetInstant("2026-06-18T07:00+02:00"), 1L))
}
@Test
fun `removing the date makes it a plain one-shot again`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7).copy(scheduledDate = june18))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onScheduledDateChosen(null)
advanceUntilIdle()
assertThat(harness.stored().scheduledDate).isNull()
assertThat(harness.scheduler.next).isEqualTo(Registration(offsetInstant("2026-06-10T07:00+02:00"), 1L))
}
@Test
fun `a date whose time has already gone by is refused`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T08:00"))
harness.given(alarmAt(hour = 7))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onScheduledDateChosen(today)
viewModel.onScheduledDateChosen(LocalDate.of(2026, 6, 1))
advanceUntilIdle()
assertThat(harness.stored().scheduledDate).isNull()
assertThat(viewModel.editing().earliestScheduleDate).isEqualTo(LocalDate.of(2026, 6, 11))
}
@Test
fun `the earliest schedulable date is today while the time is still ahead`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7))
val viewModel = editor(harness)
advanceUntilIdle()
assertThat(viewModel.editing().earliestScheduleDate).isEqualTo(today)
assertThat(viewModel.editing().today).isEqualTo(today)
}
@Test
fun `picking a repeat day clears the date`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7).copy(scheduledDate = june18))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onDayToggled(DayOfWeek.MONDAY)
advanceUntilIdle()
assertThat(harness.stored().scheduledDate).isNull()
assertThat(harness.stored().repeatDays).isEqualTo(RepeatDays.of(DayOfWeek.MONDAY))
}
@Test
fun `clearing the last repeat day ends the pause`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
val pause = PauseRange(today, june18)
harness.given(alarmAt(hour = 7, repeatDays = RepeatDays.of(DayOfWeek.MONDAY)).copy(pause = pause))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onDayToggled(DayOfWeek.MONDAY)
advanceUntilIdle()
assertThat(harness.stored().pause).isNull()
}
@Test
fun `moving the time earlier than now drops today's date`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7).copy(scheduledDate = today))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onTimePicked(4, 30)
advanceUntilIdle()
assertThat(harness.stored().scheduledDate).isNull()
}
@Test
fun `moving the time keeps a date still ahead`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7).copy(scheduledDate = june18))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onTimePicked(4, 30)
advanceUntilIdle()
assertThat(harness.stored().scheduledDate).isEqualTo(june18)
}
@Test
fun `pausing a repeating alarm stores the pause and moves its next ring past it`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onPauseChosen(PauseRange(today, LocalDate.of(2026, 6, 12)))
advanceUntilIdle()
assertThat(viewModel.editing().pause).isEqualTo(PauseRange(today, LocalDate.of(2026, 6, 12)))
assertThat(harness.scheduler.next)
.isEqualTo(Registration(offsetInstant("2026-06-13T07:00+02:00"), 1L))
}
@Test
fun `ending a pause rings the alarm again at once`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY).copy(pause = PauseRange(today, june18)))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onPauseChosen(null)
advanceUntilIdle()
assertThat(harness.stored().pause).isNull()
assertThat(harness.scheduler.next).isEqualTo(Registration(offsetInstant("2026-06-10T07:00+02:00"), 1L))
}
@Test
fun `a one-shot alarm cannot be paused`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onPauseChosen(PauseRange(today, june18))
advanceUntilIdle()
assertThat(harness.stored().pause).isNull()
}
@Test
fun `a repeating alarm cannot be scheduled on a date`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7, repeatDays = RepeatDays.WEEKDAYS))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onScheduledDateChosen(june18)
advanceUntilIdle()
assertThat(harness.stored().scheduledDate).isNull()
}
}
@@ -59,6 +59,8 @@ class AlarmEditorViewModelTest {
engine = harness.engine,
ringtones = catalog,
preview = previewer,
wallClock = harness.wallClock,
zones = harness.zones,
appScope = this,
)
@@ -1,12 +1,16 @@
package de.jeanlucmakiola.clockula.ui.alarms
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.localAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
import de.jeanlucmakiola.clockula.testing.upcomingAlarm
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
@@ -28,7 +32,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 1L, hour = 22, nextFire = tenTonight),
)
val rows = AlarmListRows.from(upcoming, now)
val rows = AlarmListRows.from(upcoming, now, BERLIN)
assertThat(rows.map { it.id }).containsExactly(2L, 1L).inOrder()
}
@@ -41,7 +45,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 3L, hour = 22, nextFire = tenTonight),
)
val rows = AlarmListRows.from(upcoming, now)
val rows = AlarmListRows.from(upcoming, now, BERLIN)
assertThat(rows.map { it.id }).containsExactly(1L, 2L, 3L).inOrder()
}
@@ -54,7 +58,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 3L, hour = 8, enabled = false, nextFire = null, source = FireSource.NONE),
)
val rows = AlarmListRows.from(upcoming, now)
val rows = AlarmListRows.from(upcoming, now, BERLIN)
assertThat(rows.filter { it.isNext }.map { it.id }).containsExactly(2L)
}
@@ -66,7 +70,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 4L, hour = 8, nextFire = now + 30.minutes),
)
val rows = AlarmListRows.from(upcoming, now)
val rows = AlarmListRows.from(upcoming, now, BERLIN)
assertThat(rows.filter { it.isNext }.map { it.id }).containsExactly(4L)
}
@@ -78,7 +82,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 2L, hour = 8, enabled = false, nextFire = null, source = FireSource.NONE),
)
val rows = AlarmListRows.from(upcoming, now)
val rows = AlarmListRows.from(upcoming, now, BERLIN)
assertThat(rows.none { it.isNext }).isTrue()
}
@@ -89,7 +93,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 1L, hour = 7, nextFire = null, source = FireSource.RESUMED_RING),
)
val row = AlarmListRows.from(upcoming, now).single()
val row = AlarmListRows.from(upcoming, now, BERLIN).single()
assertThat(listOf(row.ringing, row.isNext, row.nextFire))
.containsExactly(true, false, NextFireLabel.NotScheduled)
@@ -102,7 +106,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 1L, hour = 7, nextFire = now + 7.minutes, source = FireSource.SNOOZE),
)
val row = AlarmListRows.from(upcoming, now).single()
val row = AlarmListRows.from(upcoming, now, BERLIN).single()
assertThat(row.snoozed to row.nextFire).isEqualTo(true to NextFireLabel.InMinutes(7))
}
@@ -118,7 +122,7 @@ class AlarmListRowsTest {
)
val oneShot = upcomingAlarm(id = 2L, hour = 8, skipNextOccurrence = true, nextFire = sevenTomorrow)
val rows = AlarmListRows.from(listOf(repeating, oneShot), now)
val rows = AlarmListRows.from(listOf(repeating, oneShot), now, BERLIN)
assertThat(rows.map { it.skippingNext }).containsExactly(true, false).inOrder()
}
@@ -129,7 +133,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 1L, hour = 7, enabled = false, nextFire = null, source = FireSource.NONE),
)
val row = AlarmListRows.from(upcoming, now).single()
val row = AlarmListRows.from(upcoming, now, BERLIN).single()
assertThat(row.nextFire).isEqualTo(NextFireLabel.NotScheduled)
}
@@ -138,14 +142,14 @@ class AlarmListRowsTest {
fun `a blank label is carried through blank`() {
val upcoming = listOf(upcomingAlarm(id = 1L, hour = 7, label = "", nextFire = sevenTomorrow))
val row = AlarmListRows.from(upcoming, now).single()
val row = AlarmListRows.from(upcoming, now, BERLIN).single()
assertThat(row.label).isEmpty()
}
@Test
fun `no alarms is an empty list, not a throw`() {
val rows = AlarmListRows.from(emptyList(), now)
val rows = AlarmListRows.from(emptyList(), now, BERLIN)
assertThat(rows).isEmpty()
}
@@ -156,7 +160,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.WEEKDAYS, nextFire = sevenTomorrow),
)
val row = AlarmListRows.from(upcoming, now).single()
val row = AlarmListRows.from(upcoming, now, BERLIN).single()
assertThat(row.repeatDays).isEqualTo(RepeatDays.WEEKDAYS)
}
@@ -168,7 +172,7 @@ class AlarmListRowsTest {
upcomingAlarm(id = 4L, hour = 7, nextFire = sevenTomorrow),
)
val rows = AlarmListRows.from(upcoming, now)
val rows = AlarmListRows.from(upcoming, now, BERLIN)
assertThat(rows.map { it.id }).containsExactly(4L, 9L).inOrder()
}
@@ -180,10 +184,60 @@ class AlarmListRowsTest {
upcomingAlarm(id = 2L, hour = 8, nextFire = now + 90.minutes),
)
val rows = AlarmListRows.from(upcoming, now)
val rows = AlarmListRows.from(upcoming, now, BERLIN)
assertThat(rows.map { it.nextFire })
.containsExactly(NextFireLabel.InMinutes(30), NextFireLabel.InHoursMinutes(1, 30))
.inOrder()
}
// --- #21: the scheduled date and the pause ---
@Test
fun `a scheduled date still ahead is carried to the row`() {
val date = LocalDate.of(2026, 6, 18)
val entry = upcomingAlarm(id = 1L, hour = 7, nextFire = sevenTomorrow)
val scheduled = entry.copy(alarm = entry.alarm.copy(scheduledDate = date))
val row = AlarmListRows.from(listOf(scheduled), now, BERLIN).single()
assertThat(row.scheduledDate).isEqualTo(date)
}
@Test
fun `a scheduled date that has gone by is not shown`() {
val entry = upcomingAlarm(id = 1L, hour = 7, nextFire = sevenTomorrow)
val stale = entry.copy(alarm = entry.alarm.copy(scheduledDate = LocalDate.of(2026, 6, 1)))
val row = AlarmListRows.from(listOf(stale), now, BERLIN).single()
assertThat(row.scheduledDate).isNull()
}
@Test
fun `a pause still ahead has not begun, and one covering today has`() {
val entry = upcomingAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY, nextFire = sevenTomorrow)
val today = now.localAt(BERLIN).toLocalDate()
val ahead = PauseRange(today.plusDays(2), today.plusDays(4))
val current = PauseRange(today, today.plusDays(4))
val aheadRow = AlarmListRows.from(listOf(entry.copy(alarm = entry.alarm.copy(pause = ahead))), now, BERLIN)
.single()
val currentRow = AlarmListRows.from(listOf(entry.copy(alarm = entry.alarm.copy(pause = current))), now, BERLIN)
.single()
assertThat(aheadRow.pause to aheadRow.pauseBegun).isEqualTo(ahead to false)
assertThat(currentRow.pause to currentRow.pauseBegun).isEqualTo(current to true)
}
@Test
fun `an ended pause is not shown`() {
val entry = upcomingAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY, nextFire = sevenTomorrow)
val today = now.localAt(BERLIN).toLocalDate()
val ended = PauseRange(today.minusDays(4), today.minusDays(1))
val row = AlarmListRows.from(listOf(entry.copy(alarm = entry.alarm.copy(pause = ended))), now, BERLIN).single()
assertThat(row.pause).isNull()
}
}
@@ -51,7 +51,7 @@ class AlarmsViewModelTest {
engine = harness.engine,
wall = harness.wallClock,
zones = harness.zones,
source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker),
source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker, harness.zones),
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
private fun AlarmsUiState.rows(): List<AlarmRowState> = (this as AlarmsUiState.Loaded).rows
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.clockula.ui.alarms
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneOffset
/** The M3 pickers' UTC-midnight millis ↔ the app's local dates, with no zone in between. */
class PickerDatesTest {
@Test
fun `a date is its UTC midnight and back`() {
val date = LocalDate.of(2026, 10, 28)
val millis = PickerDates.toMillis(date)
assertThat(millis).isEqualTo(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
assertThat(PickerDates.toDate(millis)).isEqualTo(date)
}
@Test
fun `any instant within the UTC day reads as that day`() {
val date = LocalDate.of(2026, 10, 28)
assertThat(PickerDates.toDate(PickerDates.toMillis(date) + 86_399_999L)).isEqualTo(date)
}
@Test
fun `dates before the epoch convert too`() {
val date = LocalDate.of(1969, 12, 31)
assertThat(PickerDates.toDate(PickerDates.toMillis(date))).isEqualTo(date)
}
@Test
fun `nothing before the earliest date is selectable`() {
val earliest = LocalDate.of(2026, 10, 21)
val selectable = PickerDates.from(earliest)
assertThat(selectable.isSelectableDate(PickerDates.toMillis(earliest.minusDays(1)))).isFalse()
assertThat(selectable.isSelectableDate(PickerDates.toMillis(earliest))).isTrue()
assertThat(selectable.isSelectableYear(2025)).isFalse()
assertThat(selectable.isSelectableYear(2026)).isTrue()
}
}