diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt index ebfa77b3bd..2917509791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt @@ -170,7 +170,7 @@ fun EditPodcastShowScreen( SwitchRow(stringRes(R.string.podcast_show_complete), vm.complete.value) { vm.complete.value = it } SwitchRow(stringRes(R.string.podcast_show_locked), vm.locked.value) { vm.locked.value = it } - V4VSplitEditor(vm.splitEditor) + V4VSplitEditor(vm.splitEditor, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt index 9741a4f522..941a13c52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt @@ -252,7 +252,7 @@ fun NewPodcastEpisodeScreen( ) // Episode-level V4V override; leave empty to inherit the show's split. - V4VSplitEditor(vm.splitEditor) + V4VSplitEditor(vm.splitEditor, accountViewModel) } if (vm.isEditing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt index 6b8057c8f3..cf9f22fa05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -24,9 +24,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.FilterChip @@ -37,6 +40,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +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.draw.clip @@ -46,18 +52,32 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +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.Size40dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.grayText /** - * Editor for a Podcasting-2.0 value-for-value split: a card listing each recipient (name, lnaddress - * vs node toggle, address, weight, optional fee) plus an "Add recipient" action. Each recipient's - * share of incoming sats is shown live as a percentage of the total weight. Drives a - * [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue] on save. + * Editor for a Podcasting-2.0 value-for-value split. Recipients are added the Amethyst-native way — + * search for a Nostr user and they're rendered with avatar + name, their lightning address resolved + * automatically — with a manual "add address" fallback for raw lightning addresses or node keysend + * destinations. Each recipient's share of incoming sats is shown live as a percentage of the total + * weight. Drives a [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue]. */ @Composable -fun V4VSplitEditor(state: V4VSplitEditorState) { +fun V4VSplitEditor( + state: V4VSplitEditorState, + accountViewModel: AccountViewModel, +) { val total = state.totalSplit() + val userSuggestions = + remember { UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) } + var search by remember { mutableStateOf("") } Column( modifier = @@ -92,27 +112,60 @@ fun V4VSplitEditor(state: V4VSplitEditorState) { } state.recipients.forEach { draft -> - RecipientCard( - draft = draft, - total = total, - onRemove = { state.remove(draft) }, + if (draft.user.value != null) { + UserRecipientCard(draft, total, accountViewModel, onRemove = { state.remove(draft) }) + } else { + ManualRecipientCard(draft, total, onRemove = { state.remove(draft) }) + } + } + + // Search a Nostr user to add (resolves their lightning address). Beautiful path. + OutlinedTextField( + value = search, + onValueChange = { newValue -> + search = newValue + if (newValue.length > 2) userSuggestions.processCurrentWord(newValue) else userSuggestions.reset() + }, + label = { Text(stringRes(R.string.podcast_value_search_user)) }, + placeholder = { Text(stringRes(R.string.podcast_value_search_user_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (search.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + val added = state.addUser(user) + if (!added) { + accountViewModel.toastManager.toast( + R.string.podcast_value_for_value, + R.string.podcast_value_user_no_lnaddress, + ) + } + search = "" + userSuggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightPage, ) } - TextButton(onClick = { state.add() }, modifier = Modifier.fillMaxWidth()) { + // Fallback for raw destinations (a node pubkey for keysend, or a non-Nostr lightning address). + TextButton(onClick = { state.addManual() }, modifier = Modifier.fillMaxWidth()) { Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(text = stringRes(R.string.podcast_value_add_recipient), modifier = Modifier.padding(start = 6.dp)) + Text(text = stringRes(R.string.podcast_value_add_address), modifier = Modifier.padding(start = 6.dp)) } } } @Composable -private fun RecipientCard( - draft: RecipientDraft, +private fun RecipientShell( total: Int, + draft: RecipientDraft, onRemove: () -> Unit, + content: @Composable RowScope.() -> Unit, ) { - val isNode by draft.isNode val weight = draft.split.value .trim() @@ -128,6 +181,75 @@ private fun RecipientCard( .padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + Row(verticalAlignment = Alignment.CenterVertically) { + content() + Text( + text = stringRes(R.string.podcast_value_split_percent, percent), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(R.string.podcast_value_remove_recipient), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + WeightAndFeeRow(draft, weight) + } +} + +@Composable +private fun UserRecipientCard( + draft: RecipientDraft, + total: Int, + accountViewModel: AccountViewModel, + onRemove: () -> Unit, +) { + val user = draft.user.value ?: return + RecipientShell(total, draft, onRemove) { + BaseUserPicture(user, Size40dp, accountViewModel = accountViewModel) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + UsernameDisplay(user, accountViewModel = accountViewModel) + val lud = user.lnAddress() + Text( + text = lud ?: stringRes(R.string.podcast_value_user_no_lnaddress), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) + } + } +} + +@Composable +private fun ManualRecipientCard( + draft: RecipientDraft, + total: Int, + onRemove: () -> Unit, +) { + val isNode by draft.isNode + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val weight = + draft.split.value + .trim() + .toIntOrNull() ?: 0 + val percent = if (total > 0 && weight > 0) weight * 100 / total else 0 + Row(verticalAlignment = Alignment.CenterVertically) { Text( text = stringRes(R.string.podcast_value_split_percent, percent), @@ -170,15 +292,7 @@ private fun RecipientCard( OutlinedTextField( value = draft.address.value, onValueChange = { draft.address.value = it }, - label = { - Text( - if (isNode) { - stringRes(R.string.podcast_value_node_pubkey) - } else { - stringRes(R.string.podcast_value_lnaddress) - }, - ) - }, + label = { Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey) else stringRes(R.string.podcast_value_lnaddress)) }, placeholder = { Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey_hint) else stringRes(R.string.podcast_value_lnaddress_hint)) }, @@ -187,21 +301,29 @@ private fun RecipientCard( isError = draft.address.value.isBlank(), ) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = draft.split.value, - onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } }, - label = { Text(stringRes(R.string.podcast_value_weight)) }, - modifier = Modifier.weight(1f), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - isError = weight <= 0, - ) - FilterChip( - selected = draft.fee.value, - onClick = { draft.fee.value = !draft.fee.value }, - label = { Text(stringRes(R.string.podcast_value_fee)) }, - ) - } + WeightAndFeeRow(draft, weight) + } +} + +@Composable +private fun WeightAndFeeRow( + draft: RecipientDraft, + weight: Int, +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draft.split.value, + onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_value_weight)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = weight <= 0, + ) + FilterChip( + selected = draft.fee.value, + onClick = { draft.fee.value = !draft.fee.value }, + label = { Text(stringRes(R.string.podcast_value_fee)) }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt index 7177854346..99de92c0ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.podcasts.PodcastValue import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient @@ -47,10 +48,22 @@ class V4VSplitEditorState { value?.recipients?.forEach { recipients.add(RecipientDraft.from(it)) } } - fun add() { + /** Add a blank row for a raw destination (a node keysend, or a non-Nostr lightning address). */ + fun addManual() { recipients.add(RecipientDraft()) } + /** + * Add a Nostr user as a recipient. Returns false (and adds nothing) if the user has no lightning + * address to pay — there's nothing to put in the value block. Duplicate users are ignored. + */ + fun addUser(user: User): Boolean { + if (user.lnAddress().isNullOrBlank()) return false + if (recipients.any { it.user.value?.pubkeyHex == user.pubkeyHex }) return true + recipients.add(RecipientDraft.forUser(user)) + return true + } + fun remove(draft: RecipientDraft) { recipients.remove(draft) } @@ -70,8 +83,15 @@ class V4VSplitEditorState { } } -/** One editable recipient row. All fields are Compose state so the editor recomposes as they change. */ +/** + * One editable recipient row. Either backed by a Nostr [user] (rendered with avatar + name; its + * lightning address resolves at save time) or a raw destination typed by hand ([name]/[isNode]/ + * [address]). All fields are Compose state so the editor recomposes as they change. + */ class RecipientDraft { + /** When set, this row is a Nostr user — paid at their lud16, shown with avatar + name. */ + val user = mutableStateOf(null) + val name = mutableStateOf("") /** false = lnaddress (LNURL-pay), true = node (keysend to a raw node pubkey). */ @@ -80,12 +100,24 @@ class RecipientDraft { val split = mutableStateOf("1") val fee = mutableStateOf(false) - /** A recipient is payable once it has an address and a positive weight. */ + /** A recipient is payable once it resolves to an address and has a positive weight. */ fun toRecipient(): PodcastValueRecipient? { - val addr = address.value.trim() - if (addr.isBlank()) return null val weight = split.value.trim().toIntOrNull() ?: return null if (weight <= 0) return null + + user.value?.let { u -> + val lud = u.lnAddress()?.takeIf { it.isNotBlank() } ?: return null + return PodcastValueRecipient( + name = u.toBestDisplayName(), + type = PodcastValue.TYPE_LNADDRESS, + address = lud, + split = weight, + fee = if (fee.value) true else null, + ) + } + + val addr = address.value.trim() + if (addr.isBlank()) return null return PodcastValueRecipient( name = name.value.trim().ifBlank { null }, type = if (isNode.value) PodcastValue.TYPE_NODE else PodcastValue.TYPE_LNADDRESS, @@ -96,6 +128,11 @@ class RecipientDraft { } companion object { + fun forUser(user: User): RecipientDraft = + RecipientDraft().apply { + this.user.value = user + } + fun from(recipient: PodcastValueRecipient): RecipientDraft = RecipientDraft().apply { name.value = recipient.name.orEmpty() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 58d11807f3..d8b363279b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1006,6 +1006,10 @@ Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations. Add recipient + Add address manually + Add a Nostr user + Search by name or @handle + This user has no Lightning address Remove recipient Name (optional) Lightning address