feat(timers): the timers tab, and the pill that finally has something to show

Multiple concurrent timers with labels, presets, add, pause, reset and +1 min.
The setup panel is inline on an empty tab, so the first timer costs no
navigation.

The live pill has been waiting since M4 for something that could start a
timer. It now goes through the engine rather than the repository, because a
scheduler registration and a foreground service have to move with the state —
its own contract is unchanged.

Three new architecture rules keep it that way: no screen may name the
scheduler, the service or AlarmManager, and a second MediaPlayer anywhere
outside the ring path fails the build.
This commit is contained in:
2026-09-12 16:48:36 +02:00
parent 7f9219e929
commit b67142a727
24 changed files with 2733 additions and 12 deletions
@@ -0,0 +1,154 @@
package de.jeanlucmakiola.clockula.ui
import android.content.Context
import android.content.Intent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
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 dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.19 — 4 cases. What only a device can answer about the Timers tab: that the
* keypad and the bottom sheet really compose and really write, that the typed
* digits survive a real activity recreation (which is the only thing that
* proves `rememberSaveable`, D23), and that a real system back press from the
* editor lands on the tab with the tab still selected (D24).
*
* Compiled in the gate; **not run here** — no device is attached (§5.19).
*/
@RunWith(AndroidJUnit4::class)
class TimersInstrumentedTest {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Seams {
fun timers(): TimerRepository
}
@get:Rule
val composeTestRule = createEmptyComposeRule()
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val seams: Seams =
EntryPointAccessors.fromApplication(context.applicationContext, Seams::class.java)
private var scenario: ActivityScenario<MainActivity>? = null
@After
fun tearDown() {
scenario?.close()
runBlocking { seams.timers().timers().first().forEach { seams.timers().delete(it.id) } }
}
private fun string(id: Int): String = context.getString(id)
private fun stored(): List<Timer> = runBlocking { seams.timers().timers().first() }
private fun givenTimer(label: String, running: Boolean = true): Long = runBlocking {
val id = seams.timers().create(TimerDraft(5.minutes, label))
if (running) seams.timers().start(id)
id
}
private fun openTimersTab(): ActivityScenario<MainActivity> {
val launched = ActivityScenario.launch<MainActivity>(
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
).also { scenario = it }
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.tab_timers)).performClick()
composeTestRule.waitForIdle()
return launched
}
private fun typeDigits(vararg digits: Char) {
digits.forEach {
composeTestRule.onNodeWithText(it.toString()).performClick()
composeTestRule.waitForIdle()
}
}
/** §5.19 #1 */
@Test
fun typingOnTheKeypadAndPressingStartCreatesOneRunningTimer() {
openTimersTab()
typeDigits('1', '3', '0')
composeTestRule.onNodeWithText(string(R.string.timers_setup_start)).performClick()
composeTestRule.waitForIdle()
val timers = stored()
assertThat(timers.map { it.state to it.duration })
.containsExactly(TimerState.RUNNING to 90.seconds)
}
/** §5.19 #2 */
@Test
fun theTypedDigitsSurviveAnActivityRecreation() {
val launched = openTimersTab()
typeDigits('1', '3', '0')
launched.recreate()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("0:01:30").assertIsDisplayed()
}
/** §5.19 #3 */
@Test
fun pausingOneRowLeavesTheOtherRunning() {
val paused = givenTimer("Pasta")
val untouched = givenTimer("Eggs")
openTimersTab()
// Storage order, so the first row is Pasta's (D22).
composeTestRule.onAllNodesWithText(string(R.string.timer_pause))[0].performClick()
composeTestRule.waitForIdle()
val byId = stored().associateBy { it.id }
assertThat(byId[paused]?.state to byId[untouched]?.state)
.isEqualTo(TimerState.PAUSED to TimerState.RUNNING)
}
/** §5.19 #4 */
@Test
fun backFromTheTimerEditorReturnsToTheTimersTab() {
givenTimer("Pasta", running = false)
openTimersTab()
// The card announces its own content and labels the tap "Timer settings"
// through `onClick`, so the row is found by the timer it is.
composeTestRule.onNodeWithText("Pasta").performClick()
composeTestRule.waitForIdle()
Espresso.pressBack()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Pasta").assertIsDisplayed()
assertThat(scenario?.state).isNotEqualTo(Lifecycle.State.DESTROYED)
}
}