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
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user