add avatars
This commit is contained in:
@@ -66,6 +66,10 @@ kotlin {
|
|||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
implementation(compose.components.uiToolingPreview)
|
implementation(compose.components.uiToolingPreview)
|
||||||
|
|
||||||
|
// Image loading (Coil 3 - KMP)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.okhttp)
|
||||||
|
|
||||||
// LruCache (KMP-ready)
|
// LruCache (KMP-ready)
|
||||||
implementation(libs.androidx.collection)
|
implementation(libs.androidx.collection)
|
||||||
|
|
||||||
|
|||||||
+114
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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)
|
||||||
+24
-7
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
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.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +56,7 @@ data class NoteDisplayData(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val pubKeyHex: String,
|
val pubKeyHex: String,
|
||||||
val pubKeyDisplay: String,
|
val pubKeyDisplay: String,
|
||||||
|
val profilePictureUrl: String? = null,
|
||||||
val content: String,
|
val content: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
)
|
)
|
||||||
@@ -85,19 +89,32 @@ fun NoteCard(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Author (truncated, clickable)
|
// Author with avatar
|
||||||
Text(
|
Row(
|
||||||
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
maxLines = 1,
|
|
||||||
modifier =
|
modifier =
|
||||||
if (onAuthorClick != null) {
|
if (onAuthorClick != null) {
|
||||||
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
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
|
// Timestamp
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+18
-20
@@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
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.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -56,7 +54,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.account.AccountState
|
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.createUserPostsSubscription
|
||||||
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
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.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||||
@@ -321,23 +319,23 @@ fun UserProfileScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(24.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
// Profile picture placeholder
|
// Profile picture with robohash fallback
|
||||||
Surface(
|
UserAvatar(
|
||||||
modifier = Modifier.size(80.dp).clip(CircleShape),
|
userHex = pubKeyHex,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
pictureUrl = picture,
|
||||||
) {
|
size = 56.dp,
|
||||||
// TODO: Load actual image from picture URL
|
contentDescription = "Profile picture",
|
||||||
}
|
)
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
|
displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
@@ -350,7 +348,7 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (about != null) {
|
if (about != null) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
about!!,
|
about!!,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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 {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"$followersCount",
|
"$followersCount",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
@@ -376,7 +374,7 @@ fun UserProfileScreen(
|
|||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"$followingCount",
|
"$followingCount",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
@@ -389,12 +387,12 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// User's posts
|
// User's posts
|
||||||
Text(
|
Text(
|
||||||
"Posts",
|
"Posts",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user