feat(domain): next-fire text, repeat summaries, ringtones and defaults

The countdown on a row is formatting, not scheduling: it turns the engine's
resolved instant into "in 9h 12m", rounding whole minutes up so a row never
reads a minute it has already passed. Repeat masks become their own short
summaries with the week starting where the locale says it does.

A ringtone is a URI, plus one explicit silent sentinel — "Silent" has to be a
choice a user can make, and it resolves to no audio source at all, which is
what forces vibration rather than a quiet alarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 14:43:17 +02:00
co-authored by Claude Opus 5
parent 2905747a23
commit 52204d3e9c
16 changed files with 871 additions and 2 deletions
@@ -0,0 +1,21 @@
package de.jeanlucmakiola.clockula.domain
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.time.Instant
import kotlin.time.toJavaInstant
object AlarmDefaults {
/** The next whole hour in [zone], as a local time of day. Never "now". */
fun nextWholeHour(now: Instant, zone: ZoneId): TimeOfDay {
// An hour of *real* time, then truncated in the zone: on a spring-forward
// night 01:30 + 1h is 03:30 local, which truncates to the 03:00 that
// actually arrives. Adding an hour to the local reading would have
// offered 02:00, an hour that does not happen that night.
val local = now.toJavaInstant()
.atZone(zone)
.plusHours(1)
.truncatedTo(ChronoUnit.HOURS)
return TimeOfDay(local.hour, local.minute)
}
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.domain
import java.time.DayOfWeek
sealed interface RepeatSummary {
data object Once : RepeatSummary
data object EveryDay : RepeatSummary
data object Weekdays : RepeatSummary
data object Weekends : RepeatSummary
/** The mask's days, ordered from the locale's first day of week. Never empty. */
data class SomeDays(val days: List<DayOfWeek>) : RepeatSummary
}
object RepeatDaysSummary {
fun of(repeatDays: RepeatDays, firstDayOfWeek: DayOfWeek): RepeatSummary = when (repeatDays.mask) {
RepeatDays.NONE.mask -> RepeatSummary.Once
RepeatDays.EVERY_DAY.mask -> RepeatSummary.EveryDay
RepeatDays.WEEKDAYS.mask -> RepeatSummary.Weekdays
RepeatDays.WEEKENDS.mask -> RepeatSummary.Weekends
// Anything else is the days themselves, in the reader's own week order —
// "Mon, Wed, Fri" says more than any invented shorthand would.
else -> RepeatSummary.SomeDays(
RepeatDaysOrder.startingAt(firstDayOfWeek).filter { it in repeatDays },
)
}
}
object RepeatDaysOrder {
/** All seven days, starting at [firstDayOfWeek]. */
fun startingAt(firstDayOfWeek: DayOfWeek): List<DayOfWeek> =
List(DayOfWeek.entries.size) { firstDayOfWeek.plus(it.toLong()) }
}
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.clockula.domain
/** One selectable device sound: its content URI and the title to show. */
data class RingtoneOption(val uri: String, val title: String)
object Ringtones {
/** The reserved "no audio" choice. A private scheme, so no provider URI can collide. */
const val SILENT_URI: String = "clockula://silent"
fun isSilent(uri: String?): Boolean = uri == SILENT_URI
/** Trimmed; blank becomes null. [SILENT_URI] passes through unchanged. */
fun normalise(uri: String?): String? = uri?.trim()?.takeIf { it.isNotEmpty() }
}
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.Ringtones
/** What the platform will currently let the app do. */
data class CapabilitySnapshot(
val canScheduleExactAlarms: Boolean,
@@ -50,3 +52,24 @@ object AlarmVibrationPattern {
/** The index the pattern loops from: 0, so the whole pattern repeats. */
const val REPEAT_INDEX: Int = 0
}
enum class RingtoneSourceKind { ALARM_OWN, DEVICE_DEFAULT, BUILT_IN_DEFAULT }
object AudioSourcePolicy {
/**
* The audio sources to try, in order. **Empty** for the silent sentinel —
* which is what turns forced vibration on, so the alarm still does something.
*/
fun sourcesFor(ringtoneUri: String?): List<RingtoneSourceKind> = when {
Ringtones.isSilent(ringtoneUri) -> emptyList()
Ringtones.normalise(ringtoneUri) == null -> listOf(
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
)
else -> listOf(
RingtoneSourceKind.ALARM_OWN,
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
)
}
}
@@ -0,0 +1,56 @@
package de.jeanlucmakiola.clockula.domain.format
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
sealed interface NextFireLabel {
data object NotScheduled : NextFireLabel
data object Imminent : NextFireLabel
data class InMinutes(val minutes: Int) : NextFireLabel
data class InHoursMinutes(val hours: Int, val minutes: Int) : NextFireLabel
data class InDaysHours(val days: Int, val hours: Int) : NextFireLabel
}
object NextFireFormat {
/** Anything nearer than this reads as [NextFireLabel.Imminent]. */
val IMMINENT_BELOW: Duration = 1.minutes
private const val MINUTES_PER_HOUR = 60
private const val HOURS_PER_DAY = 24
private const val MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR
private const val MILLIS_PER_MINUTE = 60_000L
fun labelFor(nextFire: Instant?, now: Instant): NextFireLabel =
if (nextFire == null) NextFireLabel.NotScheduled else labelFor(nextFire - now)
/** Whole minutes, rounded **up**. Zero and negative read as Imminent. */
fun labelFor(remaining: Duration): NextFireLabel {
if (remaining < IMMINENT_BELOW) return NextFireLabel.Imminent
// Rounded up, the direction `ClockFormat.countdown` rounds: "in 9h 12m"
// must mean *at least* nine hours and eleven-and-a-bit minutes.
val wholeMinutes = ceilMinutes(remaining)
return when {
wholeMinutes < MINUTES_PER_HOUR -> NextFireLabel.InMinutes(wholeMinutes)
wholeMinutes < MINUTES_PER_DAY -> NextFireLabel.InHoursMinutes(
hours = wholeMinutes / MINUTES_PER_HOUR,
minutes = wholeMinutes % MINUTES_PER_HOUR,
)
// Past a day the unit is an hour, so the rounding moves up with it:
// 6d 23h 59m reads "in 7d", not "in 6d 23h".
else -> ceilHours(remaining).let { hours ->
NextFireLabel.InDaysHours(days = hours / HOURS_PER_DAY, hours = hours % HOURS_PER_DAY)
}
}
}
private fun ceilMinutes(remaining: Duration): Int = ceilUnits(remaining, MILLIS_PER_MINUTE)
private fun ceilHours(remaining: Duration): Int = ceilUnits(remaining, MILLIS_PER_MINUTE * MINUTES_PER_HOUR)
private fun ceilUnits(remaining: Duration, unitMillis: Long): Int {
val millis = remaining.inWholeMilliseconds
return ((millis + unitMillis - 1) / unitMillis).toInt()
}
}