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:
@@ -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,
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ fun BadgesScreen(
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
NewBadgeButton(nav)
|
||||
NewBadgeButton(accountViewModel)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { paddingValues ->
|
||||
|
||||
+40
-6
@@ -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,
|
||||
|
||||
+287
@@ -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 },
|
||||
)
|
||||
}
|
||||
+196
@@ -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
|
||||
}
|
||||
}
|
||||
-163
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-107
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user