diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9afa33e967..48a99e534f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,10 +86,12 @@ dependencies { implementation(libs.material) implementation(libs.datastore.preferences) implementation(libs.kotlinx.datetime) - implementation("com.google.zxing:core:3.5.1") - // implementation("com.google.mlkit:barcode-scanning:17.3.0") - implementation("com.google.android.gms:play-services-code-scanner:16.1.0") - + implementation("com.google.zxing:core:3.5.2") + implementation("com.google.mlkit:barcode-scanning:17.3.0") + // CameraX + implementation("androidx.camera:camera-camera2:1.4.1") + implementation("androidx.camera:camera-lifecycle:1.4.1") + implementation("androidx.camera:camera-view:1.4.1") // Crypto implementation(libs.bouncycastle.provider.jdk) implementation(libs.ldk.node.android) @@ -116,6 +118,7 @@ dependencies { debugImplementation(libs.compose.ui.test.manifest) androidTestImplementation(libs.compose.ui.test.junit4) implementation("com.google.accompanist:accompanist-pager-indicators:0.36.0") + implementation("com.google.accompanist:accompanist-permissions:0.36.0") // Compose Navigation implementation(libs.navigation.compose) androidTestImplementation(libs.navigation.testing) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31570599a7..d5ecbd5aa3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,14 @@ + + + - - diff --git a/app/src/main/java/to/bitkit/ui/AppViewModel.kt b/app/src/main/java/to/bitkit/ui/AppViewModel.kt index d31a3140ca..469b508660 100644 --- a/app/src/main/java/to/bitkit/ui/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/AppViewModel.kt @@ -1,5 +1,6 @@ package to.bitkit.ui +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -7,23 +8,49 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.PaymentId +import org.lightningdevkit.ldknode.Txid import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Tag.APP import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.Toast +import to.bitkit.services.LightningService +import to.bitkit.services.ScannerService +import to.bitkit.services.hasLightingParam +import to.bitkit.services.lightningParam import to.bitkit.ui.components.BottomSheetType +import to.bitkit.ui.screens.wallets.send.SendRoute import to.bitkit.ui.shared.toast.ToastEventBus +import uniffi.bitkitcore.LightningInvoice +import uniffi.bitkitcore.OnChainInvoice +import uniffi.bitkitcore.Scanner import javax.inject.Inject @HiltViewModel class AppViewModel @Inject constructor( private val keychain: Keychain, + private val scannerService: ScannerService, + private val lightningService: LightningService, ) : ViewModel() { var uiState by mutableStateOf(AppUiState()) private set + private val _sendUiState = MutableStateFlow(SendUiState()) + val sendUiState = _sendUiState.asStateFlow() + + private val _sendEffect = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val sendEffect = _sendEffect.asSharedFlow() + private fun setSendEffect(effect: SendEffect) = viewModelScope.launch { _sendEffect.emit(effect) } + + private val sendEvents = MutableSharedFlow() + fun setSendEvent(event: SendEvent) = viewModelScope.launch { sendEvents.emit(event) } + + private var scan: Scanner? = null + init { viewModelScope.launch { keychain.observeExists(Keychain.Key.BIP39_MNEMONIC).collect { walletExists -> @@ -36,8 +63,337 @@ class AppViewModel @Inject constructor( toast(it.type, it.title, it.description, it.autoHide, it.visibilityTime) } } + + observeSendEvents() + } + + // region send + private fun observeSendEvents() { + viewModelScope.launch { + sendEvents.collect { + when (it) { + SendEvent.EnterManually -> onEnterManuallyClick() + is SendEvent.Paste -> onPasteInvoice(it.data) + is SendEvent.Scan -> onScanClick() + + is SendEvent.AddressChange -> onAddressChange(it.value) + SendEvent.AddressReset -> resetAddressInput() + is SendEvent.AddressContinue -> onAddressContinue(it.data) + + is SendEvent.AmountChange -> onAmountChange(it.value) + SendEvent.AmountReset -> resetAmountInput() + is SendEvent.AmountContinue -> onAmountContinue(it.amount) + SendEvent.PaymentMethodSwitch -> onPaymentMethodSwitch() + + SendEvent.SpeedAndFee -> toast(Exception("Coming soon: Speed and Fee")) + SendEvent.SwipeToPay -> onPay() + } + } + } + } + + private val isMainScanner get() = currentSheet.value !is BottomSheetType.Send + + private fun onEnterManuallyClick() { + resetAddressInput() + setSendEffect(SendEffect.NavigateToAddress) + } + + private fun resetAddressInput() { + _sendUiState.update { state -> + state.copy( + addressInput = "", + isAddressInputValid = false, + ) + } + } + + private fun onAddressChange(value: String) { + viewModelScope.launch { + val result = runCatching { scannerService.decode(value) } + _sendUiState.update { + it.copy( + addressInput = value, + isAddressInputValid = result.isSuccess, + ) + } + } + } + + private fun onAddressContinue(data: String) { + viewModelScope.launch { + handleScannedData(data) + } + } + + private fun onAmountChange(value: String) { + val isAmountValid = validateAmount(value) + _sendUiState.update { + it.copy( + amountInput = value, + isAmountInputValid = isAmountValid, + ) + } + } + + private fun onPaymentMethodSwitch() { + val nextPaymentMethod = when (_sendUiState.value.payMethod) { + SendMethod.ONCHAIN -> SendMethod.LIGHTNING + SendMethod.LIGHTNING -> SendMethod.ONCHAIN + } + _sendUiState.update { + it.copy( + payMethod = nextPaymentMethod, + isAmountInputValid = validateAmount(it.amountInput, nextPaymentMethod), + ) + } + } + + private fun onAmountContinue(amount: String) { + _sendUiState.update { + it.copy( + amount = amount.toULongOrNull() ?: 0u, + ) + } + setSendEffect(SendEffect.NavigateToReview) + } + + private fun validateAmount( + value: String, + payMethod: SendMethod = _sendUiState.value.payMethod, + ): Boolean { + if (value.isBlank()) return false + val amount = value.toULongOrNull() ?: return false + return when (payMethod) { + SendMethod.ONCHAIN -> amount > getMinOnchainTx() + else -> amount > 0u + } + } + + private fun onPasteInvoice(data: String) { + if (data.isBlank()) { + Log.e(APP, "No data in clipboard") + return + } + viewModelScope.launch { + handleScannedData(data) + } + } + + private fun onScanClick() { + setSendEffect(SendEffect.NavigateToScan) + } + + fun onScanSuccess(data: String, onResultDelay: Long = 0) { + viewModelScope.launch { + delay(onResultDelay) + handleScannedData(data) + } + } + + private suspend fun handleScannedData(uri: String) { + val scan = runCatching { scannerService.decode(uri) } + .onFailure { Log.e(APP, "Failed to decode input data", it) } + .getOrNull() + this.scan = scan + + when (scan) { + is Scanner.OnChain -> { + val invoice: OnChainInvoice = scan.invoice + val lnInvoice: LightningInvoice? = invoice.lightningParam()?.let { bolt11 -> + val decoded = runCatching { scannerService.decode(bolt11) }.getOrNull() + val lightningInvoice = (decoded as? Scanner.Lightning)?.invoice + lightningInvoice?.takeIf { lightningService.canSend(it.amountSatoshis) } + } + _sendUiState.update { + it.copy( + address = invoice.address, + bolt11 = invoice.lightningParam(), + amount = invoice.amountSatoshis, + isUnified = invoice.hasLightingParam(), + decodedInvoice = lnInvoice, + payMethod = lnInvoice?.let { SendMethod.LIGHTNING } ?: SendMethod.ONCHAIN, + ) + } + val isLnInvoiceWithAmount = lnInvoice?.amountSatoshis?.takeIf { it > 0uL } != null + if (isLnInvoiceWithAmount) { + Log.i(APP, "Found amount in invoice, proceeding with payment") + + if (isMainScanner) { + showSheet(BottomSheetType.Send(SendRoute.ReviewAndSend)) + } else { + setSendEffect(SendEffect.NavigateToReview) + } + return + } + Log.i(APP, "No amount found in invoice, proceeding entering amount manually") + resetAmountInput() + + if (isMainScanner) { + showSheet(BottomSheetType.Send(SendRoute.Amount)) + } else { + setSendEffect(SendEffect.NavigateToAmount) + } + } + + is Scanner.Lightning -> { + val invoice: LightningInvoice = scan.invoice + if (invoice.isExpired) { + toast( + type = Toast.ToastType.ERROR, + title = "Invoice Expired", + description = "This invoice has expired." + ) + return + } + if (!lightningService.canSend(invoice.amountSatoshis)) { + toast( + type = Toast.ToastType.ERROR, + title = "Insufficient Funds", + description = "You do not have enough funds to send this payment." + ) + return + } + + _sendUiState.update { + it.copy( + amount = invoice.amountSatoshis, + bolt11 = uri, + description = invoice.description.orEmpty(), + decodedInvoice = invoice, + payMethod = SendMethod.LIGHTNING, + ) + } + if (invoice.amountSatoshis > 0uL) { + Log.i(APP, "Found amount in invoice, proceeding with payment") + + if (isMainScanner) { + showSheet(BottomSheetType.Send(SendRoute.ReviewAndSend)) + } else { + setSendEffect(SendEffect.NavigateToReview) + } + } else { + Log.i(APP, "No amount found in invoice, proceeding entering amount manually") + resetAmountInput() + + if (isMainScanner) { + showSheet(BottomSheetType.Send(SendRoute.Amount)) + } else { + setSendEffect(SendEffect.NavigateToAmount) + } + } + } + + null -> { + toast( + type = Toast.ToastType.ERROR, + title = "Error", + description = "Error decoding data" + ) + } + + else -> { + Log.w(APP, "Unhandled invoice type: $scan") + toast( + type = Toast.ToastType.ERROR, + title = "Unsupported", + description = "This type of invoice is not supported yet" + ) + } + } } + private fun resetAmountInput() { + _sendUiState.update { state -> + state.copy( + amountInput = state.amount.toString(), + isAmountInputValid = validateAmount(state.amount.toString()), + ) + } + } + + private fun onPay() { + viewModelScope.launch { + val amount = _sendUiState.value.amount + when (_sendUiState.value.payMethod) { + SendMethod.ONCHAIN -> { + val address = _sendUiState.value.address + val validatedAddress = runCatching { scannerService.validateBitcoinAddress(address) } + .getOrNull() + ?: return@launch // TODO show error + val result = sendOnchain(validatedAddress.address, amount) + if (result.isSuccess) { + val txId = result.getOrNull() + Log.i(APP, "Onchain send result txid: $txId") + setSendEffect( + SendEffect.PaymentSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + sats = amount.toLong(), + ) + ) + ) + resetSendState() + } else { + // TODO error UI + Log.e(APP, "Error sending onchain payment", result.exceptionOrNull()) + } + } + + SendMethod.LIGHTNING -> { + val bolt11 = _sendUiState.value.bolt11 ?: return@launch // TODO show error + // Determine if we should override amount + val decodedInvoice = _sendUiState.value.decodedInvoice + val invoiceAmount = decodedInvoice?.amountSatoshis?.takeIf { it > 0uL } ?: amount + val paymentAmount = if (decodedInvoice?.amountSatoshis != null) invoiceAmount else null + val result = sendLightning(bolt11, paymentAmount) + if (result.isSuccess) { + val paymentHash = result.getOrNull() + Log.i(APP, "Lightning send result payment hash: $paymentHash") + setSendEffect(SendEffect.PaymentSuccess()) + resetSendState() + } else { + // TODO error UI + Log.e(APP, "Error sending lightning payment", result.exceptionOrNull()) + } + } + } + } + } + + private suspend fun sendOnchain(address: String, amount: ULong): Result { + return runCatching { lightningService.send(address = address, amount) } + .onFailure { + toast( + type = Toast.ToastType.ERROR, + title = "Error Sending", + description = it.message ?: "Unknown error" + ) + } + } + + private suspend fun sendLightning(bolt11: String, amount: ULong? = null): Result { + return runCatching { lightningService.send(bolt11 = bolt11, amount) } + .onFailure { + toast( + type = Toast.ToastType.ERROR, + title = "Error Sending", + description = it.message ?: "Unknown error" + ) + } + } + + private fun getMinOnchainTx(): ULong { + // TODO implement min tx size + return 600uL + } + + fun resetSendState() { + _sendUiState.value = SendUiState() + } + // endregion + // region TxSheet var showNewTransaction by mutableStateOf(false) private set @@ -116,3 +472,47 @@ class AppViewModel @Inject constructor( data class AppUiState( val walletExists: Boolean? = null, ) + +// region send contract +data class SendUiState( + val address: String = "", + val bolt11: String? = null, + val addressInput: String = "", + val isAddressInputValid: Boolean = false, + val amount: ULong = 0u, + val amountInput: String = "", + val isAmountInputValid: Boolean = false, + val description: String = "", + val isUnified: Boolean = false, + val payMethod: SendMethod = SendMethod.ONCHAIN, + val decodedInvoice: LightningInvoice? = null, +) + +enum class SendMethod { ONCHAIN, LIGHTNING } + +sealed class SendEffect { + data object NavigateToAddress : SendEffect() + data object NavigateToAmount : SendEffect() + data object NavigateToScan : SendEffect() + data object NavigateToReview : SendEffect() + data class PaymentSuccess(val sheet: NewTransactionSheetDetails? = null) : SendEffect() +} + +sealed class SendEvent { + data object EnterManually : SendEvent() + data class Paste(val data: String) : SendEvent() + data object Scan : SendEvent() + + data object AddressReset : SendEvent() + data class AddressChange(val value: String) : SendEvent() + data class AddressContinue(val data: String) : SendEvent() + + data object AmountReset : SendEvent() + data class AmountContinue(val amount: String) : SendEvent() + data class AmountChange(val value: String) : SendEvent() + + data object SwipeToPay : SendEvent() + data object SpeedAndFee : SendEvent() + data object PaymentMethodSwitch : SendEvent() +} +// endregion diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f6090c6471..cf3eacc033 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1,40 +1,27 @@ package to.bitkit.ui -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.runtime.* import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.flowWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.NavOptionsBuilder +import androidx.navigation.* import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import androidx.navigation.toRoute import kotlinx.coroutines.flow.filter import kotlinx.serialization.Serializable import to.bitkit.ui.onboarding.InitializingWalletView import to.bitkit.ui.screens.DevSettingsScreen +import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.screens.transfer.TransferScreen import to.bitkit.ui.screens.transfer.TransferViewModel import to.bitkit.ui.screens.wallets.HomeScreen import to.bitkit.ui.screens.wallets.activity.ActivityItemScreen import to.bitkit.ui.screens.wallets.activity.AllActivityScreen -import to.bitkit.ui.settings.BackupSettingsScreen -import to.bitkit.ui.settings.BlocktankRegtestScreen -import to.bitkit.ui.settings.BlocktankRegtestViewModel -import to.bitkit.ui.settings.DefaultUnitSettingsScreen -import to.bitkit.ui.settings.GeneralSettingsScreen -import to.bitkit.ui.settings.LightningSettingsScreen -import to.bitkit.ui.settings.LocalCurrencySettingsScreen -import to.bitkit.ui.settings.SettingsScreen +import to.bitkit.ui.settings.* import to.bitkit.ui.settings.backups.BackupWalletScreen import to.bitkit.ui.settings.backups.RestoreWalletScreen import to.bitkit.viewmodels.BlocktankViewModel @@ -97,6 +84,7 @@ fun ContentView( transfer(navController) allActivity(walletViewModel, navController) activityItem(walletViewModel, navController) + qrScanner(appViewModel, navController) } } } @@ -109,7 +97,11 @@ private fun NavGraphBuilder.home( navController: NavHostController, ) { composable { - HomeScreen(viewModel, appViewModel, navController) + HomeScreen( + walletViewModel = viewModel, + appViewModel = appViewModel, + rootNavController = navController + ) } } @@ -240,6 +232,34 @@ private fun NavGraphBuilder.activityItem( ) } } + +private fun NavGraphBuilder.qrScanner( + appViewModel: AppViewModel, + navController: NavHostController, +) { + composable( + enterTransition = { + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(durationMillis = 300) + ) + }, + exitTransition = { + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(durationMillis = 300) + ) + }, + ) { + QrScanningScreen(navController = navController) { qrCode -> + navController.popBackStack() + appViewModel.onScanSuccess( + data = qrCode, + onResultDelay = 650 // slight delay to for home navigation before showing send sheet + ) + } + } +} // endregion // region events @@ -298,6 +318,10 @@ fun NavController.navigateToAllActivity() = navigate( fun NavController.navigateToActivityItem(id: String) = navigate( route = Routes.ActivityItem(id), ) + +fun NavController.navigateToQrScanner() = navigate( + route = Routes.QrScanner, +) // endregion private fun NavOptionsBuilder.clearBackStack() = popUpTo(id = 0) @@ -347,4 +371,7 @@ object Routes { @Serializable data class ActivityItem(val id: String) + + @Serializable + data object QrScanner } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 9c71a6a5d2..57468eb43d 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -22,10 +22,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import to.bitkit.ui.AppViewModel +import to.bitkit.ui.screens.wallets.send.SendRoute import to.bitkit.ui.theme.AppShapes sealed class BottomSheetType { - data object Send : BottomSheetType() + data class Send(val route: SendRoute = SendRoute.Options) : BottomSheetType() data object Receive : BottomSheetType() } diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionDeniedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionDeniedScreen.kt new file mode 100644 index 0000000000..e63891173e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionDeniedScreen.kt @@ -0,0 +1,39 @@ +package to.bitkit.ui.screens.scanner + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun CameraPermissionDeniedScreen( + requestPermission: () -> Unit, + shouldShowRationale: Boolean, +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val textToShow = if (shouldShowRationale) { + // user has denied the permission but the rationale can be shown + "The camera is required for scanning. Please grant the permission." + } else { + // first time the user sees this, or the user doesn't want to be asked again for this permission + "Camera permission is required for scanning to be available. Please grant the permission." + } + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = textToShow, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = requestPermission) { + Text("Request permission") + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionRequiredView.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionRequiredView.kt new file mode 100644 index 0000000000..1c3d8dcfc6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionRequiredView.kt @@ -0,0 +1,27 @@ +package to.bitkit.ui.screens.scanner + +import androidx.compose.animation.AnimatedContent +import androidx.compose.runtime.Composable +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraPermissionRequiredView( + deniedContent: @Composable (PermissionStatus.Denied) -> Unit, + grantedContent: @Composable () -> Unit, +) { + val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + + val status = cameraPermissionState.status + AnimatedContent( + targetState = status, + label = "cameraPermissionAnim", + ) { permissionStatus -> + when(permissionStatus) { + is PermissionStatus.Granted -> grantedContent() + is PermissionStatus.Denied -> deniedContent(permissionStatus) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt new file mode 100644 index 0000000000..3060a442dc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt @@ -0,0 +1,58 @@ +package to.bitkit.ui.screens.scanner + +import android.util.Log +import androidx.annotation.OptIn +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import to.bitkit.env.Tag.APP + +@OptIn(ExperimentalGetImage::class) +class QrCodeAnalyzer( + private val onScanResult: (Result) -> Unit, +) : ImageAnalysis.Analyzer { + private var isScanning = true + + private val scannerOptions = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + private val scanner: BarcodeScanner = BarcodeScanning.getClient(scannerOptions) + + override fun analyze(image: ImageProxy) { + if (!isScanning) { + image.close() + return + } + + if (image.image != null) { + val inputImage = InputImage.fromMediaImage(image.image!!, image.imageInfo.rotationDegrees) + scanner.process(inputImage) + .addOnCompleteListener { + if (it.isSuccessful) { + it.result.let { barcodes -> + barcodes.forEach { barcode -> + barcode.rawValue?.let { qrCode -> + isScanning = false + onScanResult(Result.success(qrCode)) + image.close() + return@addOnCompleteListener + } + } + } + } else { + val error = it.exception ?: Exception("Scan failed") + Log.e(APP, error.message.orEmpty(), error) + onScanResult(Result.failure(error)) + } + image.close() + } + } else { + image.close() + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt new file mode 100644 index 0000000000..e46890515c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -0,0 +1,194 @@ +@file:OptIn(ExperimentalPermissionsApi::class) + +package to.bitkit.ui.screens.scanner + +import android.Manifest +import android.util.Log +import android.view.View.LAYER_TYPE_HARDWARE +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clipToBounds +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import to.bitkit.R +import to.bitkit.env.Tag.APP +import to.bitkit.ui.appViewModel +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import java.util.concurrent.Executors + +@Composable +fun QrScanningScreen( + navController: NavController, + onScanSuccess: (String) -> Unit, +) { + val app = appViewModel ?: return + + // TODO maybe replace & drop accompanist permissions + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + val lensFacing by remember { mutableIntStateOf(CameraSelector.LENS_FACING_BACK) } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + cameraPermissionState.launchPermissionRequest() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val context = LocalContext.current + val previewView = remember { PreviewView(context) } + val preview by remember { mutableStateOf(Preview.Builder().build()) } + val imageAnalysis by remember { + mutableStateOf( + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + ) + } + + LaunchedEffect(lensFacing) { + imageAnalysis.setAnalyzer( + Executors.newSingleThreadExecutor(), + QrCodeAnalyzer { result -> + if (result.isSuccess) { + val qrCode = requireNotNull(result.getOrNull()) + Log.d(APP, "Scan success: $qrCode") + onScanSuccess(qrCode) + } else { + val error = requireNotNull(result.exceptionOrNull()) + Log.e(APP, "Failed to scan QR code", error) + app.toast(error) + } + } + ) + } + + val cameraSelector = remember(lensFacing) { + CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + } + var camera by remember { mutableStateOf(null) } + + LaunchedEffect(lensFacing) { + val cameraProvider = withContext(Dispatchers.IO) { + ProcessCameraProvider.getInstance(context).get() + } + camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) + preview.surfaceProvider = previewView.surfaceProvider + } + DisposableEffect(Unit) { + onDispose { + camera?.let { + ProcessCameraProvider.getInstance(context).get().unbindAll() + } + } + } + + CameraPermissionRequiredView( + deniedContent = { status -> + CameraPermissionDeniedScreen( + requestPermission = cameraPermissionState::launchPermissionRequest, + shouldShowRationale = status.shouldShowRationale, + ) + }, + grantedContent = { + ScreenColumn { + AppTopBar(stringResource(R.string.title_scan), onBackClick = { navController.popBackStack() }) + Content(previewView = previewView) + } + } + ) +} + +@Composable +private fun Content( + previewView: PreviewView, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier + .fillMaxSize() + .clipToBounds(), + factory = { previewView.apply { setLayerType(LAYER_TYPE_HARDWARE, null) } } + ) + val (widthInPx, heightInPx, radiusInPx) = with(LocalDensity.current) { + remember { + Triple(350.dp.toPx(), 350.dp.toPx(), 16.dp.toPx()) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = .5f)), + contentAlignment = Alignment.Center, + ) { + Canvas( + modifier = Modifier + .size(350.dp) + .border(1.dp, Color.White, RoundedCornerShape(16.dp)) + ) { + val offset = Offset( + x = (size.width - widthInPx) / 2, + y = (size.height - heightInPx) / 2, + ) + val cutoutRect = Rect(offset, Size(widthInPx, heightInPx)) + + drawRoundRect( + topLeft = cutoutRect.topLeft, + size = cutoutRect.size, + cornerRadius = CornerRadius(radiusInPx, radiusInPx), + color = Color.Transparent, + blendMode = BlendMode.Clear + ) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 791cb293f2..82cfce9a7e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -47,6 +47,7 @@ import to.bitkit.ui.components.SheetHost import to.bitkit.ui.components.WalletBalanceView import to.bitkit.ui.navigateToActivityItem import to.bitkit.ui.navigateToAllActivity +import to.bitkit.ui.navigateToQrScanner import to.bitkit.ui.navigateToTransfer import to.bitkit.ui.postNotificationsPermission import to.bitkit.ui.scaffold.AppScaffold @@ -54,7 +55,6 @@ import to.bitkit.ui.screens.wallets.activity.ActivityList import to.bitkit.ui.screens.wallets.receive.ReceiveQRScreen import to.bitkit.ui.screens.wallets.send.SendOptionsView import to.bitkit.ui.shared.TabBar -import to.bitkit.ui.shared.util.qrCodeScanner import to.bitkit.ui.theme.Orange500 import to.bitkit.ui.theme.Purple500 @@ -65,21 +65,23 @@ fun HomeScreen( rootNavController: NavController, ) { val uiState by walletViewModel.uiState.collectAsState() - val currentSheet by appViewModel.currentSheet + val currentSheet = appViewModel.currentSheet SheetHost( appViewModel, sheets = { - when (currentSheet) { - BottomSheetType.Send -> { + when (val sheet = currentSheet.value) { + is BottomSheetType.Send -> { SendOptionsView( - onComplete = { sheet -> + appViewModel = appViewModel, + startDestination = sheet.route, + onComplete = { txSheet -> appViewModel.hideSheet() - sheet?.let { appViewModel.showNewTransactionSheet(it) } + txSheet?.let { appViewModel.showNewTransactionSheet(it) } } ) } - BottomSheetType.Receive -> { + is BottomSheetType.Receive -> { ReceiveQRScreen(uiState) } @@ -113,20 +115,12 @@ fun HomeScreen( onBackCLick = { walletNavController.popBackStack() } ) } - } - val scanner = qrCodeScanner() TabBar( - onSendClick = { appViewModel.showSheet(BottomSheetType.Send) }, + onSendClick = { appViewModel.showSheet(BottomSheetType.Send()) }, onReceiveClick = { appViewModel.showSheet(BottomSheetType.Receive) }, - onScanClick = { - scanner?.startScan()?.addOnCompleteListener { task -> - task.takeIf { it.isSuccessful }?.result?.rawValue?.let { data -> - walletViewModel.onScanSuccess(data) - } - } - }, + onScanClick = { rootNavController.navigateToQrScanner() }, modifier = Modifier .align(Alignment.BottomCenter) .systemBarsPadding() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt index 2fb5d7b662..6bdb6ec2ea 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import to.bitkit.R +import to.bitkit.ui.SendEvent +import to.bitkit.ui.SendUiState import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.util.DarkModePreview diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index cd2bf1d8c7..818e8363a9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -25,6 +25,9 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ui.LocalBalances +import to.bitkit.ui.SendEvent +import to.bitkit.ui.SendMethod +import to.bitkit.ui.SendUiState import to.bitkit.ui.components.LabelText import to.bitkit.ui.components.OutlinedColorButton import to.bitkit.ui.components.PrimaryButton 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 6f87dbfef5..e5fdb124c0 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 @@ -25,6 +25,9 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatted import to.bitkit.ext.truncate +import to.bitkit.ui.SendEvent +import to.bitkit.ui.SendMethod +import to.bitkit.ui.SendUiState import to.bitkit.ui.components.LabelText import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.wallets.send.components.SwipeButton 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 69e16815e7..fcb1306ab0 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 @@ -25,7 +25,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -34,17 +33,21 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.ui.AppViewModel +import to.bitkit.ui.SendEffect +import to.bitkit.ui.SendEvent import to.bitkit.ui.appViewModel import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.screens.wallets.send.components.SendButton -import to.bitkit.ui.shared.util.qrCodeScanner import to.bitkit.ui.theme.AppThemeSurface @Composable fun SendOptionsView( + appViewModel: AppViewModel, + startDestination: SendRoute = SendRoute.Options, onComplete: (NewTransactionSheetDetails?) -> Unit, ) { - val sendViewModel = hiltViewModel() Column( modifier = Modifier .fillMaxWidth() @@ -52,12 +55,13 @@ fun SendOptionsView( .imePadding() ) { val navController = rememberNavController() - LaunchedEffect(sendViewModel, navController) { - sendViewModel.effect.collect { + LaunchedEffect(appViewModel, navController) { + appViewModel.sendEffect.collect { when (it) { - is SendEffect.NavigateToAmount -> navController.navigate(SendRoutes.Amount) - is SendEffect.NavigateToAddress -> navController.navigate(SendRoutes.Address) - is SendEffect.NavigateToReview -> navController.navigate(SendRoutes.ReviewAndSend) + is SendEffect.NavigateToAmount -> navController.navigate(SendRoute.Amount) + is SendEffect.NavigateToAddress -> navController.navigate(SendRoute.Address) + is SendEffect.NavigateToScan -> navController.navigate(SendRoute.QrScanner) + is SendEffect.NavigateToReview -> navController.navigate(SendRoute.ReviewAndSend) is SendEffect.PaymentSuccess -> onComplete(it.sheet) } } @@ -65,35 +69,41 @@ fun SendOptionsView( NavHost( navController = navController, - startDestination = SendRoutes.Options, + startDestination = startDestination, ) { - composable { + composable { SendOptionsContent( - onEvent = { sendViewModel.setEvent(it) } + onEvent = { appViewModel.setSendEvent(it) } ) } - composable { - val uiState by sendViewModel.uiState.collectAsStateWithLifecycle() + composable { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendAddressScreen( uiState = uiState, onBack = { navController.popBackStack() }, - onEvent = { sendViewModel.setEvent(it) }, + onEvent = { appViewModel.setSendEvent(it) }, ) } - composable { - val uiState by sendViewModel.uiState.collectAsStateWithLifecycle() + composable { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendAmountScreen( uiState = uiState, onBack = { navController.popBackStack() }, - onEvent = { sendViewModel.setEvent(it) } + onEvent = { appViewModel.setSendEvent(it) } ) } - composable { - val uiState by sendViewModel.uiState.collectAsStateWithLifecycle() + composable { + QrScanningScreen(navController = navController) { qrCode -> + navController.popBackStack() + appViewModel.onScanSuccess(data = qrCode) + } + } + composable { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendAndReviewScreen( uiState = uiState, onBack = { navController.popBackStack() }, - onEvent = { sendViewModel.setEvent(it) }, + onEvent = { appViewModel.setSendEvent(it) }, ) } } @@ -147,22 +157,11 @@ private fun SendOptionsContent( onEvent(SendEvent.EnterManually) } - val scanner = qrCodeScanner() SendButton( - stringResource(R.string.scan_qr), + label = stringResource(R.string.scan_qr), icon = Icons.Default.CenterFocusWeak, ) { - scanner?.startScan()?.addOnCompleteListener { task -> - if (task.isSuccessful) { - task.result?.rawValue?.let { data -> - onEvent(SendEvent.Scan(data)) - } - } else { - task.exception?.let { - app?.toast(it) - } - } - } + onEvent(SendEvent.Scan) } Spacer(modifier = Modifier.weight(1f)) } @@ -180,16 +179,19 @@ private fun SendOptionsContentPreview() { } // endregion -private object SendRoutes { +interface SendRoute { + @Serializable + data object Options : SendRoute + @Serializable - data object Options + data object Address : SendRoute @Serializable - data object Address + data object Amount : SendRoute @Serializable - data object Amount + data object QrScanner : SendRoute @Serializable - data object ReviewAndSend + data object ReviewAndSend : SendRoute } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendViewModel.kt deleted file mode 100644 index 9a00612f45..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendViewModel.kt +++ /dev/null @@ -1,399 +0,0 @@ -package to.bitkit.ui.screens.wallets.send - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.lightningdevkit.ldknode.PaymentId -import org.lightningdevkit.ldknode.Txid -import to.bitkit.di.UiDispatcher -import to.bitkit.env.Tag.APP -import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NewTransactionSheetDirection -import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.Toast -import to.bitkit.services.LightningService -import to.bitkit.services.ScannerService -import to.bitkit.services.hasLightingParam -import to.bitkit.services.lightningParam -import to.bitkit.ui.shared.toast.ToastEventBus -import uniffi.bitkitcore.LightningInvoice -import uniffi.bitkitcore.OnChainInvoice -import uniffi.bitkitcore.Scanner -import javax.inject.Inject - -@HiltViewModel -class SendViewModel @Inject constructor( - @UiDispatcher private val uiThread: CoroutineDispatcher, - private val lightningService: LightningService, - private val scannerService: ScannerService, -) : ViewModel() { - private val _uiState = MutableStateFlow(SendUiState()) - val uiState = _uiState.asStateFlow() - - private val _effect = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) - val effect = _effect.asSharedFlow() - private fun setEffect(effect: SendEffect) = viewModelScope.launch { _effect.emit(effect) } - - private val events = MutableSharedFlow() - fun setEvent(event: SendEvent) = viewModelScope.launch { events.emit(event) } - - private var scan: Scanner? = null - - init { - observeEvents() - } - - private fun observeEvents() { - viewModelScope.launch { - events.collect { - when (it) { - SendEvent.EnterManually -> onEnterManuallyClick() - is SendEvent.Paste -> onPasteInvoice(it.data) - is SendEvent.Scan -> onScanSuccess(it.data) - - is SendEvent.AddressChange -> onAddressChange(it.value) - SendEvent.AddressReset -> resetAddressInput() - is SendEvent.AddressContinue -> onAddressContinue(it.data) - - is SendEvent.AmountChange -> onAmountChange(it.value) - SendEvent.AmountReset -> resetAmountInput() - is SendEvent.AmountContinue -> onAmountContinue(it.amount) - SendEvent.PaymentMethodSwitch -> onPaymentMethodSwitch() - - SendEvent.SpeedAndFee -> ToastEventBus.send(Exception("Coming soon: Speed and Fee")) - SendEvent.SwipeToPay -> onPay() - } - } - } - } - - private fun onEnterManuallyClick() { - resetAddressInput() - setEffect(SendEffect.NavigateToAddress) - } - - private fun resetAddressInput() { - _uiState.update { state -> - state.copy( - addressInput = "", - isAddressInputValid = false, - ) - } - } - - private fun onAddressChange(value: String) { - viewModelScope.launch { - val result = runCatching { scannerService.decode(value) } - _uiState.update { - it.copy( - addressInput = value, - isAddressInputValid = result.isSuccess, - ) - } - } - } - - private fun onAddressContinue(data: String) { - viewModelScope.launch { - handleScannedData(data) - } - } - - private fun onAmountChange(value: String) { - val isAmountValid = validateAmount(value) - _uiState.update { - it.copy( - amountInput = value, - isAmountInputValid = isAmountValid, - ) - } - } - - private fun onPaymentMethodSwitch() { - val nextPaymentMethod = when (uiState.value.payMethod) { - SendMethod.ONCHAIN -> SendMethod.LIGHTNING - SendMethod.LIGHTNING -> SendMethod.ONCHAIN - } - _uiState.update { - it.copy( - payMethod = nextPaymentMethod, - isAmountInputValid = validateAmount(it.amountInput, nextPaymentMethod), - ) - } - } - - private fun onAmountContinue(amount: String) { - _uiState.update { - it.copy( - amount = amount.toULongOrNull() ?: 0u, - ) - } - setEffect(SendEffect.NavigateToReview) - } - - private fun validateAmount( - value: String, - payMethod: SendMethod = uiState.value.payMethod, - ): Boolean { - if (value.isBlank()) return false - val amount = value.toULongOrNull() ?: return false - return when (payMethod) { - SendMethod.ONCHAIN -> amount > getMinOnchainTx() - else -> amount > 0u - } - } - - private fun onPasteInvoice(data: String) { - if (data.isBlank()) { - Log.e(APP, "No data in clipboard") - return - } - viewModelScope.launch { - handleScannedData(data) - } - } - - private fun onScanSuccess(data: String) { - viewModelScope.launch { - handleScannedData(data) - } - } - - private suspend fun handleScannedData(data: String) { - val scan = runCatching { scannerService.decode(data) } - .onFailure { Log.e(APP, "Failed to decode input data", it) } - .getOrNull() - this.scan = scan - - when (scan) { - is Scanner.OnChain -> { - val invoice: OnChainInvoice = scan.invoice - val lnInvoice: LightningInvoice? = invoice.lightningParam()?.let { bolt11 -> - val decoded = runCatching { scannerService.decode(bolt11) }.getOrNull() - val lightningInvoice = (decoded as? Scanner.Lightning)?.invoice ?: return@let null - - if (lightningService.canSend(lightningInvoice.amountSatoshis)) { - return@let lightningInvoice - } else { - // Ignore lighting - return@let null - } - } - _uiState.update { - it.copy( - address = invoice.address, - bolt11 = invoice.lightningParam(), - amount = invoice.amountSatoshis, - isUnified = invoice.hasLightingParam(), - decodedInvoice = lnInvoice, - payMethod = lnInvoice?.let { SendMethod.LIGHTNING } ?: SendMethod.ONCHAIN, - ) - } - val isLnInvoiceWithAmount = lnInvoice?.amountSatoshis != null && lnInvoice.amountSatoshis > 0uL - if (isLnInvoiceWithAmount) { - Log.i(APP, "Found amount in invoice, proceeding with payment") - setEffect(SendEffect.NavigateToReview) - return - } - Log.i(APP, "No amount found in invoice, proceeding entering amount manually") - resetAmountInput() - setEffect(SendEffect.NavigateToAmount) - } - - is Scanner.Lightning -> { - val invoice: LightningInvoice = scan.invoice - if (invoice.isExpired) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Invoice Expired", - description = "This invoice has expired." - ) - return - } - if (!lightningService.canSend(invoice.amountSatoshis)) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Insufficient Funds", - description = "You do not have enough funds to send this payment." - ) - return - } - - _uiState.update { - it.copy( - amount = invoice.amountSatoshis, - bolt11 = data, - description = invoice.description.orEmpty(), - decodedInvoice = invoice, - payMethod = SendMethod.LIGHTNING, - ) - } - if (invoice.amountSatoshis > 0uL) { - Log.i(APP, "Found amount in invoice, proceeding with payment") - setEffect(SendEffect.NavigateToReview) - } else { - Log.i(APP, "No amount found in invoice, proceeding entering amount manually") - resetAmountInput() - setEffect(SendEffect.NavigateToAmount) - } - } - - null -> { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Error", - description = "Error decoding data" - ) - } - - else -> { - Log.w(APP, "Unhandled invoice type:: $data") - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Unsupported", - description = "This type of invoice is not supported yet" - ) - } - } - } - - private fun resetAmountInput() { - _uiState.update { state -> - state.copy( - amountInput = "", - isAmountInputValid = false, - ) - } - } - - private fun onPay() { - viewModelScope.launch { - val amount = uiState.value.amount - when (uiState.value.payMethod) { - SendMethod.ONCHAIN -> { - val address = uiState.value.address - val validatedAddress = runCatching { scannerService.validateBitcoinAddress(address) } - .getOrNull() - ?: return@launch // TODO show error - val result = sendOnchain(validatedAddress.address, amount) - if (result.isSuccess) { - val txId = result.getOrNull() - Log.i(APP, "Onchain send result txid: $txId") - setEffect( - SendEffect.PaymentSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.SENT, - sats = amount.toLong(), - ) - ) - ) - } else { - // TODO error UI - Log.e(APP, "Error sending onchain payment", result.exceptionOrNull()) - } - } - - SendMethod.LIGHTNING -> { - val bolt11 = uiState.value.bolt11 ?: return@launch // TODO show error - // Determine if we should override amount - val decodedInvoice = uiState.value.decodedInvoice - val invoiceAmount = decodedInvoice?.amountSatoshis?.takeIf { it > 0uL } ?: amount - val paymentAmount = if (decodedInvoice?.amountSatoshis != null) invoiceAmount else null - val result = sendLightning(bolt11, paymentAmount) - if (result.isSuccess) { - val paymentHash = result.getOrNull() - Log.i(APP, "Lightning send result payment hash: $paymentHash") - setEffect(SendEffect.PaymentSuccess()) - } else { - // TODO error UI - Log.e(APP, "Error sending lightning payment", result.exceptionOrNull()) - } - } - } - } - } - - private suspend fun sendOnchain(address: String, amount: ULong): Result { - return runCatching { lightningService.send(address = address, amount) } - .onFailure { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Error Sending", - description = it.message ?: "Unknown error" - ) - } - } - - private suspend fun sendLightning(bolt11: String, amount: ULong? = null): Result { - return runCatching { lightningService.send(bolt11 = bolt11, amount) } - .onFailure { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Error Sending", - description = it.message ?: "Unknown error" - ) - } - } - - private fun getMinOnchainTx(): ULong { - // TODO implement min tx size - return 600uL - } - - override fun onCleared() { - super.onCleared() - Log.w(APP, "🚫 SendViewModel cleared") - } -} - -// region contract -data class SendUiState( - val address: String = "", - val bolt11: String? = null, - val addressInput: String = "", - val isAddressInputValid: Boolean = false, - val amount: ULong = 0u, - val amountInput: String = "", - val isAmountInputValid: Boolean = false, - val description: String = "", - val isUnified: Boolean = false, - val payMethod: SendMethod = SendMethod.ONCHAIN, - val decodedInvoice: LightningInvoice? = null, -) - -enum class SendMethod { ONCHAIN, LIGHTNING } - -sealed class SendEffect { - data object NavigateToAddress : SendEffect() - data object NavigateToAmount : SendEffect() - data object NavigateToReview : SendEffect() - data class PaymentSuccess(val sheet: NewTransactionSheetDetails? = null) : SendEffect() -} - -sealed class SendEvent { - data object EnterManually : SendEvent() - data class Paste(val data: String) : SendEvent() - data class Scan(val data: String) : SendEvent() - - data object AddressReset : SendEvent() - data class AddressChange(val value: String) : SendEvent() - data class AddressContinue(val data: String) : SendEvent() - - data object AmountReset : SendEvent() - data class AmountContinue(val amount: String) : SendEvent() - data class AmountChange(val value: String) : SendEvent() - - data object SwipeToPay : SendEvent() - data object SpeedAndFee : SendEvent() - data object PaymentMethodSwitch : SendEvent() -} -// endregion diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/components/SwipeButton.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/components/SwipeButton.kt index 457d141df2..cb5c3194cc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/components/SwipeButton.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/components/SwipeButton.kt @@ -79,7 +79,6 @@ fun SwipeButton( // Text in the middle Text( text = "Swipe To Pay", - color = Color.Black, modifier = Modifier .align(Alignment.Center) .alpha(1.0f - (offsetX / (maxWidth - buttonHeightPx))) diff --git a/app/src/main/java/to/bitkit/ui/shared/TabBar.kt b/app/src/main/java/to/bitkit/ui/shared/TabBar.kt index 793ab3c93a..7be17eefd1 100644 --- a/app/src/main/java/to/bitkit/ui/shared/TabBar.kt +++ b/app/src/main/java/to/bitkit/ui/shared/TabBar.kt @@ -5,12 +5,16 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.CenterFocusWeak import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -21,8 +25,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.shared.util.DarkModePreview import to.bitkit.ui.theme.AppThemeSurface @Composable @@ -36,14 +43,17 @@ fun TabBar( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), contentColor = MaterialTheme.colorScheme.onSurface, ) - val contentPadding = PaddingValues(0.dp, 12.dp) + val contentPadding = PaddingValues(0.dp, 18.dp) Box( contentAlignment = Alignment.Center, modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) ) { Row { + val iconToTextSpace = 4.dp + val iconSize = 20.dp Button( onClick = onSendClick, shape = RoundedCornerShape(topStartPercent = 50, bottomStartPercent = 50), @@ -52,7 +62,17 @@ fun TabBar( elevation = null, modifier = Modifier.weight(1f) ) { - Text("Send") + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = stringResource(R.string.send), + modifier = Modifier.size(iconSize) + ) + Spacer(Modifier.width(iconToTextSpace)) + Text( + text = stringResource(R.string.send), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyLarge, + ) } Button( onClick = onReceiveClick, @@ -62,18 +82,28 @@ fun TabBar( elevation = null, modifier = Modifier.weight(1f) ) { - Text("Receive") + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = stringResource(R.string.receive), + modifier = Modifier.size(iconSize) + ) + Spacer(Modifier.width(iconToTextSpace)) + Text( + text = stringResource(R.string.receive), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyLarge, + ) } } IconButton( onClick = onScanClick, modifier = Modifier - .size(60.dp) - .border(1.dp, buttonColors.containerColor, MaterialTheme.shapes.large) + .size(80.dp) + .border(2.dp, buttonColors.containerColor, MaterialTheme.shapes.extraLarge) ) { Icon( imageVector = Icons.Default.CenterFocusWeak, - contentDescription = "Scan", + contentDescription = stringResource(R.string.scan), modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large) @@ -83,7 +113,7 @@ fun TabBar( } } -@Preview(showBackground = true) +@DarkModePreview @Composable private fun TabBarPreview() { AppThemeSurface { diff --git a/app/src/main/java/to/bitkit/ui/shared/util/Scanning.kt b/app/src/main/java/to/bitkit/ui/shared/util/Scanning.kt deleted file mode 100644 index 8168559ad4..0000000000 --- a/app/src/main/java/to/bitkit/ui/shared/util/Scanning.kt +++ /dev/null @@ -1,24 +0,0 @@ -package to.bitkit.ui.shared.util - -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.codescanner.GmsBarcodeScanner -import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions -import com.google.mlkit.vision.codescanner.GmsBarcodeScanning - -@Composable -fun qrCodeScanner(): GmsBarcodeScanner? { - val context = LocalContext.current - - if (LocalInspectionMode.current) { - // Return a mock or null for Preview - return null - } - val options = GmsBarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .enableAutoZoom() - .build() - return GmsBarcodeScanning.getClient(context, options) -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b18e2dba0a..c0d14c58f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,9 +49,12 @@ Pay Peers Pending + Receive Ready sat Savings + Scan + Send Settings Spending Status @@ -63,6 +66,7 @@ Your Recovery Phrase Receive Bitcoin Reset And Restore + Scan QR Code Send Bitcoin Bitcoin Amount Review & Send