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.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect 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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AwardBadgeScreen( fun AwardBadgeScreen(
kind: Int, kind: Int,
@@ -64,8 +77,19 @@ fun AwardBadgeScreen(
vm.init(accountViewModel, kind, pubKeyHex, dTag) 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 { BackHandler {
vm.cancel()
nav.popBack() nav.popBack()
} }
@@ -73,43 +97,85 @@ fun AwardBadgeScreen(
topBar = { topBar = {
SavingTopBar( SavingTopBar(
titleRes = R.string.award_badge, titleRes = R.string.award_badge,
isActive = vm::canPost, isActive = { vm.definition != null && selectedUsers.isNotEmpty() },
onCancel = { onCancel = { nav.popBack() },
vm.cancel()
nav.popBack()
},
onPost = { onPost = {
val toAward = selectedUsers.toList()
accountViewModel.launchSigner { accountViewModel.launchSigner {
vm.sendPost() vm.sendPost(toAward)
nav.popBack() nav.popBack()
} }
}, },
) )
}, },
) { pad -> ) { padding ->
Surface( Column(
modifier = modifier =
Modifier Modifier
.padding(pad) .padding(padding)
.consumeWindowInsets(pad) .consumeWindowInsets(padding)
.imePadding(), .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 @Composable
private fun AwardBadgeBody(vm: AwardBadgeViewModel) { private fun BadgeSummary(vm: AwardBadgeViewModel) {
val scrollState = rememberScrollState() val def = vm.definition
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
) {
val def = vm.definition
if (def == null) { if (def == null) {
Text( Text(
text = stringRes(R.string.award_badge_loading), text = stringRes(R.string.award_badge_loading),
@@ -118,32 +184,82 @@ private fun AwardBadgeBody(vm: AwardBadgeViewModel) {
} else { } else {
Text( Text(
text = def.name() ?: def.dTag(), text = def.name() ?: def.dTag(),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
) )
Spacer(modifier = Modifier.height(4.dp)) def.description()?.takeIf { it.isNotBlank() }?.let {
def.description()?.let { Spacer(modifier = Modifier.height(2.dp))
Text(it, style = MaterialTheme.typography.bodyMedium) 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)) Text(
text = text,
OutlinedTextField( style = MaterialTheme.typography.bodySmall,
value = vm.awardeesText, color = MaterialTheme.colorScheme.onSurfaceVariant,
onValueChange = { vm.awardeesText = it }, maxLines = 1,
label = { Text(stringRes(R.string.award_badge_recipients_label)) }, overflow = TextOverflow.Ellipsis,
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,
)
}
} }
@@ -24,15 +24,14 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
@Stable @Stable
@@ -41,7 +40,6 @@ class AwardBadgeViewModel : ViewModel() {
lateinit var account: Account lateinit var account: Account
var definition by mutableStateOf<BadgeDefinitionEvent?>(null) var definition by mutableStateOf<BadgeDefinitionEvent?>(null)
var awardeesText by mutableStateOf(TextFieldValue(""))
fun init( fun init(
accountVM: AccountViewModel, accountVM: AccountViewModel,
@@ -57,26 +55,11 @@ class AwardBadgeViewModel : ViewModel() {
definition = ev definition = ev
} }
fun parsedPubKeys(): List<HexKey> = suspend fun sendPost(awardees: List<User>) {
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() {
val def = definition ?: return val def = definition ?: return
val awardees = parsedPubKeys().map { PTag(it) }
if (awardees.isEmpty()) return if (awardees.isEmpty()) return
account.sendBadgeAward(def, awardees) val pTags = awardees.map { PTag(it.pubkeyHex) }
cancel() 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_label">Thumbnail URL (optional)</string>
<string name="badge_thumb_placeholder">https://example.com/badge-thumb.png</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_loading">Loading badge…</string>
<string name="award_badge_recipients_label">Recipients (npub or hex, one per line)</string> <string name="award_badge_search_label">Search users</string>
<string name="award_badge_recipients_placeholder">npub1…\nnpub1…</string> <string name="award_badge_search_placeholder">Name, npub, or NIP-05</string>
<string name="award_badge_recipient_count">%1$d recipient(s) will receive this badge</string> <string name="award_badge_remove_recipient">Remove</string>
<string name="badge_untitled">Untitled badge</string> <string name="badge_untitled">Untitled badge</string>
<string name="badge_awardees_label">Awarded to %1$d</string> <string name="badge_awardees_label">Awarded to %1$d</string>
<string name="badge_award_received_title">You received a badge</string> <string name="badge_award_received_title">You received a badge</string>