git.iko.soy/opencode-android
on 366f92e help sign in
200 lines 8.8 kB history raw
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
29/**
30 * Formats an epoch-millis timestamp as a short, compact relative string (e.g. "3m", "2h",
31 * "5d", "2w", "3mo", "1y").
32 *
33 * The compact single-letter form is deliberate: these labels sit in dense action rows and list
34 * rows next to other controls, so they must stay narrow. (An earlier version used
35 * [DateUtils.getRelativeTimeSpanString] with `FORMAT_ABBREV_RELATIVE` for minutes/hours/days,
36 * but that returns a full phrase like "5 min. ago" / "2 hr. ago" — not the compact form the
37 * examples and the numeric fallback imply — which wraps or crowds those rows.) Always returns a
38 * non-empty string for a valid timestamp.
39 */
40fun relativeTime(epochMillis: Long?): String {
41 if (epochMillis == null || epochMillis <= 0) return ""
42 val now = System.currentTimeMillis()
43 val diff = now - epochMillis
44 if (diff < 0) return "now"
45 val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
46 if (minutes < 1) return "now"
47 if (minutes < 60) return "${minutes}m"
48 val hours = TimeUnit.MILLISECONDS.toHours(diff)
49 if (hours < 24) return "${hours}h"
50 val days = TimeUnit.MILLISECONDS.toDays(diff)
51 if (days < 7) return "${days}d"
52 // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
53 // numeric form. The suffix is locale-stable (English letter) but these are
54 // rare timestamps (older than a week) and the numeric value is the primary signal.
55 val weeks = days / 7
56 if (weeks < 5) return "${weeks}w"
57 // Switch to years only past a full 365 days. Gating on `months < 12` (with months =
58 // days/30) instead misfires for 360–364 days: months == 12 fails the check and falls
59 // through to years, rendering "1y" for a span that's under a year (~11.9 months).
60 if (days < 365) return "${days / 30}mo"
61 val years = days / 365
62 return "${years}y"
65/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
66 * enough for the smallest unit we show (minutes). */
67private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
69/**
70 * A shared, screen-level "tick" that advances periodically so every relative-time label
71 * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
72 *
73 * val tick = rememberRelativeTimeTick()
74 * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
75 *
76 * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
77 * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
78 * timer churn as items enter and leave the viewport.
79 *
80 * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
81 * then, so it keeps auto-refreshing even without a provider.
82 */
83val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
85/**
86 * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
87 * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
88 * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
89 */
90@Composable
91fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
92 var tick by remember { mutableLongStateOf(0L) }
93 val lifecycleOwner = LocalLifecycleOwner.current
95 androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
96 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
97 // Refresh immediately on resume (so a stale label doesn't wait an interval),
98 // then periodically so labels stay current.
99 tick++
100 while (true) {
101 delay(intervalMs)
102 tick++
103 }
104 }
105 }
106 return tick
109/**
110 * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
111 * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
112 * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
113 * need a provider to get auto-refreshing labels.
114 */
115@Composable
116fun rememberRelativeTime(epochMillis: Long?): String {
117 val sharedTick = LocalRelativeTimeTick.current
118 return if (sharedTick != 0L) {
119 // Reading sharedTick above registers a recomposition dependency on the shared
120 // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
121 relativeTime(epochMillis)
122 } else {
123 rememberRelativeTimeStandalone(epochMillis)
124 }
127/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
128@Composable
129private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
130 var tick by remember { mutableLongStateOf(0L) }
131 val lifecycleOwner = LocalLifecycleOwner.current
133 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
134 if (epochMillis == null) return@LaunchedEffect
135 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
136 // Tick immediately on resume so a stale label refreshes without waiting for
137 // the first interval, then periodically.
138 tick++
139 while (true) {
140 delay(RELATIVE_TIME_INTERVAL_MS)
141 tick++
142 }
143 }
144 }
146 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
147 // the relative-time string from the current wall clock on each tick. Keying remember
148 // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
149 val formatted by remember(epochMillis) {
150 derivedStateOf {
151 // Read tick to create a recomposition dependency on the timer.
152 tick
153 relativeTime(epochMillis)
154 }
155 }
156 return formatted
159/**
160 * Formats [epochMillis] as a full, locale-aware absolute timestamp (date + time + year),
161 * e.g. "Jul 2, 2026, 14:33". Complements the compact [relativeTime] label shown in lists
162 * and bubbles — the relative form is scannable, the absolute form is precise. Returns an
163 * empty string for a null/invalid timestamp. Uses the device locale and 12/24-hour setting.
164 */
165fun absoluteTime(context: Context, epochMillis: Long?): String {
166 if (epochMillis == null || epochMillis <= 0) return ""
167 return DateUtils.formatDateTime(
168 context,
169 epochMillis,
170 DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_YEAR,
171 )
174/**
175 * A relative-time label (auto-refreshing, per [rememberRelativeTime]) that reveals the full
176 * absolute timestamp in a tooltip on long-press (or hover). Lets a compact "3m"/"2h" stay
177 * the default while the exact date/time is one long-press away — without spending screen
178 * space on it. Renders nothing when the timestamp is absent so callers can drop it in
179 * unconditionally.
180 */
181@OptIn(ExperimentalMaterial3Api::class)
182@Composable
183fun RelativeTimeText(
184 epochMillis: Long?,
185 modifier: Modifier = Modifier,
186 style: TextStyle = MaterialTheme.typography.labelSmall,
187 color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
188) {
189 val relative = rememberRelativeTime(epochMillis)
190 if (relative.isEmpty()) return
191 val context = LocalContext.current
192 val absolute = remember(epochMillis) { absoluteTime(context, epochMillis) }
193 TooltipBox(
194 positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
195 tooltip = { PlainTooltip { Text(absolute) } },
196 state = rememberTooltipState(),
197 ) {
198 Text(relative, style = style, color = color, modifier = modifier)
199 }