feat: add profile picture upload button when user has no picture

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
This commit is contained in:
Claude
2026-02-28 02:06:43 +00:00
parent 3762c8d6c3
commit 11e86b5805
2 changed files with 106 additions and 1 deletions
@@ -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( private suspend fun upload(
galleryUri: SelectedMedia, galleryUri: SelectedMedia,
context: Context, context: Context,
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.ClipData 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -38,6 +42,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -45,13 +50,20 @@ 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.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard 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.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.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.User 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.components.ZoomableImageDialog
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -126,7 +138,7 @@ fun ProfileHeader(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
ZoomableUserPicture( ProfilePictureWithUploadOverlay(
baseUser = baseUser, baseUser = baseUser,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
size = Size100dp, 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,
)
}
}
}