feat(ui): add reusable Material3 BotAvatar component (#974)
This commit is contained in:
3 files changed
+270
No files matched your search
@@ -50,3 +50,24 @@ val CodeDiffHunkText = Color(0xFF4DA8FF)
|
||||
// opaque black and Icon()'s tint (LocalContentColor) replaces the RGB at
|
||||
// draw time — only the alpha channel survives, so this must stay opaque.
|
||||
val IconDefaultFill = Color.Black
|
||||
|
||||
/**
|
||||
* Safely parse a hex color string (#RGB, #RRGGBB, or #AARRGGBB), returning [fallback] if invalid.
|
||||
*/
|
||||
fun parseHexColor(
|
||||
hex: String?,
|
||||
fallback: Color,
|
||||
): Color {
|
||||
if (hex.isNullOrBlank()) return fallback
|
||||
val clean = hex.removePrefix("#").trim()
|
||||
return try {
|
||||
val parsed = clean.toLong(16)
|
||||
when (clean.length) {
|
||||
6 -> Color((0xFF000000 or parsed).toInt())
|
||||
8 -> Color(parsed.toInt())
|
||||
else -> fallback
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Psychology
|
||||
import androidx.compose.material.icons.filled.Science
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||
import com.m57.hermescontrol.theme.StatusGreen
|
||||
import com.m57.hermescontrol.theme.parseHexColor
|
||||
|
||||
/**
|
||||
* Reusable Material3 avatar component for Bot Mode agents.
|
||||
*
|
||||
* Renders geometric shapes (circle, square, rounded, hexagon), custom hex colors,
|
||||
* Material symbol icons, external image URLs via Coil, or initials fallback, with
|
||||
* an optional active presence dot.
|
||||
*/
|
||||
@Composable
|
||||
fun BotAvatar(
|
||||
name: String,
|
||||
avatar: BotAvatarMeta?,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 40.dp,
|
||||
isActive: Boolean = false,
|
||||
showPresence: Boolean = true,
|
||||
) {
|
||||
val shape = remember(avatar?.shape, size) { resolveAvatarShape(avatar?.shape, size) }
|
||||
val fallbackColor = MaterialTheme.colorScheme.primaryContainer
|
||||
val contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
val bgColor =
|
||||
remember(avatar?.color, fallbackColor) {
|
||||
parseHexColor(avatar?.color, fallbackColor)
|
||||
}
|
||||
|
||||
val iconVector = remember(avatar?.icon) { resolveAvatarIcon(avatar?.icon) }
|
||||
val imageUrl = avatar?.image_url?.takeIf { it.isNotBlank() }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.size(size)
|
||||
.testTag("bot_avatar_$name"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(shape)
|
||||
.background(bgColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest.Builder(LocalContext.current)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (iconVector != null) {
|
||||
Icon(
|
||||
imageVector = iconVector,
|
||||
contentDescription = name,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(size * 0.55f),
|
||||
)
|
||||
} else {
|
||||
val initials = remember(name) { extractInitials(name) }
|
||||
Text(
|
||||
text = initials,
|
||||
color = contentColor,
|
||||
fontSize = (size.value * 0.4f).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive && showPresence) {
|
||||
val badgeSize = (size * 0.3f).coerceIn(8.dp, 14.dp)
|
||||
val borderSize = (size * 0.05f).coerceIn(1.5.dp, 2.5.dp)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(badgeSize)
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 1.dp, y = 1.dp)
|
||||
.border(borderSize, MaterialTheme.colorScheme.surface, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(StatusGreen)
|
||||
.testTag("bot_avatar_presence_$name"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps shape name to Compose [Shape].
|
||||
*/
|
||||
fun resolveAvatarShape(
|
||||
shapeKey: String?,
|
||||
size: Dp,
|
||||
): Shape {
|
||||
return when (shapeKey?.lowercase()?.trim()) {
|
||||
"square", "box", "boxy" -> RoundedCornerShape(size * 0.15f)
|
||||
"rounded", "nub", "organic" -> RoundedCornerShape(size * 0.32f)
|
||||
"hexagon", "cut", "diamond", "cloud", "sun" -> CutCornerShape(size * 0.25f)
|
||||
"circle", "round" -> CircleShape
|
||||
else -> CircleShape
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps known icon keys to Material [ImageVector] icons.
|
||||
*/
|
||||
fun resolveAvatarIcon(iconKey: String?): ImageVector? {
|
||||
return when (iconKey?.lowercase()?.trim()) {
|
||||
"code", "dev", "terminal" -> Icons.Filled.Code
|
||||
"build", "tool", "tools" -> Icons.Filled.Build
|
||||
"psychology", "brain", "ai", "research", "intel" -> Icons.Filled.Psychology
|
||||
"science", "lab" -> Icons.Filled.Science
|
||||
"bolt", "zap", "action" -> Icons.Filled.Bolt
|
||||
"sensors", "sensor", "monitor", "watchdog" -> Icons.Filled.Sensors
|
||||
"extension", "plugin" -> Icons.Filled.Extension
|
||||
"robot", "bot", "smart_toy" -> Icons.Filled.SmartToy
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract 1-2 uppercase letters from name for initials fallback.
|
||||
*/
|
||||
fun extractInitials(name: String): String {
|
||||
val clean = name.trim().removePrefix("@")
|
||||
if (clean.isBlank()) return "?"
|
||||
val parts = clean.split(Regex("[_\\-\\s]+")).filter { it.isNotBlank() }
|
||||
return when {
|
||||
parts.size >= 2 -> "${parts[0].first().uppercaseChar()}${parts[1].first().uppercaseChar()}"
|
||||
clean.length >= 2 -> clean.take(2).uppercase()
|
||||
else -> clean.take(1).uppercase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Psychology
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.theme.parseHexColor
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BotAvatarTest {
|
||||
@Test
|
||||
fun testShapeResolution() {
|
||||
val size = 40.dp
|
||||
assertEquals(CircleShape, resolveAvatarShape("circle", size))
|
||||
assertEquals(CircleShape, resolveAvatarShape("round", size))
|
||||
assertEquals(CircleShape, resolveAvatarShape(null, size))
|
||||
assertEquals(CircleShape, resolveAvatarShape("unknown_shape", size))
|
||||
|
||||
assertTrue(resolveAvatarShape("square", size) is RoundedCornerShape)
|
||||
assertTrue(resolveAvatarShape("boxy", size) is RoundedCornerShape)
|
||||
assertTrue(resolveAvatarShape("rounded", size) is RoundedCornerShape)
|
||||
assertTrue(resolveAvatarShape("hexagon", size) is CutCornerShape)
|
||||
assertTrue(resolveAvatarShape("diamond", size) is CutCornerShape)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIconResolution() {
|
||||
assertEquals(Icons.Filled.Code, resolveAvatarIcon("code"))
|
||||
assertEquals(Icons.Filled.Code, resolveAvatarIcon("terminal"))
|
||||
assertEquals(Icons.Filled.Psychology, resolveAvatarIcon("brain"))
|
||||
assertEquals(Icons.Filled.Psychology, resolveAvatarIcon("research"))
|
||||
assertEquals(Icons.Filled.SmartToy, resolveAvatarIcon("robot"))
|
||||
assertEquals(Icons.Filled.SmartToy, resolveAvatarIcon("bot"))
|
||||
assertNull(resolveAvatarIcon(null))
|
||||
assertNull(resolveAvatarIcon("unknown_icon"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInitialsExtraction() {
|
||||
assertEquals("RE", extractInitials("researcher"))
|
||||
assertEquals("DS", extractInitials("daily-sync"))
|
||||
assertEquals("BT", extractInitials("bot_tester"))
|
||||
assertEquals("AB", extractInitials("Alpha Beta"))
|
||||
assertEquals("A", extractInitials("a"))
|
||||
assertEquals("?", extractInitials(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHexColorParsing() {
|
||||
val fallback = Color.Gray
|
||||
val parsed = parseHexColor("#FF0000", fallback)
|
||||
assertEquals(Color(0xFFFF0000.toInt()), parsed)
|
||||
|
||||
val alphaParsed = parseHexColor("#8000FF00", fallback)
|
||||
assertEquals(Color(0x8000FF00.toInt()), alphaParsed)
|
||||
|
||||
val invalid = parseHexColor("not-a-color", fallback)
|
||||
assertEquals(fallback, invalid)
|
||||
|
||||
val nullColor = parseHexColor(null, fallback)
|
||||
assertEquals(fallback, nullColor)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user