summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Caitlin Shkuratov <caitlinshk@google.com> 2025-03-05 18:53:57 +0000
committer Caitlin Shkuratov <caitlinshk@google.com> 2025-03-07 08:30:49 -0800
commitd88a2b3301a33217459480d6f12a6fc3504defa4 (patch)
tree14f9d4d2247bc5553f630c01e172adb292884e59
parent91a4647e4f1048c00e93e9b3a0c85de5d81ec107 (diff)
[SB][Chips] Allow timer chips to count down to a time in the future.
The current timer chip implementation only counts up from a time in the past, and it shows 00:00 if the time is in the future. But apps could also set their notification time to be in the future and set `isCountDown=true`, meaning that they want the notification UI to count down to that future time. This CL implements that behavior for both non-Compose chips and Compose chips. I don't expect a lot of apps to use this feature, but we should have it in case. The Compose implementation is slightly better (if the duration would be negative, we hide it), but the non-Compose implementation works just like normal notifications so I think it's okay since we plan to launch the Compose chips at the same time as the notification chips. Fixes: 400786236 Fixes: 401464026 Bug: 364653005 Flag: com.android.systemui.status_bar_notification_chips Test: Post notif with `when` 15 seconds in the future, showsChronomter=true, isCountDown=true -> - ChipMod on: chip counts down from 00:15, 00:14, ... 00:00, then hides the text. - ChipMod off: chip counts down from 00:15, 00:14, ... 00:00 -> -00:01, -00:02, ... Test: Post notif with `when` 15 seconds in the future, showsChronometer=true, isCountDown=false -> - ChipMod on: chip shows nothing for 15 seconds, then starts showing 00:00, 00:01, etc. - ChipMod off: chip show -00:15, -00:14, ... 00:00, 00:01 ... Test: Post notif with `when` 15 seconds in the past, showChronometer=true, isCountDown=true -> - ChipMod on: chip never shows any text - ChipMod off: chip shows -00:15, -00:16, ... Test: Post notif with `when` 15 seconds in the past, showChronometer=true, isCountDown=false -> - ChipMod on: chip starts at 00:15 and counts up with 00:16, 00:17... - ChipMod off: chip starts at 00:15 and counts up with 00:16, 00:17... Test: atest ChronometerStateTest NotifChipsViewModelTest Test: Test the above with ChipsModernization both off and on Change-Id: I20d1cf44e5ae2937ac64ceacb7ea045b9fe9989d
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt4
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt193
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt4
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt5
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt6
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt43
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt6
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt55
8 files changed, 263 insertions, 53 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt
index 4993b5661373..b5cfc7e9080d 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt
@@ -573,6 +573,8 @@ class NotifChipsViewModelTest : SysuiTestCase() {
assertThat(latest!![0]).isInstanceOf(OngoingActivityChipModel.Active.Timer::class.java)
assertThat((latest!![0] as OngoingActivityChipModel.Active.Timer).startTimeMs)
.isEqualTo(whenElapsed)
+ assertThat((latest!![0] as OngoingActivityChipModel.Active.Timer).isEventInFuture)
+ .isFalse()
}
@Test
@@ -608,6 +610,8 @@ class NotifChipsViewModelTest : SysuiTestCase() {
assertThat(latest!![0]).isInstanceOf(OngoingActivityChipModel.Active.Timer::class.java)
assertThat((latest!![0] as OngoingActivityChipModel.Active.Timer).startTimeMs)
.isEqualTo(whenElapsed)
+ assertThat((latest!![0] as OngoingActivityChipModel.Active.Timer).isEventInFuture)
+ .isTrue()
}
@Test
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt
index 4e92540396d3..cd9970cfa614 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt
@@ -35,55 +35,153 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ChronometerStateTest : SysuiTestCase() {
- private lateinit var mockTimeSource: MutableTimeSource
+ private lateinit var fakeTimeSource: MutableTimeSource
@Before
fun setup() {
- mockTimeSource = MutableTimeSource()
+ fakeTimeSource = MutableTimeSource()
}
@Test
- fun initialText_isCorrect() = runTest {
- val state = ChronometerState(mockTimeSource, 0L)
- assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(0))
+ fun initialText_isEventInFutureFalse_timeIsNow() = runTest {
+ fakeTimeSource.time = 3_000
+ val state =
+ ChronometerState(fakeTimeSource, eventTimeMillis = 3_000, isEventInFuture = false)
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 0))
}
@Test
- fun textUpdates_withTime() = runTest {
- val startTime = 1000L
- val state = ChronometerState(mockTimeSource, startTime)
+ fun initialText_isEventInFutureFalse_timeInPast() = runTest {
+ fakeTimeSource.time = 3_000
+ val state =
+ ChronometerState(fakeTimeSource, eventTimeMillis = 1_000, isEventInFuture = false)
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 2))
+ }
+
+ @Test
+ fun initialText_isEventInFutureFalse_timeInFuture() = runTest {
+ fakeTimeSource.time = 3_000
+ val state =
+ ChronometerState(fakeTimeSource, eventTimeMillis = 5_000, isEventInFuture = false)
+ // When isEventInFuture=false, eventTimeMillis needs to be in the past if we want text to
+ // show
+ assertThat(state.currentTimeText).isNull()
+ }
+
+ @Test
+ fun initialText_isEventInFutureTrue_timeIsNow() = runTest {
+ fakeTimeSource.time = 3_000
+ val state =
+ ChronometerState(fakeTimeSource, eventTimeMillis = 3_000, isEventInFuture = true)
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 0))
+ }
+
+ @Test
+ fun initialText_isEventInFutureTrue_timeInFuture() = runTest {
+ fakeTimeSource.time = 3_000
+ val state =
+ ChronometerState(fakeTimeSource, eventTimeMillis = 5_000, isEventInFuture = true)
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 2))
+ }
+
+ @Test
+ fun initialText_isEventInFutureTrue_timeInPast() = runTest {
+ fakeTimeSource.time = 3_000
+ val state =
+ ChronometerState(fakeTimeSource, eventTimeMillis = 1_000, isEventInFuture = true)
+ // When isEventInFuture=true, eventTimeMillis needs to be in the future if we want text to
+ // show
+ assertThat(state.currentTimeText).isNull()
+ }
+
+ @Test
+ fun textUpdates_isEventInFutureFalse_timeInPast() = runTest {
+ val eventTime = 1000L
+ val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = false)
val job = launch { state.run() }
val elapsedTime = 5000L
- mockTimeSource.time = startTime + elapsedTime
+ fakeTimeSource.time = eventTime + elapsedTime
advanceTimeBy(elapsedTime)
assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(elapsedTime / 1000))
+ val additionalTime = 6000L
+ fakeTimeSource.time += additionalTime
+ advanceTimeBy(additionalTime)
+ assertThat(state.currentTimeText)
+ .isEqualTo(formatElapsedTime((elapsedTime + additionalTime) / 1000))
+
job.cancelAndJoin()
}
@Test
- fun textUpdates_toLargerValue() = runTest {
- val startTime = 1000L
- val state = ChronometerState(mockTimeSource, startTime)
+ fun textUpdates_isEventInFutureFalse_timeChangesFromFutureToPast() = runTest {
+ val eventTime = 15_000L
+ val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = false)
val job = launch { state.run() }
- val elapsedTime = 15000L
- mockTimeSource.time = startTime + elapsedTime
- advanceTimeBy(elapsedTime)
- assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(elapsedTime / 1000))
+ // WHEN the time is 5 but the eventTime is 15
+ fakeTimeSource.time = 5_000L
+ advanceTimeBy(5_000L)
+ // THEN no text is shown
+ assertThat(state.currentTimeText).isNull()
+
+ // WHEN the time advances to 40
+ fakeTimeSource.time = 40_000L
+ advanceTimeBy(35_000)
+ // THEN text is shown as 25 seconds (40 - 15)
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 25))
job.cancelAndJoin()
}
@Test
- fun textUpdates_afterResettingBase() = runTest {
+ fun textUpdates_isEventInFutureTrue_timeInFuture() = runTest {
+ val eventTime = 15_000L
+ val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = true)
+ val job = launch { state.run() }
+
+ fakeTimeSource.time = 5_000L
+ advanceTimeBy(5_000L)
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 10))
+
+ val additionalTime = 6000L
+ fakeTimeSource.time += additionalTime
+ advanceTimeBy(additionalTime)
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 4))
+
+ job.cancelAndJoin()
+ }
+
+ @Test
+ fun textUpdates_isEventInFutureTrue_timeChangesFromFutureToPast() = runTest {
+ val eventTime = 15_000L
+ val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = true)
+ val job = launch { state.run() }
+
+ // WHEN the time is 5 and the eventTime is 15
+ fakeTimeSource.time = 5_000L
+ advanceTimeBy(5_000L)
+ // THEN 10 seconds is shown
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 10))
+
+ // WHEN the time advances to 40 (past the event time)
+ fakeTimeSource.time = 40_000L
+ advanceTimeBy(35_000)
+ // THEN no text is shown
+ assertThat(state.currentTimeText).isNull()
+
+ job.cancelAndJoin()
+ }
+
+ @Test
+ fun textUpdates_afterResettingBase_isEventInFutureFalse() = runTest {
val initialElapsedTime = 30000L
val startTime = 50000L
- val state = ChronometerState(mockTimeSource, startTime)
+ val state = ChronometerState(fakeTimeSource, startTime, isEventInFuture = false)
val job = launch { state.run() }
- mockTimeSource.time = startTime + initialElapsedTime
+ fakeTimeSource.time = startTime + initialElapsedTime
advanceTimeBy(initialElapsedTime)
assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(initialElapsedTime / 1000))
@@ -91,15 +189,68 @@ class ChronometerStateTest : SysuiTestCase() {
val newElapsedTime = 5000L
val newStartTime = 100000L
- val newState = ChronometerState(mockTimeSource, newStartTime)
+ val newState = ChronometerState(fakeTimeSource, newStartTime, isEventInFuture = false)
val newJob = launch { newState.run() }
- mockTimeSource.time = newStartTime + newElapsedTime
+ fakeTimeSource.time = newStartTime + newElapsedTime
advanceTimeBy(newElapsedTime)
assertThat(newState.currentTimeText).isEqualTo(formatElapsedTime(newElapsedTime / 1000))
newJob.cancelAndJoin()
}
+
+ @Test
+ fun textUpdates_afterResettingBase_isEventInFutureTrue() = runTest {
+ val initialElapsedTime = 40_000L
+ val eventTime = 50_000L
+ val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = true)
+ val job = launch { state.run() }
+
+ fakeTimeSource.time = initialElapsedTime
+ advanceTimeBy(initialElapsedTime)
+ // Time should be 50 - 40 = 10
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 10))
+
+ job.cancelAndJoin()
+
+ val newElapsedTime = 75_000L
+ val newEventTime = 100_000L
+ val newState = ChronometerState(fakeTimeSource, newEventTime, isEventInFuture = true)
+ val newJob = launch { newState.run() }
+
+ fakeTimeSource.time = newElapsedTime
+ advanceTimeBy(newElapsedTime - initialElapsedTime)
+ // Time should be 100 - 75 = 25
+ assertThat(newState.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 25))
+
+ newJob.cancelAndJoin()
+ }
+
+ @Test
+ fun textUpdates_afterResettingisEventInFuture() = runTest {
+ val initialElapsedTime = 40_000L
+ val eventTime = 50_000L
+ val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = true)
+ val job = launch { state.run() }
+
+ fakeTimeSource.time = initialElapsedTime
+ advanceTimeBy(initialElapsedTime)
+ // Time should be 50 - 40 = 10
+ assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 10))
+
+ job.cancelAndJoin()
+
+ val newElapsedTime = 70_000L
+ val newState = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = false)
+ val newJob = launch { newState.run() }
+
+ fakeTimeSource.time = newElapsedTime
+ advanceTimeBy(newElapsedTime - initialElapsedTime)
+ // Time should be 70 - 50 = 20
+ assertThat(newState.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 20))
+
+ newJob.cancelAndJoin()
+ }
}
/** A fake implementation of [TimeSource] that allows the caller to set the current time */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt
index 2fe627020ebf..06656b744a5a 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt
@@ -141,7 +141,7 @@ constructor(
// When we're promoting notifications automatically, the `when` time set on the
// notification will likely just be set to the current time, which would cause the chip
// to always show "now". We don't want early testers to get that experience since it's
- // not what will happen at launch, so just don't show any time.
+ // not what will happen at launch, so just don't show any time.onometerstate
return OngoingActivityChipModel.Active.IconOnly(
this.key,
icon,
@@ -194,12 +194,12 @@ constructor(
}
}
is PromotedNotificationContentModel.When.Chronometer -> {
- // TODO(b/364653005): Check isCountDown and support CountDown.
return OngoingActivityChipModel.Active.Timer(
this.key,
icon,
colors,
startTimeMs = this.promotedContent.time.elapsedRealtimeMillis,
+ isEventInFuture = this.promotedContent.time.isCountDown,
onClickListenerLegacy,
clickBehavior,
)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt
index 2032ec8af78c..1eb46d8cc3d5 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt
@@ -16,6 +16,7 @@
package com.android.systemui.statusbar.chips.ui.binder
+import android.annotation.ElapsedRealtimeLong
import com.android.systemui.statusbar.chips.ui.view.ChipChronometer
object ChipChronometerBinder {
@@ -25,9 +26,11 @@ object ChipChronometerBinder {
* @param startTimeMs the time this event started, relative to
* [com.android.systemui.util.time.SystemClock.elapsedRealtime]. See
* [android.widget.Chronometer.setBase].
+ * @param isCountDown see [android.widget.Chronometer.setCountDown].
*/
- fun bind(startTimeMs: Long, view: ChipChronometer) {
+ fun bind(@ElapsedRealtimeLong startTimeMs: Long, isCountDown: Boolean, view: ChipChronometer) {
view.base = startTimeMs
+ view.isCountDown = isCountDown
view.start()
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt
index 6f8552738d33..77e0dde3dec5 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt
@@ -315,7 +315,11 @@ object OngoingActivityChipBinder {
chipShortTimeDeltaView.visibility = View.GONE
}
is OngoingActivityChipModel.Active.Timer -> {
- ChipChronometerBinder.bind(chipModel.startTimeMs, chipTimeView)
+ ChipChronometerBinder.bind(
+ chipModel.startTimeMs,
+ chipModel.isEventInFuture,
+ chipTimeView,
+ )
chipTimeView.visibility = View.VISIBLE
chipTextView.visibility = View.GONE
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt
index 55d753662a65..f650d8d7d10d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt
@@ -74,25 +74,30 @@ fun ChipContent(viewModel: OngoingActivityChipModel.Active, modifier: Modifier =
val textMeasurer = rememberTextMeasurer()
when (viewModel) {
is OngoingActivityChipModel.Active.Timer -> {
- val timerState = rememberChronometerState(startTimeMillis = viewModel.startTimeMs)
- val text = timerState.currentTimeText
- Text(
- text = text,
- style = textStyle,
- color = textColor,
- softWrap = false,
- modifier =
- modifier
- .hideTextIfDoesNotFit(
- text = text,
- textStyle = textStyle,
- textMeasurer = textMeasurer,
- maxTextWidth = maxTextWidth,
- startPadding = startPadding,
- endPadding = endPadding,
- )
- .neverDecreaseWidth(density),
- )
+ val timerState =
+ rememberChronometerState(
+ eventTimeMillis = viewModel.startTimeMs,
+ isCountDown = viewModel.isEventInFuture,
+ )
+ timerState.currentTimeText?.let { text ->
+ Text(
+ text = text,
+ style = textStyle,
+ color = textColor,
+ softWrap = false,
+ modifier =
+ modifier
+ .hideTextIfDoesNotFit(
+ text = text,
+ textStyle = textStyle,
+ textMeasurer = textMeasurer,
+ maxTextWidth = maxTextWidth,
+ startPadding = startPadding,
+ endPadding = endPadding,
+ )
+ .neverDecreaseWidth(density),
+ )
+ }
}
is OngoingActivityChipModel.Active.Countdown -> {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt
index 8e470742f174..106a36313190 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt
@@ -105,6 +105,12 @@ sealed class OngoingActivityChipModel {
* [android.widget.Chronometer.setBase].
*/
@ElapsedRealtimeLong val startTimeMs: Long,
+ /**
+ * True if this chip represents an event starting in the future and false if this chip
+ * represents an event that has already started. If true, [startTimeMs] should be in the
+ * future. Otherwise, [startTimeMs] should be in the past.
+ */
+ val isEventInFuture: Boolean = false,
override val onClickListenerLegacy: View.OnClickListener?,
override val clickBehavior: ClickBehavior,
override val isHidden: Boolean = false,
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt
index 62789782d0a9..7402583cafe2 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt
@@ -15,6 +15,7 @@
*/
package com.android.systemui.statusbar.chips.ui.viewmodel
+import android.annotation.ElapsedRealtimeLong
import android.os.SystemClock
import android.text.format.DateUtils.formatElapsedTime
import androidx.compose.runtime.Composable
@@ -27,6 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
+import kotlin.math.absoluteValue
import kotlinx.coroutines.delay
/** Platform-optimized interface for getting current time */
@@ -34,18 +36,50 @@ fun interface TimeSource {
fun getCurrentTime(): Long
}
-/** Holds and manages the state for a Chronometer */
-class ChronometerState(private val timeSource: TimeSource, private val startTimeMillis: Long) {
- private var currentTimeMillis by mutableLongStateOf(0L)
+/**
+ * Holds and manages the state for a Chronometer, which shows a timer in a format like "MM:SS" or
+ * "H:MM:SS".
+ *
+ * If [isEventInFuture] is false, then this Chronometer is counting up from an event that started in
+ * the past, like a phone call that was answered. [eventTimeMillis] represents the time the event
+ * started and the timer will tick up: 04:00, 04:01, ... No timer is shown if [eventTimeMillis] is
+ * in the future and [isEventInFuture] is false.
+ *
+ * If [isEventInFuture] is true, then this Chronometer is counting down to an event that will occur
+ * in the future, like a future meeting. [eventTimeMillis] represents the time the event will occur
+ * and the timer will tick down: 04:00, 03:59, ... No timer is shown if [eventTimeMillis] is in the
+ * past and [isEventInFuture] is true.
+ */
+class ChronometerState(
+ private val timeSource: TimeSource,
+ @ElapsedRealtimeLong private val eventTimeMillis: Long,
+ private val isEventInFuture: Boolean,
+) {
+ private var currentTimeMillis by mutableLongStateOf(timeSource.getCurrentTime())
private val elapsedTimeMillis: Long
- get() = maxOf(0L, currentTimeMillis - startTimeMillis)
+ get() =
+ if (isEventInFuture) {
+ eventTimeMillis - currentTimeMillis
+ } else {
+ currentTimeMillis - eventTimeMillis
+ }
- val currentTimeText: String by derivedStateOf { formatElapsedTime(elapsedTimeMillis / 1000) }
+ /**
+ * The current timer string in a format like "MM:SS" or "H:MM:SS", or null if we shouldn't show
+ * the timer string.
+ */
+ val currentTimeText: String? by derivedStateOf {
+ if (elapsedTimeMillis < 0) {
+ null
+ } else {
+ formatElapsedTime(elapsedTimeMillis / 1000)
+ }
+ }
suspend fun run() {
while (true) {
currentTimeMillis = timeSource.getCurrentTime()
- val delaySkewMillis = (currentTimeMillis - startTimeMillis) % 1000L
+ val delaySkewMillis = (eventTimeMillis - currentTimeMillis).absoluteValue % 1000L
delay(1000L - delaySkewMillis)
}
}
@@ -54,13 +88,16 @@ class ChronometerState(private val timeSource: TimeSource, private val startTime
/** Remember and manage the ChronometerState */
@Composable
fun rememberChronometerState(
- startTimeMillis: Long,
+ eventTimeMillis: Long,
+ isCountDown: Boolean,
timeSource: TimeSource = remember { TimeSource { SystemClock.elapsedRealtime() } },
): ChronometerState {
val state =
- remember(timeSource, startTimeMillis) { ChronometerState(timeSource, startTimeMillis) }
+ remember(timeSource, eventTimeMillis, isCountDown) {
+ ChronometerState(timeSource, eventTimeMillis, isCountDown)
+ }
val lifecycleOwner = LocalLifecycleOwner.current
- LaunchedEffect(lifecycleOwner, timeSource, startTimeMillis) {
+ LaunchedEffect(lifecycleOwner, timeSource, eventTimeMillis) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { state.run() }
}