feat(timers): redo the add-timer flow as a full-screen calculator keypad

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>
This commit is contained in:
2026-10-01 17:06:54 +02:00
co-authored by Claude Sonnet 5
parent 3ff74dc1d5
commit d47220947b
48 changed files with 1102 additions and 600 deletions
+1
View File
@@ -1,3 +1,4 @@
[submodule "floret-kit"]
path = floret-kit
url = https://codeberg.org/jlmakiola/floret-kit.git
branch = feat/clockula
@@ -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()
@@ -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()
}
@@ -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 */
+2
View File
@@ -27,6 +27,8 @@
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
to the foreground service, not to the notification. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Enables the system's Live Update and status bar chip for a running timer. -->
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<!-- The ringing service. systemExempted is Android's own answer for an app
holding an exact-alarm permission that continues an alarm in the
@@ -51,7 +51,6 @@ class ClockulaApp : Application() {
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
@@ -21,9 +21,10 @@ interface TimerRepository {
suspend fun reset(id: Long)
/**
* "+1 min", measured from the tap: a RUNNING timer is rebased on what is
* A signed adjustment measured from the tap: a RUNNING timer is rebased on what is
* actually left, a PAUSED one keeps its pause, an **EXPIRED one resumes with
* exactly [extra] left**, and an IDLE one is a silent no-op.
* exactly [extra] left** for a positive adjustment, and an IDLE one is a silent no-op.
* Subtraction that would leave zero or less is a no-op.
* [Timer.duration] is untouched on every branch, so [reset] still returns
* the timer to the length the user configured (M6 D12).
*/
@@ -73,19 +73,20 @@ class TimerRepositoryImpl @Inject constructor(
}
/**
* One rule for all three live states: the extra is measured **from the
* One rule for all three live states: the signed adjustment is measured **from the
* tap**, never from an anchor that has gone by. [Timer.duration] is never
* moved, so `reset` still returns the timer to the length the user
* configured (M6 D12).
*
* | Before | After |
* |---|---|
* | RUNNING | still running, rebased on `snapshot.remaining + extra` |
* | PAUSED | still paused, `remaining + extra`. The user paused deliberately |
* | EXPIRED | **RUNNING with exactly `extra` left**, all three anchors written |
* | RUNNING | still running, rebased on `snapshot.remaining + extra` if positive |
* | PAUSED | still paused, `remaining + extra` if positive |
* | EXPIRED | **RUNNING with exactly `extra` left** for positive additions |
* | IDLE | nothing. A stale notification action is a silent no-op |
*/
override suspend fun addTime(id: Long, extra: Duration) {
if (extra == Duration.ZERO) return
val now = elapsedRealtimeClock.elapsedRealtime()
val wall = wallClock.now()
edit(id, wall) { timer ->
@@ -94,17 +95,22 @@ class TimerRepositoryImpl @Inject constructor(
// a timer whose anchor is already past has zero left, and the
// user asked for `extra` more than zero — not `extra` more than
// an anchor that has gone by.
TimerState.RUNNING ->
timer.anchored(timer.snapshotAt(now, wall).remaining + extra, now, wall)
TimerState.RUNNING -> {
val left = timer.snapshotAt(now, wall).remaining + extra
if (left <= Duration.ZERO) null else timer.anchored(left, now, wall)
}
// The same sentence, with the anchor that has gone by being the
// expiry itself: "+1 min" on a timer that has just rung is the
// commonest gesture in a timer app, and a second tap on Start is
// a papercut. Resuming here is also the only way to avoid
// emitting an intermediate PAUSED frame to the pill and the row.
TimerState.EXPIRED -> timer.anchored(extra, now, wall)
TimerState.EXPIRED -> if (extra > Duration.ZERO) timer.anchored(extra, now, wall) else null
TimerState.PAUSED -> timer.copy(remaining = timer.remaining + extra)
TimerState.PAUSED -> {
val left = timer.remaining + extra
if (left <= Duration.ZERO) null else timer.copy(remaining = left)
}
TimerState.IDLE -> null
}
@@ -38,6 +38,14 @@ data class TimerDurationEntry(val digits: String = "") {
/** [duration] inside `TimerPresets.MIN..MAX`. */
val canStart: Boolean get() = duration in TimerPresets.MIN..TimerPresets.MAX
/**
* Two digits each, unlike [display]: the per-unit readout (00h 00m 00s)
* never strips a leading zero, since each segment carries its own suffix.
*/
val hoursSegment: String get() = digits.padStart(DIGITS, '0').substring(0, 2)
val minutesSegment: String get() = digits.padStart(DIGITS, '0').substring(2, 4)
val secondsSegment: String get() = digits.padStart(DIGITS, '0').substring(4, 6)
/** A non-digit, a seventh digit, and a leading zero into an empty entry are all ignored. */
fun plusDigit(digit: Char): TimerDurationEntry = when {
!digit.isDigit() -> this
@@ -48,6 +56,9 @@ data class TimerDurationEntry(val digits: String = "") {
else -> TimerDurationEntry(digits + digit)
}
/** Google Clock's "00" key: two zeroes shifted in at once, nothing more. */
fun plusDoubleZero(): TimerDurationEntry = plusDigit('0').plusDigit('0')
fun backspace(): TimerDurationEntry =
if (digits.isEmpty()) this else TimerDurationEntry(digits.dropLast(1))
@@ -11,6 +11,7 @@ data class TimerNotificationState(
val label: String,
val mode: TimerMode,
val remaining: Duration,
val duration: Duration,
/** The chronometer's wall-clock base. Non-null only for a RUNNING subject with a wall-clock end. */
val chronometerBase: Instant?,
val otherActiveTimers: Int,
@@ -32,6 +33,7 @@ object TimerNotificationPolicy {
label = subject.timer.label,
mode = subject.mode,
remaining = subject.remaining,
duration = subject.timer.duration,
// The platform renders the countdown, so the service posts once per
// state change rather than once per second (D19). A running row with
// no wall-clock end is corrupt: fall back to a static readout rather
@@ -48,7 +50,7 @@ object TimerNotificationPolicy {
)
}
/** The same pair the row offers for that mode — one vocabulary, two surfaces (D17). */
/** The two most useful shade actions for each mode; the app row has more room. */
private fun actionsFor(mode: TimerMode): List<TimerNotificationAction> = when (mode) {
TimerMode.RUNNING -> listOf(TimerNotificationAction.PAUSE, TimerNotificationAction.ADD_MINUTE)
TimerMode.PAUSED -> listOf(TimerNotificationAction.RESUME, TimerNotificationAction.RESET)
@@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
@@ -74,7 +75,7 @@ object TimerNotifications {
.setContentIntent(showPendingIntent(context))
.build()
/** The subject, and a count. Two actions at most — the pair the row offers. */
/** The subject, and a count. Two quick actions; the app row also offers subtraction. */
fun timer(context: Context, state: TimerNotificationState): Notification {
val builder = NotificationCompat.Builder(context, TIMER_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
@@ -85,6 +86,9 @@ object TimerNotifications {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
// The platform countdown supplies a ticking status bar chip on
// Android 16+ when the system promotes the running notification.
.setRequestPromotedOngoing(state.mode == TimerMode.RUNNING)
// False exactly once per ring session, so an expiry heads-ups once
// and a countdown update never does (D17). `setOnlyAlertOnce` alone
// would let the *first* post of a countdown alert, because it only
@@ -93,6 +97,16 @@ object TimerNotifications {
.setOnlyAlertOnce(state.alertOnce)
.setContentIntent(showPendingIntent(context))
// System templates keep the notification eligible for Live Updates.
val duration = state.duration.inWholeMilliseconds
var progress = 0
if (duration > 0L) {
val elapsed = (duration - state.remaining.inWholeMilliseconds)
.coerceIn(0L, duration)
progress = ((elapsed.toDouble() / duration) * 100).toInt()
builder.setProgress(100, progress, false)
}
if (state.otherActiveTimers > 0) {
builder.setSubText(
context.resources.getQuantityString(
@@ -123,7 +137,14 @@ object TimerNotifications {
actionPendingIntent(context, action, state.subjectId),
)
}
return builder.build()
val notification = builder.build()
if (Build.VERSION.SDK_INT < 36 || duration <= 0L) return notification
// Android 16's native progress template gives the timer a tracker in
// the expanded Live Update. Older devices retain the standard bar.
return Notification.Builder.recoverBuilder(context, notification)
.setStyle(Notification.ProgressStyle().setProgress(progress))
.build()
}
/** A static readout: a paused timer, an expired one, or a corrupt running row (D19). */
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
import de.jeanlucmakiola.clockula.ring.RingVibrator
@@ -81,6 +82,7 @@ class TimerService : Service() {
/** The two independent delays the engine hands down (D20). */
private var expiry: Job? = null
private var silence: Job? = null
private var progressRefresh: Job? = null
/** Which timer the audio is currently sounding for, so an unchanged decision does nothing. */
private var sounding: Long? = null
@@ -140,6 +142,7 @@ class TimerService : Service() {
}
override fun onDestroy() {
progressRefresh?.cancel()
releaseRing()
scope.cancel()
super.onDestroy()
@@ -169,6 +172,15 @@ class TimerService : Service() {
applyRing(state.ring)
post(notification)
scheduleDelays(state)
progressRefresh?.cancel()
progressRefresh = if (notification.mode == TimerMode.RUNNING) {
scope.launch {
// The system animates the chip's chronometer itself. Refresh
// the shade's determinate bar less often to limit work.
delay(15_000L)
refresh.tryEmit(Unit)
}
} else null
}
/**
@@ -289,6 +301,7 @@ class TimerService : Service() {
private fun stop() {
expiry?.cancel()
silence?.cancel()
progressRefresh?.cancel()
releaseRing()
stopForeground(STOP_FOREGROUND_REMOVE)
// The placeholder is posted before anything is read, so a service that
@@ -2,11 +2,14 @@ package de.jeanlucmakiola.clockula.ui.alarms
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -19,10 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
@@ -49,11 +50,14 @@ private enum class OpenPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMI
@Composable
fun AlarmEditorScreen(
onBack: () -> Unit,
openTimePicker: Boolean = false,
modifier: Modifier = Modifier,
viewModel: AlarmEditorViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
val formatTime = rememberAlarmTimeFormatter()
// Registered unconditionally, and deliberately **not** inside the
// `picker?.let` below: the picker's open flag is ViewModel state that does
@@ -79,9 +83,35 @@ fun AlarmEditorScreen(
onBack = onBack,
modifier = modifier,
predictiveBack = true,
actions = {
if (state is AlarmEditorUiState.Editing) {
IconButton(onClick = { confirmingDelete = true }) {
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.editor_delete),
)
}
}
},
) {
val editing = state as? AlarmEditorUiState.Editing ?: return@CollapsingScaffold
AlarmEditorContent(editing = editing, viewModel = viewModel)
AlarmEditorContent(
editing = editing,
viewModel = viewModel,
openTimePicker = openTimePicker,
)
}
val editing = state as? AlarmEditorUiState.Editing
if (confirmingDelete && editing != null) {
DeleteConfirmation(
time = formatTime(editing.time),
onConfirm = {
confirmingDelete = false
viewModel.onDelete()
},
onDismiss = { confirmingDelete = false },
)
}
picker?.let { pickerState ->
@@ -103,6 +133,7 @@ private const val AUDIO_MIME = "audio/*"
private fun AlarmEditorContent(
editing: AlarmEditorUiState.Editing,
viewModel: AlarmEditorViewModel,
openTimePicker: Boolean,
) {
// Saveable, not remembered: a dialog open across a rotation must still be
// open afterwards — `AlarmTimePickerDialog` already saves its own display
@@ -110,20 +141,23 @@ private fun AlarmEditorContent(
// and keep the mode (M5 D22's "back is just back" is about writes, not
// about losing a half-entered time).
var openPicker by rememberSaveable { mutableStateOf<OpenPicker?>(null) }
var pickingTime by rememberSaveable { mutableStateOf(false) }
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
val formatTime = rememberAlarmTimeFormatter()
var pickingTime by rememberSaveable { mutableStateOf(openTimePicker) }
TimeCard(time = editing.time, onClick = { pickingTime = true })
Spacer(Modifier.height(16.dp))
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
SectionHeader(stringResource(R.string.editor_repeat))
RepeatDaySelector(repeatDays = editing.repeatDays, onDayToggled = viewModel::onDayToggled)
GroupedSectionHeader(stringResource(R.string.editor_repeat))
RepeatDaySelector(
repeatDays = editing.repeatDays,
onDayToggled = viewModel::onDayToggled,
position = if (editing.canSkipNext) Position.Top else Position.Alone,
)
if (editing.canSkipNext) {
GroupedRow(
title = stringResource(R.string.editor_skip_next),
summary = stringResource(R.string.editor_skip_next_summary),
position = Position.Alone,
position = Position.Bottom,
trailing = {
Switch(
checked = editing.skipNextOccurrence,
@@ -134,7 +168,7 @@ private fun AlarmEditorContent(
)
}
SectionHeader(stringResource(R.string.editor_section_sound))
GroupedSectionHeader(stringResource(R.string.editor_section_sound))
SoundRow(editing = editing, position = positionOf(0, 3), onClick = viewModel::onOpenRingtonePicker)
GroupedRow(
title = stringResource(R.string.editor_vibrate),
@@ -152,7 +186,7 @@ private fun AlarmEditorContent(
onClick = { openPicker = OpenPicker.VOLUME_RAMP },
)
SectionHeader(stringResource(R.string.editor_section_snooze))
GroupedSectionHeader(stringResource(R.string.editor_section_snooze))
GroupedRow(
title = stringResource(R.string.editor_snooze_length),
summary = overrideSummary(
@@ -181,8 +215,6 @@ private fun AlarmEditorContent(
onClick = { openPicker = OpenPicker.CHALLENGE },
)
Spacer(Modifier.height(16.dp))
DeleteRow(onClick = { confirmingDelete = true })
Spacer(Modifier.height(LocalLivePillInset.current))
if (pickingTime) {
@@ -195,16 +227,6 @@ private fun AlarmEditorContent(
onDismiss = { pickingTime = false },
)
}
if (confirmingDelete) {
DeleteConfirmation(
time = formatTime(editing.time),
onConfirm = {
confirmingDelete = false
viewModel.onDelete()
},
onDismiss = { confirmingDelete = false },
)
}
when (openPicker) {
null -> Unit
OpenPicker.VIBRATE -> VibratePicker(
@@ -252,6 +274,7 @@ private fun TimeCard(time: TimeOfDay, onClick: () -> Unit, modifier: Modifier =
Text(
text = formatTime(time),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
)
@@ -301,17 +324,6 @@ private fun SoundRow(
)
}
/** Tinted through the AnnotatedString overload, so no literal colour is written. */
@Composable
private fun DeleteRow(onClick: () -> Unit) {
val error = MaterialTheme.colorScheme.error
val label = stringResource(R.string.editor_delete)
val title: AnnotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = error)) { append(label) }
}
GroupedRow(title = title, position = Position.Alone, onClick = onClick)
}
/**
* A confirmation that names the alarm, deliberately instead of an undo chip:
* an accidentally deleted alarm is discovered at 07:00, not now, so the cheap
@@ -331,17 +343,3 @@ private fun DeleteConfirmation(time: String, onConfirm: () -> Unit, onDismiss: (
},
)
}
/** A run's header, on the same edge as the cards it belongs to. */
@Composable
private fun SectionHeader(text: String) {
Column {
Spacer(Modifier.height(16.dp))
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
}
@@ -3,7 +3,10 @@ package de.jeanlucmakiola.clockula.ui.alarms
/** The editor's route and the name its argument is declared under (M5 D3). */
internal object AlarmRoutes {
const val ARG_ALARM_ID: String = "alarmId"
const val EDITOR_PATTERN: String = "alarms/edit/{alarmId}"
const val ARG_PICK_TIME: String = "pickTime"
const val EDITOR_PATTERN: String = "alarms/edit/{alarmId}?pickTime={pickTime}"
fun editor(alarmId: Long): String = "alarms/edit/$alarmId"
fun newEditor(alarmId: Long): String = "${editor(alarmId)}?pickTime=true"
}
@@ -1,9 +1,16 @@
package de.jeanlucmakiola.clockula.ui.alarms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
@@ -13,14 +20,18 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.RepeatDaysSummary
import de.jeanlucmakiola.clockula.domain.RepeatSummary
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import java.time.DayOfWeek
import java.time.format.TextStyle
@@ -28,17 +39,12 @@ import java.time.temporal.WeekFields
import java.util.Locale
/**
* One alarm, as the canonical two-line grouped row: the time (with the label
* beside it), the repeat summary and the next-fire line, and the switch.
*
* A disabled alarm is `dimmed` — the kit's own answer for "present but
* switched off" — and the *next* alarm is marked with colour rather than size:
* Expressive is not big or bold (M5 D7, D15).
* A standalone alarm card. The time and switch share the first line, leaving
* room below for the label and schedule without squeezing either beside the time.
*/
@Composable
internal fun AlarmRow(
row: AlarmRowState,
position: Position,
onClick: () -> Unit,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
@@ -46,33 +52,67 @@ internal fun AlarmRow(
val formatTime = rememberAlarmTimeFormatter()
val time = formatTime(row.time)
val toggleDescription = stringResource(R.string.alarm_row_toggle, time)
GroupedRow(
title = rowTitle(time, row.label),
summary = rowSummary(row),
position = position,
dimmed = !row.enabled,
modifier = modifier,
trailing = {
Switch(
checked = row.enabled,
onCheckedChange = onToggle,
// "Alarm at 07:00" — a bare switch announces neither which row
// it belongs to nor what it does.
modifier = Modifier.semantics { contentDescription = toggleDescription },
)
},
val textColor = if (row.enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
val supportingColor = if (row.enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
}
GroupedSurface(
position = Position.Alone,
modifier = modifier.padding(horizontal = GroupedListInset),
onClick = onClick,
)
}
@Composable
private fun rowTitle(time: String, label: String): AnnotatedString {
val variant = MaterialTheme.colorScheme.onSurfaceVariant
return buildAnnotatedString {
append(time)
if (label.isNotBlank()) {
append(" ")
withStyle(SpanStyle(color = variant)) { append(label) }
color = if (row.enabled) {
MaterialTheme.colorScheme.surfaceContainerHigh
} else {
MaterialTheme.colorScheme.surfaceContainer
},
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = time,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = textColor,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Keep the switch live and fully visible even when the alarm is off.
Switch(
checked = row.enabled,
onCheckedChange = onToggle,
// "Alarm at 07:00" — a bare switch announces neither which row
// it belongs to nor what it does.
modifier = Modifier.semantics { contentDescription = toggleDescription },
)
}
if (row.label.isNotBlank()) {
Text(
text = row.label,
style = MaterialTheme.typography.bodyLarge,
color = textColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = rowSummary(row),
style = MaterialTheme.typography.bodyMedium,
color = supportingColor,
)
}
}
}
@@ -88,7 +128,11 @@ private fun rowSummary(row: AlarmRowState): AnnotatedString {
append(repeat)
if (next != null) {
append(separator)
if (row.isNext) withStyle(SpanStyle(color = primary)) { append(next) } else append(next)
if (row.enabled && row.isNext) {
withStyle(SpanStyle(color = primary)) { append(next) }
} else {
append(next)
}
}
if (row.skippingNext) {
append(separator)
@@ -1,11 +1,12 @@
package de.jeanlucmakiola.clockula.ui.alarms
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.FloatingActionButton
@@ -26,7 +27,6 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion
import kotlinx.coroutines.flow.collectLatest
@@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.collectLatest
@Composable
fun AlarmsScreen(
onEditAlarm: (Long) -> Unit,
onCreateAlarm: (Long) -> Unit,
modifier: Modifier = Modifier,
dismissCandidates: LongArray = LongArray(0),
onDismissCandidatesConsumed: () -> Unit = {},
@@ -55,7 +56,7 @@ fun AlarmsScreen(
LaunchedEffect(viewModel) {
// The FAB creates the alarm and the editor edits a persisted row, so
// the id to navigate to arrives from the ViewModel (M5 D2, D22).
viewModel.openEditor.collectLatest(onEditAlarm)
viewModel.openEditor.collectLatest(onCreateAlarm)
}
CollapsingScaffold(
@@ -75,6 +76,7 @@ fun AlarmsScreen(
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = AlarmsDefaults.ListBottomPadding,
bottom = AlarmsDefaults.ListBottomPadding + AlarmsDefaults.FabBand + pillInset,
@@ -83,10 +85,9 @@ fun AlarmsScreen(
if (rows != null && rows.isEmpty()) {
item { AlarmsEmptyState() }
}
itemsIndexed(rows.orEmpty(), key = { _, row -> row.id }) { index, row ->
items(rows.orEmpty(), key = { row -> row.id }) { row ->
AlarmRow(
row = row,
position = positionOf(index, rows.orEmpty().size),
onClick = { onEditAlarm(row.id) },
onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) },
// An alarm whose time changed slides to its new place
@@ -1,25 +1,24 @@
package de.jeanlucmakiola.clockula.ui.alarms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
import de.jeanlucmakiola.floret.components.OptionCard
/**
* "Which alarm?" — an M3 basic dialog with a list. Tapping a row **is** the
* "Which alarm?" — an M3 basic dialog with Floret option cards. Tapping a card **is** the
* answer; the one action button is Cancel (M9 D13).
*
* The canonical component for "pick one of a short list, or cancel": not a
@@ -41,7 +40,10 @@ internal fun DismissAlarmDialog(
text = {
// A dialog's own body does not scroll, and a search can name more
// alarms than fit on a short screen.
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
rows.forEach { row ->
DismissAlarmRow(
row = row,
@@ -69,19 +71,10 @@ private fun DismissAlarmRow(
val repeat = repeatSummaryText(row.repeatDays)
val spoken = if (row.label.isBlank()) time else "$time, ${row.label}"
val description = stringResource(R.string.interop_dismiss_row_description, spoken)
ListItem(
headlineContent = { Text(time) },
overlineContent = { Text(repeat) },
supportingContent = row.label.takeIf { it.isNotBlank() }?.let { label -> { Text(label) } },
// A `ListItem`'s default container is `surface`, which sits *lower* than
// the dialog's own `surfaceContainerHigh` and would draw a visible
// lighter block per row. `Color.Transparent` is the absence of a colour,
// not a literal one.
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier
.clickable(onClick = onClick)
// One node per row, saying what it is and what tapping it does;
// the 48dp target comes free with `ListItem`.
.semantics(mergeDescendants = true) { contentDescription = description },
OptionCard(
label = time,
supportingText = listOfNotNull(row.label.takeIf { it.isNotBlank() }, repeat).joinToString(" · "),
onClick = onClick,
modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = description },
)
}
@@ -38,13 +38,14 @@ internal fun RepeatDaySelector(
repeatDays: RepeatDays,
onDayToggled: (DayOfWeek) -> Unit,
modifier: Modifier = Modifier,
position: Position = Position.Alone,
) {
val configuration = LocalConfiguration.current
val locale = remember(configuration) { Locale.getDefault() }
val days = RepeatDaysOrder.startingAt(rememberWeekStart())
GroupedSurface(
position = Position.Alone,
position = position,
modifier = modifier.padding(horizontal = GroupedListInset),
) {
Row(
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.clockula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
/** A press response that leaves neighboring button widths unchanged. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun Modifier.pressMotion(interaction: MutableInteractionSource): Modifier {
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 1.04f else 1f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "button press",
)
return graphicsLayer { scaleX = scale; scaleY = scale }
}
@@ -31,6 +31,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
@@ -71,6 +72,7 @@ internal fun RingScreen(
Text(
text = formatTime(state.time),
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
@@ -38,6 +38,7 @@ import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
import de.jeanlucmakiola.clockula.ui.stopwatch.StopwatchScreen
import de.jeanlucmakiola.clockula.ui.timers.TimerEditorScreen
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
import de.jeanlucmakiola.clockula.ui.timers.TimerSetupScreen
import de.jeanlucmakiola.clockula.ui.timers.TimersScreen
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
@@ -66,7 +67,7 @@ fun ClockulaShell(
val pill by viewModel.livePill.collectAsStateWithLifecycle()
val entry by navController.currentBackStackEntryAsState()
val route = entry?.destination?.route
val selected = ShellNavigation.destinationOf(route) ?: ShellNavigation.start
val selected = ShellNavigation.selectedDestinationOf(route) ?: ShellNavigation.start
val back = ShellNavigation.onBack(route)
// Peer destinations have no spatial relationship, so tabs fade through
@@ -77,8 +78,6 @@ fun ClockulaShell(
// process death: the intent that asked has been consumed, and re-asking
// would be a dialog with no provenance (M9 D13).
var dismissCandidates by rememberSaveable { mutableStateOf(LongArray(0)) }
// Whether an incoming intent asked for the timer setup panel (M9 D7).
var timerSetupRequested by rememberSaveable { mutableStateOf(false) }
// A notification's deep link — or the exported door's outcome — replayed as
// exactly the command a tab tap produces, which is what the user expects,
@@ -98,10 +97,12 @@ fun ClockulaShell(
navController.navigate(AlarmRoutes.editor(request.alarmId))
}
ShellRequest.ComposeTimer -> {
// The same two steps the FAB takes: the tab, then the
// full-screen setup page on top of it (M9 D7).
navController.replay(
ShellNavigation.onTabSelected(route, ClockulaDestination.TIMERS),
)
timerSetupRequested = true
navController.navigate(TimerRoutes.NEW)
}
is ShellRequest.ChooseAlarmToDismiss -> {
navController.replay(
@@ -155,6 +156,7 @@ fun ClockulaShell(
composable(ClockulaDestination.ALARMS.route) {
AlarmsScreen(
onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) },
onCreateAlarm = { navController.navigate(AlarmRoutes.newEditor(it)) },
dismissCandidates = dismissCandidates,
onDismissCandidatesConsumed = { dismissCandidates = LongArray(0) },
)
@@ -166,20 +168,26 @@ fun ClockulaShell(
route = AlarmRoutes.EDITOR_PATTERN,
arguments = listOf(
navArgument(AlarmRoutes.ARG_ALARM_ID) { type = NavType.LongType },
navArgument(AlarmRoutes.ARG_PICK_TIME) {
type = NavType.BoolType
defaultValue = false
},
),
) {
AlarmEditorScreen(onBack = { navController.popBackStack() })
) { entry ->
AlarmEditorScreen(
onBack = { navController.popBackStack() },
openTimePicker = entry.arguments?.getBoolean(AlarmRoutes.ARG_PICK_TIME) == true,
)
}
composable(ClockulaDestination.TIMERS.route) {
TimersScreen(
onEditTimer = { navController.navigate(TimerRoutes.editor(it)) },
requestSetup = timerSetupRequested,
onSetupRequestConsumed = { timerSetupRequested = false },
onAddTimer = { navController.navigate(TimerRoutes.NEW) },
)
}
// A sibling route in the same flat host, exactly as the
// alarm editor is: it is no tab, so the Timers tab stays
// selected and the shell stands aside on back (M5 D1).
// Sibling routes in the same flat host, exactly as the
// alarm editor is: neither is a tab, so the Timers tab
// stays selected and the shell stands aside on back (M5 D1).
composable(
route = TimerRoutes.EDITOR_PATTERN,
arguments = listOf(
@@ -188,6 +196,9 @@ fun ClockulaShell(
) {
TimerEditorScreen(onBack = { navController.popBackStack() })
}
composable(TimerRoutes.NEW) {
TimerSetupScreen(onBack = { navController.popBackStack() })
}
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
composable(ClockulaDestination.WORLD_CLOCK.route) { WorldClockScreen() }
}
@@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.HourglassEmpty
import androidx.compose.material.icons.rounded.Pause
@@ -18,7 +18,7 @@ import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -97,13 +97,12 @@ private fun PillBody(
val color = if (state.mode == LivePillMode.EXPIRED) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
MaterialTheme.colorScheme.surfaceContainerHigh
}
Surface(
color = color,
contentColor = contentColorFor(color),
shape = RoundedCornerShape(50),
shadowElevation = 6.dp,
shape = CircleShape,
modifier = Modifier.height(ShellDefaults.LivePillHeight).animateContentSizeMotion(),
) {
Row(
@@ -136,7 +135,7 @@ private fun PillBody(
}
if (state.canPause) {
val resume = state.mode == LivePillMode.PAUSED
IconButton(onClick = { if (live) onPrimaryAction(state) }) {
FilledTonalIconButton(onClick = { if (live) onPrimaryAction(state) }, enabled = live) {
Icon(
imageVector = if (resume) Icons.Rounded.PlayArrow else Icons.Rounded.Pause,
contentDescription = stringResource(
@@ -145,7 +144,7 @@ private fun PillBody(
)
}
}
IconButton(onClick = { if (live) onStop(state) }) {
FilledTonalIconButton(onClick = { if (live) onStop(state) }, enabled = live) {
Icon(
imageVector = Icons.Rounded.Stop,
contentDescription = stringResource(R.string.live_pill_stop),
@@ -158,7 +157,11 @@ private fun PillBody(
/** Countdowns round up, elapsed truncates — the pill never reads ahead of reality. */
@Composable
private fun LivePillState.readout(): String = when (subject) {
is LivePillSubject.TimerSubject -> ClockFormat.countdown(value)
is LivePillSubject.TimerSubject -> if (mode == LivePillMode.EXPIRED) {
stringResource(R.string.timer_finished)
} else {
ClockFormat.countdown(value)
}
LivePillSubject.StopwatchSubject -> ClockFormat.elapsed(value)
}
@@ -4,6 +4,8 @@ import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.interop.InteropIntents
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
/** What a tab tap does to the back stack. Pure data; the composable replays it. */
data class TabNavCommand(
@@ -45,6 +47,13 @@ object ShellNavigation {
/** Exact match only: a nested route is not a tab. */
fun destinationOf(route: String?): ClockulaDestination? = byRoute[route]
/** Keep the parent tab highlighted while its editor — or setup page — is open. */
fun selectedDestinationOf(route: String?): ClockulaDestination? = when (route) {
AlarmRoutes.EDITOR_PATTERN -> ClockulaDestination.ALARMS
TimerRoutes.EDITOR_PATTERN, TimerRoutes.NEW -> ClockulaDestination.TIMERS
else -> destinationOf(route)
}
/**
* The canonical multi-back-stack command. Re-selecting the tab the user is
* already on does *not* restore its saved stack, which pops that tab back
@@ -2,28 +2,38 @@ package de.jeanlucmakiola.clockula.ui.stopwatch
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.WavyProgressIndicatorDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat
import de.jeanlucmakiola.clockula.ui.common.pressMotion
import de.jeanlucmakiola.clockula.ui.theme.ClockulaReadoutDefaults
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
@@ -47,6 +57,7 @@ import kotlin.time.Duration
internal fun StopwatchReadoutPanel(
mode: StopwatchState,
elapsed: Duration,
hasLaps: Boolean,
canLap: Boolean,
onStart: () -> Unit,
onPause: () -> Unit,
@@ -69,26 +80,47 @@ internal fun StopwatchReadoutPanel(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(
// So 9:59 becoming 10:00 eases rather than jumps — the pill's
// own treatment.
modifier = Modifier
.animateContentSizeMotion()
.semantics(mergeDescendants = true) { contentDescription = description },
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = readout.major,
style = ClockulaReadoutDefaults.Hero,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = readout.hundredths,
style = ClockulaReadoutDefaults.HeroFraction,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp),
)
BoxWithConstraints(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
val dialSize = minOf(maxWidth, if (hasLaps) 232.dp else 272.dp)
val compactReadout = dialSize < 260.dp || readout.major.length > 5
Box(Modifier.size(dialSize), contentAlignment = Alignment.Center) {
MinuteRing(elapsed = elapsed, mode = mode)
Row(
// So 9:59 becoming 10:00 eases rather than jumps — the pill's
// own treatment.
modifier = Modifier
.animateContentSizeMotion()
.semantics(mergeDescendants = true) { contentDescription = description },
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = readout.major,
style = if (compactReadout) {
MaterialTheme.typography.displayMedium
} else {
ClockulaReadoutDefaults.Hero
},
// `Hero` already carries this; the compact branch
// falls back to a plain M3 style and needs it too,
// so both read as the same hero number (D12).
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
Text(
text = readout.hundredths,
style = if (compactReadout) {
MaterialTheme.typography.headlineSmall
} else {
ClockulaReadoutDefaults.HeroFraction
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp),
maxLines = 1,
)
}
}
}
StopwatchControls(
@@ -103,6 +135,27 @@ internal fun StopwatchReadoutPanel(
}
}
/** One full circle per minute, with the elapsed time remaining the accessible readout. */
@Composable
private fun MinuteRing(elapsed: Duration, mode: StopwatchState) {
val track = MaterialTheme.colorScheme.outlineVariant
val accent = if (mode == StopwatchState.PAUSED) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.primary
}
val progress = (elapsed.inWholeMilliseconds % 60_000L).toFloat() / 60_000f
CircularWavyProgressIndicator(
progress = { if (mode == StopwatchState.IDLE) 0f else progress },
modifier = Modifier.fillMaxSize().clearAndSetSemantics { },
color = accent,
trackColor = track,
wavelength = WavyProgressIndicatorDefaults.CircularWavelength * 2,
waveSpeed = 0.dp,
)
}
/**
* Start · Pause/Lap · Resume/Reset (D13). The word "Stop" is deliberately not
* used: in Clockula "Stop" already means "reset" — the timer's expired row and
@@ -137,11 +190,9 @@ private fun StopwatchControls(
Button(
onClick = primary.second,
interactionSource = primaryInteraction,
// `animateWidth` is the expressive press-widening — the reason the
// pair is a ButtonGroup rather than two buttons in a Row.
modifier = Modifier.animateWidth(primaryInteraction),
modifier = Modifier.pressMotion(primaryInteraction),
) {
Text(stringResource(primary.first))
Text(stringResource(primary.first), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (mode != StopwatchState.IDLE) {
@@ -152,12 +203,16 @@ private fun StopwatchControls(
enabled = !lapping || canLap,
interactionSource = secondaryInteraction,
modifier = Modifier
.animateWidth(secondaryInteraction)
.pressMotion(secondaryInteraction)
// A disabled button says nothing about why: the cap is the
// reason, so the button carries it (D14).
.semantics { if (lapping && !canLap) contentDescription = lapLimit },
) {
Text(stringResource(if (lapping) R.string.stopwatch_lap else R.string.stopwatch_reset))
Text(
stringResource(if (lapping) R.string.stopwatch_lap else R.string.stopwatch_reset),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -42,6 +44,14 @@ fun StopwatchScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val pillInset = LocalLivePillInset.current
val loaded = state as? StopwatchUiState.Loaded
val laps = loaded?.laps.orEmpty()
val lapListState = rememberLazyListState()
// A new lap is prepended. LazyColumn otherwise keeps the old first visible
// key anchored and leaves the new in-progress row above the viewport.
LaunchedEffect(laps.firstOrNull()?.index) {
if (laps.isNotEmpty()) lapListState.scrollToItem(0)
}
CollapsingScaffold(
title = stringResource(R.string.tab_stopwatch),
@@ -57,6 +67,7 @@ fun StopwatchScreen(
// stopwatch standing at zero — which is what an idle one is.
mode = loaded?.mode ?: StopwatchState.IDLE,
elapsed = loaded?.elapsed ?: Duration.ZERO,
hasLaps = laps.isNotEmpty(),
canLap = loaded?.canLap == true,
onStart = viewModel::onStart,
onPause = viewModel::onPause,
@@ -65,7 +76,6 @@ fun StopwatchScreen(
modifier = Modifier.padding(top = 8.dp),
)
val laps = loaded?.laps.orEmpty()
// No lap recorded ⇒ no list at all: the in-progress row's split and
// its total would both equal the hero figure (D11).
if (laps.isEmpty()) {
@@ -74,6 +84,7 @@ fun StopwatchScreen(
LapColumnHeader()
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = lapListState,
contentPadding = PaddingValues(
bottom = StopwatchDefaults.ListBottomPadding + pillInset,
),
@@ -44,16 +44,18 @@ val ClockulaTypography: Typography = Typography().run {
*
* The M3 `*Defaults` shape — composable getters over `MaterialTheme.typography`
* rather than constants — so a theme change still flows through them. No new
* font, no size outside the M3 scale, and no weight above Medium: `PLAN.md` §8's
* "Expressive ≠ big or bold" applies hardest to the one screen most tempted to
* break it.
* font and no size outside the M3 scale: refinement is still shape, colour,
* space and motion over the readout as a whole, never a one-off size bump on
* a single screen. Weight is the one dial turned up, and turned up everywhere
* a readout lives, not just here — every hero number in the app reads
* `FontWeight.Bold` (superseding the Medium cap `PLAN.md` §8 originally set).
*/
object ClockulaReadoutDefaults {
/** The stopwatch's big number. */
val Hero: TextStyle
@Composable @ReadOnlyComposable
get() = MaterialTheme.typography.displayLarge.copy(fontWeight = FontWeight.Medium)
get() = MaterialTheme.typography.displayLarge.copy(fontWeight = FontWeight.Bold)
/**
* The hundredths beside [Hero], about half its size. A readout whose
@@ -6,7 +6,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -19,10 +23,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -52,6 +53,7 @@ fun TimerEditorScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
// Registered unconditionally, and deliberately not inside the `picker?.let`
// below: the picker's open flag is ViewModel state that does not survive
@@ -76,11 +78,31 @@ fun TimerEditorScreen(
onBack = onBack,
modifier = modifier,
predictiveBack = true,
actions = {
if (state is TimerEditorUiState.Editing) {
IconButton(onClick = { confirmingDelete = true }) {
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.timer_editor_delete),
)
}
}
},
) {
val editing = state as? TimerEditorUiState.Editing ?: return@CollapsingScaffold
TimerEditorContent(editing = editing, viewModel = viewModel)
}
if (confirmingDelete && state is TimerEditorUiState.Editing) {
DeleteConfirmation(
onConfirm = {
confirmingDelete = false
viewModel.onDelete()
},
onDismiss = { confirmingDelete = false },
)
}
picker?.let { pickerState ->
val editing = state as? TimerEditorUiState.Editing
RingtonePickerScreen(
@@ -103,13 +125,13 @@ private fun TimerEditorContent(
) {
// Saveable, not remembered: a dialog open across a rotation must still be
// open afterwards (M5's lesson).
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
var editingLength by rememberSaveable { mutableStateOf(false) }
var digits by rememberSaveable { mutableStateOf("") }
val entry = TimerDurationEntry(digits)
LengthCard(
editing = editing,
position = Position.Top,
onClick = {
if (editing.canEditDuration) {
digits = ""
@@ -117,12 +139,10 @@ private fun TimerEditorContent(
}
},
)
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped, position = Position.Middle)
SoundRow(editing = editing, onClick = viewModel::onOpenRingtonePicker)
SoundRow(editing = editing, onClick = viewModel::onOpenRingtonePicker, position = Position.Bottom)
Spacer(Modifier.height(16.dp))
DeleteRow(onClick = { confirmingDelete = true })
Spacer(Modifier.height(LocalLivePillInset.current))
if (editingLength) {
@@ -133,14 +153,11 @@ private fun TimerEditorContent(
title = { Text(stringResource(R.string.timer_editor_duration)) },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = entry.display,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurface,
)
TimerDurationReadout(entry = entry)
Spacer(Modifier.height(16.dp))
TimerKeypad(
onDigit = { digits = entry.plusDigit(it).digits },
onDoubleZero = { digits = entry.plusDoubleZero().digits },
onBackspace = { digits = entry.backspace().digits },
)
}
@@ -163,15 +180,6 @@ private fun TimerEditorContent(
},
)
}
if (confirmingDelete) {
DeleteConfirmation(
onConfirm = {
confirmingDelete = false
viewModel.onDelete()
},
onDismiss = { confirmingDelete = false },
)
}
}
/** The configured length, at display size — the one thing this page is about. */
@@ -179,10 +187,11 @@ private fun TimerEditorContent(
private fun LengthCard(
editing: TimerEditorUiState.Editing,
onClick: () -> Unit,
position: Position,
modifier: Modifier = Modifier,
) {
GroupedSurface(
position = Position.Alone,
position = position,
modifier = modifier.padding(horizontal = GroupedListInset),
onClick = onClick,
) {
@@ -190,6 +199,7 @@ private fun LengthCard(
Text(
text = ClockFormat.countdown(editing.duration),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
if (!editing.canEditDuration) {
@@ -207,9 +217,14 @@ private fun LengthCard(
}
@Composable
private fun LabelField(label: String, onLabelTyped: (String) -> Unit, modifier: Modifier = Modifier) {
private fun LabelField(
label: String,
onLabelTyped: (String) -> Unit,
position: Position,
modifier: Modifier = Modifier,
) {
GroupedSurface(
position = Position.Alone,
position = position,
modifier = modifier.padding(horizontal = GroupedListInset),
) {
InlineTextField(
@@ -223,7 +238,7 @@ private fun LabelField(label: String, onLabelTyped: (String) -> Unit, modifier:
/** M5's picker, so every one of its behaviours arrives here for free (D27). */
@Composable
private fun SoundRow(editing: TimerEditorUiState.Editing, onClick: () -> Unit) {
private fun SoundRow(editing: TimerEditorUiState.Editing, onClick: () -> Unit, position: Position) {
val silent = stringResource(R.string.ringtone_silent)
val unknown = stringResource(R.string.ringtone_unknown)
val deviceDefault = stringResource(R.string.ringtone_device_default)
@@ -242,22 +257,11 @@ private fun SoundRow(editing: TimerEditorUiState.Editing, onClick: () -> Unit) {
GroupedRow(
title = stringResource(R.string.timer_editor_sound),
summary = summary.joinToString("\n"),
position = Position.Alone,
position = position,
onClick = onClick,
)
}
/** Tinted through the AnnotatedString overload, so no literal colour is written. */
@Composable
private fun DeleteRow(onClick: () -> Unit) {
val error = MaterialTheme.colorScheme.error
val label = stringResource(R.string.timer_editor_delete)
val title: AnnotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = error)) { append(label) }
}
GroupedRow(title = title, position = Position.Alone, onClick = onClick)
}
/**
* A confirmation rather than an undo chip: M5's answer, and the cheap moment
* to ask is before.
@@ -2,91 +2,114 @@ package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
/**
* Ten digits and a backspace, on a grid whose keys are comfortably past the
* 48 dp minimum: a keypad is aimed at without looking.
* Ten digits, a "00" key and a backspace, on a grid whose keys are
* comfortably past the 48 dp minimum: a keypad is aimed at without looking.
*
* The keys are the canonical M3 `TextButton`/`FilledTonalIconButton` rather
* than anything hand-rolled, so they inherit the ripple, the shape morph and
* the disabled colours from the theme.
* Digit keys are tonal-filled circles rather than bare `TextButton`s —
* Google Clock's calculator keys — so the grid reads as *buttons* at a
* glance; the shape carries that, not a larger label (`PLAN.md` §8).
*/
@Composable
internal fun TimerKeypad(
onDigit: (Char) -> Unit,
onDoubleZero: () -> Unit,
onBackspace: () -> Unit,
modifier: Modifier = Modifier,
keySize: Dp = TimersDefaults.KeypadKeySize,
keyGap: Dp = TimersDefaults.KeypadKeyGap,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(KeyGap),
verticalArrangement = Arrangement.spacedBy(keyGap),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ROWS.forEach { row ->
Row(
horizontalArrangement = Arrangement.spacedBy(KeyGap),
horizontalArrangement = Arrangement.spacedBy(keyGap),
verticalAlignment = Alignment.CenterVertically,
) {
row.forEach { key -> DigitKey(digit = key, onDigit = onDigit) }
row.forEach { key -> DigitKey(digit = key, onDigit = onDigit, size = keySize) }
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(KeyGap),
horizontalArrangement = Arrangement.spacedBy(keyGap),
verticalAlignment = Alignment.CenterVertically,
) {
DigitKey(digit = '0', onDigit = onDigit)
BackspaceKey(onBackspace = onBackspace)
DoubleZeroKey(onDoubleZero = onDoubleZero, size = keySize)
DigitKey(digit = '0', onDigit = onDigit, size = keySize)
BackspaceKey(onBackspace = onBackspace, size = keySize)
}
}
}
@Composable
private fun DigitKey(digit: Char, onDigit: (Char) -> Unit) {
private fun DigitKey(digit: Char, onDigit: (Char) -> Unit, size: Dp) {
val text = digit.toString()
// The glyph is the label, so the announcement says what pressing it does
// rather than reading a bare numeral out of context.
val description = stringResource(R.string.timer_keypad_digit, text)
TextButton(
FilledTonalButton(
onClick = { onDigit(digit) },
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier
.size(TimersDefaults.KeypadKeySize)
.size(size)
.semantics { contentDescription = description },
) {
Text(text = text, style = MaterialTheme.typography.headlineSmall)
Text(text = text, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun BackspaceKey(onBackspace: () -> Unit) {
private fun DoubleZeroKey(onDoubleZero: () -> Unit, size: Dp) {
val description = stringResource(R.string.timer_keypad_double_zero)
FilledTonalButton(
onClick = onDoubleZero,
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier
.size(size)
.semantics { contentDescription = description },
) {
Text(text = "00", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun BackspaceKey(onBackspace: () -> Unit, size: Dp) {
val description = stringResource(R.string.timer_keypad_backspace)
FilledTonalIconButton(
onClick = onBackspace,
modifier = Modifier.size(TimersDefaults.KeypadKeySize),
shape = CircleShape,
modifier = Modifier.size(size),
) {
Icon(Icons.AutoMirrored.Rounded.Backspace, contentDescription = description)
}
}
/** The 8 dp spacing step, so the grid's gaps are the system's rather than ours. */
private val KeyGap = 8.dp
private val ROWS = listOf(
listOf('1', '2', '3'),
listOf('4', '5', '6'),
@@ -0,0 +1,70 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
/**
* The digits as typed, Google Clock's calculator style: "00h 00m 00s" rather
* than a colon-separated clock face, because this is a value being *built*,
* not a time being told. The one place both the setup panel and the editor's
* duration dialog read a [TimerDurationEntry] from (D23's "one answer to 'type
* a duration' in the whole app" now covers the look, not just the state
* machine).
*
* [TimerDurationEntry.display] remains the spoken form, merged onto this whole
* row, so a screen reader hears one phrase rather than three unlabelled pairs
* of digits.
*/
@Composable
internal fun TimerDurationReadout(entry: TimerDurationEntry, modifier: Modifier = Modifier) {
val color = if (entry.isEmpty) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = modifier
.animateContentSizeMotion()
.semantics(mergeDescendants = true) { contentDescription = entry.display },
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Segment(entry.hoursSegment, stringResource(R.string.timer_unit_hours), color)
Segment(entry.minutesSegment, stringResource(R.string.timer_unit_minutes), color)
Segment(entry.secondsSegment, stringResource(R.string.timer_unit_seconds), color)
}
}
@Composable
private fun Segment(value: String, unit: String, color: Color) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = value,
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = color,
)
Text(
text = unit,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 10.dp),
)
}
}
@@ -1,9 +1,12 @@
package de.jeanlucmakiola.clockula.ui.timers
/** The editor's route and the name its argument is declared under. */
/** The editor's and the setup screen's routes, and the editor's argument name. */
internal object TimerRoutes {
const val ARG_TIMER_ID: String = "timerId"
const val EDITOR_PATTERN: String = "timers/edit/{timerId}"
/** No arguments: Start is the only thing that turns typed digits into a timer. */
const val NEW: String = "timers/new"
fun editor(timerId: Long): String = "timers/edit/$timerId"
}
@@ -1,21 +1,39 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.WavyProgressIndicatorDefaults
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -23,41 +41,70 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.ui.common.pressMotion
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.minutes
/**
* One timer as a card in the grouped vocabulary every other surface in the
* family uses: the readout, the label, the wavy progress and the two controls
* for whatever the timer is currently doing.
* One timer as a standalone card, exactly as [de.jeanlucmakiola.clockula.ui.alarms.AlarmRow]
* is: `Position.Alone`, never grouped into a connected block with its
* neighbours, so each timer reads as its own object in the list rather than
* rows of a table.
*
* The progress indicator's **amplitude is the state** — the default wave while
* the timer runs, flat while it is paused or finished. That is
* `docs/PLAN.md` §8's "refinement comes from shape, colour, space and motion,
* never from enlarging text", and timer progress is one of the three places §8
* sanctions a showpiece (D29).
*
* The top-right delete button asks nothing first: re-creating a timer costs
* one tap on the keypad and a Start, which is faster than reading a
* confirmation dialog would be, so there is nothing a dialog would protect
* that undoing the mistake doesn't already cover.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun TimerRow(
row: TimerRowState,
position: Position,
onOpen: () -> Unit,
onStart: () -> Unit,
onPause: () -> Unit,
onReset: () -> Unit,
onAddMinute: () -> Unit,
onSubtractMinute: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val untitled = stringResource(R.string.timer_untitled)
val openDescription = stringResource(R.string.timer_row_open)
val readout = readoutOf(row)
var adjustmentMinutes by remember(row.id) { mutableIntStateOf(0) }
var adjustmentSerial by remember(row.id) { mutableIntStateOf(0) }
val animatedProgress by animateFloatAsState(
targetValue = row.progress,
animationSpec = tween(durationMillis = 1_000, easing = LinearEasing),
label = "timer progress",
)
LaunchedEffect(adjustmentSerial) {
if (adjustmentSerial > 0) {
delay(1_500)
adjustmentMinutes = 0
}
}
fun adjust(direction: Int, action: () -> Unit) {
action()
adjustmentMinutes += direction
adjustmentSerial++
}
// "%1$s left" is a sentence only while something is left: an expired timer
// reads "Finished", and "Finished left" is not a readout. The card's own
// text is otherwise announced as it stands.
@@ -65,8 +112,9 @@ internal fun TimerRow(
TimerMode.EXPIRED -> readout
else -> stringResource(R.string.timer_row_progress, readout)
}
val deleteDescription = stringResource(R.string.timer_editor_delete)
GroupedSurface(
position = position,
position = Position.Alone,
modifier = modifier
.padding(horizontal = GroupedListInset)
// The *purpose of the tap*, not a description of the card: a
@@ -78,62 +126,114 @@ internal fun TimerRow(
onClick = onOpen,
color = containerFor(row.mode),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().animateContentSizeMotion(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Bottom,
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = readout,
style = MaterialTheme.typography.headlineLarge,
color = contentColorFor(containerFor(row.mode)),
modifier = Modifier.semantics { contentDescription = readoutDescription },
if (row.label.isNotBlank()) {
Text(
text = row.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// The delete button sits over this corner; the label
// never grows wide enough to reach it, but the trailing
// padding keeps the two from ever touching.
modifier = Modifier.padding(end = DeleteButtonSize + 12.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().animateContentSizeMotion(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = readout,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = contentColorFor(containerFor(row.mode)),
modifier = Modifier.semantics { contentDescription = readoutDescription },
)
AnimatedContent(
targetState = adjustmentMinutes,
transitionSpec = {
(fadeIn(tween(180)) + scaleIn(initialScale = 0.8f)) togetherWith
fadeOut(tween(120))
},
label = "timer minute adjustment",
) { feedback ->
if (feedback != 0) {
Text(
text = stringResource(
if (feedback > 0) R.string.timer_adjustment_added
else R.string.timer_adjustment_removed,
kotlin.math.abs(feedback),
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
LinearWavyProgressIndicator(
progress = { animatedProgress },
amplitude = if (row.mode == TimerMode.RUNNING) {
WavyProgressIndicatorDefaults.indicatorAmplitude
} else {
FLAT
},
modifier = Modifier.fillMaxWidth(),
)
Text(
text = row.label.ifBlank { untitled },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
TimerControls(
mode = row.mode,
canSubtract = row.remaining > 1.minutes,
onStart = onStart,
onPause = onPause,
onReset = onReset,
onAddMinute = { adjust(1, onAddMinute) },
onSubtractMinute = { adjust(-1, onSubtractMinute) },
)
}
LinearWavyProgressIndicator(
// A lambda, so the bar animates without recomposing the row.
progress = { row.progress },
amplitude = if (row.mode == TimerMode.RUNNING) {
WavyProgressIndicatorDefaults.indicatorAmplitude
} else {
FLAT
},
modifier = Modifier.fillMaxWidth(),
)
TimerControls(
mode = row.mode,
onStart = onStart,
onPause = onPause,
onReset = onReset,
onAddMinute = onAddMinute,
)
FilledTonalIconButton(
onClick = onDelete,
modifier = Modifier
.align(Alignment.TopEnd)
// Flush against the card's own corner, the button's square
// bounds would sit partly under the rounded clip; this
// inset keeps it visually inside the card.
.padding(top = 12.dp, end = 12.dp)
.size(DeleteButtonSize),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = deleteDescription,
modifier = Modifier.size(DeleteIconSize),
)
}
}
}
}
/** Two buttons, and the same pair the notification offers for that mode (D17). */
/** Small enough to read as a corner glyph, not a second action row. */
private val DeleteButtonSize = 32.dp
private val DeleteIconSize = 18.dp
/** The timer's controls, with subtraction while more than one minute remains. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun TimerControls(
mode: TimerMode,
canSubtract: Boolean,
onStart: () -> Unit,
onPause: () -> Unit,
onReset: () -> Unit,
onAddMinute: () -> Unit,
onSubtractMinute: () -> Unit,
modifier: Modifier = Modifier,
) {
val primary: Pair<Int, () -> Unit> = when (mode) {
@@ -149,17 +249,22 @@ private fun TimerControls(
}
ButtonGroup(modifier = modifier) {
listOf(primary, secondary).forEach { (label, action) ->
// `animateWidth` is the expressive press-widening — the component's
// whole point, and the reason the pair is a ButtonGroup rather than
// two buttons in a Row.
val actions = buildList {
add(primary)
if (mode == TimerMode.RUNNING || mode == TimerMode.PAUSED) {
add(R.string.timer_subtract_minute to onSubtractMinute)
}
add(secondary)
}
actions.forEach { (label, action) ->
val interaction = remember { MutableInteractionSource() }
FilledTonalButton(
onClick = action,
enabled = label != R.string.timer_subtract_minute || canSubtract,
interactionSource = interaction,
modifier = Modifier.animateWidth(interaction),
modifier = Modifier.pressMotion(interaction),
) {
Text(stringResource(label))
Text(stringResource(label), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@@ -14,7 +14,7 @@ data class TimerRowState(
/** What the row reads: the configured duration when IDLE, the snapshot otherwise. */
val remaining: Duration,
val duration: Duration,
/** remaining/duration, clamped 0f..1f. 1f when IDLE, 0f when EXPIRED or duration is zero. */
/** Elapsed fraction, clamped 0f..1f. It grows as the countdown falls. */
val progress: Float,
)
@@ -45,13 +45,14 @@ internal object TimerListRows {
mode = reading.mode,
remaining = reading.remaining,
duration = timer.duration,
progress = progressOf(reading.remaining, timer.duration),
progress = progressOf(reading.remaining, timer.duration, reading.mode),
)
}
/** A zero-length timer divides by nothing, and a longer remainder is clamped full. */
private fun progressOf(remaining: Duration, duration: Duration): Float {
/** A zero-length timer divides by nothing, and extra remaining time starts empty. */
private fun progressOf(remaining: Duration, duration: Duration, mode: TimerMode): Float {
if (mode == TimerMode.EXPIRED) return 1f
if (duration <= Duration.ZERO) return 0f
return (remaining / duration).toFloat().coerceIn(0f, 1f)
return (1f - (remaining / duration).toFloat()).coerceIn(0f, 1f)
}
}
@@ -1,188 +0,0 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
import kotlin.time.Duration
/**
* Setting a timer: the readout of what has been typed, the preset chips, the
* keypad and one Start. The most common action in the app must not cost four
* steps, so the same panel is hosted in a bottom sheet over a non-empty list
* and *inline* on an empty one — where it is the empty state, and there is no
* "no timers yet" card to look at and then dismiss (D23).
*
* The panel holds no state: [entry] and its two callbacks live in the caller as
* `rememberSaveable`, so the typed digits survive rotation *and* process death.
*/
@Composable
internal fun TimerSetupPanel(
entry: TimerDurationEntry,
presets: List<Duration>,
onDigit: (Char) -> Unit,
onBackspace: () -> Unit,
onPreset: (Duration) -> Unit,
onSavePreset: (Duration) -> Unit,
onRemovePreset: (Duration) -> Unit,
onStart: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Readout(entry = entry)
PresetChips(
entry = entry,
presets = presets,
onPreset = onPreset,
onSavePreset = onSavePreset,
onRemovePreset = onRemovePreset,
)
TimerKeypad(onDigit = onDigit, onBackspace = onBackspace)
Button(
onClick = onStart,
// Nothing outside 1 s…24 h is startable, which is `ClockPrefs`' own
// clamp — so nothing the user can choose is clamped away on the next
// read (M5 D16).
enabled = entry.canStart,
) {
Text(stringResource(R.string.timers_setup_start))
}
}
}
/**
* `displayMedium`, a plain scale role: the app-wide big-readout ramp is settled
* against a working stopwatch in M7, not guessed at here (D28).
*/
@Composable
private fun Readout(entry: TimerDurationEntry, modifier: Modifier = Modifier) {
Column(
modifier = modifier.animateContentSizeMotion(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = entry.display,
style = MaterialTheme.typography.displayMedium,
color = if (entry.isEmpty) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (!entry.isEmpty && !entry.canStart) {
Text(
text = stringResource(R.string.timers_setup_range_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* The whole preset-management surface: tap to fill the readout, the chip's own
* trailing icon to remove, and a Save chip when the typed duration is not
* already one. A presets *screen* is M10's if anyone wants one (D13).
*
* `InputChip` rather than `AssistChip`, which is where the plan's D29 landed:
* a preset **is** a user-supplied value, which is the case `InputChip` and its
* `trailingIcon` slot exist for. The plan asked for a long-press instead, and
* that cannot be built over a Material chip without hand-rolling one — the
* chip's own `clickable` consumes the gesture before any wrapper sees it — and
* a long-press with no affordance is undiscoverable either way.
*/
@Composable
private fun PresetChips(
entry: TimerDurationEntry,
presets: List<Duration>,
onPreset: (Duration) -> Unit,
onSavePreset: (Duration) -> Unit,
onRemovePreset: (Duration) -> Unit,
modifier: Modifier = Modifier,
) {
val typed = entry.duration
val canSave = entry.canStart && typed !in presets
if (presets.isEmpty() && !canSave) return
val remove = stringResource(R.string.timers_preset_remove)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.timers_presets),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = GroupedListInset),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
presets.forEach { preset ->
InputChip(
selected = preset == typed,
onClick = { onPreset(preset) },
label = { Text(ClockFormat.countdown(preset)) },
trailingIcon = {
// An `IconButton`, not a `clickable` icon: removing a
// preset is destructive and it sits inside the chip's own
// click area, so it needs the platform's 48 dp minimum
// target and the Button role rather than a 24 dp square.
// The chip grows to that height with it, which is the
// right direction for a target the user must be able to
// hit — or miss — deliberately.
IconButton(onClick = { onRemovePreset(preset) }) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = remove,
modifier = Modifier.size(InputChipDefaults.AvatarSize),
)
}
},
)
}
if (canSave) {
AssistChip(
onClick = { onSavePreset(typed) },
label = { Text(stringResource(R.string.timers_preset_save)) },
)
}
}
}
}
@@ -0,0 +1,196 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.floret.components.CollapsingScaffold
/**
* Adding a timer, full screen — Google Clock's own calculator keypad rather
* than a sheet or an inline card: the one thing this page does is take a
* duration, and it gets the whole screen to do it in. No presets here; the
* keypad and the "00" key already make a round number fast to type (D23's
* rationale for the old inline/sheet split no longer applies once there is no
* list to stay behind).
*
* [onBack] both cancels and — after a successful Start — returns to the list,
* since "Start" is still the commit point: the row exists and is running the
* moment it is created (D23).
*/
@Composable
fun TimerSetupScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TimersViewModel = hiltViewModel(),
) {
// Saveable, not remembered: the typed digits survive rotation *and*
// process death, same as the state they replaced (D23).
var digits by rememberSaveable { mutableStateOf("") }
val entry = TimerDurationEntry(digits)
fun start() {
viewModel.onStartNew(entry.duration)
onBack()
}
CollapsingScaffold(
title = stringResource(R.string.timers_set_title),
onBack = onBack,
modifier = modifier,
largeTopBar = false,
predictiveBack = true,
// The content owns its own layout below: a thumb-zone keypad wants a
// flexible spacer above it, which a `verticalScroll` column — the
// scaffold's default — gives an unbounded height and no weight to
// divide (M5's lesson, applied here).
scrollable = false,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(24.dp))
Readout(entry = entry)
// Pushes the keypad into the lower half of the screen — the thumb
// zone on a one-handed phone grip — rather than centring it, which
// leaves the heaviest, most-tapped surface at the hardest reach.
Spacer(Modifier.weight(1f))
TimerKeypad(
onDigit = { digits = entry.plusDigit(it).digits },
onDoubleZero = { digits = entry.plusDoubleZero().digits },
onBackspace = { digits = entry.backspace().digits },
keySize = TimersDefaults.KeypadKeySizeLarge,
keyGap = TimersDefaults.KeypadKeyGapLarge,
)
Spacer(Modifier.height(24.dp))
StartRow(
entry = entry,
onClear = { digits = entry.cleared().digits },
onStart = ::start,
)
Spacer(Modifier.height(32.dp))
}
}
}
/** Shared with the Timers tab's own empty-state inline input. */
@Composable
internal fun Readout(entry: TimerDurationEntry, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TimerDurationReadout(entry = entry)
if (!entry.isEmpty && !entry.canStart) {
Text(
text = stringResource(R.string.timers_setup_range_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* The play button — bigger than a normal icon button and filled rather than
* tonal, since it is the one unambiguous primary action on the screen and
* Google Clock marks it the same way — stays dead centre and never moves:
* Clear sits in a fixed slot beside it and only fades/scales into that slot,
* rather than sharing a centred row that would shift Start sideways the
* moment Clear appears.
*
* Also shared with the Timers tab's empty-state inline input.
*/
@Composable
internal fun StartRow(
entry: TimerDurationEntry,
onClear: () -> Unit,
onStart: () -> Unit,
modifier: Modifier = Modifier,
) {
val start = stringResource(R.string.timers_setup_start)
Box(
modifier = modifier
.fillMaxWidth()
.height(StartButtonSize),
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(
visible = !entry.isEmpty,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = Modifier.offset(x = -(StartButtonSize / 2 + ClearButtonGap + ClearButtonSize / 2)),
) {
FilledTonalIconButton(
onClick = onClear,
modifier = Modifier.size(ClearButtonSize),
) {
Icon(
imageVector = Icons.Rounded.DeleteOutline,
contentDescription = stringResource(R.string.timers_setup_clear),
)
}
}
FilledIconButton(
onClick = onStart,
// Nothing outside 1 s…24 h is startable, which is `ClockPrefs`'
// own clamp — so nothing the user can choose is clamped away on
// the next read (M5 D16).
enabled = entry.canStart,
modifier = Modifier
.size(StartButtonSize)
.semantics { contentDescription = start },
) {
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = null,
modifier = Modifier.size(StartButtonSize * 0.45f),
)
}
}
}
/** Comfortably past a normal icon button: the one unambiguous action gets the emphasis. */
private val StartButtonSize = 96.dp
private val ClearButtonSize = StartButtonSize * 0.6f
private val ClearButtonGap = 20.dp
@@ -16,4 +16,15 @@ internal object TimersDefaults {
/** Comfortably past the 48 dp minimum: a keypad is aimed at without looking. */
val KeypadKeySize: Dp = 64.dp
/** The 8 dp spacing step, so the grid's gaps are the system's rather than ours. */
val KeypadKeyGap: Dp = 8.dp
/**
* The setup screen's own, larger keys: it has the whole screen and nothing
* else competing for it, so the keypad — the one thing that screen does —
* takes the emphasis a shared, dialog-sized keypad can't afford.
*/
val KeypadKeySizeLarge: Dp = 92.dp
val KeypadKeyGapLarge: Dp = 14.dp
}
@@ -1,23 +1,25 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -27,75 +29,43 @@ import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion
import kotlin.time.Duration
/**
* The Timers tab: several timers at once, each with its own controls, and the
* setup panel that starts a new one.
* The Timers tab: several timers at once, each with its own controls, and a
* FAB that opens the full-screen setup page ([TimerSetupScreen]) to start a
* new one.
*
* On a **non-empty** list the FAB opens the panel as a `ModalBottomSheet`; on
* an **empty** one the tab hosts the same panel inline, because that *is* the
* empty state (D23). Deliberately not M5's create-then-navigate: an alarm is
* live the moment it exists, so creating it first is honest, while a timer has
* a natural commit point — "Start" — and a row created by an accidental FAB tap
* would be litter.
* On an **empty** list the FAB is noise — there is nothing to add a timer
* *to* yet — so the tab hosts the same readout/keypad/Start the full screen
* does, inline, in place of a "tap + to start" card: the keypad *is* the
* empty state, the same call D23 originally made, carried over to the
* redesigned input.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimersScreen(
onEditTimer: (Long) -> Unit,
onAddTimer: () -> Unit,
modifier: Modifier = Modifier,
requestSetup: Boolean = false,
onSetupRequestConsumed: () -> Unit = {},
viewModel: TimersViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pillInset = LocalLivePillInset.current
val addTimer = stringResource(R.string.timers_set_title)
// Saveable, not remembered: the typed digits and the sheet's open flag then
// survive rotation *and* process death, and the pure `TimerDurationEntry`
// is derived from the saved digit string (D23).
var digits by rememberSaveable { mutableStateOf("") }
var sheetOpen by rememberSaveable { mutableStateOf(false) }
val entry = TimerDurationEntry(digits)
val sheetState = rememberModalBottomSheetState()
val loaded = state as? TimersUiState.Loaded
val rows = loaded?.rows
val presets = loaded?.presets.orEmpty()
// `SET_TIMER` with no usable length asks for the setup panel (M9 D7). On an
// empty list that panel is already inline and *is* the empty state, so the
// sheet must not also open there.
// Keyed on whether the list has *arrived and is empty*, never on the list
// itself: `rows` is recomputed on every readout tick, so keying on it would
// cancel and relaunch this effect once a second for the life of the tab.
LaunchedEffect(requestSetup, rows?.isEmpty()) {
val loadedRows = rows ?: return@LaunchedEffect
if (!requestSetup) return@LaunchedEffect
if (loadedRows.isNotEmpty()) sheetOpen = true
onSetupRequestConsumed()
}
fun start() {
viewModel.onStartNew(entry.duration)
digits = ""
sheetOpen = false
}
CollapsingScaffold(
title = stringResource(R.string.tab_timers),
modifier = modifier,
scrollable = false,
floatingActionButton = {
// Only over a list: on an empty tab the panel is already on screen,
// and a FAB that opens what is in front of you is noise.
// Only over a list: on an empty tab the keypad is already on
// screen, and a FAB that opens what is in front of you is noise.
if (rows != null && rows.isNotEmpty()) {
FloatingActionButton(
onClick = { sheetOpen = true },
onClick = onAddTimer,
// The pill is bottom-centre and the FAB bottom-end, so on a
// narrow phone they would otherwise overlap (M5 D6).
modifier = Modifier.padding(bottom = pillInset),
@@ -107,35 +77,31 @@ fun TimersScreen(
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
// Disconnected cards, exactly as the Alarms tab's own list is: each
// timer is its own object, not a row of a table.
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = TimersDefaults.ListBottomPadding,
bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset,
),
) {
if (rows != null && rows.isEmpty()) {
item {
TimerSetupPanel(
entry = entry,
presets = presets,
onDigit = { digits = entry.plusDigit(it).digits },
onBackspace = { digits = entry.backspace().digits },
onPreset = { digits = digitsFor(it) },
onSavePreset = viewModel::onSavePreset,
onRemovePreset = viewModel::onRemovePreset,
onStart = ::start,
modifier = Modifier.padding(vertical = 16.dp),
)
}
item { InlineTimerSetup(onStartNew = viewModel::onStartNew) }
}
itemsIndexed(rows.orEmpty(), key = { _, row -> row.id }) { index, row ->
items(rows.orEmpty(), key = { row -> row.id }) { row ->
TimerRow(
row = row,
position = positionOf(index, rows.orEmpty().size),
onOpen = { onEditTimer(row.id) },
onStart = { viewModel.onStart(row.id) },
onPause = { viewModel.onPause(row.id) },
onReset = { viewModel.onReset(row.id) },
onAddMinute = { viewModel.onAddMinute(row.id) },
onSubtractMinute = { viewModel.onSubtractMinute(row.id) },
// No confirmation: re-creating a timer costs one tap and a
// Start, faster than reading a dialog would be (D2's delete
// still routes through the engine, so a ringing timer is
// stopped cleanly rather than deleted into a stuck ring).
onDelete = { viewModel.onDelete(row.id) },
// A timer that finishes stays where it is (D22); the motion
// is for a row arriving or leaving.
modifier = animateItemMotion(),
@@ -143,39 +109,48 @@ fun TimersScreen(
}
}
}
if (sheetOpen) {
ModalBottomSheet(
onDismissRequest = { sheetOpen = false },
sheetState = sheetState,
) {
TimerSetupPanel(
entry = entry,
presets = presets,
onDigit = { digits = entry.plusDigit(it).digits },
onBackspace = { digits = entry.backspace().digits },
onPreset = { digits = digitsFor(it) },
onSavePreset = viewModel::onSavePreset,
onRemovePreset = viewModel::onRemovePreset,
onStart = ::start,
modifier = Modifier.padding(bottom = 24.dp),
)
}
}
}
/**
* A preset fills the readout as if it had been typed, so the keypad and the
* chips share one state and a chip can still be corrected by hand.
* The empty state: the same readout, keypad and Start [TimerSetupScreen]
* uses, stacked inline rather than anchored to the thumb zone of a dedicated
* screen — there is no screen's worth of empty space above it to leave for
* reach, just the top of a list.
*/
private fun digitsFor(duration: Duration): String {
val total = duration.inWholeSeconds
val hours = total / 3_600
val minutes = (total % 3_600) / 60
val seconds = total % 60
return buildString {
append(hours.toString().padStart(2, '0'))
append(minutes.toString().padStart(2, '0'))
append(seconds.toString().padStart(2, '0'))
}.trimStart('0')
@Composable
private fun InlineTimerSetup(onStartNew: (kotlin.time.Duration) -> Unit, modifier: Modifier = Modifier) {
// Saveable, not remembered: the typed digits survive rotation *and*
// process death (D23).
var digits by rememberSaveable { mutableStateOf("") }
val entry = TimerDurationEntry(digits)
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Readout(entry = entry)
Spacer(Modifier.height(24.dp))
TimerKeypad(
onDigit = { digits = entry.plusDigit(it).digits },
onDoubleZero = { digits = entry.plusDoubleZero().digits },
onBackspace = { digits = entry.backspace().digits },
keySize = TimersDefaults.KeypadKeySizeLarge,
keyGap = TimersDefaults.KeypadKeyGapLarge,
)
Spacer(Modifier.height(24.dp))
StartRow(
entry = entry,
onClear = { digits = entry.cleared().digits },
onStart = {
onStartNew(entry.duration)
digits = ""
},
)
}
}
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import javax.inject.Inject
/**
@@ -75,6 +76,8 @@ class TimersViewModel @Inject internal constructor(
fun onAddMinute(timerId: Long) = launchOnEngine { engine.addTime(timerId) }
fun onSubtractMinute(timerId: Long) = launchOnEngine { engine.addTime(timerId, -1.minutes) }
/** Through the engine, so a *ringing* timer cannot be deleted into a stuck ring (D2). */
fun onDelete(timerId: Long) = launchOnEngine { engine.delete(timerId) }
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
@@ -20,6 +21,7 @@ import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
@@ -31,10 +33,10 @@ import de.jeanlucmakiola.floret.components.Position
import java.time.LocalTime
/**
* One city inside a `GroupedSurface` at `ReorderableRowHeight` — a bespoke
* One city inside a `GroupedSurface` at an 80dp row height — a bespoke
* `Row` rather than a `GroupedRow`, for the same reason M7's `LapRow` is:
* `ListItem` imposes its own 72dp two-line minimum and would fight the
* reorderable column's 64dp pitch, and the row already needs a drag handle in
* `ListItem` imposes its own minimum and would fight the
* reorderable column's fixed pitch, and the row already needs a drag handle in
* the leading slot (D14).
*
* A drag-only reorder is inaccessible — TalkBack cannot press-and-drag and
@@ -93,6 +95,7 @@ internal fun WorldClockRow(
Row(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(start = 12.dp, end = 20.dp)
.semantics(mergeDescendants = true) {
// Spoken as one sentence rather than as three merged child
@@ -131,7 +134,8 @@ internal fun WorldClockRow(
text = time,
// Tabular, because M7 made the headline role tabular
// app-wide: a column of times must not jitter as it ticks.
style = MaterialTheme.typography.headlineSmall,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = if (isDragging) {
MaterialTheme.colorScheme.primary
} else {
@@ -111,6 +111,7 @@ fun WorldClockScreen(
keyOf = { it.id },
onReorder = { rows -> viewModel.onReorder(rows.map { it.id }) },
modifier = Modifier.fillMaxWidth(),
rowHeight = 80.dp,
) { row, position, dragHandle, isDragging ->
WorldClockRow(
row = row,
+9 -2
View File
@@ -13,7 +13,6 @@
<string name="alarm_snoozed_until">Snoozed until %1$s</string>
<!-- Crash reporting (floret-kit core-crash) -->
<string name="crash_report_issue_title">Crash report</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</string>
<!-- M4: the app shell's four tabs -->
@@ -35,12 +34,20 @@
<string name="timers_preset_remove">Remove preset</string>
<string name="timer_keypad_backspace">Delete a digit</string>
<string name="timer_keypad_digit">Type %1$s</string>
<string name="timer_keypad_double_zero">Type 00</string>
<string name="timers_setup_clear">Clear</string>
<string name="timer_unit_hours">h</string>
<string name="timer_unit_minutes">m</string>
<string name="timer_unit_seconds">s</string>
<string name="timer_start">Start</string>
<string name="timer_pause">Pause</string>
<string name="timer_resume">Resume</string>
<string name="timer_reset">Reset</string>
<string name="timer_stop">Stop</string>
<string name="timer_add_minute">+1 min</string>
<string name="timer_subtract_minute">−1 min</string>
<string name="timer_adjustment_added">+%1$d min</string>
<string name="timer_adjustment_removed">−%1$d min</string>
<string name="timer_finished">Finished</string>
<string name="timer_paused_at">Paused \u00b7 %1$s</string>
<string name="timer_untitled">Timer</string>
@@ -48,7 +55,7 @@
<string name="timer_row_open">Timer settings</string>
<!-- M6: the timer editor -->
<string name="timer_editor_title">Timer</string>
<string name="timer_editor_title">Countdown</string>
<string name="timer_editor_label">Label</string>
<string name="timer_editor_duration">Length</string>
<string name="timer_editor_duration_locked">Reset the timer to change its length</string>
@@ -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()
@@ -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())
}
}
@@ -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 */
@@ -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
@@ -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`() {
@@ -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 */
View File