From d47220947b0d9926b04a4117cd7bde17c4a3d6eb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 1 Oct 2026 17:06:54 +0200 Subject: [PATCH] feat(timers): redo the add-timer flow as a full-screen calculator keypad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitmodules | 1 + .../clockula/ui/AlarmsInstrumentedTest.kt | 4 +- .../clockula/ui/StopwatchInstrumentedTest.kt | 8 +- .../clockula/ui/TimersInstrumentedTest.kt | 10 +- app/src/main/AndroidManifest.xml | 2 + .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 1 - .../clockula/data/timers/TimerRepository.kt | 5 +- .../data/timers/TimerRepositoryImpl.kt | 22 +- .../domain/timer/TimerDurationEntry.kt | 11 + .../domain/timer/TimerNotification.kt | 4 +- .../timer/service/TimerNotifications.kt | 25 ++- .../clockula/timer/service/TimerService.kt | 13 ++ .../clockula/ui/alarms/AlarmEditorScreen.kt | 100 ++++----- .../clockula/ui/alarms/AlarmRoutes.kt | 5 +- .../clockula/ui/alarms/AlarmRow.kt | 114 +++++++--- .../clockula/ui/alarms/AlarmsScreen.kt | 11 +- .../clockula/ui/alarms/DismissAlarmDialog.kt | 33 ++- .../clockula/ui/alarms/RepeatDaySelector.kt | 3 +- .../clockula/ui/common/PressMotion.kt | 24 ++ .../clockula/ui/ring/RingScreen.kt | 2 + .../clockula/ui/shell/ClockulaShell.kt | 33 ++- .../clockula/ui/shell/LivePill.kt | 19 +- .../clockula/ui/shell/ShellNavigation.kt | 9 + .../ui/stopwatch/StopwatchReadoutPanel.kt | 107 ++++++--- .../clockula/ui/stopwatch/StopwatchScreen.kt | 13 +- .../jeanlucmakiola/clockula/ui/theme/Type.kt | 10 +- .../clockula/ui/timers/TimerEditorScreen.kt | 82 +++---- .../clockula/ui/timers/TimerKeypad.kt | 65 ++++-- .../clockula/ui/timers/TimerReadout.kt | 70 ++++++ .../clockula/ui/timers/TimerRoutes.kt | 5 +- .../clockula/ui/timers/TimerRow.kt | 209 +++++++++++++----- .../clockula/ui/timers/TimerRowState.kt | 11 +- .../clockula/ui/timers/TimerSetupPanel.kt | 188 ---------------- .../clockula/ui/timers/TimerSetupScreen.kt | 196 ++++++++++++++++ .../clockula/ui/timers/TimersDefaults.kt | 11 + .../clockula/ui/timers/TimersScreen.kt | 165 ++++++-------- .../clockula/ui/timers/TimersViewModel.kt | 3 + .../clockula/ui/worldclock/WorldClockRow.kt | 12 +- .../ui/worldclock/WorldClockScreen.kt | 1 + app/src/main/res/values/strings.xml | 11 +- .../data/timers/TimerRepositoryTest.kt | 38 ++++ .../domain/timer/TimerDurationEntryTest.kt | 19 ++ .../timer/TimerNotificationPolicyTest.kt | 1 + .../clockula/ui/alarms/AlarmRoutesTest.kt | 1 + .../clockula/ui/shell/ShellNavigationTest.kt | 9 + .../clockula/ui/timers/TimerListRowsTest.kt | 14 +- docs/issues.md | 0 floret-kit | 2 +- 48 files changed, 1102 insertions(+), 600 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/common/PressMotion.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerReadout.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupPanel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerSetupScreen.kt create mode 100644 docs/issues.md diff --git a/.gitmodules b/.gitmodules index b908ca7..13ce56c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "floret-kit"] path = floret-kit url = https://codeberg.org/jlmakiola/floret-kit.git + branch = feat/clockula diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt index fa430b9..c882f88 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt @@ -122,7 +122,7 @@ class AlarmsInstrumentedTest { fun theFabCreatesAnAlarmAndOpensItsEditor() { openEditorForNewAlarm() - composeTestRule.onNodeWithText(string(R.string.editor_title)).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("hour", substring = true).assertIsDisplayed() assertThat(runBlocking { seams.alarms().alarms().first() }).hasSize(1) } @@ -205,7 +205,7 @@ class AlarmsInstrumentedTest { composeTestRule.onNodeWithText("Work").performClick() composeTestRule.waitForIdle() - composeTestRule.onNodeWithText(string(R.string.editor_delete)).performClick() + composeTestRule.onNodeWithContentDescription(string(R.string.editor_delete)).performClick() composeTestRule.waitForIdle() composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick() composeTestRule.waitForIdle() diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/StopwatchInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/StopwatchInstrumentedTest.kt index 18f8839..a8c21a5 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/StopwatchInstrumentedTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/StopwatchInstrumentedTest.kt @@ -105,14 +105,14 @@ class StopwatchInstrumentedTest { /** §5.14 #2 */ @Test - fun twoTapsOnLapAppendTwoRowsAndLeaveAThirdInProgress() { + fun newLapsStayVisibleAtTheTopOfTheList() { openStopwatchTab() - startAndLap(times = 2) + startAndLap(times = 4) - assertThat(storedLaps().map { it.index }).containsExactly(1, 2).inOrder() + assertThat(storedLaps().map { it.index }).containsExactly(1, 2, 3, 4).inOrder() composeTestRule - .onNodeWithContentDescription(context.getString(R.string.stopwatch_lap_number, 3), substring = true) + .onNodeWithContentDescription(context.getString(R.string.stopwatch_lap_number, 5), substring = true) .assertIsDisplayed() } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/TimersInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/TimersInstrumentedTest.kt index 9490f99..745d317 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/TimersInstrumentedTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/TimersInstrumentedTest.kt @@ -5,6 +5,7 @@ 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 @@ -96,10 +97,13 @@ class TimersInstrumentedTest { /** §5.19 #1 */ @Test fun typingOnTheKeypadAndPressingStartCreatesOneRunningTimer() { + // The list starts empty (teardown clears it), so the keypad is + // already inline — no FAB tap needed to reach it. openTimersTab() typeDigits('1', '3', '0') - composeTestRule.onNodeWithText(string(R.string.timers_setup_start)).performClick() + // The play button is icon-only; "Start" is its content description. + composeTestRule.onNodeWithContentDescription(string(R.string.timers_setup_start)).performClick() composeTestRule.waitForIdle() val timers = stored() @@ -116,7 +120,9 @@ class TimersInstrumentedTest { launched.recreate() composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("0:01:30").assertIsDisplayed() + // The readout is now three segments ("00h 01m 30s"); its merged + // semantics still carry the spoken "0:01:30" form (`TimerDurationReadout`). + composeTestRule.onNodeWithContentDescription("0:01:30").assertIsDisplayed() } /** §5.19 #3 */ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a5cc62..72e9b8a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,8 @@ + + - Crash report https://codeberg.org/jlmakiola/clockula/issues/new @@ -35,12 +34,20 @@ Remove preset Delete a digit Type %1$s + Type 00 + Clear + h + m + s Start Pause Resume Reset Stop +1 min + −1 min + +%1$d min + −%1$d min Finished Paused \u00b7 %1$s Timer @@ -48,7 +55,7 @@ Timer settings - Timer + Countdown Label Length Reset the timer to change its length diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt index 4057e50..ca54416 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt @@ -166,6 +166,44 @@ class TimerRepositoryTest { assertThat(timer.snapshotAt(1_100.seconds, T0).remaining).isEqualTo(260.seconds) } + @Test + fun `subtracting a minute rebases a running timer and preserves its configured duration`() = runTest { + val id = startedTimer() + elapsed.value = 1_100.seconds + + repository.addTime(id, -1.minutes) + + val timer = repository.find(id)!! + assertThat(timer.state).isEqualTo(TimerState.RUNNING) + assertThat(timer.snapshotAt(1_100.seconds, T0).remaining).isEqualTo(140.seconds) + assertThat(timer.endsAtElapsedRealtime).isEqualTo(1_240.seconds) + assertThat(timer.duration).isEqualTo(5.minutes) + } + + @Test + fun `subtracting at or below a minute leaves the timer unchanged`() = runTest { + val id = startedTimer() + elapsed.value = 1_250.seconds + val before = dao.stored.single() + + repository.addTime(id, -1.minutes) + + assertThat(dao.stored.single()).isEqualTo(before) + } + + @Test + fun `subtracting from paused timer keeps it paused`() = runTest { + val id = startedTimer() + elapsed.value = 1_100.seconds + repository.pause(id) + + repository.addTime(id, -1.minutes) + + val timer = repository.find(id)!! + assertThat(timer.state).isEqualTo(TimerState.PAUSED) + assertThat(timer.remaining).isEqualTo(140.seconds) + } + @Test fun `adding time to a running timer whose end anchor has passed grants the whole extra`() = runTest { val id = startedTimer() diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntryTest.kt index f8a4d2d..e04f632 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntryTest.kt @@ -137,4 +137,23 @@ class TimerDurationEntryTest { Locale.setDefault(previous) } } + + @Test + fun `the per-unit segments keep their own leading zero unlike display`() { + val entry = typed("130") + + assertThat(listOf(entry.hoursSegment, entry.minutesSegment, entry.secondsSegment)) + .containsExactly("00", "01", "30") + .inOrder() + } + + @Test + fun `the double-zero key shifts in two zeroes at once`() { + assertThat(typed("5").plusDoubleZero()).isEqualTo(typed("500")) + } + + @Test + fun `a double zero into an empty entry is ignored, same as a single leading zero`() { + assertThat(TimerDurationEntry().plusDoubleZero()).isEqualTo(TimerDurationEntry()) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotificationPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotificationPolicyTest.kt index f9609e4..920c72d 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotificationPolicyTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotificationPolicyTest.kt @@ -58,6 +58,7 @@ class TimerNotificationPolicyTest { listOf(TimerNotificationAction.PAUSE, TimerNotificationAction.ADD_MINUTE) to subject.timer.endsAtWallClock, ) + assertThat(state.duration).isEqualTo(subject.timer.duration) } /** §5.5 #3 */ diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutesTest.kt index 989b2ce..ca519fa 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutesTest.kt @@ -14,6 +14,7 @@ class AlarmRoutesTest { @Test fun `the editor route carries the alarm's id`() { assertThat(AlarmRoutes.editor(7L)).isEqualTo("alarms/edit/7") + assertThat(AlarmRoutes.newEditor(7L)).isEqualTo("alarms/edit/7?pickTime=true") } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt index 2f4ebd0..d992d0b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.ui.shell import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents import de.jeanlucmakiola.clockula.timer.TimerIntents +import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes import org.junit.jupiter.api.Test @@ -150,6 +151,14 @@ class ShellNavigationTest { assertThat(ShellNavigation.destinationOf(TimerRoutes.editor(7L))).isNull() } + @Test + fun `editors highlight their parent tab`() { + assertThat(ShellNavigation.selectedDestinationOf(AlarmRoutes.EDITOR_PATTERN)) + .isEqualTo(ClockulaDestination.ALARMS) + assertThat(ShellNavigation.selectedDestinationOf(TimerRoutes.EDITOR_PATTERN)) + .isEqualTo(ClockulaDestination.TIMERS) + } + /** §5.15 #2 */ @Test fun `the shell stands aside on back, leaving the timer editor to pop itself`() { diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerListRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerListRowsTest.kt index 0b606f5..c116cec 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerListRowsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimerListRowsTest.kt @@ -68,10 +68,10 @@ class TimerListRowsTest { /** §5.11 #4 */ @Test - fun `an idle timer reads its configured length, full`() { + fun `an idle timer reads its configured length, empty progress`() { val row = rows(listOf(idleTimer(duration = 5.minutes))).single() - assertThat(row.remaining to row.progress).isEqualTo(5.minutes to 1f) + assertThat(row.remaining to row.progress).isEqualTo(5.minutes to 0f) } /** §5.11 #5 */ @@ -89,15 +89,15 @@ class TimerListRowsTest { val row = rows(listOf(timer)).single() - assertThat(row.remaining to row.progress).isEqualTo(60.seconds to 0.2f) + assertThat(row.remaining to row.progress).isEqualTo(60.seconds to 0.8f) } /** §5.11 #7 */ @Test - fun `an expired timer reads empty`() { + fun `an expired timer reads full progress`() { val row = rows(listOf(expiredTimer(duration = 5.minutes))).single() - assertThat(row.remaining to row.progress).isEqualTo(Duration.ZERO to 0f) + assertThat(row.remaining to row.progress).isEqualTo(Duration.ZERO to 1f) } /** §5.11 #8 */ @@ -110,10 +110,10 @@ class TimerListRowsTest { /** §5.11 #9 */ @Test - fun `a timer with more left than its length is clamped full`() { + fun `a timer with more left than its length is clamped empty`() { val timer = runningTimerWith(left = 10.minutes, elapsedNow = now, duration = 5.minutes) - assertThat(rows(listOf(timer)).single().progress).isEqualTo(1f) + assertThat(rows(listOf(timer)).single().progress).isEqualTo(0f) } /** §5.11 #10 */ diff --git a/docs/issues.md b/docs/issues.md new file mode 100644 index 0000000..e69de29 diff --git a/floret-kit b/floret-kit index 1934306..18a7977 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 1934306903ec38df81351ef13a17d5f2ab14fd43 +Subproject commit 18a797747b6f27032bf674415af857a1223b6975