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