From b1a45db54a0619bb9d3fc0b9ae8201c3a061a1c6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 20:16:34 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + .../4.json | 399 ++++++++++++++++++ .../clockula/data/db/MigrationTest.kt | 43 +- .../clockula/alarm/AlarmEngine.kt | 12 + .../clockula/data/alarms/AlarmEntity.kt | 8 +- .../clockula/data/alarms/AlarmMapper.kt | 64 ++- .../clockula/data/backup/BackupCodec.kt | 49 ++- .../clockula/data/backup/BackupDto.kt | 9 +- .../data/backup/BackupRepositoryImpl.kt | 5 + .../clockula/data/db/ClockulaDatabase.kt | 4 +- .../clockula/data/db/Migrations.kt | 13 +- .../jeanlucmakiola/clockula/domain/Alarm.kt | 41 ++ .../clockula/domain/alarm/AlarmOccurrences.kt | 29 +- .../clockula/domain/alarm/AlarmResolver.kt | 36 +- .../clockula/domain/alarm/AlarmSchedule.kt | 59 +++ .../clockula/domain/backup/BackupContent.kt | 4 + .../domain/widget/NextAlarmWidgetModel.kt | 12 + .../clockula/interop/AlarmClockHandler.kt | 12 +- .../clockula/ui/alarms/AlarmDatePickers.kt | 188 +++++++++ .../clockula/ui/alarms/AlarmEditorScreen.kt | 55 ++- .../clockula/ui/alarms/AlarmEditorUiState.kt | 10 + .../ui/alarms/AlarmEditorViewModel.kt | 67 ++- .../clockula/ui/alarms/AlarmListSource.kt | 6 +- .../clockula/ui/alarms/AlarmRow.kt | 23 +- .../clockula/ui/alarms/AlarmRowState.kt | 16 +- .../ui/widget/NextAlarmWidgetContent.kt | 3 + app/src/main/res/values/strings.xml | 10 + .../clockula/alarm/AlarmEngineScheduleTest.kt | 110 +++++ .../clockula/data/alarms/AlarmMapperTest.kt | 58 +++ .../clockula/data/backup/BackupCodecTest.kt | 86 +++- .../clockula/data/db/SchemaExportTest.kt | 23 +- .../alarm/AlarmOccurrencesScheduleTest.kt | 299 +++++++++++++ .../domain/alarm/AlarmResolverScheduleTest.kt | 224 ++++++++++ .../domain/alarm/AlarmScheduleTest.kt | 80 ++++ .../domain/widget/NextAlarmWidgetModelTest.kt | 19 + .../clockula/interop/AlarmClockHandlerTest.kt | 33 ++ .../ui/alarms/AlarmEditorScheduleTest.kt | 228 ++++++++++ .../ui/alarms/AlarmEditorViewModelTest.kt | 2 + .../clockula/ui/alarms/AlarmListRowsTest.kt | 82 +++- .../clockula/ui/alarms/AlarmsViewModelTest.kt | 2 +- .../clockula/ui/alarms/PickerDatesTest.kt | 44 ++ docs/ARCHITECTURE.md | 67 ++- docs/BACKUP.md | 24 +- 43 files changed, 2488 insertions(+), 74 deletions(-) create mode 100644 app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/4.json create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSchedule.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrencesScheduleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverScheduleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmScheduleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScheduleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/PickerDatesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..74883cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/4.json b/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/4.json new file mode 100644 index 0000000..859b1ff --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/4.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt index b23bdc5..68d8dba 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index d9ff0a9..41bada3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -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 }, + ) + } + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt index 1c2dada..bef7c9a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapper.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapper.kt index 7c03cb5..936d2b7 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapper.kt @@ -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): List = 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) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index c6daf96..f9cbe1b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -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 + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..015d80e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -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`. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepositoryImpl.kt index d9a9556..0905278 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepositoryImpl.kt @@ -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`). */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt index a80afe8..c929455 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt @@ -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() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt index b0ab3fe..6e2d646 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt @@ -44,5 +44,16 @@ object Migrations { } } - val ALL: Array = 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 = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt index 0129029..347510f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt @@ -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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrences.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrences.kt index f543137..ff97e68 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrences.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrences.kt @@ -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 = 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() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt index ba6d548..e68d5fa 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt @@ -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 { 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 } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSchedule.kt new file mode 100644 index 0000000..495d1cb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSchedule.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index eac440d..db01590 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt index 85f3276..1b95451 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt @@ -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, 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)) }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt index c0d789d..07a849e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt @@ -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 && diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt new file mode 100644 index 0000000..5785fda --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt @@ -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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt index 3e4a3dc..b74d3b2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorUiState.kt index 852d31d..715a69e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorUiState.kt @@ -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(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, val snoozeMinutes: OverrideValue, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt index be4879f..e7fce53 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListSource.kt index d3eb85a..5621511 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListSource.kt @@ -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> = engine .upcoming(ticker.ticks(AlarmsDefaults.NextFireTick)) - .map { upcoming -> AlarmListRows.from(upcoming, wall.now()) } + .map { upcoming -> AlarmListRows.from(upcoming, wall.now(), zones.current()) } .distinctUntilChanged() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt index c47eb5a..6c58f8a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt index bfa9310..776a6bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt @@ -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, now: Instant): List { + fun from(upcoming: List, now: Instant, zone: ZoneId): List { // 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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt index c379141..07dd9d7 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt @@ -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 -> "" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..a30e877 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,12 @@ Every day Weekdays Weekends + + Scheduled for %1$s + + Paused %1$s + + Paused until %1$s " \u00b7 " @@ -125,6 +131,10 @@ Repeat Skip next alarm Ring the one after this + Schedule alarm + Pick a date for this alarm to ring on + Pause alarm + Keep it quiet for a few days, such as a holiday Alarm name Sound and vibration Snooze and dismiss diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt new file mode 100644 index 0000000..c0dc523 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt index 4d6d18e..35498bf 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt @@ -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))) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index db57734..44bc8cf 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -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 = """ diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt index 361cfec..9426167 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt @@ -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() } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrencesScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrencesScheduleTest.kt new file mode 100644 index 0000000..fa79f95 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrencesScheduleTest.kt @@ -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 = 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")) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverScheduleTest.kt new file mode 100644 index 0000000..0b441b2 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverScheduleTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmScheduleTest.kt new file mode 100644 index 0000000..e72928c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmScheduleTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt index ee2dad8..3ecf09b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt @@ -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) } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandlerTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandlerTest.kt index 799f4db..d6c628f 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandlerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandlerTest.kt @@ -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) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScheduleTest.kt new file mode 100644 index 0000000..f6b1013 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScheduleTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt index 4e9c0b9..3e2c05e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt @@ -59,6 +59,8 @@ class AlarmEditorViewModelTest { engine = harness.engine, ringtones = catalog, preview = previewer, + wallClock = harness.wallClock, + zones = harness.zones, appScope = this, ) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt index eb3aaf0..4e6e1ed 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt @@ -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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt index 1317b97..85811f3 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt @@ -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 = (this as AlarmsUiState.Loaded).rows diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/PickerDatesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/PickerDatesTest.kt new file mode 100644 index 0000000..7fbc2b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/PickerDatesTest.kt @@ -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() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..39569aa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..1ef338c 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -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,