From 64079d418803454565d3939e70700b841bb1b344 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:11:58 +0000 Subject: [PATCH] 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); parsedPubKeys / awardeesText state removed. --- .../loggedIn/badges/award/AwardBadgeScreen.kt | 220 +++++++++++++----- .../badges/award/AwardBadgeViewModel.kt | 25 +- amethyst/src/main/res/values/strings.xml | 6 +- 3 files changed, 175 insertions(+), 76 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt index 8f971fb70..f600bfd52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt @@ -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() } + + 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, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt index cdef93f67..3a2b2d5b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt @@ -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(null) - var awardeesText by mutableStateOf(TextFieldValue("")) fun init( accountVM: AccountViewModel, @@ -57,26 +55,11 @@ class AwardBadgeViewModel : ViewModel() { definition = ev } - fun parsedPubKeys(): List = - 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) { 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) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1615aeaa2..d50010c0e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -441,9 +441,9 @@ Thumbnail URL (optional) https://example.com/badge-thumb.png Loading badge… - Recipients (npub or hex, one per line) - npub1…\nnpub1… - %1$d recipient(s) will receive this badge + Search users + Name, npub, or NIP-05 + Remove Untitled badge Awarded to %1$d You received a badge