From 29518dbf1926e6a6ff8f02667de2b90716f9ed40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:53:24 +0000 Subject: [PATCH] feat(badges/new): image-first creation flow with upload + auto UUID Replace the form-first create screen with a picker-first media pipeline that matches the other upload screens in the app. - NewBadgeButton (FAB, AddPhotoAlternate icon) opens GallerySelect directly. - After the image is picked, NewBadgeDialog mirrors the NewMediaView / ImageVideoPost pattern: a thumbnail strip, name + description fields, server picker, compression-quality slider, and strip-metadata switch. - NewBadgeModel drives the upload through the shared MultiOrchestrator. Only on a successful upload does it reach into Account.sendBadgeDefinition with an auto-generated UUID d-tag, the uploaded URL + dimensions, and the uploaded URL reused as the NIP-58 thumb (a separate thumbnail upload can land as a follow-up). The Cancel / Post buttons use the CreatingTopBar so "Create" reads right on the primary action. Submit is disabled until the name is non-empty, an image is staged, and a server is selected. Drops the old route-based NewBadgeScreen / NewBadgeViewModel and the Route.NewBadge entry. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 - .../amethyst/ui/navigation/routes/Routes.kt | 4 - .../ui/screen/loggedIn/badges/BadgesScreen.kt | 2 +- .../screen/loggedIn/badges/NewBadgeButton.kt | 46 ++- .../loggedIn/badges/post/NewBadgeDialog.kt | 287 ++++++++++++++++++ .../loggedIn/badges/post/NewBadgeModel.kt | 196 ++++++++++++ .../loggedIn/badges/post/NewBadgeScreen.kt | 163 ---------- .../loggedIn/badges/post/NewBadgeViewModel.kt | 107 ------- 8 files changed, 524 insertions(+), 283 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index efbf4541d..f7a069125 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -66,7 +66,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen @@ -220,7 +219,6 @@ fun BuildNavigation( composableFromEnd { PollsScreen(accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } - composableFromBottomArgs { NewBadgeScreen(it.editDTag, accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { ProductsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 6a870f2bf..b9d6ba509 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -49,10 +49,6 @@ sealed class Route { @Serializable object ProfileBadges : Route() - @Serializable data class NewBadge( - val editDTag: String? = null, - ) : Route() - @Serializable data class AwardBadge( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt index 3379f4e2c..19cf4ed9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -77,7 +77,7 @@ fun BadgesScreen( } }, floatingButton = { - NewBadgeButton(nav) + NewBadgeButton(accountViewModel) }, accountViewModel = accountViewModel, ) { paddingValues -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt index 64c858db7..55d365f7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt @@ -22,29 +22,63 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable -fun NewBadgeButton(nav: INav) { +fun NewBadgeButton(accountViewModel: AccountViewModel) { + var wantsToPickImage by remember { mutableStateOf(false) } + var pickedMedia by remember { mutableStateOf>(persistentListOf()) } + + val postViewModel: NewBadgeModel = viewModel() + + if (wantsToPickImage) { + GallerySelect( + onImageUri = { uris -> + wantsToPickImage = false + // We only need the first picked image for a badge. + pickedMedia = if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf() + }, + ) + } + + if (pickedMedia.isNotEmpty()) { + NewBadgeDialog( + uris = pickedMedia, + onClose = { pickedMedia = persistentListOf() }, + postViewModel = postViewModel, + accountViewModel = accountViewModel, + ) + } + FloatingActionButton( - onClick = { nav.nav(Route.NewBadge()) }, + onClick = { wantsToPickImage = true }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - imageVector = Icons.Outlined.Add, + imageVector = Icons.Default.AddPhotoAlternate, contentDescription = stringRes(id = R.string.new_badge), modifier = Size26Modifier, tint = Color.White, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt new file mode 100644 index 000000000..ab2c0ff8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery +import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewBadgeDialog( + uris: ImmutableList, + onClose: () -> Unit, + postViewModel: NewBadgeModel, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val context = LocalContext.current + + val scrollState = rememberScrollState() + + LaunchedEffect(uris) { + postViewModel.load(account, uris) + } + + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + + Dialog( + onDismissRequest = onClose, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + SetDialogToEdgeToEdge() + Scaffold( + topBar = { + CreatingTopBar( + titleRes = R.string.new_badge, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.cancelModel() + onClose() + }, + onPost = { + postViewModel.upload( + context, + onSuccess = onClose, + onError = accountViewModel.toastManager::toast, + ) + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 10.dp, vertical = 10.dp), + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { + BadgeImageForm(postViewModel, accountViewModel) + } + } + } + } + } +} + +@Composable +private fun BadgeImageForm( + postViewModel: NewBadgeModel, + accountViewModel: AccountViewModel, +) { + val fileServers by accountViewModel.account.blossomServers.hostNameFlow + .collectAsState() + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { TitleExplainer(it.name, it.baseUrl) } + .toImmutableList() + } + + postViewModel.multiOrchestrator?.let { + ShowImageUploadGallery( + it, + // Only one item expected; removing via UI would orphan the dialog. + // Ignore deletes — Cancel clears state via cancelModel(). + onDelete = { }, + accountViewModel = accountViewModel, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = postViewModel.name, + onValueChange = { postViewModel.name = it }, + label = { Text(stringRes(R.string.badge_name_label)) }, + placeholder = { + Text( + text = stringRes(R.string.badge_name_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = postViewModel.description, + onValueChange = { postViewModel.description = it }, + label = { Text(stringRes(R.string.badge_description_label)) }, + placeholder = { + Text( + text = stringRes(R.string.badge_description_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .height(120.dp), + minLines = 2, + maxLines = 6, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = + fileServers + .firstOrNull { it == accountViewModel.account.settings.defaultFileServer } + ?.name + ?: fileServers.firstOrNull()?.name + ?: DEFAULT_MEDIA_SERVERS[0].name, + options = fileServerOptions, + onSelect = { postViewModel.selectedServer = fileServers[it] }, + ) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Text( + text = stringRes(R.string.media_compression_quality_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.media_compression_quality_explainer), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = + when (postViewModel.mediaQualitySlider) { + 0 -> stringRes(R.string.media_compression_quality_low) + 1 -> stringRes(R.string.media_compression_quality_medium) + 2 -> stringRes(R.string.media_compression_quality_high) + 3 -> stringRes(R.string.media_compression_quality_uncompressed) + else -> stringRes(R.string.media_compression_quality_medium) + }, + modifier = Modifier.align(Alignment.Center), + ) + } + + Slider( + value = postViewModel.mediaQualitySlider.toFloat(), + onValueChange = { postViewModel.mediaQualitySlider = it.toInt() }, + valueRange = 0f..3f, + steps = 2, + ) + } + + SettingSwitchItem( + title = R.string.strip_metadata_label, + description = R.string.strip_metadata_description, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + checked = postViewModel.stripMetadata, + onCheckedChange = { postViewModel.stripMetadata = it }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt new file mode 100644 index 000000000..f69654412 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * Drives the "new badge" creation flow: uploads the user-picked image (at the + * chosen server / compression), then publishes a NIP-58 BadgeDefinitionEvent + * (kind 30009) with an auto-generated UUID d-tag, the uploaded URL as both + * `image` and `thumb`, and the user-provided name / description. + * + * Intentionally does NOT publish the event unless the image upload succeeds — + * otherwise we'd announce a badge that references a URL that doesn't exist. + */ +@Stable +class NewBadgeModel : ViewModel() { + var account: Account? = null + + var isUploading by mutableStateOf(false) + + var selectedServer by mutableStateOf(null) + var name by mutableStateOf("") + var description by mutableStateOf("") + + var multiOrchestrator by mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + + // 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED + var mediaQualitySlider by mutableIntStateOf(1) + + var stripMetadata by mutableStateOf(true) + + var onceUploaded: () -> Unit = {} + + fun load( + account: Account, + uris: ImmutableList, + ) { + this.account = account + this.multiOrchestrator = MultiOrchestrator(uris) + this.selectedServer = defaultServer() + this.stripMetadata = account.settings.stripLocationOnUpload + this.name = "" + this.description = "" + } + + fun canPost(): Boolean = + !isUploading && + multiOrchestrator != null && + selectedServer != null && + name.isNotBlank() + + fun upload( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) = try { + uploadUnsafe(context, onSuccess, onError) + } 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), + ) + } + + private fun uploadUnsafe( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val myAccount = account ?: return@launch + val serverToUse = selectedServer ?: return@launch + val orch = multiOrchestrator ?: return@launch + + isUploading = true + + val results = + orch.upload( + alt = name, + contentWarningReason = null, + mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider), + server = serverToUse, + account = myAccount, + context = context, + useH265 = false, + stripMetadata = stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + + if (!results.allGood) { + val messages = + results.errors + .map { stringRes(context, it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages) + isUploading = false + return@launch + } + + val uploaded = + results.successful.firstNotNullOfOrNull { + it.result as? UploadOrchestrator.OrchestratorResult.ServerResult + } + + if (uploaded == null) { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "Upload succeeded but no image URL was returned by the server.", + ) + isUploading = false + return@launch + } + + val imageUrl = uploaded.url + val dimensions = uploaded.fileHeader.dim + + myAccount.sendBadgeDefinition( + badgeId = UUID.randomUUID().toString(), + name = name.trim(), + imageUrl = imageUrl, + imageDim = dimensions, + description = description.trim().ifBlank { null }, + // NIP-58 thumb is optional; we emit it pointing at the same + // uploaded asset so clients that only honor the thumb tag also + // see the badge image. + thumbs = listOf(ThumbTag(imageUrl, dimensions)), + ) + + myAccount.settings.changeDefaultFileServer(serverToUse) + myAccount.settings.changeStripLocationOnUpload(stripMetadata) + + onSuccess() + onceUploaded() + cancelModel() + } + } + + fun cancelModel() { + multiOrchestrator = null + isUploading = false + name = "" + description = "" + selectedServer = defaultServer() + } + + fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] + + fun onceUploaded(action: () -> Unit) { + this.onceUploaded = action + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt deleted file mode 100644 index ac8b6f4cb..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -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.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -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.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NewBadgeScreen( - editDTag: String?, - accountViewModel: AccountViewModel, - nav: INav, -) { - val vm: NewBadgeViewModel = viewModel() - - LaunchedEffect(accountViewModel, editDTag) { - vm.init(accountViewModel, editDTag) - } - - BackHandler { - vm.cancel() - nav.popBack() - } - - Scaffold( - topBar = { - PostingTopBar( - titleRes = if (vm.isEdit) R.string.edit_badge else R.string.new_badge, - isActive = vm::canPost, - onCancel = { - vm.cancel() - nav.popBack() - }, - onPost = { - accountViewModel.launchSigner { - vm.sendPost() - nav.popBack() - } - }, - ) - }, - ) { pad -> - Surface( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .imePadding(), - ) { - NewBadgeBody(vm) - } - } -} - -@Composable -private fun NewBadgeBody(vm: NewBadgeViewModel) { - val scrollState = rememberScrollState() - - Column( - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(16.dp), - ) { - OutlinedTextField( - value = vm.badgeId, - onValueChange = { vm.badgeId = it }, - label = { Text(stringRes(R.string.badge_id_label)) }, - placeholder = { Text(stringRes(R.string.badge_id_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !vm.isEdit, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.name, - onValueChange = { vm.name = it }, - label = { Text(stringRes(R.string.badge_name_label)) }, - placeholder = { Text(stringRes(R.string.badge_name_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.description, - onValueChange = { vm.description = it }, - label = { Text(stringRes(R.string.badge_description_label)) }, - placeholder = { Text(stringRes(R.string.badge_description_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 6, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.imageUrl, - onValueChange = { vm.imageUrl = it }, - label = { Text(stringRes(R.string.badge_image_label)) }, - placeholder = { Text(stringRes(R.string.badge_image_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.thumbUrl, - onValueChange = { vm.thumbUrl = it }, - label = { Text(stringRes(R.string.badge_thumb_label)) }, - placeholder = { Text(stringRes(R.string.badge_thumb_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt deleted file mode 100644 index 1470fdca1..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post - -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent - -@Stable -class NewBadgeViewModel : ViewModel() { - lateinit var accountViewModel: AccountViewModel - lateinit var account: Account - - var badgeId by mutableStateOf(TextFieldValue("")) - var name by mutableStateOf(TextFieldValue("")) - var description by mutableStateOf(TextFieldValue("")) - var imageUrl by mutableStateOf(TextFieldValue("")) - var thumbUrl by mutableStateOf(TextFieldValue("")) - - var isEdit by mutableStateOf(false) - - fun init( - accountVM: AccountViewModel, - editDTag: String?, - ) { - this.accountViewModel = accountVM - this.account = accountVM.account - - if (editDTag.isNullOrBlank()) return - - val existing = - LocalCache - .getAddressableNoteIfExists( - Address(BadgeDefinitionEvent.KIND, account.signer.pubKey, editDTag), - )?.event as? BadgeDefinitionEvent ?: return - - isEdit = true - badgeId = TextFieldValue(existing.dTag()) - name = TextFieldValue(existing.name() ?: "") - description = TextFieldValue(existing.description() ?: "") - imageUrl = TextFieldValue(existing.image() ?: "") - thumbUrl = TextFieldValue(existing.thumb() ?: "") - } - - fun canPost(): Boolean = badgeId.text.isNotBlank() && name.text.isNotBlank() - - fun cancel() { - badgeId = TextFieldValue("") - name = TextFieldValue("") - description = TextFieldValue("") - imageUrl = TextFieldValue("") - thumbUrl = TextFieldValue("") - isEdit = false - } - - suspend fun sendPost() { - if (!canPost()) return - - val thumb = thumbUrl.text.ifBlank { null } - val thumbs = - if (thumb != null) { - listOf( - com.vitorpamplona.quartz.nip58Badges.definition.tags - .ThumbTag(thumb), - ) - } else { - emptyList() - } - - account.sendBadgeDefinition( - badgeId = badgeId.text.trim(), - name = name.text.trim(), - imageUrl = imageUrl.text.ifBlank { null }, - imageDim = null, - description = description.text.ifBlank { null }, - thumbs = thumbs, - ) - - cancel() - } -}