feat(shell): the navigation host, four tabs and the live pill

`NavigationSuiteScaffold` gives the bar on a phone and the rail on a wide
layout from one declaration. Each tab keeps its own back stack; re-selecting
a tab returns to its root, and predictive back is wired only where back
actually goes somewhere.

The pill is the running-state surface from PLAN §9: it floats above the bar,
speaks for whichever subject is closest to needing attention, and pauses or
stops it from any tab. Its callbacks are gated on a live subject, so a second
tap during its exit animation cannot restart a timer the user just reset.

The notification permission is asked once, and the flag is written from the
request's result rather than before it — a process death mid-dialog leaves
the ask due again rather than recorded and never made. Three tabs are empty
shells until M5 through M8 fill them.
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:18 +02:00
parent 199005e795
commit 83c5e9fe3f
21 changed files with 1544 additions and 28 deletions
@@ -0,0 +1,89 @@
package de.jeanlucmakiola.clockula.ui
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import de.jeanlucmakiola.clockula.ui.shell.LivePill
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.minutes
/**
* The pill's exit animation, which only a real composition can show: the state
* the pill was showing is latched so the spring has something to animate away,
* and for those few hundred milliseconds the body is still laid out and still
* takes taps. A second tap in that window must not reach the subject that has
* already gone — `timers.start(id)` on a timer the user just stopped restarts
* it from its full duration.
*
* Compiled in the gate, run on a device (slice plan D18).
*/
@RunWith(AndroidJUnit4::class)
class LivePillInstrumentedTest {
@get:Rule
val composeTestRule = createComposeRule()
private val running = LivePillState(
subject = LivePillSubject.TimerSubject(timerId = 7L, label = "Pasta"),
mode = LivePillMode.RUNNING,
value = 4.minutes,
otherActiveTimers = 0,
)
private fun string(id: Int): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
@Test
fun aTapWhileThePillIsLeavingDoesNotReachTheOldSubject() {
var stops = 0
var primaryActions = 0
val state = mutableStateOf<LivePillState?>(running)
composeTestRule.setContent {
ClockulaTheme {
LivePill(
state = state.value,
onPrimaryAction = { primaryActions++ },
onStop = { stops++ },
)
}
}
composeTestRule.onAllNodesWithContentDescription(string(R.string.live_pill_stop))
.onFirst()
.performClick()
assertThat(stops).isEqualTo(1)
// The subject is gone; the exit spring has not finished playing.
composeTestRule.mainClock.autoAdvance = false
composeTestRule.runOnUiThread { state.value = null }
composeTestRule.mainClock.advanceTimeByFrame()
tapIfPresent(string(R.string.live_pill_stop))
tapIfPresent(string(R.string.live_pill_pause))
assertThat(listOf(stops, primaryActions)).containsExactly(1, 0).inOrder()
}
/**
* Whether the body has already left the composition or is still springing
* out, the promise is the same: nothing more happens. So the tap is made
* only if there is something there to tap.
*/
private fun tapIfPresent(contentDescription: String) {
val nodes = composeTestRule.onAllNodesWithContentDescription(contentDescription)
if (nodes.fetchSemanticsNodes().isNotEmpty()) nodes.onFirst().performClick()
}
}
@@ -0,0 +1,80 @@
package de.jeanlucmakiola.clockula.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* What only a device can answer about the shell: that the four tabs really
* compose, that a real system back press goes where the policy says, and that
* the selected tab survives an activity recreation.
*
* Compiled in the gate, run on a device (slice plan D18).
*/
@RunWith(AndroidJUnit4::class)
class ShellInstrumentedTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
private fun string(id: Int): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
@Test
fun allFourTabsAreDisplayed() {
val labels = listOf(
R.string.tab_alarms,
R.string.tab_timers,
R.string.tab_stopwatch,
R.string.tab_world_clock,
).map(::string)
labels.forEach { composeTestRule.onNodeWithText(it).assertIsDisplayed() }
}
@Test
fun tappingATabSwapsTheContent() {
composeTestRule.onNodeWithText(string(R.string.tab_timers)).performClick()
composeTestRule.onNodeWithText(string(R.string.timers_empty)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertDoesNotExist()
}
@Test
fun backFromANonStartTabReturnsToAlarms() {
composeTestRule.onNodeWithText(string(R.string.tab_stopwatch)).performClick()
Espresso.pressBack()
composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertIsDisplayed()
assertThat(composeTestRule.activity.isFinishing).isFalse()
}
@Test
fun backFromAlarmsLeavesTheApp() {
Espresso.pressBackUnconditionally()
composeTestRule.activityRule.scenario.onActivity { }
assertThat(composeTestRule.activityRule.scenario.state).isEqualTo(Lifecycle.State.DESTROYED)
}
@Test
fun theSelectedTabSurvivesRecreation() {
composeTestRule.onNodeWithText(string(R.string.tab_world_clock)).performClick()
composeTestRule.activityRule.scenario.recreate()
composeTestRule.onNodeWithText(string(R.string.world_clocks_empty)).assertIsDisplayed()
}
}
@@ -8,22 +8,18 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell
import de.jeanlucmakiola.clockula.ui.shell.RequestNotificationPermissionOnce
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
@@ -33,9 +29,11 @@ import de.jeanlucmakiola.floret.prefs.isDark
import javax.inject.Inject
/**
* Single activity. M1 scaffolding: it brings up the theme — now following the
* stored appearance preference — and the crash surface, and nothing else. The
* four-tab shell and the live pill (docs/PLAN.md §9) land in M4.
* Single activity: the theme — following the stored appearance preference — the
* crash surface, and the four-tab shell with its live pill (docs/PLAN.md §9).
* Every screen above it is a destination inside that shell's `NavHost`; the
* ring screen is the one deliberate exception, since it has to come up over a
* lock screen on its own.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -84,7 +82,10 @@ class MainActivity : ComponentActivity() {
darkTheme = darkTheme,
dynamicColor = appearance.dynamicColor,
) {
Placeholder(Modifier.fillMaxSize())
ClockulaShell(Modifier.fillMaxSize())
// First launch asks for notifications once, and only here:
// it belongs to starting the app, not to any one tab.
RequestNotificationPermissionOnce(settingsPrefs)
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
@@ -118,24 +119,6 @@ class MainActivity : ComponentActivity() {
}
}
/** Stands in for the app shell until M4. Proves the theme resolves and renders. */
@Composable
private fun Placeholder(modifier: Modifier = Modifier) {
Surface(modifier, color = MaterialTheme.colorScheme.background) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringResourceAppName(),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
/** The scrims androidx uses for a three-button navigation bar, matched exactly. */
private val NAV_BAR_LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
private val NAV_BAR_DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
@Composable
private fun stringResourceAppName(): String =
androidx.compose.ui.res.stringResource(R.string.app_name)
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.ui.alarms
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen
/** A top-level tab: its title bar and its empty state. The alarm list is M5's. */
@Composable
fun AlarmsScreen(modifier: Modifier = Modifier) {
EmptyTabScreen(
title = stringResource(R.string.tab_alarms),
message = stringResource(R.string.alarms_empty),
modifier = modifier,
)
}
@@ -0,0 +1,12 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.annotation.StringRes
import de.jeanlucmakiola.clockula.R
/** The shell's four top-level destinations, in tab order. No Compose types. */
enum class ClockulaDestination(val route: String, @StringRes val titleRes: Int) {
ALARMS("alarms", R.string.tab_alarms),
TIMERS("timers", R.string.tab_timers),
STOPWATCH("stopwatch", R.string.tab_stopwatch),
WORLD_CLOCK("world_clock", R.string.tab_world_clock),
}
@@ -0,0 +1,141 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
import de.jeanlucmakiola.clockula.ui.stopwatch.StopwatchScreen
import de.jeanlucmakiola.clockula.ui.timers.TimersScreen
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
/**
* The adaptive navigation shell: four tabs, the live pill, predictive back.
*
* The navigation component itself is the M3 Expressive one chosen by window
* size — a short navigation bar on a phone, a wide rail on a tablet — because
* `NavigationSuiteScaffold`'s default already answers the tabletop posture and
* the compact-*height* case that an `if (compact) bar else rail` would miss.
*
* The `NavHost` is the single source of truth for the selected tab: Navigation
* Compose already saves its back stack through a `SavedStateHandle`, so the
* selection survives rotation and process death with no mirror of our own.
*/
@Composable
fun ClockulaShell(
modifier: Modifier = Modifier,
viewModel: ShellViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
) {
val pill by viewModel.livePill.collectAsStateWithLifecycle()
val entry by navController.currentBackStackEntryAsState()
val route = entry?.destination?.route
val selected = ShellNavigation.destinationOf(route) ?: ShellNavigation.start
val back = ShellNavigation.onBack(route)
// Peer destinations have no spatial relationship, so tabs fade through
// rather than sliding: a slide would claim a hierarchy that is not there.
val fade = fadeThrough()
NavigationSuiteScaffold(
navigationItems = {
ShellNavigation.topLevel.forEach { destination ->
val current = destination == selected
NavigationSuiteItem(
selected = current,
onClick = {
navController.replay(ShellNavigation.onTabSelected(route, destination))
},
// The label is right there beside it; announcing the glyph
// too would only say everything twice.
icon = { Icon(destination.icon(current), contentDescription = null) },
label = { Text(stringResource(destination.titleRes)) },
)
}
},
modifier = modifier.predictiveBack(
onBack = {
if (back is ShellBackAction.GoTo) {
navController.replay(ShellNavigation.onTabSelected(route, back.destination))
}
},
// On Alarms the handler stands aside, so the gesture previews
// *leaving the app* — which is what it is about to do.
enabled = back is ShellBackAction.GoTo,
),
) {
Box(Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalLivePillInset provides if (pill != null) ShellDefaults.LivePillBand else 0.dp,
) {
NavHost(
navController = navController,
startDestination = ShellNavigation.start.route,
enterTransition = { fade.targetContentEnter },
exitTransition = { fade.initialContentExit },
popEnterTransition = { fade.targetContentEnter },
popExitTransition = { fade.initialContentExit },
) {
composable(ClockulaDestination.ALARMS.route) { AlarmsScreen() }
composable(ClockulaDestination.TIMERS.route) { TimersScreen() }
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
composable(ClockulaDestination.WORLD_CLOCK.route) { WorldClockScreen() }
}
}
// In the pill's own band above the navigation component, not in the
// scaffold's primary-action slot: a collapsed wide rail is ~96dp
// across and cannot hold `1:23:45 ⏸ ⏹`. `windowInsetsPadding`
// honours what the nav suite already consumed, so this adds nothing
// in compact and clears the gesture bar on the rail layout.
LivePill(
state = pill,
onPrimaryAction = viewModel::onPillPrimaryAction,
onStop = viewModel::onPillStop,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal,
),
)
.padding(bottom = ShellDefaults.LivePillMargin),
)
}
}
}
/** Replays a [TabNavCommand] — the policy's decision — onto the controller. */
private fun NavHostController.replay(command: TabNavCommand) {
navigate(command.route) {
popUpTo(command.popUpTo) {
inclusive = command.popUpToInclusive
saveState = command.saveState
}
launchSingleTop = command.launchSingleTop
restoreState = command.restoreState
}
}
@@ -0,0 +1,191 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.HourglassEmpty
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
/**
* The running-state surface, reachable from every tab (docs/PLAN.md §9): what is
* counting, how far it has got, and the two controls — pause/resume and stop —
* that otherwise mean going to find the row that owns it. App-local by design:
* a clock's running state is not a shape the family shares.
*
* Enters and leaves like the kit's `SnackChip`, so the two pills in the family
* move alike, and eases its width when `9:59` becomes `10:00`.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun LivePill(
state: LivePillState?,
onPrimaryAction: (LivePillState) -> Unit,
onStop: (LivePillState) -> Unit,
modifier: Modifier = Modifier,
) {
// The pill springs *out* as well as in, so the state it was showing has to
// outlive the null that removed it — otherwise it would blink to nothing
// and then animate an empty pill away.
var shown by remember { mutableStateOf<LivePillState?>(null) }
if (state != null) shown = state
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
val origin = TransformOrigin(0.5f, 1f)
AnimatedVisibility(
visible = state != null,
enter = scaleIn(spatial, initialScale = 0.8f, transformOrigin = origin) + fadeIn(effects),
exit = scaleOut(effects, targetScale = 0.9f, transformOrigin = origin) + fadeOut(effects),
modifier = modifier,
) {
// Latched state, live callbacks: for the whole exit animation the body
// still draws (and still takes taps) the subject that has just gone
// away, so acting on it would pause or restart something the user
// already stopped. `state == null` is the moment that stops.
shown?.let { PillBody(it, state != null, onPrimaryAction, onStop) }
}
}
@Composable
private fun PillBody(
state: LivePillState,
/** False once the pill is on its way out: its subject is no longer current. */
live: Boolean,
onPrimaryAction: (LivePillState) -> Unit,
onStop: (LivePillState) -> Unit,
) {
// A finished timer is asking for something; a running one is only
// reporting. The tertiary container is the tonal step that says so.
val color = if (state.mode == LivePillMode.EXPIRED) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
Surface(
color = color,
contentColor = contentColorFor(color),
shape = RoundedCornerShape(50),
shadowElevation = 6.dp,
modifier = Modifier.height(ShellDefaults.LivePillHeight).animateContentSizeMotion(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 20.dp, end = 8.dp),
) {
// Beside a visible readout, the glyph is decoration: announcing it
// would only put a noun in front of every reading.
Icon(state.subject.icon(), contentDescription = null)
val spoken = state.spokenValue()
Text(
text = state.readout(),
style = MaterialTheme.typography.titleMedium,
// Not a live region: at 1 Hz that would recite the countdown
// for as long as it runs. A full sentence when focused, and
// silence otherwise.
modifier = Modifier.clearAndSetSemantics { contentDescription = spoken },
)
if (state.otherActiveTimers > 0) {
Text(
text = pluralStringResource(
R.plurals.live_pill_more_timers,
state.otherActiveTimers,
state.otherActiveTimers,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.canPause) {
val resume = state.mode == LivePillMode.PAUSED
IconButton(onClick = { if (live) onPrimaryAction(state) }) {
Icon(
imageVector = if (resume) Icons.Rounded.PlayArrow else Icons.Rounded.Pause,
contentDescription = stringResource(
if (resume) R.string.live_pill_resume else R.string.live_pill_pause,
),
)
}
}
IconButton(onClick = { if (live) onStop(state) }) {
Icon(
imageVector = Icons.Rounded.Stop,
contentDescription = stringResource(R.string.live_pill_stop),
)
}
}
}
}
/** Countdowns round up, elapsed truncates — the pill never reads ahead of reality. */
@Composable
private fun LivePillState.readout(): String = when (subject) {
is LivePillSubject.TimerSubject -> ClockFormat.countdown(value)
LivePillSubject.StopwatchSubject -> ClockFormat.elapsed(value)
}
/** The whole sentence a screen reader says when the readout takes focus (D26). */
@Composable
private fun LivePillState.spokenValue(): String {
val reading = readout()
return when (val subject = subject) {
is LivePillSubject.TimerSubject -> {
val label = subject.label.ifBlank { stringResource(R.string.live_pill_timer_untitled) }
when (mode) {
LivePillMode.RUNNING ->
stringResource(R.string.live_pill_timer_running, label, reading)
LivePillMode.PAUSED ->
stringResource(R.string.live_pill_timer_paused, label, reading)
LivePillMode.EXPIRED ->
stringResource(R.string.live_pill_timer_expired, label)
}
}
LivePillSubject.StopwatchSubject -> when (mode) {
LivePillMode.RUNNING -> stringResource(R.string.live_pill_stopwatch_running, reading)
else -> stringResource(R.string.live_pill_stopwatch_paused, reading)
}
}
}
private fun LivePillSubject.icon(): ImageVector = when (this) {
is LivePillSubject.TimerSubject -> Icons.Rounded.HourglassEmpty
LivePillSubject.StopwatchSubject -> Icons.Rounded.Timer
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.ui.shell
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.live.LivePillSelector
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
/** The two repositories and the ticker, combined into one pill state. */
@Singleton
internal class LivePillSource @Inject constructor(
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
private val elapsed: ElapsedRealtimeClock,
private val wall: WallClock,
private val ticker: Ticker,
) {
/** Null when nothing is active. Distinct-until-changed. */
fun state(): Flow<LivePillState?> =
// Three inputs, one answer: a repository write lands at once, and the
// seconds counting down cost nothing but a tick. The clocks are read
// inside the combine, so the value is always resolved *now*.
combine(timers.timers(), stopwatch.run(), ticker.ticks(ShellDefaults.PillTick)) { rows, run, _ ->
LivePillSelector.select(rows, run, elapsed.elapsedRealtime(), wall.now())
}.distinctUntilChanged()
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.clockula.ui.shell
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import kotlinx.coroutines.launch
/** What the one-shot ask should do, given what is known. Pure, so it is testable. */
internal enum class NotificationAsk {
/** The flag has not been read yet, or the ask has already happened. Do nothing. */
NOTHING,
/** Already granted: record the ask without putting a dialog in front of anyone. */
MARK_ONLY,
/** Put the request up. The flag is written from the *result*, never before it. */
REQUEST,
}
/**
* The decision behind [RequestNotificationPermissionOnce].
*
* The one rule that matters: when a request is due the answer is [REQUEST] and
* *only* [REQUEST] — marking the preference is what the result callback does.
* Writing the flag first would flip `asked`, cancel the effect that was about to
* launch the dialog, and leave an install recorded as "asked" that was never
* asked, with alarms silent and no second chance.
*/
internal fun notificationAskDecision(
asked: Boolean,
alreadyRequested: Boolean,
granted: Boolean,
): NotificationAsk = when {
asked || alreadyRequested -> NotificationAsk.NOTHING
granted -> NotificationAsk.MARK_ONLY
else -> NotificationAsk.REQUEST
}
/**
* Asks for POST_NOTIFICATIONS once per install, on API 33+. No-op below.
*
* Gated on a preference of our own rather than on the platform's two-strike
* suppression, so "we have asked" is a recorded fact instead of a guess. A
* denial is *not* explained here: the screen with room to say why an alarm
* might stay silent is M10's self-check, and that is where the exact-alarm and
* full-screen-intent grant pages are linked from too.
*
* The order is deliberate: launch, then record. The preference is the effect's
* key, so writing it before the launch races the recomposition that cancels the
* effect — and losing that race means the user is never prompted at all. If the
* process dies between the dialog appearing and the answer arriving, the flag
* stays false and the ask is simply due again, which is the failure worth
* having.
*/
@Composable
internal fun RequestNotificationPermissionOnce(prefs: SettingsPrefs) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val context = LocalContext.current
val scope = rememberCoroutineScope()
// True until the flag is read: never ask on the strength of a default.
val asked by prefs.notificationPermissionAsked.collectAsStateWithLifecycle(initialValue = true)
// Survives a rotation, so a configuration change while the system dialog is
// up does not stack a second one behind it.
var requested by rememberSaveable { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) {
// Either way the flag is set: one ask, whatever the answer. Written
// here, from the result, so it can only ever record a real ask.
scope.launch { prefs.markNotificationPermissionAsked() }
}
LaunchedEffect(asked) {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
when (notificationAskDecision(asked, requested, granted)) {
NotificationAsk.NOTHING -> Unit
NotificationAsk.MARK_ONLY -> prefs.markNotificationPermissionAsked()
NotificationAsk.REQUEST -> {
requested = true
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
@@ -0,0 +1,75 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** The pill's measurements, and the cadence its readout advances on. */
object ShellDefaults {
val LivePillHeight: Dp = 56.dp
val LivePillMargin: Dp = 16.dp
/** Height + margin — what tab content keeps clear while the pill is up. */
val LivePillBand: Dp = LivePillHeight + LivePillMargin
/**
* One second: the pill formats to whole seconds, so anything faster is
* recomposition nobody can see. The stopwatch tab's centisecond readout is
* M7's, and will ask the ticker for its own period.
*/
val PillTick: Duration = 1.seconds
}
/**
* Extra bottom inset a tab's content should add. Zero when no pill is showing.
*
* `compositionLocalOf`, not the static form: the value changes whenever the
* pill comes and goes, and a static local would recompose the whole subtree.
*/
val LocalLivePillInset: ProvidableCompositionLocal<Dp> = compositionLocalOf { 0.dp }
/**
* A top-level tab's title bar over a single sentence on a grouped surface —
* the shape M4's four tabs share while their content belongs to M5–M8. Each
* tab keeps the pill's band clear through [LocalLivePillInset], so a countdown
* never sits on top of the last row.
*
* Lives here rather than being written out four times: the tabs differ only in
* two strings today, and each of M5–M8 replaces its own tab outright.
*/
@Composable
internal fun EmptyTabScreen(
title: String,
message: String,
modifier: Modifier = Modifier,
) {
CollapsingScaffold(title = title, modifier = modifier) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = GroupedListInset),
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp),
)
}
Spacer(Modifier.height(LocalLivePillInset.current))
}
}
@@ -0,0 +1,25 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Alarm
import androidx.compose.material.icons.outlined.HourglassEmpty
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.HourglassEmpty
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
/** The navigation item's icon, filled when selected and outlined when not. */
@Composable
internal fun ClockulaDestination.icon(selected: Boolean): ImageVector = when (this) {
// The hourglass is the countdown and the stopwatch face is the count-up:
// two clocks that would otherwise wear the same glyph in the same bar.
ClockulaDestination.ALARMS -> if (selected) Icons.Rounded.Alarm else Icons.Outlined.Alarm
ClockulaDestination.TIMERS ->
if (selected) Icons.Rounded.HourglassEmpty else Icons.Outlined.HourglassEmpty
ClockulaDestination.STOPWATCH -> if (selected) Icons.Rounded.Timer else Icons.Outlined.Timer
ClockulaDestination.WORLD_CLOCK -> if (selected) Icons.Rounded.Public else Icons.Outlined.Public
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.clockula.ui.shell
/** What a tab tap does to the back stack. Pure data; the composable replays it. */
data class TabNavCommand(
val route: String,
val popUpTo: String,
val popUpToInclusive: Boolean,
val saveState: Boolean,
val restoreState: Boolean,
val launchSingleTop: Boolean,
)
sealed interface ShellBackAction {
data class GoTo(val destination: ClockulaDestination) : ShellBackAction
/** Not ours — let the dispatcher continue, which leaves the app. */
data object Exit : ShellBackAction
}
/** The shell's navigation policy, as pure functions over routes. */
object ShellNavigation {
val start: ClockulaDestination = ClockulaDestination.ALARMS
val topLevel: List<ClockulaDestination> = ClockulaDestination.entries.toList()
private val byRoute: Map<String, ClockulaDestination> = topLevel.associateBy { it.route }
/** Exact match only: a nested route is not a tab. */
fun destinationOf(route: String?): ClockulaDestination? = byRoute[route]
/**
* The canonical multi-back-stack command. Re-selecting the tab the user is
* already on does *not* restore its saved stack, which pops that tab back
* to its own root — the standard behaviour, and the contract M5's editor
* will rely on.
*/
fun onTabSelected(currentRoute: String?, target: ClockulaDestination): TabNavCommand =
TabNavCommand(
route = target.route,
popUpTo = start.route,
popUpToInclusive = false,
saveState = true,
restoreState = currentRoute != target.route,
launchSingleTop = true,
)
/**
* Alarms is the shell's root: back from any other tab returns to it, and
* back from Alarms leaves the app. An unrecognised route is a nested
* destination the `NavController` pops itself, so the shell stands aside.
*/
fun onBack(currentRoute: String?): ShellBackAction = when (destinationOf(currentRoute)) {
null, start -> ShellBackAction.Exit
else -> ShellBackAction.GoTo(start)
}
}
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/** The pill's state and its two actions, shared by every tab. */
@HiltViewModel
class ShellViewModel @Inject internal constructor(
source: LivePillSource,
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
) : ViewModel() {
val livePill: StateFlow<LivePillState?> = source.state()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), null)
/** Pause a running subject, resume a paused one. No-op when [LivePillState.canPause] is false. */
fun onPillPrimaryAction(state: LivePillState) {
if (!state.canPause) return
val resume = state.mode == LivePillMode.PAUSED
viewModelScope.launch {
when (val subject = state.subject) {
is LivePillSubject.TimerSubject ->
if (resume) timers.start(subject.timerId) else timers.pause(subject.timerId)
LivePillSubject.StopwatchSubject ->
if (resume) stopwatch.start() else stopwatch.pause()
}
}
}
/** Stop: `reset()`, never `delete()`. */
fun onPillStop(state: LivePillState) {
viewModelScope.launch {
when (val subject = state.subject) {
// Reset, not delete: the pill must not be able to destroy a
// timer the user configured and will want again.
is LivePillSubject.TimerSubject -> timers.reset(subject.timerId)
LivePillSubject.StopwatchSubject -> stopwatch.reset()
}
}
}
private companion object {
/** Long enough to survive a rotation without re-subscribing the repositories. */
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import kotlin.time.Duration
/**
* A top-level tab: its title bar and the readout, standing at zero. Starting,
* lapping and the readout's final typography are M7's — which is why the
* figure here is the same [ClockFormat] a running stopwatch will print.
*/
@Composable
fun StopwatchScreen(modifier: Modifier = Modifier) {
CollapsingScaffold(title = stringResource(R.string.tab_stopwatch), modifier = modifier) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = GroupedListInset),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = ClockFormat.elapsed(Duration.ZERO),
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Spacer(Modifier.height(LocalLivePillInset.current))
}
}
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen
/** A top-level tab: its title bar and its empty state. Creating a timer is M6's. */
@Composable
fun TimersScreen(modifier: Modifier = Modifier) {
EmptyTabScreen(
title = stringResource(R.string.tab_timers),
message = stringResource(R.string.timers_empty),
modifier = modifier,
)
}
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.ui.worldclock
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen
/** A top-level tab: its title bar and its empty state. The zone list is M8's. */
@Composable
fun WorldClockScreen(modifier: Modifier = Modifier) {
EmptyTabScreen(
title = stringResource(R.string.tab_world_clock),
message = stringResource(R.string.world_clocks_empty),
modifier = modifier,
)
}
+34
View File
@@ -15,4 +15,38 @@
<!-- Crash reporting (floret-kit core-crash) -->
<string name="crash_report_issue_title">Crash report</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</string>
<!-- M4: the app shell's four tabs -->
<string name="tab_alarms">Alarms</string>
<string name="tab_timers">Timers</string>
<string name="tab_stopwatch">Stopwatch</string>
<string name="tab_world_clock">World clock</string>
<!-- M4: the tabs' empty states -->
<string name="alarms_empty">No alarms yet</string>
<string name="timers_empty">No timers yet</string>
<string name="world_clocks_empty">No world clocks yet</string>
<!-- M4: the live pill -->
<string name="live_pill_pause">Pause</string>
<string name="live_pill_resume">Resume</string>
<string name="live_pill_stop">Stop</string>
<plurals name="live_pill_more_timers">
<item quantity="one">+%1$d more</item>
<item quantity="other">+%1$d more</item>
</plurals>
<string name="live_pill_timer_running">Timer %1$s, %2$s remaining</string>
<string name="live_pill_timer_paused">Timer %1$s paused, %2$s remaining</string>
<string name="live_pill_timer_expired">Timer %1$s finished</string>
<string name="live_pill_timer_untitled">Timer</string>
<string name="live_pill_stopwatch_running">Stopwatch running, %1$s</string>
<string name="live_pill_stopwatch_paused">Stopwatch paused at %1$s</string>
<!-- M4: the ring screen's dismiss challenge -->
<string name="ring_challenge_math_prompt">What is %1$s?</string>
<string name="ring_challenge_math_hint">Answer</string>
<string name="ring_challenge_wrong">Not quite \u2014 here is another</string>
<string name="ring_challenge_hold">Hold to dismiss</string>
<string name="ring_challenge_escape">Dismiss without solving</string>
<string name="ring_snooze_unavailable">No snoozes left</string>
</resources>
@@ -0,0 +1,181 @@
package de.jeanlucmakiola.clockula.ui.shell
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeElapsedRealtimeClock
import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository
import de.jeanlucmakiola.clockula.testing.FakeTicker
import de.jeanlucmakiola.clockula.testing.FakeTimerRepository
import de.jeanlucmakiola.clockula.testing.FakeWallClock
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.runningTimer
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The pill's wiring: two repositories and one injected ticker, combined into a
* single distinct-until-changed stream. A repository write lands at once; the
* seconds ticking down cost nothing but a tick.
*/
class LivePillSourceTest {
private val now = 1.hours
private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00")
private val timers = FakeTimerRepository()
private val stopwatch = FakeStopwatchRepository()
private val elapsed = FakeElapsedRealtimeClock(now)
private val wall = FakeWallClock(wallInstant)
private val ticker = FakeTicker()
private val source = LivePillSource(timers, stopwatch, elapsed, wall, ticker)
private fun running(id: Long = 7L, label: String = "Pasta", left: Duration): Timer =
runningTimer().copy(
id = id,
label = label,
state = TimerState.RUNNING,
remaining = left,
startedAtElapsedRealtime = Duration.ZERO,
endsAtElapsedRealtime = now + left,
endsAtWallClock = wallInstant + left,
sortOrder = 0,
)
private fun pausedForm(timer: Timer): Timer = timer.copy(
state = TimerState.PAUSED,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
@Test
fun `nothing active emits no pill`() = runTest {
source.state().test {
assertThat(awaitItem()).isNull()
cancel()
}
}
@Test
fun `a timer appearing in the repository raises the pill`() = runTest {
source.state().test {
assertThat(awaitItem()).isNull()
timers.set(listOf(running(left = 4.minutes)))
assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta"))
cancel()
}
}
@Test
fun `the readout is driven at the shell's pill cadence`() = runTest {
source.state().test {
awaitItem()
assertThat(ticker.requestedPeriods).containsExactly(ShellDefaults.PillTick)
cancel()
}
}
@Test
fun `a tick advances the readout without a repository write`() = runTest {
timers.set(listOf(running(left = 3.minutes)))
source.state().test {
assertThat(awaitItem()?.value).isEqualTo(3.minutes)
elapsed.advance(60.seconds)
ticker.tick()
assertThat(awaitItem()?.value).isEqualTo(2.minutes)
assertThat(timers.calls).isEmpty()
cancel()
}
}
@Test
fun `a tick against a frozen clock emits nothing`() = runTest {
timers.set(listOf(running(left = 3.minutes)))
source.state().test {
awaitItem()
ticker.tick()
ticker.tick()
expectNoEvents()
cancel()
}
}
@Test
fun `pausing the timer reaches the pill without waiting for a tick`() = runTest {
val timer = running(left = 3.minutes)
timers.set(listOf(timer))
source.state().test {
assertThat(awaitItem()?.mode).isEqualTo(LivePillMode.RUNNING)
timers.set(listOf(pausedForm(timer)))
assertThat(awaitItem()?.mode).isEqualTo(LivePillMode.PAUSED)
cancel()
}
}
@Test
fun `emptying the repository lowers the pill`() = runTest {
timers.set(listOf(running(left = 3.minutes)))
source.state().test {
assertThat(awaitItem()).isNotNull()
timers.set(emptyList())
assertThat(awaitItem()).isNull()
cancel()
}
}
@Test
fun `a timer takes the pill over from a running stopwatch`() = runTest {
stopwatch.set(
StopwatchRun(
state = StopwatchState.RUNNING,
startedAtElapsedRealtime = now - 30.seconds,
),
)
source.state().test {
assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.StopwatchSubject)
timers.set(listOf(running(left = 3.minutes)))
assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta"))
cancel()
}
}
@Test
fun `a fresh collector is never blank for a cadence`() = runTest {
timers.set(listOf(running(left = 3.minutes)))
source.state().test {
assertThat(awaitItem()?.value).isEqualTo(3.minutes)
cancel()
}
}
}
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.clockula.ui.shell
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The one-shot notification ask, minus Compose: whether a request is due, and —
* the part that was a bug — that "record the ask" is never the answer when a
* dialog still has to be put up. The preference is the effect's key, so a write
* before the launch cancels the very effect that was about to ask, and the
* install ends up recorded as asked while the user was never prompted.
*/
class NotificationPermissionTest {
@Test
fun `a request that is due asks, and does not record the ask first`() {
val decision = notificationAskDecision(asked = false, alreadyRequested = false, granted = false)
assertThat(decision).isEqualTo(NotificationAsk.REQUEST)
}
@Test
fun `an unread flag reads as asked, so nothing happens on a default`() {
val decision = notificationAskDecision(asked = true, alreadyRequested = false, granted = false)
assertThat(decision).isEqualTo(NotificationAsk.NOTHING)
}
@Test
fun `an already granted permission is recorded without a dialog`() {
val decision = notificationAskDecision(asked = false, alreadyRequested = false, granted = true)
assertThat(decision).isEqualTo(NotificationAsk.MARK_ONLY)
}
@Test
fun `a request already up is not put up twice`() {
val decision = notificationAskDecision(asked = false, alreadyRequested = true, granted = false)
assertThat(decision).isEqualTo(NotificationAsk.NOTHING)
}
@Test
fun `having asked once, a later grant changes nothing`() {
val decision = notificationAskDecision(asked = true, alreadyRequested = true, granted = true)
assertThat(decision).isEqualTo(NotificationAsk.NOTHING)
}
}
@@ -0,0 +1,141 @@
package de.jeanlucmakiola.clockula.ui.shell
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The shell's navigation policy as pure data: which tab is the root, what a tab
* tap does to the back stack, and where Back goes. The four lines that replay a
* [TabNavCommand] onto a `NavController` are not worth testing; this is.
*/
class ShellNavigationTest {
@Test
fun `the four tabs are listed in tab order`() {
val tabs = ShellNavigation.topLevel
assertThat(tabs).containsExactly(
ClockulaDestination.ALARMS,
ClockulaDestination.TIMERS,
ClockulaDestination.STOPWATCH,
ClockulaDestination.WORLD_CLOCK,
).inOrder()
}
@Test
fun `every tab has its own non-blank route`() {
val routes = ShellNavigation.topLevel.map { it.route }
assertThat(routes.filter { it.isNotBlank() }.toSet()).hasSize(4)
}
@Test
fun `every tab has its own title resource`() {
val titles = ShellNavigation.topLevel.map { it.titleRes }
assertThat(titles.filter { it != 0 }.toSet()).hasSize(4)
}
@Test
fun `alarms is the start destination`() {
assertThat(ShellNavigation.start).isEqualTo(ClockulaDestination.ALARMS)
}
@Test
fun `each tab's own route resolves back to it`() {
val resolved = ClockulaDestination.entries.map { ShellNavigation.destinationOf(it.route) }
assertThat(resolved).isEqualTo(ClockulaDestination.entries.toList())
}
@Test
fun `a null route resolves to no destination`() {
assertThat(ShellNavigation.destinationOf(null)).isNull()
}
@Test
fun `an unknown route resolves to no destination`() {
assertThat(ShellNavigation.destinationOf("nope")).isNull()
}
@Test
fun `a nested route does not resolve by prefix`() {
assertThat(ShellNavigation.destinationOf("alarms/12")).isNull()
}
@Test
fun `selecting another tab saves and restores the tab's own stack`() {
val command = ShellNavigation.onTabSelected("alarms", ClockulaDestination.TIMERS)
assertThat(command).isEqualTo(
TabNavCommand(
route = "timers",
popUpTo = "alarms",
popUpToInclusive = false,
saveState = true,
restoreState = true,
launchSingleTop = true,
),
)
}
@Test
fun `re-selecting the current tab pops its own stack instead of restoring it`() {
val command = ShellNavigation.onTabSelected("timers", ClockulaDestination.TIMERS)
assertThat(command).isEqualTo(
TabNavCommand(
route = "timers",
popUpTo = "alarms",
popUpToInclusive = false,
saveState = true,
restoreState = false,
launchSingleTop = true,
),
)
}
@Test
fun `selecting a tab before the first destination is known restores its stack`() {
val command = ShellNavigation.onTabSelected(null, ClockulaDestination.TIMERS)
assertThat(command.restoreState).isTrue()
}
@Test
fun `every tab tap pops up to the start destination`() {
val popUpTos = ClockulaDestination.entries.flatMap { current ->
ClockulaDestination.entries.map { target ->
ShellNavigation.onTabSelected(current.route, target).popUpTo
}
}
assertThat(popUpTos.toSet()).containsExactly(ShellNavigation.start.route)
}
@Test
fun `back from alarms leaves the app`() {
assertThat(ShellNavigation.onBack("alarms")).isEqualTo(ShellBackAction.Exit)
}
@Test
fun `back from any other tab returns to alarms`() {
val actions = listOf("timers", "stopwatch", "world_clock").map(ShellNavigation::onBack)
assertThat(actions).containsExactly(
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
)
}
@Test
fun `back before the first destination is known leaves the app`() {
assertThat(ShellNavigation.onBack(null)).isEqualTo(ShellBackAction.Exit)
}
@Test
fun `back from a nested destination is not the shell's to intercept`() {
assertThat(ShellNavigation.onBack("alarms/12")).isEqualTo(ShellBackAction.Exit)
}
}
@@ -0,0 +1,163 @@
package de.jeanlucmakiola.clockula.ui.shell
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeElapsedRealtimeClock
import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository
import de.jeanlucmakiola.clockula.testing.FakeTicker
import de.jeanlucmakiola.clockula.testing.FakeTimerRepository
import de.jeanlucmakiola.clockula.testing.FakeWallClock
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.runningTimer
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* The pill's two buttons, and the promise behind them: the primary one pauses
* or resumes whatever the pill is showing, and Stop is always `reset` — the
* pill must not be able to destroy a timer the user will want again.
*/
class ShellViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private val now = 1.hours
private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00")
private val timers = FakeTimerRepository()
private val stopwatch = FakeStopwatchRepository()
private val ticker = FakeTicker()
private fun viewModel() = ShellViewModel(
source = LivePillSource(
timers = timers,
stopwatch = stopwatch,
elapsed = FakeElapsedRealtimeClock(now),
wall = FakeWallClock(wallInstant),
ticker = ticker,
),
timers = timers,
stopwatch = stopwatch,
)
private fun timerState(id: Long = 7L, mode: LivePillMode) = LivePillState(
subject = LivePillSubject.TimerSubject(id, "Pasta"),
mode = mode,
value = 4.minutes,
otherActiveTimers = 0,
)
private fun stopwatchState(mode: LivePillMode) = LivePillState(
subject = LivePillSubject.StopwatchSubject,
mode = mode,
value = 4.minutes,
otherActiveTimers = 0,
)
private fun runningTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer =
runningTimer().copy(
id = id,
state = TimerState.RUNNING,
remaining = left,
startedAtElapsedRealtime = Duration.ZERO,
endsAtElapsedRealtime = now + left,
endsAtWallClock = wallInstant + left,
)
@Test
fun `the pill starts empty and then follows the source`() = runTest(main.dispatcher) {
val viewModel = viewModel()
viewModel.livePill.test {
assertThat(awaitItem()).isNull()
timers.set(listOf(runningTimerRow()))
assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta"))
cancel()
}
}
@Test
fun `the primary action pauses a running timer`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING))
assertThat(timers.calls.last()).isEqualTo("pause(7)")
}
@Test
fun `the primary action resumes a paused timer`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED))
assertThat(timers.calls.last()).isEqualTo("start(7)")
}
@Test
fun `the primary action pauses a running stopwatch`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING))
assertThat(stopwatch.calls.last()).isEqualTo("pause()")
}
@Test
fun `the primary action resumes a paused stopwatch`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.PAUSED))
assertThat(stopwatch.calls.last()).isEqualTo("start()")
}
@Test
fun `an expired timer has nothing left to pause`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.EXPIRED))
assertThat(timers.calls + stopwatch.calls).isEmpty()
}
@Test
fun `stop resets a timer in any mode and never deletes it`() = runTest(main.dispatcher) {
val viewModel = viewModel()
LivePillMode.entries.forEach { viewModel.onPillStop(timerState(mode = it)) }
assertThat(timers.calls).containsExactly("reset(7)", "reset(7)", "reset(7)")
}
@Test
fun `stop resets the stopwatch`() = runTest(main.dispatcher) {
viewModel().onPillStop(stopwatchState(mode = LivePillMode.RUNNING))
assertThat(stopwatch.calls.last()).isEqualTo("reset()")
}
@Test
fun `stopping the only active subject lowers the pill`() = runTest(main.dispatcher) {
timers.set(listOf(runningTimerRow()))
stopwatch.set(StopwatchRun(state = StopwatchState.IDLE))
val viewModel = viewModel()
viewModel.livePill.test {
val showing = awaitItem()
assertThat(showing).isNotNull()
viewModel.onPillStop(showing!!)
assertThat(awaitItem()).isNull()
cancel()
}
}
}