git.iko.soy/opencode-android
on d29e57b help sign in
128 lines 5.3 kB history raw
1package soy.iko.opencode.ui.components
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.compositionLocalOf
5import androidx.compose.runtime.derivedStateOf
6import androidx.compose.runtime.getValue
7import androidx.compose.runtime.mutableLongStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.runtime.setValue
10import androidx.lifecycle.Lifecycle
11import androidx.lifecycle.compose.LocalLifecycleOwner
12import androidx.lifecycle.repeatOnLifecycle
13import kotlinx.coroutines.delay
14import java.util.concurrent.TimeUnit
16/** Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d"). */
17fun relativeTime(epochMillis: Long?): String {
18 if (epochMillis == null || epochMillis <= 0) return ""
19 val diff = System.currentTimeMillis() - epochMillis
20 if (diff < 0) return "now"
21 val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
22 if (minutes < 1) return "now"
23 if (minutes < 60) return "${minutes}m"
24 val hours = TimeUnit.MILLISECONDS.toHours(diff)
25 if (hours < 24) return "${hours}h"
26 val days = TimeUnit.MILLISECONDS.toDays(diff)
27 if (days < 7) return "${days}d"
28 val weeks = days / 7
29 if (weeks < 5) return "${weeks}w"
30 val months = days / 30
31 if (months < 12) return "${months}mo"
32 val years = (days / 365).coerceAtLeast(1)
33 return "${years}y"
36/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
37 * enough for the smallest unit we show (minutes). */
38private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
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 */
54val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
56/**
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].
60 */
61@Composable
62fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
63 var tick by remember { mutableLongStateOf(0L) }
64 val lifecycleOwner = LocalLifecycleOwner.current
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
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
87fun 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)
95 }
98/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
99@Composable
100private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
101 var tick by remember { mutableLongStateOf(0L) }
102 val lifecycleOwner = LocalLifecycleOwner.current
104 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
105 if (epochMillis == null) return@LaunchedEffect
106 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++
110 while (true) {
111 delay(RELATIVE_TIME_INTERVAL_MS)
112 tick++
113 }
114 }
115 }
117 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
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.
120 val formatted by remember(epochMillis) {
121 derivedStateOf {
122 // Read tick to create a recomposition dependency on the timer.
123 tick
124 relativeTime(epochMillis)
125 }
126 }
127 return formatted