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.
This commit is contained in:
Claude
2026-04-20 22:11:34 +00:00
parent 0d1f5f0ed8
commit e23f02d72d
3 changed files with 296 additions and 130 deletions
@@ -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,
)
}
@@ -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<SelectedMedia?>(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
}
}
}
+2
View File
@@ -2387,6 +2387,8 @@
<string name="emoji_pack_name_label">Pack name</string>
<string name="emoji_pack_description_label">Description (optional)</string>
<string name="emoji_pack_image_label">Cover image URL (optional)</string>
<string name="emoji_pack_upload_image_cta">Upload a cover image</string>
<string name="emoji_pack_upload_image_hint">Pick a square image to represent this emoji pack.</string>
<string name="no_emoji_packs">You don\'t have any emoji packs yet</string>
<string name="emoji_pack_count">%1$d emojis</string>
<string name="my_emoji_list_title">My Emoji List</string>