From e23f02d72dfbd8e81166cfa5da3231b08565e977 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 22:11:34 +0000 Subject: [PATCH] feat(emoji): modernize pack metadata screen (hero image, inline upload) Replaces the plain stacked-form EmojiPackMetadataScreen with the badge- definition layout: a large square hero preview at the top, Name and Description fields below, and a single Create/Save action. Picking an image now launches the gallery directly (no URL paste step). If the user submits with a freshly picked local image, the ViewModel uploads to the account's default file server first, then publishes the EmojiPackEvent with the uploaded URL in `image`. Existing remote URLs keep rendering as the hero until replaced. The signer contract and ownedEmojiPacks create/update calls are unchanged. --- .../list/metadata/EmojiPackMetadataScreen.kt | 256 ++++++++++++------ .../metadata/EmojiPackMetadataViewModel.kt | 168 ++++++++---- amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 296 insertions(+), 130 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt index e909c2c35..27b1e7aeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt @@ -20,38 +20,59 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Composable fun EmojiPackMetadataScreen( @@ -82,34 +103,65 @@ private fun EmojiPackMetadataScaffold( accountViewModel: AccountViewModel, nav: INav, ) { + val context = LocalContext.current + val scrollState = rememberScrollState() + + var wantsToPickImage by remember { mutableStateOf(false) } + + if (wantsToPickImage) { + GallerySelectSingle( + onImageUri = { media -> + wantsToPickImage = false + if (media != null) { + viewModel.pickMedia(media) + } + }, + ) + } + + val onSubmit: () -> Unit = { + viewModel.submit( + context = context, + onSuccess = { nav.popBack() }, + onError = accountViewModel.toastManager::toast, + ) + } + Scaffold( topBar = { EmojiPackMetadataTopBar( viewModel = viewModel, - accountViewModel = accountViewModel, nav = nav, + onSubmit = onSubmit, ) }, ) { pad -> - LazyColumn( - Modifier - .fillMaxSize() - .padding( - start = 10.dp, - end = 10.dp, - top = pad.calculateTopPadding(), - bottom = pad.calculateBottomPadding(), - ).consumeWindowInsets(pad) - .imePadding(), + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), ) { - item { - PackName(viewModel) - Spacer(modifier = DoubleVertSpacer) + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 10.dp, vertical = 10.dp), + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { + PackImagePicker( + viewModel = viewModel, + onPickImage = { wantsToPickImage = true }, + ) - PackImage(viewModel, accountViewModel) - Spacer(modifier = DoubleVertSpacer) + Spacer(modifier = Modifier.height(12.dp)) - PackDescription(viewModel) + PackFormFields(viewModel) + } } } } @@ -118,8 +170,8 @@ private fun EmojiPackMetadataScaffold( @Composable private fun EmojiPackMetadataTopBar( viewModel: EmojiPackMetadataViewModel, - accountViewModel: AccountViewModel, nav: INav, + onSubmit: () -> Unit, ) { if (viewModel.isNewPack) { CreatingTopBar( @@ -129,17 +181,7 @@ private fun EmojiPackMetadataTopBar( viewModel.clear() nav.popBack() }, - onPost = { - try { - viewModel.createOrUpdate() - nav.popBack() - } catch (e: SignerExceptions.ReadOnlyException) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_sign_events, - ) - } - }, + onPost = onSubmit, ) } else { SavingTopBar( @@ -149,83 +191,135 @@ private fun EmojiPackMetadataTopBar( viewModel.clear() nav.popBack() }, - onPost = { - try { - viewModel.createOrUpdate() - nav.popBack() - } catch (e: SignerExceptions.ReadOnlyException) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_sign_events, - ) - } - }, + onPost = onSubmit, ) } } @Composable -private fun PackName(viewModel: EmojiPackMetadataViewModel) { +private fun PackImagePicker( + viewModel: EmojiPackMetadataViewModel, + onPickImage: () -> Unit, +) { + val picked = viewModel.pickedMedia + val currentUrl = viewModel.picture.value.text + + when { + picked != null -> { + HeroImagePreview( + model = picked.uri, + onClick = onPickImage, + ) + } + + currentUrl.isNotBlank() -> { + HeroImagePreview( + model = currentUrl, + onClick = onPickImage, + ) + } + + else -> { + UploadPlaceholder(onClick = onPickImage) + } + } +} + +@Composable +private fun HeroImagePreview( + model: Any, + onClick: () -> Unit, +) { + AsyncImage( + model = model, + contentDescription = stringRes(R.string.emoji_pack_image_label), + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + ) +} + +@Composable +private fun UploadPlaceholder(onClick: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), + ).clickable(onClick = onClick) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringRes(R.string.emoji_pack_upload_image_cta), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.emoji_pack_upload_image_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun PackFormFields(viewModel: EmojiPackMetadataViewModel) { OutlinedTextField( - label = { Text(text = stringRes(R.string.emoji_pack_name_label)) }, - modifier = Modifier.fillMaxWidth(), value = viewModel.name.value, onValueChange = { viewModel.name.value = it }, + label = { Text(text = stringRes(R.string.emoji_pack_name_label)) }, placeholder = { Text( text = stringRes(R.string.emoji_pack_name_label), color = MaterialTheme.colorScheme.placeholderText, ) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, ), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), ) -} -@Composable -private fun PackImage( - viewModel: EmojiPackMetadataViewModel, - accountViewModel: AccountViewModel, -) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.emoji_pack_image_label)) }, - modifier = Modifier.fillMaxWidth(), - value = viewModel.picture.value, - onValueChange = { viewModel.picture.value = it }, - placeholder = { - Text( - text = "https://example.com/cover.jpg", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - leadingIcon = { - val context = LocalContext.current - SelectSingleFromGallery( - isUploading = viewModel.isUploadingImageForPicture, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 2.dp), - ) { - viewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) - } - }, - ) -} + Spacer(modifier = Modifier.height(12.dp)) -@Composable -private fun PackDescription(viewModel: EmojiPackMetadataViewModel) { OutlinedTextField( - label = { Text(text = stringRes(R.string.emoji_pack_description_label)) }, - modifier = Modifier.fillMaxWidth(), value = viewModel.description.value, onValueChange = { viewModel.description.value = it }, + label = { Text(text = stringRes(R.string.emoji_pack_description_label)) }, + modifier = + Modifier + .fillMaxWidth() + .height(120.dp), + minLines = 2, + maxLines = 6, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, ), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - minLines = 3, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt index 739538384..56ff41e30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt @@ -59,12 +59,25 @@ class EmojiPackMetadataViewModel : ViewModel() { val picture = mutableStateOf(TextFieldValue()) val description = mutableStateOf(TextFieldValue()) - var isUploadingImageForPicture by mutableStateOf(false) + /** + * Local image the user just picked from the gallery but hasn't uploaded yet. + * When non-null the hero preview shows this file and `submit()` will upload + * it before publishing the emoji pack event. Mutated only via [pickMedia] / + * [clearPickedMedia] so the setter name doesn't collide on the JVM. + */ + var pickedMedia by mutableStateOf(null) + private set + + /** True while upload-then-publish is running. Disables the submit button and shows a spinner. */ + var isWorking by mutableStateOf(false) val canPost by derivedStateOf { - name.value.text.isNotBlank() + !isWorking && name.value.text.isNotBlank() } + /** True when either a remote cover URL exists OR the user has picked a local image. */ + fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank() + fun init(accountViewModel: AccountViewModel) { this.accountViewModel = accountViewModel this.account = accountViewModel.account @@ -81,25 +94,90 @@ class EmojiPackMetadataViewModel : ViewModel() { name.value = TextFieldValue(existing?.title ?: "") picture.value = TextFieldValue(existing?.image ?: "") description.value = TextFieldValue(existing?.description ?: "") + pickedMedia = null } + fun pickMedia(media: SelectedMedia) { + pickedMedia = media + } + + fun clearPickedMedia() { + pickedMedia = null + } + + /** + * Kicks off the full create/update flow: + * 1. If a local image was picked, upload it first and update `picture`. + * 2. Build & sign the EmojiPackEvent with the (possibly newly uploaded) URL. + * + * Mirrors the badge-definition flow where the user never sees the URL and the + * image upload is implicit in pressing "Create" / "Save". + */ + fun submit( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) { + if (isWorking) return + viewModelScope.launch(Dispatchers.IO) { + isWorking = true + try { + val local = pickedMedia + if (local != null) { + val uploadedUrl = uploadImage(local, context, onError) + if (uploadedUrl == null) { + isWorking = false + return@launch + } + picture.value = TextFieldValue(uploadedUrl) + pickedMedia = null + } + + try { + publish() + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + isWorking = false + return@launch + } + clear() + onSuccess() + } finally { + isWorking = false + } + } + } + + private suspend fun publish() { + val currentPack = pack + if (currentPack == null) { + account.createOwnedEmojiPack( + title = name.value.text, + description = description.value.text, + image = picture.value.text, + ) + } else { + account.updateOwnedEmojiPackMetadata( + dTag = currentPack.identifier, + newTitle = name.value.text, + newDescription = description.value.text, + newImage = picture.value.text, + ) + } + } + + /** + * Retained for backward compatibility with the old "paste URL + upload button" + * flow. New UI goes through [submit]. The signer contract is unchanged: the + * final signed EmojiPackEvent still carries the published URL in `image`. + */ + @Suppress("unused") fun createOrUpdate() { accountViewModel.launchSigner { - val currentPack = pack - if (currentPack == null) { - account.createOwnedEmojiPack( - title = name.value.text, - description = description.value.text, - image = picture.value.text, - ) - } else { - account.updateOwnedEmojiPackMetadata( - dTag = currentPack.identifier, - newTitle = name.value.text, - newDescription = description.value.text, - newImage = picture.value.text, - ) - } + publish() clear() } } @@ -108,33 +186,21 @@ class EmojiPackMetadataViewModel : ViewModel() { name.value = TextFieldValue() picture.value = TextFieldValue() description.value = TextFieldValue() + pickedMedia = null } - fun uploadForPicture( - uri: SelectedMedia, - context: Context, - onError: (String, String) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - upload( - uri, - context, - onUploading = { isUploadingImageForPicture = it }, - onUploaded = { picture.value = TextFieldValue(it) }, - onError = onError, - ) - } - } - - private suspend fun upload( + /** + * Uploads [galleryUri] using the user's configured default file server, + * respecting the account's strip-location-on-upload preference. Returns the + * published URL or null on failure (having already called [onError]). + * + * Mirrors the NIP-96/Blossom block used by `BookmarkGroupMetadataViewModel.upload`. + */ + private suspend fun uploadImage( galleryUri: SelectedMedia, context: Context, - onUploading: (Boolean) -> Unit, - onUploaded: (String) -> Unit, onError: (String, String) -> Unit, - ) { - onUploading(true) - + ): String? { val sourceUri = if (account.settings.stripLocationOnUpload) { val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) @@ -143,8 +209,7 @@ class EmojiPackMetadataViewModel : ViewModel() { stringRes(context, R.string.metadata_strip_failed_title), stringRes(context, R.string.metadata_strip_failed_upload_cancelled), ) - onUploading(false) - return + return null } result.uri } else { @@ -152,7 +217,7 @@ class EmojiPackMetadataViewModel : ViewModel() { } val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) - try { + return try { val result = if (account.settings.defaultFileServer.type == ServerType.NIP96) { Nip96Uploader().upload( @@ -182,19 +247,24 @@ class EmojiPackMetadataViewModel : ViewModel() { } if (result.url != null) { - onUploading(false) - onUploaded(result.url) + result.url } else { - onUploading(false) - onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + stringRes(context, R.string.server_did_not_provide_a_url_after_uploading), + ) + null } } catch (_: SignerExceptions.ReadOnlyException) { - onUploading(false) - onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload), + ) + null } catch (e: Exception) { if (e is CancellationException) throw e - onUploading(false) onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) + null } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index adb3432f6..03451b9f8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2387,6 +2387,8 @@ Pack name Description (optional) Cover image URL (optional) + Upload a cover image + Pick a square image to represent this emoji pack. You don\'t have any emoji packs yet %1$d emojis My Emoji List