feat(domain): plain-Kotlin models and the two clocks

The aggregates Clockula stores — alarms, timers, world clocks and the
stopwatch run — as ordinary Kotlin, with no Room or Android type anywhere
near them.

Time is taken as a parameter, never read ambiently: WallClock and
ElapsedRealtimeClock are separate types so a call site has to name which
one it means. A running timer resolves against the monotonic clock and
keeps a wall-clock value only as a post-reboot fallback; the stopwatch
gets no wall-clock value at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:02 +02:00
co-authored by Claude Opus 5
parent bd8654bf31
commit a2beae94ec
13 changed files with 936 additions and 0 deletions
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.clockula.domain
import java.time.DayOfWeek
import kotlin.time.Instant
/** A local time of day. Always valid; use [clamped] at a data boundary. */
data class TimeOfDay(val hour: Int, val minute: Int) {
init {
require(hour in 0..23) { "hour out of range: $hour" }
require(minute in 0..59) { "minute out of range: $minute" }
}
/** Minutes since local midnight, 0..1439. */
val minutesOfDay: Int get() = hour * 60 + minute
companion object {
/** Never throws — out-of-range input is clamped into range. */
fun clamped(hour: Int, minute: Int): TimeOfDay =
TimeOfDay(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
}
}
/**
* The days an alarm repeats on, as a 7-bit mask: bit `n` is ISO day `n + 1`,
* so Monday is bit 0 and Sunday is bit 6. The constructor is private; every
* entry point sanitises, so a corrupt stored mask can never widen the set.
*/
@JvmInline
value class RepeatDays private constructor(val mask: Int) {
/** Iteration order MONDAY..SUNDAY. */
val days: Set<DayOfWeek>
get() = DayOfWeek.entries.filterTo(LinkedHashSet()) { it in this }
val isRepeating: Boolean get() = mask != 0
operator fun contains(day: DayOfWeek): Boolean = mask and day.bit != 0
operator fun plus(day: DayOfWeek): RepeatDays = RepeatDays(mask or day.bit)
operator fun minus(day: DayOfWeek): RepeatDays = RepeatDays(mask and day.bit.inv())
companion object {
const val ALL_MASK: Int = 0b111_1111
val NONE: RepeatDays = RepeatDays(0)
val EVERY_DAY: RepeatDays = RepeatDays(ALL_MASK)
val WEEKDAYS: RepeatDays = of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
)
val WEEKENDS: RepeatDays = of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)
/** Keeps only bits 0..6; never throws. The only way to build one from stored data. */
fun fromMask(mask: Int): RepeatDays = RepeatDays(mask and ALL_MASK)
fun of(days: Set<DayOfWeek>): RepeatDays =
RepeatDays(days.fold(0) { mask, day -> mask or day.bit })
fun of(vararg days: DayOfWeek): RepeatDays =
RepeatDays(days.fold(0) { mask, day -> mask or day.bit })
/** ISO day `n` lives in bit `n - 1`, so `DayOfWeek.value` needs no lookup table. */
private val DayOfWeek.bit: Int get() = 1 shl (value - 1)
}
}
/** The optional barrier between a ringing alarm and dismissing it. */
enum class DismissChallenge { NONE, MATH, HOLD }
/**
* One alarm as the app sees it. The nullable fields are **overrides**: null
* means "inherit [ClockDefaults]". Resolve with [resolveSettings].
*/
data class Alarm(
val id: Long,
val time: TimeOfDay,
val label: String,
val enabled: Boolean,
val repeatDays: RepeatDays,
val skipNextOccurrence: Boolean,
val ringtoneUri: String?,
val vibrate: Boolean?,
val snoozeMinutes: Int?,
val snoozeLimit: Int?,
val volumeRampSeconds: Int?,
val dismissChallenge: DismissChallenge?,
val createdAt: Instant,
val updatedAt: Instant,
)
/** Everything needed to create an alarm; the repository supplies id and timestamps. */
data class AlarmDraft(
val time: TimeOfDay,
val label: String = "",
val enabled: Boolean = true,
val repeatDays: RepeatDays = RepeatDays.NONE,
val ringtoneUri: String? = null,
val vibrate: Boolean? = null,
val snoozeMinutes: Int? = null,
val snoozeLimit: Int? = null,
val volumeRampSeconds: Int? = null,
val dismissChallenge: DismissChallenge? = null,
)
/** An alarm's overrides resolved against the app defaults — no nullables left. */
data class AlarmSettings(
val ringtoneUri: String?,
val vibrate: Boolean,
val snoozeMinutes: Int,
val snoozeLimit: Int,
val volumeRampSeconds: Int,
val dismissChallenge: DismissChallenge,
)
/**
* `?:` throughout, never `takeIf`/truthiness: a stored `false` is a choice the
* user made, not an absent value.
*/
fun Alarm.resolveSettings(defaults: ClockDefaults): AlarmSettings = AlarmSettings(
ringtoneUri = ringtoneUri ?: defaults.alarmRingtoneUri,
vibrate = vibrate ?: defaults.vibrate,
snoozeMinutes = snoozeMinutes ?: defaults.snoozeMinutes,
snoozeLimit = snoozeLimit ?: defaults.snoozeLimit,
volumeRampSeconds = volumeRampSeconds ?: defaults.volumeRampSeconds,
dismissChallenge = dismissChallenge ?: defaults.dismissChallenge,
)
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.clockula.domain
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* The app-wide defaults an alarm or timer inherits when it has no override.
* Backed by DataStore, edited in M10's settings screen.
*/
data class ClockDefaults(
val snoozeMinutes: Int = 10,
val snoozeLimit: Int = 3,
val vibrate: Boolean = true,
val volumeRampSeconds: Int = 15,
val alarmRingtoneUri: String? = null,
val timerRingtoneUri: String? = null,
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
val timerDuration: Duration = 5.minutes,
val homeZoneId: String? = null,
)
@@ -0,0 +1,46 @@
package de.jeanlucmakiola.clockula.domain
import kotlin.time.Duration
enum class StopwatchState { IDLE, RUNNING, PAUSED }
/**
* The stopwatch's persisted run record. Carries **no wall-clock value at all**:
* a stopwatch is monotonic time and nothing else.
*/
data class StopwatchRun(
val state: StopwatchState = StopwatchState.IDLE,
val accumulated: Duration = Duration.ZERO,
val startedAtElapsedRealtime: Duration? = null,
val lastLapCumulative: Duration = Duration.ZERO,
)
data class StopwatchSnapshot(
val elapsed: Duration,
val isRunning: Boolean,
val anchorIsStale: Boolean,
)
/** One recorded lap. [index] is 1-based and is the lap's identity. */
data class Lap(
val index: Int,
val split: Duration,
val cumulative: Duration,
)
/** Resolve against the monotonic clock. Never throws. */
fun StopwatchRun.snapshotAt(elapsedRealtime: Duration): StopwatchSnapshot = when {
state == StopwatchState.IDLE -> StopwatchSnapshot(Duration.ZERO, false, anchorIsStale = false)
state == StopwatchState.PAUSED -> StopwatchSnapshot(accumulated, false, anchorIsStale = false)
// RUNNING from here: no anchor, or a monotonic clock that has gone backwards,
// both mean the current segment is unknowable. Discard it rather than guess —
// a stopwatch has no wall-clock fallback, by design.
startedAtElapsedRealtime == null -> StopwatchSnapshot(accumulated, false, anchorIsStale = true)
elapsedRealtime < startedAtElapsedRealtime ->
StopwatchSnapshot(accumulated, false, anchorIsStale = true)
else -> StopwatchSnapshot(
elapsed = accumulated + (elapsedRealtime - startedAtElapsedRealtime),
isRunning = true,
anchorIsStale = false,
)
}
@@ -0,0 +1,73 @@
package de.jeanlucmakiola.clockula.domain
import kotlin.time.Duration
import kotlin.time.Instant
enum class TimerState { IDLE, RUNNING, PAUSED, EXPIRED }
/**
* One timer as stored. [endsAtElapsedRealtime] is authoritative while running;
* [endsAtWallClock] is the post-reboot fallback only.
*/
data class Timer(
val id: Long,
val label: String,
val duration: Duration,
val state: TimerState,
val remaining: Duration,
val startedAtElapsedRealtime: Duration?,
val endsAtElapsedRealtime: Duration?,
val endsAtWallClock: Instant?,
val ringtoneUri: String?,
val sortOrder: Int,
val createdAt: Instant,
val updatedAt: Instant,
)
data class TimerDraft(
val duration: Duration,
val label: String = "",
val ringtoneUri: String? = null,
)
/** What a [Timer] reads as *right now*. [remaining] is never negative. */
data class TimerSnapshot(
val remaining: Duration,
val isRunning: Boolean,
val hasExpired: Boolean,
val anchorIsStale: Boolean,
)
/**
* Resolve this timer against both clocks. [wallClock] is consulted **only**
* when the elapsed-realtime anchor is stale — i.e. after a reboot. Never throws.
*/
fun Timer.snapshotAt(elapsedRealtime: Duration, wallClock: Instant): TimerSnapshot {
if (state != TimerState.RUNNING) {
return when (state) {
TimerState.EXPIRED -> TimerSnapshot(Duration.ZERO, false, hasExpired = true, anchorIsStale = false)
else -> TimerSnapshot(remaining, false, hasExpired = false, anchorIsStale = false)
}
}
val startedAt = startedAtElapsedRealtime
val endsAt = endsAtElapsedRealtime
// A row that says RUNNING without both anchors is corrupt, not stale-by-reboot;
// read it as whatever it last banked rather than throwing inside a list.
if (startedAt == null || endsAt == null) {
return TimerSnapshot(remaining, false, hasExpired = remaining == Duration.ZERO, anchorIsStale = true)
}
// Staleness is decided monotonically: elapsedRealtime() never decreases within
// one boot, so only a reboot can put it behind the start anchor. A user moving
// the system clock therefore cannot make a running timer look stale.
if (elapsedRealtime < startedAt) {
val endsAtWall = endsAtWallClock
?: return TimerSnapshot(Duration.ZERO, false, hasExpired = true, anchorIsStale = true)
val left = (endsAtWall - wallClock).coerceAtLeast(Duration.ZERO)
return TimerSnapshot(left, left > Duration.ZERO, left == Duration.ZERO, anchorIsStale = true)
}
val left = (endsAt - elapsedRealtime).coerceAtLeast(Duration.ZERO)
return TimerSnapshot(left, left > Duration.ZERO, left == Duration.ZERO, anchorIsStale = false)
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.domain
import java.time.ZoneId
data class WorldClock(
val id: Long,
val zoneId: String,
val label: String?,
val sortOrder: Int,
)
/** False for a row whose zone id no longer exists in the device's tzdata. */
val WorldClock.isKnownZone: Boolean get() = Zones.isValid(zoneId)
/** IANA zone-id validation. Shared with M9's intent-extra validation. */
object Zones {
/** True only for region-based IANA ids the device knows; rejects blanks and fixed offsets. */
fun isValid(zoneId: String): Boolean = zoneId in ZoneId.getAvailableZoneIds()
/** Trimmed id if [isValid] after trimming, else null. Never throws. */
fun normalise(zoneId: String?): String? = zoneId?.trim()?.takeIf(::isValid)
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.domain.time
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Wall-clock time: what the user's calendar says. Moves when the user or the
* network changes the system time, and across DST. Alarms and record
* timestamps use this — never a running timer.
*/
interface WallClock {
fun now(): Instant
}
/**
* Monotonic milliseconds since boot (`SystemClock.elapsedRealtime()`), as a
* Duration. Never decreases within one boot and is immune to system-clock
* changes; resets to zero on reboot. Running timers and the stopwatch use this.
*/
interface ElapsedRealtimeClock {
fun elapsedRealtime(): Duration
}