refactor(badges/new): show form first with an upload placeholder

The FAB now opens the new-badge dialog directly. The dialog renders a
big bordered "Upload an image" placeholder where the picture will go;
tapping it opens the gallery. Once the user picks an image, the
placeholder is replaced by the existing ShowImageUploadGallery preview
and tapping the preview lets them pick a different image.

Lets the user see the whole form (name, description, server, quality,
strip-metadata) immediately instead of being thrown into the picker
the moment they hit the FAB.
This commit is contained in:
Claude
2026-04-19 21:20:38 +00:00
parent 29518dbf19
commit 8eeeae9601
4 changed files with 122 additions and 43 deletions
@@ -21,8 +21,6 @@
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.filled.AddPhotoAlternate
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -34,51 +32,35 @@ 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.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.painterRes
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(accountViewModel: AccountViewModel) {
var wantsToPickImage by remember { mutableStateOf(false) }
var pickedMedia by remember { mutableStateOf<ImmutableList<SelectedMedia>>(persistentListOf()) }
var showDialog by remember { mutableStateOf(false) }
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()) {
if (showDialog) {
NewBadgeDialog(
uris = pickedMedia,
onClose = { pickedMedia = persistentListOf() },
onClose = { showDialog = false },
postViewModel = postViewModel,
accountViewModel = accountViewModel,
)
}
FloatingActionButton(
onClick = { wantsToPickImage = true },
onClick = { showDialog = true },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
painter = painterRes(R.drawable.ic_compose, 5),
contentDescription = stringRes(id = R.string.new_badge),
modifier = Size26Modifier,
tint = Color.White,
@@ -20,20 +20,28 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.aspectRatio
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -44,12 +52,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -57,7 +69,7 @@ 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.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TextSpinner
@@ -69,13 +81,12 @@ 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.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewBadgeDialog(
uris: ImmutableList<SelectedMedia>,
onClose: () -> Unit,
postViewModel: NewBadgeModel,
accountViewModel: AccountViewModel,
@@ -85,12 +96,25 @@ fun NewBadgeDialog(
val scrollState = rememberScrollState()
LaunchedEffect(uris) {
postViewModel.load(account, uris)
LaunchedEffect(account) {
postViewModel.init(account)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
var wantsToPickImage by remember { mutableStateOf(false) }
if (wantsToPickImage) {
GallerySelect(
onImageUri = { uris ->
wantsToPickImage = false
postViewModel.setPickedMedia(
if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(),
)
},
)
}
Dialog(
onDismissRequest = onClose,
properties =
@@ -137,7 +161,15 @@ fun NewBadgeDialog(
.fillMaxWidth()
.verticalScroll(scrollState),
) {
BadgeImageForm(postViewModel, accountViewModel)
BadgeImagePicker(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
onPickImage = { wantsToPickImage = true },
)
Spacer(modifier = Modifier.height(12.dp))
BadgeFormFields(postViewModel, accountViewModel)
}
}
}
@@ -146,7 +178,69 @@ fun NewBadgeDialog(
}
@Composable
private fun BadgeImageForm(
private fun BadgeImagePicker(
postViewModel: NewBadgeModel,
accountViewModel: AccountViewModel,
onPickImage: () -> Unit,
) {
if (postViewModel.hasPickedImage()) {
postViewModel.multiOrchestrator?.let {
// Tap the preview to swap to a different image.
Box(modifier = Modifier.clickable(onClick = onPickImage)) {
ShowImageUploadGallery(
list = it,
onDelete = { postViewModel.setPickedMedia(persistentListOf()) },
accountViewModel = accountViewModel,
)
}
}
} else {
UploadPlaceholder(onClick = onPickImage)
}
}
@Composable
private fun UploadPlaceholder(onClick: () -> Unit) {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(12.dp),
).clickable(onClick = onClick)
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringRes(R.string.badge_upload_image_cta),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.badge_upload_image_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun BadgeFormFields(
postViewModel: NewBadgeModel,
accountViewModel: AccountViewModel,
) {
@@ -160,18 +254,6 @@ private fun BadgeImageForm(
.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 },
@@ -75,6 +75,13 @@ class NewBadgeModel : ViewModel() {
var onceUploaded: () -> Unit = {}
fun init(account: Account) {
if (this.account == account) return
this.account = account
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
}
fun load(
account: Account,
uris: ImmutableList<SelectedMedia>,
@@ -87,6 +94,12 @@ class NewBadgeModel : ViewModel() {
this.description = ""
}
fun setPickedMedia(uris: ImmutableList<SelectedMedia>) {
this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
}
fun hasPickedImage(): Boolean = multiOrchestrator != null
fun canPost(): Boolean =
!isUploading &&
multiOrchestrator != null &&
+2
View File
@@ -440,6 +440,8 @@
<string name="badge_image_placeholder">https://example.com/badge.png</string>
<string name="badge_thumb_label">Thumbnail URL (optional)</string>
<string name="badge_thumb_placeholder">https://example.com/badge-thumb.png</string>
<string name="badge_upload_image_cta">Upload an image</string>
<string name="badge_upload_image_hint">Pick a square image to be the face of your badge.</string>
<string name="award_badge_loading">Loading badge…</string>
<string name="award_badge_search_label">Search users</string>
<string name="award_badge_search_placeholder">Name, npub, or NIP-05</string>