Skip to content

Commit 6bf78e8

Browse files
committed
(android) Add basic UI to attach a payer note to a payment
1 parent cfe6ac4 commit 6bf78e8

File tree

4 files changed

+97
-13
lines changed

4 files changed

+97
-13
lines changed

phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt

+82-5
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,22 @@
1616

1717
package fr.acinq.phoenix.android.payments.offer
1818

19+
import androidx.compose.foundation.layout.Column
1920
import androidx.compose.foundation.layout.Row
2021
import androidx.compose.foundation.layout.Spacer
22+
import androidx.compose.foundation.layout.fillMaxWidth
2123
import androidx.compose.foundation.layout.height
22-
import androidx.compose.foundation.text.selection.SelectionContainer
24+
import androidx.compose.foundation.layout.offset
25+
import androidx.compose.foundation.layout.padding
26+
import androidx.compose.foundation.layout.sizeIn
27+
import androidx.compose.foundation.rememberScrollState
28+
import androidx.compose.foundation.shape.RoundedCornerShape
29+
import androidx.compose.foundation.verticalScroll
2330
import androidx.compose.material.MaterialTheme
2431
import androidx.compose.material.Text
32+
import androidx.compose.material3.ExperimentalMaterial3Api
33+
import androidx.compose.material3.ModalBottomSheet
34+
import androidx.compose.material3.rememberModalBottomSheetState
2535
import androidx.compose.runtime.Composable
2636
import androidx.compose.runtime.LaunchedEffect
2737
import androidx.compose.runtime.collectAsState
@@ -33,7 +43,7 @@ import androidx.compose.ui.Alignment
3343
import androidx.compose.ui.Modifier
3444
import androidx.compose.ui.platform.LocalContext
3545
import androidx.compose.ui.res.stringResource
36-
import androidx.compose.ui.text.style.TextOverflow
46+
import androidx.compose.ui.text.style.TextAlign
3747
import androidx.compose.ui.unit.dp
3848
import androidx.compose.ui.unit.sp
3949
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -46,10 +56,12 @@ import fr.acinq.phoenix.android.business
4656
import fr.acinq.phoenix.android.components.AmountHeroInput
4757
import fr.acinq.phoenix.android.components.AmountWithFiatRowView
4858
import fr.acinq.phoenix.android.components.BackButtonWithBalance
59+
import fr.acinq.phoenix.android.components.Clickable
4960
import fr.acinq.phoenix.android.components.FilledButton
5061
import fr.acinq.phoenix.android.components.ProgressView
5162
import fr.acinq.phoenix.android.components.SplashLabelRow
5263
import fr.acinq.phoenix.android.components.SplashLayout
64+
import fr.acinq.phoenix.android.components.TextInput
5365
import fr.acinq.phoenix.android.components.contact.ContactOrOfferView
5466
import fr.acinq.phoenix.android.components.feedback.ErrorMessage
5567
import fr.acinq.phoenix.android.payments.details.translatePaymentError
@@ -67,7 +79,7 @@ fun SendOfferView(
6779
val balance = business.balanceManager.balance.collectAsState(null).value
6880
val prefBitcoinUnit = LocalBitcoinUnit.current
6981

70-
val vm = viewModel<SendOfferViewModel>(factory = SendOfferViewModel.Factory(business.peerManager))
82+
val vm = viewModel<SendOfferViewModel>(factory = SendOfferViewModel.Factory(business.peerManager, business.nodeParamsManager))
7183
val requestedAmount = offer.amount
7284
var amount by remember { mutableStateOf(requestedAmount) }
7385
val amountErrorMessage: String = remember(amount) {
@@ -90,6 +102,9 @@ fun SendOfferView(
90102
}
91103
val isOverpaymentEnabled by userPrefs.getIsOverpaymentEnabled.collectAsState(initial = false)
92104

105+
var message by remember { mutableStateOf("") }
106+
var showMessageDialog by remember { mutableStateOf(false) }
107+
93108
SplashLayout(
94109
header = { BackButtonWithBalance(onBackClick = onBackClick, balance = balance) },
95110
topContent = {
@@ -112,10 +127,27 @@ fun SendOfferView(
112127
Spacer(modifier = Modifier.height(8.dp))
113128
}
114129

115-
SplashLabelRow(label = stringResource(id = R.string.send_destination_label)) {
130+
SplashLabelRow(label = stringResource(id = R.string.send_destination_label), icon = R.drawable.ic_zap) {
116131
ContactOrOfferView(offer = offer)
117132
}
118133

134+
Spacer(modifier = Modifier.height(8.dp))
135+
SplashLabelRow(label = stringResource(id = R.string.send_offer_payer_note_label), icon = R.drawable.ic_message_circle) {
136+
Clickable(
137+
onClick = { showMessageDialog = true },
138+
modifier = Modifier
139+
.fillMaxWidth()
140+
.offset(x = (-8).dp),
141+
shape = RoundedCornerShape(12.dp)
142+
) {
143+
Column(modifier = Modifier.padding(8.dp)) {
144+
message.takeIf { it.isNotBlank() }?.let {
145+
Text(text = it)
146+
} ?: Text(text = stringResource(id = R.string.send_offer_payer_note_placeholder), style = MaterialTheme.typography.caption)
147+
}
148+
}
149+
}
150+
119151
Spacer(modifier = Modifier.height(8.dp))
120152
SplashLabelRow(label = stringResource(id = R.string.send_trampoline_fee_label)) {
121153
val amt = amount
@@ -134,10 +166,14 @@ fun SendOfferView(
134166
offer = offer,
135167
amount = amount,
136168
isAmountInError = amountErrorMessage.isNotBlank(),
137-
onSendClick = { amount, offer -> vm.sendOffer(amount, offer) },
169+
onSendClick = { amount, offer -> vm.sendOffer(amount, message, offer) },
138170
onPaymentSent = onPaymentSent,
139171
)
140172
}
173+
174+
if (showMessageDialog) {
175+
PayerNoteInput(initialMessage = message, onMessageChange = { message = it }, onDismiss = { showMessageDialog = false })
176+
}
141177
}
142178

143179
@Composable
@@ -158,6 +194,7 @@ private fun SendOfferStateButton(
158194
is OfferState.Complete.Failed.CouldNotGetInvoice -> stringResource(id = R.string.send_offer_failure_timeout)
159195
is OfferState.Complete.Failed.PaymentNotSent -> translatePaymentError(paymentFailure = state.reason)
160196
is OfferState.Complete.Failed.Error -> state.throwable.message
197+
is OfferState.Complete.Failed.PayerNoteTooLong -> "The message is too long (max. 64 chars)"
161198
},
162199
alignment = Alignment.CenterHorizontally,
163200
)
@@ -191,3 +228,43 @@ private fun SendOfferStateButton(
191228
}
192229
}
193230
}
231+
232+
@OptIn(ExperimentalMaterial3Api::class)
233+
@Composable
234+
private fun PayerNoteInput(
235+
initialMessage: String,
236+
onMessageChange: (String) -> Unit,
237+
onDismiss: () -> Unit,
238+
) {
239+
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
240+
241+
ModalBottomSheet(
242+
sheetState = sheetState,
243+
onDismissRequest = onDismiss,
244+
containerColor = MaterialTheme.colors.surface,
245+
contentColor = MaterialTheme.colors.onSurface,
246+
scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.2f),
247+
) {
248+
Column(
249+
modifier = Modifier
250+
.verticalScroll(rememberScrollState())
251+
.padding(horizontal = 32.dp)
252+
.sizeIn(minHeight = 400.dp, maxHeight = 600.dp),
253+
horizontalAlignment = Alignment.CenterHorizontally
254+
) {
255+
Text(text = "Attach a custom message", style = MaterialTheme.typography.h4, textAlign = TextAlign.Center)
256+
Text(text = "The recipient will see this message along with the payment", style = MaterialTheme.typography.caption, textAlign = TextAlign.Center)
257+
Spacer(modifier = Modifier.height(16.dp))
258+
TextInput(
259+
text = initialMessage,
260+
staticLabel = null,
261+
onTextChange = onMessageChange,
262+
placeholder = { Text(text = "Enter a message" )},
263+
maxChars = 64,
264+
singleLine = true
265+
)
266+
Spacer(modifier = Modifier.height(16.dp))
267+
FilledButton(text = stringResource(id = R.string.btn_ok), onClick = onDismiss, modifier = Modifier.align(Alignment.End))
268+
}
269+
}
270+
}

phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferViewModel.kt

+11-6
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import fr.acinq.lightning.io.PaymentNotSent
3030
import fr.acinq.lightning.io.PaymentSent
3131
import fr.acinq.lightning.payment.OutgoingPaymentFailure
3232
import fr.acinq.lightning.wire.OfferTypes
33+
import fr.acinq.phoenix.managers.NodeParamsManager
3334
import fr.acinq.phoenix.managers.PeerManager
3435
import kotlinx.coroutines.CoroutineExceptionHandler
3536
import kotlinx.coroutines.Dispatchers
@@ -46,27 +47,30 @@ sealed class OfferState {
4647
data class Error(val throwable: Throwable) : Failed()
4748
data object CouldNotGetInvoice: Failed()
4849
data class PaymentNotSent(val reason : OutgoingPaymentFailure): Failed()
50+
data object PayerNoteTooLong: Failed()
4951
}
5052
}
5153
}
5254

53-
class SendOfferViewModel(val peerManager: PeerManager) : ViewModel() {
55+
class SendOfferViewModel(val peerManager: PeerManager, val nodeParamsManager: NodeParamsManager) : ViewModel() {
5456
private val log = LoggerFactory.getLogger(this::class.java)
5557

5658
var state by mutableStateOf<OfferState>(OfferState.Init)
5759

58-
fun sendOffer(amount: MilliSatoshi, offer: OfferTypes.Offer) {
60+
fun sendOffer(amount: MilliSatoshi, message: String, offer: OfferTypes.Offer) {
5961
if (state is OfferState.FetchingInvoice) return
6062
state = OfferState.FetchingInvoice
6163
viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e ->
6264
log.error("error when paying offer payment: ", e)
6365
}) {
6466
val peer = peerManager.getPeer()
65-
log.info("sending amount=$amount for offer=$offer")
67+
val payerNote = message.takeIf { it.isNotBlank() }
68+
log.info("sending amount=$amount message=$message for offer=$offer")
6669
val paymentResult = peer.payOffer(
6770
amount = amount,
6871
offer = offer,
69-
payerKey = Lightning.randomKey(), // payer-key will prove the payment was received
72+
payerKey = payerNote?.let { nodeParamsManager.defaultOffer().payerKey } ?: Lightning.randomKey(),
73+
payerNote = payerNote,
7074
fetchInvoiceTimeout = 30.seconds,
7175
// FIXME: this method should accept a trampolineFees parameter
7276
)
@@ -79,11 +83,12 @@ class SendOfferViewModel(val peerManager: PeerManager) : ViewModel() {
7983
}
8084

8185
class Factory(
82-
private val peerManager: PeerManager
86+
private val peerManager: PeerManager,
87+
private val nodeParamsManager: NodeParamsManager,
8388
) : ViewModelProvider.Factory {
8489
override fun <T : ViewModel> create(modelClass: Class<T>): T {
8590
@Suppress("UNCHECKED_CAST")
86-
return SendOfferViewModel(peerManager) as T
91+
return SendOfferViewModel(peerManager, nodeParamsManager) as T
8792
}
8893
}
8994
}

phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ class ReceiveViewModel(
146146

147147
private fun getDeterministicOffer() {
148148
viewModelScope.launch {
149-
val offer = nodeParamsManager.defaultOffer()
150-
val encoded = offer.encode()
149+
val offerData = nodeParamsManager.defaultOffer()
150+
val encoded = offerData.defaultOffer.encode()
151151
val image = BitmapHelper.generateBitmap(encoded).asImageBitmap()
152152
offerState = OfferState.Show(encoded, image)
153153
}

phoenix-android/src/main/res/values/strings.xml

+2
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@
167167
<string name="send_pay_button">Pay</string>
168168
<string name="send_pay_retry_button">Try again</string>
169169

170+
<string name="send_offer_payer_note_label">Message</string>
171+
<string name="send_offer_payer_note_placeholder">Tap to attach a message…</string>
170172
<string name="send_offer_fetching">Fetching payment details…</string>
171173
<string name="send_offer_failure_title">Payment has failed</string>
172174
<string name="send_offer_failure_timeout">Could not retrieve payment details within a reasonable time.\n\nThe recipient may be offline or unreachable.</string>

0 commit comments

Comments
 (0)