feat: Add NIP-75 Zap Goal rendering and creation screen

- Add GoalEvent renderer (Goal.kt) with progress bar showing zap
  funding status, goal image, summary, and closed status
- Add NoteCompose integration so GoalEvents render properly in feeds
- Add NewGoalScreen with form for description, amount, summary,
  image URL, website URL, and optional deadline
- Add NewGoalViewModel handling event creation via GoalEvent.build()
- Add Route.NewGoal and navigation registration
- Add string resources for goal UI

https://claude.ai/code/session_012dS1srrK9LjA4vEZ8WKDEk
This commit is contained in:
Claude
2026-03-27 00:16:18 +00:00
parent 46b75abc81
commit be297b89b3
7 changed files with 532 additions and 0 deletions
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals.NewGoalScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
@@ -322,6 +323,13 @@ fun AppNavigation(
)
}
composableFromBottom<Route.NewGoal> {
NewGoalScreen(
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.NewLongFormPost> {
LongFormPostScreen(
draftId = it.draft,
@@ -290,6 +290,8 @@ sealed class Route {
val draft: String? = null,
) : Route()
@Serializable data object NewGoal : Route()
@Serializable
data class NewLongFormPost(
val draft: String? = null,
@@ -123,6 +123,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGoal
import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight
import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
@@ -250,6 +251,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@@ -1017,6 +1019,10 @@ private fun RenderNoteRow(
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
}
is GoalEvent -> {
RenderGoal(baseNote, accountViewModel, nav)
}
is HighlightEvent -> {
RenderHighlight(
baseNote,
@@ -0,0 +1,200 @@
/*
* 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.note.types
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.fundraiserProgressColor
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import java.math.BigDecimal
import kotlin.math.roundToInt
@Composable
fun RenderGoal(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? GoalEvent ?: return
GoalHeader(noteEvent, note, accountViewModel, nav)
}
@Composable
fun GoalHeader(
noteEvent: GoalEvent,
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val image = noteEvent.image()
val summary =
remember(noteEvent) {
noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null }
}
val goalAmountMillisats = noteEvent.amount() ?: 0L
val goalAmountSats = goalAmountMillisats / 1000
val closedAt = noteEvent.closedAt()
val isClosed = closedAt != null && closedAt < TimeUtils.now()
Column(MaterialTheme.colorScheme.replyModifier) {
image?.let {
Box {
MyAsyncImage(
imageUrl = it,
contentDescription = stringRes(R.string.preview_card_image_for, it),
contentScale = ContentScale.FillWidth,
mainImageModifier = Modifier.fillMaxWidth(),
loadedImageModifier = Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) },
onError = { DefaultImageHeader(note, accountViewModel) },
)
}
}
Column(Modifier.padding(10.dp)) {
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(8.dp))
}
if (goalAmountSats > 0) {
GoalProgressBar(
note = note,
goalAmountSats = goalAmountSats,
accountViewModel = accountViewModel,
)
}
if (isClosed) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.goal_closed),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
}
}
}
@Composable
fun GoalProgressBar(
note: Note,
goalAmountSats: Long,
accountViewModel: AccountViewModel,
) {
val zapsState by observeNoteZaps(note, accountViewModel)
var zapraiserStatus by
remember { mutableStateOf(ZapraiserStatus(0F, showAmount(goalAmountSats.toBigDecimal()))) }
LaunchedEffect(key1 = zapsState) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.account.calculateZappedAmount(note)
var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat()
if (percentage > 1) percentage = 1f
val left =
if (percentage > 0.99) {
"0"
} else {
showAmount(
goalAmountSats.toBigDecimal() * BigDecimal(1.0 - percentage),
)
}
zapraiserStatus = ZapraiserStatus(percentage, left)
}
}
Column(Modifier.fillMaxWidth()) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(24.dp),
color = MaterialTheme.colorScheme.fundraiserProgressColor,
progress = { zapraiserStatus.progress },
gapSize = 0.dp,
strokeCap = StrokeCap.Square,
drawStopIndicator = {},
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
val totalPercentage by
remember(zapraiserStatus) {
derivedStateOf { "${(zapraiserStatus.progress * 100).roundToInt()}%" }
}
Text(
text =
stringRes(
R.string.goal_progress,
totalPercentage,
showAmount(goalAmountSats.toBigDecimal()),
),
fontSize = Font14SP,
maxLines = 1,
)
}
}
}
@@ -0,0 +1,197 @@
/*
* 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.home.nip75Goals
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewGoalScreen(
accountViewModel: AccountViewModel,
nav: Nav,
) {
val goalViewModel: NewGoalViewModel = viewModel()
goalViewModel.init(accountViewModel)
LaunchedEffect(goalViewModel, accountViewModel) {
// no-op for now, could load drafts
}
NewGoalScreen(
goalViewModel,
accountViewModel,
nav,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewGoalScreen(
goalViewModel: NewGoalViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
BackHandler {
goalViewModel.cancel()
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
titleRes = R.string.new_goal,
isActive = goalViewModel::canPost,
onCancel = {
goalViewModel.cancel()
nav.popBack()
},
onPost = {
accountViewModel.launchSigner {
goalViewModel.sendPostSync()
nav.popBack()
}
},
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
NewGoalBody(goalViewModel)
}
}
}
@Composable
private fun NewGoalBody(goalViewModel: NewGoalViewModel) {
val scrollState = rememberScrollState()
Column(
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
) {
OutlinedTextField(
value = goalViewModel.description,
onValueChange = { goalViewModel.description = it },
label = { Text(stringRes(R.string.goal_description_label)) },
placeholder = { Text(stringRes(R.string.goal_description_placeholder)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 6,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = goalViewModel.amount,
onValueChange = { goalViewModel.amount = it },
label = { Text(stringRes(R.string.goal_amount_label)) },
placeholder = { Text(stringRes(R.string.goal_amount_placeholder)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = goalViewModel.summary,
onValueChange = { goalViewModel.summary = it },
label = { Text(stringRes(R.string.goal_summary_label)) },
placeholder = { Text(stringRes(R.string.goal_summary_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = goalViewModel.imageUrl,
onValueChange = { goalViewModel.imageUrl = it },
label = { Text(stringRes(R.string.goal_image_label)) },
placeholder = { Text(stringRes(R.string.goal_image_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = goalViewModel.websiteUrl,
onValueChange = { goalViewModel.websiteUrl = it },
label = { Text(stringRes(R.string.goal_website_label)) },
placeholder = { Text(stringRes(R.string.goal_website_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = CenterVertically) {
Checkbox(
checked = goalViewModel.wantsDeadline,
onCheckedChange = { goalViewModel.wantsDeadline = it },
)
Text(
text = stringRes(R.string.goal_set_deadline),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@@ -0,0 +1,101 @@
/*
* 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.home.nip75Goals
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class NewGoalViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
var description by mutableStateOf(TextFieldValue(""))
var amount by mutableStateOf(TextFieldValue(""))
var summary by mutableStateOf(TextFieldValue(""))
var imageUrl by mutableStateOf(TextFieldValue(""))
var websiteUrl by mutableStateOf(TextFieldValue(""))
var wantsDeadline by mutableStateOf(false)
var deadlineTimestamp by mutableLongStateOf(TimeUtils.now() + TimeUtils.ONE_WEEK)
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
}
fun canPost(): Boolean =
description.text.isNotBlank() &&
amount.text.isNotBlank() &&
amount.text.toLongOrNull() != null &&
(amount.text.toLongOrNull() ?: 0) > 0
fun cancel() {
description = TextFieldValue("")
amount = TextFieldValue("")
summary = TextFieldValue("")
imageUrl = TextFieldValue("")
websiteUrl = TextFieldValue("")
wantsDeadline = false
deadlineTimestamp = TimeUtils.now() + TimeUtils.ONE_WEEK
}
suspend fun sendPostSync() {
val template = createTemplate() ?: return
cancel()
account.signAndComputeBroadcast(template)
}
private fun createTemplate(): EventTemplate<out Event>? {
val amountSats = amount.text.toLongOrNull() ?: return null
val amountMillisats = amountSats * 1000L
val relays =
account.outboxRelays.flow.value
.map { it.url }
val closedAt = if (wantsDeadline) deadlineTimestamp else null
val img = imageUrl.text.ifBlank { null }
val sum = summary.text.ifBlank { null }
val web = websiteUrl.text.ifBlank { null }
return GoalEvent.build(
description = description.text,
amount = amountMillisats,
relays = relays,
closedAt = closedAt,
image = img,
summary = sum,
websiteUrl = web,
)
}
}
+18
View File
@@ -1955,4 +1955,22 @@
<string name="web_bookmark_delete">Delete</string>
<string name="web_bookmark_delete_confirm">Delete this web bookmark?</string>
<string name="web_bookmark_open_url">Open URL</string>
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">This goal has closed</string>
<string name="goal_progress">%1$s funded of %2$s sats goal</string>
<string name="goal_amount_label">Goal amount (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Describe your goal</string>
<string name="goal_description_placeholder">What are you fundraising for?</string>
<string name="goal_summary_label">Short summary</string>
<string name="goal_summary_placeholder">Brief description shown in previews</string>
<string name="goal_image_label">Image URL (optional)</string>
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
<string name="goal_website_label">Website URL (optional)</string>
<string name="goal_website_placeholder">https://example.com</string>
<string name="goal_deadline_label">Deadline (optional)</string>
<string name="goal_set_deadline">Set a deadline</string>
<string name="new_goal">New Goal</string>
<string name="goal_create">Create Goal</string>
</resources>