1package soy.iko.opencode.ui.components
3import android.text.format.DateUtils
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.compositionLocalOf
6import androidx.compose.runtime.derivedStateOf
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.mutableLongStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11import androidx.lifecycle.Lifecycle
12import androidx.lifecycle.compose.LocalLifecycleOwner
13import androidx.lifecycle.repeatOnLifecycle
14import kotlinx.coroutines.delay
15import java.util.concurrent.TimeUnit
18 * Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d").
20 * For coarse units (minutes/hours/days) prefers Android's locale-aware
21 * [DateUtils.getRelativeTimeSpanString] so the abbreviations match the device locale
22 * instead of hardcoded English. Falls back to a compact numeric form (e.g. "5m",
23 * "3h", "2d") when [DateUtils] returns null (e.g. under JVM unit tests where
24 * `isReturnDefaultValues = true` stubs the framework call to null). For
25 * weeks/months/years — which [DateUtils] doesn't provide short abbreviations for —
26 * uses the compact numeric form with a locale-stable unit suffix. Always returns a
27 * non-empty string for a valid timestamp.
29fun relativeTime(epochMillis: Long?): String {
30 if (epochMillis == null || epochMillis <= 0) return ""
31 val now = System.currentTimeMillis()
32 val diff = now - epochMillis
33 if (diff < 0) return "now"
34 val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
35 if (minutes < 1) return "now"
37 val locale = DateUtils.getRelativeTimeSpanString(
38 epochMillis, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
40 return locale?.takeIf { it.isNotBlank() } ?: "${minutes}m"
42 val hours = TimeUnit.MILLISECONDS.toHours(diff)
44 val locale = DateUtils.getRelativeTimeSpanString(
45 epochMillis, now, DateUtils.HOUR_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
47 return locale?.takeIf { it.isNotBlank() } ?: "${hours}h"
49 val days = TimeUnit.MILLISECONDS.toDays(diff)
51 val locale = DateUtils.getRelativeTimeSpanString(
52 epochMillis, now, DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
54 return locale?.takeIf { it.isNotBlank() } ?: "${days}d"
56 // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
57 // numeric form. The suffix is locale-stable (English letter) but these are
58 // rare timestamps (older than a week) and the numeric value is the primary signal.
60 if (weeks < 5) return "${weeks}w"
61 val months = days / 30
62 if (months < 12) return "${months}mo"
63 val years = (days / 365).coerceAtLeast(1)
67/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
68 * enough for the smallest unit we show (minutes). */
69private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
72 * A shared, screen-level "tick" that advances periodically so every relative-time label
73 * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
75 * val tick = rememberRelativeTimeTick()
76 * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
78 * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
79 * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
80 * timer churn as items enter and leave the viewport.
82 * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
83 * then, so it keeps auto-refreshing even without a provider.
85val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
88 * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
89 * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
90 * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
93fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
94 var tick by remember { mutableLongStateOf(0L) }
95 val lifecycleOwner = LocalLifecycleOwner.current
97 androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
98 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
99 // Refresh immediately on resume (so a stale label doesn't wait an interval),
100 // then periodically so labels stay current.
112 * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
113 * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
114 * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
115 * need a provider to get auto-refreshing labels.
118fun rememberRelativeTime(epochMillis: Long?): String {
119 val sharedTick = LocalRelativeTimeTick.current
120 return if (sharedTick != 0L) {
121 // Reading sharedTick above registers a recomposition dependency on the shared
122 // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
123 relativeTime(epochMillis)
125 rememberRelativeTimeStandalone(epochMillis)
129/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
131private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
132 var tick by remember { mutableLongStateOf(0L) }
133 val lifecycleOwner = LocalLifecycleOwner.current
135 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
136 if (epochMillis == null) return@LaunchedEffect
137 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
138 // Tick immediately on resume so a stale label refreshes without waiting for
139 // the first interval, then periodically.
142 delay(RELATIVE_TIME_INTERVAL_MS)
148 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
149 // the relative-time string from the current wall clock on each tick. Keying remember
150 // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
151 val formatted by remember(epochMillis) {
153 // Read tick to create a recomposition dependency on the timer.
155 relativeTime(epochMillis)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list