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:
+168
-52
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+4
-21
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user