Skip to content

Commit e5cd050

Browse files
committed
refactor(android): split v2 shell screens
1 parent aca2236 commit e5cd050

3 files changed

Lines changed: 666 additions & 537 deletions

File tree

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
package ai.openclaw.app.ui
2+
3+
import ai.openclaw.app.GatewayModelProviderSummary
4+
import ai.openclaw.app.GatewayModelSummary
5+
import ai.openclaw.app.MainViewModel
6+
import ai.openclaw.app.providerDisplayName
7+
import ai.openclaw.app.ui.design.ClawEmptyState
8+
import ai.openclaw.app.ui.design.ClawPanel
9+
import ai.openclaw.app.ui.design.ClawScaffold
10+
import ai.openclaw.app.ui.design.ClawTheme
11+
import androidx.compose.foundation.BorderStroke
12+
import androidx.compose.foundation.background
13+
import androidx.compose.foundation.layout.Arrangement
14+
import androidx.compose.foundation.layout.Box
15+
import androidx.compose.foundation.layout.Column
16+
import androidx.compose.foundation.layout.PaddingValues
17+
import androidx.compose.foundation.layout.Row
18+
import androidx.compose.foundation.layout.Spacer
19+
import androidx.compose.foundation.layout.fillMaxSize
20+
import androidx.compose.foundation.layout.fillMaxWidth
21+
import androidx.compose.foundation.layout.height
22+
import androidx.compose.foundation.layout.padding
23+
import androidx.compose.foundation.layout.size
24+
import androidx.compose.foundation.layout.width
25+
import androidx.compose.foundation.lazy.LazyColumn
26+
import androidx.compose.foundation.lazy.items
27+
import androidx.compose.foundation.shape.CircleShape
28+
import androidx.compose.foundation.shape.RoundedCornerShape
29+
import androidx.compose.material.icons.Icons
30+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
31+
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
32+
import androidx.compose.material.icons.filled.Add
33+
import androidx.compose.material.icons.filled.KeyboardArrowDown
34+
import androidx.compose.material3.HorizontalDivider
35+
import androidx.compose.material3.Icon
36+
import androidx.compose.material3.Surface
37+
import androidx.compose.material3.Text
38+
import androidx.compose.runtime.Composable
39+
import androidx.compose.runtime.LaunchedEffect
40+
import androidx.compose.runtime.collectAsState
41+
import androidx.compose.runtime.getValue
42+
import androidx.compose.ui.Alignment
43+
import androidx.compose.ui.Modifier
44+
import androidx.compose.ui.draw.clip
45+
import androidx.compose.ui.graphics.Color
46+
import androidx.compose.ui.graphics.vector.ImageVector
47+
import androidx.compose.ui.text.style.TextAlign
48+
import androidx.compose.ui.text.style.TextOverflow
49+
import androidx.compose.ui.unit.dp
50+
import androidx.compose.ui.unit.sp
51+
52+
@Composable
53+
internal fun V2ProvidersModelsScreen(
54+
viewModel: MainViewModel,
55+
onBack: () -> Unit,
56+
onAddProvider: () -> Unit,
57+
) {
58+
val isConnected by viewModel.isConnected.collectAsState()
59+
val models by viewModel.modelCatalog.collectAsState()
60+
val providers by viewModel.modelAuthProviders.collectAsState()
61+
val refreshing by viewModel.modelCatalogRefreshing.collectAsState()
62+
val errorText by viewModel.modelCatalogErrorText.collectAsState()
63+
val providerRows = providerRows(providers = providers, models = models)
64+
val modelGroups = sortedModelGroups(models)
65+
66+
LaunchedEffect(isConnected) {
67+
if (isConnected) {
68+
viewModel.refreshModelCatalog()
69+
}
70+
}
71+
72+
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
73+
Box(modifier = Modifier.fillMaxSize()) {
74+
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 52.dp)) {
75+
item {
76+
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
77+
Row(
78+
modifier = Modifier.fillMaxWidth(),
79+
verticalAlignment = Alignment.CenterVertically,
80+
horizontalArrangement = Arrangement.SpaceBetween,
81+
) {
82+
V2ProviderHeaderIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
83+
V2ProviderHeaderIconButton(icon = Icons.Default.Add, contentDescription = "Add provider", outlined = true, onClick = onAddProvider)
84+
}
85+
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
86+
Text(text = "Providers & Models", style = ClawTheme.type.display.copy(fontSize = 14.8.sp, lineHeight = 18.sp), color = ClawTheme.colors.text, maxLines = 1)
87+
Text(
88+
text = "Connect and manage AI providers\nBrowse models and their capabilities.",
89+
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
90+
color = ClawTheme.colors.textMuted,
91+
)
92+
}
93+
}
94+
}
95+
96+
item {
97+
V2ProviderSectionLabel(title = "Providers")
98+
}
99+
100+
item {
101+
if (!isConnected && providerRows.isEmpty()) {
102+
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness and model catalog.")
103+
} else {
104+
V2ProviderList(rows = providerRows, refreshing = refreshing)
105+
}
106+
}
107+
108+
errorText?.let { message ->
109+
item {
110+
ClawPanel {
111+
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
112+
}
113+
}
114+
}
115+
116+
item {
117+
V2ProviderSectionLabel(title = "Model catalog")
118+
}
119+
120+
if (modelGroups.isEmpty()) {
121+
item {
122+
V2ModelCatalogEmpty(
123+
title = if (refreshing) "Loading models" else "No models loaded",
124+
body = if (isConnected) "Refresh after configuring a provider on the Gateway." else "Connect the Gateway to browse models.",
125+
)
126+
}
127+
} else {
128+
items(modelGroups, key = { it.first }) { entry ->
129+
V2ModelGroup(provider = entry.first, models = entry.second)
130+
}
131+
}
132+
}
133+
V2ProviderAddButton(onClick = onAddProvider, modifier = Modifier.align(Alignment.BottomCenter))
134+
}
135+
}
136+
}
137+
138+
private data class V2ProviderRow(
139+
val id: String,
140+
val name: String,
141+
val status: String,
142+
val ready: Boolean,
143+
val modelCount: Int,
144+
)
145+
146+
private fun providerRows(
147+
providers: List<GatewayModelProviderSummary>,
148+
models: List<GatewayModelSummary>,
149+
): List<V2ProviderRow> {
150+
val modelCounts = models.groupingBy { it.provider }.eachCount()
151+
val authRows =
152+
providers.map { provider ->
153+
val ready = modelProviderReady(provider.status)
154+
V2ProviderRow(
155+
id = provider.id,
156+
name = provider.displayName,
157+
status = if (ready) "Ready" else "Needs setup",
158+
ready = ready,
159+
modelCount = modelCounts[provider.id] ?: 0,
160+
)
161+
}
162+
val missingAuthRows =
163+
modelCounts.keys
164+
.filter { provider -> authRows.none { it.id == provider } }
165+
.map { provider ->
166+
V2ProviderRow(
167+
id = provider,
168+
name = providerDisplayName(provider),
169+
status = "Ready",
170+
ready = true,
171+
modelCount = modelCounts[provider] ?: 0,
172+
)
173+
}
174+
return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
175+
}
176+
177+
internal fun modelProviderReady(status: String): Boolean {
178+
val normalized = status.trim().lowercase()
179+
return normalized == "ok" || normalized == "ready" || normalized == "healthy" || normalized == "configured"
180+
}
181+
182+
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
183+
models
184+
.groupBy { it.provider }
185+
.entries
186+
.sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() }))
187+
.map { it.key to it.value }
188+
189+
private fun providerPriority(row: V2ProviderRow): Int = providerPriority(row.id)
190+
191+
private fun providerPriority(provider: String): Int =
192+
when (provider.trim().lowercase()) {
193+
"openai" -> 0
194+
"anthropic" -> 1
195+
"google" -> 2
196+
"openrouter" -> 3
197+
"ollama", "ollama-local" -> 4
198+
"codex", "openai-codex" -> 5
199+
else -> 100
200+
}
201+
202+
@Composable
203+
private fun V2ProviderList(
204+
rows: List<V2ProviderRow>,
205+
refreshing: Boolean,
206+
) {
207+
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
208+
Column {
209+
if (rows.isEmpty()) {
210+
V2ProviderListRow(V2ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
211+
} else {
212+
val visibleRows = rows.take(5)
213+
visibleRows.forEachIndexed { index, row ->
214+
V2ProviderListRow(row)
215+
if (index != visibleRows.lastIndex) {
216+
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
217+
}
218+
}
219+
}
220+
}
221+
}
222+
}
223+
224+
@Composable
225+
private fun V2ProviderListRow(row: V2ProviderRow) {
226+
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
227+
V2ProviderBadge(text = row.name)
228+
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
229+
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
230+
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
231+
}
232+
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
233+
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
234+
Text(text = row.status, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
235+
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
236+
}
237+
}
238+
}
239+
240+
@Composable
241+
private fun V2ProviderBadge(text: String) {
242+
Surface(modifier = Modifier.size(24.dp), shape = RoundedCornerShape(6.dp), color = ClawTheme.colors.surfacePressed, border = BorderStroke(1.dp, ClawTheme.colors.border)) {
243+
Box(contentAlignment = Alignment.Center) {
244+
Text(text = providerInitials(text), style = ClawTheme.type.section, color = ClawTheme.colors.text, textAlign = TextAlign.Center)
245+
}
246+
}
247+
}
248+
249+
private fun providerInitials(value: String): String =
250+
value
251+
.split(' ', '-', '_')
252+
.filter { it.isNotBlank() }
253+
.take(2)
254+
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
255+
.joinToString("")
256+
.ifBlank { "AI" }
257+
258+
@Composable
259+
private fun V2ModelCatalogEmpty(
260+
title: String,
261+
body: String,
262+
) {
263+
ClawPanel(contentPadding = PaddingValues(horizontal = 11.dp, vertical = 10.dp)) {
264+
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
265+
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
266+
Text(text = body, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
267+
}
268+
}
269+
}
270+
271+
@Composable
272+
private fun V2ModelGroup(
273+
provider: String,
274+
models: List<GatewayModelSummary>,
275+
) {
276+
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
277+
Column {
278+
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
279+
V2ProviderBadge(text = providerDisplayName(provider))
280+
Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
281+
V2ProviderMiniTag(text = "${models.size} models")
282+
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textMuted)
283+
}
284+
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
285+
models.take(3).forEach { model ->
286+
V2ModelRow(model)
287+
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
288+
}
289+
if (models.size > 3) {
290+
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
291+
Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f))
292+
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
293+
}
294+
}
295+
}
296+
}
297+
}
298+
299+
@Composable
300+
private fun V2ModelRow(model: GatewayModelSummary) {
301+
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
302+
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
303+
modelCapabilityLabels(model).take(3).forEach { label ->
304+
V2ProviderMiniTag(text = label)
305+
}
306+
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
307+
}
308+
}
309+
310+
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
311+
buildList {
312+
if (model.supportsReasoning) add("Reasoning")
313+
if (model.supportsVision) add("Vision")
314+
if (model.supportsAudio) add("Voice")
315+
if (model.supportsDocuments) add("Docs")
316+
if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context")
317+
if (isEmpty()) add("Fast")
318+
}
319+
320+
@Composable
321+
private fun V2ProviderSectionLabel(title: String) {
322+
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
323+
Text(text = title.uppercase(), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
324+
}
325+
}
326+
327+
@Composable
328+
private fun V2ProviderHeaderIconButton(
329+
icon: ImageVector,
330+
contentDescription: String,
331+
outlined: Boolean = false,
332+
onClick: () -> Unit,
333+
) {
334+
Surface(
335+
onClick = onClick,
336+
modifier = Modifier.size(if (outlined) 28.dp else 30.dp),
337+
shape = CircleShape,
338+
color = Color.Transparent,
339+
contentColor = ClawTheme.colors.text,
340+
border = if (outlined) BorderStroke(1.dp, ClawTheme.colors.borderStrong) else null,
341+
) {
342+
Box(contentAlignment = Alignment.Center) {
343+
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(if (outlined) 15.dp else 19.dp))
344+
}
345+
}
346+
}
347+
348+
@Composable
349+
private fun V2ProviderAddButton(
350+
onClick: () -> Unit,
351+
modifier: Modifier = Modifier,
352+
) {
353+
Surface(
354+
onClick = onClick,
355+
modifier = modifier.fillMaxWidth().height(30.dp),
356+
shape = RoundedCornerShape(ClawTheme.radii.pill),
357+
color = ClawTheme.colors.primary,
358+
contentColor = ClawTheme.colors.primaryText,
359+
) {
360+
Row(
361+
modifier = Modifier.fillMaxSize(),
362+
verticalAlignment = Alignment.CenterVertically,
363+
horizontalArrangement = Arrangement.Center,
364+
) {
365+
Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(13.dp))
366+
Spacer(modifier = Modifier.width(7.dp))
367+
Text(text = "Add Provider", style = ClawTheme.type.label, maxLines = 1)
368+
}
369+
}
370+
}
371+
372+
@Composable
373+
private fun V2ProviderMiniTag(text: String) {
374+
Surface(
375+
shape = RoundedCornerShape(5.dp),
376+
color = Color.Transparent,
377+
border = BorderStroke(1.dp, ClawTheme.colors.border),
378+
contentColor = ClawTheme.colors.textMuted,
379+
) {
380+
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
381+
}
382+
}

0 commit comments

Comments
 (0)