git.iko.soy/opencode-android
on 5d56632 help sign in
226 lines 9.6 kB history raw
1package soy.iko.opencode.ui.usage
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.filled.Refresh
18import androidx.compose.material3.Button
19import androidx.compose.material3.Card
20import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.ExperimentalMaterial3Api
22import androidx.compose.material3.Icon
23import androidx.compose.material3.IconButton
24import androidx.compose.material3.MaterialTheme
25import androidx.compose.material3.Scaffold
26import androidx.compose.material3.Text
27import androidx.compose.material3.TopAppBar
28import androidx.compose.runtime.Composable
29import androidx.compose.runtime.getValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.semantics.heading
34import androidx.compose.ui.semantics.semantics
35import androidx.compose.ui.text.style.TextOverflow
36import androidx.compose.ui.unit.dp
37import androidx.lifecycle.compose.collectAsStateWithLifecycle
38import androidx.lifecycle.viewmodel.compose.viewModel
39import soy.iko.opencode.R
40import soy.iko.opencode.data.model.Tokens
41import soy.iko.opencode.di.AppContainer
42import soy.iko.opencode.ui.vmFactory
43import java.text.NumberFormat
44import java.util.Locale
46@OptIn(ExperimentalMaterial3Api::class)
47@Composable
48fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
49 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
50 val state by vm.state.collectAsStateWithLifecycle()
52 Scaffold(
53 topBar = {
54 TopAppBar(
55 title = { Text(stringResource(R.string.usage_title)) },
56 navigationIcon = {
57 IconButton(onClick = onBack) {
58 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
59 }
60 },
61 actions = {
62 IconButton(onClick = { vm.load() }) {
63 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
64 }
65 },
66 )
67 },
68 ) { padding ->
69 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
70 when (val s = state) {
71 is UsageViewModel.State.Loading -> Centered {
72 CircularProgressIndicator()
73 Spacer(Modifier.size(12.dp))
74 Text(stringResource(R.string.usage_loading), color = MaterialTheme.colorScheme.onSurfaceVariant)
75 }
76 is UsageViewModel.State.Disconnected -> Centered {
77 Text(stringResource(R.string.not_connected), color = MaterialTheme.colorScheme.onSurfaceVariant)
78 }
79 is UsageViewModel.State.Error -> Centered {
80 Text(stringResource(R.string.usage_failed), color = MaterialTheme.colorScheme.error)
81 Spacer(Modifier.size(12.dp))
82 Button(onClick = { vm.load() }) { Text(stringResource(R.string.retry)) }
83 }
84 is UsageViewModel.State.Ready ->
85 if (s.report.isEmpty) {
86 Centered {
87 Text(stringResource(R.string.usage_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
88 }
89 } else {
90 UsageContent(s.report)
91 }
92 }
93 }
94 }
97@Composable
98private fun Centered(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {
99 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
100 Column(horizontalAlignment = Alignment.CenterHorizontally, content = content)
101 }
104@Composable
105private fun UsageContent(report: UsageReport) {
106 LazyColumn(
107 modifier = Modifier.fillMaxSize(),
108 contentPadding = PaddingValues(16.dp),
109 verticalArrangement = Arrangement.spacedBy(12.dp),
110 ) {
111 item { TotalsCard(report) }
112 item { TokenBreakdownCard(report.totalTokens) }
113 if (report.byModel.isNotEmpty()) {
114 item { SectionHeader(stringResource(R.string.usage_by_model)) }
115 // Key on provider+model: the same model id served by two providers yields two
116 // rows, so keying on the bare model would duplicate keys and crash the list.
117 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
118 UsageRow(title = m.provider + " / " + m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
119 }
120 }
121 if (report.bySession.isNotEmpty()) {
122 item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
123 items(report.bySession, key = { it.sessionId }) { s ->
124 UsageRow(title = s.title, cost = s.cost, tokens = s.tokens, messages = s.messages)
125 }
126 }
127 }
130@Composable
131private fun TotalsCard(report: UsageReport) {
132 Card(modifier = Modifier.fillMaxWidth()) {
133 Column(Modifier.padding(16.dp)) {
134 Text(
135 stringResource(R.string.usage_total_cost),
136 style = MaterialTheme.typography.labelMedium,
137 color = MaterialTheme.colorScheme.onSurfaceVariant,
138 )
139 Text(
140 formatCost(report.totalCost),
141 style = MaterialTheme.typography.headlineMedium,
142 color = MaterialTheme.colorScheme.primary,
143 )
144 Spacer(Modifier.size(8.dp))
145 Text(
146 stringResource(R.string.usage_messages_count, report.messageCount),
147 style = MaterialTheme.typography.bodySmall,
148 color = MaterialTheme.colorScheme.onSurfaceVariant,
149 )
150 Text(
151 stringResource(R.string.usage_total_tokens) + ": " + formatInt(report.totalTokens.total),
152 style = MaterialTheme.typography.bodySmall,
153 color = MaterialTheme.colorScheme.onSurfaceVariant,
154 )
155 }
156 }
159@Composable
160private fun TokenBreakdownCard(tokens: Tokens) {
161 Card(modifier = Modifier.fillMaxWidth()) {
162 Column(Modifier.padding(16.dp)) {
163 StatRow(stringResource(R.string.usage_input), formatInt(tokens.input))
164 StatRow(stringResource(R.string.usage_output), formatInt(tokens.output))
165 if (tokens.reasoning > 0) StatRow(stringResource(R.string.usage_reasoning), formatInt(tokens.reasoning))
166 if (tokens.cache.read > 0 || tokens.cache.write > 0) {
167 StatRow(stringResource(R.string.usage_cache), formatInt(tokens.cache.read + tokens.cache.write))
168 }
169 }
170 }
173@Composable
174private fun StatRow(label: String, value: String) {
175 Row(
176 modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
177 horizontalArrangement = Arrangement.SpaceBetween,
178 ) {
179 Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
180 Text(value, style = MaterialTheme.typography.bodyMedium)
181 }
184@Composable
185private fun SectionHeader(text: String) {
186 Text(
187 text,
188 style = MaterialTheme.typography.titleSmall,
189 color = MaterialTheme.colorScheme.primary,
190 modifier = Modifier.padding(top = 8.dp).semantics { heading() },
191 )
194@Composable
195private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
196 Row(
197 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
198 verticalAlignment = Alignment.CenterVertically,
199 ) {
200 Column(modifier = Modifier.weight(1f)) {
201 Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
202 Text(
203 stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
204 style = MaterialTheme.typography.labelSmall,
205 color = MaterialTheme.colorScheme.onSurfaceVariant,
206 )
207 }
208 Text(
209 formatCost(cost),
210 style = MaterialTheme.typography.bodyMedium,
211 color = MaterialTheme.colorScheme.primary,
212 modifier = Modifier.padding(start = 12.dp),
213 )
214 }
217// Locale.US so amounts/counts format identically regardless of device locale (stable digits
218// and a dot decimal for a dollar figure). Sub-cent totals get more precision so a tiny run
219// doesn't collapse to "$0.00".
220private fun formatCost(cost: Double): String =
221 if (cost in 0.0..0.01) String.format(Locale.US, "$%.4f", cost)
222 else String.format(Locale.US, "$%.2f", cost)
224private val integerFormat: NumberFormat = NumberFormat.getIntegerInstance(Locale.US)
226private fun formatInt(n: Long): String = integerFormat.format(n)