Skip to content

Commit

Permalink
Add option to overpay LN payments (#541)
Browse files Browse the repository at this point in the history
The option to overpay has been added to both iOS and Android, in 
Settings > Payment options.

Overpayment is still capped to 2 times the requested amount.

On Android, +- 5% tipping buttons have been added when sending 
LN payments. The iOS app already had buttons to add tips.

On Android, convert the amount when switching unit, instead of
only changing the unit. It causes some rounding issues, but it's 
a reasonable tradeoff.

---------

Co-authored-by: Robbie Hanson <304604+robbiehanson@users.noreply.github.com>
  • Loading branch information
dpad85 and robbiehanson committed Apr 19, 2024
1 parent d5a0ac5 commit 968f9c8
Show file tree
Hide file tree
Showing 45 changed files with 751 additions and 521 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.navigation.NavController
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.android.utils.UserTheme
import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository
import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository
import fr.acinq.phoenix.controllers.ControllerFactory
import fr.acinq.phoenix.data.*

Expand Down Expand Up @@ -75,6 +76,10 @@ val internalData: InternalDataRepository
@Composable
get() = application.internalDataRepository

val userPrefs: UserPrefsRepository
@Composable
get() = application.userPrefs

val controllerFactory: ControllerFactory
@Composable
get() = LocalControllerFactory.current ?: error("No controller factory set. Please use appView or mockView.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ import fr.acinq.phoenix.android.settings.walletinfo.WalletInfoView
import fr.acinq.phoenix.android.startup.LegacySwitcherView
import fr.acinq.phoenix.android.startup.StartupView
import fr.acinq.phoenix.android.utils.appBackground
import fr.acinq.phoenix.android.utils.datastore.UserPrefs
import fr.acinq.phoenix.android.utils.logger
import fr.acinq.phoenix.data.BitcoinUnit
import fr.acinq.phoenix.data.FiatCurrency
Expand Down Expand Up @@ -124,9 +123,9 @@ fun AppView(
log.debug("init app view composition")

val context = LocalContext.current
val isAmountInFiat = UserPrefs.getIsAmountInFiat(context).collectAsState(false)
val fiatCurrency = UserPrefs.getFiatCurrency(context).collectAsState(initial = FiatCurrency.USD)
val bitcoinUnit = UserPrefs.getBitcoinUnit(context).collectAsState(initial = BitcoinUnit.Sat)
val isAmountInFiat = userPrefs.getIsAmountInFiat.collectAsState(false)
val fiatCurrency = userPrefs.getFiatCurrency.collectAsState(initial = FiatCurrency.USD)
val bitcoinUnit = userPrefs.getBitcoinUnit.collectAsState(initial = BitcoinUnit.Sat)
val fiatRates by business.currencyManager.ratesFlow.collectAsState(emptyList())

CompositionLocalProvider(
Expand Down Expand Up @@ -498,7 +497,8 @@ private fun MonitorNotices(
vm: NoticesViewModel
) {
val context = LocalContext.current
val internalData = application.internalDataRepository
val internalData = internalData
val userPrefs = userPrefs

LaunchedEffect(Unit) {
internalData.showSeedBackupNotice.collect {
Expand All @@ -514,15 +514,15 @@ private fun MonitorNotices(
val notificationPermission = rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
if (!notificationPermission.status.isGranted) {
LaunchedEffect(Unit) {
if (UserPrefs.getShowNotificationPermissionReminder(context).first()) {
if (userPrefs.getShowNotificationPermissionReminder.first()) {
vm.addNotice(Notice.NotificationPermission)
}
}
} else {
vm.removeNotice<Notice.NotificationPermission>()
}
LaunchedEffect(Unit) {
UserPrefs.getShowNotificationPermissionReminder(context).collect {
userPrefs.getShowNotificationPermissionReminder.collect {
if (it && !notificationPermission.status.isGranted) {
vm.addNotice(Notice.NotificationPermission)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.android.utils.Logging
import fr.acinq.phoenix.android.utils.SystemNotificationHelper
import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository
import fr.acinq.phoenix.android.utils.datastore.UserPrefs
import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository
import fr.acinq.phoenix.legacy.AppContext
import fr.acinq.phoenix.legacy.internalData
import fr.acinq.phoenix.legacy.userPrefs
import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import fr.acinq.phoenix.utils.PlatformContext
Expand All @@ -32,17 +33,19 @@ import kotlinx.coroutines.flow.asStateFlow

class PhoenixApplication : AppContext() {

private val _business = MutableStateFlow<PhoenixBusiness?>(null) // by lazy { MutableStateFlow(PhoenixBusiness(PlatformContext(applicationContext))) }
private val _business = MutableStateFlow<PhoenixBusiness?>(null)
val business = _business.asStateFlow()

lateinit var internalDataRepository: InternalDataRepository
lateinit var userPrefs: UserPrefsRepository

override fun onCreate() {
super.onCreate()
_business.value = PhoenixBusiness(PlatformContext(applicationContext))
Logging.setupLogger(applicationContext)
SystemNotificationHelper.registerNotificationChannels(applicationContext)
internalDataRepository = InternalDataRepository(applicationContext.internalData)
userPrefs = UserPrefsRepository(applicationContext.userPrefs)
}

override fun onLegacyFinish() {
Expand All @@ -63,7 +66,7 @@ class PhoenixApplication : AppContext() {

suspend fun clearPreferences() {
internalDataRepository.clear()
UserPrefs.clear(applicationContext)
userPrefs.clear()
LegacyPrefsDatastore.clear(applicationContext)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package fr.acinq.phoenix.android.components

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clipScrollableContainer
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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.geometry.Offset
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.LocalFiatCurrency
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.fiatRate
import fr.acinq.phoenix.android.utils.Converter.toFiat
import fr.acinq.phoenix.android.utils.Converter.toPlainString
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.Converter.toUnit
import fr.acinq.phoenix.android.utils.negativeColor
import fr.acinq.phoenix.data.BitcoinUnit
import fr.acinq.phoenix.data.CurrencyUnit
import fr.acinq.phoenix.data.FiatCurrency

private enum class SlotsEnum { Input, Unit, DashedLine }

/**
* This input is designed to be in the center stage of a screen. It uses a customised basic input
* instead of a standard, material-design input.
*/
@Composable
fun AmountHeroInput(
initialAmount: MilliSatoshi?,
onAmountChange: (ComplexAmount?) -> Unit,
validationErrorMessage: String,
modifier: Modifier = Modifier,
inputModifier: Modifier = Modifier,
dropdownModifier: Modifier = Modifier,
inputTextSize: TextUnit = 16.sp,
enabled: Boolean = true,
) {
val context = LocalContext.current
val prefBitcoinUnit = LocalBitcoinUnit.current
val prefFiat = LocalFiatCurrency.current
val rate = fiatRate
val units = listOf<CurrencyUnit>(BitcoinUnit.Sat, BitcoinUnit.Bit, BitcoinUnit.MBtc, BitcoinUnit.Btc, prefFiat)
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current

var unit: CurrencyUnit by remember { mutableStateOf(prefBitcoinUnit) }
var inputValue by remember(initialAmount) {
mutableStateOf(TextFieldValue(
when (val u = unit) {
is FiatCurrency -> {
if (rate != null) {
initialAmount?.toFiat(rate.price).toPlainString(limitDecimal = true)
} else "?!"
}
is BitcoinUnit -> {
initialAmount?.toUnit(u).toPlainString()
}
else -> "?!"
}
))
}
var convertedValue: String by remember(initialAmount) {
mutableStateOf(initialAmount?.toPrettyString(if (unit is FiatCurrency) prefBitcoinUnit else prefFiat, rate, withUnit = true) ?: "")
}

var internalErrorMessage: String by remember { mutableStateOf(validationErrorMessage) }
val errorMessage = validationErrorMessage.ifBlank { internalErrorMessage.ifBlank { null } }

val input: @Composable () -> Unit = {
BasicTextField(
value = inputValue,
onValueChange = { newValue ->
inputValue = newValue
AmountInputHelper.convertToComplexAmount(
context = context,
input = inputValue.text,
unit = unit,
prefBitcoinUnit = prefBitcoinUnit,
rate = rate,
onConverted = { convertedValue = it },
onError = { internalErrorMessage = it }
).let { onAmountChange(it) }
},
modifier = inputModifier
.clipScrollableContainer(Orientation.Horizontal)
.defaultMinSize(minWidth = 32.dp) // for good ux
.width(IntrinsicSize.Min), // make the textfield fits its content
textStyle = MaterialTheme.typography.body1.copy(
fontSize = inputTextSize,
color = if (errorMessage == null) MaterialTheme.colors.primary else negativeColor,
fontWeight = FontWeight.Light,
),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboardController?.hide() }),
singleLine = true,
enabled = enabled
)
}

val unitDropdown: @Composable () -> Unit = {
UnitDropdown(
selectedUnit = unit,
units = units,
onUnitChange = { newUnit ->
val currentAmount = AmountInputHelper.convertToComplexAmount(
context = context,
input = inputValue.text,
unit = unit,
prefBitcoinUnit = prefBitcoinUnit,
rate = rate,
onConverted = { },
onError = { }
)?.amount
// update the actual input value to the converted amount
when (newUnit) {
is FiatCurrency -> {
inputValue = TextFieldValue(if (rate == null) "?!" else currentAmount?.toFiat(rate.price).toPlainString(limitDecimal = false))
}
is BitcoinUnit -> {
inputValue = TextFieldValue(currentAmount?.toUnit(newUnit).toPlainString())
}
}
unit = newUnit
AmountInputHelper.convertToComplexAmount(
context = context,
input = inputValue.text,
unit = unit,
prefBitcoinUnit = prefBitcoinUnit,
rate = rate,
onConverted = { convertedValue = it },
onError = { internalErrorMessage = it }
).let { onAmountChange(it) }
},
onDismiss = { },
modifier = dropdownModifier,
enabled = enabled
)
}

val dashedLine: @Composable () -> Unit = {
val dotColor = if (errorMessage == null) MaterialTheme.colors.primary else negativeColor
Canvas(modifier = Modifier.fillMaxWidth()) {
drawLine(
pathEffect = PathEffect.dashPathEffect(floatArrayOf(1f, 20f)),
start = Offset(x = 0f, y = 0f),
end = Offset(x = size.width, y = 0f),
cap = StrokeCap.Round,
color = dotColor,
strokeWidth = 7f
)
}
}

Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
SubcomposeLayout(modifier = Modifier) { constraints ->
val unitSlotPlaceables: List<Placeable> = subcompose(SlotsEnum.Unit, unitDropdown).map { it.measure(constraints) }
val unitWidth = unitSlotPlaceables.maxOf { it.width }
val unitHeight = unitSlotPlaceables.maxOf { it.height }

val inputSlotPlaceables: List<Placeable> = subcompose(SlotsEnum.Input, input).map {
it.measure(constraints.copy(maxWidth = constraints.maxWidth - unitWidth))
}
val inputWidth = inputSlotPlaceables.maxOf { it.width }
val inputHeight = inputSlotPlaceables.maxOf { it.height }

// dashed line width is input's width + unit's width
val layoutWidth = inputWidth + unitWidth
val dashedLinePlaceables = subcompose(SlotsEnum.DashedLine, dashedLine).map {
it.measure(constraints.copy(minWidth = layoutWidth, maxWidth = layoutWidth))
}
val dashedLineHeight = dashedLinePlaceables.maxOf { it.height }
val layoutHeight = listOf(inputHeight, unitHeight).maxOrNull() ?: (0 + dashedLineHeight)

val inputBaseline = inputSlotPlaceables.map { it[FirstBaseline] }.maxOrNull() ?: 0
val unitBaseline = unitSlotPlaceables.map { it[FirstBaseline] }.maxOrNull() ?: 0

layout(layoutWidth, layoutHeight) {
var x = 0
var y = 0
inputSlotPlaceables.forEach {
it.placeRelative(x, 0)
x += it.width
y = maxOf(y, it.height)
}
unitSlotPlaceables.forEach {
it.placeRelative(x, inputBaseline - unitBaseline)
x += it.width
y = maxOf(y, it.height)
}
dashedLinePlaceables.forEach {
it.placeRelative(0, y)
}
}
}

Spacer(Modifier.height(8.dp))
if (errorMessage != null) {
Text(
text = errorMessage,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = negativeColor, fontSize = 14.sp, textAlign = TextAlign.Center),
modifier = Modifier.sizeIn(maxWidth = 300.dp, minHeight = 28.dp)
)
} else {
Text(
text = convertedValue.takeIf { it.isNotBlank() }?.let { stringResource(id = R.string.utils_converted_amount, it) } ?: "",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(textAlign = TextAlign.Center),
modifier = Modifier.sizeIn(minHeight = 28.dp)
)
}
}
}
Loading

0 comments on commit 968f9c8

Please sign in to comment.