The Timers tab gets a full rewrite of how a timer is created, after Google Clock as the interaction reference (PLAN.md §11): - A dedicated TimerSetupScreen replaces the bottom-sheet/inline setup panel: a per-unit "00h 00m 00s" readout, a "00" key, and a big centred play button that never shifts position when Clear fades in beside it. - The keypad grows into the thumb zone on its own screen (92dp keys, pushed toward the bottom), and both it and the readout read bold. - Presets are dropped from the flow; TimerDurationEntry gains the matching domain support (hoursSegment/minutesSegment/secondsSegment, plusDoubleZero()). - An empty timer list shows the same readout/keypad/Start inline, in place of a "tap + to add" card — the keypad is still the empty state, just the redesigned one. - Timer cards are disconnected like the Alarms tab's own list, each with an instant, unconfirmed delete via a small corner button (re-creating a timer is faster than reading a confirmation dialog would be). - Hero readouts go bold app-wide to match: stopwatch, alarm rows, world clock, the ring screen, and both editors' length/time displays (ClockulaReadoutDefaults.Hero and matching call sites). Alongside: Android 16 Live Update support for the running-timer notification (POST_PROMOTED_NOTIFICATIONS, ProgressStyle), the live pill restyled to a plain tonal circle, the alarm dismiss-chooser dialog moved onto Floret's OptionCard, and small fixes to addTime's zero/negative-result handling, repeat-day selection and alarm routes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
145 lines
5.1 KiB
Kotlin
145 lines
5.1 KiB
Kotlin
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.onNodeWithContentDescription
|
|
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.stopwatch.StopwatchRepository
|
|
import de.jeanlucmakiola.clockula.domain.Lap
|
|
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
|
|
|
|
/**
|
|
* §5.14 — 4 cases. What only a device can answer about the Stopwatch tab: that
|
|
* a real clock really moves the hero readout, that Lap really appends rows,
|
|
* that the tab survives a real activity recreation because its state is storage
|
|
* and not a `rememberSaveable`, and that Back lands on Alarms.
|
|
*
|
|
* Compiled in the gate; **not run here** — no device is attached (§5.14).
|
|
*/
|
|
@RunWith(AndroidJUnit4::class)
|
|
class StopwatchInstrumentedTest {
|
|
|
|
@EntryPoint
|
|
@InstallIn(SingletonComponent::class)
|
|
interface Seams {
|
|
fun stopwatch(): StopwatchRepository
|
|
}
|
|
|
|
@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.stopwatch().reset() }
|
|
}
|
|
|
|
private fun string(id: Int): String = context.getString(id)
|
|
|
|
private fun storedLaps(): List<Lap> = runBlocking { seams.stopwatch().laps().first() }
|
|
|
|
private fun openStopwatchTab(): 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_stopwatch)).performClick()
|
|
composeTestRule.waitForIdle()
|
|
return launched
|
|
}
|
|
|
|
private fun startAndLap(times: Int) {
|
|
composeTestRule.onNodeWithText(string(R.string.stopwatch_start)).performClick()
|
|
composeTestRule.waitForIdle()
|
|
repeat(times) {
|
|
composeTestRule.onNodeWithText(string(R.string.stopwatch_lap)).performClick()
|
|
composeTestRule.waitForIdle()
|
|
}
|
|
}
|
|
|
|
/** §5.14 #1 */
|
|
@Test
|
|
fun startingReallyAdvancesTheReadoutAndOffersPauseAndLap() {
|
|
openStopwatchTab()
|
|
|
|
composeTestRule.onNodeWithText(string(R.string.stopwatch_start)).performClick()
|
|
composeTestRule.waitForIdle()
|
|
|
|
composeTestRule.onNodeWithText(string(R.string.stopwatch_pause)).assertIsDisplayed()
|
|
composeTestRule.onNodeWithText(string(R.string.stopwatch_lap)).assertIsDisplayed()
|
|
// Polled, never slept on: the hero figure has to leave zero by itself.
|
|
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
composeTestRule.onAllNodesWithText("0:00", substring = true)
|
|
.fetchSemanticsNodes()
|
|
.isEmpty()
|
|
}
|
|
}
|
|
|
|
/** §5.14 #2 */
|
|
@Test
|
|
fun newLapsStayVisibleAtTheTopOfTheList() {
|
|
openStopwatchTab()
|
|
|
|
startAndLap(times = 4)
|
|
|
|
assertThat(storedLaps().map { it.index }).containsExactly(1, 2, 3, 4).inOrder()
|
|
composeTestRule
|
|
.onNodeWithContentDescription(context.getString(R.string.stopwatch_lap_number, 5), substring = true)
|
|
.assertIsDisplayed()
|
|
}
|
|
|
|
/** §5.14 #3 */
|
|
@Test
|
|
fun theReadoutAndTheLapListSurviveAnActivityRecreation() {
|
|
val launched = openStopwatchTab()
|
|
startAndLap(times = 2)
|
|
|
|
launched.recreate()
|
|
composeTestRule.waitForIdle()
|
|
|
|
assertThat(storedLaps()).hasSize(2)
|
|
composeTestRule.onNodeWithText(string(R.string.stopwatch_laps_header_total)).assertIsDisplayed()
|
|
composeTestRule.onNodeWithText(string(R.string.stopwatch_pause)).assertIsDisplayed()
|
|
}
|
|
|
|
/** §5.14 #4 */
|
|
@Test
|
|
fun backFromTheStopwatchTabLandsOnAlarms() {
|
|
openStopwatchTab()
|
|
|
|
Espresso.pressBack()
|
|
composeTestRule.waitForIdle()
|
|
|
|
composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertIsDisplayed()
|
|
assertThat(scenario?.state).isNotEqualTo(Lifecycle.State.DESTROYED)
|
|
}
|
|
}
|