@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.components
22
33import androidx.compose.runtime.Composable
4+import androidx.compose.runtime.compositionLocalOf
45import androidx.compose.runtime.derivedStateOf
56import androidx.compose.runtime.getValue
67import androidx.compose.runtime.mutableLongStateOf
@@ -8,7 +9,6 @@ import androidx.compose.runtime.remember
89import androidx.compose.runtime.setValue
910import androidx.lifecycle.Lifecycle
1011import androidx.lifecycle.compose.LocalLifecycleOwner
11−import androidx.lifecycle.compose.LifecycleResumeEffect
1212import androidx.lifecycle.repeatOnLifecycle
1313import kotlinx.coroutines.delay
1414import java.util.concurrent.TimeUnit
@@ -33,42 +33,90 @@ fun relativeTime(epochMillis: Long?): String {
3333 return "${years}y"
3434}
3535
36+/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
37+ * enough for the smallest unit we show (minutes). */
38+private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
39+
40+/**
41+ * A shared, screen-level "tick" that advances periodically so every relative-time label
42+ * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
43+ *
44+ * val tick = rememberRelativeTimeTick()
45+ * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
46+ *
47+ * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
48+ * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
49+ * timer churn as items enter and leave the viewport.
50+ *
51+ * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
52+ * then, so it keeps auto-refreshing even without a provider.
53+ */
54+val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
55+
3656/**
37− * Returns a relative-time string for [epochMillis] that auto-refreshes so the label
38− * doesn't go stale (e.g. "3m" → "4m" → "5m" while the screen is visible). Re-evaluates
39− * every [intervalMs] (default 30 s — coarse enough for battery, fine enough for the
40− * smallest unit we show, which is minutes). Pauses the timer when the lifecycle is
41− * not at least resumed (screen off / app backgrounded) to avoid battery drain.
57+ * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
58+ * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
59+ * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
4260 */
4361@Composable
44−fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
62+fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
4563 var tick by remember { mutableLongStateOf(0L) }
4664 val lifecycleOwner = LocalLifecycleOwner.current
4765
48− // Tick immediately on resume so a stale label is refreshed without waiting for
49− // the first delay interval to elapse.
50− LifecycleResumeEffect(epochMillis, lifecycleOwner) {
51− tick++
52− onPauseOrDispose { /* nothing to clean up */ }
66+ androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
67+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
68+ // Refresh immediately on resume (so a stale label doesn't wait an interval),
69+ // then periodically so labels stay current.
70+ tick++
71+ while (true) {
72+ delay(intervalMs)
73+ tick++
74+ }
75+ }
76+ }
77+ return tick
78+}
79+
80+/**
81+ * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
82+ * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
83+ * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
84+ * need a provider to get auto-refreshing labels.
85+ */
86+@Composable
87+fun rememberRelativeTime(epochMillis: Long?): String {
88+ val sharedTick = LocalRelativeTimeTick.current
89+ return if (sharedTick != 0L) {
90+ // Reading sharedTick above registers a recomposition dependency on the shared
91+ // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
92+ relativeTime(epochMillis)
93+ } else {
94+ rememberRelativeTimeStandalone(epochMillis)
5395 }
96+}
97+
98+/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
99+@Composable
100+private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
101+ var tick by remember { mutableLongStateOf(0L) }
102+ val lifecycleOwner = LocalLifecycleOwner.current
54103
55− // Drive the periodic refresh with repeatOnLifecycle so the delay loop is
56− // suspended (not running) when the screen isn't at least resumed. This avoids
57− // unnecessary CPU usage from timer coroutines while the app is backgrounded.
58104 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
59105 if (epochMillis == null) return@LaunchedEffect
60106 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
107+ // Tick immediately on resume so a stale label refreshes without waiting for
108+ // the first interval, then periodically.
109+ tick++
61110 while (true) {
62− delay(intervalMs)
111+ delay(RELATIVE_TIME_INTERVAL_MS)
63112 tick++
64113 }
65114 }
66115 }
67116
68117 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
69− // the relative-time string from the current wall clock on each tick.
70− // Keying remember on epochMillis ensures a new derivedStateOf is created when the
71− // timestamp changes, so the new value is reflected immediately.
118+ // the relative-time string from the current wall clock on each tick. Keying remember
119+ // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
72120 val formatted by remember(epochMillis) {
73121 derivedStateOf {
74122 // Read tick to create a recomposition dependency on the timer.