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:
@@ -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.HomeScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
|
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.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.keyBackup.AccountBackupScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
|
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> {
|
composableFromBottomArgs<Route.NewLongFormPost> {
|
||||||
LongFormPostScreen(
|
LongFormPostScreen(
|
||||||
draftId = it.draft,
|
draftId = it.draft,
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ sealed class Route {
|
|||||||
val draft: String? = null,
|
val draft: String? = null,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable data object NewGoal : Route()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class NewLongFormPost(
|
data class NewLongFormPost(
|
||||||
val draft: String? = null,
|
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.RenderGitIssueEvent
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
|
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
|
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.RenderHighlight
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory
|
import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
|
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.communityAddress
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
|
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
|
||||||
|
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||||
@@ -1017,6 +1019,10 @@ private fun RenderNoteRow(
|
|||||||
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
|
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is GoalEvent -> {
|
||||||
|
RenderGoal(baseNote, accountViewModel, nav)
|
||||||
|
}
|
||||||
|
|
||||||
is HighlightEvent -> {
|
is HighlightEvent -> {
|
||||||
RenderHighlight(
|
RenderHighlight(
|
||||||
baseNote,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1955,4 +1955,22 @@
|
|||||||
<string name="web_bookmark_delete">Delete</string>
|
<string name="web_bookmark_delete">Delete</string>
|
||||||
<string name="web_bookmark_delete_confirm">Delete this web bookmark?</string>
|
<string name="web_bookmark_delete_confirm">Delete this web bookmark?</string>
|
||||||
<string name="web_bookmark_open_url">Open URL</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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user