git.iko.soy/opencode-android
on 8bc2cce help sign in
161 lines 7.1 kB history raw
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
17/**
18 * Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d").
19 *
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.
28 */
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"
36 if (minutes < 60) {
37 val locale = DateUtils.getRelativeTimeSpanString(
38 epochMillis, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
39 )?.toString()
40 return locale?.takeIf { it.isNotBlank() } ?: "${minutes}m"
41 }
42 val hours = TimeUnit.MILLISECONDS.toHours(diff)
43 if (hours < 24) {
44 val locale = DateUtils.getRelativeTimeSpanString(
45 epochMillis, now, DateUtils.HOUR_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
46 )?.toString()
47 return locale?.takeIf { it.isNotBlank() } ?: "${hours}h"
48 }
49 val days = TimeUnit.MILLISECONDS.toDays(diff)
50 if (days < 7) {
51 val locale = DateUtils.getRelativeTimeSpanString(
52 epochMillis, now, DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
53 )?.toString()
54 return locale?.takeIf { it.isNotBlank() } ?: "${days}d"
55 }
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.
59 val weeks = days / 7
60 if (weeks < 5) return "${weeks}w"
61 // Switch to years only past a full 365 days. Gating on `months < 12` (with months =
62 // days/30) instead misfires for 360–364 days: months == 12 fails the check and falls
63 // through to years, rendering "1y" for a span that's under a year (~11.9 months).
64 if (days < 365) return "${days / 30}mo"
65 val years = days / 365
66 return "${years}y"
69/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
70 * enough for the smallest unit we show (minutes). */
71private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
73/**
74 * A shared, screen-level "tick" that advances periodically so every relative-time label
75 * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
76 *
77 * val tick = rememberRelativeTimeTick()
78 * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
79 *
80 * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
81 * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
82 * timer churn as items enter and leave the viewport.
83 *
84 * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
85 * then, so it keeps auto-refreshing even without a provider.
86 */
87val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
89/**
90 * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
91 * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
92 * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
93 */
94@Composable
95fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
96 var tick by remember { mutableLongStateOf(0L) }
97 val lifecycleOwner = LocalLifecycleOwner.current
99 androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
100 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
101 // Refresh immediately on resume (so a stale label doesn't wait an interval),
102 // then periodically so labels stay current.
103 tick++
104 while (true) {
105 delay(intervalMs)
106 tick++
107 }
108 }
109 }
110 return tick
113/**
114 * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
115 * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
116 * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
117 * need a provider to get auto-refreshing labels.
118 */
119@Composable
120fun rememberRelativeTime(epochMillis: Long?): String {
121 val sharedTick = LocalRelativeTimeTick.current
122 return if (sharedTick != 0L) {
123 // Reading sharedTick above registers a recomposition dependency on the shared
124 // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
125 relativeTime(epochMillis)
126 } else {
127 rememberRelativeTimeStandalone(epochMillis)
128 }
131/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
132@Composable
133private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
134 var tick by remember { mutableLongStateOf(0L) }
135 val lifecycleOwner = LocalLifecycleOwner.current
137 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
138 if (epochMillis == null) return@LaunchedEffect
139 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
140 // Tick immediately on resume so a stale label refreshes without waiting for
141 // the first interval, then periodically.
142 tick++
143 while (true) {
144 delay(RELATIVE_TIME_INTERVAL_MS)
145 tick++
146 }
147 }
148 }
150 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
151 // the relative-time string from the current wall clock on each tick. Keying remember
152 // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
153 val formatted by remember(epochMillis) {
154 derivedStateOf {
155 // Read tick to create a recomposition dependency on the timer.
156 tick
157 relativeTime(epochMillis)
158 }
159 }
160 return formatted