diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index f679de2eb..5b80ad3a7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -23,6 +23,7 @@ class LocalPreferences(context: Context) { remove("relays") remove("dontTranslateFrom") remove("translateTo") + remove("zapAmounts") }.apply() } @@ -35,6 +36,7 @@ class LocalPreferences(context: Context) { account.localRelays.let { putString("relays", gson.toJson(it)) } account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) } account.translateTo.let { putString("translateTo", it) } + account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) } }.apply() } @@ -52,6 +54,11 @@ class LocalPreferences(context: Context) { val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf() val translateTo = getString("translateTo", null) ?: Locale.getDefault().language + val zapAmountChoices = gson.fromJson( + getString("zapAmounts", "[]"), + object : TypeToken>() {}.type + ) ?: listOf(500L, 1000L, 5000L) + if (pubKey != null) { return Account( Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), @@ -59,7 +66,8 @@ class LocalPreferences(context: Context) { hiddenUsers, localRelays, dontTranslateFrom, - translateTo + translateTo, + zapAmountChoices ) } else { return null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4de61d476..ce8e592b3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -56,7 +56,8 @@ class Account( var hiddenUsers: Set = setOf(), var localRelays: Set = Constants.defaultRelays.toSet(), var dontTranslateFrom: Set = getLanguagesSpokenByUser(), - var translateTo: String = Locale.getDefault().language + var translateTo: String = Locale.getDefault().language, + var zapAmountChoices: List = listOf(500L, 1000L, 5000L) ) { var transientHiddenUsers: Set = setOf() @@ -327,6 +328,11 @@ class Account( invalidateData(live) } + fun changeZapAmounts(newAmounts: List) { + zapAmountChoices = newAmounts + invalidateData(live) + } + fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { if (!isWriteable()) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index d8e2d8e2a..3481c0a6d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -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 fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) { Button( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 75a30b8d9..b804198f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -1,24 +1,39 @@ package com.vitorpamplona.amethyst.ui.note -import android.content.Intent -import android.net.Uri 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.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight 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.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.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons 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.Bolt +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -26,28 +41,48 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi 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.ColorFilter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalUriHandler 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.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.request.CachePolicy import coil.request.ImageRequest +import com.vitorpamplona.amethyst.LocalPreferences 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.ui.actions.CloseButton 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.theme.BitcoinOrange import java.math.BigDecimal import java.math.RoundingMode +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +@OptIn(ExperimentalFoundationApi::class) @Composable fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -77,6 +112,9 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { if (wantsToReplyTo != null) NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account) + var wantsToZap by remember { mutableStateOf(false) } + var wantsToChangeZapAmount by remember { mutableStateOf(false) } + Row( modifier = Modifier .padding(top = 8.dp) @@ -192,26 +230,69 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { modifier = Modifier.weight(1f) ) - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { - if (account.isWriteable()) { - accountViewModel.zap(baseNote, 1000 * 1000, "", context) { - scope.launch { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + + Row( + modifier = Modifier + .then(Modifier.size(20.dp)) + .combinedClickable( + role = Role.Button, + 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) { Icon( imageVector = Icons.Default.Bolt, @@ -236,7 +317,6 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { modifier = Modifier.weight(1f) ) - IconButton( modifier = Modifier.then(Modifier.size(20.dp)), 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 { + 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 { if (count == null) return " " if (count == 0) return " "