Skip to content

Commit 51f5f23

Browse files
feat(android): add Icon, List, ActionSet elements for v4.1.0
1 parent 84419a6 commit 51f5f23

1 file changed

Lines changed: 101 additions & 2 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/ui/chat/AdaptiveCardComposable.kt

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,26 @@ import ai.openclaw.app.ui.mobileCodeBorder
3737
import ai.openclaw.app.ui.mobileCodeText
3838
import ai.openclaw.app.ui.mobileText
3939
import ai.openclaw.app.ui.mobileTextSecondary
40+
import androidx.compose.foundation.layout.size
41+
import androidx.compose.material.icons.Icons
42+
import androidx.compose.material.icons.filled.CheckCircle
43+
import androidx.compose.material.icons.filled.Info
44+
import androidx.compose.material.icons.filled.Warning
45+
import androidx.compose.material.icons.outlined.CheckCircle
46+
import androidx.compose.material.icons.outlined.Info
47+
import androidx.compose.material.icons.outlined.Warning
48+
import androidx.compose.material3.Icon
49+
import androidx.compose.ui.Alignment
50+
import androidx.compose.ui.graphics.Color
51+
import androidx.compose.ui.unit.sp
4052

4153
private const val TAG = "AdaptiveCard"
4254

4355
/**
4456
* Renders an Adaptive Card from a parsed JSON map inline in a chat bubble.
4557
* Supports TextBlock, FactSet, ColumnSet, Container, Image (placeholder),
46-
* Table, RichTextBlock, CodeBlock, ActionSet, ImageSet, Rating, ProgressBar,
47-
* Action.Submit, Action.Execute, and Action.OpenUrl.
58+
* Table, RichTextBlock, CodeBlock, ActionSet, Icon, List, ImageSet, Rating,
59+
* ProgressBar, Action.Submit, Action.Execute, and Action.OpenUrl.
4860
* Unknown element types are silently skipped.
4961
*/
5062
@Composable
@@ -96,6 +108,8 @@ private fun RenderElement(element: Map<String, Any>) {
96108
"RichTextBlock" -> RenderRichTextBlock(element)
97109
"CodeBlock" -> RenderCodeBlock(element)
98110
"ActionSet" -> RenderActionSet(element)
111+
"Icon" -> RenderIcon(element)
112+
"List" -> RenderList(element)
99113
"ImageSet" -> RenderImageSet(element)
100114
"Rating" -> RenderRating(element)
101115
"ProgressBar" -> RenderProgressBar(element)
@@ -346,6 +360,85 @@ private fun RenderActionSet(element: Map<String, Any>) {
346360
}
347361
}
348362

363+
@Composable
364+
private fun RenderIcon(element: Map<String, Any>) {
365+
val name = element["name"] as? String ?: return
366+
val size = element["size"] as? String
367+
val color = element["color"] as? String
368+
val style = element["style"] as? String // "regular" or "filled"
369+
370+
val iconSize = when (size?.lowercase()) {
371+
"small" -> 16.dp
372+
"large" -> 32.dp
373+
else -> 20.dp // medium / default
374+
}
375+
376+
val iconColor = when (color?.lowercase()) {
377+
"accent" -> mobileAccent
378+
"good", "green" -> Color(0xFF4CAF50)
379+
"warning", "yellow" -> Color(0xFFFFC107)
380+
"attention", "red" -> Color(0xFFF44336)
381+
else -> mobileText
382+
}
383+
384+
// Map well-known icon names to Material icons; fall back to text label
385+
val isFilled = style?.lowercase() == "filled"
386+
val imageVector = when (name.lowercase()) {
387+
"info", "information" -> if (isFilled) Icons.Filled.Info else Icons.Outlined.Info
388+
"warning", "alert" -> if (isFilled) Icons.Filled.Warning else Icons.Outlined.Warning
389+
"checkmark", "check", "success" -> if (isFilled) Icons.Filled.CheckCircle else Icons.Outlined.CheckCircle
390+
else -> null
391+
}
392+
393+
if (imageVector != null) {
394+
Icon(
395+
imageVector = imageVector,
396+
contentDescription = name,
397+
tint = iconColor,
398+
modifier = Modifier.size(iconSize),
399+
)
400+
} else {
401+
// Fallback: render icon name as a small styled label
402+
Text(
403+
text = "[$name]",
404+
style = mobileCaption1,
405+
color = iconColor,
406+
)
407+
}
408+
}
409+
410+
@Composable
411+
private fun RenderList(element: Map<String, Any>) {
412+
val items = element.typedList("items")
413+
if (items.isEmpty()) return
414+
415+
val style = element["style"] as? String // "ordered" or "unordered"
416+
val isOrdered = style?.lowercase() == "ordered"
417+
418+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
419+
for ((index, item) in items.withIndex()) {
420+
val text = item["text"] as? String ?: continue
421+
val icon = item.typedMap("icon")
422+
val prefix = if (isOrdered) "${index + 1}. " else "\u2022 "
423+
424+
Row(
425+
horizontalArrangement = Arrangement.spacedBy(6.dp),
426+
verticalAlignment = Alignment.CenterVertically,
427+
) {
428+
if (icon != null) {
429+
// Render inline icon before the list item text
430+
RenderIcon(icon)
431+
}
432+
Text(
433+
text = "$prefix$text",
434+
style = mobileCallout,
435+
color = mobileText,
436+
)
437+
}
438+
}
439+
}
440+
}
441+
349442
@OptIn(ExperimentalLayoutApi::class)
350443
@Composable
351444
private fun RenderImageSet(element: Map<String, Any>) {
@@ -471,3 +564,9 @@ private fun Map<String, Any>.typedList(key: String): List<Map<String, Any>> {
471564
?.map { it as Map<String, Any> }
472565
?: emptyList()
473566
}
567+
568+
/** Safely cast a nested map value from a loosely-typed map to Map<String, Any>?. */
569+
@Suppress("UNCHECKED_CAST")
570+
private fun Map<String, Any>.typedMap(key: String): Map<String, Any>? {
571+
return this[key] as? Map<String, Any>
572+
}

0 commit comments

Comments
 (0)