diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 968f391b..c3128283 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/BotAvatar.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/BotAvatar.kt new file mode 100644 index 00000000..dd9870df --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/BotAvatar.kt @@ -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() + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/BotAvatarTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/BotAvatarTest.kt new file mode 100644 index 00000000..4dbe8379 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/BotAvatarTest.kt @@ -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) + } +}