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