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:
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
+81
-26
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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 */
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 1934306903...18a797747b
Reference in New Issue
Block a user