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
|
// opaque black and Icon()'s tint (LocalContentColor) replaces the RGB at
|
||||||
// draw time — only the alpha channel survives, so this must stay opaque.
|
// draw time — only the alpha channel survives, so this must stay opaque.
|
||||||
val IconDefaultFill = Color.Black
|
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