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()
}
}