From fedb0d3324132909c12de46196caf52e0f92bbe2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 13 Dec 2024 19:53:05 +0100 Subject: [PATCH 1/3] feat: on-device main scanner WIP --- app/build.gradle.kts | 9 +- app/src/main/AndroidManifest.xml | 5 + app/src/main/java/to/bitkit/ui/ContentView.kt | 22 +++ .../scanner/CameraPermissionDeniedScreen.kt | 39 ++++ .../scanner/CameraPermissionRequiredView.kt | 27 +++ .../ui/screens/scanner/QrCodeAnalyzer.kt | 57 ++++++ .../ui/screens/scanner/QrScanViewModel.kt | 26 +++ .../ui/screens/scanner/QrScanningScreen.kt | 182 ++++++++++++++++++ .../util => screens/scanner}/Scanning.kt | 2 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 13 +- .../screens/wallets/send/SendOptionsView.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 12 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionDeniedScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionRequiredView.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/scanner/QrScanViewModel.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt rename app/src/main/java/to/bitkit/ui/{shared/util => screens/scanner}/Scanning.kt (95%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9afa33e967..dd2cb00258 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,9 +86,14 @@ 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.zxing:core:3.5.2") + implementation("com.google.mlkit:barcode-scanning:17.3.0") implementation("com.google.android.gms:play-services-code-scanner:16.1.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") + implementation("com.google.accompanist:accompanist-permissions:0.36.0") // Crypto implementation(libs.bouncycastle.provider.jdk) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31570599a7..11b9288691 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,14 @@ + + + { + val viewModel = hiltViewModel() + QrScanningScreen( + viewModel = viewModel, + navController = navController, + ) + } +} // endregion // region events @@ -298,6 +313,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 +366,7 @@ object Routes { @Serializable data class ActivityItem(val id: String) + + @Serializable + data object QrScanner } 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..ebcb9f83fb --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt @@ -0,0 +1,57 @@ +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 onQrCodeDetected: (String) -> 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 + // Success callback + onQrCodeDetected(qrCode) + image.close() + return@addOnCompleteListener + } + } + } + } else { + Log.e(APP, it.exception?.message.orEmpty(), it.exception) + } + image.close() + } + } else { + image.close() + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanViewModel.kt new file mode 100644 index 0000000000..bb97056d5a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanViewModel.kt @@ -0,0 +1,26 @@ +package to.bitkit.ui.screens.scanner + +import android.util.Log +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import to.bitkit.env.Tag.APP +import javax.inject.Inject + +@HiltViewModel +class QrScanViewModel @Inject constructor( +) : ViewModel() { + private val _uiState: MutableStateFlow = MutableStateFlow(QrScanUIState()) + val uiState = _uiState.asStateFlow() + + fun onQrCodeDetected(result: String) { + Log.d(APP, "Scan result: $result") + _uiState.update { it.copy(detectedQR = result) } + } +} + +data class QrScanUIState( + val detectedQR: String = "", +) 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..10aa975caf --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -0,0 +1,182 @@ +@file:OptIn(ExperimentalPermissionsApi::class) + +package to.bitkit.ui.screens.scanner + +import android.Manifest +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.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.asExecutor +import kotlinx.coroutines.withContext +import to.bitkit.R +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn + +@Composable +fun QrScanningScreen( + viewModel: QrScanViewModel, + navController: NavController, +) { + val uiState by viewModel.uiState.collectAsState() + 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 = Preview.Builder().build() + val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + LaunchedEffect(lensFacing) { + imageAnalysis.setAnalyzer( + Dispatchers.Default.asExecutor(), + QrCodeAnalyzer { result -> + viewModel.onQrCodeDetected(result) + } + ) + } + + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + var camera by remember { mutableStateOf(null) } + + LaunchedEffect(lensFacing) { + val cameraProvider = ProcessCameraProvider.getInstance(context) + camera = withContext(Dispatchers.IO) { cameraProvider.get() } + .bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) + preview.surfaceProvider = previewView.surfaceProvider + } + + CameraPermissionRequiredView( + deniedContent = { status -> + CameraPermissionDeniedScreen( + requestPermission = cameraPermissionState::launchPermissionRequest, + shouldShowRationale = status.shouldShowRationale, + ) + }, + grantedContent = { + ScreenColumn { + AppTopBar(stringResource(R.string.title_scan), onBackClick = { navController.popBackStack() }) + Content( + previewView = previewView, + uiState = uiState, + ) + } + } + ) +} + +@Composable +private fun Content( + previewView: PreviewView, + uiState: QrScanUIState, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { previewView } + ) + val widthInPx: Float + val heightInPx: Float + val radiusInPx: Float + with(LocalDensity.current) { + widthInPx = 350.dp.toPx() + heightInPx = 350.dp.toPx() + radiusInPx = 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 + ) + } + } + if (uiState.detectedQR.isNotEmpty()) { + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp) + .background(Color.White.copy(alpha = .6f), RoundedCornerShape(16.dp)) + .padding(horizontal = 16.dp, vertical = 8.dp), + text = uiState.detectedQR, + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/shared/util/Scanning.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/Scanning.kt similarity index 95% rename from app/src/main/java/to/bitkit/ui/shared/util/Scanning.kt rename to app/src/main/java/to/bitkit/ui/screens/scanner/Scanning.kt index 8168559ad4..8e1491ea10 100644 --- a/app/src/main/java/to/bitkit/ui/shared/util/Scanning.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/Scanning.kt @@ -1,4 +1,4 @@ -package to.bitkit.ui.shared.util +package to.bitkit.ui.screens.scanner import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext 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..a2fd3e48da 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,7 @@ 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.screens.scanner.qrCodeScanner import to.bitkit.ui.theme.Orange500 import to.bitkit.ui.theme.Purple500 @@ -113,20 +114,12 @@ fun HomeScreen( onBackCLick = { walletNavController.popBackStack() } ) } - } - val scanner = qrCodeScanner() TabBar( 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/SendOptionsView.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt index 69e16815e7..9ffa9799d2 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 @@ -37,7 +37,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.ui.appViewModel import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.wallets.send.components.SendButton -import to.bitkit.ui.shared.util.qrCodeScanner +import to.bitkit.ui.screens.scanner.qrCodeScanner import to.bitkit.ui.theme.AppThemeSurface @Composable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b18e2dba0a..d039e0b590 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,6 +63,7 @@ Your Recovery Phrase Receive Bitcoin Reset And Restore + Scan QR Code Send Bitcoin Bitcoin Amount Review & Send From 82933e7086def54b1d3e9940abe22112cfe9fcf6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 14 Dec 2024 17:27:35 +0100 Subject: [PATCH 2/3] refactor: move scan logic to app viewmodel --- app/build.gradle.kts | 1 - app/src/main/AndroidManifest.xml | 4 - .../main/java/to/bitkit/ui/AppViewModel.kt | 380 +++++++++++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 45 +- .../java/to/bitkit/ui/components/SheetHost.kt | 3 +- .../ui/screens/scanner/QrCodeAnalyzer.kt | 9 +- .../ui/screens/scanner/QrScanViewModel.kt | 26 -- .../ui/screens/scanner/QrScanningScreen.kt | 43 +- .../to/bitkit/ui/screens/scanner/Scanning.kt | 24 -- .../bitkit/ui/screens/wallets/HomeScreen.kt | 17 +- .../screens/wallets/send/SendAddressScreen.kt | 2 + .../screens/wallets/send/SendAmountScreen.kt | 3 + .../wallets/send/SendAndReviewScreen.kt | 3 + .../screens/wallets/send/SendOptionsView.kt | 78 ++-- .../ui/screens/wallets/send/SendViewModel.kt | 399 ------------------ .../wallets/send/components/SwipeButton.kt | 1 - 16 files changed, 475 insertions(+), 563 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/ui/screens/scanner/QrScanViewModel.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/scanner/Scanning.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/send/SendViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd2cb00258..531991ae18 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,7 +88,6 @@ dependencies { implementation(libs.kotlinx.datetime) implementation("com.google.zxing:core:3.5.2") implementation("com.google.mlkit:barcode-scanning:17.3.0") - implementation("com.google.android.gms:play-services-code-scanner:16.1.0") // CameraX implementation("androidx.camera:camera-camera2:1.4.1") implementation("androidx.camera:camera-lifecycle:1.4.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 11b9288691..d5ecbd5aa3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,10 +69,6 @@ - - diff --git a/app/src/main/java/to/bitkit/ui/AppViewModel.kt b/app/src/main/java/to/bitkit/ui/AppViewModel.kt index d31a3140ca..3a7edbcc41 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,317 @@ 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) + } + } + + // TODO handle all cases for mainScanner: onChainWithoutAmount, lnWithAmount, lnWithoutAmount, etc… + 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 != null && lnInvoice.amountSatoshis > 0uL + if (isLnInvoiceWithAmount) { + Log.i(APP, "Found amount in invoice, proceeding with payment") + setSendEffect(SendEffect.NavigateToReview) + return + } + Log.i(APP, "No amount found in invoice, proceeding entering amount manually") + resetAmountInput() + + if (isMainScanner) { + showSheet(BottomSheetType.Send(route = 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") + setSendEffect(SendEffect.NavigateToReview) + } else { + Log.i(APP, "No amount found in invoice, proceeding entering amount manually") + resetAmountInput() + 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 = "", + isAmountInputValid = false, + ) + } + } + + 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(), + ) + ) + ) + } 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()) + } 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 + } + // endregion + // region TxSheet var showNewTransaction by mutableStateOf(false) private set @@ -116,3 +452,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 84f6c78b1c..da69777f2d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1,42 +1,24 @@ 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.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.QrScanViewModel 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 @@ -99,7 +81,7 @@ fun ContentView( transfer(navController) allActivity(walletViewModel, navController) activityItem(walletViewModel, navController) - qrScanner(navController) + qrScanner(appViewModel, navController) } } } @@ -112,7 +94,11 @@ private fun NavGraphBuilder.home( navController: NavHostController, ) { composable { - HomeScreen(viewModel, appViewModel, navController) + HomeScreen( + walletViewModel = viewModel, + appViewModel = appViewModel, + rootNavController = navController + ) } } @@ -245,14 +231,17 @@ private fun NavGraphBuilder.activityItem( } private fun NavGraphBuilder.qrScanner( + appViewModel: AppViewModel, navController: NavHostController, ) { composable { - val viewModel = hiltViewModel() - QrScanningScreen( - viewModel = viewModel, - navController = navController, - ) + QrScanningScreen(navController = navController) { qrCode -> + navController.popBackStack() + appViewModel.onScanSuccess( + data = qrCode, + onResultDelay = 650 // slight delay to for home navigation before showing send sheet + ) + } } } // endregion 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/QrCodeAnalyzer.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt index ebcb9f83fb..3060a442dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt @@ -14,7 +14,7 @@ import to.bitkit.env.Tag.APP @OptIn(ExperimentalGetImage::class) class QrCodeAnalyzer( - private val onQrCodeDetected: (String) -> Unit, + private val onScanResult: (Result) -> Unit, ) : ImageAnalysis.Analyzer { private var isScanning = true @@ -38,15 +38,16 @@ class QrCodeAnalyzer( barcodes.forEach { barcode -> barcode.rawValue?.let { qrCode -> isScanning = false - // Success callback - onQrCodeDetected(qrCode) + onScanResult(Result.success(qrCode)) image.close() return@addOnCompleteListener } } } } else { - Log.e(APP, it.exception?.message.orEmpty(), it.exception) + val error = it.exception ?: Exception("Scan failed") + Log.e(APP, error.message.orEmpty(), error) + onScanResult(Result.failure(error)) } image.close() } diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanViewModel.kt deleted file mode 100644 index bb97056d5a..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanViewModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package to.bitkit.ui.screens.scanner - -import android.util.Log -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import to.bitkit.env.Tag.APP -import javax.inject.Inject - -@HiltViewModel -class QrScanViewModel @Inject constructor( -) : ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(QrScanUIState()) - val uiState = _uiState.asStateFlow() - - fun onQrCodeDetected(result: String) { - Log.d(APP, "Scan result: $result") - _uiState.update { it.copy(detectedQR = result) } - } -} - -data class QrScanUIState( - val detectedQR: String = "", -) 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 index 10aa975caf..1e20737fd0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -14,19 +14,9 @@ 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius @@ -50,15 +40,18 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.withContext import to.bitkit.R +import to.bitkit.ui.appViewModel import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.ScreenColumn @Composable fun QrScanningScreen( - viewModel: QrScanViewModel, navController: NavController, + onScanSuccess: (String) -> Unit, ) { - val uiState by viewModel.uiState.collectAsState() + 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) } @@ -87,7 +80,13 @@ fun QrScanningScreen( imageAnalysis.setAnalyzer( Dispatchers.Default.asExecutor(), QrCodeAnalyzer { result -> - viewModel.onQrCodeDetected(result) + if (result.isSuccess) { + val qrCode = requireNotNull(result.getOrNull()) + onScanSuccess(qrCode) + } else { + val error = requireNotNull(result.exceptionOrNull()) + app.toast(error) + } } ) } @@ -114,10 +113,7 @@ fun QrScanningScreen( grantedContent = { ScreenColumn { AppTopBar(stringResource(R.string.title_scan), onBackClick = { navController.popBackStack() }) - Content( - previewView = previewView, - uiState = uiState, - ) + Content(previewView = previewView) } } ) @@ -126,7 +122,6 @@ fun QrScanningScreen( @Composable private fun Content( previewView: PreviewView, - uiState: QrScanUIState, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { @@ -168,15 +163,5 @@ private fun Content( ) } } - if (uiState.detectedQR.isNotEmpty()) { - Text( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 24.dp) - .background(Color.White.copy(alpha = .6f), RoundedCornerShape(16.dp)) - .padding(horizontal = 16.dp, vertical = 8.dp), - text = uiState.detectedQR, - ) - } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/Scanning.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/Scanning.kt deleted file mode 100644 index 8e1491ea10..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/Scanning.kt +++ /dev/null @@ -1,24 +0,0 @@ -package to.bitkit.ui.screens.scanner - -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/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index a2fd3e48da..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 @@ -55,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.screens.scanner.qrCodeScanner import to.bitkit.ui.theme.Orange500 import to.bitkit.ui.theme.Purple500 @@ -66,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) } @@ -117,7 +118,7 @@ fun HomeScreen( } TabBar( - onSendClick = { appViewModel.showSheet(BottomSheetType.Send) }, + onSendClick = { appViewModel.showSheet(BottomSheetType.Send()) }, onReceiveClick = { appViewModel.showSheet(BottomSheetType.Receive) }, onScanClick = { rootNavController.navigateToQrScanner() }, modifier = Modifier 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 9ffa9799d2..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.screens.scanner.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))) From 65c93a675ecccaa7b6f22bfeafa5d67cd0578072 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 14 Dec 2024 19:58:28 +0100 Subject: [PATCH 3/3] feat: Connect main scanner to send flows --- app/build.gradle.kts | 3 +- .../main/java/to/bitkit/ui/AppViewModel.kt | 38 +++++++--- app/src/main/java/to/bitkit/ui/ContentView.kt | 18 ++++- .../ui/screens/scanner/QrScanningScreen.kt | 71 +++++++++++++------ .../main/java/to/bitkit/ui/shared/TabBar.kt | 46 +++++++++--- app/src/main/res/values/strings.xml | 3 + 6 files changed, 137 insertions(+), 42 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 531991ae18..48a99e534f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,8 +92,6 @@ dependencies { implementation("androidx.camera:camera-camera2:1.4.1") implementation("androidx.camera:camera-lifecycle:1.4.1") implementation("androidx.camera:camera-view:1.4.1") - implementation("com.google.accompanist:accompanist-permissions:0.36.0") - // Crypto implementation(libs.bouncycastle.provider.jdk) implementation(libs.ldk.node.android) @@ -120,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/java/to/bitkit/ui/AppViewModel.kt b/app/src/main/java/to/bitkit/ui/AppViewModel.kt index 3a7edbcc41..469b508660 100644 --- a/app/src/main/java/to/bitkit/ui/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/AppViewModel.kt @@ -191,8 +191,7 @@ class AppViewModel @Inject constructor( } } - // TODO handle all cases for mainScanner: onChainWithoutAmount, lnWithAmount, lnWithoutAmount, etc… - suspend fun handleScannedData(uri: String) { + private suspend fun handleScannedData(uri: String) { val scan = runCatching { scannerService.decode(uri) } .onFailure { Log.e(APP, "Failed to decode input data", it) } .getOrNull() @@ -216,17 +215,22 @@ class AppViewModel @Inject constructor( payMethod = lnInvoice?.let { SendMethod.LIGHTNING } ?: SendMethod.ONCHAIN, ) } - val isLnInvoiceWithAmount = lnInvoice?.amountSatoshis != null && lnInvoice.amountSatoshis > 0uL + val isLnInvoiceWithAmount = lnInvoice?.amountSatoshis?.takeIf { it > 0uL } != null if (isLnInvoiceWithAmount) { Log.i(APP, "Found amount in invoice, proceeding with payment") - setSendEffect(SendEffect.NavigateToReview) + + 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(route = SendRoute.Amount)) + showSheet(BottomSheetType.Send(SendRoute.Amount)) } else { setSendEffect(SendEffect.NavigateToAmount) } @@ -262,11 +266,21 @@ class AppViewModel @Inject constructor( } if (invoice.amountSatoshis > 0uL) { Log.i(APP, "Found amount in invoice, proceeding with payment") - setSendEffect(SendEffect.NavigateToReview) + + 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() - setSendEffect(SendEffect.NavigateToAmount) + + if (isMainScanner) { + showSheet(BottomSheetType.Send(SendRoute.Amount)) + } else { + setSendEffect(SendEffect.NavigateToAmount) + } } } @@ -292,8 +306,8 @@ class AppViewModel @Inject constructor( private fun resetAmountInput() { _sendUiState.update { state -> state.copy( - amountInput = "", - isAmountInputValid = false, + amountInput = state.amount.toString(), + isAmountInputValid = validateAmount(state.amount.toString()), ) } } @@ -320,6 +334,7 @@ class AppViewModel @Inject constructor( ) ) ) + resetSendState() } else { // TODO error UI Log.e(APP, "Error sending onchain payment", result.exceptionOrNull()) @@ -337,6 +352,7 @@ class AppViewModel @Inject constructor( 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()) @@ -372,6 +388,10 @@ class AppViewModel @Inject constructor( // TODO implement min tx size return 600uL } + + fun resetSendState() { + _sendUiState.value = SendUiState() + } // endregion // region TxSheet diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index da69777f2d..cf3eacc033 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1,5 +1,8 @@ package to.bitkit.ui +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 @@ -234,7 +237,20 @@ private fun NavGraphBuilder.qrScanner( appViewModel: AppViewModel, navController: NavHostController, ) { - composable { + 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( 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 index 1e20737fd0..e46890515c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -3,6 +3,8 @@ 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 @@ -16,9 +18,17 @@ 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.* +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 @@ -37,12 +47,13 @@ import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor 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( @@ -71,37 +82,53 @@ fun QrScanningScreen( val context = LocalContext.current val previewView = remember { PreviewView(context) } - val preview = Preview.Builder().build() - val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() + 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( - Dispatchers.Default.asExecutor(), + 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 = CameraSelector.Builder() - .requireLensFacing(lensFacing) - .build() + val cameraSelector = remember(lensFacing) { + CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + } var camera by remember { mutableStateOf(null) } LaunchedEffect(lensFacing) { - val cameraProvider = ProcessCameraProvider.getInstance(context) - camera = withContext(Dispatchers.IO) { cameraProvider.get() } - .bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) + 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 -> @@ -126,17 +153,17 @@ private fun Content( ) { Box(modifier = modifier.fillMaxSize()) { AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { previewView } + modifier = Modifier + .fillMaxSize() + .clipToBounds(), + factory = { previewView.apply { setLayerType(LAYER_TYPE_HARDWARE, null) } } ) - val widthInPx: Float - val heightInPx: Float - val radiusInPx: Float - with(LocalDensity.current) { - widthInPx = 350.dp.toPx() - heightInPx = 350.dp.toPx() - radiusInPx = 16.dp.toPx() + val (widthInPx, heightInPx, radiusInPx) = with(LocalDensity.current) { + remember { + Triple(350.dp.toPx(), 350.dp.toPx(), 16.dp.toPx()) + } } + Box( modifier = Modifier .fillMaxSize() 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index d039e0b590..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