feat(data): ui preferences, the real ticker and its binding

The ticker is injected rather than read from the composition so the shell's
per-second text can be driven by a test scheduler instead of a wall clock.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 13:07:04 +02:00
parent 6dee033aba
commit 199005e795
6 changed files with 185 additions and 2 deletions
@@ -5,11 +5,13 @@ import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
import de.jeanlucmakiola.clockula.data.time.RealTicker
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
import de.jeanlucmakiola.clockula.data.time.SystemWallClock import de.jeanlucmakiola.clockula.data.time.SystemWallClock
import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
@@ -34,4 +36,7 @@ abstract class TimeModule {
@Binds @Binds
abstract fun bindBootIdProvider(impl: AndroidBootIdProvider): BootIdProvider abstract fun bindBootIdProvider(impl: AndroidBootIdProvider): BootIdProvider
@Binds
abstract fun bindTicker(impl: RealTicker): Ticker
} }
@@ -92,4 +92,16 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setTimerDuration(duration: Duration) = store.set(ClockPrefs.timerDurationMillis, duration) suspend fun setTimerDuration(duration: Duration) = store.set(ClockPrefs.timerDurationMillis, duration)
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId) suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
// --- first-launch bookkeeping (M4) ---
/**
* Whether the POST_NOTIFICATIONS request has already been shown once.
* Distinct-until-changed through [PrefStore.flow], so writing an unrelated
* preference does not re-emit it.
*/
val notificationPermissionAsked: Flow<Boolean> = store.flow(UiPrefs.notificationPermissionAsked)
suspend fun markNotificationPermissionAsked() =
store.set(UiPrefs.notificationPermissionAsked, true)
} }
@@ -0,0 +1,11 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.booleanPref
/** The shell's own on-disk keys — first-launch bookkeeping, not a user setting. */
object UiPrefs {
/** Whether the POST_NOTIFICATIONS request has been shown once. */
val notificationPermissionAsked: Pref<Boolean> =
booleanPref("notification_permission_asked", false)
}
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.clockula.data.time
import de.jeanlucmakiola.clockula.domain.time.Ticker
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
/** The [Ticker] seam over `delay`: emit, then wait [ticks]' period, forever. */
@Singleton
class RealTicker @Inject constructor() : Ticker {
/**
* Cold, and per-collector: two readouts on different cadences get two
* independent streams, and a collector that walks away takes its own
* `delay` with it. The first emission is immediate, so a subscriber is
* never blank for a period.
*/
override fun ticks(period: Duration): Flow<Unit> = flow {
while (true) {
emit(Unit)
delay(period)
}
}
}
@@ -11,7 +11,9 @@ import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.job
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -26,12 +28,24 @@ import java.nio.file.Path
*/ */
class SettingsPrefsTest { class SettingsPrefsTest {
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> = /**
* One store's worth of lifetime. DataStore keeps a registry of the files it
* has open and refuses a second store over a live one, so a test that wants
* to *reopen* a file has to end the first scope first — the way a process
* ending is what really releases the file.
*/
private fun TestScope.newScope(): CoroutineScope =
CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job())
private fun newDataStore(tempDir: Path, scope: CoroutineScope): DataStore<Preferences> =
PreferenceDataStoreFactory.create( PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), scope = scope,
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() }, produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
) )
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
newDataStore(tempDir, newScope())
@Test @Test
fun `a fresh install follows the system theme with dynamic colour on`(@TempDir tempDir: Path) = runTest { fun `a fresh install follows the system theme with dynamic colour on`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
@@ -105,4 +119,41 @@ class SettingsPrefsTest {
cancel() cancel()
} }
} }
// --- M4: the one-shot notification permission request ---
@Test
fun `a fresh install has not been asked for the notification permission`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
assertThat(prefs.notificationPermissionAsked.first()).isFalse()
}
@Test
fun `having asked once survives process death`(@TempDir tempDir: Path) = runTest {
val firstProcess = newScope()
SettingsPrefs(PrefStore(newDataStore(tempDir, firstProcess))).markNotificationPermissionAsked()
// The process death: the store's scope dies with the process, and that
// is also what lets the file be opened again.
firstProcess.coroutineContext.job.cancelAndJoin()
val reopened = SettingsPrefs(PrefStore(newDataStore(tempDir, newScope())))
assertThat(reopened.notificationPermissionAsked.first()).isTrue()
}
@Test
fun `the asked flag does not re-emit when an unrelated preference is written`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.notificationPermissionAsked.test {
assertThat(awaitItem()).isFalse()
prefs.setSnoozeMinutes(7)
expectNoEvents()
cancel()
}
}
} }
@@ -0,0 +1,77 @@
package de.jeanlucmakiola.clockula.data.time
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* The ticker is nothing but `delay`, which is why it is injected: under
* `runTest`'s virtual clock five seconds of it cost nothing. It emits once
* immediately so a subscriber is never blank for a cadence.
*/
class RealTickerTest {
private val ticker = RealTicker()
@Test
fun `the first tick arrives immediately`() = runTest {
var ticks = 0
backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } }
runCurrent()
assertThat(ticks).isEqualTo(1)
}
@Test
fun `a second tick arrives one period later`() = runTest {
var ticks = 0
backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } }
runCurrent()
advanceTimeBy(1.seconds)
runCurrent()
assertThat(ticks).isEqualTo(2)
}
@Test
fun `five seconds is one immediate tick and five periodic ones`() = runTest {
var ticks = 0
backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } }
runCurrent()
advanceTimeBy(5.seconds)
runCurrent()
assertThat(ticks).isEqualTo(6)
}
@Test
fun `each collector gets its own cadence`() = runTest {
var slow = 0
var fast = 0
backgroundScope.launch { ticker.ticks(1.seconds).collect { slow++ } }
backgroundScope.launch { ticker.ticks(200.milliseconds).collect { fast++ } }
runCurrent()
advanceTimeBy(1.seconds)
runCurrent()
assertThat(slow to fast).isEqualTo(2 to 6)
}
@Test
fun `a collector that has seen enough can walk away`() = runTest {
val ticks = ticker.ticks(1.seconds).take(3).toList()
assertThat(ticks).hasSize(3)
}
}