@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.usage
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.layout.Arrangement
45import androidx.compose.foundation.layout.Box
56import androidx.compose.foundation.layout.Column
@@ -8,13 +9,16 @@ import androidx.compose.foundation.layout.Row
89import androidx.compose.foundation.layout.Spacer
910import androidx.compose.foundation.layout.fillMaxSize
1011import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.height
1113import androidx.compose.foundation.layout.padding
1214import androidx.compose.foundation.layout.size
1315import androidx.compose.foundation.lazy.LazyColumn
1416import androidx.compose.foundation.lazy.items
1517import 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
1621import androidx.compose.material.icons.filled.Refresh
17−import androidx.compose.material3.Button
1822import androidx.compose.material3.Card
1923import androidx.compose.material3.CircularProgressIndicator
2024import androidx.compose.material3.ExperimentalMaterial3Api
@@ -23,11 +27,15 @@ import androidx.compose.material3.IconButton
2327import androidx.compose.material3.MaterialTheme
2428import androidx.compose.material3.Scaffold
2529import androidx.compose.material3.Text
30+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
2631import androidx.compose.runtime.Composable
2732import androidx.compose.runtime.getValue
2833import androidx.compose.ui.Alignment
2934import androidx.compose.ui.Modifier
35+import androidx.compose.ui.draw.clip
3036import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.semantics
3139import androidx.compose.ui.text.style.TextOverflow
3240import androidx.compose.ui.unit.dp
3341import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -36,6 +44,7 @@ import soy.iko.opencode.R
3644import soy.iko.opencode.data.model.Tokens
3745import soy.iko.opencode.di.AppContainer
3846import soy.iko.opencode.ui.components.AppTopBar
47+import soy.iko.opencode.ui.components.EmptyState
3948import soy.iko.opencode.ui.components.SectionHeader
4049import soy.iko.opencode.ui.vmFactory
4150import java.text.NumberFormat
@@ -46,6 +55,7 @@ import java.util.Locale
4655fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
4756 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
4857 val state by vm.state.collectAsStateWithLifecycle()
58+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
4959
5060 Scaffold(
5161 topBar = {
@@ -60,29 +70,44 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
6070 )
6171 },
6272 ) { padding ->
63− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
64− when (val s = state) {
65− is UsageViewModel.State.Loading -> Centered {
66− CircularProgressIndicator()
67− Spacer(Modifier.size(12.dp))
68− Text(stringResource(R.string.usage_loading), color = MaterialTheme.colorScheme.onSurfaceVariant)
69− }
70− is UsageViewModel.State.Disconnected -> Centered {
71− Text(stringResource(R.string.not_connected), color = MaterialTheme.colorScheme.onSurfaceVariant)
72− }
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)) }
77− }
78− is UsageViewModel.State.Ready ->
79− if (s.report.isEmpty) {
80− Centered {
81− Text(stringResource(R.string.usage_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
82− }
83− } else {
84− UsageContent(s.report)
73+ PullToRefreshBox(
74+ isRefreshing = refreshing,
75+ onRefresh = { vm.load() },
76+ modifier = Modifier.fillMaxSize().padding(padding),
77+ ) {
78+ Box(modifier = Modifier.fillMaxSize()) {
79+ val loadingLabel = stringResource(R.string.usage_loading)
80+ when (val s = state) {
81+ is UsageViewModel.State.Loading -> Centered {
82+ CircularProgressIndicator(
83+ Modifier.semantics { contentDescription = loadingLabel },
84+ )
85+ Spacer(Modifier.size(12.dp))
86+ Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
8587 }
88+ is UsageViewModel.State.Disconnected -> EmptyState(
89+ icon = Icons.Filled.CloudOff,
90+ title = stringResource(R.string.not_connected),
91+ modifier = Modifier.align(Alignment.Center),
92+ )
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() },
99+ )
100+ is UsageViewModel.State.Ready ->
101+ if (s.report.isEmpty) {
102+ EmptyState(
103+ icon = Icons.Filled.QueryStats,
104+ title = stringResource(R.string.usage_empty),
105+ modifier = Modifier.align(Alignment.Center),
106+ )
107+ } else {
108+ UsageContent(s.report)
109+ }
110+ }
86111 }
87112 }
88113 }
@@ -109,18 +134,55 @@ private fun UsageContent(report: UsageReport) {
109134 // Key on provider+model: the same model id served by two providers yields two
110135 // rows, so keying on the bare model would duplicate keys and crash the list.
111136 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)
137+ UsageRow(
138+ title = m.provider + " / " + m.model,
139+ cost = m.cost,
140+ tokens = m.tokens,
141+ messages = m.messages,
142+ costFraction = fractionOf(m.cost, report.totalCost),
143+ )
113144 }
114145 }
115146 if (report.bySession.isNotEmpty()) {
116147 item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
117148 items(report.bySession, key = { it.sessionId }) { s ->
118− UsageRow(title = s.title, cost = s.cost, tokens = s.tokens, messages = s.messages)
149+ UsageRow(
150+ title = s.title,
151+ cost = s.cost,
152+ tokens = s.tokens,
153+ messages = s.messages,
154+ costFraction = fractionOf(s.cost, report.totalCost),
155+ )
119156 }
120157 }
121158 }
122159}
123160
161+private fun fractionOf(part: Double, total: Double): Float =
162+ if (total > 0.0) (part / total).toFloat().coerceIn(0f, 1f) else 0f
163+
164+/** A slim horizontal bar showing this row's share of the total cost. */
165+@Composable
166+private fun CostBar(fraction: Float) {
167+ Box(
168+ modifier = Modifier
169+ .fillMaxWidth()
170+ .height(4.dp)
171+ .clip(MaterialTheme.shapes.small)
172+ .background(MaterialTheme.colorScheme.surfaceVariant),
173+ ) {
174+ if (fraction > 0f) {
175+ Box(
176+ modifier = Modifier
177+ .fillMaxWidth(fraction)
178+ .height(4.dp)
179+ .clip(MaterialTheme.shapes.small)
180+ .background(MaterialTheme.colorScheme.primary),
181+ )
182+ }
183+ }
184+}
185+
124186@Composable
125187private fun TotalsCard(report: UsageReport) {
126188 Card(modifier = Modifier.fillMaxWidth()) {
@@ -176,25 +238,26 @@ private fun StatRow(label: String, value: String) {
176238}
177239
178240@Composable
179−private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
180− Row(
181− modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
182− verticalAlignment = Alignment.CenterVertically,
183− ) {
184− Column(modifier = Modifier.weight(1f)) {
185− Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
241+private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int, costFraction: Float) {
242+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
243+ Row(verticalAlignment = Alignment.CenterVertically) {
244+ Column(modifier = Modifier.weight(1f)) {
245+ Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
246+ Text(
247+ stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
248+ style = MaterialTheme.typography.labelSmall,
249+ color = MaterialTheme.colorScheme.onSurfaceVariant,
250+ )
251+ }
186252 Text(
187− stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
188− style = MaterialTheme.typography.labelSmall,
189− color = MaterialTheme.colorScheme.onSurfaceVariant,
253+ formatCost(cost),
254+ style = MaterialTheme.typography.bodyMedium,
255+ color = MaterialTheme.colorScheme.primary,
256+ modifier = Modifier.padding(start = 12.dp),
190257 )
191258 }
192− Text(
193− formatCost(cost),
194− style = MaterialTheme.typography.bodyMedium,
195− color = MaterialTheme.colorScheme.primary,
196− modifier = Modifier.padding(start = 12.dp),
197− )
259+ Spacer(Modifier.size(6.dp))
260+ CostBar(costFraction)
198261 }
199262}
200263