diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Ambients.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Ambients.kt index 130bab230..a61cf65f3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Ambients.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Ambients.kt @@ -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.* @@ -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.") diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt index b65347c30..1cd0ab5a4 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt @@ -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 @@ -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( @@ -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 { @@ -514,7 +514,7 @@ 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) } } @@ -522,7 +522,7 @@ private fun MonitorNotices( vm.removeNotice() } LaunchedEffect(Unit) { - UserPrefs.getShowNotificationPermissionReminder(context).collect { + userPrefs.getShowNotificationPermissionReminder.collect { if (it && !notificationPermission.status.isGranted) { vm.addNotice(Notice.NotificationPermission) } else { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/PhoenixApplication.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/PhoenixApplication.kt index c8306b8fd..3bbd00d81 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/PhoenixApplication.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/PhoenixApplication.kt @@ -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 @@ -32,10 +33,11 @@ import kotlinx.coroutines.flow.asStateFlow class PhoenixApplication : AppContext() { - private val _business = MutableStateFlow(null) // by lazy { MutableStateFlow(PhoenixBusiness(PlatformContext(applicationContext))) } + private val _business = MutableStateFlow(null) val business = _business.asStateFlow() lateinit var internalDataRepository: InternalDataRepository + lateinit var userPrefs: UserPrefsRepository override fun onCreate() { super.onCreate() @@ -43,6 +45,7 @@ class PhoenixApplication : AppContext() { Logging.setupLogger(applicationContext) SystemNotificationHelper.registerNotificationChannels(applicationContext) internalDataRepository = InternalDataRepository(applicationContext.internalData) + userPrefs = UserPrefsRepository(applicationContext.userPrefs) } override fun onLegacyFinish() { @@ -63,7 +66,7 @@ class PhoenixApplication : AppContext() { suspend fun clearPreferences() { internalDataRepository.clear() - UserPrefs.clear(applicationContext) + userPrefs.clear() LegacyPrefsDatastore.clear(applicationContext) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountHeroInput.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountHeroInput.kt new file mode 100644 index 000000000..8ac03cdab --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountHeroInput.kt @@ -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(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 = subcompose(SlotsEnum.Unit, unitDropdown).map { it.measure(constraints) } + val unitWidth = unitSlotPlaceables.maxOf { it.width } + val unitHeight = unitSlotPlaceables.maxOf { it.height } + + val inputSlotPlaceables: List = 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) + ) + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt index e8d1eaaa2..fe3e87f2d 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt @@ -17,16 +17,11 @@ package fr.acinq.phoenix.android.components import android.content.Context -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clipScrollableContainer -import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.LocalTextSelectionColors @@ -43,28 +38,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -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.bitcoin.Satoshi @@ -371,180 +354,8 @@ fun InlineSatoshiInput( } } -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. - */ -@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) -@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(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 { mutableStateOf(TextFieldValue(initialAmount?.toUnit(prefBitcoinUnit).toPlainString())) } - var convertedValue: String by remember { mutableStateOf(initialAmount?.toPrettyString(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 = { newValue -> - unit = newValue - 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 = subcompose(SlotsEnum.Unit, unitDropdown).map { it.measure(constraints) } - val unitWidth = unitSlotPlaceables.maxOf { it.width } - val unitHeight = unitSlotPlaceables.maxOf { it.height } - - val inputSlotPlaceables: List = 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) - ) - } - } - -} - @Composable -private fun UnitDropdown( +fun UnitDropdown( selectedUnit: CurrencyUnit, units: List, onUnitChange: (CurrencyUnit) -> Unit, @@ -558,11 +369,12 @@ private fun UnitDropdown( Box(modifier = modifier.wrapContentSize(Alignment.TopStart)) { Button( text = units[selectedIndex].displayCode, - icon = R.drawable.ic_chevron_down, + icon = if (enabled) R.drawable.ic_chevron_down else null, onClick = { expanded = true }, padding = internalPadding, - space = 8.dp, + space = if (enabled) 8.dp else 0.dp, enabled = enabled, + enabledEffect = false, ) DropdownMenu( expanded = expanded, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt index e4b4ccaa4..4ae863e41 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt @@ -16,7 +16,6 @@ package fr.acinq.phoenix.android.components -import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -28,7 +27,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle @@ -40,7 +38,7 @@ import fr.acinq.phoenix.android.* import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.MSatDisplayPolicy -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository import fr.acinq.phoenix.data.CurrencyUnit import fr.acinq.phoenix.data.FiatCurrency import kotlinx.coroutines.launch @@ -57,10 +55,10 @@ fun AmountView( unitTextStyle: TextStyle = MaterialTheme.typography.body1, separatorSpace: Dp = 4.dp, mSatDisplayPolicy: MSatDisplayPolicy = MSatDisplayPolicy.HIDE, - onClick: (suspend (Context, Boolean) -> Unit)? = { context, inFiat -> UserPrefs.saveIsAmountInFiat(context, !inFiat) } + onClick: (suspend (UserPrefsRepository, Boolean) -> Unit)? = { userPrefs, inFiat -> userPrefs.saveIsAmountInFiat(!inFiat) } ) { val scope = rememberCoroutineScope() - val context = LocalContext.current + val userPrefs = userPrefs val unit = forceUnit ?: if (LocalShowInFiat.current) { LocalFiatCurrency.current } else { @@ -76,7 +74,7 @@ fun AmountView( interactionSource = interactionSource, indication = null, role = Role.Button, - onClick = { scope.launch { onClick(context, inFiat) } } + onClick = { scope.launch { onClick(userPrefs, inFiat) } } ) else Modifier) ) { if (!isRedacted && prefix != null && amount > MilliSatoshi(0)) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Layout.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Layout.kt index 1c209053f..26317876a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Layout.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Layout.kt @@ -36,16 +36,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import fr.acinq.lightning.MilliSatoshi import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.borderColor import fr.acinq.phoenix.android.utils.datastore.HomeAmountDisplayMode -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.android.utils.mutedTextColor import kotlinx.coroutines.flow.firstOrNull @@ -83,8 +82,7 @@ fun BackButtonWithBalance( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - val context = LocalContext.current - val balanceDisplayMode by UserPrefs.getHomeAmountDisplayMode(context).collectAsState(initial = HomeAmountDisplayMode.REDACTED) + val balanceDisplayMode by userPrefs.getHomeAmountDisplayMode.collectAsState(initial = HomeAmountDisplayMode.REDACTED) BackButton(onClick = onBackClick) Row(modifier = Modifier.padding(horizontal = 16.dp)) { @@ -96,13 +94,13 @@ fun BackButtonWithBalance( Spacer(modifier = Modifier.width(4.dp)) balance?.let { AmountView(amount = it, modifier = Modifier.alignBy(FirstBaseline), isRedacted = balanceDisplayMode==HomeAmountDisplayMode.REDACTED, - onClick = { context, inFiat -> - val mode = UserPrefs.getHomeAmountDisplayMode(context).firstOrNull() + onClick = { userPrefs, inFiat -> + val mode = userPrefs.getHomeAmountDisplayMode.firstOrNull() when { - inFiat && mode == HomeAmountDisplayMode.BTC -> UserPrefs.saveHomeAmountDisplayMode(context, HomeAmountDisplayMode.REDACTED) - mode == HomeAmountDisplayMode.BTC -> UserPrefs.saveHomeAmountDisplayMode(context, HomeAmountDisplayMode.FIAT) - mode == HomeAmountDisplayMode.FIAT -> UserPrefs.saveHomeAmountDisplayMode(context, HomeAmountDisplayMode.REDACTED) - mode == HomeAmountDisplayMode.REDACTED -> UserPrefs.saveHomeAmountDisplayMode(context, HomeAmountDisplayMode.BTC) + inFiat && mode == HomeAmountDisplayMode.BTC -> userPrefs.saveHomeAmountDisplayMode(HomeAmountDisplayMode.REDACTED) + mode == HomeAmountDisplayMode.BTC -> userPrefs.saveHomeAmountDisplayMode(HomeAmountDisplayMode.FIAT) + mode == HomeAmountDisplayMode.FIAT -> userPrefs.saveHomeAmountDisplayMode(HomeAmountDisplayMode.REDACTED) + mode == HomeAmountDisplayMode.REDACTED -> userPrefs.saveHomeAmountDisplayMode(HomeAmountDisplayMode.BTC) else -> Unit } }) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/ConnectionDialog.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/ConnectionDialog.kt index 2e3e3ce49..e53ba7513 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/ConnectionDialog.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/ConnectionDialog.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp @@ -42,7 +41,7 @@ import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.Dialog import fr.acinq.phoenix.android.components.HSeparator import fr.acinq.phoenix.android.components.TextWithIcon -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.isBadCertificate import fr.acinq.phoenix.android.utils.monoTypo import fr.acinq.phoenix.android.utils.negativeColor @@ -59,8 +58,6 @@ fun ConnectionDialog( onTorClick: () -> Unit, onElectrumClick: () -> Unit, ) { - val context = LocalContext.current - Dialog(title = stringResource(id = R.string.conndialog_title), onDismiss = onClose) { Column { if (connections.internet != Connection.ESTABLISHED) { @@ -77,7 +74,7 @@ fun ConnectionDialog( ConnectionDialogLine(label = stringResource(id = R.string.conndialog_internet), connection = connections.internet) HSeparator() - val isTorEnabled = UserPrefs.getIsTorEnabled(context).collectAsState(initial = null).value + val isTorEnabled = userPrefs.getIsTorEnabled.collectAsState(initial = null).value if (isTorEnabled != null && isTorEnabled) { ConnectionDialogLine(label = stringResource(id = R.string.conndialog_tor), connection = connections.tor, onClick = onTorClick) HSeparator() diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeBalance.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeBalance.kt index a55cb59a7..3bd535709 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeBalance.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeBalance.kt @@ -19,13 +19,11 @@ package fr.acinq.phoenix.android.home import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration @@ -42,9 +40,9 @@ import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.fiatRate import fr.acinq.phoenix.android.preferredAmountUnit +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.datastore.HomeAmountDisplayMode -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.managers.WalletBalance import kotlinx.coroutines.flow.firstOrNull @@ -73,13 +71,13 @@ fun HomeBalance( amountTextStyle = MaterialTheme.typography.body2.copy(fontSize = 40.sp), unitTextStyle = MaterialTheme.typography.h3.copy(fontWeight = FontWeight.Light, color = MaterialTheme.colors.primary), isRedacted = isAmountRedacted, - onClick = { context, inFiat -> - val mode = UserPrefs.getHomeAmountDisplayMode(context).firstOrNull() + onClick = { userPrefs, inFiat -> + val mode = userPrefs.getHomeAmountDisplayMode.firstOrNull() when { - inFiat && mode == HomeAmountDisplayMode.BTC -> UserPrefs.saveHomeAmountDisplayMode(context, HomeAmountDisplayMode.REDACTED) - mode == HomeAmountDisplayMode.BTC -> UserPrefs.saveHomeAmountDisplayMode(context, HomeAmountDisplayMode.FIAT) - mode == HomeAmountDisplayMode.FIAT -> UserPrefs.saveHomeAmountDisplayMode(context, HomeAmountDisplayMode.REDACTED) - mode == HomeAmountDisplayMode.REDACTED -> UserPrefs.saveHomeAmountDisplayMode(context, HomeAmountDisplayMode.BTC) + inFiat && mode == HomeAmountDisplayMode.BTC -> userPrefs.saveHomeAmountDisplayMode(HomeAmountDisplayMode.REDACTED) + mode == HomeAmountDisplayMode.BTC -> userPrefs.saveHomeAmountDisplayMode(HomeAmountDisplayMode.FIAT) + mode == HomeAmountDisplayMode.FIAT -> userPrefs.saveHomeAmountDisplayMode(HomeAmountDisplayMode.REDACTED) + mode == HomeAmountDisplayMode.REDACTED -> userPrefs.saveHomeAmountDisplayMode(HomeAmountDisplayMode.BTC) else -> Unit } } @@ -133,7 +131,7 @@ private fun IncomingBalance( ) if (showSwapInHelp) { - val liquidityPolicyInPrefs by UserPrefs.getLiquidityPolicy(LocalContext.current).collectAsState(null) + val liquidityPolicyInPrefs by userPrefs.getLiquidityPolicy.collectAsState(null) val bitcoinUnit = LocalBitcoinUnit.current PopupDialog( onDismiss = { showSwapInHelp = false }, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt index f66e216d1..d6b142b5d 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt @@ -55,7 +55,6 @@ import fr.acinq.phoenix.android.components.PrimarySeparator import fr.acinq.phoenix.android.components.mvi.MVIView import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.datastore.HomeAmountDisplayMode -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.android.utils.findActivity import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.canRequestLiquidity @@ -83,8 +82,9 @@ fun HomeView( val context = LocalContext.current val internalData = application.internalDataRepository - val torEnabledState = UserPrefs.getIsTorEnabled(context).collectAsState(initial = null) - val balanceDisplayMode by UserPrefs.getHomeAmountDisplayMode(context).collectAsState(initial = HomeAmountDisplayMode.REDACTED) + val userPrefs = application.userPrefs + val torEnabledState = userPrefs.getIsTorEnabled.collectAsState(initial = null) + val balanceDisplayMode by userPrefs.getHomeAmountDisplayMode.collectAsState(initial = HomeAmountDisplayMode.REDACTED) val connections by business.connectionsManager.connections.collectAsState() val electrumMessages by business.appConfigurationManager.electrumMessages.collectAsState() diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/RestoreWalletView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/RestoreWalletView.kt index d78579036..9c849ca3d 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/RestoreWalletView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/RestoreWalletView.kt @@ -53,7 +53,6 @@ import fr.acinq.phoenix.android.controllerFactory import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.security.SeedFileState import fr.acinq.phoenix.android.security.SeedManager -import fr.acinq.phoenix.android.utils.logger import fr.acinq.phoenix.android.utils.negativeColor import fr.acinq.phoenix.controllers.init.RestoreWallet import fr.acinq.phoenix.utils.MnemonicLanguage diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlAuthView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlAuthView.kt index 9195f09b8..08ca102af 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlAuthView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlAuthView.kt @@ -24,14 +24,13 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.controllers.payments.Scan import fr.acinq.phoenix.data.lnurl.LnurlAuth @@ -42,9 +41,8 @@ fun LnurlAuthView( onLoginClick: (Scan.Intent.LnurlAuthFlow) -> Unit, onAuthSchemeInfoClick: () -> Unit ) { - val context = LocalContext.current var showHowItWorks by remember { mutableStateOf(false) } - val prefAuthScheme by UserPrefs.getLnurlAuthScheme(context).collectAsState(initial = null) + val prefAuthScheme by userPrefs.getLnurlAuthScheme.collectAsState(initial = null) val isLegacyDomain = remember(model) { LnurlAuth.LegacyDomain.isEligible(model.auth.initialUrl) } Column( diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlPayView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlPayView.kt index e32becfcf..74b36e156 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlPayView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlPayView.kt @@ -62,14 +62,15 @@ fun LnurlPayView( val prefUnit = preferredAmountUnit val rate = fiatRate - var amount by remember { mutableStateOf(model.paymentIntent.minSendable) } + val minRequestedAmount = model.paymentIntent.minSendable + var amount by remember { mutableStateOf(minRequestedAmount) } var amountErrorMessage by remember { mutableStateOf("") } SplashLayout( header = { BackButtonWithBalance(onBackClick = onBackClick, balance = balance) }, topContent = { AmountHeroInput( - initialAmount = amount, + initialAmount = minRequestedAmount, onAmountChange = { newAmount -> amountErrorMessage = "" when { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlWithdrawView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlWithdrawView.kt index 274938f9e..3b6870c46 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlWithdrawView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/LnurlWithdrawView.kt @@ -49,7 +49,8 @@ fun LnurlWithdrawView( val prefUnit = preferredAmountUnit val rate = fiatRate - var amount by remember { mutableStateOf(model.lnurlWithdraw.maxWithdrawable) } + val maxWithdrawable = model.lnurlWithdraw.maxWithdrawable + var amount by remember { mutableStateOf(maxWithdrawable) } var amountErrorMessage by remember { mutableStateOf("") } SplashLayout( @@ -58,7 +59,7 @@ fun LnurlWithdrawView( Text(text = annotatedStringResource(R.string.lnurl_withdraw_header, model.lnurlWithdraw.initialUrl.host), textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(16.dp)) AmountHeroInput( - initialAmount = amount, + initialAmount = maxWithdrawable, onAmountChange = { newAmount -> amountErrorMessage = "" when { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt index a82c0006f..357ee6f33 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt @@ -16,9 +16,12 @@ package fr.acinq.phoenix.android.payments +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -36,6 +39,7 @@ import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.safeLet import fr.acinq.phoenix.controllers.payments.Scan @@ -54,38 +58,74 @@ fun SendBolt11PaymentView( val requestedAmount = invoice.amount var amount by remember { mutableStateOf(requestedAmount) } - var amountErrorMessage by remember { mutableStateOf("") } + val amountErrorMessage: String = remember(amount) { + val currentAmount = amount + when { + currentAmount == null -> "" + balance != null && currentAmount > balance -> context.getString(R.string.send_error_amount_over_balance) + requestedAmount != null && currentAmount < requestedAmount -> context.getString( + R.string.send_error_amount_below_requested, + (requestedAmount).toPrettyString(prefBitcoinUnit, withUnit = true) + ) + requestedAmount != null && currentAmount > requestedAmount * 2 -> context.getString( + R.string.send_error_amount_overpaying, + (requestedAmount * 2).toPrettyString(prefBitcoinUnit, withUnit = true) + ) + else -> "" + } + } + val isOverpaymentEnabled by userPrefs.getIsOverpaymentEnabled.collectAsState(initial = false) SplashLayout( header = { BackButtonWithBalance(onBackClick = onBackClick, balance = balance) }, topContent = { + var inputForcedAmount by remember { mutableStateOf(requestedAmount) } AmountHeroInput( - initialAmount = amount, - onAmountChange = { newAmount -> - amountErrorMessage = "" - when { - newAmount == null -> {} - balance != null && newAmount.amount > balance -> { - amountErrorMessage = context.getString(R.string.send_error_amount_over_balance) - } - requestedAmount != null && newAmount.amount < requestedAmount -> { - amountErrorMessage = context.getString( - R.string.send_error_amount_below_requested, - (requestedAmount).toPrettyString(prefBitcoinUnit, withUnit = true) - ) - } - requestedAmount != null && newAmount.amount > requestedAmount * 2 -> { - amountErrorMessage = context.getString( - R.string.send_error_amount_overpaying, - (requestedAmount * 2).toPrettyString(prefBitcoinUnit, withUnit = true) - ) - } - } - amount = newAmount?.amount - }, + initialAmount = inputForcedAmount, + enabled = requestedAmount == null || isOverpaymentEnabled, + onAmountChange = { newAmount -> amount = newAmount?.amount }, validationErrorMessage = amountErrorMessage, inputTextSize = 42.sp, ) + Spacer(modifier = Modifier.height(16.dp)) + if (requestedAmount != null) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Button( + text = "5%", + icon = R.drawable.ic_minus_circle, + onClick = { + val newAmount = amount?.let { + it - requestedAmount * 0.05 + }?.coerceAtLeast(requestedAmount) ?: requestedAmount + + inputForcedAmount = newAmount + amount = newAmount + }, + enabled = amount?.let { it > requestedAmount } ?: false, + padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), + space = 6.dp, + textStyle = MaterialTheme.typography.caption.copy(fontSize = 14.sp), + shape = CircleShape, + ) + Button( + text = "5%", + icon = R.drawable.ic_plus_circle, + onClick = { + val newAmount = amount?.let { + it + requestedAmount * 0.05 + }?.coerceAtMost(requestedAmount * 2) ?: requestedAmount + + inputForcedAmount = newAmount + amount = newAmount + }, + enabled = amount?.let { it < requestedAmount * 2 } ?: false, + padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), + space = 6.dp, + textStyle = MaterialTheme.typography.caption.copy(fontSize = 14.sp), + shape = CircleShape, + ) + } + } } ) { invoice.description?.takeIf { it.isNotBlank() }?.let { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt index 0c3985bdd..fded4f993 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt @@ -21,27 +21,20 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,33 +53,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.lightning.payment.LiquidityPolicy -import fr.acinq.lightning.payment.PaymentRequest -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.sum -import fr.acinq.phoenix.android.LocalBitcoinUnit -import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.Screen import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* -import fr.acinq.phoenix.android.components.feedback.ErrorMessage -import fr.acinq.phoenix.android.components.feedback.InfoMessage -import fr.acinq.phoenix.android.fiatRate -import fr.acinq.phoenix.android.navController -import fr.acinq.phoenix.android.utils.Converter.toPrettyString -import fr.acinq.phoenix.android.utils.borderColor +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.copyToClipboard -import fr.acinq.phoenix.android.utils.datastore.UserPrefs -import fr.acinq.phoenix.android.utils.logger import fr.acinq.phoenix.android.utils.safeLet -import fr.acinq.phoenix.android.utils.share -import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore -import fr.acinq.phoenix.managers.WalletBalance -import kotlinx.coroutines.launch @Composable fun ReceiveView( @@ -95,11 +67,10 @@ fun ReceiveView( onFeeManagementClick: () -> Unit, onScanDataClick: () -> Unit, ) { - val context = LocalContext.current // val balanceManager = business.balanceManager val vm: ReceiveViewModel = viewModel(factory = ReceiveViewModel.Factory(business.chain, business.peerManager, business.walletManager)) - val defaultInvoiceExpiry by UserPrefs.getInvoiceDefaultExpiry(context).collectAsState(null) - val defaultInvoiceDesc by UserPrefs.getInvoiceDefaultDesc(context).collectAsState(null) + val defaultInvoiceExpiry by userPrefs.getInvoiceDefaultExpiry.collectAsState(null) + val defaultInvoiceDesc by userPrefs.getInvoiceDefaultDesc.collectAsState(null) // // When a on-chain payment has been received, go back to the home screen (via the onSwapInReceived callback) // LaunchedEffect(key1 = Unit) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt index a91a7ee3a..b1ade5f06 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt @@ -64,7 +64,6 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.LiquidityPolicy -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.phoenix.android.LocalBitcoinUnit @@ -82,12 +81,10 @@ import fr.acinq.phoenix.android.components.TextInput import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.feedback.InfoMessage import fr.acinq.phoenix.android.components.feedback.WarningMessage -import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.borderColor import fr.acinq.phoenix.android.utils.copyToClipboard -import fr.acinq.phoenix.android.utils.datastore.UserPrefs -import fr.acinq.phoenix.android.utils.logger import fr.acinq.phoenix.android.utils.share import fr.acinq.phoenix.data.availableForReceive import fr.acinq.phoenix.data.canRequestLiquidity @@ -318,9 +315,6 @@ private fun EvaluateLiquidityIssuesForPayment( onFeeManagementClick: () -> Unit, isEditing: Boolean, // do not show the dialog immediately when editing the invoice, it's annoying ) { - val log = logger("EvaluateLiquidity") - val context = LocalContext.current - val channelsMap by business.peerManager.channelsFlow.collectAsState() val canRequestLiquidity = remember(channelsMap) { channelsMap.canRequestLiquidity() } val availableForReceive = remember(channelsMap) { channelsMap.availableForReceive() } @@ -328,7 +322,7 @@ private fun EvaluateLiquidityIssuesForPayment( val mempoolFeerate by business.appConfigurationManager.mempoolFeerate.collectAsState() val swapFee = remember(mempoolFeerate, amount) { mempoolFeerate?.payToOpenEstimationFee(amount = amount ?: 0.msat, hasNoChannels = channelsMap?.values?.filterNot { it.isTerminated }.isNullOrEmpty()) } - val liquidityPolicyPrefs = UserPrefs.getLiquidityPolicy(context).collectAsState(null) + val liquidityPolicyPrefs = userPrefs.getLiquidityPolicy.collectAsState(null) when (val liquidityPolicy = liquidityPolicyPrefs.value) { null -> {} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt index 64fe11374..588fed26b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt @@ -16,7 +16,6 @@ package fr.acinq.phoenix.android.payments.receive -import android.content.Context import androidx.annotation.UiThread import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,7 +26,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras -import fr.acinq.bitcoin.Bitcoin import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.Lightning @@ -37,7 +35,7 @@ import fr.acinq.phoenix.android.PhoenixApplication import fr.acinq.phoenix.android.utils.BitmapHelper import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository import fr.acinq.phoenix.android.utils.datastore.SwapAddressFormat -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository import fr.acinq.phoenix.managers.PeerManager import fr.acinq.phoenix.managers.WalletManager import kotlinx.coroutines.CoroutineExceptionHandler @@ -64,7 +62,7 @@ class ReceiveViewModel( private val peerManager: PeerManager, private val walletManager: WalletManager, private val internalDataRepository: InternalDataRepository, - private val context: Context, + private val userPrefs: UserPrefsRepository ): ViewModel() { private val log = LoggerFactory.getLogger(this::class.java) @@ -111,7 +109,7 @@ class ReceiveViewModel( @UiThread private fun monitorCurrentSwapAddress() { viewModelScope.launch { - val swapAddressFormat = UserPrefs.getSwapAddressFormat(context).first() + val swapAddressFormat = userPrefs.getSwapAddressFormat.first() if (swapAddressFormat == SwapAddressFormat.LEGACY) { val legacySwapInAddress = peerManager.getPeer().swapInWallet.legacySwapInAddress val image = BitmapHelper.generateBitmap(legacySwapInAddress).asImageBitmap() @@ -145,7 +143,7 @@ class ReceiveViewModel( override fun create(modelClass: Class, extras: CreationExtras): T { val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication) @Suppress("UNCHECKED_CAST") - return ReceiveViewModel(chain, peerManager, walletManager, application.internalDataRepository, application.applicationContext) as T + return ReceiveViewModel(chain, peerManager, walletManager, application.internalDataRepository, application.userPrefs) as T } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt index c010f2c92..de5b4beb0 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt @@ -74,7 +74,7 @@ fun SendSpliceOutView( header = { BackButtonWithBalance(onBackClick = onBackClick, balance = balance) }, topContent = { AmountHeroInput( - initialAmount = amount?.toMilliSatoshi(), + initialAmount = requestedAmount?.toMilliSatoshi(), onAmountChange = { amountErrorMessage = "" val newAmount = it?.amount?.truncateToSatoshi() diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt index b82ed5dc0..47edd66da 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt @@ -33,7 +33,6 @@ import fr.acinq.phoenix.android.PhoenixApplication import fr.acinq.phoenix.android.security.EncryptedSeed import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.utils.SystemNotificationHelper -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.data.StartupParams import fr.acinq.phoenix.data.WatchTowerOutcome import fr.acinq.phoenix.legacy.utils.LegacyAppStatus @@ -59,6 +58,7 @@ class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : Corout val application = applicationContext as PhoenixApplication val internalData = application.internalDataRepository + val userPrefs = application.userPrefs val legacyAppStatus = LegacyPrefsDatastore.getLegacyAppStatus(applicationContext).filterNotNull().first() if (legacyAppStatus !is LegacyAppStatus.NotRequired) { log.info("aborting channels-watcher service in state=${legacyAppStatus.name()}") @@ -74,9 +74,9 @@ class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : Corout val seed = business.walletManager.mnemonicsToSeed(EncryptedSeed.toMnemonics(encryptedSeed.decrypt()), wordList = MnemonicLanguage.English.wordlist()) business.walletManager.loadWallet(seed) - val isTorEnabled = UserPrefs.getIsTorEnabled(applicationContext).first() - val liquidityPolicy = UserPrefs.getLiquidityPolicy(applicationContext).first() - val electrumServer = UserPrefs.getElectrumServer(applicationContext).first() + val isTorEnabled = userPrefs.getIsTorEnabled.first() + val liquidityPolicy = userPrefs.getLiquidityPolicy.first() + val electrumServer = userPrefs.getElectrumServer.first() business.appConfigurationManager.updateElectrumConfig(electrumServer) business.start(StartupParams(requestCheckLegacyChannels = false, isTorEnabled = isTorEnabled, liquidityPolicy = liquidityPolicy, trustedSwapInTxs = emptySet())) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt index c9cbcde72..39fa252e8 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt @@ -39,7 +39,7 @@ import fr.acinq.phoenix.android.PhoenixApplication import fr.acinq.phoenix.android.security.EncryptedSeed import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.utils.SystemNotificationHelper -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository import fr.acinq.phoenix.data.LocalChannelInfo import fr.acinq.phoenix.data.StartupParams import fr.acinq.phoenix.data.inFlightPaymentsCount @@ -88,7 +88,9 @@ class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) try { - val internalData = (applicationContext as PhoenixApplication).internalDataRepository + val application = (applicationContext as PhoenixApplication) + val internalData = application.internalDataRepository + val userPrefs = application.userPrefs val inFlightPaymentsCount = internalData.getInFlightPaymentsCount.first() if (inFlightPaymentsCount == 0) { @@ -151,7 +153,7 @@ class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) jobChannelsWatcher = launch { val mnemonics = encryptedSeed.decrypt() - business = startBusiness(mnemonics) + business = startBusiness(mnemonics, userPrefs) business?.connectionsManager?.connections?.first { it.global is Connection.ESTABLISHED } log.info("connections established, watching channels for in-flight payments...") @@ -217,14 +219,14 @@ class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) } } - private suspend fun startBusiness(mnemonics: ByteArray): PhoenixBusiness { + private suspend fun startBusiness(mnemonics: ByteArray, userPrefs: UserPrefsRepository): PhoenixBusiness { // retrieve preferences before starting business val business = PhoenixBusiness(PlatformContext(applicationContext)) - val electrumServer = UserPrefs.getElectrumServer(applicationContext).first() - val isTorEnabled = UserPrefs.getIsTorEnabled(applicationContext).first() - val liquidityPolicy = UserPrefs.getLiquidityPolicy(applicationContext).first() + val electrumServer = userPrefs.getElectrumServer.first() + val isTorEnabled = userPrefs.getIsTorEnabled.first() + val liquidityPolicy = userPrefs.getLiquidityPolicy.first() val trustedSwapInTxs = LegacyPrefsDatastore.getMigrationTrustedSwapInTxs(applicationContext).first() - val preferredFiatCurrency = UserPrefs.getFiatCurrency(applicationContext).first() + val preferredFiatCurrency = userPrefs.getFiatCurrency.first() // preparing business val seed = business.walletManager.mnemonicsToSeed(EncryptedSeed.toMnemonics(mnemonics), wordList = MnemonicLanguage.English.wordlist()) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt index 50b422088..c99dad40c 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt @@ -32,7 +32,7 @@ import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.utils.LegacyMigrationHelper 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.data.StartupParams import fr.acinq.phoenix.data.inFlightPaymentsCount import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore @@ -247,14 +247,16 @@ class NodeService : Service() { } // retrieve preferences before starting business - val business = (applicationContext as? PhoenixApplication)?.business?.first() ?: throw RuntimeException("invalid context type, should be PhoenixApplication") - val electrumServer = UserPrefs.getElectrumServer(applicationContext).first() - val isTorEnabled = UserPrefs.getIsTorEnabled(applicationContext).first() - val liquidityPolicy = UserPrefs.getLiquidityPolicy(applicationContext).first() + val application = (applicationContext as? PhoenixApplication) ?: throw RuntimeException("invalid context type, should be PhoenixApplication") + val userPrefs = application.userPrefs + val business = application.business.filterNotNull().first() + val electrumServer = userPrefs.getElectrumServer.first() + val isTorEnabled = userPrefs.getIsTorEnabled.first() + val liquidityPolicy = userPrefs.getLiquidityPolicy.first() val trustedSwapInTxs = LegacyPrefsDatastore.getMigrationTrustedSwapInTxs(applicationContext).first() - val preferredFiatCurrency = UserPrefs.getFiatCurrency(applicationContext).first() + val preferredFiatCurrency = userPrefs.getFiatCurrency.first() - monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.peerManager, business.currencyManager) } + monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.peerManager, business.currencyManager, userPrefs) } monitorNodeEventsJob = serviceScope.launch { monitorNodeEvents(business.peerManager, business.nodeParamsManager) } monitorFcmTokenJob = serviceScope.launch { monitorFcmToken(business) } monitorInFlightPaymentsJob = serviceScope.launch { monitorInFlightPayments(business.peerManager) } @@ -334,7 +336,7 @@ class NodeService : Service() { } } - private suspend fun monitorPaymentsWhenHeadless(peerManager: PeerManager, currencyManager: CurrencyManager) { + private suspend fun monitorPaymentsWhenHeadless(peerManager: PeerManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) { peerManager.getPeer().eventsFlow.collect { event -> when (event) { is PaymentReceived -> { @@ -342,6 +344,7 @@ class NodeService : Service() { receivedInBackground.add(event.received.amount) SystemNotificationHelper.notifyPaymentsReceived( context = applicationContext, + userPrefs = userPrefs, paymentHash = event.incomingPayment.paymentHash, amount = event.received.amount, rates = currencyManager.ratesFlow.value, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt index 85720b716..c5bf911ca 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.* -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import kotlinx.coroutines.launch @@ -40,7 +40,7 @@ fun AppLockView( ) { val context = LocalContext.current val authStatus = BiometricsHelper.authStatus(context) - val isScreenLockActive by UserPrefs.getIsScreenLockActive(context).collectAsState(null) + val isScreenLockActive by userPrefs.getIsScreenLockActive.collectAsState(null) DefaultScreenLayout { DefaultScreenHeader(onBackClick = onBackClick, title = stringResource(id = R.string.accessctrl_title)) @@ -72,6 +72,7 @@ private fun AuthSwitch( isScreenLockActive: Boolean?, ) { val context = LocalContext.current + val userPrefs = userPrefs val activity = context.findActivity() val scope = rememberCoroutineScope() var errorMessage by remember { mutableStateOf("") } @@ -91,7 +92,7 @@ private fun AuthSwitch( if (it) { BiometricsHelper // if user wants to enable screen lock, we don't need to check authentication - UserPrefs.saveIsScreenLockActive(context, true) + userPrefs.saveIsScreenLockActive(true) } else { // if user wants to disable screen lock, we must first check his credentials val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { @@ -101,7 +102,7 @@ private fun AuthSwitch( BiometricsHelper.getPrompt( activity = activity, onSuccess = { - scope.launch { UserPrefs.saveIsScreenLockActive(context, false) } + scope.launch { userPrefs.saveIsScreenLockActive(false) } }, onFailure = { errorCode -> errorMessage = errorCode?.let { BiometricsHelper.getAuthErrorMessage(context, code = it) } ?: "" diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt index 14b00f805..c79b6e38a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt @@ -16,7 +16,6 @@ package fr.acinq.phoenix.android.settings -import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build @@ -32,8 +31,9 @@ import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.UserTheme -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository import fr.acinq.phoenix.android.utils.label import fr.acinq.phoenix.android.utils.labels import fr.acinq.phoenix.data.BitcoinUnit @@ -46,23 +46,23 @@ import java.util.Locale @Composable fun DisplayPrefsView() { val nc = navController - val context = LocalContext.current + val userPrefs = userPrefs val scope = rememberCoroutineScope() DefaultScreenLayout { DefaultScreenHeader(onBackClick = { nc.popBackStack() }, title = stringResource(id = R.string.prefs_display_title)) Card { - BitcoinUnitPreference(context = context, scope = scope) - FiatCurrencyPreference(context = context, scope = scope) - UserThemePreference(context = context, scope = scope) + BitcoinUnitPreference(userPrefs = userPrefs, scope = scope) + FiatCurrencyPreference(userPrefs = userPrefs, scope = scope) + UserThemePreference(userPrefs = userPrefs, scope = scope) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - AppLocaleSetting(context = context) + AppLocaleSetting() } } } } @Composable -private fun BitcoinUnitPreference(context: Context, scope: CoroutineScope) { +private fun BitcoinUnitPreference(userPrefs: UserPrefsRepository, scope: CoroutineScope) { var prefsEnabled by remember { mutableStateOf(true) } val preferences = listOf( PreferenceItem(item = BitcoinUnit.Sat, title = BitcoinUnit.Sat.label(), description = stringResource(id = R.string.prefs_display_coin_sat_desc)), @@ -80,7 +80,7 @@ private fun BitcoinUnitPreference(context: Context, scope: CoroutineScope) { onPreferenceSubmit = { prefsEnabled = false scope.launch { - UserPrefs.saveBitcoinUnit(context, it.item) + userPrefs.saveBitcoinUnit(it.item) prefsEnabled = true } } @@ -88,7 +88,7 @@ private fun BitcoinUnitPreference(context: Context, scope: CoroutineScope) { } @Composable -private fun FiatCurrencyPreference(context: Context, scope: CoroutineScope) { +private fun FiatCurrencyPreference(userPrefs: UserPrefsRepository, scope: CoroutineScope) { var prefEnabled by remember { mutableStateOf(true) } val preferences = FiatCurrency.values.map { @@ -108,7 +108,7 @@ private fun FiatCurrencyPreference(context: Context, scope: CoroutineScope) { onPreferenceSubmit = { prefEnabled = false scope.launch { - UserPrefs.saveFiatCurrency(context, it.item) + userPrefs.saveFiatCurrency(it.item) appConfigurationManager.updatePreferredFiatCurrencies(AppConfigurationManager.PreferredFiatCurrencies(primary = it.item, others = emptySet())) prefEnabled = true } @@ -117,12 +117,12 @@ private fun FiatCurrencyPreference(context: Context, scope: CoroutineScope) { } @Composable -private fun UserThemePreference(context: Context, scope: CoroutineScope) { +private fun UserThemePreference(userPrefs: UserPrefsRepository, scope: CoroutineScope) { var prefEnabled by remember { mutableStateOf(true) } val preferences = UserTheme.values().map { PreferenceItem(it, title = it.label()) } - val currentPref by UserPrefs.getUserTheme(context).collectAsState(initial = UserTheme.SYSTEM) + val currentPref by userPrefs.getUserTheme.collectAsState(initial = UserTheme.SYSTEM) ListPreferenceButton( title = stringResource(id = R.string.prefs_display_theme_label), subtitle = { Text(text = currentPref.label()) }, @@ -132,7 +132,7 @@ private fun UserThemePreference(context: Context, scope: CoroutineScope) { onPreferenceSubmit = { prefEnabled = false scope.launch { - UserPrefs.saveUserTheme(context, it.item) + userPrefs.saveUserTheme(it.item) prefEnabled = true } } @@ -141,7 +141,8 @@ private fun UserThemePreference(context: Context, scope: CoroutineScope) { @RequiresApi(Build.VERSION_CODES.TIRAMISU) @Composable -private fun AppLocaleSetting(context: Context) { +private fun AppLocaleSetting() { + val context = LocalContext.current SettingInteractive( title = stringResource(id = R.string.prefs_locale_label), description = Locale.getDefault().displayLanguage.replaceFirstChar { it.uppercase() }, // context.getSystemService(LocaleManager::class.java).applicationLocales.get(0).language, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt index f79abef63..cd1b5237e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt @@ -43,7 +43,6 @@ import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.mvi.MVIView import fr.acinq.phoenix.android.utils.* -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.controllers.config.ElectrumConfiguration import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.secp256k1.Hex @@ -57,9 +56,9 @@ import java.text.NumberFormat @Composable fun ElectrumView() { val nc = navController - val context = LocalContext.current val scope = rememberCoroutineScope() - val electrumServerInPrefs by UserPrefs.getElectrumServer(context).collectAsState(initial = null) + val userPrefs = userPrefs + val electrumServerInPrefs by userPrefs.getElectrumServer.collectAsState(initial = null) var showCustomServerDialog by rememberSaveable { mutableStateOf(false) } DefaultScreenLayout { @@ -78,7 +77,7 @@ fun ElectrumView() { initialAddress = electrumServerInPrefs, onConfirm = { address -> scope.launch { - UserPrefs.saveElectrumServer(context, address) + userPrefs.saveElectrumServer(address) postIntent(ElectrumConfiguration.Intent.UpdateElectrumServer(address)) showCustomServerDialog = false } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt index 071cb2905..ee7b80484 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt @@ -49,7 +49,6 @@ import fr.acinq.phoenix.android.services.ChannelsWatcher import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.Converter.toRelativeDateString -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.android.utils.safeLet import fr.acinq.phoenix.data.Notification import fr.acinq.phoenix.data.WatchTowerOutcome @@ -119,6 +118,7 @@ private fun PermamentNotice( ) { val context = LocalContext.current val internalData = application.internalDataRepository + val userPrefs = application.userPrefs val nc = LocalNavController.current val scope = rememberCoroutineScope() @@ -173,7 +173,7 @@ private fun PermamentNotice( confirmStateChange = { if (it == DismissValue.DismissedToEnd || it == DismissValue.DismissedToStart) { scope.launch { - UserPrefs.saveShowNotificationPermissionReminder(context, false) + userPrefs.saveShowNotificationPermissionReminder(false) } } true diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt index 81abed974..24b4aa738 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt @@ -41,8 +41,8 @@ import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.datastore.SwapAddressFormat -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.data.lnurl.LnurlAuth import kotlinx.coroutines.launch import java.text.NumberFormat @@ -53,15 +53,14 @@ fun PaymentSettingsView( ) { val nc = navController val scope = rememberCoroutineScope() - val context = LocalContext.current - val btcUnit = LocalBitcoinUnit.current + val userPrefs = userPrefs var showDescriptionDialog by rememberSaveable { mutableStateOf(false) } var showExpiryDialog by rememberSaveable { mutableStateOf(false) } - val invoiceDefaultDesc by UserPrefs.getInvoiceDefaultDesc(LocalContext.current).collectAsState(initial = "") - val invoiceDefaultExpiry by UserPrefs.getInvoiceDefaultExpiry(LocalContext.current).collectAsState(null) - val swapAddressFormatState = UserPrefs.getSwapAddressFormat(context).collectAsState(initial = null) + val invoiceDefaultDesc by userPrefs.getInvoiceDefaultDesc.collectAsState(initial = "") + val invoiceDefaultExpiry by userPrefs.getInvoiceDefaultExpiry.collectAsState(null) + val swapAddressFormatState = userPrefs.getSwapAddressFormat.collectAsState(initial = null) DefaultScreenLayout { DefaultScreenHeader( @@ -108,21 +107,34 @@ fun PaymentSettingsView( when (swapAddressFormat) { SwapAddressFormat.LEGACY -> Text(text = stringResource(id = R.string.paymentsettings_swap_format_legacy_title)) SwapAddressFormat.TAPROOT_ROTATE -> Text(text = stringResource(id = R.string.paymentsettings_swap_format_taproot_title)) - else -> Text(text = stringResource(id = R.string.utils_unknown)) } }, enabled = true, selectedItem = swapAddressFormat, preferences = schemes, onPreferenceSubmit = { - scope.launch { UserPrefs.saveSwapAddressFormat(context, it.item) } + scope.launch { userPrefs.saveSwapAddressFormat(it.item) } }, initialShowDialog = false ) } } - val prefLnurlAuthSchemeState = UserPrefs.getLnurlAuthScheme(context).collectAsState(initial = null) + val isOverpaymentEnabled by userPrefs.getIsOverpaymentEnabled.collectAsState(initial = false) + CardHeader(text = stringResource(id = R.string.paymentsettings_category_outgoing)) + Card { + SettingSwitch( + title = stringResource(id = R.string.paymentsettings_overpayment_title), + description = stringResource(id = if (isOverpaymentEnabled) R.string.paymentsettings_overpayment_enabled else R.string.paymentsettings_overpayment_disabled), + enabled = true, + isChecked = isOverpaymentEnabled, + onCheckChangeAttempt = { + scope.launch { userPrefs.saveIsOverpaymentEnabled(it) } + } + ) + } + + val prefLnurlAuthSchemeState = userPrefs.getLnurlAuthScheme.collectAsState(initial = null) val prefLnurlAuthScheme = prefLnurlAuthSchemeState.value if (prefLnurlAuthScheme != null) { CardHeader(text = stringResource(id = R.string.paymentsettings_category_lnurl)) @@ -152,7 +164,7 @@ fun PaymentSettingsView( selectedItem = prefLnurlAuthScheme, preferences = schemes, onPreferenceSubmit = { - scope.launch { UserPrefs.saveLnurlAuthScheme(context, it.item) } + scope.launch { userPrefs.saveLnurlAuthScheme(it.item) } }, initialShowDialog = initialShowLnurlAuthSchemeDialog ) @@ -165,7 +177,7 @@ fun PaymentSettingsView( description = invoiceDefaultDesc, onDismiss = { showDescriptionDialog = false }, onConfirm = { - scope.launch { UserPrefs.saveInvoiceDefaultDesc(context, it) } + scope.launch { userPrefs.saveInvoiceDefaultDesc(it) } showDescriptionDialog = false } ) @@ -177,7 +189,7 @@ fun PaymentSettingsView( expiry = it, onDismiss = { showExpiryDialog = false }, onConfirm = { - scope.launch { UserPrefs.saveInvoiceDefaultExpiry(context, it.toLong()) } + scope.launch { userPrefs.saveInvoiceDefaultExpiry(it.toLong()) } showExpiryDialog = false } ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/TorConfigView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/TorConfigView.kt index c50de7d1a..c8d25f2e2 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/TorConfigView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/TorConfigView.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import fr.acinq.lightning.utils.Connection @@ -33,7 +32,7 @@ import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.navController -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.negativeColor import fr.acinq.phoenix.android.utils.orange import fr.acinq.phoenix.android.utils.positiveColor @@ -41,11 +40,11 @@ import kotlinx.coroutines.launch @Composable fun TorConfigView() { - val context = LocalContext.current val scope = rememberCoroutineScope() val business = business val nc = navController - val torEnabledState = UserPrefs.getIsTorEnabled(context).collectAsState(initial = null) + val userPrefs = userPrefs + val torEnabledState = userPrefs.getIsTorEnabled.collectAsState(initial = null) val connState = business.connectionsManager.connections.collectAsState() DefaultScreenLayout { @@ -69,7 +68,7 @@ fun TorConfigView() { onCheckChangeAttempt = { scope.launch { business.appConfigurationManager.updateTorUsage(it) - UserPrefs.saveIsTorEnabled(context, it) + userPrefs.saveIsTorEnabled(it) } } ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt index 79982e1ca..52c15c336 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt @@ -34,10 +34,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business @@ -51,9 +49,7 @@ import fr.acinq.phoenix.android.components.ProgressView import fr.acinq.phoenix.android.components.SettingSwitch import fr.acinq.phoenix.android.components.feedback.WarningMessage import fr.acinq.phoenix.android.components.enableOrFade -import fr.acinq.phoenix.android.utils.Converter.toPrettyString -import fr.acinq.phoenix.android.utils.datastore.UserPrefs -import fr.acinq.phoenix.data.BitcoinUnit +import fr.acinq.phoenix.android.userPrefs import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -61,14 +57,14 @@ import kotlin.math.roundToInt fun AdvancedIncomingFeePolicy( onBackClick: () -> Unit ) { - val context = LocalContext.current val scope = rememberCoroutineScope() + val userPrefs = userPrefs val peerManager = business.peerManager val notificationsManager = business.notificationsManager - val maxSatFeePrefsFlow = UserPrefs.getIncomingMaxSatFeeInternal(context).collectAsState(null) - val maxPropFeePrefsFlow = UserPrefs.getIncomingMaxPropFeeInternal(context).collectAsState(null) - val liquidityPolicyInPrefsFlow = UserPrefs.getLiquidityPolicy(context).collectAsState(null) + val maxSatFeePrefsFlow = userPrefs.getIncomingMaxSatFeeInternal.collectAsState(null) + val maxPropFeePrefsFlow = userPrefs.getIncomingMaxPropFeeInternal.collectAsState(null) + val liquidityPolicyInPrefsFlow = userPrefs.getLiquidityPolicy.collectAsState(null) DefaultScreenLayout { DefaultScreenHeader( @@ -128,7 +124,7 @@ fun AdvancedIncomingFeePolicy( onClick = { scope.launch { newPolicy?.let { - UserPrefs.saveLiquidityPolicy(context, newPolicy) + userPrefs.saveLiquidityPolicy(newPolicy) peerManager.updatePeerLiquidityPolicy(newPolicy) notificationsManager.dismissAllNotifications() } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt index 64571b2af..cf0f638d9 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt @@ -29,7 +29,6 @@ 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.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -43,16 +42,15 @@ import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.fiatRate +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.annotatedStringResource -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.android.utils.negativeColor import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.MempoolFeerate import fr.acinq.phoenix.data.canRequestLiquidity import kotlinx.coroutines.launch -@OptIn(ExperimentalComposeUiApi::class) @Composable fun LiquidityPolicyView( onBackClick: () -> Unit, @@ -61,10 +59,11 @@ fun LiquidityPolicyView( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val userPrefs = userPrefs - val maxSatFeePrefsFlow = UserPrefs.getIncomingMaxSatFeeInternal(context).collectAsState(null) - val maxPropFeePrefsFlow = UserPrefs.getIncomingMaxPropFeeInternal(context).collectAsState(null) - val liquidityPolicyPrefsFlow = UserPrefs.getLiquidityPolicy(context).collectAsState(null) + val maxSatFeePrefsFlow = userPrefs.getIncomingMaxSatFeeInternal.collectAsState(null) + val maxPropFeePrefsFlow = userPrefs.getIncomingMaxPropFeeInternal.collectAsState(null) + val liquidityPolicyPrefsFlow = userPrefs.getLiquidityPolicy.collectAsState(null) val peerManager = business.peerManager val notificationsManager = business.notificationsManager @@ -147,7 +146,7 @@ fun LiquidityPolicyView( keyboardManager?.hide() scope.launch { if (newPolicy != null) { - UserPrefs.saveLiquidityPolicy(context, newPolicy) + userPrefs.saveLiquidityPolicy(newPolicy) peerManager.updatePeerLiquidityPolicy(newPolicy) notificationsManager.dismissAllNotifications() } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt index 0fa8f39e3..c3299bb8f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt @@ -36,7 +36,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -58,10 +57,10 @@ import fr.acinq.phoenix.android.components.DefaultScreenLayout import fr.acinq.phoenix.android.components.HSeparator import fr.acinq.phoenix.android.components.IconPopup import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.Converter.toRelativeDateString import fr.acinq.phoenix.android.utils.annotatedStringResource -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.android.utils.negativeColor import fr.acinq.phoenix.data.Notification import fr.acinq.phoenix.utils.extensions.nextTimeout @@ -76,10 +75,9 @@ fun SwapInWallet( onViewChannelPolicyClick: () -> Unit, onAdvancedClick: () -> Unit, ) { - val context = LocalContext.current val btcUnit = LocalBitcoinUnit.current - val liquidityPolicyInPrefs by UserPrefs.getLiquidityPolicy(context).collectAsState(null) + val liquidityPolicyInPrefs by userPrefs.getLiquidityPolicy.collectAsState(null) val swapInWallet by business.peerManager.swapInWallet.collectAsState() var showAdvancedMenuPopIn by remember { mutableStateOf(false) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupView.kt index 2d49a790d..4111587dd 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupView.kt @@ -61,7 +61,6 @@ import fr.acinq.bitcoin.MnemonicCode import fr.acinq.phoenix.android.AppViewModel import fr.acinq.phoenix.android.BuildConfig import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.application import fr.acinq.phoenix.android.components.BorderButton import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.Card @@ -69,12 +68,13 @@ import fr.acinq.phoenix.android.components.FilledButton import fr.acinq.phoenix.android.components.HSeparator import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.internalData import fr.acinq.phoenix.android.security.SeedFileState import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.services.NodeServiceState +import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.BiometricsHelper import fr.acinq.phoenix.android.utils.Logging -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.android.utils.errorOutlinedTextFieldColors import fr.acinq.phoenix.android.utils.findActivity import fr.acinq.phoenix.android.utils.logger @@ -94,8 +94,8 @@ fun StartupView( ) { val context = LocalContext.current val serviceState by appVM.serviceState.observeAsState() - val showIntro by application.internalDataRepository.getShowIntro.collectAsState(initial = null) - val isLockActiveState by UserPrefs.getIsScreenLockActive(context).collectAsState(initial = null) + val showIntro by internalData.getShowIntro.collectAsState(initial = null) + val isLockActiveState by userPrefs.getIsScreenLockActive.collectAsState(initial = null) Column( modifier = Modifier diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Converter.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Converter.kt index 011c466d3..c6a4685ce 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Converter.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Converter.kt @@ -92,9 +92,10 @@ object Converter { BitcoinUnit.Btc -> this.msat.toBigDecimal().movePointLeft(11).toDouble() } - /** Format the double as a String using [DecimalFormat]. */ - fun Double?.toPlainString(): String = this?.takeIf { it > 0 }?.run { - DecimalFormat("0.########").format(this) + /** Format the [Double] as a String using [DecimalFormat]. */ + fun Double?.toPlainString(limitDecimal: Boolean = false): String = this?.takeIf { it > 0 }?.run { + val df = if (limitDecimal) DecimalFormat("0.00") else DecimalFormat("0.########") + df.format(this) } ?: "" /** Converts [MilliSatoshi] to a fiat amount. */ diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt index ac0be1da0..63228d378 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt @@ -39,11 +39,9 @@ import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.TcpSocket import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.* import fr.acinq.phoenix.android.PhoenixApplication import fr.acinq.phoenix.android.utils.datastore.HomeAmountDisplayMode -import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.WalletPaymentId @@ -74,7 +72,10 @@ object LegacyMigrationHelper { ) { log.info("started migrating legacy user preferences") - val (business, internalData) = (context as PhoenixApplication).run { business.filterNotNull().first() to internalDataRepository } + val application = context as PhoenixApplication + val internalData = application.internalDataRepository + val userPrefs = application.userPrefs + val business = application.business.filterNotNull().first() val appConfigurationManager = business.appConfigurationManager // -- utils @@ -88,30 +89,30 @@ object LegacyMigrationHelper { // -- display - UserPrefs.saveUserTheme( - context, when (Prefs.getTheme(context)) { + userPrefs.saveUserTheme( + when (Prefs.getTheme(context)) { ThemeHelper.darkMode -> UserTheme.DARK ThemeHelper.lightMode -> UserTheme.LIGHT else -> UserTheme.SYSTEM } ) - UserPrefs.saveBitcoinUnit( - context, when (Prefs.getCoinUnit(context).code()) { + userPrefs.saveBitcoinUnit( + when (Prefs.getCoinUnit(context).code()) { "sat" -> BitcoinUnit.Sat "bits" -> BitcoinUnit.Bit "mbtc" -> BitcoinUnit.MBtc else -> BitcoinUnit.Btc } ) - UserPrefs.saveHomeAmountDisplayMode(context, if (Prefs.showBalanceHome(context)) HomeAmountDisplayMode.BTC else HomeAmountDisplayMode.REDACTED) - UserPrefs.saveIsAmountInFiat(context, Prefs.getShowAmountInFiat(context)) - UserPrefs.saveFiatCurrency(context, FiatCurrency.valueOfOrNull(Prefs.getFiatCurrency(context)) ?: FiatCurrency.USD) + userPrefs.saveHomeAmountDisplayMode(if (Prefs.showBalanceHome(context)) HomeAmountDisplayMode.BTC else HomeAmountDisplayMode.REDACTED) + userPrefs.saveIsAmountInFiat(Prefs.getShowAmountInFiat(context)) + userPrefs.saveFiatCurrency(FiatCurrency.valueOfOrNull(Prefs.getFiatCurrency(context)) ?: FiatCurrency.USD) // -- security & tor - UserPrefs.saveIsScreenLockActive(context, Prefs.isScreenLocked(context)) + userPrefs.saveIsScreenLockActive(Prefs.isScreenLocked(context)) Prefs.isTorEnabled(context).let { - UserPrefs.saveIsTorEnabled(context, it) + userPrefs.saveIsTorEnabled(it) appConfigurationManager.updateTorUsage(it) } @@ -122,23 +123,23 @@ object LegacyMigrationHelper { // TODO: handle onion addresses and TOR ServerAddress(hostPort.host, hostPort.port, TcpSocket.TLS.TRUSTED_CERTIFICATES()) }?.let { - UserPrefs.saveElectrumServer(context, it) + userPrefs.saveElectrumServer(it) appConfigurationManager.updateElectrumConfig(it) } // -- payment settings - UserPrefs.saveInvoiceDefaultDesc(context, Prefs.getDefaultPaymentDescription(context)) - UserPrefs.saveInvoiceDefaultExpiry(context, Prefs.getPaymentsExpirySeconds(context)) + userPrefs.saveInvoiceDefaultDesc(Prefs.getDefaultPaymentDescription(context)) + userPrefs.saveInvoiceDefaultExpiry(Prefs.getPaymentsExpirySeconds(context)) Prefs.getMaxTrampolineCustomFee(context)?.let { TrampolineFees(feeBase = Satoshi(it.feeBase.toLong()), feeProportional = it.feeProportionalMillionths, cltvExpiryDelta = CltvExpiryDelta(it.cltvExpiry.toInt())) }?.let { - UserPrefs.saveTrampolineMaxFee(context, it) + userPrefs.saveTrampolineMaxFee(it) } // use the default scheme when migrating from legacy, instead of the default one - UserPrefs.saveLnurlAuthScheme(context, LnurlAuth.Scheme.ANDROID_LEGACY_SCHEME) + userPrefs.saveLnurlAuthScheme(LnurlAuth.Scheme.ANDROID_LEGACY_SCHEME) business.appConnectionsDaemon?.forceReconnect(AppConnectionsDaemon.ControlTarget.All) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt index 3822736ca..bed6ef8a3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt @@ -24,9 +24,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.os.Build import android.text.format.DateUtils -import androidx.compose.ui.res.stringResource import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -42,7 +40,7 @@ import fr.acinq.phoenix.android.MainActivity import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateString import fr.acinq.phoenix.android.utils.Converter.toPrettyString -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.FiatCurrency @@ -51,7 +49,6 @@ import kotlinx.coroutines.flow.first import org.slf4j.LoggerFactory import java.text.DecimalFormat import java.util.Random -import kotlin.math.ceil object SystemNotificationHelper { private const val PAYMENT_FAILED_NOTIF_ID = 354319 @@ -247,16 +244,17 @@ object SystemNotificationHelper { suspend fun notifyPaymentsReceived( context: Context, + userPrefs: UserPrefsRepository, paymentHash: ByteVector32, amount: MilliSatoshi, rates: List, isHeadless: Boolean, ): Notification { - val isFiat = UserPrefs.getIsAmountInFiat(context).first() && rates.isNotEmpty() + val isFiat = userPrefs.getIsAmountInFiat.first() && rates.isNotEmpty() val unit = if (isFiat) { - UserPrefs.getFiatCurrency(context).first() + userPrefs.getFiatCurrency.first() } else { - UserPrefs.getBitcoinUnit(context).first() + userPrefs.getBitcoinUnit.first() } val rate = if (isFiat) { when (val rate = rates.find { it.fiatCurrency == unit }) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt index 6d12d9a73..575f6efe0 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle @@ -41,7 +40,7 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController import fr.acinq.phoenix.android.LocalTheme import fr.acinq.phoenix.android.Screen import fr.acinq.phoenix.android.isDarkTheme -import fr.acinq.phoenix.android.utils.datastore.UserPrefs +import fr.acinq.phoenix.android.userPrefs // primary for testnet val horizon = Color(0xff91b4d1) @@ -211,8 +210,7 @@ fun PhoenixAndroidTheme( navController: NavController, content: @Composable () -> Unit ) { - val context = LocalContext.current - val userTheme by UserPrefs.getUserTheme(context).collectAsState(initial = UserTheme.SYSTEM) + val userTheme by userPrefs.getUserTheme.collectAsState(initial = UserTheme.SYSTEM) val systemUiController = rememberSystemUiController() CompositionLocalProvider( diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt similarity index 52% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt index d318fb714..113592063 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt @@ -16,26 +16,21 @@ package fr.acinq.phoenix.android.utils.datastore -import android.content.Context import android.text.format.DateUtils +import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.* import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.CltvExpiryDelta -import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.TrampolineFees import fr.acinq.lightning.io.TcpSocket import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.utils.ServerAddress -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.android.utils.UserTheme import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.lnurl.LnurlAuth import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer -import fr.acinq.phoenix.legacy.userPrefs import fr.acinq.phoenix.managers.NodeParamsManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -45,42 +40,67 @@ import kotlinx.serialization.json.Json import org.slf4j.LoggerFactory import java.io.IOException -object UserPrefs { +class UserPrefsRepository(private val data: DataStore) { + private val log = LoggerFactory.getLogger(this::class.java) private val json = Json { ignoreUnknownKeys = true } // some prefs are json-serialized - private fun prefs(context: Context): Flow { - return context.userPrefs.data.catch { exception -> - if (exception is IOException) { - emit(emptyPreferences()) - } else { - throw exception - } + /** Retrieve preferences from [data], with a fallback to empty prefs if the data file can't be read. */ + private val safeData: Flow = data.data.catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception } } - suspend fun clear(context: Context) = context.userPrefs.edit { it.clear() } - - // -- unit, fiat, conversion... + suspend fun clear() = data.edit { it.clear() } + + private companion object { + // display + private val BITCOIN_UNIT = stringPreferencesKey("BITCOIN_UNIT") + private val FIAT_CURRENCY = stringPreferencesKey("FIAT_CURRENCY") + private val SHOW_AMOUNT_IN_FIAT = booleanPreferencesKey("SHOW_AMOUNT_IN_FIAT") + private val HOME_AMOUNT_DISPLAY_MODE = stringPreferencesKey("HOME_AMOUNT_DISPLAY_MODE") + private val THEME = stringPreferencesKey("THEME") + private val HIDE_BALANCE = booleanPreferencesKey("HIDE_BALANCE") + // electrum + val PREFS_ELECTRUM_ADDRESS_HOST = stringPreferencesKey("PREFS_ELECTRUM_ADDRESS_HOST") + val PREFS_ELECTRUM_ADDRESS_PORT = intPreferencesKey("PREFS_ELECTRUM_ADDRESS_PORT") + val PREFS_ELECTRUM_ADDRESS_PINNED_KEY = stringPreferencesKey("PREFS_ELECTRUM_ADDRESS_PINNED_KEY") + // access control + val PREFS_SCREEN_LOCK = booleanPreferencesKey("PREFS_SCREEN_LOCK") + // payments options + private val INVOICE_DEFAULT_DESC = stringPreferencesKey("INVOICE_DEFAULT_DESC") + private val INVOICE_DEFAULT_EXPIRY = longPreferencesKey("INVOICE_DEFAULT_EXPIRY") + private val TRAMPOLINE_MAX_BASE_FEE = longPreferencesKey("TRAMPOLINE_MAX_BASE_FEE") + private val TRAMPOLINE_MAX_PROPORTIONAL_FEE = longPreferencesKey("TRAMPOLINE_MAX_PROPORTIONAL_FEE") + private val SWAP_ADDRESS_FORMAT = intPreferencesKey("SWAP_ADDRESS_FORMAT") + private val LNURL_AUTH_SCHEME = intPreferencesKey("LNURL_AUTH_SCHEME") + private val IS_OVERPAYMENT_ENABLED = booleanPreferencesKey("IS_OVERPAYMENT_ENABLED") + // liquidity policy & channels management + private val LIQUIDITY_POLICY = stringPreferencesKey("LIQUIDITY_POLICY") + private val INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER = longPreferencesKey("INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER") + private val INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER = intPreferencesKey("INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER") + // tor + private val IS_TOR_ENABLED = booleanPreferencesKey("IS_TOR_ENABLED") + // misc + private val SHOW_NOTIFICATION_PERMISSION_REMINDER = booleanPreferencesKey("SHOW_NOTIFICATION_PERMISSION_REMINDER") + } - private val BITCOIN_UNIT = stringPreferencesKey("BITCOIN_UNIT") - fun getBitcoinUnit(context: Context): Flow = prefs(context).map { it[BITCOIN_UNIT]?.let { BitcoinUnit.valueOfOrNull(it) } ?: BitcoinUnit.Sat } - suspend fun saveBitcoinUnit(context: Context, coinUnit: BitcoinUnit) = context.userPrefs.edit { it[BITCOIN_UNIT] = coinUnit.name } + val getBitcoinUnit: Flow = safeData.map { it[BITCOIN_UNIT]?.let { BitcoinUnit.valueOfOrNull(it) } ?: BitcoinUnit.Sat } + suspend fun saveBitcoinUnit(coinUnit: BitcoinUnit) = data.edit { it[BITCOIN_UNIT] = coinUnit.name } - private val FIAT_CURRENCY = stringPreferencesKey("FIAT_CURRENCY") - fun getFiatCurrency(context: Context): Flow = prefs(context).map { it[FIAT_CURRENCY]?.let { FiatCurrency.valueOfOrNull(it) } ?: FiatCurrency.USD } - suspend fun saveFiatCurrency(context: Context, currency: FiatCurrency) = context.userPrefs.edit { it[FIAT_CURRENCY] = currency.name } + val getFiatCurrency: Flow = safeData.map { it[FIAT_CURRENCY]?.let { FiatCurrency.valueOfOrNull(it) } ?: FiatCurrency.USD } + suspend fun saveFiatCurrency(currency: FiatCurrency) = data.edit { it[FIAT_CURRENCY] = currency.name } - private val SHOW_AMOUNT_IN_FIAT = booleanPreferencesKey("SHOW_AMOUNT_IN_FIAT") - fun getIsAmountInFiat(context: Context): Flow = prefs(context).map { it[SHOW_AMOUNT_IN_FIAT] ?: false } - suspend fun saveIsAmountInFiat(context: Context, inFiat: Boolean) = context.userPrefs.edit { it[SHOW_AMOUNT_IN_FIAT] = inFiat } + val getIsAmountInFiat: Flow = safeData.map { it[SHOW_AMOUNT_IN_FIAT] ?: false } + suspend fun saveIsAmountInFiat(inFiat: Boolean) = data.edit { it[SHOW_AMOUNT_IN_FIAT] = inFiat } - private val HOME_AMOUNT_DISPLAY_MODE = stringPreferencesKey("HOME_AMOUNT_DISPLAY_MODE") - fun getHomeAmountDisplayMode(context: Context): Flow = prefs(context).map { + val getHomeAmountDisplayMode: Flow = safeData.map { HomeAmountDisplayMode.safeValueOf(it[HOME_AMOUNT_DISPLAY_MODE]) } - - suspend fun saveHomeAmountDisplayMode(context: Context, displayMode: HomeAmountDisplayMode) = context.userPrefs.edit { + suspend fun saveHomeAmountDisplayMode(displayMode: HomeAmountDisplayMode) = data.edit { it[HOME_AMOUNT_DISPLAY_MODE] = displayMode.name when (displayMode) { HomeAmountDisplayMode.FIAT -> it[SHOW_AMOUNT_IN_FIAT] = true @@ -89,21 +109,13 @@ object UserPrefs { } } - private val THEME = stringPreferencesKey("THEME") - fun getUserTheme(context: Context): Flow = prefs(context).map { UserTheme.safeValueOf(it[THEME]) } - suspend fun saveUserTheme(context: Context, theme: UserTheme) = context.userPrefs.edit { it[THEME] = theme.name } - - private val HIDE_BALANCE = booleanPreferencesKey("HIDE_BALANCE") - fun getHideBalance(context: Context): Flow = prefs(context).map { it[HIDE_BALANCE] ?: false } - suspend fun saveHideBalance(context: Context, hideBalance: Boolean) = context.userPrefs.edit { it[HIDE_BALANCE] = hideBalance } - - // -- electrum + val getUserTheme: Flow = safeData.map { UserTheme.safeValueOf(it[THEME]) } + suspend fun saveUserTheme(theme: UserTheme) = data.edit { it[THEME] = theme.name } - val PREFS_ELECTRUM_ADDRESS_HOST = stringPreferencesKey("PREFS_ELECTRUM_ADDRESS_HOST") - val PREFS_ELECTRUM_ADDRESS_PORT = intPreferencesKey("PREFS_ELECTRUM_ADDRESS_PORT") - val PREFS_ELECTRUM_ADDRESS_PINNED_KEY = stringPreferencesKey("PREFS_ELECTRUM_ADDRESS_PINNED_KEY") + val getHideBalance: Flow = safeData.map { it[HIDE_BALANCE] ?: false } + suspend fun saveHideBalance(hideBalance: Boolean) = data.edit { it[HIDE_BALANCE] = hideBalance } - fun getElectrumServer(context: Context): Flow = prefs(context).map { + val getElectrumServer: Flow = safeData.map { val host = it[PREFS_ELECTRUM_ADDRESS_HOST]?.takeIf { it.isNotBlank() } val port = it[PREFS_ELECTRUM_ADDRESS_PORT] val pinnedKey = it[PREFS_ELECTRUM_ADDRESS_PINNED_KEY]?.takeIf { it.isNotBlank() } @@ -117,7 +129,7 @@ object UserPrefs { } } - suspend fun saveElectrumServer(context: Context, address: ServerAddress?) = context.userPrefs.edit { + suspend fun saveElectrumServer(address: ServerAddress?) = data.edit { if (address == null) { it.remove(PREFS_ELECTRUM_ADDRESS_HOST) it.remove(PREFS_ELECTRUM_ADDRESS_PORT) @@ -136,23 +148,16 @@ object UserPrefs { // -- security - val PREFS_SCREEN_LOCK = booleanPreferencesKey("PREFS_SCREEN_LOCK") - fun getIsScreenLockActive(context: Context): Flow = prefs(context).map { it[PREFS_SCREEN_LOCK] ?: false } - suspend fun saveIsScreenLockActive(context: Context, isScreenLockActive: Boolean) = context.userPrefs.edit { it[PREFS_SCREEN_LOCK] = isScreenLockActive } + val getIsScreenLockActive: Flow = safeData.map { it[PREFS_SCREEN_LOCK] ?: false } + suspend fun saveIsScreenLockActive(isScreenLockActive: Boolean) = data.edit { it[PREFS_SCREEN_LOCK] = isScreenLockActive } - // -- payments options + val getInvoiceDefaultDesc: Flow = safeData.map { it[INVOICE_DEFAULT_DESC]?.takeIf { it.isNotBlank() } ?: "" } + suspend fun saveInvoiceDefaultDesc(description: String) = data.edit { it[INVOICE_DEFAULT_DESC] = description } - private val INVOICE_DEFAULT_DESC = stringPreferencesKey("INVOICE_DEFAULT_DESC") - fun getInvoiceDefaultDesc(context: Context): Flow = prefs(context).map { it[INVOICE_DEFAULT_DESC]?.takeIf { it.isNotBlank() } ?: "" } - suspend fun saveInvoiceDefaultDesc(context: Context, description: String) = context.userPrefs.edit { it[INVOICE_DEFAULT_DESC] = description } + val getInvoiceDefaultExpiry: Flow = safeData.map { it[INVOICE_DEFAULT_EXPIRY] ?: (DateUtils.WEEK_IN_MILLIS / 1000) } + suspend fun saveInvoiceDefaultExpiry(expirySeconds: Long) = data.edit { it[INVOICE_DEFAULT_EXPIRY] = expirySeconds } - private val INVOICE_DEFAULT_EXPIRY = longPreferencesKey("INVOICE_DEFAULT_EXPIRY") - fun getInvoiceDefaultExpiry(context: Context): Flow = prefs(context).map { it[INVOICE_DEFAULT_EXPIRY] ?: (DateUtils.WEEK_IN_MILLIS / 1000) } - suspend fun saveInvoiceDefaultExpiry(context: Context, expirySeconds: Long) = context.userPrefs.edit { it[INVOICE_DEFAULT_EXPIRY] = expirySeconds } - - private val TRAMPOLINE_MAX_BASE_FEE = longPreferencesKey("TRAMPOLINE_MAX_BASE_FEE") - private val TRAMPOLINE_MAX_PROPORTIONAL_FEE = longPreferencesKey("TRAMPOLINE_MAX_PROPORTIONAL_FEE") - fun getTrampolineMaxFee(context: Context): Flow = prefs(context).map { + val getTrampolineMaxFee: Flow = safeData.map { val feeBase = it[TRAMPOLINE_MAX_BASE_FEE]?.sat val feeProportional = it[TRAMPOLINE_MAX_PROPORTIONAL_FEE] if (feeBase != null && feeProportional != null) { @@ -160,7 +165,7 @@ object UserPrefs { } else null } - suspend fun saveTrampolineMaxFee(context: Context, fee: TrampolineFees?) = context.userPrefs.edit { + suspend fun saveTrampolineMaxFee(fee: TrampolineFees?) = data.edit { if (fee == null) { it.remove(TRAMPOLINE_MAX_BASE_FEE) it.remove(TRAMPOLINE_MAX_PROPORTIONAL_FEE) @@ -170,19 +175,15 @@ object UserPrefs { } } - private val SWAP_ADDRESS_FORMAT = intPreferencesKey("SWAP_ADDRESS_FORMAT") - fun getSwapAddressFormat(context: Context): Flow = prefs(context).map { + val getSwapAddressFormat: Flow = safeData.map { it[SWAP_ADDRESS_FORMAT]?.let { SwapAddressFormat.getFormatForCode(it) } ?: SwapAddressFormat.TAPROOT_ROTATE } - suspend fun saveSwapAddressFormat(context: Context, format: SwapAddressFormat) = context.userPrefs.edit { + suspend fun saveSwapAddressFormat(format: SwapAddressFormat) = data.edit { log.info("saving swap-address-format=$format") it[SWAP_ADDRESS_FORMAT] = format.code } - // -- liquidity policy - - private val LIQUIDITY_POLICY = stringPreferencesKey("LIQUIDITY_POLICY") - fun getLiquidityPolicy(context: Context): Flow = prefs(context).map { + val getLiquidityPolicy: Flow = safeData.map { try { it[LIQUIDITY_POLICY]?.let { policy -> when (val res = json.decodeFromString(policy)) { @@ -192,12 +193,12 @@ object UserPrefs { } } catch (e: Exception) { log.error("failed to read liquidity-policy preference, replace with default: ${e.localizedMessage}") - saveLiquidityPolicy(context, NodeParamsManager.defaultLiquidityPolicy) + saveLiquidityPolicy(NodeParamsManager.defaultLiquidityPolicy) null } ?: NodeParamsManager.defaultLiquidityPolicy } - suspend fun saveLiquidityPolicy(context: Context, policy: LiquidityPolicy) = context.userPrefs.edit { + suspend fun saveLiquidityPolicy(policy: LiquidityPolicy) = data.edit { log.info("saving new liquidity policy=$policy") val serialisable = when (policy) { is LiquidityPolicy.Auto -> InternalLiquidityPolicy.Auto(policy.maxRelativeFeeBasisPoints, policy.maxAbsoluteFee, policy.skipAbsoluteFeeCheck) @@ -212,21 +213,16 @@ object UserPrefs { } /** This is used to keep track of the user's max fee preferences, even if he's not currently using a relevant liquidity policy. */ - private val INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER = longPreferencesKey("INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER") - fun getIncomingMaxSatFeeInternal(context: Context): Flow = prefs(context).map { + val getIncomingMaxSatFeeInternal: Flow = safeData.map { it[INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER]?.sat ?: NodeParamsManager.defaultLiquidityPolicy.maxAbsoluteFee } /** This is used to keep track of the user's proportional fee preferences, even if he's not currently using a relevant liquidity policy. */ - private val INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER = intPreferencesKey("INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER") - fun getIncomingMaxPropFeeInternal(context: Context): Flow = prefs(context).map { + val getIncomingMaxPropFeeInternal: Flow = safeData.map { it[INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER] ?: NodeParamsManager.defaultLiquidityPolicy.maxRelativeFeeBasisPoints } - // -- lnurl - - private val LNURL_AUTH_SCHEME = intPreferencesKey("LNURL_AUTH_SCHEME") - fun getLnurlAuthScheme(context: Context): Flow = prefs(context).map { + val getLnurlAuthScheme: Flow = safeData.map { when (it[LNURL_AUTH_SCHEME]) { LnurlAuth.Scheme.DEFAULT_SCHEME.id -> LnurlAuth.Scheme.DEFAULT_SCHEME LnurlAuth.Scheme.ANDROID_LEGACY_SCHEME.id -> LnurlAuth.Scheme.ANDROID_LEGACY_SCHEME @@ -234,7 +230,7 @@ object UserPrefs { } } - suspend fun saveLnurlAuthScheme(context: Context, scheme: LnurlAuth.Scheme?) = context.userPrefs.edit { + suspend fun saveLnurlAuthScheme(scheme: LnurlAuth.Scheme?) = data.edit { if (scheme == null) { it.remove(LNURL_AUTH_SCHEME) } else { @@ -242,21 +238,21 @@ object UserPrefs { } } - private val IS_TOR_ENABLED = booleanPreferencesKey("IS_TOR_ENABLED") - fun getIsTorEnabled(context: Context): Flow = prefs(context).map { it[IS_TOR_ENABLED] ?: false } - suspend fun saveIsTorEnabled(context: Context, isEnabled: Boolean) = context.userPrefs.edit { it[IS_TOR_ENABLED] = isEnabled } + val getIsOverpaymentEnabled: Flow = safeData.map { it[IS_OVERPAYMENT_ENABLED] ?: false } + suspend fun saveIsOverpaymentEnabled(enabled: Boolean) = data.edit { it[IS_OVERPAYMENT_ENABLED] = enabled } - private val SHOW_NOTIFICATION_PERMISSION_REMINDER = booleanPreferencesKey("SHOW_NOTIFICATION_PERMISSION_REMINDER") - fun getShowNotificationPermissionReminder(context: Context): Flow = prefs(context).map { it[SHOW_NOTIFICATION_PERMISSION_REMINDER] ?: true } - suspend fun saveShowNotificationPermissionReminder(context: Context, show: Boolean) = context.userPrefs.edit { it[SHOW_NOTIFICATION_PERMISSION_REMINDER] = show } + val getIsTorEnabled: Flow = safeData.map { it[IS_TOR_ENABLED] ?: false } + suspend fun saveIsTorEnabled(isEnabled: Boolean) = data.edit { it[IS_TOR_ENABLED] = isEnabled } + val getShowNotificationPermissionReminder: Flow = safeData.map { it[SHOW_NOTIFICATION_PERMISSION_REMINDER] ?: true } + suspend fun saveShowNotificationPermissionReminder(show: Boolean) = data.edit { it[SHOW_NOTIFICATION_PERMISSION_REMINDER] = show } } /** Our own format for [LiquidityPolicy], serializable and decoupled from lightning-kmp. */ @Serializable sealed class InternalLiquidityPolicy { @Serializable - object Disable : InternalLiquidityPolicy() + data object Disable : InternalLiquidityPolicy() @Serializable data class Auto( diff --git a/phoenix-android/src/main/res/drawable/ic_minus_circle.xml b/phoenix-android/src/main/res/drawable/ic_minus_circle.xml new file mode 100644 index 000000000..667f72fa7 --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_minus_circle.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/phoenix-android/src/main/res/drawable/ic_plus_circle.xml b/phoenix-android/src/main/res/drawable/ic_plus_circle.xml new file mode 100644 index 000000000..c5896a186 --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_plus_circle.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/phoenix-android/src/main/res/drawable/ic_revert.xml b/phoenix-android/src/main/res/drawable/ic_revert.xml new file mode 100644 index 000000000..2d4f36b5a --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_revert.xml @@ -0,0 +1,20 @@ + + + + diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 5268a6b11..e8e77d5ea 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -613,6 +613,7 @@ Payment options Incoming payments + Outgoing payments LNURL Invoice description @@ -637,6 +638,10 @@ Taproot (recommended) Default format, with better privacy, cheaper fees and address rotation. Some services or wallets may however not understand the address. + Enable overpayment + You\'ll be able to overpay Lightning invoices up to 2 times the amount requested. Useful for manual tipping, or as a privacy measure. + Disabled (default) + Argentine Peso (official rate) diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index f58381125..7eac61b51 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -9767,6 +9767,9 @@ } } } + }, + "Enable overpayment" : { + }, "End date" : { "localizations" : { @@ -17051,6 +17054,9 @@ } } } + }, + "Outgoing payments" : { + }, "Output" : { "comment" : "Label in SummaryInfoGrid", @@ -28418,6 +28424,9 @@ } } } + }, + "You'll be able to overpay Lightning invoices up to 2 times the amount requested. Useful for manual tipping, or as a privacy measure." : { + }, "You've already paid this invoice. Paying it again could result in stolen funds." : { "comment" : "Error message - scanning lightning invoice", diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs.swift b/phoenix-ios/phoenix-ios/prefs/Prefs.swift index 4c1fa56a7..378d8e81a 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs.swift @@ -23,6 +23,7 @@ fileprivate enum Key: String { case swapInAddressIndex case hasUpgradedSeedCloudBackups case serverMessageReadIndex + case allowOverpayment } fileprivate enum KeyDeprecated: String { @@ -158,6 +159,17 @@ class Prefs { } } + lazy private(set) var allowOverpaymentPublisher: AnyPublisher = { + defaults.publisher(for: \.allowOverpayment, options: [.initial, .new]) + .removeDuplicates() + .eraseToAnyPublisher() + }() + + var allowOverpayment: Bool { + get { defaults.allowOverpayment } + set { defaults.allowOverpayment = newValue } + } + // -------------------------------------------------- // MARK: Wallet State // -------------------------------------------------- @@ -243,6 +255,7 @@ class Prefs { defaults.removeObject(forKey: Key.swapInAddressIndex.rawValue) defaults.removeObject(forKey: Key.hasUpgradedSeedCloudBackups.rawValue) defaults.removeObject(forKey: Key.serverMessageReadIndex.rawValue) + defaults.removeObject(forKey: Key.allowOverpayment.rawValue) self.backupTransactions.resetWallet(encryptedNodeId: encryptedNodeId) self.backupSeed.resetWallet(encryptedNodeId: encryptedNodeId) @@ -343,7 +356,7 @@ extension UserDefaults { set { set(newValue, forKey: Key.swapInAddressIndex.rawValue) } } - @objc fileprivate var hasUpgradedSeedCloudBackups: Bool { + @objc fileprivate var hasUpgradedSeedCloudBackups: Bool { get { bool(forKey: Key.hasUpgradedSeedCloudBackups.rawValue) } set { set(newValue, forKey: Key.hasUpgradedSeedCloudBackups.rawValue) } } @@ -352,4 +365,9 @@ extension UserDefaults { get { object(forKey: Key.serverMessageReadIndex.rawValue) as? NSNumber } set { set(newValue, forKey: Key.serverMessageReadIndex.rawValue) } } + + @objc fileprivate var allowOverpayment: Bool { + get { bool(forKey: Key.allowOverpayment.rawValue) } + set { set(newValue, forKey: Key.allowOverpayment.rawValue) } + } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift index bdf0c1752..b239148c6 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift @@ -33,6 +33,8 @@ fileprivate struct PaymentOptionsList: View { @State var invoiceExpirationDays: Int = Prefs.shared.invoiceExpirationDays let invoiceExpirationDaysOptions = [7, 30, 60] + @State var allowOverpayment: Bool = Prefs.shared.allowOverpayment + @State var notificationSettings = NotificationsManager.shared.settings.value @State var firstAppearance = true @@ -41,6 +43,7 @@ fileprivate struct PaymentOptionsList: View { @State private var swiftUiBugWorkaroundIdx = 0 @Namespace var sectionID_incomingPayments + @Namespace var sectionID_outgoingPayments @Namespace var sectionID_backgroundPayments @Environment(\.openURL) var openURL @@ -65,6 +68,7 @@ fileprivate struct PaymentOptionsList: View { List { section_incomingPayments() + section_outgoingPayments() section_backgroundPayments() } .listStyle(.insetGrouped) @@ -97,6 +101,37 @@ fileprivate struct PaymentOptionsList: View { .id(sectionID_incomingPayments) } + @ViewBuilder + func section_outgoingPayments() -> some View { + + Section { + + Toggle(isOn: $allowOverpayment) { + Text("Enable overpayment") + } + .onChange(of: allowOverpayment) { newValue in + Prefs.shared.allowOverpayment = newValue + } + + Text( + """ + You'll be able to overpay Lightning invoices up to 2 times the amount requested. \ + Useful for manual tipping, or as a privacy measure. + """ + ) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) // SwiftUI truncation bugs + .foregroundColor(Color.secondary) + .padding(.top, 8) + .padding(.bottom, 4) + + } /* Section.*/header: { + Text("Outgoing payments") + + } // + .id(sectionID_outgoingPayments) + } + @ViewBuilder func subsection_defaultPaymentDescription() -> some View { diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index a80b67d0a..a06318144 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -54,6 +54,8 @@ struct ValidateView: View { @State var satsPerByte: String = "" @State var parsedSatsPerByte: Result = Result.failure(.emptyInput) + @State var allowOverpayment = Prefs.shared.allowOverpayment + @State var mempoolRecommendedResponse: MempoolRecommendedResponse? = nil @State var comment: String = "" @@ -163,6 +165,9 @@ struct ValidateView: View { .onChange(of: currencyPickerChoice) { _ in currencyPickerDidChange() } + .onReceive(Prefs.shared.allowOverpaymentPublisher) { + allowOverpayment = $0 + } .onReceive(balancePublisher) { balanceDidChange($0) } @@ -237,7 +242,7 @@ struct ValidateView: View { .disableAutocorrection(true) .fixedSize() .font(.title) - .disabled(paymentRequest()?.amount != nil) + .disabled(isInvoiceWithAmount() && !allowOverpayment) .multilineTextAlignment(.trailing) .minimumScaleFactor(0.95) // SwiftUI bugs: truncating text in RTL .foregroundColor(isInvalidAmount() ? Color.appNegative : Color.primaryForeground) @@ -743,7 +748,7 @@ struct ValidateView: View { return model.invoice } else { // Note: there's technically a `paymentRequest` within `Scan.Model_SwapOutFlow_Ready`. - // But this method is designed to only pull from `Scan.Model_InvoiceFlow_InvoiceRequest`. + // But this method is designed to only pull from `Scan.Model_Bolt11InvoiceFlow_InvoiceRequest`. return nil } } @@ -785,6 +790,14 @@ struct ValidateView: View { } } + /// Returns true if this is a Bolt 11 invoice with an (exact) amount. + /// When this is the case, we may disable manual editing of the amount field. + /// + func isInvoiceWithAmount() -> Bool { + + return paymentRequest()?.amount != nil + } + func priceRange() -> MsatRange? { if let paymentRequest = paymentRequest() {