git.iko.soy/opencode-android
on bc6b45e help sign in
159 lines 6.9 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 val months = days / 30
62 if (months < 12) return "${months}mo"
63 val years = (days / 365).coerceAtLeast(1)
64 return "${years}y"
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
71/**
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:
74 *
75 * val tick = rememberRelativeTimeTick()
76 * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
77 *
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.
81 *
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.
84 */
85val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
87/**
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].
91 */
92@Composable
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.
101 tick++
102 while (true) {
103 delay(intervalMs)
104 tick++
105 }
106 }
107 }
108 return tick
111/**
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.
116 */
117@Composable
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)
124 } else {
125 rememberRelativeTimeStandalone(epochMillis)
126 }
129/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
130@Composable
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.
140 tick++
141 while (true) {
142 delay(RELATIVE_TIME_INTERVAL_MS)
143 tick++
144 }
145 }
146 }
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) {
152 derivedStateOf {
153 // Read tick to create a recomposition dependency on the timer.
154 tick
155 relativeTime(epochMillis)
156 }
157 }
158 return formatted