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.
This commit is contained in:
Claude
2026-04-19 20:53:24 +00:00
parent a346ae86de
commit 29518dbf19
8 changed files with 524 additions and 283 deletions
@@ -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<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewBadge> { NewBadgeScreen(it.editDTag, accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
@@ -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,
@@ -77,7 +77,7 @@ fun BadgesScreen(
}
},
floatingButton = {
NewBadgeButton(nav)
NewBadgeButton(accountViewModel)
},
accountViewModel = accountViewModel,
) { paddingValues ->
@@ -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<ImmutableList<SelectedMedia>>(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,
@@ -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<SelectedMedia>,
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 },
)
}
@@ -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<ServerName?>(null)
var name by mutableStateOf("")
var description by mutableStateOf("")
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(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<SelectedMedia>,
) {
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
}
}
@@ -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,
)
}
}
@@ -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()
}
}