diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76d4d0d..bf7eedf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -58,6 +58,14 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
state: permissions, battery optimisation, Do Not Disturb, alarm volume, and
whether the system has Clockula's next alarm. It links straight to the
setting that fixes each problem.
+- **Bedtime**, under Settings: Clockula can turn on a Do Not Disturb mode of
+ its own at bedtime and off at your wake-up time, on the nights you choose,
+ with an optional reminder before it starts. On Android 15 and later it is a
+ real mode in Settings › Modes and can also turn the screen grayscale, dim the
+ wallpaper, switch to the dark theme and hide the always-on display. It needs
+ Do Not Disturb access, which the screen helps you grant, and it always lets
+ alarms through. It is separate from Digital Wellbeing's own Bedtime mode,
+ which apps can't turn on.
- Home-screen widgets: next alarm, timer (with controls), a quick timer, and
analog and digital clocks that show your world clocks at larger sizes. Each
widget can follow the system theme or stay light or dark, and choose what a
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cfe76b4..fc436c2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -50,6 +50,12 @@
+
+
+
+
+
+
+
+
+
= settings.bedtime
+
+ /**
+ * A change made on the bedtime screen: stored, then applied at once. The
+ * rule is rewritten from the new settings, and the mode is switched to
+ * what the new schedule says now, so turning bedtime on at 23:30 for a
+ * 23:00 bedtime starts it straight away.
+ */
+ suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
+ val updated = transform(settings.currentBedtime())
+ settings.setBedtime(updated)
+ apply(updated, refreshRule = true)
+ }
+
+ /**
+ * Everything that can invalidate the slot or the rule: a reboot, an app
+ * update, a clock or zone change, a backup restore, and Do Not Disturb
+ * access being granted or revoked.
+ */
+ suspend fun resync(refreshRule: Boolean = false) = lock.withLock {
+ apply(settings.currentBedtime(), refreshRule = refreshRule)
+ }
+
+ /**
+ * App launch: re-arm the slot only. AlarmManager forgets it on a force
+ * stop, but the mode's state is left alone here — a launch is not a
+ * bedtime event, and a mode the user switched on or off by hand should
+ * stay that way until the next one.
+ */
+ suspend fun rearm() = lock.withLock {
+ schedule(settings.currentBedtime())
+ }
+
+ /**
+ * The slot arriving. A reminder fire posts the reminder and leaves the
+ * mode alone; any other fire switches the mode to what the schedule says
+ * now. Either way the next event is armed.
+ */
+ suspend fun onSlotFired() = lock.withLock {
+ val current = settings.currentBedtime()
+ val now = wallClock.now()
+ val zone = zones.current()
+ val reminderFor = BedtimeSchedule.dueReminder(current, now, zone)
+ if (reminderFor != null) {
+ notifier.showReminder(reminderFor.start, current.reminderMinutes)
+ schedule(current)
+ } else {
+ apply(current, refreshRule = false)
+ }
+ }
+
+ /** Whether Clockula may change Do Not Disturb, for the screen's onboarding row. */
+ fun hasAccess(): Boolean = mode.hasAccess()
+
+ val supportsEffects: Boolean get() = mode.supportsEffects
+
+ fun ruleId(): String? = mode.ruleId()
+
+ private fun apply(current: BedtimeSettings, refreshRule: Boolean) {
+ if (!current.enabled) {
+ scheduler.cancel()
+ notifier.cancelReminder()
+ // Removing the rule ends the mode if it is on. Without access there
+ // is nothing Clockula may remove; revoking access in Settings
+ // already removed the rule.
+ if (mode.hasAccess()) mode.uninstall()
+ return
+ }
+ val now = wallClock.now()
+ val active = BedtimeSchedule.activeWindow(current, now, zones.current()) != null
+ if (active) notifier.cancelReminder()
+ if (mode.hasAccess()) {
+ mode.install(current, refresh = refreshRule)
+ mode.setActive(active)
+ }
+ schedule(current)
+ }
+
+ private fun schedule(current: BedtimeSettings) {
+ val next = BedtimeSchedule.nextEvent(current, wallClock.now(), zones.current())
+ if (next == null) scheduler.cancel() else scheduler.schedule(next)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt
new file mode 100644
index 0000000..4eb52a0
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt
@@ -0,0 +1,28 @@
+package de.jeanlucmakiola.clockula.bedtime
+
+/**
+ * The bedtime slot's broadcast. Internal and unexported, like the alarm's and
+ * the timer's: only the PendingIntent this app created can deliver it.
+ */
+object BedtimeIntents {
+ const val ACTION_SLOT: String = "de.jeanlucmakiola.clockula.bedtime.action.SLOT"
+
+ /** Opens the bedtime screen, from the reminder notification. */
+ const val ACTION_SHOW_BEDTIME: String = "de.jeanlucmakiola.clockula.action.SHOW_BEDTIME"
+
+ /**
+ * `NotificationManager.ACTION_AUTOMATIC_ZEN_RULE`: what Settings sends the
+ * rule's configuration activity (`MainActivity`) when the user taps
+ * "Clockula" on the mode's page. Spelled out so the shell's pure routing
+ * table needs no Android import.
+ */
+ const val ACTION_AUTOMATIC_ZEN_RULE: String = "android.app.action.AUTOMATIC_ZEN_RULE"
+
+ /**
+ * Apart from every alarm and timer request code, although the explicit
+ * receiver and the action already keep these PendingIntents distinct.
+ */
+ const val REQUEST_SLOT: Int = 40
+
+ const val REQUEST_SHOW: Int = 41
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt
new file mode 100644
index 0000000..8192ed5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt
@@ -0,0 +1,51 @@
+package de.jeanlucmakiola.clockula.bedtime
+
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+import kotlin.time.Instant
+
+/**
+ * The AlarmManager seam: one slot of its own, on its own receiver and request
+ * code, so nothing bedtime does can replace or cancel an alarm's or a timer's
+ * registration.
+ */
+interface BedtimeScheduler {
+ fun schedule(at: Instant)
+
+ fun cancel()
+}
+
+/**
+ * The Do Not Disturb seam: Clockula's one automatic zen rule (a "mode" on
+ * Android 15 and later). Every call is a no-op without Do Not Disturb access,
+ * and none of them throws: a failure here must never stop the slot from being
+ * rescheduled.
+ */
+interface BedtimeMode {
+ /** Whether the user granted Do Not Disturb access ("Special app access"). */
+ fun hasAccess(): Boolean
+
+ /** Whether the mode can carry screen effects (Android 15 and later). */
+ val supportsEffects: Boolean
+
+ /**
+ * Creates the rule when it is missing. [refresh] also rewrites an existing
+ * one from [settings]; otherwise an existing rule is left exactly as it is.
+ */
+ fun install(settings: BedtimeSettings, refresh: Boolean)
+
+ /** Turns the mode on or off. Does nothing when there is no rule. */
+ fun setActive(active: Boolean)
+
+ /** Removes the rule, which also ends it if it is on. */
+ fun uninstall()
+
+ /** The rule's id, for a deep link into the system's settings; null when there is none. */
+ fun ruleId(): String?
+}
+
+/** The reminder notification. */
+interface BedtimeNotifier {
+ fun showReminder(start: Instant, minutesBefore: Int)
+
+ fun cancelReminder()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt
new file mode 100644
index 0000000..ce71253
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt
@@ -0,0 +1,168 @@
+package de.jeanlucmakiola.clockula.bedtime.android
+
+import android.app.AutomaticZenRule
+import android.app.NotificationManager
+import android.content.ComponentName
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.service.notification.Condition
+import android.service.notification.ZenDeviceEffects
+import android.service.notification.ZenPolicy
+import androidx.annotation.RequiresApi
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.clockula.MainActivity
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
+import de.jeanlucmakiola.clockula.domain.TimeOfDay
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+import java.time.LocalTime
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Clockula's one automatic zen rule. On Android 15 and later it is a mode in
+ * Settings › Modes, typed as a time schedule, under Clockula's name and icon,
+ * carrying the screen effects the user picked. On Android 10 to 14 it is a
+ * plain Do Not Disturb schedule rule.
+ *
+ * Digital Wellbeing's own Bedtime mode is off limits: the platform refuses
+ * `TYPE_BEDTIME` from any package but the system's wellbeing app, and no app
+ * can read its schedule. This is a separate mode next to it.
+ *
+ * **Alarms always get through.** The rule's [ZenPolicy] allows alarms (and
+ * media, for a sleep sound) and leaves everything else to the device's own Do
+ * Not Disturb defaults. A rule needs an owner service or a configuration
+ * activity; [MainActivity] is the latter, and it opens the bedtime screen when
+ * Settings sends `ACTION_AUTOMATIC_ZEN_RULE`. It is already exported, so no new
+ * component is exposed for this.
+ *
+ * Nothing is stored about the rule. [NotificationManager.getAutomaticZenRules]
+ * lists only this app's own rules, and the condition id tells Clockula's apart,
+ * so a reinstall, a cleared app or a restore onto another device finds or
+ * recreates it without a stale id. Every call is guarded: a revoked access or
+ * an OEM quirk costs the mode, never the caller.
+ */
+@Singleton
+class AndroidBedtimeMode @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+) : BedtimeMode {
+
+ private val manager: NotificationManager = context.getSystemService(NotificationManager::class.java)
+
+ private val conditionId: Uri = Uri.Builder()
+ .scheme(Condition.SCHEME)
+ .authority(context.packageName)
+ .appendPath("bedtime")
+ .build()
+
+ override val supportsEffects: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
+
+ override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted }
+
+ override fun install(settings: BedtimeSettings, refresh: Boolean) {
+ guarded(Unit) {
+ val ours = ownRules()
+ // Only ever one. A second (two passes racing on another process,
+ // say) is removed rather than left to fight the first.
+ ours.keys.drop(1).forEach(manager::removeAutomaticZenRule)
+ val existing = ours.entries.firstOrNull()
+ if (existing == null) {
+ manager.addAutomaticZenRule(rule(settings, enabled = true))
+ } else if (refresh) {
+ // Keeps the user's own on/off switch for the rule in Settings: a
+ // change on Clockula's screen must not re-enable a rule they disabled.
+ manager.updateAutomaticZenRule(existing.key, rule(settings, enabled = existing.value.isEnabled))
+ }
+ }
+ }
+
+ override fun setActive(active: Boolean) {
+ guarded(Unit) {
+ val id = ownRules().keys.firstOrNull() ?: return@guarded
+ val state = if (active) Condition.STATE_TRUE else Condition.STATE_FALSE
+ val summary = context.getString(R.string.bedtime_mode_name)
+ val condition = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ // A schedule's own transition, so Android 15 keeps a user's manual
+ // override of the mode until the schedule's next one.
+ Condition(conditionId, summary, state, Condition.SOURCE_SCHEDULE)
+ } else {
+ Condition(conditionId, summary, state)
+ }
+ manager.setAutomaticZenRuleState(id, condition)
+ }
+ }
+
+ override fun uninstall() {
+ guarded(Unit) {
+ ownRules().keys.forEach(manager::removeAutomaticZenRule)
+ }
+ }
+
+ override fun ruleId(): String? = guarded(null) { ownRules().keys.firstOrNull() }
+
+ private fun ownRules(): Map =
+ manager.automaticZenRules.filterValues { it.conditionId == conditionId }
+
+ private fun rule(settings: BedtimeSettings, enabled: Boolean): AutomaticZenRule {
+ val name = context.getString(R.string.bedtime_mode_name)
+ val configuration = ComponentName(context, MainActivity::class.java)
+ val policy = ZenPolicy.Builder()
+ .allowAlarms(true)
+ .allowMedia(true)
+ .build()
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ modeRule(name, configuration, policy, settings, enabled)
+ } else {
+ AutomaticZenRule(
+ name,
+ null,
+ configuration,
+ conditionId,
+ policy,
+ NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+ enabled,
+ )
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+ private fun modeRule(
+ name: String,
+ configuration: ComponentName,
+ policy: ZenPolicy,
+ settings: BedtimeSettings,
+ enabled: Boolean,
+ ): AutomaticZenRule = AutomaticZenRule.Builder(name, conditionId)
+ .setType(AutomaticZenRule.TYPE_SCHEDULE_TIME)
+ .setConfigurationActivity(configuration)
+ .setZenPolicy(policy)
+ .setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
+ .setDeviceEffects(
+ ZenDeviceEffects.Builder()
+ .setShouldDisplayGrayscale(settings.effects.grayscale)
+ .setShouldDimWallpaper(settings.effects.dimWallpaper)
+ .setShouldUseNightMode(settings.effects.darkTheme)
+ .setShouldSuppressAmbientDisplay(settings.effects.hideAlwaysOnDisplay)
+ .build(),
+ )
+ .setTriggerDescription(
+ context.getString(
+ R.string.bedtime_mode_trigger,
+ formatTime(settings.bedtime),
+ formatTime(settings.wakeTime),
+ ),
+ )
+ .setIconResId(R.drawable.ic_bedtime)
+ .setManualInvocationAllowed(true)
+ .setEnabled(enabled)
+ .build()
+
+ private fun formatTime(time: TimeOfDay): String = formatBedtimeTime(context, LocalTime.of(time.hour, time.minute))
+
+ private inline fun guarded(fallback: T, block: () -> T): T = try {
+ block()
+ } catch (_: RuntimeException) {
+ fallback
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt
new file mode 100644
index 0000000..00dbcf4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt
@@ -0,0 +1,88 @@
+package de.jeanlucmakiola.clockula.bedtime.android
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.clockula.MainActivity
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
+import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
+import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
+import kotlin.time.Instant
+import kotlin.time.toJavaInstant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The "bedtime soon" reminder, on a channel of its own so it can be silenced
+ * without touching anything an alarm or timer posts. Denied the notification
+ * permission, the user loses the reminder and nothing else.
+ */
+@Singleton
+class AndroidBedtimeNotifier @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+ private val zones: ZoneProvider,
+) : BedtimeNotifier {
+
+ private val notifications = NotificationManagerCompat.from(context)
+
+ override fun showReminder(start: Instant, minutesBefore: Int) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+ val time = formatBedtimeTime(context, start.toJavaInstant().atZone(zones.current()).toLocalTime())
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_bedtime)
+ .setContentTitle(
+ context.resources.getQuantityString(R.plurals.bedtime_reminder_title, minutesBefore, minutesBefore),
+ )
+ .setContentText(context.getString(R.string.bedtime_reminder_text, time))
+ .setCategory(NotificationCompat.CATEGORY_REMINDER)
+ .setAutoCancel(true)
+ .setContentIntent(showPendingIntent())
+ .build()
+ runCatching { notifications.notify(NOTIFICATION_ID, notification) }
+ }
+
+ override fun cancelReminder() = notifications.cancel(NOTIFICATION_ID)
+
+ private fun showPendingIntent(): PendingIntent = PendingIntent.getActivity(
+ context,
+ BedtimeIntents.REQUEST_SHOW,
+ Intent(context, MainActivity::class.java)
+ .setAction(BedtimeIntents.ACTION_SHOW_BEDTIME)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ companion object {
+ const val CHANNEL_ID: String = "bedtime_reminder"
+
+ /** Next to the other fixed ids (1_001..1_004), below the snoozed alarms' 2_000 + id. */
+ private const val NOTIFICATION_ID: Int = 1_005
+
+ /** Idempotent; called from the app class on every start, like the other channels. */
+ fun createChannel(context: Context) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.channel_bedtime_name),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = context.getString(R.string.channel_bedtime_description)
+ }
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt
new file mode 100644
index 0000000..11ba260
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt
@@ -0,0 +1,48 @@
+package de.jeanlucmakiola.clockula.bedtime.android
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
+import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
+import de.jeanlucmakiola.clockula.bedtime.receiver.BedtimeReceiver
+import kotlin.time.Instant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * One wall-clock slot for the schedule's next event. Never `setAlarmClock`:
+ * that slot is the status bar's "next alarm", and bedtime is not an alarm.
+ * Exact while idle where the platform allows it, the same two branches the
+ * timer's slot uses: a wake-up time that ends the mode an hour late, after a
+ * doze maintenance window, is the failure this avoids. It is its own
+ * PendingIntent (its own receiver, action and request code), so arming or
+ * cancelling it can never touch an alarm's or a timer's registration.
+ */
+@Singleton
+class AndroidBedtimeScheduler @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+) : BedtimeScheduler {
+
+ private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java)
+
+ override fun schedule(at: Instant) {
+ val millis = at.toEpochMilliseconds()
+ val pendingIntent = slotPendingIntent()
+ val exact = runCatching {
+ manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
+ }.isSuccess
+ if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
+ }
+
+ override fun cancel() = manager.cancel(slotPendingIntent())
+
+ private fun slotPendingIntent(): PendingIntent = PendingIntent.getBroadcast(
+ context,
+ BedtimeIntents.REQUEST_SLOT,
+ Intent(context, BedtimeReceiver::class.java).setAction(BedtimeIntents.ACTION_SLOT),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt
new file mode 100644
index 0000000..4504780
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt
@@ -0,0 +1,22 @@
+package de.jeanlucmakiola.clockula.bedtime.android
+
+import android.content.Context
+import android.text.format.DateFormat
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+/**
+ * The same two steps `ui/common/AlarmTimeFormatter.kt` takes — the device's
+ * 12/24-hour choice, then the locale's short form — for the two bedtime
+ * strings the system shows outside the app: the mode's trigger line and the
+ * reminder notification.
+ */
+internal fun formatBedtimeTime(context: Context, time: LocalTime): String {
+ val formatter = if (DateFormat.is24HourFormat(context)) {
+ DateTimeFormatter.ofPattern("HH:mm")
+ } else {
+ DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
+ }
+ return formatter.format(time)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt
new file mode 100644
index 0000000..7e0c0d6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt
@@ -0,0 +1,31 @@
+package de.jeanlucmakiola.clockula.bedtime.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
+import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
+import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
+import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeMode
+import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
+import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeScheduler
+import javax.inject.Singleton
+
+/** The three seams `BedtimeEngine` sees, which keep it JVM-testable. */
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class BedtimeModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindBedtimeScheduler(impl: AndroidBedtimeScheduler): BedtimeScheduler
+
+ @Binds
+ @Singleton
+ abstract fun bindBedtimeMode(impl: AndroidBedtimeMode): BedtimeMode
+
+ @Binds
+ @Singleton
+ abstract fun bindBedtimeNotifier(impl: AndroidBedtimeNotifier): BedtimeNotifier
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt
new file mode 100644
index 0000000..2d6b448
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt
@@ -0,0 +1,49 @@
+package de.jeanlucmakiola.clockula.bedtime.receiver
+
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
+import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
+import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
+import de.jeanlucmakiola.floret.di.ApplicationScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * The bedtime slot arriving, and Do Not Disturb access changing. The second
+ * is sent by the system to this package only, so an unexported receiver gets
+ * it: granting access creates the rule at once, and revoking it leaves the
+ * slot running for the reminder (Settings removes the rule itself when the
+ * user revokes access there).
+ */
+@AndroidEntryPoint
+class BedtimeReceiver : HiltBroadcastReceiver() {
+
+ @Inject
+ lateinit var engine: BedtimeEngine
+
+ @Inject
+ @ApplicationScope
+ lateinit var scope: CoroutineScope
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+
+ val action = intent.action ?: return
+ val pendingResult = goAsync()
+ scope.launch {
+ try {
+ when (action) {
+ BedtimeIntents.ACTION_SLOT -> engine.onSlotFired()
+ NotificationManager.ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED ->
+ engine.resync(refreshRule = true)
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
index c6daf96..3fd7069 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
@@ -11,6 +11,9 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
@@ -128,8 +131,44 @@ object BackupCodec {
dismissChallenge = defaults.dismissChallenge.name,
homeZoneId = defaults.homeZoneId,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
+ bedtime = bedtime.toDto(),
)
+ private fun BedtimeSettings.toDto() = BedtimeDto(
+ enabled = enabled,
+ bedtimeMinutes = bedtime.minutesOfDay,
+ wakeMinutes = wakeTime.minutesOfDay,
+ days = days.mask,
+ reminderMinutes = reminderMinutes,
+ grayscale = effects.grayscale,
+ dimWallpaper = effects.dimWallpaper,
+ darkTheme = effects.darkTheme,
+ hideAlwaysOnDisplay = effects.hideAlwaysOnDisplay,
+ )
+
+ /**
+ * Forgiving, like the other settings: an out-of-range time or reminder is
+ * clamped rather than failing the import.
+ */
+ private fun BedtimeDto.toBedtime() = BedtimeSettings(
+ enabled = enabled,
+ bedtime = minutesToTime(bedtimeMinutes),
+ wakeTime = minutesToTime(wakeMinutes),
+ days = RepeatDays.fromMask(days),
+ reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES),
+ effects = BedtimeEffects(
+ grayscale = grayscale,
+ dimWallpaper = dimWallpaper,
+ darkTheme = darkTheme,
+ hideAlwaysOnDisplay = hideAlwaysOnDisplay,
+ ),
+ )
+
+ private fun minutesToTime(minutes: Int): TimeOfDay {
+ val clamped = minutes.coerceIn(0, 24 * 60 - 1)
+ return TimeOfDay(clamped / 60, clamped % 60)
+ }
+
// --- decode + validate ---
private fun BackupDocument.toContent(): BackupResult {
@@ -235,6 +274,7 @@ object BackupCodec {
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
),
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
+ bedtime = settingsDto.bedtime?.toBedtime() ?: BedtimeSettings(),
)
return BackupResult.Success(
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
index 118e99e..b225f4a 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
@@ -77,4 +77,23 @@ data class SettingsDto(
* built-in ones.
*/
@SerialName("timer_presets") val timerPresets: List? = null,
+ /** Absent from files written before issue #1; restores as "off" with the default schedule. */
+ val bedtime: BedtimeDto? = null,
+)
+
+/**
+ * The bedtime schedule (issue #1). Times are minutes since midnight, like the
+ * `BedtimePrefs` keys; `days` is the same 7-bit mask an alarm's `repeat_days` is.
+ */
+@Serializable
+data class BedtimeDto(
+ val enabled: Boolean = false,
+ @SerialName("bedtime_minutes") val bedtimeMinutes: Int = 22 * 60 + 30,
+ @SerialName("wake_minutes") val wakeMinutes: Int = 7 * 60,
+ val days: Int = 0b111_1111,
+ @SerialName("reminder_minutes") val reminderMinutes: Int = 0,
+ val grayscale: Boolean = true,
+ @SerialName("dim_wallpaper") val dimWallpaper: Boolean = true,
+ @SerialName("dark_theme") val darkTheme: Boolean = true,
+ @SerialName("hide_always_on_display") val hideAlwaysOnDisplay: Boolean = false,
)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt
new file mode 100644
index 0000000..ab1704c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt
@@ -0,0 +1,56 @@
+package de.jeanlucmakiola.clockula.data.prefs
+
+import de.jeanlucmakiola.clockula.domain.RepeatDays
+import de.jeanlucmakiola.clockula.domain.TimeOfDay
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+import de.jeanlucmakiola.floret.prefs.Pref
+import de.jeanlucmakiola.floret.prefs.booleanPref
+import de.jeanlucmakiola.floret.prefs.intPref
+
+/**
+ * The bedtime schedule's on-disk keys (issue #1). Configuration only: the
+ * id of the system mode Clockula created is not stored at all, because the
+ * platform already lists an app's own rules (`AndroidBedtimeMode`), and a
+ * stored id would go stale after a restore onto another device.
+ */
+object BedtimePrefs {
+
+ private val DEFAULT = BedtimeSettings()
+
+ val enabled: Pref = booleanPref("bedtime_enabled", DEFAULT.enabled)
+
+ val bedtime: Pref = intPref("bedtime_start_minutes", DEFAULT.bedtime.minutesOfDay).asTimeOfDay()
+
+ val wakeTime: Pref = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay()
+
+ /** The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set. */
+ val days: Pref = intPref("bedtime_days", DEFAULT.days.mask)
+ .map(decode = RepeatDays::fromMask, encode = { it.mask })
+
+ val reminderMinutes: Pref = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes)
+ .map(
+ decode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
+ encode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
+ )
+
+ val grayscale: Pref = booleanPref("bedtime_effect_grayscale", DEFAULT.effects.grayscale)
+
+ val dimWallpaper: Pref = booleanPref("bedtime_effect_dim_wallpaper", DEFAULT.effects.dimWallpaper)
+
+ val darkTheme: Pref = booleanPref("bedtime_effect_dark_theme", DEFAULT.effects.darkTheme)
+
+ val hideAlwaysOnDisplay: Pref =
+ booleanPref("bedtime_effect_hide_aod", DEFAULT.effects.hideAlwaysOnDisplay)
+
+ /** Minutes since midnight on disk, clamped into a day both ways. */
+ private fun Pref.asTimeOfDay(): Pref = map(
+ decode = { minutes ->
+ val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
+ TimeOfDay(clamped / 60, clamped % 60)
+ },
+ encode = { it.minutesOfDay },
+ )
+
+ private const val MINUTES_PER_DAY = 24 * 60
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
index b76785b..b802eca 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
@@ -1,8 +1,11 @@
package de.jeanlucmakiola.clockula.data.prefs
+import androidx.datastore.preferences.core.MutablePreferences
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
@@ -93,6 +96,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
prefs[TimerPrefs.presets] = backup.timerPresets
+ prefs.putBedtime(backup.bedtime)
}
}
@@ -133,6 +137,59 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
}
+ // --- bedtime (issue #1) ---
+
+ /** Distinct-until-changed, composed from the individual keys like [defaults]. */
+ val bedtime: Flow = combine(
+ combine(
+ store.flow(BedtimePrefs.enabled),
+ store.flow(BedtimePrefs.bedtime),
+ store.flow(BedtimePrefs.wakeTime),
+ store.flow(BedtimePrefs.days),
+ store.flow(BedtimePrefs.reminderMinutes),
+ ) { enabled, bedtime, wakeTime, days, reminderMinutes ->
+ BedtimeSettings(
+ enabled = enabled,
+ bedtime = bedtime,
+ wakeTime = wakeTime,
+ days = days,
+ reminderMinutes = reminderMinutes,
+ )
+ },
+ store.flow(BedtimePrefs.grayscale),
+ store.flow(BedtimePrefs.dimWallpaper),
+ store.flow(BedtimePrefs.darkTheme),
+ store.flow(BedtimePrefs.hideAlwaysOnDisplay),
+ ) { partial, grayscale, dimWallpaper, darkTheme, hideAlwaysOnDisplay ->
+ partial.copy(
+ effects = BedtimeEffects(
+ grayscale = grayscale,
+ dimWallpaper = dimWallpaper,
+ darkTheme = darkTheme,
+ hideAlwaysOnDisplay = hideAlwaysOnDisplay,
+ ),
+ )
+ }.distinctUntilChanged()
+
+ suspend fun currentBedtime(): BedtimeSettings = bedtime.first()
+
+ /** Every bedtime key in one transaction. `BedtimeEngine` is the only writer. */
+ suspend fun setBedtime(settings: BedtimeSettings) {
+ store.edit { prefs -> prefs.putBedtime(settings) }
+ }
+
+ private fun MutablePreferences.putBedtime(settings: BedtimeSettings) {
+ this[BedtimePrefs.enabled] = settings.enabled
+ this[BedtimePrefs.bedtime] = settings.bedtime
+ this[BedtimePrefs.wakeTime] = settings.wakeTime
+ this[BedtimePrefs.days] = settings.days
+ this[BedtimePrefs.reminderMinutes] = settings.reminderMinutes
+ this[BedtimePrefs.grayscale] = settings.effects.grayscale
+ this[BedtimePrefs.dimWallpaper] = settings.effects.dimWallpaper
+ this[BedtimePrefs.darkTheme] = settings.effects.darkTheme
+ this[BedtimePrefs.hideAlwaysOnDisplay] = settings.effects.hideAlwaysOnDisplay
+ }
+
// --- first-launch bookkeeping (M4) ---
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
index eac440d..e20b821 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlin.time.Duration
import kotlin.time.Instant
@@ -56,4 +57,6 @@ data class BackupSettings(
val dynamicColor: Boolean,
val defaults: ClockDefaults,
val timerPresets: List,
+ /** Absent from older backups, which restore it as [BedtimeSettings]' defaults (off). */
+ val bedtime: BedtimeSettings = BedtimeSettings(),
)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt
new file mode 100644
index 0000000..09e5b67
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt
@@ -0,0 +1,89 @@
+package de.jeanlucmakiola.clockula.domain.bedtime
+
+import de.jeanlucmakiola.clockula.domain.alarm.AlarmOccurrences
+import java.time.LocalDate
+import java.time.ZoneId
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+import kotlin.time.toJavaInstant
+
+/** One night of bedtime: on from [start] (inclusive) to [end] (exclusive). */
+data class BedtimeWindow(val start: Instant, val end: Instant) {
+ operator fun contains(instant: Instant): Boolean = instant >= start && instant < end
+}
+
+/**
+ * Where a bedtime schedule stands at a given instant. Like the alarm path,
+ * every window is re-resolved from local times and the zone, never by adding
+ * a fixed length to the last one, so a DST night is as long as the clock
+ * says it is.
+ */
+object BedtimeSchedule {
+
+ /**
+ * The window that starts on [date], if [date] is one of the schedule's
+ * nights. It ends at the first wake time after it starts, so a wake time
+ * at or before the bedtime means the next morning, and an equal one a
+ * full day.
+ */
+ fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? {
+ if (date.dayOfWeek !in settings.days) return null
+ val start = AlarmOccurrences.at(date, settings.bedtime, zone)
+ val sameDayEnd = AlarmOccurrences.at(date, settings.wakeTime, zone)
+ val end = if (sameDayEnd > start) sameDayEnd else AlarmOccurrences.at(date.plusDays(1), settings.wakeTime, zone)
+ return BedtimeWindow(start, end)
+ }
+
+ /** The window [now] falls in, or null when it is daytime or the schedule is off. */
+ fun activeWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
+ if (!settings.enabled) return null
+ val today = today(now, zone)
+ // A window is at most a day long, so one that contains now started
+ // today or the day before.
+ return listOf(today, today.minusDays(1))
+ .firstNotNullOfOrNull { date -> windowOn(settings, date, zone)?.takeIf { now in it } }
+ }
+
+ /** The first window that starts strictly after [now], within the coming week. */
+ fun nextWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
+ if (!settings.enabled) return null
+ val today = today(now, zone)
+ return (0L..7L)
+ .firstNotNullOfOrNull { offset ->
+ windowOn(settings, today.plusDays(offset), zone)?.takeIf { it.start > now }
+ }
+ }
+
+ /**
+ * The upcoming window whose reminder is due at [now]: [now] lies between
+ * its reminder time and its start. Null when reminders are off, or when
+ * [now] is already inside a window (the reminder has nothing left to say).
+ */
+ fun dueReminder(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
+ if (!settings.enabled || settings.reminderMinutes <= 0) return null
+ if (activeWindow(settings, now, zone) != null) return null
+ val next = nextWindow(settings, now, zone) ?: return null
+ return next.takeIf { now >= reminderAt(settings, next) }
+ }
+
+ /**
+ * When the schedule next needs attention: the end of the current window,
+ * the next window's reminder or the next window's start, whichever comes
+ * first. Null when there is nothing to schedule.
+ */
+ fun nextEvent(settings: BedtimeSettings, now: Instant, zone: ZoneId): Instant? {
+ if (!settings.enabled) return null
+ val active = activeWindow(settings, now, zone)
+ val next = nextWindow(settings, now, zone)
+ val reminder = next
+ ?.takeIf { settings.reminderMinutes > 0 }
+ ?.let { reminderAt(settings, it) }
+ ?.takeIf { it > now }
+ return listOfNotNull(active?.end, reminder, next?.start).minOrNull()
+ }
+
+ private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant =
+ window.start - settings.reminderMinutes.minutes
+
+ private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt
new file mode 100644
index 0000000..f8b0e82
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt
@@ -0,0 +1,39 @@
+package de.jeanlucmakiola.clockula.domain.bedtime
+
+import de.jeanlucmakiola.clockula.domain.RepeatDays
+import de.jeanlucmakiola.clockula.domain.TimeOfDay
+
+/**
+ * The bedtime schedule (issue #1): an opt-in Do Not Disturb mode Clockula
+ * turns on at [bedtime] and off at [wakeTime]. [days] are the nights the
+ * mode *starts* on, so a Friday-only schedule runs from Friday evening into
+ * Saturday morning.
+ */
+data class BedtimeSettings(
+ val enabled: Boolean = false,
+ val bedtime: TimeOfDay = TimeOfDay(22, 30),
+ val wakeTime: TimeOfDay = TimeOfDay(7, 0),
+ val days: RepeatDays = RepeatDays.EVERY_DAY,
+ /** How long before [bedtime] to post a reminder; 0 is off. */
+ val reminderMinutes: Int = 0,
+ val effects: BedtimeEffects = BedtimeEffects(),
+)
+
+/**
+ * The screen effects a mode can carry on Android 15 and later
+ * (`ZenDeviceEffects`). Earlier versions keep the setting and ignore it.
+ */
+data class BedtimeEffects(
+ val grayscale: Boolean = true,
+ val dimWallpaper: Boolean = true,
+ val darkTheme: Boolean = true,
+ val hideAlwaysOnDisplay: Boolean = false,
+)
+
+object BedtimeLimits {
+ /** The reminder lead times the picker offers, in minutes; 0 is off. */
+ val REMINDER_CHOICES: List = listOf(0, 15, 30, 45, 60)
+
+ /** Anything read from disk or a backup is clamped into this. */
+ val REMINDER_MINUTES: IntRange = 0..120
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt
index 0450a24..792eb50 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt
@@ -155,9 +155,9 @@ object SelfCheck {
/**
* Only total silence (`INTERRUPTION_FILTER_NONE`) is treated as blocking
* alarms: Priority and Alarms-only modes leave the system's own alarm
- * exemption in effect. Clockula never requests `ACCESS_NOTIFICATION_POLICY`
- * and never changes DND itself — it only reports what the user already has
- * (`docs/PLAN.md` §4).
+ * exemption in effect. The check never changes DND itself — it only
+ * reports what the user already has (`docs/PLAN.md` §4). The opt-in
+ * bedtime mode does change it, with a rule that always allows alarms.
*/
private fun dnd(state: DeviceState): CheckResult {
val blocked = state.interruptionFilter == INTERRUPTION_FILTER_NONE
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt
new file mode 100644
index 0000000..9102513
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt
@@ -0,0 +1,265 @@
+package de.jeanlucmakiola.clockula.ui.bedtime
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.provider.Settings
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.DoNotDisturbOn
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
+import de.jeanlucmakiola.clockula.ui.alarms.AlarmTimePickerDialog
+import de.jeanlucmakiola.clockula.ui.alarms.RepeatDaySelector
+import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
+import de.jeanlucmakiola.clockula.ui.settings.SettingsHint
+import de.jeanlucmakiola.floret.components.CollapsingScaffold
+import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.OptionPicker
+import de.jeanlucmakiola.floret.components.Position
+
+/** Which of the screen's dialogs is open. */
+private enum class BedtimePicker { BEDTIME, WAKE_TIME, REMINDER }
+
+/**
+ * Bedtime (issue #1): an opt-in Do Not Disturb mode that Clockula turns on at
+ * bedtime and off at the wake-up time, with an optional reminder before it
+ * starts and, on Android 15 and later, screen effects. Reached from Settings
+ * rather than a fifth tab, from the reminder, and from the mode's own page in
+ * the system settings.
+ */
+@Composable
+fun BedtimeScreen(
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: BedtimeViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val formatTime = rememberAlarmTimeFormatter()
+ var picker by rememberSaveable { mutableStateOf(null) }
+
+ // Access is granted in the system's settings, so it is re-read whenever
+ // the user comes back from there.
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh()
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ val loaded = state as? BedtimeUiState.Loaded
+
+ CollapsingScaffold(
+ title = stringResource(R.string.bedtime_title),
+ onBack = onBack,
+ modifier = modifier,
+ ) {
+ if (loaded == null) return@CollapsingScaffold
+ val settings = loaded.settings
+
+ SettingsHint(stringResource(R.string.bedtime_hint))
+ Spacer(Modifier.height(8.dp))
+
+ GroupedRow(
+ title = stringResource(R.string.bedtime_enabled),
+ summary = if (settings.enabled) {
+ stringResource(R.string.bedtime_enabled_on, formatTime(settings.bedtime), formatTime(settings.wakeTime))
+ } else {
+ stringResource(R.string.bedtime_enabled_off)
+ },
+ position = Position.Alone,
+ trailing = {
+ Switch(checked = settings.enabled, onCheckedChange = viewModel::onEnabledChanged)
+ },
+ onClick = { viewModel.onEnabledChanged(!settings.enabled) },
+ )
+
+ // The onboarding step: access is a special app access the user grants
+ // by hand, so the row says what it is for and opens the right page.
+ if (!loaded.hasAccess) {
+ Spacer(Modifier.height(16.dp))
+ GroupedRow(
+ title = stringResource(R.string.bedtime_access_title),
+ summary = stringResource(R.string.bedtime_access_summary),
+ position = Position.Alone,
+ leading = {
+ Icon(
+ Icons.Rounded.DoNotDisturbOn,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.tertiary,
+ )
+ },
+ trailing = {
+ TextButton(onClick = { openAccessSettings(context) }) {
+ Text(stringResource(R.string.bedtime_access_allow))
+ }
+ },
+ onClick = { openAccessSettings(context) },
+ )
+ }
+
+ GroupedSectionHeader(stringResource(R.string.bedtime_section_schedule))
+ GroupedRow(
+ title = stringResource(R.string.bedtime_start),
+ summary = formatTime(settings.bedtime),
+ position = Position.Top,
+ onClick = { picker = BedtimePicker.BEDTIME },
+ )
+ GroupedRow(
+ title = stringResource(R.string.bedtime_wake),
+ summary = formatTime(settings.wakeTime),
+ position = Position.Middle,
+ onClick = { picker = BedtimePicker.WAKE_TIME },
+ )
+ RepeatDaySelector(
+ repeatDays = settings.days,
+ onDayToggled = viewModel::onDayToggled,
+ position = Position.Bottom,
+ )
+ SettingsHint(stringResource(R.string.bedtime_days_hint))
+
+ GroupedSectionHeader(stringResource(R.string.bedtime_section_reminder))
+ GroupedRow(
+ title = stringResource(R.string.bedtime_reminder),
+ summary = reminderText(settings.reminderMinutes),
+ position = Position.Alone,
+ onClick = { picker = BedtimePicker.REMINDER },
+ )
+
+ GroupedSectionHeader(stringResource(R.string.bedtime_section_effects))
+ if (loaded.supportsEffects) {
+ EffectRow(
+ title = stringResource(R.string.bedtime_effect_grayscale),
+ checked = settings.effects.grayscale,
+ position = Position.Top,
+ onChange = viewModel::onGrayscaleChanged,
+ )
+ EffectRow(
+ title = stringResource(R.string.bedtime_effect_dim_wallpaper),
+ checked = settings.effects.dimWallpaper,
+ position = Position.Middle,
+ onChange = viewModel::onDimWallpaperChanged,
+ )
+ EffectRow(
+ title = stringResource(R.string.bedtime_effect_dark_theme),
+ checked = settings.effects.darkTheme,
+ position = Position.Middle,
+ onChange = viewModel::onDarkThemeChanged,
+ )
+ EffectRow(
+ title = stringResource(R.string.bedtime_effect_hide_aod),
+ checked = settings.effects.hideAlwaysOnDisplay,
+ position = Position.Bottom,
+ onChange = viewModel::onHideAlwaysOnDisplayChanged,
+ )
+ val ruleId = loaded.ruleId
+ if (ruleId != null) {
+ Spacer(Modifier.height(16.dp))
+ GroupedRow(
+ title = stringResource(R.string.bedtime_open_mode),
+ summary = stringResource(R.string.bedtime_open_mode_summary),
+ position = Position.Alone,
+ onClick = { openModeSettings(context, ruleId) },
+ )
+ }
+ } else {
+ SettingsHint(stringResource(R.string.bedtime_effects_unavailable))
+ }
+
+ Spacer(Modifier.height(16.dp))
+ SettingsHint(stringResource(R.string.bedtime_alarms_note))
+ Spacer(Modifier.height(8.dp))
+ SettingsHint(stringResource(R.string.bedtime_wellbeing_note))
+ Spacer(Modifier.height(16.dp))
+ }
+
+ val settings = loaded?.settings ?: return
+ when (picker) {
+ null -> Unit
+ BedtimePicker.BEDTIME -> AlarmTimePickerDialog(
+ initial = settings.bedtime,
+ onConfirm = { hour, minute ->
+ viewModel.onBedtimeChanged(hour, minute)
+ picker = null
+ },
+ onDismiss = { picker = null },
+ )
+ BedtimePicker.WAKE_TIME -> AlarmTimePickerDialog(
+ initial = settings.wakeTime,
+ onConfirm = { hour, minute ->
+ viewModel.onWakeTimeChanged(hour, minute)
+ picker = null
+ },
+ onDismiss = { picker = null },
+ )
+ BedtimePicker.REMINDER -> OptionPicker(
+ title = stringResource(R.string.bedtime_reminder),
+ options = BedtimeLimits.REMINDER_CHOICES,
+ selected = settings.reminderMinutes,
+ label = { reminderText(it) },
+ onSelect = {
+ viewModel.onReminderChanged(it)
+ picker = null
+ },
+ onDismiss = { picker = null },
+ )
+ }
+}
+
+@Composable
+private fun EffectRow(title: String, checked: Boolean, position: Position, onChange: (Boolean) -> Unit) {
+ GroupedRow(
+ title = title,
+ position = position,
+ trailing = { Switch(checked = checked, onCheckedChange = onChange) },
+ onClick = { onChange(!checked) },
+ )
+}
+
+@Composable
+private fun reminderText(minutes: Int): String = if (minutes <= 0) {
+ stringResource(R.string.bedtime_reminder_off)
+} else {
+ pluralStringResource(R.plurals.bedtime_reminder_before, minutes, minutes)
+}
+
+/** Guarded like the self-check's fixes: a settings page a device cannot open must not crash the row. */
+private fun openAccessSettings(context: Context) {
+ runCatching { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)) }
+}
+
+/** Android 15's page for one mode; the link is only offered there. */
+private fun openModeSettings(context: Context, ruleId: String) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return
+ runCatching {
+ context.startActivity(
+ Intent(Settings.ACTION_AUTOMATIC_ZEN_RULE_SETTINGS).putExtra(Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID, ruleId),
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt
new file mode 100644
index 0000000..147afe4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt
@@ -0,0 +1,18 @@
+package de.jeanlucmakiola.clockula.ui.bedtime
+
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+
+/** The bedtime screen's state (issue #1). */
+internal sealed interface BedtimeUiState {
+ data object Loading : BedtimeUiState
+
+ data class Loaded(
+ val settings: BedtimeSettings,
+ /** Whether the user granted Do Not Disturb access; without it only the reminder works. */
+ val hasAccess: Boolean,
+ /** Android 15 and later: the mode can carry screen effects. */
+ val supportsEffects: Boolean,
+ /** The system rule's id while it exists, for the "open in Settings" link. */
+ val ruleId: String?,
+ ) : BedtimeUiState
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt
new file mode 100644
index 0000000..d05c397
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt
@@ -0,0 +1,91 @@
+package de.jeanlucmakiola.clockula.ui.bedtime
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
+import de.jeanlucmakiola.clockula.domain.TimeOfDay
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import java.time.DayOfWeek
+import javax.inject.Inject
+
+/**
+ * The bedtime screen. Every control writes at once through [BedtimeEngine],
+ * which stores the change and applies it to the slot and the Do Not Disturb
+ * rule — no Save button, as everywhere else in Settings (M5 D22).
+ *
+ * Do Not Disturb access is granted outside the app, so [refresh] re-reads it
+ * on every `ON_RESUME`, and a change found there is applied straight away:
+ * coming back from granting access creates the mode without waiting for the
+ * system's broadcast.
+ */
+@HiltViewModel
+class BedtimeViewModel @Inject internal constructor(
+ private val engine: BedtimeEngine,
+) : ViewModel() {
+
+ private val access = MutableStateFlow(engine.hasAccess())
+ private val ruleId = MutableStateFlow(engine.ruleId())
+
+ internal val state: StateFlow = combine(engine.bedtime, access, ruleId) { settings, access, ruleId ->
+ BedtimeUiState.Loaded(
+ settings = settings,
+ hasAccess = access,
+ supportsEffects = engine.supportsEffects,
+ ruleId = ruleId,
+ )
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), BedtimeUiState.Loading)
+
+ fun refresh() {
+ val granted = engine.hasAccess()
+ val changed = granted != access.value
+ access.value = granted
+ viewModelScope.launch {
+ if (changed) engine.resync(refreshRule = true)
+ ruleId.value = engine.ruleId()
+ }
+ }
+
+ fun onEnabledChanged(enabled: Boolean) = update { it.copy(enabled = enabled) }
+
+ fun onBedtimeChanged(hour: Int, minute: Int) = update { it.copy(bedtime = TimeOfDay(hour, minute)) }
+
+ fun onWakeTimeChanged(hour: Int, minute: Int) = update { it.copy(wakeTime = TimeOfDay(hour, minute)) }
+
+ /** The last night cannot be switched off: a schedule with no nights would never start. */
+ fun onDayToggled(day: DayOfWeek) = update { current ->
+ val days = if (day in current.days) current.days - day else current.days + day
+ if (days.isRepeating) current.copy(days = days) else current
+ }
+
+ fun onReminderChanged(minutes: Int) = update { it.copy(reminderMinutes = minutes) }
+
+ fun onGrayscaleChanged(on: Boolean) = updateEffects { it.copy(grayscale = on) }
+
+ fun onDimWallpaperChanged(on: Boolean) = updateEffects { it.copy(dimWallpaper = on) }
+
+ fun onDarkThemeChanged(on: Boolean) = updateEffects { it.copy(darkTheme = on) }
+
+ fun onHideAlwaysOnDisplayChanged(on: Boolean) = updateEffects { it.copy(hideAlwaysOnDisplay = on) }
+
+ private fun updateEffects(transform: (BedtimeEffects) -> BedtimeEffects) =
+ update { it.copy(effects = transform(it.effects)) }
+
+ private fun update(transform: (BedtimeSettings) -> BedtimeSettings) {
+ viewModelScope.launch {
+ engine.update(transform)
+ ruleId.value = engine.ruleId()
+ }
+ }
+
+ private companion object {
+ const val STOP_TIMEOUT_MILLIS = 5_000L
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt
index ce364a0..1aad5f4 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt
@@ -1,8 +1,9 @@
package de.jeanlucmakiola.clockula.ui.settings
-/** Settings hub and its two sub-screens. No arguments: each is a singleton page (M10). */
+/** Settings hub and its three sub-screens. No arguments: each is a singleton page (M10). */
internal object SettingsRoutes {
const val MAIN: String = "settings"
const val SELF_CHECK: String = "settings/self_check"
const val BACKUP: String = "settings/backup"
+ const val BEDTIME: String = "settings/bedtime"
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt
index eaeda0d..8ea54a1 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Alarm
import androidx.compose.material.icons.filled.Backup
+import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
@@ -83,6 +84,7 @@ fun SettingsScreen(
onBack: () -> Unit,
onOpenSelfCheck: () -> Unit,
onOpenBackup: () -> Unit,
+ onOpenBedtime: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(),
) {
@@ -127,6 +129,7 @@ fun SettingsScreen(
onOpenSection = { section = it },
onOpenSelfCheck = onOpenSelfCheck,
onOpenBackup = onOpenBackup,
+ onOpenBedtime = onOpenBedtime,
)
if (loaded != null) {
@@ -183,8 +186,8 @@ private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
// ---------------------------------------------------------------------------
/**
- * Four named groups of category rows, after Calendula's hub: look & behaviour,
- * data, app, and a foot of reference links. Source, licence and privacy sit in that
+ * Five named groups of category rows, after Calendula's hub: look & behaviour,
+ * sleep, data, app, and a foot of reference links. Source, licence and privacy sit in that
* last group rather than as buttons inside the About card — they are reference
* material, and the card is the app's identity.
*/
@@ -195,6 +198,7 @@ private fun SettingsHub(
onOpenSection: (SettingsSection) -> Unit,
onOpenSelfCheck: () -> Unit,
onOpenBackup: () -> Unit,
+ onOpenBedtime: () -> Unit,
) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
// Card and support row are one grouped block, so the call to action
@@ -235,6 +239,18 @@ private fun SettingsHub(
onClick = { onOpenSection(SettingsSection.Clock) },
)
+ // Opt-in and its own destination (issue #1): a Do Not Disturb mode,
+ // not a default every alarm inherits, so not one of the rows above.
+ Spacer(Modifier.height(8.dp))
+ GroupedSectionHeader(stringResource(R.string.settings_group_sleep))
+ GroupedRow(
+ title = stringResource(R.string.bedtime_title),
+ summary = stringResource(R.string.settings_bedtime_summary),
+ position = Position.Alone,
+ leading = { CategoryIcon(Icons.Default.Bedtime, ChipAccent.Tertiary) },
+ onClick = onOpenBedtime,
+ )
+
Spacer(Modifier.height(8.dp))
GroupedSectionHeader(stringResource(R.string.settings_group_data))
GroupedRow(
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt
index 71a4595..713674a 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt
@@ -42,6 +42,7 @@ import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
+import de.jeanlucmakiola.clockula.ui.bedtime.BedtimeScreen
import de.jeanlucmakiola.clockula.ui.settings.BackupScreen
import de.jeanlucmakiola.clockula.ui.settings.SelfCheckScreen
import de.jeanlucmakiola.clockula.ui.settings.SettingsRoutes
@@ -158,6 +159,12 @@ fun ClockulaShell(
select(ShellNavigation.onTabRequested(route, ClockulaDestination.ALARMS))
dismissCandidates = request.alarmIds.toLongArray()
}
+ ShellRequest.OpenBedtime -> {
+ // Over whichever tab is showing, after popping anything nested,
+ // so back from it returns to the tabs.
+ select(ShellNavigation.onTabRequested(route, selected))
+ navController.navigate(SettingsRoutes.BEDTIME)
+ }
}
onRequestConsumed()
}
@@ -255,6 +262,7 @@ fun ClockulaShell(
onBack = { navController.popBackStack() },
onOpenSelfCheck = { navController.navigate(SettingsRoutes.SELF_CHECK) },
onOpenBackup = { navController.navigate(SettingsRoutes.BACKUP) },
+ onOpenBedtime = { navController.navigate(SettingsRoutes.BEDTIME) },
)
}
composable(SettingsRoutes.SELF_CHECK) {
@@ -263,6 +271,9 @@ fun ClockulaShell(
composable(SettingsRoutes.BACKUP) {
BackupScreen(onBack = { navController.popBackStack() })
}
+ composable(SettingsRoutes.BEDTIME) {
+ BedtimeScreen(onBack = { navController.popBackStack() })
+ }
}
}
// In the pill's own band above the navigation component, not in the
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt
index aac52fc..b0bb5bd 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
+import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents
@@ -31,6 +32,9 @@ sealed interface ShellRequest {
data class OpenAlarmEditor(val alarmId: Long) : ShellRequest
data object ComposeTimer : ShellRequest
data class ChooseAlarmToDismiss(val alarmIds: List) : ShellRequest
+
+ /** The bedtime screen, from its reminder or from the mode's page in Settings (issue #1). */
+ data object OpenBedtime : ShellRequest
}
sealed interface ShellBackAction {
@@ -156,6 +160,9 @@ object ShellNavigation {
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
}
InteropIntents.ACTION_COMPOSE_TIMER -> ShellRequest.ComposeTimer
+ BedtimeIntents.ACTION_SHOW_BEDTIME,
+ BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE,
+ -> ShellRequest.OpenBedtime
InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS ->
if (alarmIds.isEmpty()) {
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
diff --git a/app/src/main/res/drawable/ic_bedtime.xml b/app/src/main/res/drawable/ic_bedtime.xml
new file mode 100644
index 0000000..091d0fd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bedtime.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ad0a66c..06d57cb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -439,4 +439,46 @@
Custom…
Custom duration
Between 1 second and 59:59
+
+
+ Sleep
+ A Do Not Disturb mode for the night
+ Bedtime
+ Clockula turns on a Do Not Disturb mode of its own at bedtime and turns it off when you wake up. Alarms always ring.
+ Bedtime mode
+ On, %1$s to %2$s
+ Off
+ Allow Do Not Disturb access
+ Clockula needs it to turn its bedtime mode on and off. Find Clockula in the list and allow it. Without it, only the reminder works.
+ Allow
+ Schedule
+ Bedtime
+ Wake up
+ The nights bedtime starts on. A wake-up time before the bedtime is the next morning.
+ Reminder
+ Remind me before bedtime
+ Off
+
+ - %1$d minute before
+ - %1$d minutes before
+
+ Screen
+ Grayscale
+ Dim the wallpaper
+ Dark theme
+ Hide the always-on display
+ Screen effects need Android 15 or later. On this phone, bedtime silences notifications and calls the way Do Not Disturb does.
+ Open in system settings
+ Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept.
+ Bedtime never silences an alarm or a timer: its mode always lets alarms through.
+ This is not Digital Wellbeing\'s Bedtime mode. Android lets only the system turn that one on, so if you use it too you will see two modes in Settings › Modes. Use one of them.
+ Clockula bedtime
+ %1$s to %2$s
+ Bedtime reminders
+ A reminder shortly before bedtime starts.
+
+ - Bedtime in %1$d minute
+ - Bedtime in %1$d minutes
+
+ Bedtime mode starts at %1$s.
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt
index d22312d..1ae6a3e 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt
@@ -140,6 +140,7 @@ class ArchitectureRulesTest {
path.endsWith("de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt") ||
path.endsWith("de/jeanlucmakiola/clockula/timer/TimerEngine.kt") ||
path.endsWith("de/jeanlucmakiola/clockula/stopwatch/StopwatchEngine.kt") ||
+ path.endsWith("de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt") ||
path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmResolver.kt") ||
path.contains("de/jeanlucmakiola/clockula/system/")
}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
index fc5bea3..3acd004 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
@@ -231,6 +231,10 @@ class ManifestRulesTest {
"android.permission.FOREGROUND_SERVICE_SPECIAL_USE",
"android.permission.WAKE_LOCK",
"android.permission.VIBRATE",
+ // Issue #1's opt-in bedtime mode. Install-time and harmless on its
+ // own: it only lists Clockula under Do Not Disturb access, which
+ // the user still has to grant by hand.
+ "android.permission.ACCESS_NOTIFICATION_POLICY",
)
}
}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt
index f572b7d..43b7261 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt
@@ -70,6 +70,29 @@ class BackupEngineTest {
assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York")
}
+ @Test
+ fun `the bedtime schedule round-trips and is applied on import`(@TempDir tempDir: Path) = runTest {
+ val source = backupHarness(tempDir.resolve("bedtimeSource").also { it.toFile().mkdirs() })
+ source.bedtimeEngine.update {
+ it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0), reminderMinutes = 15)
+ }
+
+ val exported = source.backupEngine.export()
+
+ val target = backupHarness(tempDir.resolve("bedtimeTarget").also { it.toFile().mkdirs() })
+ target.backupEngine.import(exported)
+
+ val restored = target.settings.currentBedtime()
+ assertThat(restored.enabled).isTrue()
+ assertThat(restored.bedtime).isEqualTo(TimeOfDay(23, 0))
+ assertThat(restored.reminderMinutes).isEqualTo(15)
+ // The device's own rule and slot follow the restored settings: 05:00
+ // is inside last night's window, so the mode is on until 07:00.
+ assertThat(target.bedtimeMode.rule).isEqualTo(restored)
+ assertThat(target.bedtimeMode.on).isTrue()
+ assertThat(target.bedtimeScheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T07:00"))
+ }
+
@Test
fun `import never carries ring state`(@TempDir tempDir: Path) = runTest {
val source = backupHarness(tempDir.resolve("source2").also { it.toFile().mkdirs() })
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt
new file mode 100644
index 0000000..d961b20
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt
@@ -0,0 +1,190 @@
+package de.jeanlucmakiola.clockula.bedtime
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.clockula.domain.TimeOfDay
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+import de.jeanlucmakiola.clockula.testing.BERLIN
+import de.jeanlucmakiola.clockula.testing.FakeBedtimeMode
+import de.jeanlucmakiola.clockula.testing.FakeBedtimeNotifier
+import de.jeanlucmakiola.clockula.testing.FakeBedtimeScheduler
+import de.jeanlucmakiola.clockula.testing.FakeWallClock
+import de.jeanlucmakiola.clockula.testing.FakeZoneProvider
+import de.jeanlucmakiola.clockula.testing.instantAt
+import de.jeanlucmakiola.clockula.testing.offsetInstant
+import de.jeanlucmakiola.floret.prefs.PrefStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+import java.nio.file.Path
+import kotlin.time.Instant
+
+/**
+ * [BedtimeEngine] over a real DataStore and fake seams: the slot is always
+ * armed for the next event, the rule follows the switch and the access, and a
+ * reminder fire never touches the mode.
+ */
+class BedtimeEngineTest {
+
+ private class Harness(scope: TestScope, tempDir: Path, now: Instant, access: Boolean) {
+ val wallClock = FakeWallClock(now)
+ val scheduler = FakeBedtimeScheduler()
+ val mode = FakeBedtimeMode(access = access)
+ val notifier = FakeBedtimeNotifier()
+ val settings = SettingsPrefs(
+ PrefStore(
+ PreferenceDataStoreFactory.create(
+ scope = CoroutineScope(UnconfinedTestDispatcher(scope.testScheduler) + Job()),
+ produceFile = { tempDir.resolve("bedtime_test.preferences_pb").toFile() },
+ ),
+ ),
+ )
+ val engine = BedtimeEngine(settings, scheduler, mode, notifier, wallClock, FakeZoneProvider(BERLIN))
+ }
+
+ private fun TestScope.harness(
+ tempDir: Path,
+ now: Instant = instantAt(BERLIN, "2026-06-10T12:00"),
+ access: Boolean = true,
+ ) = Harness(this, tempDir, now, access)
+
+ private val nightly: (BedtimeSettings) -> BedtimeSettings = {
+ it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0))
+ }
+
+ @Test
+ fun `turning bedtime on creates the rule and arms the slot for bedtime`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir)
+
+ h.engine.update(nightly)
+
+ assertThat(h.mode.rule?.enabled).isTrue()
+ assertThat(h.mode.on).isFalse()
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
+ }
+
+ @Test
+ fun `turning it on during the night starts the mode at once`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
+
+ h.engine.update(nightly)
+
+ assertThat(h.mode.on).isTrue()
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
+ }
+
+ @Test
+ fun `the bedtime and wake-up fires switch the mode`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir)
+ h.engine.update(nightly)
+
+ h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
+ h.engine.onSlotFired()
+ assertThat(h.mode.on).isTrue()
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
+
+ h.wallClock.instant = instantAt(BERLIN, "2026-06-11T07:00")
+ h.engine.onSlotFired()
+ assertThat(h.mode.on).isFalse()
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T23:00+02:00"))
+ }
+
+ @Test
+ fun `a reminder fire posts the reminder and leaves the mode alone`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir)
+ h.engine.update { nightly(it).copy(reminderMinutes = 30) }
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T22:30+02:00"))
+ // Switched on by hand from the system's Modes page.
+ h.mode.on = true
+
+ h.wallClock.instant = instantAt(BERLIN, "2026-06-10T22:30")
+ h.engine.onSlotFired()
+
+ assertThat(h.notifier.reminders).containsExactly(offsetInstant("2026-06-10T23:00+02:00") to 30)
+ assertThat(h.mode.on).isTrue()
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
+
+ h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
+ h.engine.onSlotFired()
+ assertThat(h.notifier.showing).isFalse()
+ }
+
+ @Test
+ fun `turning bedtime off removes the rule and disarms the slot`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
+ h.engine.update(nightly)
+ assertThat(h.mode.on).isTrue()
+
+ h.engine.update { it.copy(enabled = false) }
+
+ assertThat(h.mode.rule).isNull()
+ assertThat(h.mode.on).isFalse()
+ assertThat(h.scheduler.armedAt).isNull()
+ }
+
+ @Test
+ fun `without access the reminder still runs and no rule is made`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir, access = false)
+
+ h.engine.update { nightly(it).copy(reminderMinutes = 15) }
+
+ assertThat(h.mode.rule).isNull()
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T22:45+02:00"))
+ }
+
+ @Test
+ fun `granting access later creates the rule in the current state`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-11T01:00"), access = false)
+ h.engine.update(nightly)
+
+ h.mode.access = true
+ h.engine.resync(refreshRule = true)
+
+ assertThat(h.mode.rule).isNotNull()
+ assertThat(h.mode.on).isTrue()
+ }
+
+ @Test
+ fun `an edit rewrites the rule, a plain resync does not`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir)
+ h.engine.update(nightly)
+
+ h.engine.resync()
+ assertThat(h.mode.refreshes).isEqualTo(0)
+
+ h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) }
+ assertThat(h.mode.refreshes).isEqualTo(1)
+ assertThat(h.mode.rule?.effects?.grayscale).isFalse()
+ }
+
+ @Test
+ fun `rearming touches the slot and not the mode`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir)
+ h.engine.update(nightly)
+ h.mode.on = true
+ h.scheduler.cancel()
+
+ h.engine.rearm()
+
+ assertThat(h.mode.on).isTrue()
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
+ }
+
+ @Test
+ fun `a missed wake-up fire is caught up by the next resync`(@TempDir tempDir: Path) = runTest {
+ val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
+ h.engine.update(nightly)
+
+ // A reboot through the wake-up time.
+ h.wallClock.instant = instantAt(BERLIN, "2026-06-11T09:00")
+ h.engine.resync()
+
+ assertThat(h.mode.on).isFalse()
+ assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T23:00+02:00"))
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
index db57734..f4bbe52 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.ThemeMode
import org.junit.jupiter.api.Test
@@ -62,6 +64,19 @@ class BackupCodecTest {
homeZoneId = "America/New_York",
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
+ bedtime = BedtimeSettings(
+ enabled = true,
+ bedtime = TimeOfDay(23, 15),
+ wakeTime = TimeOfDay(6, 45),
+ days = RepeatDays.WEEKDAYS,
+ reminderMinutes = 30,
+ effects = BedtimeEffects(
+ grayscale = false,
+ dimWallpaper = true,
+ darkTheme = false,
+ hideAlwaysOnDisplay = true,
+ ),
+ ),
),
)
@@ -263,6 +278,27 @@ class BackupCodecTest {
assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN)
}
+ @Test
+ fun `a file from before bedtime existed restores it off`() {
+ val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
+ assertThat(settings.bedtime).isEqualTo(BedtimeSettings())
+ }
+
+ @Test
+ fun `out-of-range bedtime values are clamped rather than rejected`() {
+ val json = minimalDocument().replace(
+ "\"settings\": {}",
+ "\"settings\": {\"bedtime\": {\"enabled\": true, \"bedtime_minutes\": 99999, " +
+ "\"wake_minutes\": -5, \"days\": 255, \"reminder_minutes\": 999}}",
+ )
+ val bedtime = (BackupCodec.decode(json) as BackupResult.Success).content.settings.bedtime
+ assertThat(bedtime.enabled).isTrue()
+ assertThat(bedtime.bedtime).isEqualTo(TimeOfDay(23, 59))
+ assertThat(bedtime.wakeTime).isEqualTo(TimeOfDay(0, 0))
+ assertThat(bedtime.days).isEqualTo(RepeatDays.EVERY_DAY)
+ assertThat(bedtime.reminderMinutes).isEqualTo(120)
+ }
+
@Test
fun `an empty preset list is honoured as all removed`() {
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
index 71ead3e..8037794 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
@@ -3,11 +3,17 @@ package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
+import de.jeanlucmakiola.clockula.domain.RepeatDays
+import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
@@ -143,6 +149,48 @@ class SettingsPrefsTest {
}
}
+ // --- issue #1: bedtime ---
+
+ @Test
+ fun `a fresh install has bedtime off`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
+
+ assertThat(prefs.currentBedtime()).isEqualTo(BedtimeSettings())
+ }
+
+ @Test
+ fun `the bedtime schedule survives process death`(@TempDir tempDir: Path) = runTest {
+ val stored = BedtimeSettings(
+ enabled = true,
+ bedtime = TimeOfDay(0, 30),
+ wakeTime = TimeOfDay(8, 15),
+ days = RepeatDays.WEEKENDS,
+ reminderMinutes = 45,
+ effects = BedtimeEffects(grayscale = false, dimWallpaper = false, darkTheme = true, hideAlwaysOnDisplay = true),
+ )
+ val firstProcess = newScope()
+ SettingsPrefs(PrefStore(newDataStore(tempDir, firstProcess))).setBedtime(stored)
+ firstProcess.coroutineContext.job.cancelAndJoin()
+
+ val reopened = SettingsPrefs(PrefStore(newDataStore(tempDir, newScope())))
+
+ assertThat(reopened.currentBedtime()).isEqualTo(stored)
+ }
+
+ @Test
+ fun `a corrupt bedtime value reads back clamped`(@TempDir tempDir: Path) = runTest {
+ val dataStore = newDataStore(tempDir)
+ dataStore.edit {
+ it[intPreferencesKey("bedtime_start_minutes")] = 5_000
+ it[intPreferencesKey("bedtime_reminder_minutes")] = -3
+ }
+
+ val bedtime = SettingsPrefs(PrefStore(dataStore)).currentBedtime()
+
+ assertThat(bedtime.bedtime).isEqualTo(TimeOfDay(23, 59))
+ assertThat(bedtime.reminderMinutes).isEqualTo(0)
+ }
+
// --- M4: the one-shot notification permission request ---
@Test
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt
new file mode 100644
index 0000000..9b48ad6
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt
@@ -0,0 +1,131 @@
+package de.jeanlucmakiola.clockula.domain.bedtime
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.clockula.domain.RepeatDays
+import de.jeanlucmakiola.clockula.domain.TimeOfDay
+import de.jeanlucmakiola.clockula.testing.BERLIN
+import de.jeanlucmakiola.clockula.testing.instantAt
+import de.jeanlucmakiola.clockula.testing.offsetInstant
+import org.junit.jupiter.api.Test
+import java.time.DayOfWeek
+import kotlin.time.Duration.Companion.hours
+
+/** 2026-06-10 is a Wednesday. */
+class BedtimeScheduleTest {
+
+ private val everyNight = BedtimeSettings(
+ enabled = true,
+ bedtime = TimeOfDay(23, 0),
+ wakeTime = TimeOfDay(7, 0),
+ )
+
+ @Test
+ fun `a wake time before the bedtime is the next morning`() {
+ val window = BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN)
+
+ assertThat(window).isEqualTo(
+ BedtimeWindow(offsetInstant("2026-06-10T23:00+02:00"), offsetInstant("2026-06-11T07:00+02:00")),
+ )
+ }
+
+ @Test
+ fun `a wake time after the bedtime is the same day`() {
+ val earlyNap = everyNight.copy(bedtime = TimeOfDay(1, 0), wakeTime = TimeOfDay(6, 0))
+
+ val window = BedtimeSchedule.activeWindow(earlyNap, instantAt(BERLIN, "2026-06-10T03:00"), BERLIN)
+
+ assertThat(window?.start).isEqualTo(offsetInstant("2026-06-10T01:00+02:00"))
+ assertThat(window?.end).isEqualTo(offsetInstant("2026-06-10T06:00+02:00"))
+ }
+
+ @Test
+ fun `daytime is outside every window`() {
+ assertThat(BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull()
+ }
+
+ @Test
+ fun `the wake time itself is already outside the window`() {
+ assertThat(BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-11T07:00"), BERLIN)).isNull()
+ }
+
+ @Test
+ fun `a disabled schedule is never active and schedules nothing`() {
+ val off = everyNight.copy(enabled = false)
+ val night = instantAt(BERLIN, "2026-06-11T02:00")
+
+ assertThat(BedtimeSchedule.activeWindow(off, night, BERLIN)).isNull()
+ assertThat(BedtimeSchedule.nextEvent(off, night, BERLIN)).isNull()
+ }
+
+ @Test
+ fun `the days are the nights a window starts on`() {
+ val fridays = everyNight.copy(days = RepeatDays.of(DayOfWeek.FRIDAY))
+
+ // Saturday 02:00 belongs to Friday night.
+ assertThat(BedtimeSchedule.activeWindow(fridays, instantAt(BERLIN, "2026-06-13T02:00"), BERLIN)).isNotNull()
+ // Thursday 02:00 would belong to Wednesday night, which is not one.
+ assertThat(BedtimeSchedule.activeWindow(fridays, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN)).isNull()
+ assertThat(BedtimeSchedule.nextWindow(fridays, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)?.start)
+ .isEqualTo(offsetInstant("2026-06-12T23:00+02:00"))
+ }
+
+ @Test
+ fun `in the daytime the next event is bedtime`() {
+ assertThat(BedtimeSchedule.nextEvent(everyNight, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN))
+ .isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
+ }
+
+ @Test
+ fun `at night the next event is the wake time`() {
+ assertThat(BedtimeSchedule.nextEvent(everyNight, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN))
+ .isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
+ }
+
+ @Test
+ fun `a reminder comes before bedtime, then bedtime itself`() {
+ val reminded = everyNight.copy(reminderMinutes = 30)
+
+ assertThat(BedtimeSchedule.nextEvent(reminded, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN))
+ .isEqualTo(offsetInstant("2026-06-10T22:30+02:00"))
+ assertThat(BedtimeSchedule.nextEvent(reminded, instantAt(BERLIN, "2026-06-10T22:30"), BERLIN))
+ .isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
+ }
+
+ @Test
+ fun `a reminder is due between its time and bedtime only`() {
+ val reminded = everyNight.copy(reminderMinutes = 30)
+
+ assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T22:29"), BERLIN)).isNull()
+ assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T22:30"), BERLIN)?.start)
+ .isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
+ assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T23:05"), BERLIN)).isNull()
+ assertThat(BedtimeSchedule.dueReminder(everyNight, instantAt(BERLIN, "2026-06-10T22:45"), BERLIN)).isNull()
+ }
+
+ @Test
+ fun `an equal bedtime and wake time is a whole day, back to back`() {
+ val always = everyNight.copy(wakeTime = TimeOfDay(23, 0))
+
+ val window = BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T22:00"), BERLIN)
+
+ assertThat(window?.start).isEqualTo(offsetInstant("2026-06-09T23:00+02:00"))
+ assertThat(window?.end).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
+ assertThat(BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T23:00"), BERLIN)).isNotNull()
+ }
+
+ @Test
+ fun `a fall-back night is an hour longer, by the wall clock`() {
+ // Berlin falls back on 2026-10-25 at 03:00.
+ val window = BedtimeSchedule.windowOn(everyNight, java.time.LocalDate.of(2026, 10, 24), BERLIN)!!
+
+ assertThat(window.end - window.start).isEqualTo(9.hours)
+ assertThat(window.end).isEqualTo(offsetInstant("2026-10-25T07:00+01:00"))
+ }
+
+ @Test
+ fun `no nights means nothing to schedule`() {
+ val none = everyNight.copy(days = RepeatDays.NONE)
+
+ assertThat(BedtimeSchedule.nextEvent(none, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt
index 43e2b66..2a83db0 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.testing
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.backup.BackupEngine
+import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
@@ -99,11 +100,24 @@ class BackupHarness(
wall = wallClock,
)
+ val bedtimeScheduler: FakeBedtimeScheduler = FakeBedtimeScheduler()
+ val bedtimeMode: FakeBedtimeMode = FakeBedtimeMode()
+
+ val bedtimeEngine: BedtimeEngine = BedtimeEngine(
+ settings = settings,
+ scheduler = bedtimeScheduler,
+ mode = bedtimeMode,
+ notifier = FakeBedtimeNotifier(),
+ wallClock = wallClock,
+ zones = zones,
+ )
+
val backupEngine: BackupEngine = BackupEngine(
repository = backupRepository,
settings = settings,
alarmEngine = alarmEngine,
timerEngine = timerEngine,
+ bedtimeEngine = bedtimeEngine,
wallClock = wallClock,
)
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt
new file mode 100644
index 0000000..6cef44e
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt
@@ -0,0 +1,78 @@
+package de.jeanlucmakiola.clockula.testing
+
+import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
+import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
+import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
+import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+import kotlin.time.Instant
+
+/** The bedtime slot: the instant it is armed for, or null when it is not. */
+class FakeBedtimeScheduler : BedtimeScheduler {
+ var armedAt: Instant? = null
+ private set
+
+ override fun schedule(at: Instant) {
+ armedAt = at
+ }
+
+ override fun cancel() {
+ armedAt = null
+ }
+}
+
+/**
+ * The Do Not Disturb rule as the platform would hold it: at most one, with its
+ * last-written settings and whether its condition is true. Without [access],
+ * every write is a no-op, as `AndroidBedtimeMode`'s guarded calls are.
+ */
+class FakeBedtimeMode(
+ var access: Boolean = true,
+ override val supportsEffects: Boolean = true,
+) : BedtimeMode {
+ /** The settings the rule was last written from; null when there is no rule. */
+ var rule: BedtimeSettings? = null
+ var on: Boolean = false
+ var refreshes: Int = 0
+ private set
+
+ override fun hasAccess(): Boolean = access
+
+ override fun install(settings: BedtimeSettings, refresh: Boolean) {
+ if (!access) return
+ if (rule == null) {
+ rule = settings
+ } else if (refresh) {
+ rule = settings
+ refreshes++
+ }
+ }
+
+ override fun setActive(active: Boolean) {
+ if (!access || rule == null) return
+ on = active
+ }
+
+ override fun uninstall() {
+ if (!access) return
+ rule = null
+ on = false
+ }
+
+ override fun ruleId(): String? = if (access && rule != null) "rule" else null
+}
+
+class FakeBedtimeNotifier : BedtimeNotifier {
+ /** Each reminder posted, as (window start, minutes before). */
+ val reminders: MutableList> = mutableListOf()
+ var showing: Boolean = false
+ private set
+
+ override fun showReminder(start: Instant, minutesBefore: Int) {
+ reminders += start to minutesBefore
+ showing = true
+ }
+
+ override fun cancelReminder() {
+ showing = false
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt
index 09190be..cbe63e3 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.shell
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
+import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
@@ -122,4 +123,12 @@ class ShellNavigationInteropTest {
assertThat(InteropIntents.ACTIONS.intersect(others.toSet())).isEmpty()
assertThat(InteropIntents.ACTIONS).containsNoDuplicates()
}
+
+ @Test
+ fun `the bedtime reminder and the mode's settings page both open the bedtime screen`() {
+ assertThat(ShellNavigation.requestFor(BedtimeIntents.ACTION_SHOW_BEDTIME, null, emptyList()))
+ .isEqualTo(ShellRequest.OpenBedtime)
+ assertThat(ShellNavigation.requestFor(BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE, null, emptyList()))
+ .isEqualTo(ShellRequest.OpenBedtime)
+ }
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 97ef4e8..363dc39 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -98,6 +98,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `domain/format/` | `ClockFormat` (`M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated), `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat`, the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
+| `domain/bedtime/` | the bedtime schedule's pure half (issue #1): `BedtimeSettings`/`BedtimeEffects`/`BedtimeLimits` and `BedtimeSchedule` (the window a night resolves to, the active window, the due reminder, the next event). §18 |
| `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
| `data/db/` | `ClockulaDatabase` (`@Database` v3, `exportSchema = true`) and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
@@ -105,7 +106,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory`, the locale-keyed catalog cache and the DST-keyed display-name memo |
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
-| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
+| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs`, `BedtimePrefs` (issue #1) |
| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer`, plus their `System*` implementations, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8; one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) |
@@ -127,6 +128,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `stopwatch/service/` | `StopwatchService` (the `specialUse` foreground service) and `StopwatchNotifications` |
| `stopwatch/di/` | `StopwatchModule`: `@Binds` for the one seam |
| `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator; no `android.*` import, ever), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` |
+| `bedtime/` | `BedtimeEngine` (issue #1) and its three seams, `BedtimeScheduler`, `BedtimeMode` and `BedtimeNotifier`, plus `BedtimeIntents`. `bedtime/android/` holds their implementations (the one wall-clock slot, the Do Not Disturb rule, the reminder), `bedtime/receiver/` the `BedtimeReceiver`, `bedtime/di/` the `BedtimeModule`. §18 |
| `system/` | `RebootRepair`, the boot-id gate |
| `backup/` | `BackupEngine` (M10), the orchestrator (a ringing alarm dismissed and running timers deleted before a replace, `AlarmEngine.reschedule()`/`TimerEngine.resync()` after it), plus `BackupFiles`, the SAF seam, with its Android implementation in `backup/android/` |
| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed |
@@ -139,6 +141,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from a gear icon on every tab, with no owning tab of their own |
+| `ui/bedtime/` | `BedtimeScreen`/`BedtimeViewModel` (issue #1), a nested destination reached from the settings hub, the bedtime reminder and the mode's page in system Settings, never a tab |
| `ui/widget/` | the three widgets' `@Composable` Glance content (M10), the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI. `ui/widget/config/` holds each widget's config activity; every widget has one, if only for its per-instance colours (`domain/widget/WidgetColors.kt`: System/Light/Dark and, on API 31+, wallpaper colours on/off). Glance content takes those through `WidgetTheme`; the plain-`RemoteViews` faces, readouts and chronometers through `WidgetPalette.kt`, which resolves the `widget_*` palette under a forced day/night (the app's own tones are `widget_app_*`) and sets concrete colours, or day/night pairs, on the views. The analog and digital clocks are re-coloured on API 31+ only, since below that `AnalogClock`'s hands and a shape's background tint can't be set through `RemoteViews` |
---
@@ -599,6 +602,7 @@ Four slices:
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
+| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` and written as one record by `BedtimeEngine` only. Times clamp into a day, the reminder into 0–120 minutes, the days through `RepeatDays.fromMask`. The system rule's id is **not** stored (§18) |
The stopwatch's *run* is a single record, so it lives in DataStore rather than
as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room.
@@ -668,6 +672,7 @@ Hilt, `SingletonComponent` throughout. Nine app modules plus the kit's:
| `ZoneModule` | `@Binds` for the one ICU seam: `ZoneNames → IcuZoneNames` (M8). `ZoneDirectory` needs no binding; it is a `@Singleton` `@Inject` class |
| `StopwatchModule` | `@Binds` for the stopwatch engine's one seam: `StopwatchServiceHandle` (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline |
| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") |
+| `BedtimeModule` | `@Binds` for the bedtime engine's three seams (issue #1): `BedtimeScheduler`, `BedtimeMode`, `BedtimeNotifier` |
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs`
@@ -968,6 +973,7 @@ declared because a specific path would otherwise fail, silently, at 07:00.
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the two ringing services: the alarm's, and (since M6) the timers' |
| `WAKE_LOCK` | keep the CPU up for the length of a ring |
| `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens |
+| `ACCESS_NOTIFICATION_POLICY` | issue #1's opt-in bedtime mode. Install-time, and on its own it does nothing: it lists Clockula under Special app access › Do Not Disturb, which the user grants by hand. With that grant Clockula adds one Do Not Disturb rule of its own, which always lets alarms through (§18) |
**M6 adds no permission at all.** A timer never takes over the screen (no
full-screen intent, no ring activity, no `showWhenLocked`), because its user set
@@ -2686,3 +2692,121 @@ app has shipped nine milestones with no toast anywhere, and formatting "Alarm se
for 7:00" would need a second, non-composable 12/24-hour formatter, the thing
M8 spent a milestone eliminating. The platform's own receipt is better than a
toast and costs nothing.
+
+---
+
+## 18. Bedtime
+
+Issue #1: an opt-in Do Not Disturb mode, after Google Clock's Bedtime tab,
+without a fifth tab. v1's four surfaces stay as they are; bedtime is a nested
+destination (`settings/bedtime`) reached from a row on the settings hub, from
+its own reminder notification, and from the mode's page in the system's
+Settings.
+
+### Why a mode of Clockula's own
+
+Digital Wellbeing's Bedtime mode is off limits. `NotificationManagerService`
+refuses an `AutomaticZenRule` of `TYPE_BEDTIME` from any package but the one in
+`config_systemWellbeing`, and no third-party app can trigger Wellbeing's mode or
+read its schedule. What Android does offer an app with Do Not Disturb access
+(`ACCESS_NOTIFICATION_POLICY`, granted by hand under Special app access) is an
+automatic zen rule of its own:
+
+- **Android 15 and later**: a *mode*, built with `AutomaticZenRule.Builder`,
+ typed `TYPE_SCHEDULE_TIME`, shown in Settings › Modes under Clockula's name and
+ the `ic_bedtime` icon, with a trigger line ("23:00 to 07:00") and the public
+ `ZenDeviceEffects` the screen offers: grayscale, dimmed wallpaper, dark theme
+ and hiding the always-on display. The hidden effects (auto-brightness,
+ tap/tilt to wake, doze, radios) are dropped by `ZenModeHelper` when an app
+ sets them, so the screen does not offer them. Manual invocation is allowed,
+ so the user can also switch the mode on from Settings or Quick Settings.
+- **Android 10 to 14** (minSdk 29): a plain Do Not Disturb schedule rule from
+ the seven-argument constructor that takes a `ZenPolicy`. No type, no effects:
+ the screen says so and hides the effect switches.
+
+A rule needs an owner `ConditionProviderService` or a configuration activity.
+Clockula uses `MainActivity` as the configuration activity: it is already
+exported, so no new component is exposed, and when Settings sends it
+`NotificationManager.ACTION_AUTOMATIC_ZEN_RULE` the shell opens the bedtime
+screen (`ShellRequest.OpenBedtime`). There is no condition provider service:
+Clockula reports the rule's state itself with `setAutomaticZenRuleState`.
+
+The two modes coexist. A Pixel user who also uses Wellbeing's Bedtime sees two
+modes in Settings › Modes; the screen says so and suggests using one.
+
+### Alarms are never suppressed
+
+The rule's `ZenPolicy` sets exactly two things, `allowAlarms(true)` and
+`allowMedia(true)` (a sleep sound keeps playing), and leaves every other field
+unset, so calls, messages and the rest follow the device's own Do Not Disturb
+defaults. Clockula's alarms and timers play with `USAGE_ALARM`, so the
+exemption applies. A user who later edits the mode in Settings and blocks alarms
+there has made that choice in the system's own UI; Android 15 keeps a user's
+edits over an app's updates, and Clockula does not fight them. The self-check
+(M10's `SelfCheck`) still reports total silence as a problem whatever caused it.
+
+### The engine
+
+`BedtimeEngine` is plain Kotlin behind three seams, JVM-tested like the other
+engines (`ArchitectureRulesTest` holds it to no `android.*` import):
+
+- `BedtimeScheduler`, **one** AlarmManager slot of its own. Its PendingIntent
+ targets `BedtimeReceiver` with its own action and request code, so arming or
+ cancelling it cannot replace or cancel an alarm's or a timer's registration.
+ It is `RTC_WAKEUP`, exact while idle when the platform allows it and
+ `setAndAllowWhileIdle` otherwise, the timer slot's two branches. It never
+ uses `setAlarmClock`: that slot is the status bar's next *alarm*. Alarms
+ themselves use `setAlarmClock`, which the while-idle quota does not touch,
+ and bedtime adds at most three fires a night.
+- `BedtimeMode`, the rule. Every call is guarded and a no-op without access.
+- `BedtimeNotifier`, the reminder, on its own `bedtime_reminder` channel.
+
+Every entry point is an idempotent pass over "where does the schedule stand
+now", computed by `BedtimeSchedule`: a night's window runs from the bedtime on
+one of the chosen days to the first wake time after it (an earlier wake time is
+the next morning, an equal one a full day), resolved from local times and the
+zone like an alarm, so a DST night is as long as the wall clock says. The slot
+is armed for the earliest of the current window's end, the next window's
+reminder and the next window's start.
+
+| Entry point | Called from | What it does |
+|---|---|---|
+| `update(transform)` | the bedtime screen | stores the settings, rewrites the rule from them, sets the mode to what the schedule says now (on at 23:30 for a 23:00 bedtime starts it at once), arms the slot. Off removes the rule, which ends the mode, cancels the slot and clears the reminder |
+| `onSlotFired()` | `BedtimeReceiver` | a reminder fire posts the reminder and **leaves the mode alone**; any other fire sets the mode on or off. Then arms the next event |
+| `resync(refreshRule)` | `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission), Do Not Disturb access changing, a backup restore | creates the rule if it is missing, sets the mode, arms the slot |
+| `rearm()` | `ClockulaApp.onCreate` | arms the slot only. A force stop clears it, but a launch is not a bedtime event, so a mode the user switched by hand stays as it is |
+
+Every bedtime call on a shared path runs **after** the alarm, timer and
+stopwatch passes and inside `runCatching`, so nothing bedtime does can delay
+or break them.
+
+### No stored rule id
+
+`NotificationManager.getAutomaticZenRules()` returns only the calling app's
+rules, and the rule's condition id (`condition:///bedtime`) marks
+Clockula's. So nothing about the rule is stored: a reinstall, a cleared app or
+a restore onto another device finds the rule or creates it, and a stray second
+one is removed. An update keeps the rule's own enabled switch, so a change on
+Clockula's screen does not re-enable a rule the user disabled in Settings.
+
+### Access, granted and revoked
+
+Do Not Disturb access cannot be requested with a dialog. The screen shows an
+onboarding row while it is missing, deep-linking to
+`Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS`, and re-reads access on
+every `ON_RESUME`; a change found there runs `resync(refreshRule = true)` at
+once, so coming back from granting it creates the mode without waiting for the
+system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` (which
+`BedtimeReceiver` also answers). Without access the schedule still runs for the
+reminder. Revoking access in Settings removes the app's rules with it (that is
+Settings' own confirmation dialog), and Clockula cannot remove a rule without
+access, so a rule left by a revoke from outside Settings is the platform's.
+Turning bedtime off removes the rule cleanly.
+
+### Backup
+
+The settings travel in the backup's `settings.bedtime` object
+(`docs/BACKUP.md`); a file from before issue #1 restores bedtime as off.
+`BackupEngine.import` resyncs the engine after the replace, or after rolling
+it back.
+
diff --git a/docs/BACKUP.md b/docs/BACKUP.md
index e8536de..e58eb1f 100644
--- a/docs/BACKUP.md
+++ b/docs/BACKUP.md
@@ -136,10 +136,30 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"default_timer_ringtone_uri": null,
"default_dismiss_challenge": "NONE",
"home_zone_id": null,
- "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
+ "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
+ "bedtime": {
+ "enabled": false,
+ "bedtime_minutes": 1350,
+ "wake_minutes": 420,
+ "days": 127,
+ "reminder_minutes": 0,
+ "grayscale": true,
+ "dim_wallpaper": true,
+ "dark_theme": true,
+ "hide_always_on_display": false
+ }
}
```
+`bedtime` is the opt-in bedtime schedule (issue #1). Its times are minutes
+since local midnight, `days` is the same 7-bit mask as an alarm's
+`repeat_days` (the nights bedtime starts on), and `reminder_minutes` 0 means no
+reminder. A file without `bedtime` (any file written before it existed)
+restores bedtime as off with the default schedule. Like the other settings it
+is clamped rather than rejected: times into a day, the reminder into 0–120
+minutes. The id of the Do Not Disturb rule a device created is not part of it;
+the importing device creates its own (`docs/ARCHITECTURE.md` §18).
+
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
files may also carry `default_timer_duration_millis`, from a default timer
length setting that has since been removed; it is ignored on import like any
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 4899d1e..25015e9 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -162,9 +162,11 @@ notification whose content intent is the ring activity.
A "**Why might my alarm not ring?**" screen that reports actual device state, not
generic advice: exact-alarm permission, notification permission, full-screen-intent
permission, battery-optimisation exemption, **whether DND currently blocks alarms**
-(read via `NotificationManager.getCurrentInterruptionFilter()`; Clockula never
-requests `ACCESS_NOTIFICATION_POLICY` or changes DND itself, it only reports what the
-user already has), whether the OEM has Clockula under an aggressive app-killer, and
+(read via `NotificationManager.getCurrentInterruptionFilter()`; the self-check never
+changes DND itself, it only reports what the user already has. The one place Clockula
+touches DND is the opt-in bedtime mode added after v1, which needs the user to grant
+`ACCESS_NOTIFICATION_POLICY` by hand and always lets alarms through; see
+`ARCHITECTURE.md` §18), whether the OEM has Clockula under an aggressive app-killer, and
the next scheduled fire time as the system sees it (`getNextAlarmClock()`), compared
against the next fire Clockula itself computed. Where possible each row deep-links to
the exact settings page. This screen is a v1 feature, not a support afterthought.
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 17c2562..385d5ec 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -1,7 +1,7 @@
---
title: Privacy Policy — Clockula
description: What Clockula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
-updated: 2026-10-02
+updated: 2026-10-22
---
-**Last updated:** 2 October 2026
+**Last updated:** 22 October 2026
Applies to the Android app **Clockula** (package `de.jeanlucmakiola.clockula`),
all versions and all distribution channels.
@@ -167,6 +167,10 @@ on. Clockula only opens the address and sends no data of yours with it.
stopwatch going while the app is in the background.
- `WAKE_LOCK`: keep the phone awake while an alarm or timer rings.
- `VIBRATE`: vibrate when an alarm or timer rings.
+- `ACCESS_NOTIFICATION_POLICY`: only used if you turn on Bedtime and allow
+ Clockula Do Not Disturb access yourself. Clockula then adds one Do Not
+ Disturb mode of its own and turns it on and off on your schedule. It does not
+ read your notifications.
- `ACCESS_NETWORK_STATE`: added by the Android library that updates the
home-screen widgets (WorkManager). It lets an app see whether a network
connection exists. It does not let it use one, and without `INTERNET` no
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index bb7731b..8b0f770 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -611,8 +611,15 @@ Deferred deliberately, in rough priority order:
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
win, and the surface people look at most.
- **Quick Settings tile**: timer control and next alarm from the shade.
-- **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature
- in its own right, realistically v2.
+- 🚧 **Bedtime / sleep schedule** (issue #1): a first version exists as an
+ opt-in screen under Settings, not a fifth tab. Clockula's own Do Not Disturb
+ mode (a real mode in Settings › Modes on Android 15+, with grayscale, dimmed
+ wallpaper, dark theme and hidden always-on display; a plain DND rule on 10–14),
+ bedtime and wake-up times, nights, an optional reminder, and backup. Digital
+ Wellbeing's Bedtime mode stays out of reach (the platform refuses
+ `TYPE_BEDTIME` to third parties). Still open: a wind-down phase before
+ bedtime, and whether the wake-up should tie into an alarm. See
+ `ARCHITECTURE.md` §18.
- **`core-notification` in floret-kit**: revisit once Clockula's real
notification surface is known (`PLAN.md` §3).
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`**: tracked in