@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.usage
1package soy.iko.opencode.ui.usage
3+import androidx.compose.foundation.background
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
@@ -8,13 +9,16 @@ import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.height
11import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
16import androidx.compose.foundation.lazy.items
15import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.Icons
18+import androidx.compose.material.icons.filled.CloudOff
19+import androidx.compose.material.icons.filled.ErrorOutline
20+import androidx.compose.material.icons.filled.QueryStats
16import androidx.compose.material.icons.filled.Refresh
21import androidx.compose.material.icons.filled.Refresh
17−import androidx.compose.material3.Button
18import androidx.compose.material3.Card
22import androidx.compose.material3.Card
19import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.ExperimentalMaterial3Api
@@ -23,11 +27,15 @@ import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
27import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.Scaffold
28import androidx.compose.material3.Scaffold
25import androidx.compose.material3.Text
29import androidx.compose.material3.Text
30+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
26import androidx.compose.runtime.Composable
31import androidx.compose.runtime.Composable
27import androidx.compose.runtime.getValue
32import androidx.compose.runtime.getValue
28import androidx.compose.ui.Alignment
33import androidx.compose.ui.Alignment
29import androidx.compose.ui.Modifier
34import androidx.compose.ui.Modifier
35+import androidx.compose.ui.draw.clip
30import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.semantics
31import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.text.style.TextOverflow
32import androidx.compose.ui.unit.dp
40import androidx.compose.ui.unit.dp
33import androidx.lifecycle.compose.collectAsStateWithLifecycle
41import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -36,6 +44,7 @@ import soy.iko.opencode.R
36import soy.iko.opencode.data.model.Tokens
44import soy.iko.opencode.data.model.Tokens
37import soy.iko.opencode.di.AppContainer
45import soy.iko.opencode.di.AppContainer
38import soy.iko.opencode.ui.components.AppTopBar
46import soy.iko.opencode.ui.components.AppTopBar
47+import soy.iko.opencode.ui.components.EmptyState
39import soy.iko.opencode.ui.components.SectionHeader
48import soy.iko.opencode.ui.components.SectionHeader
40import soy.iko.opencode.ui.vmFactory
49import soy.iko.opencode.ui.vmFactory
41import java.text.NumberFormat
50import java.text.NumberFormat
@@ -46,6 +55,7 @@ import java.util.Locale
46fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
55fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
47 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
56 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
48 val state by vm.state.collectAsStateWithLifecycle()
57 val state by vm.state.collectAsStateWithLifecycle()
58+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
51 topBar = {
61 topBar = {
@@ -60,29 +70,44 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
62 ) { padding ->
72 ) { padding ->
74+ isRefreshing = refreshing,
75+ onRefresh = { vm.load() },
76+ modifier = Modifier.fillMaxSize().padding(padding),
63− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
78+ Box(modifier = Modifier.fillMaxSize()) {
79+ val loadingLabel = stringResource(R.string.usage_loading)
64− when (val s = state) {
80+ when (val s = state) {
65− is UsageViewModel.State.Loading -> Centered {
81+ is UsageViewModel.State.Loading -> Centered {
66− CircularProgressIndicator()
82+ CircularProgressIndicator(
67− Spacer(Modifier.size(12.dp))
83+ Modifier.semantics { contentDescription = loadingLabel },
68− Text(stringResource(R.string.usage_loading), color = MaterialTheme.colorScheme.onSurfaceVariant)
84+ )
69− }
85+ Spacer(Modifier.size(12.dp))
70− is UsageViewModel.State.Disconnected -> Centered {
71− Text(stringResource(R.string.not_connected), color = MaterialTheme.colorScheme.onSurfaceVariant)
73− is UsageViewModel.State.Error -> Centered {
74− Text(stringResource(R.string.usage_failed), color = MaterialTheme.colorScheme.error)
75− Spacer(Modifier.size(12.dp))
76− Button(onClick = { vm.load() }) { Text(stringResource(R.string.retry)) }
78− is UsageViewModel.State.Ready ->
79− if (s.report.isEmpty) {
81− Text(stringResource(R.string.usage_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
86+ Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
84− UsageContent(s.report)
88+ is UsageViewModel.State.Disconnected -> EmptyState(
89+ icon = Icons.Filled.CloudOff,
90+ title = stringResource(R.string.not_connected),
91+ modifier = Modifier.align(Alignment.Center),
93+ is UsageViewModel.State.Error -> EmptyState(
94+ icon = Icons.Filled.ErrorOutline,
95+ title = stringResource(R.string.usage_failed),
96+ modifier = Modifier.align(Alignment.Center),
97+ actionLabel = stringResource(R.string.retry),
98+ onAction = { vm.load() },
100+ is UsageViewModel.State.Ready ->
101+ if (s.report.isEmpty) {
103+ icon = Icons.Filled.QueryStats,
104+ title = stringResource(R.string.usage_empty),
105+ modifier = Modifier.align(Alignment.Center),
108+ UsageContent(s.report)
@@ -109,18 +134,55 @@ private fun UsageContent(report: UsageReport) {
109 // Key on provider+model: the same model id served by two providers yields two
134 // Key on provider+model: the same model id served by two providers yields two
110 // rows, so keying on the bare model would duplicate keys and crash the list.
135 // rows, so keying on the bare model would duplicate keys and crash the list.
111 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
136 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
112− UsageRow(title = m.provider + " / " + m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
138+ title = m.provider + " / " + m.model,
141+ messages = m.messages,
142+ costFraction = fractionOf(m.cost, report.totalCost),
115 if (report.bySession.isNotEmpty()) {
146 if (report.bySession.isNotEmpty()) {
116 item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
147 item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
117 items(report.bySession, key = { it.sessionId }) { s ->
148 items(report.bySession, key = { it.sessionId }) { s ->
118− UsageRow(title = s.title, cost = s.cost, tokens = s.tokens, messages = s.messages)
149+ UsageRow(
153+ messages = s.messages,
154+ costFraction = fractionOf(s.cost, report.totalCost),
161+private fun fractionOf(part: Double, total: Double): Float =
162+ if (total > 0.0) (part / total).toFloat().coerceIn(0f, 1f) else 0f
164+/** A slim horizontal bar showing this row's share of the total cost. */
166+private fun CostBar(fraction: Float) {
171+ .clip(MaterialTheme.shapes.small)
172+ .background(MaterialTheme.colorScheme.surfaceVariant),
174+ if (fraction > 0f) {
177+ .fillMaxWidth(fraction)
179+ .clip(MaterialTheme.shapes.small)
180+ .background(MaterialTheme.colorScheme.primary),
124@Composable
186@Composable
125private fun TotalsCard(report: UsageReport) {
187private fun TotalsCard(report: UsageReport) {
126 Card(modifier = Modifier.fillMaxWidth()) {
188 Card(modifier = Modifier.fillMaxWidth()) {
@@ -176,25 +238,26 @@ private fun StatRow(label: String, value: String) {
178@Composable
240@Composable
179−private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
241+private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int, costFraction: Float) {
181− modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
242+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
182− verticalAlignment = Alignment.CenterVertically,
243+ Row(verticalAlignment = Alignment.CenterVertically) {
184− Column(modifier = Modifier.weight(1f)) {
244+ Column(modifier = Modifier.weight(1f)) {
185− Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
245+ Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
247+ stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
248+ style = MaterialTheme.typography.labelSmall,
249+ color = MaterialTheme.colorScheme.onSurfaceVariant,
187− stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
253+ formatCost(cost),
188− style = MaterialTheme.typography.labelSmall,
254+ style = MaterialTheme.typography.bodyMedium,
189− color = MaterialTheme.colorScheme.onSurfaceVariant,
255+ color = MaterialTheme.colorScheme.primary,
256+ modifier = Modifier.padding(start = 12.dp),
192− Text(
259+ Spacer(Modifier.size(6.dp))
193− formatCost(cost),
260+ CostBar(costFraction)
194− style = MaterialTheme.typography.bodyMedium,
195− color = MaterialTheme.colorScheme.primary,
196− modifier = Modifier.padding(start = 12.dp),