git.iko.soy/opencode-android
on e66c7ed help sign in
419 lines 19 kB history raw
1package soy.iko.opencode.ui.usage
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.ExperimentalLayoutApi
9import androidx.compose.foundation.layout.FlowRow
10import androidx.compose.foundation.layout.PaddingValues
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.height
16import androidx.compose.foundation.layout.imePadding
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.widthIn
20import androidx.compose.foundation.layout.wrapContentWidth
21import androidx.compose.foundation.lazy.LazyColumn
22import androidx.compose.foundation.lazy.items
23import androidx.compose.animation.Crossfade
24import androidx.compose.animation.core.tween
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
27import androidx.compose.material.icons.filled.Close
28import androidx.compose.material.icons.filled.CloudOff
29import androidx.compose.material.icons.filled.ErrorOutline
30import androidx.compose.material.icons.filled.QueryStats
31import androidx.compose.material.icons.filled.Refresh
32import androidx.compose.material.icons.filled.Search
33import androidx.compose.material.icons.filled.SearchOff
34import androidx.compose.material3.Card
35import androidx.compose.material3.ExperimentalMaterial3Api
36import androidx.compose.material3.FilterChip
37import androidx.compose.material3.Icon
38import androidx.compose.material3.IconButton
39import androidx.compose.material3.MaterialTheme
40import androidx.compose.material3.OutlinedTextField
41import androidx.compose.material3.Scaffold
42import androidx.compose.material3.SnackbarHost
43import androidx.compose.material3.SnackbarHostState
44import androidx.compose.material3.Text
45import androidx.compose.material3.pulltorefresh.PullToRefreshBox
46import androidx.compose.runtime.Composable
47import androidx.compose.runtime.getValue
48import androidx.compose.runtime.mutableStateOf
49import androidx.compose.runtime.remember
50import androidx.compose.runtime.saveable.rememberSaveable
51import androidx.compose.runtime.setValue
52import androidx.compose.ui.Alignment
53import androidx.compose.ui.Modifier
54import androidx.compose.ui.draw.clip
55import androidx.compose.ui.res.pluralStringResource
56import androidx.compose.ui.res.stringResource
57import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.contentDescription
59import androidx.compose.ui.semantics.heading
60import androidx.compose.ui.semantics.invisibleToUser
61import androidx.compose.ui.semantics.semantics
62import androidx.compose.ui.text.style.TextOverflow
63import androidx.compose.ui.unit.dp
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.viewmodel.compose.viewModel
66import soy.iko.opencode.R
67import soy.iko.opencode.data.model.Tokens
68import soy.iko.opencode.data.network.NetworkConfig
69import soy.iko.opencode.di.AppContainer
70import soy.iko.opencode.ui.components.AppTopBar
71import soy.iko.opencode.ui.components.ConnectionBannerFor
72import soy.iko.opencode.ui.components.EmptyState
73import soy.iko.opencode.ui.components.SectionHeader
74import soy.iko.opencode.ui.components.SkeletonRow
75import soy.iko.opencode.ui.components.reducedMotionAnimateItem
76import soy.iko.opencode.ui.vmFactory
77import java.text.NumberFormat
78import java.util.Locale
80@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
81@Composable
82fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
83 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
84 val state by vm.state.collectAsStateWithLifecycle()
85 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
86 val snackbar = remember { SnackbarHostState() }
88 Scaffold(
89 topBar = {
90 AppTopBar(
91 title = stringResource(R.string.usage_title),
92 onBack = onBack,
93 actions = {
94 IconButton(onClick = { vm.load() }) {
95 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
96 }
97 },
98 )
99 },
100 snackbarHost = { SnackbarHost(snackbar) },
101 ) { padding ->
102 PullToRefreshBox(
103 isRefreshing = refreshing,
104 onRefresh = { vm.load() },
105 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding),
106 ) {
107 Box(modifier = Modifier.fillMaxSize()) {
108 ConnectionBannerFor(container)
109 // Crossfade between content states so transitions read as a smooth fade instead
110 // of an instant snap. Matches the session list's Crossfade pattern; reduced
111 // motion is honored by Crossfade's default spec.
112 val stateKey = state::class.simpleName
113 @Suppress("UnusedCrossfadeTargetStateParameter")
114 Crossfade(
115 targetState = stateKey,
116 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
117 label = "usage_state",
118 ) {
119 when (val s = state) {
120 is UsageViewModel.State.Loading -> Column(
121 modifier = Modifier.fillMaxSize().padding(16.dp),
122 verticalArrangement = Arrangement.spacedBy(12.dp),
123 ) {
124 repeat(6) { SkeletonRow() }
125 }
126 is UsageViewModel.State.Disconnected -> EmptyState(
127 icon = Icons.Filled.CloudOff,
128 title = stringResource(R.string.not_connected),
129 modifier = Modifier.align(Alignment.Center),
130 )
131 is UsageViewModel.State.Error -> EmptyState(
132 icon = Icons.Filled.ErrorOutline,
133 title = stringResource(R.string.usage_failed),
134 description = s.message,
135 modifier = Modifier.align(Alignment.Center),
136 actionLabel = stringResource(R.string.retry),
137 onAction = { vm.load() },
138 )
139 is UsageViewModel.State.Ready ->
140 if (s.report.isEmpty) {
141 EmptyState(
142 icon = Icons.Filled.QueryStats,
143 title = stringResource(R.string.usage_empty),
144 description = stringResource(R.string.usage_empty_desc),
145 modifier = Modifier.align(Alignment.Center),
146 )
147 } else {
148 val timeRange by vm.timeRange.collectAsStateWithLifecycle()
149 UsageContent(
150 report = s.report,
151 onOpenSession = onOpenSession,
152 timeRange = timeRange,
153 onTimeRangeChange = vm::setTimeRange,
154 )
155 }
156 }
157 }
158 }
159 }
160 }
163@OptIn(ExperimentalLayoutApi::class)
164@Composable
165private fun UsageContent(
166 report: UsageReport,
167 onOpenSession: (String) -> Unit,
168 timeRange: UsageTimeRange,
169 onTimeRangeChange: (UsageTimeRange) -> Unit,
170) {
171 // Optional session-title filter. The global totals (cost/tokens/by-model) always reflect
172 // the whole history, but a user with many sessions can narrow the per-session list to find
173 // a specific conversation's spend without scrolling through all of it.
174 var sessionQuery by rememberSaveable { mutableStateOf("") }
175 val filteredSessions = remember(report.bySession, sessionQuery) {
176 val q = sessionQuery.trim()
177 if (q.isEmpty()) report.bySession
178 else report.bySession.filter { it.title.contains(q, ignoreCase = true) }
179 }
180 val ranges = remember {
181 listOf(
182 UsageTimeRange.TWENTY_FOUR_HOURS to R.string.usage_range_24h,
183 UsageTimeRange.SEVEN_DAYS to R.string.usage_range_7d,
184 UsageTimeRange.THIRTY_DAYS to R.string.usage_range_30d,
185 UsageTimeRange.ALL_TIME to R.string.usage_range_all,
186 )
187 }
188 LazyColumn(
189 modifier = Modifier.fillMaxSize(),
190 contentPadding = PaddingValues(16.dp),
191 verticalArrangement = Arrangement.spacedBy(12.dp),
192 ) {
193 // Time-range filter: narrows the totals AND the breakdowns to the selected window, so
194 // "how much did I spend today?" is answerable instead of drowning in all-time history.
195 item {
196 FlowRow(
197 modifier = Modifier.fillMaxWidth(),
198 horizontalArrangement = Arrangement.spacedBy(8.dp),
199 ) {
200 ranges.forEach { (range, labelRes) ->
201 FilterChip(
202 selected = timeRange == range,
203 onClick = { onTimeRangeChange(range) },
204 label = { Text(stringResource(labelRes)) },
205 )
206 }
207 }
208 }
209 item { TotalsCard(report, timeRange) }
210 item { TokenBreakdownCard(report.totalTokens) }
211 if (report.byModel.isNotEmpty()) {
212 item { SectionHeader(stringResource(R.string.usage_by_model), Modifier.semantics { heading() }) }
213 // Key on provider+model: the same model id served by two providers yields two
214 // rows, so keying on the bare model would duplicate keys and crash the list.
215 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
216 UsageRow(
217 title = m.provider + " / " + m.model,
218 cost = m.cost,
219 tokens = m.tokens,
220 messages = m.messages,
221 costFraction = fractionOf(m.cost, report.totalCost),
222 modifier = Modifier.then(reducedMotionAnimateItem()),
223 )
224 }
225 }
226 if (report.bySession.isNotEmpty()) {
227 item { SectionHeader(stringResource(R.string.usage_all_sessions), Modifier.semantics { heading() }) }
228 // Only show the filter field once there are enough sessions to justify it, so a
229 // short history isn't cluttered with a search bar.
230 if (report.bySession.size >= NetworkConfig.usageSessionSearchThreshold) {
231 item(key = "__session_search") {
232 OutlinedTextField(
233 value = sessionQuery,
234 onValueChange = { sessionQuery = it },
235 modifier = Modifier.fillMaxWidth(),
236 placeholder = { Text(stringResource(R.string.usage_filter_sessions)) },
237 singleLine = true,
238 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
239 trailingIcon = {
240 if (sessionQuery.isNotEmpty()) {
241 IconButton(onClick = { sessionQuery = "" }) {
242 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
243 }
244 }
245 },
246 )
247 }
248 }
249 if (sessionQuery.isNotBlank() && filteredSessions.isEmpty()) {
250 item(key = "__no_match") {
251 EmptyState(
252 icon = Icons.Filled.SearchOff,
253 title = stringResource(R.string.usage_no_sessions),
254 modifier = Modifier.fillMaxWidth().padding(24.dp),
255 )
256 }
257 }
258 items(filteredSessions, key = { it.sessionId }) { s ->
259 UsageRow(
260 title = s.title,
261 cost = s.cost,
262 tokens = s.tokens,
263 messages = s.messages,
264 costFraction = fractionOf(s.cost, report.totalCost),
265 onClick = { onOpenSession(s.sessionId) },
266 modifier = Modifier.then(reducedMotionAnimateItem()),
267 )
268 }
269 }
270 }
273private fun fractionOf(part: Double, total: Double): Float =
274 if (total > 0.0) (part / total).toFloat().coerceIn(0f, 1f) else 0f
276/** A slim horizontal bar showing this row's share of the total cost. */
277@Composable
278private fun CostBar(fraction: Float) {
279 val pct = (fraction * 100).toInt()
280 Box(
281 modifier = Modifier
282 .fillMaxWidth()
283 .height(4.dp)
284 .clip(MaterialTheme.shapes.small)
285 .background(MaterialTheme.colorScheme.surfaceVariant)
286 .semantics {
287 contentDescription = "${pct}% of total"
288 invisibleToUser()
289 },
290 ) {
291 if (fraction > 0f) {
292 Box(
293 modifier = Modifier
294 .fillMaxWidth(fraction)
295 .height(4.dp)
296 .clip(MaterialTheme.shapes.small)
297 .background(MaterialTheme.colorScheme.primary),
298 )
299 }
300 }
303@Composable
304private fun TotalsCard(report: UsageReport, timeRange: UsageTimeRange) {
305 val rangeLabelRes = when (timeRange) {
306 UsageTimeRange.TWENTY_FOUR_HOURS -> R.string.usage_range_24h
307 UsageTimeRange.SEVEN_DAYS -> R.string.usage_range_7d
308 UsageTimeRange.THIRTY_DAYS -> R.string.usage_range_30d
309 UsageTimeRange.ALL_TIME -> R.string.usage_range_all
310 }
311 Card(modifier = Modifier.fillMaxWidth()) {
312 Column(Modifier.padding(16.dp)) {
313 Text(
314 stringResource(R.string.usage_total_cost_with_range, stringResource(rangeLabelRes)),
315 style = MaterialTheme.typography.labelMedium,
316 color = MaterialTheme.colorScheme.onSurfaceVariant,
317 )
318 Text(
319 formatCost(report.totalCost),
320 style = MaterialTheme.typography.headlineMedium,
321 color = MaterialTheme.colorScheme.primary,
322 )
323 Spacer(Modifier.size(8.dp))
324 Text(
325 pluralStringResource(R.plurals.usage_messages_count, report.messageCount, report.messageCount),
326 style = MaterialTheme.typography.bodySmall,
327 color = MaterialTheme.colorScheme.onSurfaceVariant,
328 )
329 Text(
330 stringResource(R.string.usage_total_tokens) + ": " + formatInt(report.totalTokens.total),
331 style = MaterialTheme.typography.bodySmall,
332 color = MaterialTheme.colorScheme.onSurfaceVariant,
333 )
334 }
335 }
338@Composable
339private fun TokenBreakdownCard(tokens: Tokens) {
340 Card(modifier = Modifier.fillMaxWidth()) {
341 Column(Modifier.padding(16.dp)) {
342 StatRow(stringResource(R.string.usage_input), formatInt(tokens.input))
343 StatRow(stringResource(R.string.usage_output), formatInt(tokens.output))
344 if (tokens.reasoning > 0) StatRow(stringResource(R.string.usage_reasoning), formatInt(tokens.reasoning))
345 if (tokens.cache.read > 0 || tokens.cache.write > 0) {
346 StatRow(stringResource(R.string.usage_cache), formatInt(tokens.cache.read + tokens.cache.write))
347 }
348 }
349 }
352@Composable
353private fun StatRow(label: String, value: String) {
354 Row(
355 modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
356 horizontalArrangement = Arrangement.SpaceBetween,
357 ) {
358 Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
359 Text(value, style = MaterialTheme.typography.bodyMedium)
360 }
363@Composable
364private fun UsageRow(
365 title: String,
366 cost: Double,
367 tokens: Tokens,
368 messages: Int,
369 costFraction: Float,
370 onClick: (() -> Unit)? = null,
371 modifier: Modifier = Modifier,
372) {
373 val rowModifier = modifier
374 .fillMaxWidth()
375 .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
376 .padding(vertical = 6.dp)
377 Column(modifier = rowModifier) {
378 Row(verticalAlignment = Alignment.CenterVertically) {
379 Column(modifier = Modifier.weight(1f)) {
380 Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
381 Text(
382 pluralStringResource(R.plurals.usage_messages_count, messages, messages) + " · " + formatInt(tokens.total),
383 style = MaterialTheme.typography.labelSmall,
384 color = MaterialTheme.colorScheme.onSurfaceVariant,
385 )
386 }
387 Text(
388 formatCost(cost),
389 style = MaterialTheme.typography.bodyMedium,
390 color = MaterialTheme.colorScheme.primary,
391 modifier = Modifier.padding(start = 12.dp),
392 )
393 // Chevron signals that tapping a session row navigates to it (mirrors the M3
394 // list-item pattern for navigable rows), so the affordance isn't hidden behind
395 // just the click ripple.
396 if (onClick != null) {
397 Icon(
398 Icons.AutoMirrored.Filled.KeyboardArrowRight,
399 contentDescription = null,
400 tint = MaterialTheme.colorScheme.onSurfaceVariant,
401 modifier = Modifier.size(20.dp),
402 )
403 }
404 }
405 Spacer(Modifier.size(6.dp))
406 CostBar(costFraction)
407 }
410// Locale.US so amounts/counts format identically regardless of device locale (stable digits
411// and a dot decimal for a dollar figure). Sub-cent totals get more precision so a tiny run
412// doesn't collapse to "$0.00".
413private fun formatCost(cost: Double): String =
414 if (cost in 0.0..0.01) String.format(Locale.US, "$%.4f", cost)
415 else String.format(Locale.US, "$%.2f", cost)
417private val integerFormat: NumberFormat = NumberFormat.getIntegerInstance(Locale.US)
419private fun formatInt(n: Long): String = integerFormat.format(n)