feat(data): entity and domain mappers

The translation either way, including the repeat mask's bit order and the
nullable per-alarm overrides that mean "inherit the app default" rather
than a concrete value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:11 +02:00
co-authored by Claude Opus 5
parent 778d08af24
commit 1d85faa54e
9 changed files with 583 additions and 0 deletions
@@ -0,0 +1,70 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.floret.prefs.toEnum
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,
snoozeLimit = entity.snoozeLimit,
volumeRampSeconds = entity.volumeRampSeconds,
// 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),
)
fun toDomain(entities: List<AlarmEntity>): List<Alarm> = entities.map(::toDomain)
fun toEntity(alarm: Alarm): AlarmEntity = AlarmEntity(
id = alarm.id,
hour = alarm.time.hour,
minute = alarm.time.minute,
label = alarm.label,
enabled = alarm.enabled,
repeatDays = alarm.repeatDays.mask,
skipNextOccurrence = alarm.skipNextOccurrence,
ringtoneUri = alarm.ringtoneUri,
vibrate = alarm.vibrate,
snoozeMinutes = alarm.snoozeMinutes,
snoozeLimit = alarm.snoozeLimit,
volumeRampSeconds = alarm.volumeRampSeconds,
dismissChallenge = alarm.dismissChallenge?.name,
createdAt = alarm.createdAt.toEpochMilliseconds(),
updatedAt = alarm.updatedAt.toEpochMilliseconds(),
)
/** id = 0, createdAt = updatedAt = [now]. */
fun toEntity(draft: AlarmDraft, now: Instant): AlarmEntity = AlarmEntity(
id = 0L,
hour = draft.time.hour,
minute = draft.time.minute,
label = draft.label,
enabled = draft.enabled,
repeatDays = draft.repeatDays.mask,
skipNextOccurrence = false,
ringtoneUri = draft.ringtoneUri,
vibrate = draft.vibrate,
snoozeMinutes = draft.snoozeMinutes,
snoozeLimit = draft.snoozeLimit,
volumeRampSeconds = draft.volumeRampSeconds,
dismissChallenge = draft.dismissChallenge?.name,
createdAt = now.toEpochMilliseconds(),
updatedAt = now.toEpochMilliseconds(),
)
}
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import de.jeanlucmakiola.clockula.domain.Lap
import kotlin.time.Duration.Companion.milliseconds
object LapMapper {
fun toDomain(entity: LapEntity): Lap = Lap(
index = entity.lapIndex,
split = entity.splitMillis.coerceAtLeast(0L).milliseconds,
cumulative = entity.cumulativeMillis.coerceAtLeast(0L).milliseconds,
)
fun toDomain(entities: List<LapEntity>): List<Lap> = entities.map(::toDomain)
}
@@ -0,0 +1,61 @@
package de.jeanlucmakiola.clockula.data.timers
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
object TimerMapper {
fun toDomain(entity: TimerEntity): Timer = Timer(
id = entity.id,
label = entity.label,
duration = entity.durationMillis.coerceAtLeast(0L).milliseconds,
state = entity.state.toEnum(TimerState.IDLE),
remaining = entity.remainingMillis.coerceAtLeast(0L).milliseconds,
startedAtElapsedRealtime = entity.startedAtElapsedRealtimeMillis?.milliseconds,
endsAtElapsedRealtime = entity.endsAtElapsedRealtimeMillis?.milliseconds,
endsAtWallClock = entity.endsAtWallClockMillis?.let(Instant::fromEpochMilliseconds),
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
sortOrder = entity.sortOrder,
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
)
fun toDomain(entities: List<TimerEntity>): List<Timer> = entities.map(::toDomain)
fun toEntity(timer: Timer): TimerEntity = TimerEntity(
id = timer.id,
label = timer.label,
durationMillis = timer.duration.inWholeMilliseconds,
state = timer.state.name,
remainingMillis = timer.remaining.inWholeMilliseconds,
startedAtElapsedRealtimeMillis = timer.startedAtElapsedRealtime?.inWholeMilliseconds,
endsAtElapsedRealtimeMillis = timer.endsAtElapsedRealtime?.inWholeMilliseconds,
endsAtWallClockMillis = timer.endsAtWallClock?.toEpochMilliseconds(),
ringtoneUri = timer.ringtoneUri,
sortOrder = timer.sortOrder,
createdAt = timer.createdAt.toEpochMilliseconds(),
updatedAt = timer.updatedAt.toEpochMilliseconds(),
)
fun toEntity(draft: TimerDraft, sortOrder: Int, now: Instant): TimerEntity {
val duration = draft.duration.coerceAtLeast(Duration.ZERO)
return TimerEntity(
id = 0L,
label = draft.label,
durationMillis = duration.inWholeMilliseconds,
state = TimerState.IDLE.name,
remainingMillis = duration.inWholeMilliseconds,
startedAtElapsedRealtimeMillis = null,
endsAtElapsedRealtimeMillis = null,
endsAtWallClockMillis = null,
ringtoneUri = draft.ringtoneUri,
sortOrder = sortOrder,
createdAt = now.toEpochMilliseconds(),
updatedAt = now.toEpochMilliseconds(),
)
}
}
@@ -0,0 +1,26 @@
package de.jeanlucmakiola.clockula.data.worldclocks
import de.jeanlucmakiola.clockula.domain.WorldClock
object WorldClockMapper {
/**
* A zone id the device no longer knows is passed through untouched —
* `WorldClock.isKnownZone` says so and M8 shows the row as broken. Blanking
* it here would silently lose what the user chose.
*/
fun toDomain(entity: WorldClockEntity): WorldClock = WorldClock(
id = entity.id,
zoneId = entity.zoneId,
label = entity.label?.takeIf { it.isNotBlank() },
sortOrder = entity.sortOrder,
)
fun toDomain(entities: List<WorldClockEntity>): List<WorldClock> = entities.map(::toDomain)
fun toEntity(clock: WorldClock): WorldClockEntity = WorldClockEntity(
id = clock.id,
zoneId = clock.zoneId,
label = clock.label,
sortOrder = clock.sortOrder,
)
}