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 7cdfb9ad8e..ebfa77b3bd 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 @@ -169,6 +169,8 @@ fun EditPodcastShowScreen( SwitchRow(stringRes(R.string.podcast_show_explicit), vm.explicit.value) { vm.explicit.value = it } 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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt index 1c82ce3b88..d45f9b922f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata -import com.vitorpamplona.quartz.podcasts.PodcastValue import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -82,9 +81,11 @@ class EditPodcastShowViewModel : ViewModel() { val mediaQualitySlider = mutableStateOf(1) val stripMetadata = mutableStateOf(true) + /** Editable value-for-value split for the show. */ + val splitEditor = V4VSplitEditorState() + /** Fields the editor doesn't surface but must not drop on save. */ private var preservedGuid: String? = null - private var preservedValue: PodcastValue? = null private var hasExisting = false fun init(accountViewModel: AccountViewModel) { @@ -98,7 +99,7 @@ class EditPodcastShowViewModel : ViewModel() { if (existing != null) { hasExisting = true preservedGuid = existing.guid() - preservedValue = existing.showValue() + splitEditor.load(existing.showValue()) title.value = existing.showTitle().orEmpty() description.value = existing.showDescription().orEmpty() author.value = existing.showAuthor().orEmpty() @@ -162,7 +163,7 @@ class EditPodcastShowViewModel : ViewModel() { type = type.value.trim().ifBlank { null }, complete = complete.value.takeIf { it }, guid = preservedGuid, - value = preservedValue, + value = splitEditor.toPodcastValue(), ), coverOrchestrator = coverOrch, server = server, 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 1b593d324a..9741a4f522 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 @@ -250,6 +250,9 @@ fun NewPodcastEpisodeScreen( singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), ) + + // Episode-level V4V override; leave empty to inherit the show's split. + V4VSplitEditor(vm.splitEditor) } if (vm.isEditing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt index 0ac2b4f3bc..d2309ea701 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt @@ -88,9 +88,11 @@ class NewPodcastEpisodeViewModel : ViewModel() { private var dTag: String? = null private var loadedEvent: Podcasting20EpisodeEvent? = null - /** Carried across an edit so we don't drop the original publish date or value splits on save. */ + /** Editable value-for-value split for this episode (overrides the show's split). */ + val splitEditor = V4VSplitEditorState() + + /** Carried across an edit so we don't drop the original publish date on save. */ private var preservedPubDate: String? = null - private var preservedValue: PodcastValue? = null val isEditing: Boolean get() = loadedEvent != null @@ -110,7 +112,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { dTag = editDTag loadedEvent = existing preservedPubDate = existing.pubDate() - preservedValue = existing.value() + splitEditor.load(existing.value()) title.value = existing.title().orEmpty() description.value = existing.description().orEmpty() audioUrl.value = @@ -196,6 +198,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { transcriptUrl = transcriptUrl.value.trim().ifBlank { null }, chaptersUrl = chaptersUrl.value.trim().ifBlank { null }, topics = PodcastComposerMedia.parseCsv(topics.value), + value = splitEditor.toPodcastValue(), coverOrchestrator = coverMedia.value, audioOrchestrator = audioMedia.value, existingCoverUrl = coverUrl.value.trim().ifBlank { null }, @@ -255,6 +258,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { val transcriptUrl: String?, val chaptersUrl: String?, val topics: List, + val value: PodcastValue?, val coverOrchestrator: MultiOrchestrator?, val audioOrchestrator: MultiOrchestrator?, val existingCoverUrl: String?, @@ -315,7 +319,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { season = snapshot.season, transcriptUrl = snapshot.transcriptUrl, chaptersUrl = snapshot.chaptersUrl, - value = preservedValue, + value = snapshot.value, topics = snapshot.topics, ) account.signAndComputeBroadcast(template) 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 new file mode 100644 index 0000000000..6b8057c8f3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +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.stringRes +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. + */ +@Composable +fun V4VSplitEditor(state: V4VSplitEditorState) { + val total = state.totalSplit() + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.podcast_value_for_value), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (state.recipients.isEmpty()) { + Text( + text = stringRes(R.string.podcast_value_editor_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + + state.recipients.forEach { draft -> + RecipientCard( + draft = draft, + total = total, + onRemove = { state.remove(draft) }, + ) + } + + TextButton(onClick = { state.add() }, 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)) + } + } +} + +@Composable +private fun RecipientCard( + draft: RecipientDraft, + total: Int, + onRemove: () -> Unit, +) { + val isNode by draft.isNode + val weight = + draft.split.value + .trim() + .toIntOrNull() ?: 0 + val percent = if (total > 0 && weight > 0) weight * 100 / total else 0 + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(R.string.podcast_value_split_percent, percent), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(R.string.podcast_value_remove_recipient), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + OutlinedTextField( + value = draft.name.value, + onValueChange = { draft.name.value = it }, + label = { Text(stringRes(R.string.podcast_value_recipient_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = !isNode, + onClick = { draft.isNode.value = false }, + label = { Text(stringRes(R.string.podcast_value_type_lnaddress)) }, + ) + FilterChip( + selected = isNode, + onClick = { draft.isNode.value = true }, + label = { Text(stringRes(R.string.podcast_value_type_node)) }, + ) + } + + 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) + }, + ) + }, + placeholder = { + Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey_hint) else stringRes(R.string.podcast_value_lnaddress_hint)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + 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)) }, + ) + } + } +} 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 new file mode 100644 index 0000000000..7177854346 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient + +/** + * Editable state for a Podcasting-2.0 value-for-value (V4V) split, shared by the show and episode + * composers. Holds one [RecipientDraft] per payee; [toPodcastValue] turns the drafts back into a + * [PodcastValue] on save (or null when there are no payable recipients). The suggested amount / + * currency / enabled flag on a loaded block are carried through untouched — the editor only manages + * the recipient list. + */ +class V4VSplitEditorState { + val recipients = mutableStateListOf() + + private var amount: Long? = null + private var currency: String? = null + private var enabled: Boolean? = null + + fun load(value: PodcastValue?) { + recipients.clear() + amount = value?.amount + currency = value?.currency + enabled = value?.enabled + value?.recipients?.forEach { recipients.add(RecipientDraft.from(it)) } + } + + fun add() { + recipients.add(RecipientDraft()) + } + + fun remove(draft: RecipientDraft) { + recipients.remove(draft) + } + + /** Sum of the weights of the payable recipients — used to show each as a percentage. */ + fun totalSplit(): Int = recipients.mapNotNull { it.toRecipient() }.sumOf { it.split } + + fun toPodcastValue(): PodcastValue? { + val valid = recipients.mapNotNull { it.toRecipient() } + if (valid.isEmpty()) return null + return PodcastValue( + enabled = enabled, + amount = amount, + currency = currency, + recipients = valid, + ) + } +} + +/** One editable recipient row. All fields are Compose state so the editor recomposes as they change. */ +class RecipientDraft { + val name = mutableStateOf("") + + /** false = lnaddress (LNURL-pay), true = node (keysend to a raw node pubkey). */ + val isNode = mutableStateOf(false) + val address = mutableStateOf("") + val split = mutableStateOf("1") + val fee = mutableStateOf(false) + + /** A recipient is payable once it has an address and 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 + return PodcastValueRecipient( + name = name.value.trim().ifBlank { null }, + type = if (isNode.value) PodcastValue.TYPE_NODE else PodcastValue.TYPE_LNADDRESS, + address = addr, + split = weight, + fee = if (fee.value) true else null, + ) + } + + companion object { + fun from(recipient: PodcastValueRecipient): RecipientDraft = + RecipientDraft().apply { + name.value = recipient.name.orEmpty() + isNode.value = recipient.type == PodcastValue.TYPE_NODE + address.value = recipient.address.orEmpty() + split.value = recipient.split.toString() + fee.value = recipient.fee == true + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ab1c969588..58d11807f3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1004,6 +1004,18 @@ %1$d trailer %1$d trailers + Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations. + Add recipient + Remove recipient + Name (optional) + Lightning address + Node (keysend) + Lightning address + name@example.com + Node pubkey + 02abc… (33-byte hex) + Weight + Fee %1$d episode %1$d episodes