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