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"]
|
[submodule "floret-kit"]
|
||||||
path = floret-kit
|
path = floret-kit
|
||||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||||
|
branch = feat/clockula
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class AlarmsInstrumentedTest {
|
|||||||
fun theFabCreatesAnAlarmAndOpensItsEditor() {
|
fun theFabCreatesAnAlarmAndOpensItsEditor() {
|
||||||
openEditorForNewAlarm()
|
openEditorForNewAlarm()
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(string(R.string.editor_title)).assertIsDisplayed()
|
composeTestRule.onNodeWithContentDescription("hour", substring = true).assertIsDisplayed()
|
||||||
assertThat(runBlocking { seams.alarms().alarms().first() }).hasSize(1)
|
assertThat(runBlocking { seams.alarms().alarms().first() }).hasSize(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ class AlarmsInstrumentedTest {
|
|||||||
composeTestRule.onNodeWithText("Work").performClick()
|
composeTestRule.onNodeWithText("Work").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(string(R.string.editor_delete)).performClick()
|
composeTestRule.onNodeWithContentDescription(string(R.string.editor_delete)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
|
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|||||||
@@ -105,14 +105,14 @@ class StopwatchInstrumentedTest {
|
|||||||
|
|
||||||
/** §5.14 #2 */
|
/** §5.14 #2 */
|
||||||
@Test
|
@Test
|
||||||
fun twoTapsOnLapAppendTwoRowsAndLeaveAThirdInProgress() {
|
fun newLapsStayVisibleAtTheTopOfTheList() {
|
||||||
openStopwatchTab()
|
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
|
composeTestRule
|
||||||
.onNodeWithContentDescription(context.getString(R.string.stopwatch_lap_number, 3), substring = true)
|
.onNodeWithContentDescription(context.getString(R.string.stopwatch_lap_number, 5), substring = true)
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
|||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -96,10 +97,13 @@ class TimersInstrumentedTest {
|
|||||||
/** §5.19 #1 */
|
/** §5.19 #1 */
|
||||||
@Test
|
@Test
|
||||||
fun typingOnTheKeypadAndPressingStartCreatesOneRunningTimer() {
|
fun typingOnTheKeypadAndPressingStartCreatesOneRunningTimer() {
|
||||||
|
// The list starts empty (teardown clears it), so the keypad is
|
||||||
|
// already inline — no FAB tap needed to reach it.
|
||||||
openTimersTab()
|
openTimersTab()
|
||||||
|
|
||||||
typeDigits('1', '3', '0')
|
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()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
val timers = stored()
|
val timers = stored()
|
||||||
@@ -116,7 +120,9 @@ class TimersInstrumentedTest {
|
|||||||
launched.recreate()
|
launched.recreate()
|
||||||
composeTestRule.waitForIdle()
|
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 */
|
/** §5.19 #3 */
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
|
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
|
||||||
to the foreground service, not to the notification. -->
|
to the foreground service, not to the notification. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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
|
<!-- The ringing service. systemExempted is Android's own answer for an app
|
||||||
holding an exact-alarm permission that continues an alarm in the
|
holding an exact-alarm permission that continues an alarm in the
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class ClockulaApp : Application() {
|
|||||||
appLabel = getString(R.string.app_name),
|
appLabel = getString(R.string.app_name),
|
||||||
newIssueUrl = getString(R.string.report_issue_url),
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
chooseIssueUrl = 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)
|
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
|
* 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
|
* [Timer.duration] is untouched on every branch, so [reset] still returns
|
||||||
* the timer to the length the user configured (M6 D12).
|
* 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
|
* 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
|
* moved, so `reset` still returns the timer to the length the user
|
||||||
* configured (M6 D12).
|
* configured (M6 D12).
|
||||||
*
|
*
|
||||||
* | Before | After |
|
* | Before | After |
|
||||||
* |---|---|
|
* |---|---|
|
||||||
* | RUNNING | still running, rebased on `snapshot.remaining + extra` |
|
* | RUNNING | still running, rebased on `snapshot.remaining + extra` if positive |
|
||||||
* | PAUSED | still paused, `remaining + extra`. The user paused deliberately |
|
* | PAUSED | still paused, `remaining + extra` if positive |
|
||||||
* | EXPIRED | **RUNNING with exactly `extra` left**, all three anchors written |
|
* | EXPIRED | **RUNNING with exactly `extra` left** for positive additions |
|
||||||
* | IDLE | nothing. A stale notification action is a silent no-op |
|
* | IDLE | nothing. A stale notification action is a silent no-op |
|
||||||
*/
|
*/
|
||||||
override suspend fun addTime(id: Long, extra: Duration) {
|
override suspend fun addTime(id: Long, extra: Duration) {
|
||||||
|
if (extra == Duration.ZERO) return
|
||||||
val now = elapsedRealtimeClock.elapsedRealtime()
|
val now = elapsedRealtimeClock.elapsedRealtime()
|
||||||
val wall = wallClock.now()
|
val wall = wallClock.now()
|
||||||
edit(id, wall) { timer ->
|
edit(id, wall) { timer ->
|
||||||
@@ -94,17 +95,22 @@ class TimerRepositoryImpl @Inject constructor(
|
|||||||
// a timer whose anchor is already past has zero left, and the
|
// a timer whose anchor is already past has zero left, and the
|
||||||
// user asked for `extra` more than zero — not `extra` more than
|
// user asked for `extra` more than zero — not `extra` more than
|
||||||
// an anchor that has gone by.
|
// an anchor that has gone by.
|
||||||
TimerState.RUNNING ->
|
TimerState.RUNNING -> {
|
||||||
timer.anchored(timer.snapshotAt(now, wall).remaining + extra, now, wall)
|
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
|
// 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
|
// 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
|
// commonest gesture in a timer app, and a second tap on Start is
|
||||||
// a papercut. Resuming here is also the only way to avoid
|
// a papercut. Resuming here is also the only way to avoid
|
||||||
// emitting an intermediate PAUSED frame to the pill and the row.
|
// 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
|
TimerState.IDLE -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ data class TimerDurationEntry(val digits: String = "") {
|
|||||||
/** [duration] inside `TimerPresets.MIN..MAX`. */
|
/** [duration] inside `TimerPresets.MIN..MAX`. */
|
||||||
val canStart: Boolean get() = duration in TimerPresets.MIN..TimerPresets.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. */
|
/** A non-digit, a seventh digit, and a leading zero into an empty entry are all ignored. */
|
||||||
fun plusDigit(digit: Char): TimerDurationEntry = when {
|
fun plusDigit(digit: Char): TimerDurationEntry = when {
|
||||||
!digit.isDigit() -> this
|
!digit.isDigit() -> this
|
||||||
@@ -48,6 +56,9 @@ data class TimerDurationEntry(val digits: String = "") {
|
|||||||
else -> TimerDurationEntry(digits + digit)
|
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 =
|
fun backspace(): TimerDurationEntry =
|
||||||
if (digits.isEmpty()) this else TimerDurationEntry(digits.dropLast(1))
|
if (digits.isEmpty()) this else TimerDurationEntry(digits.dropLast(1))
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ data class TimerNotificationState(
|
|||||||
val label: String,
|
val label: String,
|
||||||
val mode: TimerMode,
|
val mode: TimerMode,
|
||||||
val remaining: Duration,
|
val remaining: Duration,
|
||||||
|
val duration: Duration,
|
||||||
/** The chronometer's wall-clock base. Non-null only for a RUNNING subject with a wall-clock end. */
|
/** The chronometer's wall-clock base. Non-null only for a RUNNING subject with a wall-clock end. */
|
||||||
val chronometerBase: Instant?,
|
val chronometerBase: Instant?,
|
||||||
val otherActiveTimers: Int,
|
val otherActiveTimers: Int,
|
||||||
@@ -32,6 +33,7 @@ object TimerNotificationPolicy {
|
|||||||
label = subject.timer.label,
|
label = subject.timer.label,
|
||||||
mode = subject.mode,
|
mode = subject.mode,
|
||||||
remaining = subject.remaining,
|
remaining = subject.remaining,
|
||||||
|
duration = subject.timer.duration,
|
||||||
// The platform renders the countdown, so the service posts once per
|
// The platform renders the countdown, so the service posts once per
|
||||||
// state change rather than once per second (D19). A running row with
|
// 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
|
// 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) {
|
private fun actionsFor(mode: TimerMode): List<TimerNotificationAction> = when (mode) {
|
||||||
TimerMode.RUNNING -> listOf(TimerNotificationAction.PAUSE, TimerNotificationAction.ADD_MINUTE)
|
TimerMode.RUNNING -> listOf(TimerNotificationAction.PAUSE, TimerNotificationAction.ADD_MINUTE)
|
||||||
TimerMode.PAUSED -> listOf(TimerNotificationAction.RESUME, TimerNotificationAction.RESET)
|
TimerMode.PAUSED -> listOf(TimerNotificationAction.RESUME, TimerNotificationAction.RESET)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import de.jeanlucmakiola.clockula.MainActivity
|
import de.jeanlucmakiola.clockula.MainActivity
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
@@ -74,7 +75,7 @@ object TimerNotifications {
|
|||||||
.setContentIntent(showPendingIntent(context))
|
.setContentIntent(showPendingIntent(context))
|
||||||
.build()
|
.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 {
|
fun timer(context: Context, state: TimerNotificationState): Notification {
|
||||||
val builder = NotificationCompat.Builder(context, TIMER_CHANNEL_ID)
|
val builder = NotificationCompat.Builder(context, TIMER_CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
@@ -85,6 +86,9 @@ object TimerNotifications {
|
|||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.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
|
// False exactly once per ring session, so an expiry heads-ups once
|
||||||
// and a countdown update never does (D17). `setOnlyAlertOnce` alone
|
// and a countdown update never does (D17). `setOnlyAlertOnce` alone
|
||||||
// would let the *first* post of a countdown alert, because it only
|
// would let the *first* post of a countdown alert, because it only
|
||||||
@@ -93,6 +97,16 @@ object TimerNotifications {
|
|||||||
.setOnlyAlertOnce(state.alertOnce)
|
.setOnlyAlertOnce(state.alertOnce)
|
||||||
.setContentIntent(showPendingIntent(context))
|
.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) {
|
if (state.otherActiveTimers > 0) {
|
||||||
builder.setSubText(
|
builder.setSubText(
|
||||||
context.resources.getQuantityString(
|
context.resources.getQuantityString(
|
||||||
@@ -123,7 +137,14 @@ object TimerNotifications {
|
|||||||
actionPendingIntent(context, action, state.subjectId),
|
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). */
|
/** 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.data.timers.TimerRepository
|
||||||
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
|
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
|
||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
|
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.domain.timer.TimerRingDecision
|
||||||
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
|
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
|
||||||
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
||||||
@@ -81,6 +82,7 @@ class TimerService : Service() {
|
|||||||
/** The two independent delays the engine hands down (D20). */
|
/** The two independent delays the engine hands down (D20). */
|
||||||
private var expiry: Job? = null
|
private var expiry: Job? = null
|
||||||
private var silence: 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. */
|
/** Which timer the audio is currently sounding for, so an unchanged decision does nothing. */
|
||||||
private var sounding: Long? = null
|
private var sounding: Long? = null
|
||||||
@@ -140,6 +142,7 @@ class TimerService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
progressRefresh?.cancel()
|
||||||
releaseRing()
|
releaseRing()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@@ -169,6 +172,15 @@ class TimerService : Service() {
|
|||||||
applyRing(state.ring)
|
applyRing(state.ring)
|
||||||
post(notification)
|
post(notification)
|
||||||
scheduleDelays(state)
|
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() {
|
private fun stop() {
|
||||||
expiry?.cancel()
|
expiry?.cancel()
|
||||||
silence?.cancel()
|
silence?.cancel()
|
||||||
|
progressRefresh?.cancel()
|
||||||
releaseRing()
|
releaseRing()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
// The placeholder is posted before anything is read, so a service that
|
// 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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -19,10 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -49,11 +50,14 @@ private enum class OpenPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMI
|
|||||||
@Composable
|
@Composable
|
||||||
fun AlarmEditorScreen(
|
fun AlarmEditorScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
openTimePicker: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AlarmEditorViewModel = hiltViewModel(),
|
viewModel: AlarmEditorViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
|
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
|
||||||
|
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val formatTime = rememberAlarmTimeFormatter()
|
||||||
|
|
||||||
// Registered unconditionally, and deliberately **not** inside the
|
// Registered unconditionally, and deliberately **not** inside the
|
||||||
// `picker?.let` below: the picker's open flag is ViewModel state that does
|
// `picker?.let` below: the picker's open flag is ViewModel state that does
|
||||||
@@ -79,9 +83,35 @@ fun AlarmEditorScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
predictiveBack = true,
|
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
|
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 ->
|
picker?.let { pickerState ->
|
||||||
@@ -103,6 +133,7 @@ private const val AUDIO_MIME = "audio/*"
|
|||||||
private fun AlarmEditorContent(
|
private fun AlarmEditorContent(
|
||||||
editing: AlarmEditorUiState.Editing,
|
editing: AlarmEditorUiState.Editing,
|
||||||
viewModel: AlarmEditorViewModel,
|
viewModel: AlarmEditorViewModel,
|
||||||
|
openTimePicker: Boolean,
|
||||||
) {
|
) {
|
||||||
// Saveable, not remembered: a dialog open across a rotation must still be
|
// Saveable, not remembered: a dialog open across a rotation must still be
|
||||||
// open afterwards — `AlarmTimePickerDialog` already saves its own display
|
// 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
|
// and keep the mode (M5 D22's "back is just back" is about writes, not
|
||||||
// about losing a half-entered time).
|
// about losing a half-entered time).
|
||||||
var openPicker by rememberSaveable { mutableStateOf<OpenPicker?>(null) }
|
var openPicker by rememberSaveable { mutableStateOf<OpenPicker?>(null) }
|
||||||
var pickingTime by rememberSaveable { mutableStateOf(false) }
|
var pickingTime by rememberSaveable { mutableStateOf(openTimePicker) }
|
||||||
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val formatTime = rememberAlarmTimeFormatter()
|
|
||||||
|
|
||||||
TimeCard(time = editing.time, onClick = { pickingTime = true })
|
TimeCard(time = editing.time, onClick = { pickingTime = true })
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
|
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
|
||||||
|
|
||||||
SectionHeader(stringResource(R.string.editor_repeat))
|
GroupedSectionHeader(stringResource(R.string.editor_repeat))
|
||||||
RepeatDaySelector(repeatDays = editing.repeatDays, onDayToggled = viewModel::onDayToggled)
|
RepeatDaySelector(
|
||||||
|
repeatDays = editing.repeatDays,
|
||||||
|
onDayToggled = viewModel::onDayToggled,
|
||||||
|
position = if (editing.canSkipNext) Position.Top else Position.Alone,
|
||||||
|
)
|
||||||
if (editing.canSkipNext) {
|
if (editing.canSkipNext) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.editor_skip_next),
|
title = stringResource(R.string.editor_skip_next),
|
||||||
summary = stringResource(R.string.editor_skip_next_summary),
|
summary = stringResource(R.string.editor_skip_next_summary),
|
||||||
position = Position.Alone,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = editing.skipNextOccurrence,
|
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)
|
SoundRow(editing = editing, position = positionOf(0, 3), onClick = viewModel::onOpenRingtonePicker)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.editor_vibrate),
|
title = stringResource(R.string.editor_vibrate),
|
||||||
@@ -152,7 +186,7 @@ private fun AlarmEditorContent(
|
|||||||
onClick = { openPicker = OpenPicker.VOLUME_RAMP },
|
onClick = { openPicker = OpenPicker.VOLUME_RAMP },
|
||||||
)
|
)
|
||||||
|
|
||||||
SectionHeader(stringResource(R.string.editor_section_snooze))
|
GroupedSectionHeader(stringResource(R.string.editor_section_snooze))
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.editor_snooze_length),
|
title = stringResource(R.string.editor_snooze_length),
|
||||||
summary = overrideSummary(
|
summary = overrideSummary(
|
||||||
@@ -181,8 +215,6 @@ private fun AlarmEditorContent(
|
|||||||
onClick = { openPicker = OpenPicker.CHALLENGE },
|
onClick = { openPicker = OpenPicker.CHALLENGE },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
DeleteRow(onClick = { confirmingDelete = true })
|
|
||||||
Spacer(Modifier.height(LocalLivePillInset.current))
|
Spacer(Modifier.height(LocalLivePillInset.current))
|
||||||
|
|
||||||
if (pickingTime) {
|
if (pickingTime) {
|
||||||
@@ -195,16 +227,6 @@ private fun AlarmEditorContent(
|
|||||||
onDismiss = { pickingTime = false },
|
onDismiss = { pickingTime = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (confirmingDelete) {
|
|
||||||
DeleteConfirmation(
|
|
||||||
time = formatTime(editing.time),
|
|
||||||
onConfirm = {
|
|
||||||
confirmingDelete = false
|
|
||||||
viewModel.onDelete()
|
|
||||||
},
|
|
||||||
onDismiss = { confirmingDelete = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
when (openPicker) {
|
when (openPicker) {
|
||||||
null -> Unit
|
null -> Unit
|
||||||
OpenPicker.VIBRATE -> VibratePicker(
|
OpenPicker.VIBRATE -> VibratePicker(
|
||||||
@@ -252,6 +274,7 @@ private fun TimeCard(time: TimeOfDay, onClick: () -> Unit, modifier: Modifier =
|
|||||||
Text(
|
Text(
|
||||||
text = formatTime(time),
|
text = formatTime(time),
|
||||||
style = MaterialTheme.typography.displayMedium,
|
style = MaterialTheme.typography.displayMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
|
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:
|
* 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
|
* 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). */
|
/** The editor's route and the name its argument is declared under (M5 D3). */
|
||||||
internal object AlarmRoutes {
|
internal object AlarmRoutes {
|
||||||
const val ARG_ALARM_ID: String = "alarmId"
|
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 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
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
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.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
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.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||||
import de.jeanlucmakiola.clockula.domain.RepeatDaysSummary
|
import de.jeanlucmakiola.clockula.domain.RepeatDaysSummary
|
||||||
import de.jeanlucmakiola.clockula.domain.RepeatSummary
|
import de.jeanlucmakiola.clockula.domain.RepeatSummary
|
||||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
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 de.jeanlucmakiola.floret.components.Position
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.format.TextStyle
|
import java.time.format.TextStyle
|
||||||
@@ -28,17 +39,12 @@ import java.time.temporal.WeekFields
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One alarm, as the canonical two-line grouped row: the time (with the label
|
* A standalone alarm card. The time and switch share the first line, leaving
|
||||||
* beside it), the repeat summary and the next-fire line, and the switch.
|
* room below for the label and schedule without squeezing either beside the time.
|
||||||
*
|
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AlarmRow(
|
internal fun AlarmRow(
|
||||||
row: AlarmRowState,
|
row: AlarmRowState,
|
||||||
position: Position,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onToggle: (Boolean) -> Unit,
|
onToggle: (Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -46,33 +52,67 @@ internal fun AlarmRow(
|
|||||||
val formatTime = rememberAlarmTimeFormatter()
|
val formatTime = rememberAlarmTimeFormatter()
|
||||||
val time = formatTime(row.time)
|
val time = formatTime(row.time)
|
||||||
val toggleDescription = stringResource(R.string.alarm_row_toggle, time)
|
val toggleDescription = stringResource(R.string.alarm_row_toggle, time)
|
||||||
GroupedRow(
|
val textColor = if (row.enabled) {
|
||||||
title = rowTitle(time, row.label),
|
MaterialTheme.colorScheme.onSurface
|
||||||
summary = rowSummary(row),
|
} else {
|
||||||
position = position,
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
dimmed = !row.enabled,
|
}
|
||||||
modifier = modifier,
|
val supportingColor = if (row.enabled) {
|
||||||
trailing = {
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
Switch(
|
} else {
|
||||||
checked = row.enabled,
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||||
onCheckedChange = onToggle,
|
}
|
||||||
// "Alarm at 07:00" — a bare switch announces neither which row
|
GroupedSurface(
|
||||||
// it belongs to nor what it does.
|
position = Position.Alone,
|
||||||
modifier = Modifier.semantics { contentDescription = toggleDescription },
|
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
color = if (row.enabled) {
|
||||||
}
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
} else {
|
||||||
@Composable
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
private fun rowTitle(time: String, label: String): AnnotatedString {
|
},
|
||||||
val variant = MaterialTheme.colorScheme.onSurfaceVariant
|
) {
|
||||||
return buildAnnotatedString {
|
Column(
|
||||||
append(time)
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 20.dp),
|
||||||
if (label.isNotBlank()) {
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
append(" ")
|
) {
|
||||||
withStyle(SpanStyle(color = variant)) { append(label) }
|
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)
|
append(repeat)
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
append(separator)
|
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) {
|
if (row.skippingNext) {
|
||||||
append(separator)
|
append(separator)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.alarms
|
package de.jeanlucmakiola.clockula.ui.alarms
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.Icons
|
||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.material3.FloatingActionButton
|
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.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
@Composable
|
@Composable
|
||||||
fun AlarmsScreen(
|
fun AlarmsScreen(
|
||||||
onEditAlarm: (Long) -> Unit,
|
onEditAlarm: (Long) -> Unit,
|
||||||
|
onCreateAlarm: (Long) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
dismissCandidates: LongArray = LongArray(0),
|
dismissCandidates: LongArray = LongArray(0),
|
||||||
onDismissCandidatesConsumed: () -> Unit = {},
|
onDismissCandidatesConsumed: () -> Unit = {},
|
||||||
@@ -55,7 +56,7 @@ fun AlarmsScreen(
|
|||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
// The FAB creates the alarm and the editor edits a persisted row, so
|
// 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).
|
// the id to navigate to arrives from the ViewModel (M5 D2, D22).
|
||||||
viewModel.openEditor.collectLatest(onEditAlarm)
|
viewModel.openEditor.collectLatest(onCreateAlarm)
|
||||||
}
|
}
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
@@ -75,6 +76,7 @@ fun AlarmsScreen(
|
|||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
top = AlarmsDefaults.ListBottomPadding,
|
top = AlarmsDefaults.ListBottomPadding,
|
||||||
bottom = AlarmsDefaults.ListBottomPadding + AlarmsDefaults.FabBand + pillInset,
|
bottom = AlarmsDefaults.ListBottomPadding + AlarmsDefaults.FabBand + pillInset,
|
||||||
@@ -83,10 +85,9 @@ fun AlarmsScreen(
|
|||||||
if (rows != null && rows.isEmpty()) {
|
if (rows != null && rows.isEmpty()) {
|
||||||
item { AlarmsEmptyState() }
|
item { AlarmsEmptyState() }
|
||||||
}
|
}
|
||||||
itemsIndexed(rows.orEmpty(), key = { _, row -> row.id }) { index, row ->
|
items(rows.orEmpty(), key = { row -> row.id }) { row ->
|
||||||
AlarmRow(
|
AlarmRow(
|
||||||
row = row,
|
row = row,
|
||||||
position = positionOf(index, rows.orEmpty().size),
|
|
||||||
onClick = { onEditAlarm(row.id) },
|
onClick = { onEditAlarm(row.id) },
|
||||||
onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) },
|
onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) },
|
||||||
// An alarm whose time changed slides to its new place
|
// An alarm whose time changed slides to its new place
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.alarms
|
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.layout.Column
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
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).
|
* answer; the one action button is Cancel (M9 D13).
|
||||||
*
|
*
|
||||||
* The canonical component for "pick one of a short list, or cancel": not a
|
* The canonical component for "pick one of a short list, or cancel": not a
|
||||||
@@ -41,7 +40,10 @@ internal fun DismissAlarmDialog(
|
|||||||
text = {
|
text = {
|
||||||
// A dialog's own body does not scroll, and a search can name more
|
// A dialog's own body does not scroll, and a search can name more
|
||||||
// alarms than fit on a short screen.
|
// 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 ->
|
rows.forEach { row ->
|
||||||
DismissAlarmRow(
|
DismissAlarmRow(
|
||||||
row = row,
|
row = row,
|
||||||
@@ -69,19 +71,10 @@ private fun DismissAlarmRow(
|
|||||||
val repeat = repeatSummaryText(row.repeatDays)
|
val repeat = repeatSummaryText(row.repeatDays)
|
||||||
val spoken = if (row.label.isBlank()) time else "$time, ${row.label}"
|
val spoken = if (row.label.isBlank()) time else "$time, ${row.label}"
|
||||||
val description = stringResource(R.string.interop_dismiss_row_description, spoken)
|
val description = stringResource(R.string.interop_dismiss_row_description, spoken)
|
||||||
ListItem(
|
OptionCard(
|
||||||
headlineContent = { Text(time) },
|
label = time,
|
||||||
overlineContent = { Text(repeat) },
|
supportingText = listOfNotNull(row.label.takeIf { it.isNotBlank() }, repeat).joinToString(" · "),
|
||||||
supportingContent = row.label.takeIf { it.isNotBlank() }?.let { label -> { Text(label) } },
|
onClick = onClick,
|
||||||
// A `ListItem`'s default container is `surface`, which sits *lower* than
|
modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = description },
|
||||||
// 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 },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,13 +38,14 @@ internal fun RepeatDaySelector(
|
|||||||
repeatDays: RepeatDays,
|
repeatDays: RepeatDays,
|
||||||
onDayToggled: (DayOfWeek) -> Unit,
|
onDayToggled: (DayOfWeek) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
position: Position = Position.Alone,
|
||||||
) {
|
) {
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val locale = remember(configuration) { Locale.getDefault() }
|
val locale = remember(configuration) { Locale.getDefault() }
|
||||||
val days = RepeatDaysOrder.startingAt(rememberWeekStart())
|
val days = RepeatDaysOrder.startingAt(rememberWeekStart())
|
||||||
|
|
||||||
GroupedSurface(
|
GroupedSurface(
|
||||||
position = Position.Alone,
|
position = position,
|
||||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||||
) {
|
) {
|
||||||
Row(
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||||
@@ -71,6 +72,7 @@ internal fun RingScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = formatTime(state.time),
|
text = formatTime(state.time),
|
||||||
style = MaterialTheme.typography.displayLarge,
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
|
|||||||
import de.jeanlucmakiola.clockula.ui.stopwatch.StopwatchScreen
|
import de.jeanlucmakiola.clockula.ui.stopwatch.StopwatchScreen
|
||||||
import de.jeanlucmakiola.clockula.ui.timers.TimerEditorScreen
|
import de.jeanlucmakiola.clockula.ui.timers.TimerEditorScreen
|
||||||
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
|
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.timers.TimersScreen
|
||||||
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockScreen
|
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
@@ -66,7 +67,7 @@ fun ClockulaShell(
|
|||||||
val pill by viewModel.livePill.collectAsStateWithLifecycle()
|
val pill by viewModel.livePill.collectAsStateWithLifecycle()
|
||||||
val entry by navController.currentBackStackEntryAsState()
|
val entry by navController.currentBackStackEntryAsState()
|
||||||
val route = entry?.destination?.route
|
val route = entry?.destination?.route
|
||||||
val selected = ShellNavigation.destinationOf(route) ?: ShellNavigation.start
|
val selected = ShellNavigation.selectedDestinationOf(route) ?: ShellNavigation.start
|
||||||
val back = ShellNavigation.onBack(route)
|
val back = ShellNavigation.onBack(route)
|
||||||
|
|
||||||
// Peer destinations have no spatial relationship, so tabs fade through
|
// 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
|
// process death: the intent that asked has been consumed, and re-asking
|
||||||
// would be a dialog with no provenance (M9 D13).
|
// would be a dialog with no provenance (M9 D13).
|
||||||
var dismissCandidates by rememberSaveable { mutableStateOf(LongArray(0)) }
|
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
|
// 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,
|
// 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))
|
navController.navigate(AlarmRoutes.editor(request.alarmId))
|
||||||
}
|
}
|
||||||
ShellRequest.ComposeTimer -> {
|
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(
|
navController.replay(
|
||||||
ShellNavigation.onTabSelected(route, ClockulaDestination.TIMERS),
|
ShellNavigation.onTabSelected(route, ClockulaDestination.TIMERS),
|
||||||
)
|
)
|
||||||
timerSetupRequested = true
|
navController.navigate(TimerRoutes.NEW)
|
||||||
}
|
}
|
||||||
is ShellRequest.ChooseAlarmToDismiss -> {
|
is ShellRequest.ChooseAlarmToDismiss -> {
|
||||||
navController.replay(
|
navController.replay(
|
||||||
@@ -155,6 +156,7 @@ fun ClockulaShell(
|
|||||||
composable(ClockulaDestination.ALARMS.route) {
|
composable(ClockulaDestination.ALARMS.route) {
|
||||||
AlarmsScreen(
|
AlarmsScreen(
|
||||||
onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) },
|
onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) },
|
||||||
|
onCreateAlarm = { navController.navigate(AlarmRoutes.newEditor(it)) },
|
||||||
dismissCandidates = dismissCandidates,
|
dismissCandidates = dismissCandidates,
|
||||||
onDismissCandidatesConsumed = { dismissCandidates = LongArray(0) },
|
onDismissCandidatesConsumed = { dismissCandidates = LongArray(0) },
|
||||||
)
|
)
|
||||||
@@ -166,20 +168,26 @@ fun ClockulaShell(
|
|||||||
route = AlarmRoutes.EDITOR_PATTERN,
|
route = AlarmRoutes.EDITOR_PATTERN,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
navArgument(AlarmRoutes.ARG_ALARM_ID) { type = NavType.LongType },
|
navArgument(AlarmRoutes.ARG_ALARM_ID) { type = NavType.LongType },
|
||||||
|
navArgument(AlarmRoutes.ARG_PICK_TIME) {
|
||||||
|
type = NavType.BoolType
|
||||||
|
defaultValue = false
|
||||||
|
},
|
||||||
),
|
),
|
||||||
) {
|
) { entry ->
|
||||||
AlarmEditorScreen(onBack = { navController.popBackStack() })
|
AlarmEditorScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
openTimePicker = entry.arguments?.getBoolean(AlarmRoutes.ARG_PICK_TIME) == true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(ClockulaDestination.TIMERS.route) {
|
composable(ClockulaDestination.TIMERS.route) {
|
||||||
TimersScreen(
|
TimersScreen(
|
||||||
onEditTimer = { navController.navigate(TimerRoutes.editor(it)) },
|
onEditTimer = { navController.navigate(TimerRoutes.editor(it)) },
|
||||||
requestSetup = timerSetupRequested,
|
onAddTimer = { navController.navigate(TimerRoutes.NEW) },
|
||||||
onSetupRequestConsumed = { timerSetupRequested = false },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A sibling route in the same flat host, exactly as the
|
// Sibling routes in the same flat host, exactly as the
|
||||||
// alarm editor is: it is no tab, so the Timers tab stays
|
// alarm editor is: neither is a tab, so the Timers tab
|
||||||
// selected and the shell stands aside on back (M5 D1).
|
// stays selected and the shell stands aside on back (M5 D1).
|
||||||
composable(
|
composable(
|
||||||
route = TimerRoutes.EDITOR_PATTERN,
|
route = TimerRoutes.EDITOR_PATTERN,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
@@ -188,6 +196,9 @@ fun ClockulaShell(
|
|||||||
) {
|
) {
|
||||||
TimerEditorScreen(onBack = { navController.popBackStack() })
|
TimerEditorScreen(onBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
|
composable(TimerRoutes.NEW) {
|
||||||
|
TimerSetupScreen(onBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
|
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
|
||||||
composable(ClockulaDestination.WORLD_CLOCK.route) { WorldClockScreen() }
|
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.Row
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.rounded.HourglassEmpty
|
import androidx.compose.material.icons.rounded.HourglassEmpty
|
||||||
import androidx.compose.material.icons.rounded.Pause
|
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.material.icons.rounded.Timer
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -97,13 +97,12 @@ private fun PillBody(
|
|||||||
val color = if (state.mode == LivePillMode.EXPIRED) {
|
val color = if (state.mode == LivePillMode.EXPIRED) {
|
||||||
MaterialTheme.colorScheme.tertiaryContainer
|
MaterialTheme.colorScheme.tertiaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
color = color,
|
color = color,
|
||||||
contentColor = contentColorFor(color),
|
contentColor = contentColorFor(color),
|
||||||
shape = RoundedCornerShape(50),
|
shape = CircleShape,
|
||||||
shadowElevation = 6.dp,
|
|
||||||
modifier = Modifier.height(ShellDefaults.LivePillHeight).animateContentSizeMotion(),
|
modifier = Modifier.height(ShellDefaults.LivePillHeight).animateContentSizeMotion(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -136,7 +135,7 @@ private fun PillBody(
|
|||||||
}
|
}
|
||||||
if (state.canPause) {
|
if (state.canPause) {
|
||||||
val resume = state.mode == LivePillMode.PAUSED
|
val resume = state.mode == LivePillMode.PAUSED
|
||||||
IconButton(onClick = { if (live) onPrimaryAction(state) }) {
|
FilledTonalIconButton(onClick = { if (live) onPrimaryAction(state) }, enabled = live) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (resume) Icons.Rounded.PlayArrow else Icons.Rounded.Pause,
|
imageVector = if (resume) Icons.Rounded.PlayArrow else Icons.Rounded.Pause,
|
||||||
contentDescription = stringResource(
|
contentDescription = stringResource(
|
||||||
@@ -145,7 +144,7 @@ private fun PillBody(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { if (live) onStop(state) }) {
|
FilledTonalIconButton(onClick = { if (live) onStop(state) }, enabled = live) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Stop,
|
imageVector = Icons.Rounded.Stop,
|
||||||
contentDescription = stringResource(R.string.live_pill_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. */
|
/** Countdowns round up, elapsed truncates — the pill never reads ahead of reality. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun LivePillState.readout(): String = when (subject) {
|
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)
|
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.interop.InteropIntents
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
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. */
|
/** What a tab tap does to the back stack. Pure data; the composable replays it. */
|
||||||
data class TabNavCommand(
|
data class TabNavCommand(
|
||||||
@@ -45,6 +47,13 @@ object ShellNavigation {
|
|||||||
/** Exact match only: a nested route is not a tab. */
|
/** Exact match only: a nested route is not a tab. */
|
||||||
fun destinationOf(route: String?): ClockulaDestination? = byRoute[route]
|
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
|
* 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
|
* 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.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonGroup
|
import androidx.compose.material3.ButtonGroup
|
||||||
|
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.WavyProgressIndicatorDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
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 androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.domain.StopwatchState
|
import de.jeanlucmakiola.clockula.domain.StopwatchState
|
||||||
import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat
|
import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat
|
||||||
|
import de.jeanlucmakiola.clockula.ui.common.pressMotion
|
||||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaReadoutDefaults
|
import de.jeanlucmakiola.clockula.ui.theme.ClockulaReadoutDefaults
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
@@ -47,6 +57,7 @@ import kotlin.time.Duration
|
|||||||
internal fun StopwatchReadoutPanel(
|
internal fun StopwatchReadoutPanel(
|
||||||
mode: StopwatchState,
|
mode: StopwatchState,
|
||||||
elapsed: Duration,
|
elapsed: Duration,
|
||||||
|
hasLaps: Boolean,
|
||||||
canLap: Boolean,
|
canLap: Boolean,
|
||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
onPause: () -> Unit,
|
onPause: () -> Unit,
|
||||||
@@ -69,26 +80,47 @@ internal fun StopwatchReadoutPanel(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
BoxWithConstraints(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
// So 9:59 becoming 10:00 eases rather than jumps — the pill's
|
val dialSize = minOf(maxWidth, if (hasLaps) 232.dp else 272.dp)
|
||||||
// own treatment.
|
val compactReadout = dialSize < 260.dp || readout.major.length > 5
|
||||||
modifier = Modifier
|
Box(Modifier.size(dialSize), contentAlignment = Alignment.Center) {
|
||||||
.animateContentSizeMotion()
|
MinuteRing(elapsed = elapsed, mode = mode)
|
||||||
.semantics(mergeDescendants = true) { contentDescription = description },
|
Row(
|
||||||
verticalAlignment = Alignment.Bottom,
|
// So 9:59 becoming 10:00 eases rather than jumps — the pill's
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
// own treatment.
|
||||||
) {
|
modifier = Modifier
|
||||||
Text(
|
.animateContentSizeMotion()
|
||||||
text = readout.major,
|
.semantics(mergeDescendants = true) { contentDescription = description },
|
||||||
style = ClockulaReadoutDefaults.Hero,
|
verticalAlignment = Alignment.Bottom,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
)
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = readout.hundredths,
|
text = readout.major,
|
||||||
style = ClockulaReadoutDefaults.HeroFraction,
|
style = if (compactReadout) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
MaterialTheme.typography.displayMedium
|
||||||
modifier = Modifier.padding(bottom = 6.dp),
|
} 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(
|
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
|
* 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
|
* used: in Clockula "Stop" already means "reset" — the timer's expired row and
|
||||||
@@ -137,11 +190,9 @@ private fun StopwatchControls(
|
|||||||
Button(
|
Button(
|
||||||
onClick = primary.second,
|
onClick = primary.second,
|
||||||
interactionSource = primaryInteraction,
|
interactionSource = primaryInteraction,
|
||||||
// `animateWidth` is the expressive press-widening — the reason the
|
modifier = Modifier.pressMotion(primaryInteraction),
|
||||||
// pair is a ButtonGroup rather than two buttons in a Row.
|
|
||||||
modifier = Modifier.animateWidth(primaryInteraction),
|
|
||||||
) {
|
) {
|
||||||
Text(stringResource(primary.first))
|
Text(stringResource(primary.first), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode != StopwatchState.IDLE) {
|
if (mode != StopwatchState.IDLE) {
|
||||||
@@ -152,12 +203,16 @@ private fun StopwatchControls(
|
|||||||
enabled = !lapping || canLap,
|
enabled = !lapping || canLap,
|
||||||
interactionSource = secondaryInteraction,
|
interactionSource = secondaryInteraction,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.animateWidth(secondaryInteraction)
|
.pressMotion(secondaryInteraction)
|
||||||
// A disabled button says nothing about why: the cap is the
|
// A disabled button says nothing about why: the cap is the
|
||||||
// reason, so the button carries it (D14).
|
// reason, so the button carries it (D14).
|
||||||
.semantics { if (lapping && !canLap) contentDescription = lapLimit },
|
.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.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -42,6 +44,14 @@ fun StopwatchScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val pillInset = LocalLivePillInset.current
|
val pillInset = LocalLivePillInset.current
|
||||||
val loaded = state as? StopwatchUiState.Loaded
|
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(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.tab_stopwatch),
|
title = stringResource(R.string.tab_stopwatch),
|
||||||
@@ -57,6 +67,7 @@ fun StopwatchScreen(
|
|||||||
// stopwatch standing at zero — which is what an idle one is.
|
// stopwatch standing at zero — which is what an idle one is.
|
||||||
mode = loaded?.mode ?: StopwatchState.IDLE,
|
mode = loaded?.mode ?: StopwatchState.IDLE,
|
||||||
elapsed = loaded?.elapsed ?: Duration.ZERO,
|
elapsed = loaded?.elapsed ?: Duration.ZERO,
|
||||||
|
hasLaps = laps.isNotEmpty(),
|
||||||
canLap = loaded?.canLap == true,
|
canLap = loaded?.canLap == true,
|
||||||
onStart = viewModel::onStart,
|
onStart = viewModel::onStart,
|
||||||
onPause = viewModel::onPause,
|
onPause = viewModel::onPause,
|
||||||
@@ -65,7 +76,6 @@ fun StopwatchScreen(
|
|||||||
modifier = Modifier.padding(top = 8.dp),
|
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
|
// No lap recorded ⇒ no list at all: the in-progress row's split and
|
||||||
// its total would both equal the hero figure (D11).
|
// its total would both equal the hero figure (D11).
|
||||||
if (laps.isEmpty()) {
|
if (laps.isEmpty()) {
|
||||||
@@ -74,6 +84,7 @@ fun StopwatchScreen(
|
|||||||
LapColumnHeader()
|
LapColumnHeader()
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
state = lapListState,
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
bottom = StopwatchDefaults.ListBottomPadding + pillInset,
|
bottom = StopwatchDefaults.ListBottomPadding + pillInset,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -44,16 +44,18 @@ val ClockulaTypography: Typography = Typography().run {
|
|||||||
*
|
*
|
||||||
* The M3 `*Defaults` shape — composable getters over `MaterialTheme.typography`
|
* The M3 `*Defaults` shape — composable getters over `MaterialTheme.typography`
|
||||||
* rather than constants — so a theme change still flows through them. No new
|
* 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
|
* font and no size outside the M3 scale: refinement is still shape, colour,
|
||||||
* "Expressive ≠ big or bold" applies hardest to the one screen most tempted to
|
* space and motion over the readout as a whole, never a one-off size bump on
|
||||||
* break it.
|
* 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 {
|
object ClockulaReadoutDefaults {
|
||||||
|
|
||||||
/** The stopwatch's big number. */
|
/** The stopwatch's big number. */
|
||||||
val Hero: TextStyle
|
val Hero: TextStyle
|
||||||
@Composable @ReadOnlyComposable
|
@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
|
* 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.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -19,10 +23,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -52,6 +53,7 @@ fun TimerEditorScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
|
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
|
||||||
|
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Registered unconditionally, and deliberately not inside the `picker?.let`
|
// Registered unconditionally, and deliberately not inside the `picker?.let`
|
||||||
// below: the picker's open flag is ViewModel state that does not survive
|
// below: the picker's open flag is ViewModel state that does not survive
|
||||||
@@ -76,11 +78,31 @@ fun TimerEditorScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
predictiveBack = true,
|
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
|
val editing = state as? TimerEditorUiState.Editing ?: return@CollapsingScaffold
|
||||||
TimerEditorContent(editing = editing, viewModel = viewModel)
|
TimerEditorContent(editing = editing, viewModel = viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (confirmingDelete && state is TimerEditorUiState.Editing) {
|
||||||
|
DeleteConfirmation(
|
||||||
|
onConfirm = {
|
||||||
|
confirmingDelete = false
|
||||||
|
viewModel.onDelete()
|
||||||
|
},
|
||||||
|
onDismiss = { confirmingDelete = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
picker?.let { pickerState ->
|
picker?.let { pickerState ->
|
||||||
val editing = state as? TimerEditorUiState.Editing
|
val editing = state as? TimerEditorUiState.Editing
|
||||||
RingtonePickerScreen(
|
RingtonePickerScreen(
|
||||||
@@ -103,13 +125,13 @@ private fun TimerEditorContent(
|
|||||||
) {
|
) {
|
||||||
// Saveable, not remembered: a dialog open across a rotation must still be
|
// Saveable, not remembered: a dialog open across a rotation must still be
|
||||||
// open afterwards (M5's lesson).
|
// open afterwards (M5's lesson).
|
||||||
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var editingLength by rememberSaveable { mutableStateOf(false) }
|
var editingLength by rememberSaveable { mutableStateOf(false) }
|
||||||
var digits by rememberSaveable { mutableStateOf("") }
|
var digits by rememberSaveable { mutableStateOf("") }
|
||||||
val entry = TimerDurationEntry(digits)
|
val entry = TimerDurationEntry(digits)
|
||||||
|
|
||||||
LengthCard(
|
LengthCard(
|
||||||
editing = editing,
|
editing = editing,
|
||||||
|
position = Position.Top,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (editing.canEditDuration) {
|
if (editing.canEditDuration) {
|
||||||
digits = ""
|
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))
|
Spacer(Modifier.height(LocalLivePillInset.current))
|
||||||
|
|
||||||
if (editingLength) {
|
if (editingLength) {
|
||||||
@@ -133,14 +153,11 @@ private fun TimerEditorContent(
|
|||||||
title = { Text(stringResource(R.string.timer_editor_duration)) },
|
title = { Text(stringResource(R.string.timer_editor_duration)) },
|
||||||
text = {
|
text = {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
TimerDurationReadout(entry = entry)
|
||||||
text = entry.display,
|
|
||||||
style = MaterialTheme.typography.displayMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
TimerKeypad(
|
TimerKeypad(
|
||||||
onDigit = { digits = entry.plusDigit(it).digits },
|
onDigit = { digits = entry.plusDigit(it).digits },
|
||||||
|
onDoubleZero = { digits = entry.plusDoubleZero().digits },
|
||||||
onBackspace = { digits = entry.backspace().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. */
|
/** The configured length, at display size — the one thing this page is about. */
|
||||||
@@ -179,10 +187,11 @@ private fun TimerEditorContent(
|
|||||||
private fun LengthCard(
|
private fun LengthCard(
|
||||||
editing: TimerEditorUiState.Editing,
|
editing: TimerEditorUiState.Editing,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
position: Position,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
GroupedSurface(
|
GroupedSurface(
|
||||||
position = Position.Alone,
|
position = position,
|
||||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
) {
|
) {
|
||||||
@@ -190,6 +199,7 @@ private fun LengthCard(
|
|||||||
Text(
|
Text(
|
||||||
text = ClockFormat.countdown(editing.duration),
|
text = ClockFormat.countdown(editing.duration),
|
||||||
style = MaterialTheme.typography.displayMedium,
|
style = MaterialTheme.typography.displayMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (!editing.canEditDuration) {
|
if (!editing.canEditDuration) {
|
||||||
@@ -207,9 +217,14 @@ private fun LengthCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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(
|
GroupedSurface(
|
||||||
position = Position.Alone,
|
position = position,
|
||||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||||
) {
|
) {
|
||||||
InlineTextField(
|
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). */
|
/** M5's picker, so every one of its behaviours arrives here for free (D27). */
|
||||||
@Composable
|
@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 silent = stringResource(R.string.ringtone_silent)
|
||||||
val unknown = stringResource(R.string.ringtone_unknown)
|
val unknown = stringResource(R.string.ringtone_unknown)
|
||||||
val deviceDefault = stringResource(R.string.ringtone_device_default)
|
val deviceDefault = stringResource(R.string.ringtone_device_default)
|
||||||
@@ -242,22 +257,11 @@ private fun SoundRow(editing: TimerEditorUiState.Editing, onClick: () -> Unit) {
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.timer_editor_sound),
|
title = stringResource(R.string.timer_editor_sound),
|
||||||
summary = summary.joinToString("\n"),
|
summary = summary.joinToString("\n"),
|
||||||
position = Position.Alone,
|
position = position,
|
||||||
onClick = onClick,
|
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
|
* A confirmation rather than an undo chip: M5's answer, and the cheap moment
|
||||||
* to ask is before.
|
* 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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.Backspace
|
import androidx.compose.material.icons.automirrored.rounded.Backspace
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
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 androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ten digits and a backspace, on a grid whose keys are comfortably past the
|
* Ten digits, a "00" key and a backspace, on a grid whose keys are
|
||||||
* 48 dp minimum: a keypad is aimed at without looking.
|
* comfortably past the 48 dp minimum: a keypad is aimed at without looking.
|
||||||
*
|
*
|
||||||
* The keys are the canonical M3 `TextButton`/`FilledTonalIconButton` rather
|
* Digit keys are tonal-filled circles rather than bare `TextButton`s —
|
||||||
* than anything hand-rolled, so they inherit the ripple, the shape morph and
|
* Google Clock's calculator keys — so the grid reads as *buttons* at a
|
||||||
* the disabled colours from the theme.
|
* glance; the shape carries that, not a larger label (`PLAN.md` §8).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimerKeypad(
|
internal fun TimerKeypad(
|
||||||
onDigit: (Char) -> Unit,
|
onDigit: (Char) -> Unit,
|
||||||
|
onDoubleZero: () -> Unit,
|
||||||
onBackspace: () -> Unit,
|
onBackspace: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
keySize: Dp = TimersDefaults.KeypadKeySize,
|
||||||
|
keyGap: Dp = TimersDefaults.KeypadKeyGap,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.spacedBy(KeyGap),
|
verticalArrangement = Arrangement.spacedBy(keyGap),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
ROWS.forEach { row ->
|
ROWS.forEach { row ->
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(KeyGap),
|
horizontalArrangement = Arrangement.spacedBy(keyGap),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
row.forEach { key -> DigitKey(digit = key, onDigit = onDigit) }
|
row.forEach { key -> DigitKey(digit = key, onDigit = onDigit, size = keySize) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(KeyGap),
|
horizontalArrangement = Arrangement.spacedBy(keyGap),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
DigitKey(digit = '0', onDigit = onDigit)
|
DoubleZeroKey(onDoubleZero = onDoubleZero, size = keySize)
|
||||||
BackspaceKey(onBackspace = onBackspace)
|
DigitKey(digit = '0', onDigit = onDigit, size = keySize)
|
||||||
|
BackspaceKey(onBackspace = onBackspace, size = keySize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DigitKey(digit: Char, onDigit: (Char) -> Unit) {
|
private fun DigitKey(digit: Char, onDigit: (Char) -> Unit, size: Dp) {
|
||||||
val text = digit.toString()
|
val text = digit.toString()
|
||||||
// The glyph is the label, so the announcement says what pressing it does
|
// The glyph is the label, so the announcement says what pressing it does
|
||||||
// rather than reading a bare numeral out of context.
|
// rather than reading a bare numeral out of context.
|
||||||
val description = stringResource(R.string.timer_keypad_digit, text)
|
val description = stringResource(R.string.timer_keypad_digit, text)
|
||||||
TextButton(
|
FilledTonalButton(
|
||||||
onClick = { onDigit(digit) },
|
onClick = { onDigit(digit) },
|
||||||
|
shape = CircleShape,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(TimersDefaults.KeypadKeySize)
|
.size(size)
|
||||||
.semantics { contentDescription = description },
|
.semantics { contentDescription = description },
|
||||||
) {
|
) {
|
||||||
Text(text = text, style = MaterialTheme.typography.headlineSmall)
|
Text(text = text, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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)
|
val description = stringResource(R.string.timer_keypad_backspace)
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onBackspace,
|
onClick = onBackspace,
|
||||||
modifier = Modifier.size(TimersDefaults.KeypadKeySize),
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(size),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.AutoMirrored.Rounded.Backspace, contentDescription = description)
|
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(
|
private val ROWS = listOf(
|
||||||
listOf('1', '2', '3'),
|
listOf('1', '2', '3'),
|
||||||
listOf('4', '5', '6'),
|
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
|
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 {
|
internal object TimerRoutes {
|
||||||
const val ARG_TIMER_ID: String = "timerId"
|
const val ARG_TIMER_ID: String = "timerId"
|
||||||
const val EDITOR_PATTERN: String = "timers/edit/{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"
|
fun editor(timerId: Long): String = "timers/edit/$timerId"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.timers
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.ButtonGroup
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.WavyProgressIndicatorDefaults
|
import androidx.compose.material3.WavyProgressIndicatorDefaults
|
||||||
import androidx.compose.material3.contentColorFor
|
import androidx.compose.material3.contentColorFor
|
||||||
import androidx.compose.runtime.Composable
|
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.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.contentDescription
|
||||||
import androidx.compose.ui.semantics.onClick
|
import androidx.compose.ui.semantics.onClick
|
||||||
import androidx.compose.ui.semantics.semantics
|
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 androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
|
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
|
||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
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.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
|
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
|
* One timer as a standalone card, exactly as [de.jeanlucmakiola.clockula.ui.alarms.AlarmRow]
|
||||||
* family uses: the readout, the label, the wavy progress and the two controls
|
* is: `Position.Alone`, never grouped into a connected block with its
|
||||||
* for whatever the timer is currently doing.
|
* 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 progress indicator's **amplitude is the state** — the default wave while
|
||||||
* the timer runs, flat while it is paused or finished. That is
|
* 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,
|
* `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
|
* never from enlarging text", and timer progress is one of the three places §8
|
||||||
* sanctions a showpiece (D29).
|
* 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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimerRow(
|
internal fun TimerRow(
|
||||||
row: TimerRowState,
|
row: TimerRowState,
|
||||||
position: Position,
|
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
onPause: () -> Unit,
|
onPause: () -> Unit,
|
||||||
onReset: () -> Unit,
|
onReset: () -> Unit,
|
||||||
onAddMinute: () -> Unit,
|
onAddMinute: () -> Unit,
|
||||||
|
onSubtractMinute: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val untitled = stringResource(R.string.timer_untitled)
|
|
||||||
val openDescription = stringResource(R.string.timer_row_open)
|
val openDescription = stringResource(R.string.timer_row_open)
|
||||||
val readout = readoutOf(row)
|
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
|
// "%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
|
// reads "Finished", and "Finished left" is not a readout. The card's own
|
||||||
// text is otherwise announced as it stands.
|
// text is otherwise announced as it stands.
|
||||||
@@ -65,8 +112,9 @@ internal fun TimerRow(
|
|||||||
TimerMode.EXPIRED -> readout
|
TimerMode.EXPIRED -> readout
|
||||||
else -> stringResource(R.string.timer_row_progress, readout)
|
else -> stringResource(R.string.timer_row_progress, readout)
|
||||||
}
|
}
|
||||||
|
val deleteDescription = stringResource(R.string.timer_editor_delete)
|
||||||
GroupedSurface(
|
GroupedSurface(
|
||||||
position = position,
|
position = Position.Alone,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(horizontal = GroupedListInset)
|
.padding(horizontal = GroupedListInset)
|
||||||
// The *purpose of the tap*, not a description of the card: a
|
// The *purpose of the tap*, not a description of the card: a
|
||||||
@@ -78,62 +126,114 @@ internal fun TimerRow(
|
|||||||
onClick = onOpen,
|
onClick = onOpen,
|
||||||
color = containerFor(row.mode),
|
color = containerFor(row.mode),
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier
|
Column(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
.fillMaxWidth()
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
.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,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
if (row.label.isNotBlank()) {
|
||||||
text = readout,
|
Text(
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
text = row.label,
|
||||||
color = contentColorFor(containerFor(row.mode)),
|
style = MaterialTheme.typography.labelLarge,
|
||||||
modifier = Modifier.semantics { contentDescription = readoutDescription },
|
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 },
|
TimerControls(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
mode = row.mode,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
canSubtract = row.remaining > 1.minutes,
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
onStart = onStart,
|
||||||
|
onPause = onPause,
|
||||||
|
onReset = onReset,
|
||||||
|
onAddMinute = { adjust(1, onAddMinute) },
|
||||||
|
onSubtractMinute = { adjust(-1, onSubtractMinute) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LinearWavyProgressIndicator(
|
FilledTonalIconButton(
|
||||||
// A lambda, so the bar animates without recomposing the row.
|
onClick = onDelete,
|
||||||
progress = { row.progress },
|
modifier = Modifier
|
||||||
amplitude = if (row.mode == TimerMode.RUNNING) {
|
.align(Alignment.TopEnd)
|
||||||
WavyProgressIndicatorDefaults.indicatorAmplitude
|
// Flush against the card's own corner, the button's square
|
||||||
} else {
|
// bounds would sit partly under the rounded clip; this
|
||||||
FLAT
|
// inset keeps it visually inside the card.
|
||||||
},
|
.padding(top = 12.dp, end = 12.dp)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.size(DeleteButtonSize),
|
||||||
)
|
) {
|
||||||
|
Icon(
|
||||||
TimerControls(
|
imageVector = Icons.Rounded.Close,
|
||||||
mode = row.mode,
|
contentDescription = deleteDescription,
|
||||||
onStart = onStart,
|
modifier = Modifier.size(DeleteIconSize),
|
||||||
onPause = onPause,
|
)
|
||||||
onReset = onReset,
|
}
|
||||||
onAddMinute = onAddMinute,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimerControls(
|
private fun TimerControls(
|
||||||
mode: TimerMode,
|
mode: TimerMode,
|
||||||
|
canSubtract: Boolean,
|
||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
onPause: () -> Unit,
|
onPause: () -> Unit,
|
||||||
onReset: () -> Unit,
|
onReset: () -> Unit,
|
||||||
onAddMinute: () -> Unit,
|
onAddMinute: () -> Unit,
|
||||||
|
onSubtractMinute: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val primary: Pair<Int, () -> Unit> = when (mode) {
|
val primary: Pair<Int, () -> Unit> = when (mode) {
|
||||||
@@ -149,17 +249,22 @@ private fun TimerControls(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ButtonGroup(modifier = modifier) {
|
ButtonGroup(modifier = modifier) {
|
||||||
listOf(primary, secondary).forEach { (label, action) ->
|
val actions = buildList {
|
||||||
// `animateWidth` is the expressive press-widening — the component's
|
add(primary)
|
||||||
// whole point, and the reason the pair is a ButtonGroup rather than
|
if (mode == TimerMode.RUNNING || mode == TimerMode.PAUSED) {
|
||||||
// two buttons in a Row.
|
add(R.string.timer_subtract_minute to onSubtractMinute)
|
||||||
|
}
|
||||||
|
add(secondary)
|
||||||
|
}
|
||||||
|
actions.forEach { (label, action) ->
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = action,
|
onClick = action,
|
||||||
|
enabled = label != R.string.timer_subtract_minute || canSubtract,
|
||||||
interactionSource = interaction,
|
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. */
|
/** What the row reads: the configured duration when IDLE, the snapshot otherwise. */
|
||||||
val remaining: Duration,
|
val remaining: Duration,
|
||||||
val duration: 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,
|
val progress: Float,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,13 +45,14 @@ internal object TimerListRows {
|
|||||||
mode = reading.mode,
|
mode = reading.mode,
|
||||||
remaining = reading.remaining,
|
remaining = reading.remaining,
|
||||||
duration = timer.duration,
|
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. */
|
/** A zero-length timer divides by nothing, and extra remaining time starts empty. */
|
||||||
private fun progressOf(remaining: Duration, duration: Duration): Float {
|
private fun progressOf(remaining: Duration, duration: Duration, mode: TimerMode): Float {
|
||||||
|
if (mode == TimerMode.EXPIRED) return 1f
|
||||||
if (duration <= Duration.ZERO) return 0f
|
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. */
|
/** Comfortably past the 48 dp minimum: a keypad is aimed at without looking. */
|
||||||
val KeypadKeySize: Dp = 64.dp
|
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
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.Icons
|
||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
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.domain.timer.TimerDurationEntry
|
||||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import kotlin.time.Duration
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Timers tab: several timers at once, each with its own controls, and the
|
* The Timers tab: several timers at once, each with its own controls, and a
|
||||||
* setup panel that starts a new one.
|
* 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
|
* On an **empty** list the FAB is noise — there is nothing to add a timer
|
||||||
* an **empty** one the tab hosts the same panel inline, because that *is* the
|
* *to* yet — so the tab hosts the same readout/keypad/Start the full screen
|
||||||
* empty state (D23). Deliberately not M5's create-then-navigate: an alarm is
|
* does, inline, in place of a "tap + to start" card: the keypad *is* the
|
||||||
* live the moment it exists, so creating it first is honest, while a timer has
|
* empty state, the same call D23 originally made, carried over to the
|
||||||
* a natural commit point — "Start" — and a row created by an accidental FAB tap
|
* redesigned input.
|
||||||
* would be litter.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimersScreen(
|
fun TimersScreen(
|
||||||
onEditTimer: (Long) -> Unit,
|
onEditTimer: (Long) -> Unit,
|
||||||
|
onAddTimer: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
requestSetup: Boolean = false,
|
|
||||||
onSetupRequestConsumed: () -> Unit = {},
|
|
||||||
viewModel: TimersViewModel = hiltViewModel(),
|
viewModel: TimersViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val pillInset = LocalLivePillInset.current
|
val pillInset = LocalLivePillInset.current
|
||||||
val addTimer = stringResource(R.string.timers_set_title)
|
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 loaded = state as? TimersUiState.Loaded
|
||||||
val rows = loaded?.rows
|
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(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.tab_timers),
|
title = stringResource(R.string.tab_timers),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
// Only over a list: on an empty tab the panel is already on screen,
|
// Only over a list: on an empty tab the keypad is already on
|
||||||
// and a FAB that opens what is in front of you is noise.
|
// screen, and a FAB that opens what is in front of you is noise.
|
||||||
if (rows != null && rows.isNotEmpty()) {
|
if (rows != null && rows.isNotEmpty()) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { sheetOpen = true },
|
onClick = onAddTimer,
|
||||||
// The pill is bottom-centre and the FAB bottom-end, so on a
|
// The pill is bottom-centre and the FAB bottom-end, so on a
|
||||||
// narrow phone they would otherwise overlap (M5 D6).
|
// narrow phone they would otherwise overlap (M5 D6).
|
||||||
modifier = Modifier.padding(bottom = pillInset),
|
modifier = Modifier.padding(bottom = pillInset),
|
||||||
@@ -107,35 +77,31 @@ fun TimersScreen(
|
|||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
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(
|
contentPadding = PaddingValues(
|
||||||
top = TimersDefaults.ListBottomPadding,
|
top = TimersDefaults.ListBottomPadding,
|
||||||
bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset,
|
bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
if (rows != null && rows.isEmpty()) {
|
if (rows != null && rows.isEmpty()) {
|
||||||
item {
|
item { InlineTimerSetup(onStartNew = viewModel::onStartNew) }
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemsIndexed(rows.orEmpty(), key = { _, row -> row.id }) { index, row ->
|
items(rows.orEmpty(), key = { row -> row.id }) { row ->
|
||||||
TimerRow(
|
TimerRow(
|
||||||
row = row,
|
row = row,
|
||||||
position = positionOf(index, rows.orEmpty().size),
|
|
||||||
onOpen = { onEditTimer(row.id) },
|
onOpen = { onEditTimer(row.id) },
|
||||||
onStart = { viewModel.onStart(row.id) },
|
onStart = { viewModel.onStart(row.id) },
|
||||||
onPause = { viewModel.onPause(row.id) },
|
onPause = { viewModel.onPause(row.id) },
|
||||||
onReset = { viewModel.onReset(row.id) },
|
onReset = { viewModel.onReset(row.id) },
|
||||||
onAddMinute = { viewModel.onAddMinute(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
|
// A timer that finishes stays where it is (D22); the motion
|
||||||
// is for a row arriving or leaving.
|
// is for a row arriving or leaving.
|
||||||
modifier = animateItemMotion(),
|
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
|
* The empty state: the same readout, keypad and Start [TimerSetupScreen]
|
||||||
* chips share one state and a chip can still be corrected by hand.
|
* 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 {
|
@Composable
|
||||||
val total = duration.inWholeSeconds
|
private fun InlineTimerSetup(onStartNew: (kotlin.time.Duration) -> Unit, modifier: Modifier = Modifier) {
|
||||||
val hours = total / 3_600
|
// Saveable, not remembered: the typed digits survive rotation *and*
|
||||||
val minutes = (total % 3_600) / 60
|
// process death (D23).
|
||||||
val seconds = total % 60
|
var digits by rememberSaveable { mutableStateOf("") }
|
||||||
return buildString {
|
val entry = TimerDurationEntry(digits)
|
||||||
append(hours.toString().padStart(2, '0'))
|
|
||||||
append(minutes.toString().padStart(2, '0'))
|
Column(
|
||||||
append(seconds.toString().padStart(2, '0'))
|
modifier = modifier
|
||||||
}.trimStart('0')
|
.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.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +76,8 @@ class TimersViewModel @Inject internal constructor(
|
|||||||
|
|
||||||
fun onAddMinute(timerId: Long) = launchOnEngine { engine.addTime(timerId) }
|
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). */
|
/** Through the engine, so a *ringing* timer cannot be deleted into a stuck ring (D2). */
|
||||||
fun onDelete(timerId: Long) = launchOnEngine { engine.delete(timerId) }
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
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.contentDescription
|
||||||
import androidx.compose.ui.semantics.customActions
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
@@ -31,10 +33,10 @@ import de.jeanlucmakiola.floret.components.Position
|
|||||||
import java.time.LocalTime
|
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:
|
* `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
|
* `ListItem` imposes its own minimum and would fight the
|
||||||
* reorderable column's 64dp pitch, and the row already needs a drag handle in
|
* reorderable column's fixed pitch, and the row already needs a drag handle in
|
||||||
* the leading slot (D14).
|
* the leading slot (D14).
|
||||||
*
|
*
|
||||||
* A drag-only reorder is inaccessible — TalkBack cannot press-and-drag and
|
* A drag-only reorder is inaccessible — TalkBack cannot press-and-drag and
|
||||||
@@ -93,6 +95,7 @@ internal fun WorldClockRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
.padding(start = 12.dp, end = 20.dp)
|
.padding(start = 12.dp, end = 20.dp)
|
||||||
.semantics(mergeDescendants = true) {
|
.semantics(mergeDescendants = true) {
|
||||||
// Spoken as one sentence rather than as three merged child
|
// Spoken as one sentence rather than as three merged child
|
||||||
@@ -131,7 +134,8 @@ internal fun WorldClockRow(
|
|||||||
text = time,
|
text = time,
|
||||||
// Tabular, because M7 made the headline role tabular
|
// Tabular, because M7 made the headline role tabular
|
||||||
// app-wide: a column of times must not jitter as it ticks.
|
// 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) {
|
color = if (isDragging) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ fun WorldClockScreen(
|
|||||||
keyOf = { it.id },
|
keyOf = { it.id },
|
||||||
onReorder = { rows -> viewModel.onReorder(rows.map { it.id }) },
|
onReorder = { rows -> viewModel.onReorder(rows.map { it.id }) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
rowHeight = 80.dp,
|
||||||
) { row, position, dragHandle, isDragging ->
|
) { row, position, dragHandle, isDragging ->
|
||||||
WorldClockRow(
|
WorldClockRow(
|
||||||
row = row,
|
row = row,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
<string name="alarm_snoozed_until">Snoozed until %1$s</string>
|
<string name="alarm_snoozed_until">Snoozed until %1$s</string>
|
||||||
|
|
||||||
<!-- Crash reporting (floret-kit core-crash) -->
|
<!-- 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>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</string>
|
||||||
|
|
||||||
<!-- M4: the app shell's four tabs -->
|
<!-- M4: the app shell's four tabs -->
|
||||||
@@ -35,12 +34,20 @@
|
|||||||
<string name="timers_preset_remove">Remove preset</string>
|
<string name="timers_preset_remove">Remove preset</string>
|
||||||
<string name="timer_keypad_backspace">Delete a digit</string>
|
<string name="timer_keypad_backspace">Delete a digit</string>
|
||||||
<string name="timer_keypad_digit">Type %1$s</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_start">Start</string>
|
||||||
<string name="timer_pause">Pause</string>
|
<string name="timer_pause">Pause</string>
|
||||||
<string name="timer_resume">Resume</string>
|
<string name="timer_resume">Resume</string>
|
||||||
<string name="timer_reset">Reset</string>
|
<string name="timer_reset">Reset</string>
|
||||||
<string name="timer_stop">Stop</string>
|
<string name="timer_stop">Stop</string>
|
||||||
<string name="timer_add_minute">+1 min</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_finished">Finished</string>
|
||||||
<string name="timer_paused_at">Paused \u00b7 %1$s</string>
|
<string name="timer_paused_at">Paused \u00b7 %1$s</string>
|
||||||
<string name="timer_untitled">Timer</string>
|
<string name="timer_untitled">Timer</string>
|
||||||
@@ -48,7 +55,7 @@
|
|||||||
<string name="timer_row_open">Timer settings</string>
|
<string name="timer_row_open">Timer settings</string>
|
||||||
|
|
||||||
<!-- M6: the timer editor -->
|
<!-- 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_label">Label</string>
|
||||||
<string name="timer_editor_duration">Length</string>
|
<string name="timer_editor_duration">Length</string>
|
||||||
<string name="timer_editor_duration_locked">Reset the timer to change its 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)
|
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
|
@Test
|
||||||
fun `adding time to a running timer whose end anchor has passed grants the whole extra`() = runTest {
|
fun `adding time to a running timer whose end anchor has passed grants the whole extra`() = runTest {
|
||||||
val id = startedTimer()
|
val id = startedTimer()
|
||||||
|
|||||||
@@ -137,4 +137,23 @@ class TimerDurationEntryTest {
|
|||||||
Locale.setDefault(previous)
|
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
|
listOf(TimerNotificationAction.PAUSE, TimerNotificationAction.ADD_MINUTE) to
|
||||||
subject.timer.endsAtWallClock,
|
subject.timer.endsAtWallClock,
|
||||||
)
|
)
|
||||||
|
assertThat(state.duration).isEqualTo(subject.timer.duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** §5.5 #3 */
|
/** §5.5 #3 */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class AlarmRoutesTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `the editor route carries the alarm's id`() {
|
fun `the editor route carries the alarm's id`() {
|
||||||
assertThat(AlarmRoutes.editor(7L)).isEqualTo("alarms/edit/7")
|
assertThat(AlarmRoutes.editor(7L)).isEqualTo("alarms/edit/7")
|
||||||
|
assertThat(AlarmRoutes.newEditor(7L)).isEqualTo("alarms/edit/7?pickTime=true")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.ui.shell
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
||||||
|
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
|
||||||
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
|
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@@ -150,6 +151,14 @@ class ShellNavigationTest {
|
|||||||
assertThat(ShellNavigation.destinationOf(TimerRoutes.editor(7L))).isNull()
|
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 */
|
/** §5.15 #2 */
|
||||||
@Test
|
@Test
|
||||||
fun `the shell stands aside on back, leaving the timer editor to pop itself`() {
|
fun `the shell stands aside on back, leaving the timer editor to pop itself`() {
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ class TimerListRowsTest {
|
|||||||
|
|
||||||
/** §5.11 #4 */
|
/** §5.11 #4 */
|
||||||
@Test
|
@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()
|
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 */
|
/** §5.11 #5 */
|
||||||
@@ -89,15 +89,15 @@ class TimerListRowsTest {
|
|||||||
|
|
||||||
val row = rows(listOf(timer)).single()
|
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 */
|
/** §5.11 #7 */
|
||||||
@Test
|
@Test
|
||||||
fun `an expired timer reads empty`() {
|
fun `an expired timer reads full progress`() {
|
||||||
val row = rows(listOf(expiredTimer(duration = 5.minutes))).single()
|
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 */
|
/** §5.11 #8 */
|
||||||
@@ -110,10 +110,10 @@ class TimerListRowsTest {
|
|||||||
|
|
||||||
/** §5.11 #9 */
|
/** §5.11 #9 */
|
||||||
@Test
|
@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)
|
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 */
|
/** §5.11 #10 */
|
||||||
|
|||||||
+1
-1
Submodule floret-kit updated: 1934306903...18a797747b
Reference in New Issue
Block a user