git.iko.soy/opencode-android
on 066cded help sign in
288 lines 12 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.CloudOff
20import androidx.compose.material.icons.filled.ErrorOutline
21import androidx.compose.material.icons.filled.QueryStats
22import androidx.compose.material.icons.filled.Refresh
23import androidx.compose.material3.Card
24import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.ExperimentalMaterial3Api
26import androidx.compose.material3.Icon
27import androidx.compose.material3.IconButton
28import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.Scaffold
30import androidx.compose.material3.Text
31import androidx.compose.material3.pulltorefresh.PullToRefreshBox
32import androidx.compose.runtime.Composable
33import androidx.compose.runtime.getValue
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.draw.clip
37import androidx.compose.ui.res.pluralStringResource
38import androidx.compose.ui.res.stringResource
39import androidx.compose.ui.semantics.Role
40import androidx.compose.ui.semantics.contentDescription
41import androidx.compose.ui.semantics.semantics
42import androidx.compose.ui.text.style.TextOverflow
43import androidx.compose.ui.unit.dp
44import androidx.lifecycle.compose.collectAsStateWithLifecycle
45import androidx.lifecycle.viewmodel.compose.viewModel
46import soy.iko.opencode.R
47import soy.iko.opencode.data.model.Tokens
48import soy.iko.opencode.di.AppContainer
49import soy.iko.opencode.ui.components.AppTopBar
50import soy.iko.opencode.ui.components.EmptyState
51import soy.iko.opencode.ui.components.SectionHeader
52import soy.iko.opencode.ui.vmFactory
53import java.text.NumberFormat
54import java.util.Locale
56@OptIn(ExperimentalMaterial3Api::class)
57@Composable
58fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
59 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
60 val state by vm.state.collectAsStateWithLifecycle()
61 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
63 Scaffold(
64 topBar = {
65 AppTopBar(
66 title = stringResource(R.string.usage_title),
67 onBack = onBack,
68 actions = {
69 IconButton(onClick = { vm.load() }) {
70 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
71 }
72 },
73 )
74 },
75 ) { padding ->
76 PullToRefreshBox(
77 isRefreshing = refreshing,
78 onRefresh = { vm.load() },
79 modifier = Modifier.fillMaxSize().padding(padding),
80 ) {
81 Box(modifier = Modifier.fillMaxSize()) {
82 val loadingLabel = stringResource(R.string.usage_loading)
83 when (val s = state) {
84 is UsageViewModel.State.Loading -> Centered {
85 CircularProgressIndicator(
86 Modifier.semantics { contentDescription = loadingLabel },
87 )
88 Spacer(Modifier.size(12.dp))
89 Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
90 }
91 is UsageViewModel.State.Disconnected -> EmptyState(
92 icon = Icons.Filled.CloudOff,
93 title = stringResource(R.string.not_connected),
94 modifier = Modifier.align(Alignment.Center),
95 )
96 is UsageViewModel.State.Error -> EmptyState(
97 icon = Icons.Filled.ErrorOutline,
98 title = stringResource(R.string.usage_failed),
99 modifier = Modifier.align(Alignment.Center),
100 actionLabel = stringResource(R.string.retry),
101 onAction = { vm.load() },
102 )
103 is UsageViewModel.State.Ready ->
104 if (s.report.isEmpty) {
105 EmptyState(
106 icon = Icons.Filled.QueryStats,
107 title = stringResource(R.string.usage_empty),
108 modifier = Modifier.align(Alignment.Center),
109 )
110 } else {
111 UsageContent(s.report, onOpenSession)
112 }
113 }
114 }
115 }
116 }
119@Composable
120private fun Centered(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {
121 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
122 Column(horizontalAlignment = Alignment.CenterHorizontally, content = content)
123 }
126@Composable
127private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
128 LazyColumn(
129 modifier = Modifier.fillMaxSize(),
130 contentPadding = PaddingValues(16.dp),
131 verticalArrangement = Arrangement.spacedBy(12.dp),
132 ) {
133 item { TotalsCard(report) }
134 item { TokenBreakdownCard(report.totalTokens) }
135 if (report.byModel.isNotEmpty()) {
136 item { SectionHeader(stringResource(R.string.usage_by_model)) }
137 // Key on provider+model: the same model id served by two providers yields two
138 // rows, so keying on the bare model would duplicate keys and crash the list.
139 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
140 UsageRow(
141 title = m.provider + " / " + m.model,
142 cost = m.cost,
143 tokens = m.tokens,
144 messages = m.messages,
145 costFraction = fractionOf(m.cost, report.totalCost),
146 )
147 }
148 }
149 if (report.bySession.isNotEmpty()) {
150 item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
151 items(report.bySession, key = { it.sessionId }) { s ->
152 UsageRow(
153 title = s.title,
154 cost = s.cost,
155 tokens = s.tokens,
156 messages = s.messages,
157 costFraction = fractionOf(s.cost, report.totalCost),
158 onClick = { onOpenSession(s.sessionId) },
159 )
160 }
161 }
162 }
165private fun fractionOf(part: Double, total: Double): Float =
166 if (total > 0.0) (part / total).toFloat().coerceIn(0f, 1f) else 0f
168/** A slim horizontal bar showing this row's share of the total cost. */
169@Composable
170private fun CostBar(fraction: Float) {
171 Box(
172 modifier = Modifier
173 .fillMaxWidth()
174 .height(4.dp)
175 .clip(MaterialTheme.shapes.small)
176 .background(MaterialTheme.colorScheme.surfaceVariant),
177 ) {
178 if (fraction > 0f) {
179 Box(
180 modifier = Modifier
181 .fillMaxWidth(fraction)
182 .height(4.dp)
183 .clip(MaterialTheme.shapes.small)
184 .background(MaterialTheme.colorScheme.primary),
185 )
186 }
187 }
190@Composable
191private fun TotalsCard(report: UsageReport) {
192 Card(modifier = Modifier.fillMaxWidth()) {
193 Column(Modifier.padding(16.dp)) {
194 Text(
195 stringResource(R.string.usage_total_cost),
196 style = MaterialTheme.typography.labelMedium,
197 color = MaterialTheme.colorScheme.onSurfaceVariant,
198 )
199 Text(
200 formatCost(report.totalCost),
201 style = MaterialTheme.typography.headlineMedium,
202 color = MaterialTheme.colorScheme.primary,
203 )
204 Spacer(Modifier.size(8.dp))
205 Text(
206 pluralStringResource(R.plurals.usage_messages_count, report.messageCount, report.messageCount),
207 style = MaterialTheme.typography.bodySmall,
208 color = MaterialTheme.colorScheme.onSurfaceVariant,
209 )
210 Text(
211 stringResource(R.string.usage_total_tokens) + ": " + formatInt(report.totalTokens.total),
212 style = MaterialTheme.typography.bodySmall,
213 color = MaterialTheme.colorScheme.onSurfaceVariant,
214 )
215 }
216 }
219@Composable
220private fun TokenBreakdownCard(tokens: Tokens) {
221 Card(modifier = Modifier.fillMaxWidth()) {
222 Column(Modifier.padding(16.dp)) {
223 StatRow(stringResource(R.string.usage_input), formatInt(tokens.input))
224 StatRow(stringResource(R.string.usage_output), formatInt(tokens.output))
225 if (tokens.reasoning > 0) StatRow(stringResource(R.string.usage_reasoning), formatInt(tokens.reasoning))
226 if (tokens.cache.read > 0 || tokens.cache.write > 0) {
227 StatRow(stringResource(R.string.usage_cache), formatInt(tokens.cache.read + tokens.cache.write))
228 }
229 }
230 }
233@Composable
234private fun StatRow(label: String, value: String) {
235 Row(
236 modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
237 horizontalArrangement = Arrangement.SpaceBetween,
238 ) {
239 Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
240 Text(value, style = MaterialTheme.typography.bodyMedium)
241 }
244@Composable
245private fun UsageRow(
246 title: String,
247 cost: Double,
248 tokens: Tokens,
249 messages: Int,
250 costFraction: Float,
251 onClick: (() -> Unit)? = null,
252) {
253 val rowModifier = Modifier
254 .fillMaxWidth()
255 .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
256 .padding(vertical = 6.dp)
257 Column(modifier = rowModifier) {
258 Row(verticalAlignment = Alignment.CenterVertically) {
259 Column(modifier = Modifier.weight(1f)) {
260 Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
261 Text(
262 pluralStringResource(R.plurals.usage_messages_count, messages, messages) + " · " + formatInt(tokens.total),
263 style = MaterialTheme.typography.labelSmall,
264 color = MaterialTheme.colorScheme.onSurfaceVariant,
265 )
266 }
267 Text(
268 formatCost(cost),
269 style = MaterialTheme.typography.bodyMedium,
270 color = MaterialTheme.colorScheme.primary,
271 modifier = Modifier.padding(start = 12.dp),
272 )
273 }
274 Spacer(Modifier.size(6.dp))
275 CostBar(costFraction)
276 }
279// Locale.US so amounts/counts format identically regardless of device locale (stable digits
280// and a dot decimal for a dollar figure). Sub-cent totals get more precision so a tiny run
281// doesn't collapse to "$0.00".
282private fun formatCost(cost: Double): String =
283 if (cost in 0.0..0.01) String.format(Locale.US, "$%.4f", cost)
284 else String.format(Locale.US, "$%.2f", cost)
286private val integerFormat: NumberFormat = NumberFormat.getIntegerInstance(Locale.US)
288private fun formatInt(n: Long): String = integerFormat.format(n)