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