feat(badges/award): swap raw pubkey textarea for user search

The award screen now collects awardees the same way other selection
screens do (AddMemberScreen pattern):
- A search OutlinedTextField wired into UserSuggestionState +
  ShowUserSuggestionList. Typing >2 chars triggers the existing user
  search pipeline.
- Selecting a suggestion adds the user to a header list of selected
  recipients (avatar, display name, NIP-05 / pubkey), each with a
  Remove button.
- Submit button disables until at least one recipient is picked.

ViewModel reduced to definition + sendPost(awardees: List<User>);
parsedPubKeys / awardeesText state removed.
This commit is contained in:
Claude
2026-04-19 15:11:58 +00:00
parent 55679b0a9e
commit 64079d4188
3 changed files with 175 additions and 76 deletions
@@ -22,34 +22,47 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AwardBadgeScreen(
kind: Int,
@@ -64,8 +77,19 @@ fun AwardBadgeScreen(
vm.init(accountViewModel, kind, pubKeyHex, dTag)
}
var searchInput by remember { mutableStateOf("") }
val selectedUsers = remember { mutableStateListOf<User>() }
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
DisposableEffect(Unit) {
onDispose { userSuggestions.reset() }
}
BackHandler {
vm.cancel()
nav.popBack()
}
@@ -73,43 +97,85 @@ fun AwardBadgeScreen(
topBar = {
SavingTopBar(
titleRes = R.string.award_badge,
isActive = vm::canPost,
onCancel = {
vm.cancel()
nav.popBack()
},
isActive = { vm.definition != null && selectedUsers.isNotEmpty() },
onCancel = { nav.popBack() },
onPost = {
val toAward = selectedUsers.toList()
accountViewModel.launchSigner {
vm.sendPost()
vm.sendPost(toAward)
nav.popBack()
}
},
)
},
) { pad ->
Surface(
) { padding ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.padding(padding)
.consumeWindowInsets(padding)
.imePadding(),
) {
AwardBadgeBody(vm)
BadgeSummary(vm)
HorizontalDivider()
if (selectedUsers.isNotEmpty()) {
selectedUsers.toList().forEachIndexed { index, user ->
SelectedUserRow(
user = user,
accountViewModel = accountViewModel,
nav = nav,
onClear = { selectedUsers.remove(user) },
)
if (index < selectedUsers.lastIndex) HorizontalDivider()
}
HorizontalDivider()
}
OutlinedTextField(
value = searchInput,
onValueChange = { newValue ->
searchInput = newValue
if (newValue.length > 2) {
userSuggestions.processCurrentWord(newValue)
} else {
userSuggestions.reset()
}
},
label = { Text(stringRes(R.string.award_badge_search_label)) },
placeholder = { Text(stringRes(R.string.award_badge_search_placeholder)) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
)
Spacer(modifier = Modifier.height(4.dp))
if (searchInput.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
if (selectedUsers.none { it.pubkeyHex == user.pubkeyHex }) {
selectedUsers.add(user)
}
searchInput = ""
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
}
}
}
@Composable
private fun AwardBadgeBody(vm: AwardBadgeViewModel) {
val scrollState = rememberScrollState()
Column(
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
) {
val def = vm.definition
private fun BadgeSummary(vm: AwardBadgeViewModel) {
val def = vm.definition
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
if (def == null) {
Text(
text = stringRes(R.string.award_badge_loading),
@@ -118,32 +184,82 @@ private fun AwardBadgeBody(vm: AwardBadgeViewModel) {
} else {
Text(
text = def.name() ?: def.dTag(),
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
def.description()?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
def.description()?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun SelectedUserRow(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
onClear: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UserPicture(
userHex = user.pubkeyHex,
size = 40.dp,
accountViewModel = accountViewModel,
nav = nav,
)
Column(
modifier = Modifier.weight(1f).padding(start = 12.dp),
) {
Text(
text = user.toBestDisplayName(),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
UserSecondaryLine(user)
}
TextButton(onClick = onClear) {
Text(stringRes(R.string.award_badge_remove_recipient))
}
}
}
@Composable
private fun UserSecondaryLine(user: User) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
val text =
when (val state = nip05StateMetadata) {
is Nip05State.Exists -> {
val name = state.nip05.name
if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}"
}
else -> {
user.pubkeyDisplayHex()
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = vm.awardeesText,
onValueChange = { vm.awardeesText = it },
label = { Text(stringRes(R.string.award_badge_recipients_label)) },
placeholder = { Text(stringRes(R.string.award_badge_recipients_placeholder)) },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
maxLines = 10,
)
Spacer(modifier = Modifier.height(8.dp))
val parsed = vm.parsedPubKeys()
Text(
text = stringRes(R.string.award_badge_recipient_count, parsed.size),
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -24,15 +24,14 @@ 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.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
@Stable
@@ -41,7 +40,6 @@ class AwardBadgeViewModel : ViewModel() {
lateinit var account: Account
var definition by mutableStateOf<BadgeDefinitionEvent?>(null)
var awardeesText by mutableStateOf(TextFieldValue(""))
fun init(
accountVM: AccountViewModel,
@@ -57,26 +55,11 @@ class AwardBadgeViewModel : ViewModel() {
definition = ev
}
fun parsedPubKeys(): List<HexKey> =
awardeesText.text
.split('\n', ',', ' ', ';')
.mapNotNull { raw ->
val trimmed = raw.trim()
if (trimmed.isEmpty()) null else decodePublicKeyAsHexOrNull(trimmed)
}.distinct()
fun canPost(): Boolean = definition != null && parsedPubKeys().isNotEmpty()
fun cancel() {
awardeesText = TextFieldValue("")
}
suspend fun sendPost() {
suspend fun sendPost(awardees: List<User>) {
val def = definition ?: return
val awardees = parsedPubKeys().map { PTag(it) }
if (awardees.isEmpty()) return
account.sendBadgeAward(def, awardees)
cancel()
val pTags = awardees.map { PTag(it.pubkeyHex) }
account.sendBadgeAward(def, pTags)
}
}
+3 -3
View File
@@ -441,9 +441,9 @@
<string name="badge_thumb_label">Thumbnail URL (optional)</string>
<string name="badge_thumb_placeholder">https://example.com/badge-thumb.png</string>
<string name="award_badge_loading">Loading badge…</string>
<string name="award_badge_recipients_label">Recipients (npub or hex, one per line)</string>
<string name="award_badge_recipients_placeholder">npub1…\nnpub1…</string>
<string name="award_badge_recipient_count">%1$d recipient(s) will receive this badge</string>
<string name="award_badge_search_label">Search users</string>
<string name="award_badge_search_placeholder">Name, npub, or NIP-05</string>
<string name="award_badge_remove_recipient">Remove</string>
<string name="badge_untitled">Untitled badge</string>
<string name="badge_awardees_label">Awarded to %1$d</string>
<string name="badge_award_received_title">You received a badge</string>