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.res.stringResource
23import androidx.compose.ui.text.TextStyle
24import androidx.lifecycle.Lifecycle
25import androidx.lifecycle.compose.LocalLifecycleOwner
26import androidx.lifecycle.repeatOnLifecycle
27import kotlinx.coroutines.delay
28import soy.iko.opencode.R
31 * Formats an epoch-millis timestamp as a short, compact relative string (e.g. "3m", "2h",
32 * "5d", "2w", "3mo", "1y").
34 * The compact single-letter form is deliberate: these labels sit in dense action rows and list
35 * rows next to other controls, so they must stay narrow. (An earlier version used
36 * [DateUtils.getRelativeTimeSpanString] with `FORMAT_ABBREV_RELATIVE` for minutes/hours/days,
37 * but that returns a full phrase like "5 min. ago" / "2 hr. ago" — not the compact form the
38 * examples and the numeric fallback imply — which wraps or crowds those rows.) Always returns a
39 * non-empty string for a valid timestamp.
41 * The unit suffixes are resolved from string resources so they follow the device locale
42 * (e.g. "3m" in English, "3 min" in Spanish) instead of a hardcoded English letter.
44fun relativeTime(context: Context, epochMillis: Long?): String {
45 return when (val unit = relativeTimeUnit(epochMillis)) {
47 is RelativeTimeUnit.Now -> context.getString(R.string.time_now)
48 is RelativeTimeUnit.Minutes -> context.getString(R.string.time_minutes_abbrev, unit.count)
49 is RelativeTimeUnit.Hours -> context.getString(R.string.time_hours_abbrev, unit.count)
50 is RelativeTimeUnit.Days -> context.getString(R.string.time_days_abbrev, unit.count)
51 is RelativeTimeUnit.Weeks -> context.getString(R.string.time_weeks_abbrev, unit.count)
52 is RelativeTimeUnit.Months -> context.getString(R.string.time_months_abbrev, unit.count)
53 is RelativeTimeUnit.Years -> context.getString(R.string.time_years_abbrev, unit.count)
57/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
58 * enough for the smallest unit we show (minutes). */
59private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
62 * A shared, screen-level "tick" that advances periodically so every relative-time label
63 * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
65 * val tick = rememberRelativeTimeTick()
66 * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
68 * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
69 * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
70 * timer churn as items enter and leave the viewport.
72 * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
73 * then, so it keeps auto-refreshing even without a provider.
75val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
78 * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
79 * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
80 * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
83fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
84 var tick by remember { mutableLongStateOf(0L) }
85 val lifecycleOwner = LocalLifecycleOwner.current
87 androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
88 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
89 // Refresh immediately on resume (so a stale label doesn't wait an interval),
90 // then periodically so labels stay current.
102 * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
103 * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
104 * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
105 * need a provider to get auto-refreshing labels.
108fun rememberRelativeTime(epochMillis: Long?): String {
109 val sharedTick = LocalRelativeTimeTick.current
110 val context = LocalContext.current
111 return if (sharedTick != 0L) {
112 // Reading sharedTick above registers a recomposition dependency on the shared
113 // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
114 relativeTime(context, epochMillis)
116 rememberRelativeTimeStandalone(epochMillis)
120/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
122private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
123 var tick by remember { mutableLongStateOf(0L) }
124 val lifecycleOwner = LocalLifecycleOwner.current
125 val context = LocalContext.current
127 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
128 if (epochMillis == null) return@LaunchedEffect
129 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
130 // Tick immediately on resume so a stale label refreshes without waiting for
131 // the first interval, then periodically.
134 delay(RELATIVE_TIME_INTERVAL_MS)
140 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
141 // the relative-time string from the current wall clock on each tick. Keying remember
142 // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
143 val formatted by remember(epochMillis) {
145 // Read tick to create a recomposition dependency on the timer.
147 relativeTime(context, epochMillis)
154 * Formats [epochMillis] as a full, locale-aware absolute timestamp (date + time + year),
155 * e.g. "Jul 2, 2026, 14:33". Complements the compact [relativeTime] label shown in lists
156 * and bubbles — the relative form is scannable, the absolute form is precise. Returns an
157 * empty string for a null/invalid timestamp. Uses the device locale and 12/24-hour setting.
159fun absoluteTime(context: Context, epochMillis: Long?): String {
160 if (epochMillis == null || epochMillis <= 0) return ""
161 return DateUtils.formatDateTime(
164 DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_YEAR,
169 * A relative-time label (auto-refreshing, per [rememberRelativeTime]) that reveals the full
170 * absolute timestamp in a tooltip on long-press (or hover). Lets a compact "3m"/"2h" stay
171 * the default while the exact date/time is one long-press away — without spending screen
172 * space on it. Renders nothing when the timestamp is absent so callers can drop it in
175@OptIn(ExperimentalMaterial3Api::class)
179 modifier: Modifier = Modifier,
180 style: TextStyle = MaterialTheme.typography.labelSmall,
181 color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
183 val relative = rememberRelativeTime(epochMillis)
184 if (relative.isEmpty()) return
185 val context = LocalContext.current
186 val absolute = remember(epochMillis) { absoluteTime(context, epochMillis) }
188 positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
189 tooltip = { PlainTooltip { Text(absolute) } },
190 state = rememberTooltipState(),
192 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