Merge modern emoji metadata screen into integration branch

This commit is contained in:
Claude
2026-04-20 22:20:49 +00:00
3 changed files with 296 additions and 130 deletions
@@ -20,38 +20,59 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata 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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
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.LaunchedEffect 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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext 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.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Composable @Composable
fun EmojiPackMetadataScreen( fun EmojiPackMetadataScreen(
@@ -82,34 +103,65 @@ private fun EmojiPackMetadataScaffold(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, 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( Scaffold(
topBar = { topBar = {
EmojiPackMetadataTopBar( EmojiPackMetadataTopBar(
viewModel = viewModel, viewModel = viewModel,
accountViewModel = accountViewModel,
nav = nav, nav = nav,
onSubmit = onSubmit,
) )
}, },
) { pad -> ) { pad ->
LazyColumn( Surface(
Modifier modifier =
.fillMaxSize() Modifier
.padding( .padding(pad)
start = 10.dp, .consumeWindowInsets(pad)
end = 10.dp, .imePadding(),
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) { ) {
item { Column(
PackName(viewModel) Modifier
Spacer(modifier = DoubleVertSpacer) .fillMaxSize()
.padding(horizontal = 10.dp, vertical = 10.dp),
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
) {
PackImagePicker(
viewModel = viewModel,
onPickImage = { wantsToPickImage = true },
)
PackImage(viewModel, accountViewModel) Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = DoubleVertSpacer)
PackDescription(viewModel) PackFormFields(viewModel)
}
} }
} }
} }
@@ -118,8 +170,8 @@ private fun EmojiPackMetadataScaffold(
@Composable @Composable
private fun EmojiPackMetadataTopBar( private fun EmojiPackMetadataTopBar(
viewModel: EmojiPackMetadataViewModel, viewModel: EmojiPackMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav, nav: INav,
onSubmit: () -> Unit,
) { ) {
if (viewModel.isNewPack) { if (viewModel.isNewPack) {
CreatingTopBar( CreatingTopBar(
@@ -129,17 +181,7 @@ private fun EmojiPackMetadataTopBar(
viewModel.clear() viewModel.clear()
nav.popBack() nav.popBack()
}, },
onPost = { onPost = onSubmit,
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,
)
}
},
) )
} else { } else {
SavingTopBar( SavingTopBar(
@@ -149,83 +191,135 @@ private fun EmojiPackMetadataTopBar(
viewModel.clear() viewModel.clear()
nav.popBack() nav.popBack()
}, },
onPost = { onPost = onSubmit,
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,
)
}
},
) )
} }
} }
@Composable @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( OutlinedTextField(
label = { Text(text = stringRes(R.string.emoji_pack_name_label)) },
modifier = Modifier.fillMaxWidth(),
value = viewModel.name.value, value = viewModel.name.value,
onValueChange = { viewModel.name.value = it }, onValueChange = { viewModel.name.value = it },
label = { Text(text = stringRes(R.string.emoji_pack_name_label)) },
placeholder = { placeholder = {
Text( Text(
text = stringRes(R.string.emoji_pack_name_label), text = stringRes(R.string.emoji_pack_name_label),
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = keyboardOptions =
KeyboardOptions.Default.copy( KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences, capitalization = KeyboardCapitalization.Sentences,
), ),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
) )
}
@Composable Spacer(modifier = Modifier.height(12.dp))
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)
}
},
)
}
@Composable
private fun PackDescription(viewModel: EmojiPackMetadataViewModel) {
OutlinedTextField( OutlinedTextField(
label = { Text(text = stringRes(R.string.emoji_pack_description_label)) },
modifier = Modifier.fillMaxWidth(),
value = viewModel.description.value, value = viewModel.description.value,
onValueChange = { viewModel.description.value = it }, 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 =
KeyboardOptions.Default.copy( KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences, capitalization = KeyboardCapitalization.Sentences,
), ),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
minLines = 3,
) )
} }
@@ -59,12 +59,25 @@ class EmojiPackMetadataViewModel : ViewModel() {
val picture = mutableStateOf(TextFieldValue()) val picture = mutableStateOf(TextFieldValue())
val description = 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 { 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) { fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel this.accountViewModel = accountViewModel
this.account = accountViewModel.account this.account = accountViewModel.account
@@ -81,25 +94,90 @@ class EmojiPackMetadataViewModel : ViewModel() {
name.value = TextFieldValue(existing?.title ?: "") name.value = TextFieldValue(existing?.title ?: "")
picture.value = TextFieldValue(existing?.image ?: "") picture.value = TextFieldValue(existing?.image ?: "")
description.value = TextFieldValue(existing?.description ?: "") 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() { fun createOrUpdate() {
accountViewModel.launchSigner { accountViewModel.launchSigner {
val currentPack = pack publish()
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,
)
}
clear() clear()
} }
} }
@@ -108,33 +186,21 @@ class EmojiPackMetadataViewModel : ViewModel() {
name.value = TextFieldValue() name.value = TextFieldValue()
picture.value = TextFieldValue() picture.value = TextFieldValue()
description.value = TextFieldValue() description.value = TextFieldValue()
pickedMedia = null
} }
fun uploadForPicture( /**
uri: SelectedMedia, * Uploads [galleryUri] using the user's configured default file server,
context: Context, * respecting the account's strip-location-on-upload preference. Returns the
onError: (String, String) -> Unit, * published URL or null on failure (having already called [onError]).
) { *
viewModelScope.launch(Dispatchers.IO) { * Mirrors the NIP-96/Blossom block used by `BookmarkGroupMetadataViewModel.upload`.
upload( */
uri, private suspend fun uploadImage(
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia, galleryUri: SelectedMedia,
context: Context, context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
) { ): String? {
onUploading(true)
val sourceUri = val sourceUri =
if (account.settings.stripLocationOnUpload) { if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) 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_title),
stringRes(context, R.string.metadata_strip_failed_upload_cancelled), stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
) )
onUploading(false) return null
return
} }
result.uri result.uri
} else { } else {
@@ -152,7 +217,7 @@ class EmojiPackMetadataViewModel : ViewModel() {
} }
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try { return try {
val result = val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) { if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload( Nip96Uploader().upload(
@@ -182,19 +247,24 @@ class EmojiPackMetadataViewModel : ViewModel() {
} }
if (result.url != null) { if (result.url != null) {
onUploading(false) result.url
onUploaded(result.url)
} else { } else {
onUploading(false) onError(
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) 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) { } catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false) onError(
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)) 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) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) 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_name_label">Pack name</string>
<string name="emoji_pack_description_label">Description (optional)</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_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="no_emoji_packs">You don\'t have any emoji packs yet</string>
<string name="emoji_pack_count">%1$d emojis</string> <string name="emoji_pack_count">%1$d emojis</string>
<string name="my_emoji_list_title">My Emoji List</string> <string name="my_emoji_list_title">My Emoji List</string>