From 11e86b58053b087cd87b58bf426d754fa9291cb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 02:06:43 +0000 Subject: [PATCH] feat: add profile picture upload button when user has no picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user visits their own profile and has no profile picture set, show a semi-transparent upload button overlay directly on the profile picture area. Tapping it opens the gallery picker, uploads the image to the configured server (NIP-96 or Blossom), and immediately saves the metadata — without requiring the user to navigate to the edit screen. - Add uploadPictureAndSave() to NewUserMetadataViewModel: loads current metadata, uploads the image, then saves all metadata with the new picture - Add ProfilePictureWithUploadOverlay composable that wraps ZoomableUserPicture and conditionally shows the upload overlay when isMe && no picture - Add ProfilePictureUploadButton composable for the overlay UI with loading state support - Use ProfilePictureWithUploadOverlay in ProfileHeader instead of ZoomableUserPicture directly https://claude.ai/code/session_01XemJ8Hx9q4mMd1StZNY9CE --- .../ui/actions/NewUserMetadataViewModel.kt | 20 +++++ .../loggedIn/profile/header/ProfileHeader.kt | 87 ++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index edebdb0ef..611563181 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -169,6 +169,26 @@ class NewUserMetadataViewModel : ViewModel() { } } + fun uploadPictureAndSave( + uri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ) { + load() + accountViewModel.launchSigner { + upload( + uri, + context, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { + picture.value = it + create() + }, + onError = onError, + ) + } + } + private suspend fun upload( galleryUri: SelectedMedia, context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt index 19c31a91b..52ad0230b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.ClipData +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,7 +32,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -38,6 +42,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,13 +50,20 @@ 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.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture +import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataViewModel +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture @@ -126,7 +138,7 @@ fun ProfileHeader( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom, ) { - ZoomableUserPicture( + ProfilePictureWithUploadOverlay( baseUser = baseUser, accountViewModel = accountViewModel, size = Size100dp, @@ -189,3 +201,76 @@ fun ZoomableUserPicture( ) } } + +@Composable +fun ProfilePictureWithUploadOverlay( + baseUser: User, + accountViewModel: AccountViewModel, + size: Dp, +) { + val isMe by remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } + val picture by observeUserPicture(baseUser, accountViewModel) + + Box(contentAlignment = Alignment.Center) { + ZoomableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = size, + ) + if (isMe && picture.isNullOrBlank()) { + ProfilePictureUploadButton(accountViewModel, size) + } + } +} + +@Composable +private fun ProfilePictureUploadButton( + accountViewModel: AccountViewModel, + size: Dp, +) { + val postViewModel: NewUserMetadataViewModel = viewModel() + postViewModel.init(accountViewModel) + val context = LocalContext.current + + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelectSingle( + onImageUri = { media -> + showGallerySelect = false + if (media != null) { + postViewModel.uploadPictureAndSave(media, context, accountViewModel.toastManager::toast) + } + }, + ) + } + + if (postViewModel.isUploadingImageForPicture) { + Box( + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center, + ) { + LoadingAnimation() + } + } else { + Box( + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .clickable { showGallerySelect = true }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = stringRes(R.string.upload_image), + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +}