diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 606471955..5d226cad4 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -80,6 +80,9 @@ class SettingsStore @Inject constructor( val isPinOnIdleEnabled: Flow = store.data.map { it[IS_PIN_ON_IDLE_ENABLED] == true } suspend fun setIsPinOnIdleEnabled(value: Boolean) { store.edit { it[IS_PIN_ON_IDLE_ENABLED] = value } } + val isPinForPaymentsEnabled: Flow = store.data.map { it[IS_PIN_FOR_PAYMENTS_ENABLED] == true } + suspend fun setIsPinForPaymentsEnabled(value: Boolean) { store.edit { it[IS_PIN_FOR_PAYMENTS_ENABLED] = value } } + suspend fun wipe() { store.edit { it.clear() } Logger.info("Deleted all user settings data.") @@ -97,5 +100,6 @@ class SettingsStore @Inject constructor( private val IS_PIN_ON_LAUNCH_ENABLED = booleanPreferencesKey("is_pin_on_launch_enabled") private val IS_BIOMETRIC_ENABLED = booleanPreferencesKey("is_biometric_enabled") private val IS_PIN_ON_IDLE_ENABLED = booleanPreferencesKey("is_pin_on_idle_enabled") + private val IS_PIN_FOR_PAYMENTS_ENABLED = booleanPreferencesKey("is_pin_for_payments_enabled") } } diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt index eda0731c8..480b92540 100644 --- a/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt @@ -17,6 +17,7 @@ fun AuthCheckScreen( val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle() val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle() val isPinOnIdleEnabled by app.isPinOnIdleEnabled.collectAsStateWithLifecycle() + val isPinForPaymentsEnabled by app.isPinForPaymentsEnabled.collectAsStateWithLifecycle() AuthCheckView( showLogoOnPin = route.showLogoOnPin, @@ -37,6 +38,10 @@ fun AuthCheckScreen( app.setIsPinOnIdleEnabled(!isPinOnIdleEnabled) } + AuthCheckAction.TOGGLE_PIN_FOR_PAYMENTS -> { + app.setIsPinForPaymentsEnabled(!isPinForPaymentsEnabled) + } + AuthCheckAction.DISABLE_PIN -> { app.removePin() } @@ -52,5 +57,6 @@ object AuthCheckAction { const val TOGGLE_PIN_ON_LAUNCH = "TOGGLE_PIN_ON_LAUNCH" const val TOGGLE_BIOMETRICS = "TOGGLE_BIOMETRICS" const val TOGGLE_PIN_ON_IDLE = "TOGGLE_PIN_ON_IDLE" + const val TOGGLE_PIN_FOR_PAYMENTS = "TOGGLE_PIN_FOR_PAYMENTS" const val DISABLE_PIN = "DISABLE_PIN" } diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt index a736b6fef..c1b834154 100644 --- a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt @@ -163,7 +163,8 @@ private fun PinPad( ) } else { BodyS( - text = stringResource(R.string.security__pin_attempts).replace("{attemptsRemaining}", "$attemptsRemaining"), + text = stringResource(R.string.security__pin_attempts) + .replace("{attemptsRemaining}", "$attemptsRemaining"), color = Colors.Brand, textAlign = TextAlign.Center, modifier = Modifier.clickableAlpha { onClickForgotPin() } @@ -175,7 +176,8 @@ private fun PinPad( if (allowBiometrics) { val biometricsName = stringResource(R.string.security__bio) PrimaryButton( - text = stringResource(R.string.security__pin_use_biometrics).replace("{biometricsName}", biometricsName), + text = stringResource(R.string.security__pin_use_biometrics) + .replace("{biometricsName}", biometricsName), onClick = onShowBiometrics, fullWidth = false, size = ButtonSize.Small, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/PinCheckScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/PinCheckScreen.kt new file mode 100644 index 000000000..d7dfd2dfd --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/PinCheckScreen.kt @@ -0,0 +1,187 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.PinNumberPad +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +const val PIN_CHECK_RESULT_KEY = "PIN_CHECK_RESULT_KEY" + +@Composable +fun PinCheckScreen( + onBack: () -> Unit, + onSuccess: () -> Unit, +) { + val app = appViewModel ?: return + val attemptsRemaining by app.pinAttemptsRemaining.collectAsStateWithLifecycle() + var pin by remember { mutableStateOf("") } + + LaunchedEffect(pin) { + if (pin.length == Env.PIN_LENGTH) { + if (app.validatePin(pin)) { + onSuccess() + } + pin = "" + } + } + + PinCheckContent( + pin = pin, + attemptsRemaining = attemptsRemaining, + onKeyPress = { key -> + if (key == KEY_DELETE) { + if (pin.isNotEmpty()) { + pin = pin.dropLast(1) + } + } else if (pin.length < Env.PIN_LENGTH) { + pin += key + } + }, + onBack = onBack, + onClickForgotPin = { app.setShowForgotPin(true) }, + ) +} + +@Composable +private fun PinCheckContent( + pin: String, + attemptsRemaining: Int, + onKeyPress: (String) -> Unit, + onBack: () -> Unit, + onClickForgotPin: () -> Unit, +) { + val isLastAttempt = attemptsRemaining == 1 + + Column( + modifier = Modifier + .fillMaxWidth() + .gradientBackground() + .navigationBarsPadding() + ) { + SheetTopBar( + titleText = stringResource(R.string.security__pin_send_title), + onBack = onBack, + ) + Spacer(Modifier.height(32.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BodyM( + text = stringResource(R.string.security__pin_send), + color = Colors.White64, + modifier = Modifier.padding(horizontal = 32.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + AnimatedVisibility(visible = attemptsRemaining < Env.PIN_ATTEMPTS) { + if (isLastAttempt) { + BodyS( + text = stringResource(R.string.security__pin_last_attempt), + color = Colors.Brand, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp) + ) + } else { + BodyS( + text = stringResource(R.string.security__pin_attempts) + .replace("{attemptsRemaining}", "$attemptsRemaining"), + color = Colors.Brand, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 32.dp) + .clickableAlpha { onClickForgotPin() } + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + PinDots( + pin = pin, + modifier = Modifier.padding(vertical = 16.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + PinNumberPad( + onPress = onKeyPress, + modifier = Modifier + .height(350.dp) + .background(Colors.Black) + ) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + PinCheckContent( + pin = "123", + attemptsRemaining = 8, + onKeyPress = {}, + onBack = {}, + onClickForgotPin = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewAttempts() { + AppThemeSurface { + PinCheckContent( + pin = "123", + attemptsRemaining = 3, + onKeyPress = {}, + onBack = {}, + onClickForgotPin = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewAttemptsLast() { + AppThemeSurface { + PinCheckContent( + pin = "123", + attemptsRemaining = 1, + onKeyPress = {}, + onBack = {}, + onClickForgotPin = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAndReviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAndReviewScreen.kt index 1ba4af4a9..40a5a407d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAndReviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAndReviewScreen.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.screens.wallets.send import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -17,10 +18,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,13 +31,18 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.DatePattern import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatted +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up @@ -44,6 +52,7 @@ import to.bitkit.ui.components.TagButton import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberBiometricAuthSupported import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState @@ -54,86 +63,156 @@ import java.time.Instant @OptIn(ExperimentalLayoutApi::class) @Composable fun SendAndReviewScreen( + savedStateHandle: SavedStateHandle, uiState: SendUiState, onBack: () -> Unit, onEvent: (SendEvent) -> Unit, onClickAddTag: () -> Unit, onClickTag: (String) -> Unit, + onNavigateToPin: () -> Unit, ) { - Column( - modifier = Modifier.fillMaxSize() - ) { - val scope = rememberCoroutineScope() - // TODO handle loading via uiState? - var isLoading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + // TODO handle loading via uiState? + var isLoading by rememberSaveable { mutableStateOf(false) } + var showBiometrics by remember { mutableStateOf(false) } - SheetTopBar(stringResource(R.string.title_send_review)) { - onBack() - } - - Spacer(Modifier.height(16.dp)) + val app = appViewModel ?: return + val isPinEnabled by app.isPinEnabled.collectAsStateWithLifecycle() + val pinForPayments by app.isPinForPaymentsEnabled.collectAsStateWithLifecycle() + val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle() + val isBiometrySupported = rememberBiometricAuthSupported() - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - ) { - BalanceHeaderView(sats = uiState.amount.toLong(), modifier = Modifier.fillMaxWidth()) + LaunchedEffect(savedStateHandle) { + savedStateHandle.getStateFlow(PIN_CHECK_RESULT_KEY, null) + .filterNotNull() + .collect { + isLoading = it + savedStateHandle.remove(PIN_CHECK_RESULT_KEY) + } + } - Spacer(modifier = Modifier.height(16.dp)) + SendAndReviewContent( + uiState = uiState, + isLoading = isLoading, + showBiometrics = showBiometrics, + onBack = onBack, + onEvent = onEvent, + onClickAddTag = onClickAddTag, + onClickTag = onClickTag, + onSwipeToConfirm = { + scope.launch { + isLoading = true + delay(300) + if (isPinEnabled && pinForPayments) { + if (isBiometricEnabled && isBiometrySupported) { + showBiometrics = true + } else { + onNavigateToPin() + } + } else { + onEvent(SendEvent.SwipeToPay) + } + } + }, + onBiometricsSuccess = { + isLoading = true + showBiometrics = false + onEvent(SendEvent.SwipeToPay) + }, + onBiometricsFailure = { + isLoading = false + showBiometrics = false + onNavigateToPin() + }, + ) +} - when (uiState.payMethod) { - SendMethod.ONCHAIN -> OnChainDescription(uiState = uiState, onEvent = onEvent) - SendMethod.LIGHTNING -> LightningDescription(uiState = uiState, onEvent = onEvent) +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SendAndReviewContent( + uiState: SendUiState, + isLoading: Boolean, + showBiometrics: Boolean, + onBack: () -> Unit, + onEvent: (SendEvent) -> Unit, + onClickAddTag: () -> Unit, + onClickTag: (String) -> Unit, + onSwipeToConfirm: () -> Unit, + onBiometricsSuccess: () -> Unit, + onBiometricsFailure: () -> Unit, +) { + Box { + Column(modifier = Modifier.fillMaxSize()) { + SheetTopBar(stringResource(R.string.title_send_review)) { + onBack() } - Spacer(modifier = Modifier.height(16.dp)) - Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) - Spacer(modifier = Modifier.height(8.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + Spacer(Modifier.height(16.dp)) + + Column( modifier = Modifier + .padding(horizontal = 16.dp) .fillMaxWidth() - .padding(bottom = 16.dp) ) { - uiState.selectedTags.map { tagText -> - TagButton( - text = tagText, - isSelected = false, - displayIconClose = true, - onClick = { onClickTag(tagText) }, - ) + BalanceHeaderView(sats = uiState.amount.toLong(), modifier = Modifier.fillMaxWidth()) + + Spacer(modifier = Modifier.height(16.dp)) + + when (uiState.payMethod) { + SendMethod.ONCHAIN -> OnChainDescription(uiState = uiState, onEvent = onEvent) + SendMethod.LIGHTNING -> LightningDescription(uiState = uiState) } - } - PrimaryButton( - text = stringResource(R.string.wallet__tags_add), - size = ButtonSize.Small, - onClick = { onClickAddTag() }, - icon = { - Icon( - painter = painterResource(R.drawable.ic_tag), - contentDescription = null, - tint = Colors.Brand - ) - }, - fullWidth = false - ) - HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) - Spacer(modifier = Modifier.weight(1f)) - SwipeToConfirm( - text = stringResource(R.string.wallet__send_swipe), - loading = isLoading, - onConfirm = { - scope.launch { - isLoading = true - delay(300) - onEvent(SendEvent.SwipeToPay) + Spacer(modifier = Modifier.height(16.dp)) + Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + uiState.selectedTags.map { tagText -> + TagButton( + text = tagText, + isSelected = false, + displayIconClose = true, + onClick = { onClickTag(tagText) }, + ) } } + PrimaryButton( + text = stringResource(R.string.wallet__tags_add), + size = ButtonSize.Small, + onClick = onClickAddTag, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + tint = Colors.Brand + ) + }, + fullWidth = false + ) + HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + + Spacer(modifier = Modifier.weight(1f)) + SwipeToConfirm( + text = stringResource(R.string.wallet__send_swipe), + loading = isLoading, + confirmed = isLoading, + onConfirm = onSwipeToConfirm, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + + if (showBiometrics) { + BiometricsView( + onSuccess = onBiometricsSuccess, + onFailure = onBiometricsFailure, ) - Spacer(modifier = Modifier.height(24.dp)) } } } @@ -219,7 +298,6 @@ private fun OnChainDescription( @Composable private fun LightningDescription( uiState: SendUiState, - onEvent: (SendEvent) -> Unit, ) { Column(modifier = Modifier.fillMaxWidth()) { Caption13Up( @@ -305,9 +383,9 @@ private fun LightningDescription( @Suppress("SpellCheckingInspection") @Preview(name = "Lightning") @Composable -private fun SendAndReviewPreview() { +private fun Preview() { AppThemeSurface { - SendAndReviewScreen( + SendAndReviewContent( uiState = SendUiState( amount = 1234uL, address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", @@ -323,12 +401,17 @@ private fun SendAndReviewPreview() { networkType = NetworkType.REGTEST, payeeNodeId = null, description = "Some invoice description", - ) + ), ), + isLoading = false, + showBiometrics = false, onBack = {}, onEvent = {}, onClickAddTag = {}, onClickTag = {}, + onSwipeToConfirm = {}, + onBiometricsSuccess = {}, + onBiometricsFailure = {}, ) } } @@ -336,9 +419,9 @@ private fun SendAndReviewPreview() { @Suppress("SpellCheckingInspection") @Preview(name = "OnChain") @Composable -private fun SendAndReviewPreview2() { +private fun PreviewOnChain() { AppThemeSurface { - SendAndReviewScreen( + SendAndReviewContent( uiState = SendUiState( amount = 1234uL, address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", @@ -355,12 +438,54 @@ private fun SendAndReviewPreview2() { networkType = NetworkType.REGTEST, payeeNodeId = null, description = "Some invoice description", - ) + ), + ), + isLoading = false, + showBiometrics = false, + onBack = {}, + onEvent = {}, + onClickAddTag = {}, + onClickTag = {}, + onSwipeToConfirm = {}, + onBiometricsSuccess = {}, + onBiometricsFailure = {}, + ) + } +} + +@Suppress("SpellCheckingInspection") +@Preview +@Composable +private fun PreviewBio() { + AppThemeSurface { + SendAndReviewContent( + uiState = SendUiState( + amount = 1234uL, + address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", + bolt11 = "lnbcrt1…", + payMethod = SendMethod.ONCHAIN, + selectedTags = listOf("car", "house", "uber"), + decodedInvoice = LightningInvoice( + bolt11 = "bcrt123", + paymentHash = ByteArray(0), + amountSatoshis = 10000uL, + timestampSeconds = 0uL, + expirySeconds = 3600uL, + isExpired = false, + networkType = NetworkType.REGTEST, + payeeNodeId = null, + description = "Some invoice description", + ), ), + isLoading = false, + showBiometrics = true, onBack = {}, onEvent = {}, onClickAddTag = {}, onClickTag = {}, + onSwipeToConfirm = {}, + onBiometricsSuccess = {}, + onBiometricsFailure = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt index a59650eb5..73645c04e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -33,6 +32,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.RectangleButton +import to.bitkit.ui.composableWithDefaultTransitions import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.shared.util.gradientBackground @@ -73,12 +73,12 @@ fun SendOptionsView( navController = navController, startDestination = startDestination, ) { - composable { + composableWithDefaultTransitions { SendOptionsContent( onEvent = { appViewModel.setSendEvent(it) } ) } - composable { + composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendAddressScreen( uiState = uiState, @@ -86,7 +86,7 @@ fun SendOptionsView( onEvent = { appViewModel.setSendEvent(it) }, ) } - composable { + composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val walletUiState by walletViewModel.uiState.collectAsStateWithLifecycle() SendAmountScreen( @@ -96,23 +96,25 @@ fun SendOptionsView( onEvent = { appViewModel.setSendEvent(it) } ) } - composable { + composableWithDefaultTransitions { QrScanningScreen(navController = navController) { qrCode -> navController.popBackStack() appViewModel.onScanSuccess(data = qrCode) } } - composable { + composableWithDefaultTransitions { backStackEntry -> val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendAndReviewScreen( + savedStateHandle = backStackEntry.savedStateHandle, uiState = uiState, onBack = { navController.popBackStack() }, onEvent = { appViewModel.setSendEvent(it) }, onClickAddTag = { navController.navigate(SendRoute.AddTag) }, - onClickTag = { tag -> appViewModel.removeTag(tag) } + onClickTag = { tag -> appViewModel.removeTag(tag) }, + onNavigateToPin = { navController.navigate(SendRoute.PinCheck) } ) } - composable { + composableWithDefaultTransitions { AddTagScreen( onBack = { navController.popBackStack() }, onTagSelected = { tag -> @@ -121,6 +123,18 @@ fun SendOptionsView( }, ) } + composableWithDefaultTransitions { + PinCheckScreen( + onBack = { navController.popBackStack() }, + onSuccess = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(PIN_CHECK_RESULT_KEY, true) + navController.popBackStack() + appViewModel.setSendEvent(SendEvent.SwipeToPay) + }, + ) + } } } } @@ -215,7 +229,6 @@ private fun SendOptionsContent( } } -// region preview @Preview(showBackground = true) @Composable private fun SendOptionsContentPreview() { @@ -225,7 +238,6 @@ private fun SendOptionsContentPreview() { ) } } -// endregion interface SendRoute { @Serializable @@ -245,4 +257,7 @@ interface SendRoute { @Serializable data object AddTag : SendRoute + + @Serializable + data object PinCheck : SendRoute } diff --git a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt index 7e01f6a6d..ab6106c2b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt @@ -44,6 +44,7 @@ fun SecuritySettingsScreen( val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle() val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle() val isPinOnIdleEnabled by app.isPinOnIdleEnabled.collectAsStateWithLifecycle() + val isPinForPaymentsEnabled by app.isPinForPaymentsEnabled.collectAsStateWithLifecycle() PinNavigationSheet( showSheet = showPinSheet, @@ -55,6 +56,7 @@ fun SecuritySettingsScreen( isPinOnLaunchEnabled = isPinOnLaunchEnabled, isBiometricEnabled = isBiometricEnabled, isPinOnIdleEnabled = isPinOnIdleEnabled, + isPinForPaymentsEnabled = isPinForPaymentsEnabled, isBiometrySupported = rememberBiometricAuthSupported(), onPinClick = { if (!isPinEnabled) { @@ -76,6 +78,11 @@ fun SecuritySettingsScreen( onSuccessActionId = AuthCheckAction.TOGGLE_PIN_ON_IDLE, ) }, + onPinForPaymentsClick = { + navController.navigateToAuthCheck( + onSuccessActionId = AuthCheckAction.TOGGLE_PIN_FOR_PAYMENTS, + ) + }, onUseBiometricsClick = { navController.navigateToAuthCheck( requireBiometrics = true, @@ -94,11 +101,13 @@ private fun SecuritySettingsContent( isPinOnLaunchEnabled: Boolean, isBiometricEnabled: Boolean, isPinOnIdleEnabled: Boolean, + isPinForPaymentsEnabled: Boolean, isBiometrySupported: Boolean, onPinClick: () -> Unit = {}, onChangePinClick: () -> Unit = {}, onPinOnLaunchClick: () -> Unit = {}, onPinOnIdleClick: () -> Unit = {}, + onPinForPaymentsClick: () -> Unit = {}, onUseBiometricsClick: () -> Unit = {}, onBackClick: () -> Unit = {}, onCloseClick: () -> Unit = {}, @@ -136,6 +145,11 @@ private fun SecuritySettingsContent( isChecked = isPinOnIdleEnabled, onClick = onPinOnIdleClick, ) + SettingsSwitchRow( + title = stringResource(R.string.settings__security__pin_payments), + isChecked = isPinForPaymentsEnabled, + onClick = onPinForPaymentsClick, + ) } if (isPinEnabled && isBiometrySupported) { SettingsSwitchRow( @@ -170,6 +184,7 @@ fun Preview() { isPinOnLaunchEnabled = true, isBiometricEnabled = false, isPinOnIdleEnabled = false, + isPinForPaymentsEnabled = false, isBiometrySupported = true, ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinResultScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinResultScreen.kt index 1ecf72ef3..4436f6161 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/PinResultScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinResultScreen.kt @@ -15,15 +15,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Switch 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM @@ -43,18 +41,14 @@ fun PinResultScreen( onBack: () -> Unit, ) { val app = appViewModel ?: return - var pinForPayments by remember { mutableStateOf(false) } + val pinForPayments by app.isPinForPaymentsEnabled.collectAsStateWithLifecycle() BackHandler { onBack() } PinResultContent( bio = isBioOn, pinForPayments = pinForPayments, - onTogglePinForPayments = { - pinForPayments = !pinForPayments - // TODO: set pin for payments in settings store, etc. - app.toast(Exception("Pin for payments is not yet implemented")) - }, + onTogglePinForPayments = { app.setIsPinForPaymentsEnabled(!pinForPayments) }, onContinueClick = onDismiss, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index dad552f69..e83da17ce 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -135,6 +135,15 @@ class AppViewModel @Inject constructor( } } + val isPinForPaymentsEnabled: StateFlow = settingsStore.isPinForPaymentsEnabled + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + fun setIsPinForPaymentsEnabled(value: Boolean) { + viewModelScope.launch { + settingsStore.setIsPinForPaymentsEnabled(value) + } + } + val isBiometricEnabled: StateFlow = settingsStore.isBiometricEnabled .stateIn(viewModelScope, SharingStarted.Eagerly, false) @@ -844,6 +853,7 @@ class AppViewModel @Inject constructor( setIsPinEnabled(false) setIsPinOnLaunchEnabled(true) setIsPinOnIdleEnabled(false) + setIsPinForPaymentsEnabled(false) setIsBiometricEnabled(false) viewModelScope.launch { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 5aed4cd87..1eb3f96a7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -111,7 +111,6 @@ class WalletViewModel @Inject constructor( .filter { _uiState.value.nodeLifecycleState == NodeLifecycleState.Running } .collect { runCatching { sync() } - Logger.verbose("App state synced with ldk-node.") } }