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