1package soy.iko.opencode.ui.components
3import android.content.Context
4import android.text.format.DateUtils
5import androidx.compose.material3.ExperimentalMaterial3Api
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.PlainTooltip
8import androidx.compose.material3.Text
9import androidx.compose.material3.TooltipBox
10import androidx.compose.material3.TooltipDefaults
11import androidx.compose.material3.rememberTooltipState
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.compositionLocalOf
14import androidx.compose.runtime.derivedStateOf
15import androidx.compose.runtime.getValue
16import androidx.compose.runtime.mutableLongStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
19import androidx.compose.ui.Modifier
20import androidx.compose.ui.graphics.Color
21import androidx.compose.ui.platform.LocalContext
22import androidx.compose.ui.text.TextStyle
23import androidx.lifecycle.Lifecycle
24import androidx.lifecycle.compose.LocalLifecycleOwner
25import androidx.lifecycle.repeatOnLifecycle
26import kotlinx.coroutines.delay
27import java.util.concurrent.TimeUnit
30 * Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d").
32 * For coarse units (minutes/hours/days) prefers Android's locale-aware
33 * [DateUtils.getRelativeTimeSpanString] so the abbreviations match the device locale
34 * instead of hardcoded English. Falls back to a compact numeric form (e.g. "5m",
35 * "3h", "2d") when [DateUtils] returns null (e.g. under JVM unit tests where
36 * `isReturnDefaultValues = true` stubs the framework call to null). For
37 * weeks/months/years — which [DateUtils] doesn't provide short abbreviations for —
38 * uses the compact numeric form with a locale-stable unit suffix. Always returns a
39 * non-empty string for a valid timestamp.
41fun relativeTime(epochMillis: Long?): String {
42 if (epochMillis == null || epochMillis <= 0) return ""
43 val now = System.currentTimeMillis()
44 val diff = now - epochMillis
45 if (diff < 0) return "now"
46 val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
47 if (minutes < 1) return "now"
49 val locale = DateUtils.getRelativeTimeSpanString(
50 epochMillis, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
52 return locale?.takeIf { it.isNotBlank() } ?: "${minutes}m"
54 val hours = TimeUnit.MILLISECONDS.toHours(diff)
56 val locale = DateUtils.getRelativeTimeSpanString(
57 epochMillis, now, DateUtils.HOUR_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
59 return locale?.takeIf { it.isNotBlank() } ?: "${hours}h"
61 val days = TimeUnit.MILLISECONDS.toDays(diff)
63 val locale = DateUtils.getRelativeTimeSpanString(
64 epochMillis, now, DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
66 return locale?.takeIf { it.isNotBlank() } ?: "${days}d"
68 // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
69 // numeric form. The suffix is locale-stable (English letter) but these are
70 // rare timestamps (older than a week) and the numeric value is the primary signal.
72 if (weeks < 5) return "${weeks}w"
73 // Switch to years only past a full 365 days. Gating on `months < 12` (with months =
74 // days/30) instead misfires for 360–364 days: months == 12 fails the check and falls
75 // through to years, rendering "1y" for a span that's under a year (~11.9 months).
76 if (days < 365) return "${days / 30}mo"
77 val years = days / 365
81/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
82 * enough for the smallest unit we show (minutes). */
83private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
86 * A shared, screen-level "tick" that advances periodically so every relative-time label
87 * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
89 * val tick = rememberRelativeTimeTick()
90 * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
92 * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
93 * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
94 * timer churn as items enter and leave the viewport.
96 * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
97 * then, so it keeps auto-refreshing even without a provider.
99val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
102 * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
103 * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
104 * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
107fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
108 var tick by remember { mutableLongStateOf(0L) }
109 val lifecycleOwner = LocalLifecycleOwner.current
111 androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
112 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
113 // Refresh immediately on resume (so a stale label doesn't wait an interval),
114 // then periodically so labels stay current.
126 * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
127 * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
128 * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
129 * need a provider to get auto-refreshing labels.
132fun rememberRelativeTime(epochMillis: Long?): String {
133 val sharedTick = LocalRelativeTimeTick.current
134 return if (sharedTick != 0L) {
135 // Reading sharedTick above registers a recomposition dependency on the shared
136 // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
137 relativeTime(epochMillis)
139 rememberRelativeTimeStandalone(epochMillis)
143/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
145private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
146 var tick by remember { mutableLongStateOf(0L) }
147 val lifecycleOwner = LocalLifecycleOwner.current
149 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
150 if (epochMillis == null) return@LaunchedEffect
151 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
152 // Tick immediately on resume so a stale label refreshes without waiting for
153 // the first interval, then periodically.
156 delay(RELATIVE_TIME_INTERVAL_MS)
162 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
163 // the relative-time string from the current wall clock on each tick. Keying remember
164 // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
165 val formatted by remember(epochMillis) {
167 // Read tick to create a recomposition dependency on the timer.
169 relativeTime(epochMillis)
176 * Formats [epochMillis] as a full, locale-aware absolute timestamp (date + time + year),
177 * e.g. "Jul 2, 2026, 14:33". Complements the compact [relativeTime] label shown in lists
178 * and bubbles — the relative form is scannable, the absolute form is precise. Returns an
179 * empty string for a null/invalid timestamp. Uses the device locale and 12/24-hour setting.
181fun absoluteTime(context: Context, epochMillis: Long?): String {
182 if (epochMillis == null || epochMillis <= 0) return ""
183 return DateUtils.formatDateTime(
186 DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_YEAR,
191 * A relative-time label (auto-refreshing, per [rememberRelativeTime]) that reveals the full
192 * absolute timestamp in a tooltip on long-press (or hover). Lets a compact "3m"/"2h" stay
193 * the default while the exact date/time is one long-press away — without spending screen
194 * space on it. Renders nothing when the timestamp is absent so callers can drop it in
197@OptIn(ExperimentalMaterial3Api::class)
201 modifier: Modifier = Modifier,
202 style: TextStyle = MaterialTheme.typography.labelSmall,
203 color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
205 val relative = rememberRelativeTime(epochMillis)
206 if (relative.isEmpty()) return
207 val context = LocalContext.current
208 val absolute = remember(epochMillis) { absoluteTime(context, epochMillis) }
210 positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
211 tooltip = { PlainTooltip { Text(absolute) } },
212 state = rememberTooltipState(),
214 Text(relative, style = style, color = color, modifier = modifier)
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