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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user