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