feat(alarms): the list and the editor

The tab the app has been missing. Rows carry the time, the repeat summary and
the next-fire countdown, ordered by time of day rather than by when they fire
next — a row must not move under the thumb reaching for it, so the next alarm
is marked by colour instead. The editor covers time, repeat days, label,
ringtone, vibration and snooze, with the five per-alarm overrides as
three-state pickers: a switch cannot say "I have not chosen".

Two things that look like details and are not. A ringing alarm is dismissed
through the engine before it is disabled, edited or deleted — otherwise its
state is cleared while the service keeps sounding and the auto-silence backstop
returns early on its own guard, leaving the alarm ringing until the wake-lock
timeout. And clearing the last repeat day disarms a pending skip, because
otherwise the resolver disables the alarm and the control that would have
explained it is already hidden.

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 14:43:46 +02:00
co-authored by Claude Opus 5
parent 4662aee90d
commit 97ca617e6a
25 changed files with 3530 additions and 7 deletions
@@ -0,0 +1,216 @@
package de.jeanlucmakiola.clockula.ui
import android.content.Context
import android.content.Intent
import android.text.format.DateFormat
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
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.alarms.AlarmRepository
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.TimeOfDay
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 java.time.DayOfWeek
import java.time.format.TextStyle
import java.util.Calendar
import java.util.Locale
/**
* What only a device can answer about the alarms tab: that the row's switch,
* the FAB, the M3 time picker, the day toggles, the ringtone picker and the
* delete confirmation really compose and really write.
*
* Compiled in the gate; **not run here** — no device is attached (M5 §5.17).
*/
@RunWith(AndroidJUnit4::class)
class AlarmsInstrumentedTest {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Seams {
fun alarms(): AlarmRepository
fun states(): AlarmStateRepository
}
@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.states().clearAll()
seams.alarms().alarms().first().forEach { seams.alarms().delete(it.id) }
}
}
private fun string(id: Int): String = context.getString(id)
/** The alarm's time as the device itself renders it, 12- or 24-hour. */
private fun timeText(hour: Int, minute: Int): String {
val calendar = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
}
return DateFormat.getTimeFormat(context).format(calendar.time)
}
private fun givenAlarm(label: String = "Work"): Long = runBlocking {
seams.alarms().create(AlarmDraft(time = TimeOfDay(7, 0), label = label))
}
private fun stored(id: Long) = runBlocking { seams.alarms().find(id) }
private fun launch(): ActivityScenario<MainActivity> =
ActivityScenario.launch<MainActivity>(
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
).also { scenario = it }
private fun openEditorForNewAlarm() {
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(string(R.string.alarms_add)).performClick()
composeTestRule.waitForIdle()
}
@Test
fun tappingTheSwitchDisablesTheAlarm() {
val id = givenAlarm()
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(
context.getString(R.string.alarm_row_toggle, timeText(7, 0)),
).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(
context.getString(R.string.alarm_row_toggle, timeText(7, 0)),
).assertIsOff()
assertThat(stored(id)!!.enabled).isFalse()
}
@Test
fun theFabCreatesAnAlarmAndOpensItsEditor() {
openEditorForNewAlarm()
composeTestRule.onNodeWithText(string(R.string.editor_title)).assertIsDisplayed()
assertThat(runBlocking { seams.alarms().alarms().first() }).hasSize(1)
}
@Test
fun theTimePickerWritesTheTypedTime() {
val id = givenAlarm()
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(timeText(7, 0), substring = true).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription("hour", substring = true)
.performTextReplacement("06")
composeTestRule.onNodeWithContentDescription("minute", substring = true)
.performTextReplacement("45")
composeTestRule.onNodeWithText(string(R.string.action_ok)).performClick()
composeTestRule.waitForIdle()
assertThat(stored(id)!!.time).isEqualTo(TimeOfDay(6, 45))
}
@Test
fun togglingADayReplacesTheOnceSummary() {
val id = givenAlarm()
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(
DayOfWeek.TUESDAY.getDisplayName(TextStyle.FULL, Locale.getDefault()),
).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.alarm_repeat_once)).assertDoesNotExist()
assertThat(DayOfWeek.TUESDAY in stored(id)!!.repeatDays).isTrue()
}
@Test
fun leavingTheRingtonePickerChangesNothing() {
val id = givenAlarm()
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.editor_ringtone)).performClick()
composeTestRule.waitForIdle()
Espresso.pressBack()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.ringtone_picker_title)).assertDoesNotExist()
assertThat(stored(id)!!.ringtoneUri).isNull()
}
@Test
fun backFromTheEditorReturnsToTheAlarmsTab() {
givenAlarm()
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(
string(de.jeanlucmakiola.floret.components.R.string.back),
).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.tab_alarms)).assertIsDisplayed()
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
}
@Test
fun deletingFromTheEditorRemovesTheRow() {
val id = givenAlarm()
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.editor_delete)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work").assertDoesNotExist()
assertThat(stored(id)).isNull()
}
}