From 7d3ac9ec8ad96ac568578a73f31ac4a6b84b9ad9 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 5 Jan 2026 13:43:00 +0200 Subject: [PATCH] add avatars --- commons/build.gradle.kts | 4 + .../commons/ui/components/UserAvatar.kt | 114 ++++++++++++++++++ .../commons/ui/theme/ThemeExtensions.kt | 38 ++++++ .../amethyst/commons/ui/note/NoteCard.kt | 31 +++-- .../amethyst/desktop/ui/UserProfileScreen.kt | 38 +++--- 5 files changed, 198 insertions(+), 27 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 095072fe3..d93e36172 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -66,6 +66,10 @@ kotlin { implementation(compose.materialIconsExtended) implementation(compose.components.uiToolingPreview) + // Image loading (Coil 3 - KMP) + implementation(libs.coil.compose) + implementation(libs.coil.okhttp) + // LruCache (KMP-ready) implementation(libs.androidx.collection) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt new file mode 100644 index 000000000..02478adeb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.MaterialTheme +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.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash +import com.vitorpamplona.amethyst.commons.ui.theme.isLight +import com.vitorpamplona.amethyst.commons.ui.theme.onBackgroundColorFilter + +/** + * Shared avatar component that displays a user's profile picture with Robohash fallback. + * + * @param userHex The user's public key hex (used for Robohash generation) + * @param pictureUrl Optional URL to the user's profile picture + * @param size Size of the avatar (both width and height) + * @param modifier Additional modifiers to apply + * @param contentDescription Accessibility description + * @param loadProfilePicture Whether to load the profile picture (false = show robohash only) + * @param loadRobohash Whether to generate robohash (false = show generic icon) + */ +@Composable +fun UserAvatar( + userHex: String, + pictureUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + contentDescription: String? = null, + loadProfilePicture: Boolean = true, + loadRobohash: Boolean = true, +) { + val avatarModifier = remember(size) { + modifier.clip(shape = CircleShape) + } + + if (pictureUrl != null && loadProfilePicture) { + // Show profile picture with robohash/icon as fallback + val fallbackPainter = + if (loadRobohash) { + rememberVectorPainter( + image = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight), + ) + } else { + rememberVectorPainter( + image = Icons.Default.Face, + ) + } + + AsyncImage( + model = pictureUrl, + contentDescription = contentDescription, + modifier = avatarModifier, + placeholder = fallbackPainter, + fallback = fallbackPainter, + error = fallbackPainter, + alignment = Alignment.Center, + contentScale = ContentScale.Crop, + alpha = DefaultAlpha, + colorFilter = null, + filterQuality = DrawScope.DefaultFilterQuality, + ) + } else if (loadRobohash) { + // Show robohash only + Image( + imageVector = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight), + contentDescription = contentDescription, + modifier = avatarModifier, + contentScale = ContentScale.Crop, + ) + } else { + // Show generic icon + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = avatarModifier, + contentScale = ContentScale.Crop, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt new file mode 100644 index 000000000..543b3861c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.luminance + +/** + * Determines if the color scheme is light mode. + * Based on primary color luminance. + */ +val ColorScheme.isLight: Boolean + get() = primary.luminance() < 0.5f + +/** + * Color filter for onBackground color (for tinting icons/images). + */ +val ColorScheme.onBackgroundColorFilter: ColorFilter + get() = ColorFilter.tint(onBackground) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt index 7a05268b2..304929af4 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -44,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.toTimeAgo /** @@ -53,6 +56,7 @@ data class NoteDisplayData( val id: String, val pubKeyHex: String, val pubKeyDisplay: String, + val profilePictureUrl: String? = null, val content: String, val createdAt: Long, ) @@ -85,19 +89,32 @@ fun NoteCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - // Author (truncated, clickable) - Text( - text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, + // Author with avatar + Row( + verticalAlignment = Alignment.CenterVertically, modifier = if (onAuthorClick != null) { Modifier.clickable { onAuthorClick(note.pubKeyHex) } } else { Modifier }, - ) + ) { + UserAvatar( + userHex = note.pubKeyHex, + pictureUrl = note.profilePictureUrl, + size = 32.dp, + contentDescription = "Profile picture of ${note.pubKeyDisplay}", + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } // Timestamp Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 9dc55d75a..c3dc70722 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.PersonAdd @@ -45,7 +44,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -56,7 +54,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.account.AccountState @@ -68,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscripti import com.vitorpamplona.amethyst.commons.subscriptions.createUserPostsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull @@ -321,23 +319,23 @@ fun UserProfileScreen( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), ) { - Column(modifier = Modifier.padding(24.dp)) { + Column(modifier = Modifier.padding(16.dp)) { Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top, ) { - // Profile picture placeholder - Surface( - modifier = Modifier.size(80.dp).clip(CircleShape), - color = MaterialTheme.colorScheme.primary, - ) { - // TODO: Load actual image from picture URL - } + // Profile picture with robohash fallback + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 56.dp, + contentDescription = "Profile picture", + ) Column(modifier = Modifier.weight(1f)) { Text( displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(4.dp)) @@ -350,7 +348,7 @@ fun UserProfileScreen( } if (about != null) { - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) Text( about!!, style = MaterialTheme.typography.bodyMedium, @@ -358,13 +356,13 @@ fun UserProfileScreen( ) } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Column { Text( "$followersCount", - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) Text( @@ -376,7 +374,7 @@ fun UserProfileScreen( Column { Text( "$followingCount", - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) Text( @@ -389,12 +387,12 @@ fun UserProfileScreen( } } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) // User's posts Text( "Posts", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp), )