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

+4
View File
@@ -21,6 +21,10 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
Changes apply as you make them, with no Save button.
- Repeat days that start on your locale's first day of the week, and a "skip
next alarm" switch so you can sleep in once without turning the alarm off.
- Schedule a one-off alarm for a date, such as a flight next Thursday, and
pause a repeating alarm for a range of days, such as a holiday. The list
shows "Scheduled for Wed, Oct 28" or "Paused Oct 26 – 28", and a pause ends
on its own.
- Per-alarm sound, vibration, gradual volume, snooze length and limit, and a
dismiss challenge (a quick sum or a two-second hold). Each one can follow the
app default, so changing a default later still reaches it.
@@ -0,0 +1,399 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "5704da9a79231cc225da71c4f8a672f8",
"entities": [
{
"tableName": "alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `label` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `repeat_days` INTEGER NOT NULL, `skip_next_occurrence` INTEGER NOT NULL, `ringtone_uri` TEXT, `vibrate` INTEGER, `snooze_minutes` INTEGER, `snooze_limit` INTEGER, `volume_ramp_seconds` INTEGER, `dismiss_challenge` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0, `scheduled_date` INTEGER, `pause_start` INTEGER, `pause_end` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hour",
"columnName": "hour",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minute",
"columnName": "minute",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "repeatDays",
"columnName": "repeat_days",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skipNextOccurrence",
"columnName": "skip_next_occurrence",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ringtoneUri",
"columnName": "ringtone_uri",
"affinity": "TEXT"
},
{
"fieldPath": "vibrate",
"columnName": "vibrate",
"affinity": "INTEGER"
},
{
"fieldPath": "snoozeMinutes",
"columnName": "snooze_minutes",
"affinity": "INTEGER"
},
{
"fieldPath": "snoozeLimit",
"columnName": "snooze_limit",
"affinity": "INTEGER"
},
{
"fieldPath": "volumeRampSeconds",
"columnName": "volume_ramp_seconds",
"affinity": "INTEGER"
},
{
"fieldPath": "dismissChallenge",
"columnName": "dismiss_challenge",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleteAfterUse",
"columnName": "delete_after_use",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "scheduledDate",
"columnName": "scheduled_date",
"affinity": "INTEGER"
},
{
"fieldPath": "pauseStart",
"columnName": "pause_start",
"affinity": "INTEGER"
},
{
"fieldPath": "pauseEnd",
"columnName": "pause_end",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_alarms_enabled",
"unique": false,
"columnNames": [
"enabled"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_alarms_enabled` ON `${TABLE_NAME}` (`enabled`)"
}
]
},
{
"tableName": "alarm_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alarm_id` INTEGER NOT NULL, `snoozed_until` INTEGER, `snooze_count` INTEGER NOT NULL, `ringing_since` INTEGER, `handled_occurrence` INTEGER, `skipped_occurrence` INTEGER, PRIMARY KEY(`alarm_id`), FOREIGN KEY(`alarm_id`) REFERENCES `alarms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "alarmId",
"columnName": "alarm_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "snoozedUntil",
"columnName": "snoozed_until",
"affinity": "INTEGER"
},
{
"fieldPath": "snoozeCount",
"columnName": "snooze_count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ringingSince",
"columnName": "ringing_since",
"affinity": "INTEGER"
},
{
"fieldPath": "handledOccurrence",
"columnName": "handled_occurrence",
"affinity": "INTEGER"
},
{
"fieldPath": "skippedOccurrence",
"columnName": "skipped_occurrence",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"alarm_id"
]
},
"foreignKeys": [
{
"table": "alarms",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"alarm_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "timers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `duration_millis` INTEGER NOT NULL, `state` TEXT NOT NULL, `remaining_millis` INTEGER NOT NULL, `started_at_elapsed_realtime_millis` INTEGER, `ends_at_elapsed_realtime_millis` INTEGER, `ends_at_wall_clock_millis` INTEGER, `ringtone_uri` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMillis",
"columnName": "duration_millis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "remainingMillis",
"columnName": "remaining_millis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAtElapsedRealtimeMillis",
"columnName": "started_at_elapsed_realtime_millis",
"affinity": "INTEGER"
},
{
"fieldPath": "endsAtElapsedRealtimeMillis",
"columnName": "ends_at_elapsed_realtime_millis",
"affinity": "INTEGER"
},
{
"fieldPath": "endsAtWallClockMillis",
"columnName": "ends_at_wall_clock_millis",
"affinity": "INTEGER"
},
{
"fieldPath": "ringtoneUri",
"columnName": "ringtone_uri",
"affinity": "TEXT"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleteAfterUse",
"columnName": "delete_after_use",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_timers_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_timers_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
]
},
{
"tableName": "world_clocks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `zone_id` TEXT NOT NULL, `label` TEXT, `sort_order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "zoneId",
"columnName": "zone_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_world_clocks_zone_id",
"unique": true,
"columnNames": [
"zone_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_world_clocks_zone_id` ON `${TABLE_NAME}` (`zone_id`)"
},
{
"name": "index_world_clocks_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_world_clocks_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
]
},
{
"tableName": "stopwatch_laps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lap_index` INTEGER NOT NULL, `split_millis` INTEGER NOT NULL, `cumulative_millis` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lapIndex",
"columnName": "lap_index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splitMillis",
"columnName": "split_millis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cumulativeMillis",
"columnName": "cumulative_millis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_stopwatch_laps_lap_index",
"unique": true,
"columnNames": [
"lap_index"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stopwatch_laps_lap_index` ON `${TABLE_NAME}` (`lap_index`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5704da9a79231cc225da71c4f8a672f8')"
]
}
}
@@ -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()
}
}
+61 -6
View File
@@ -48,7 +48,7 @@ through.
│ entities │ typed prefs
┌──────────────┴─────────────┐ ┌─────────┴───────────────┐
│ DAOs + mappers (Room) │ │ PrefStore (DataStore) │
│ ClockulaDatabase v3 │ │ clockula_prefs │
│ ClockulaDatabase v4 │ │ clockula_prefs │
└──────────────┬─────────────┘ └─────────┬───────────────┘
│ │
SQLite preferences_pb
@@ -99,7 +99,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
| `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
| `data/db/` | `ClockulaDatabase` (`@Database` v3, `exportSchema = true`) and `Migrations` |
| `data/db/` | `ClockulaDatabase` (`@Database` v4, `exportSchema = true`) and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
@@ -145,10 +145,11 @@ kit module: the kit's roadmap keeps schedulers app-local.
## 4. The data model
`ClockulaDatabase` is at **version 3** with five tables. Every column is a
`ClockulaDatabase` is at **version 4** with five tables. Every column is a
primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room
`TypeConverter`s**. Enums are stored as `Enum.name` in a `TEXT` column,
instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask.
instants and durations as milliseconds, local dates as epoch days, the repeat
set as an `INTEGER` bitmask.
That puts the whole entity↔domain translation inside the mappers, which are
plain JVM objects that a unit test can feed a corrupt row. A converter would
move the same translation into generated code, where an unknown enum name throws
@@ -169,6 +170,8 @@ JSON and M10's JSON backup all read the same vocabulary in a diff.
| `skip_next_occurrence` | INTEGER | |
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI |
| `delete_after_use` | INTEGER | v3 (M9): a transient alarm the `AlarmClock` contract asked for. Deleted rather than disabled when its cycle closes (§17) |
| `scheduled_date` | INTEGER? | v4: a one-shot's date, as `LocalDate.toEpochDay()`. A local date, never an instant, so no zone is baked in (§11, "A date and a pause") |
| `pause_start`, `pause_end` | INTEGER? | v4: a repeating alarm's pause, both ends inclusive epoch days. One without the other reads as no pause |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
Per-alarm settings are nullable *overrides*, never concrete copies of the
@@ -389,6 +392,14 @@ table that *has* one, and the migration test fails for a reason nobody enjoys
finding. Every row written before M9 therefore reads as the permanent alarm or
timer it was.
**v3 → v4 (#21).** `MIGRATION_3_4` adds three nullable `INTEGER` columns to
`alarms`: `scheduled_date`, `pause_start` and `pause_end`, with no default, as
the entity declares them, so every alarm written before v4 reads as unscheduled
and unpaused. A date is only ever stored on a non-repeating alarm and a pause
only on a repeating one; `AlarmMapper` reads the other combination, or an epoch
day outside `AlarmDates` (2000-01-01 to 9999-12-31), as absent rather than
throwing.
---
## 5. The two clocks
@@ -751,7 +762,7 @@ and a TalkBack-shaped accessibility click completing the hold challenge.
zones with awkward transitions.
- **Instrumentation-only** is the half a fake cannot prove: generated SQL,
unique indices, real `@Transaction` behaviour, the foreign key cascading, the
v1 → v2 and v2 → v3 migrations validated against the exported schema, and the
v1 → v2, v2 → v3 and v3 → v4 migrations validated against the exported schema, and the
database opening at its current version.
- **`ArchitectureRulesTest`** greps the main source set for Room leakage above
`data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and
@@ -1289,6 +1300,46 @@ one audio stream.
---
### A date and a pause
Two optional fields shape the occurrence sequence without touching the
resolver's precedence (#21). Both are **local dates**, so "Thursday the 28th"
is the 28th of whatever zone the phone is in when it arrives, and both are
applied by `AlarmOccurrences.sequence`, the one place that knows about DST:
- **`scheduledDate`** (one-shot only) is a *floor*: no candidate on an earlier
local date. It is not a filter, so the sequence stays infinite. A date missed
while the phone was off leaves the alarm behaving exactly as a missed plain
one-shot does, ringing at its next time of day, and the snooze and skip
paths, which call `.first` on the sequence, can never run dry. Ringing on the
date closes the cycle like any one-shot's: the alarm switches off.
- **`pause`** (repeating only) is a *hole*: candidates whose local date falls
inside it are dropped. It is jumped rather than walked, so a corrupt pause
centuries long costs one step. "Skip next" still works on a paused alarm and
skips the first ring after the pause; it stays the quick version of a pause.
Both clear themselves, and the rule is exact rather than approximate: a date or
a pause is cleared once it lies wholly before `firstDateFor(now - fireGrace)`,
the earliest local date the sequence looks at. At that point it cannot change a
single candidate, so clearing it changes nothing but the row. Measuring from
`now - fireGrace` rather than from today is load-bearing: a pause ending at
midnight would otherwise be cleared at 00:00 and let its last day's 23:59
occurrence ring late through the grace window. The resolver reports
`clearScheduledDate` / `clearPause` on every branch, a disabled alarm included,
and the engine writes them through `AlarmRepository.edit`, conditional on the
stored value still being the one it resolved, so a pause the editor has just
picked is never cleared by a resolution of the old one.
The surfaces never show a stale one in the meantime: `AlarmSchedule` shows a
date only while its occurrence is still ahead and a pause only while its last
day has not passed. The editor's date picker starts at `AlarmSchedule.earliestDate`,
the date of the alarm's next plain occurrence, so neither a past day nor
today-after-the-time-has-gone can be picked; moving the time earlier than now
on a date set for today drops the date. Picking a repeat day clears the date
and clearing the last one clears the pause, as in Google Clock. The
`AlarmClock` contract cannot express either field, so `SET_ALARM` never
re-uses an alarm that carries one (§17).
## 12. The app shell
Four tabs, one host, and one surface that follows whatever is running.
@@ -2605,7 +2656,11 @@ the extras can set, so a snooze override the user set by hand does not prevent i
Ties go to the **lower id**, the same rule the alarm engine's precedence
already uses. A reused alarm is enabled *and* has any pending skip cleared,
because leaving one armed would silently eat the occurrence just asked for, and
it is never made transient, as the contract's own parenthesis says. A timer is
it is never made transient, as the contract's own parenthesis says. An alarm
with a scheduled date or a pause is never identical: the extras cannot express
either, and re-using it would either keep the caller's alarm quiet until the
pause ends or throw away a holiday the user set, so a new alarm is created
instead. A timer is
identical only if it is **IDLE** with the same duration and label: restarting
somebody's running countdown is destructive.
+19 -5
View File
@@ -11,7 +11,7 @@ written here.
```json
{
"format": "clockula-backup",
"version": 1,
"version": 2,
"exportedAt": 1700000000000,
"alarms": [ … ],
"timers": [ … ],
@@ -21,8 +21,14 @@ written here.
```
- `format`: must be exactly `"clockula-backup"`.
- `version`: must be exactly `1`. Any other value rejects the whole file:
Clockula does not guess at a schema it has not implemented.
- `version`: `2` on export. Import accepts `1` and `2`; any other value
rejects the whole file, since Clockula does not guess at a schema it has not
implemented. Version 2 adds an alarm's `scheduled_date`, `pause_start` and
`pause_end`; a version 1 file is a version 2 file without them, and imports
with every alarm unscheduled and unpaused. The bump is what keeps an older
Clockula from importing a v2 file and silently turning a dated alarm into
tomorrow's, or a paused one back on. The file name stays
`clockula-backup-v1.json` so an existing backup folder keeps working.
- `exportedAt`: epoch milliseconds, informational only (not re-imported).
## Parsing rules
@@ -30,8 +36,8 @@ written here.
- **Tolerant of unknown fields, at any level.** An older or newer client's
extra field is ignored, never an error.
- **Strict about known fields.** A malformed value in a field this schema
defines (`format`, `version`, an alarm's `hour`/`minute`, a world clock's
`zone_id`, a timer's `duration_millis`) rejects the **whole file** with a
defines (`format`, `version`, an alarm's `hour`/`minute` or dates, a world
clock's `zone_id`, a timer's `duration_millis`) rejects the **whole file** with a
specific reason. Nothing is imported half-way.
- Settings values (snooze minutes, volume ramp, …) are
**not** range-checked at parse time; they pass through the same clamps
@@ -92,6 +98,14 @@ One object per row of `alarms`, config only:
| `volume_ramp_seconds` | Int? | `null` = inherit. |
| `dismiss_challenge` | String? | `NONE`/`MATH`/`HOLD`, or `null` = inherit. |
| `delete_after_use` | Boolean | A transient, contract-created alarm. |
| `scheduled_date` | String? | v2. ISO-8601 local date (`2026-10-28`) a one-shot alarm rings on. Ignored on a repeating alarm. |
| `pause_start` | String? | v2. First day of a repeating alarm's pause, ISO-8601. Ignored on a one-shot alarm. |
| `pause_end` | String? | v2. Last day of the pause, inclusive. Present exactly when `pause_start` is. |
A date that does not parse, lies outside 2000-01-01..9999-12-31, a pause with
only one end, or a pause ending before it starts rejects the whole file, like
an out-of-range `hour`. A date on a repeating alarm or a pause on a one-shot is
dropped rather than rejected, which is what the editor would do with it.
**Not exported:** the `alarm_states` ring-state table (snooze/ringing/handled-
occurrence bookkeeping). It is volatile, per-device state, not configuration,