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