Custom Zap Amounts.

This commit is contained in:
Vitor Pamplona
2023-02-20 13:23:44 -05:00
parent dcc84f4572
commit ddd35c5119
4 changed files with 305 additions and 25 deletions
@@ -23,6 +23,7 @@ class LocalPreferences(context: Context) {
remove("relays") remove("relays")
remove("dontTranslateFrom") remove("dontTranslateFrom")
remove("translateTo") remove("translateTo")
remove("zapAmounts")
}.apply() }.apply()
} }
@@ -35,6 +36,7 @@ class LocalPreferences(context: Context) {
account.localRelays.let { putString("relays", gson.toJson(it)) } account.localRelays.let { putString("relays", gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) } account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
account.translateTo.let { putString("translateTo", it) } account.translateTo.let { putString("translateTo", it) }
account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) }
}.apply() }.apply()
} }
@@ -52,6 +54,11 @@ class LocalPreferences(context: Context) {
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf() val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
val zapAmountChoices = gson.fromJson(
getString("zapAmounts", "[]"),
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
if (pubKey != null) { if (pubKey != null) {
return Account( return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
@@ -59,7 +66,8 @@ class LocalPreferences(context: Context) {
hiddenUsers, hiddenUsers,
localRelays, localRelays,
dontTranslateFrom, dontTranslateFrom,
translateTo translateTo,
zapAmountChoices
) )
} else { } else {
return null return null
@@ -56,7 +56,8 @@ class Account(
var hiddenUsers: Set<String> = setOf(), var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(), var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(), var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L)
) { ) {
var transientHiddenUsers: Set<String> = setOf() var transientHiddenUsers: Set<String> = setOf()
@@ -327,6 +328,11 @@ class Account(
invalidateData(live) invalidateData(live)
} }
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
invalidateData(live)
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -245,6 +245,25 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
} }
} }
@Composable
fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Save", color = Color.White)
}
}
@Composable @Composable
fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) { fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button( Button(
@@ -1,24 +1,39 @@
package com.vitorpamplona.amethyst.ui.note package com.vitorpamplona.amethyst.ui.note
import android.content.Intent
import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -26,28 +41,48 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.CachePolicy import coil.request.CachePolicy
import coil.request.ImageRequest import coil.request.ImageRequest
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -77,6 +112,9 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
if (wantsToReplyTo != null) if (wantsToReplyTo != null)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account) NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account)
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
Row( Row(
modifier = Modifier modifier = Modifier
.padding(top = 8.dp) .padding(top = 8.dp)
@@ -192,26 +230,69 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)), Row(
onClick = { modifier = Modifier
if (account.isWriteable()) { .then(Modifier.size(20.dp))
accountViewModel.zap(baseNote, 1000 * 1000, "", context) { .combinedClickable(
scope.launch { role = Role.Button,
Toast.makeText(context, it, Toast.LENGTH_SHORT).show() interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (account.zapAmountChoices.isEmpty()) {
scope.launch {
Toast
.makeText(
context,
"No Zap Amount Setup. Long Press to change",
Toast.LENGTH_SHORT
)
.show()
}
} else if (!account.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
"Login with a Private key to be able to send Zaps",
Toast.LENGTH_SHORT
)
.show()
}
} else if (account.zapAmountChoices.size == 1) {
accountViewModel.zap(baseNote, account.zapAmountChoices.first() * 1000, "", context) {
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
}
} else if (account.zapAmountChoices.size > 1) {
wantsToZap = true
} }
},
onLongClick = {
wantsToChangeZapAmount = true
} }
} else { )
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to send Zaps",
Toast.LENGTH_SHORT
).show()
}
}
}
) { ) {
if (wantsToZap) {
ZapAmountChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToZap = false
},
onChangeAmount = {
wantsToZap = false
wantsToChangeZapAmount = true
}
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account)
}
if (zappedNote?.isZappedBy(account.userProfile()) == true) { if (zappedNote?.isZappedBy(account.userProfile()) == true) {
Icon( Icon(
imageVector = Icons.Default.Bolt, imageVector = Icons.Default.Bolt,
@@ -236,7 +317,6 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton( IconButton(
modifier = Modifier.then(Modifier.size(20.dp)), modifier = Modifier.then(Modifier.size(20.dp)),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
@@ -265,6 +345,173 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
} }
} }
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
private fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
onDismissRequest = { onDismiss() }
) {
FlowRow() {
account.zapAmountChoices.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.zap(baseNote, amountInSats * 1000, "", context) {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
onDismiss()
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text("${showAmount(amountInSats.toBigDecimal())}",
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {
accountViewModel.zap(baseNote, amountInSats * 1000, "", context) {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
onDismiss()
},
onLongClick = {
onChangeAmount()
},
)
)
}
}
}
}
}
class UpdateZapAmountViewModel: ViewModel() {
private var account: Account? = null
var amounts by mutableStateOf(TextFieldValue(""))
fun load(account: Account) {
this.account = account
this.amounts = TextFieldValue(account.zapAmountChoices.joinToString(", "))
}
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
}
fun updateAmounts(commaSeparatedAmounts: TextFieldValue) {
val correctedText = toListOfAmounts(commaSeparatedAmounts.text).joinToString(", ")
amounts = TextFieldValue(correctedText, commaSeparatedAmounts.selection, commaSeparatedAmounts.composition)
}
fun sendPost() {
account?.changeZapAmounts(toListOfAmounts(amounts.text))
amounts = TextFieldValue("")
}
fun cancel() {
amounts = TextFieldValue("")
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
val postViewModel: UpdateZapAmountViewModel = viewModel()
postViewModel.load(account)
val ctx = LocalContext.current.applicationContext
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
SaveButton(
onPost = {
postViewModel.sendPost()
LocalPreferences(ctx).saveToEncryptedStorage(account)
onClose()
},
isActive = postViewModel.amounts.text.isNotBlank()
)
}
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
label = { Text(text = "Comma-separated Zap amounts in sats") },
value = postViewModel.amounts,
onValueChange = {
postViewModel.updateAmounts(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
}
)
}
}
}
}
}
fun showCount(count: Int?): String { fun showCount(count: Int?): String {
if (count == null) return " " if (count == null) return " "
if (count == 0) return " " if (count == 0) return " "