diff --git a/app/src/main/java/to/bitkit/ui/components/RectangleButton.kt b/app/src/main/java/to/bitkit/ui/components/RectangleButton.kt index 66786b6e4..1d5f9d727 100644 --- a/app/src/main/java/to/bitkit/ui/components/RectangleButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/RectangleButton.kt @@ -1,56 +1,111 @@ package to.bitkit.ui.components +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import to.bitkit.R -import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Shapes @Composable fun RectangleButton( label: String, - icon: @Composable () -> Unit, modifier: Modifier = Modifier, + @DrawableRes icon: Int? = null, + imageVector: ImageVector? = null, + iconTint: Color = Colors.White, enabled: Boolean = true, + iconSize: Dp = 20.dp, onClick: () -> Unit = {}, ) { Button( onClick = onClick, colors = ButtonDefaults.buttonColors( - containerColor = Colors.White10, + containerColor = Colors.Gray6, ), enabled = enabled, - shape = AppShapes.small, - contentPadding = PaddingValues(24.dp), + shape = Shapes.medium, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 0.dp), modifier = modifier .alpha(if (enabled) 1f else 0.5f) .height(80.dp) .fillMaxWidth() ) { - icon() - Spacer(modifier = Modifier.width(16.dp)) + icon?.let { + CircularIcon( + painter = painterResource(it), + iconTint = iconTint, + iconSize = iconSize + ) + } + imageVector?.let { + CircularIcon( + imageVector = it, + iconTint = iconTint, + iconSize = iconSize + ) + } + HorizontalSpacer(16.dp) BodyMSB(text = label, color = Colors.White) - Spacer(modifier = Modifier.weight(1f)) + FillWidth() + } +} + +@Composable +private fun CircularIcon( + iconTint: Color, + iconSize: Dp, + painter: Painter? = null, + imageVector: ImageVector? = null, +) { + Box( + modifier = Modifier + .clip(CircleShape) + .size(40.dp) + .background(Colors.Black), + contentAlignment = Alignment.Center + ) { + when { + painter != null -> Icon( + painter = painter, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(iconSize), + ) + + imageVector != null -> Icon( + imageVector = imageVector, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(iconSize), + ) + } } } @@ -64,26 +119,14 @@ private fun RectangleButtonPreview() { ) { RectangleButton( label = "Button", - icon = { - Icon( - painter = painterResource(R.drawable.ic_scan), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(28.dp), - ) - } + icon = R.drawable.ic_scan ) RectangleButton( label = "Button Disabled", enabled = false, - icon = { - Icon( - imageVector = Icons.Default.ContentPaste, - contentDescription = null, - tint = Colors.Brand, - modifier = Modifier.size(28.dp), - ) - } + icon = null, + iconTint = Colors.Purple, + imageVector = Icons.Default.ContentPaste, ) } } 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 cb13d1f58..1969dae7a 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 @@ -16,19 +16,21 @@ import to.bitkit.utils.Logger class QrCodeAnalyzer( private val onScanResult: (Result) -> Unit, ) : ImageAnalysis.Analyzer { - private var isScanning = true + private var lastScannedCode: String? = null + private var lastScanTime: Long = 0 + private val scanCooldownMs = 2000L // 2 seconds cooldown between scans 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 - } + fun reset() { + lastScannedCode = null + lastScanTime = 0 + } + override fun analyze(image: ImageProxy) { if (image.image != null) { val inputImage = InputImage.fromMediaImage(image.image!!, image.imageInfo.rotationDegrees) scanner.process(inputImage) @@ -37,8 +39,15 @@ class QrCodeAnalyzer( it.result.let { barcodes -> barcodes.forEach { barcode -> barcode.rawValue?.let { qrCode -> - isScanning = false - onScanResult(Result.success(qrCode)) + val currentTime = System.currentTimeMillis() + val isDifferentCode = qrCode != lastScannedCode + val isCooldownExpired = currentTime - lastScanTime > scanCooldownMs + + if (isDifferentCode || isCooldownExpired) { + lastScannedCode = qrCode + lastScanTime = currentTime + onScanResult(Result.success(qrCode)) + } image.close() return@addOnCompleteListener } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt index 6eb6dd8db..8e777e34d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt @@ -6,12 +6,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -58,26 +55,16 @@ fun FundingAdvancedScreen( ) { RectangleButton( label = stringResource(R.string.lightning__funding_advanced__button1), - icon = { - Icon( - painter = painterResource(R.drawable.ic_scan), - contentDescription = null, - tint = Colors.Purple, - modifier = Modifier.size(28.dp), - ) - }, + icon = R.drawable.ic_scan, + iconTint = Colors.Purple, + iconSize = 13.75.dp, onClick = onLnurl, ) RectangleButton( label = stringResource(R.string.lightning__funding_advanced__button2), - icon = { - Icon( - painter = painterResource(R.drawable.ic_pencil_full), - contentDescription = null, - tint = Colors.Purple, - modifier = Modifier.size(28.dp), - ) - }, + icon = R.drawable.ic_pencil_full, + iconTint = Colors.Purple, + iconSize = 13.37.dp, onClick = onManual, modifier = Modifier.testTag("FundManual") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt index d72d99552..f0c45e1a4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt @@ -8,9 +8,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -19,9 +17,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -83,14 +79,8 @@ fun FundingScreen( Box { RectangleButton( label = stringResource(R.string.lightning__funding__button1), - icon = { - Icon( - painter = painterResource(R.drawable.ic_transfer), - contentDescription = null, - tint = Colors.Purple, - modifier = Modifier.size(28.dp), - ) - }, + icon = R.drawable.ic_transfer, + iconTint = Colors.Purple, enabled = canTransfer && !isGeoBlocked, onClick = onTransfer, modifier = Modifier.testTag("FundTransfer") @@ -111,28 +101,17 @@ fun FundingScreen( } RectangleButton( label = stringResource(R.string.lightning__funding__button2), - icon = { - Icon( - painter = painterResource(R.drawable.ic_qr_purple), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(28.dp), - ) - }, + icon = R.drawable.ic_qr_purple, + iconTint = Colors.Purple, + iconSize = 13.75.dp, enabled = !isGeoBlocked, onClick = onFund, modifier = Modifier.testTag("FundReceive") ) RectangleButton( label = stringResource(R.string.lightning__funding__button3), - icon = { - Icon( - painter = painterResource(R.drawable.ic_share_purple), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(28.dp), - ) - }, + icon = R.drawable.ic_share_purple, + iconTint = Colors.Purple, onClick = onAdvanced, modifier = Modifier.testTag("FundCustom") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt index 7bb7797ad..640e47aa8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt @@ -1,44 +1,287 @@ package to.bitkit.ui.screens.wallets.send -import androidx.compose.foundation.Image +import android.Manifest +import android.content.Context +import android.net.Uri +import android.os.Build +import android.view.View.LAYER_TYPE_HARDWARE +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import to.bitkit.R +import to.bitkit.ext.startActivityAppSettings +import to.bitkit.models.Toast import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.RectangleButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.scanner.QrCodeAnalyzer import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Shapes +import to.bitkit.ui.theme.TRANSITION_SCREEN_MS +import to.bitkit.ui.utils.withAccent +import to.bitkit.utils.Logger import to.bitkit.viewmodels.SendEvent +import java.util.concurrent.Executors +import androidx.camera.core.Preview as CameraPreview +@OptIn(ExperimentalPermissionsApi::class) @Composable fun SendRecipientScreen( onEvent: (SendEvent) -> Unit, modifier: Modifier = Modifier, ) { - val scope = rememberCoroutineScope() val app = appViewModel + + // Context & lifecycle + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + + // Camera state + var isFlashlightOn by remember { mutableStateOf(false) } + var isCameraInitialized by remember { mutableStateOf(false) } + val previewView = remember { PreviewView(context) } + val preview = remember { CameraPreview.Builder().build() } + var camera by remember { mutableStateOf(null) } + val executor = remember { Executors.newSingleThreadExecutor() } + + val cameraSelector = remember { + CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + cameraPermissionState.launchPermissionRequest() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // QR code analyzer with auto-proceed callback + val analyzer = remember(onEvent) { + QrCodeAnalyzer { result -> + if (result.isSuccess) { + val qrCode = result.getOrThrow() + Logger.debug("QR scanned: $qrCode") + onEvent(SendEvent.AddressContinue(qrCode)) + } else { + val error = requireNotNull(result.exceptionOrNull()) + Logger.error("Scan failed", error) + app?.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) + } + } + } + + val imageAnalysis = remember { + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + } + + LaunchedEffect(cameraPermissionState.status, isCameraInitialized) { + if (cameraPermissionState.status.isGranted && !isCameraInitialized) { + runCatching { + delay(TRANSITION_SCREEN_MS) + imageAnalysis.setAnalyzer(executor, analyzer) + + val cameraProvider = withContext(Dispatchers.IO) { + ProcessCameraProvider.getInstance(context).get() + } + camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalysis + ) + preview.surfaceProvider = previewView.surfaceProvider + isCameraInitialized = true + }.onFailure { e -> + Logger.error("Camera initialization failed", e) + app?.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = "Failed to initialize camera: ${e.message}" + ) + isCameraInitialized = false + } + } + } + + // Camera cleanup + DisposableEffect(Unit) { + onDispose { + camera?.let { + runCatching { + ProcessCameraProvider.getInstance(context).get().unbindAll() + }.onFailure { e -> + Logger.error("Camera cleanup failed", e) + } + } + // Reset state - camera will reinit if needed on next composition + isCameraInitialized = false + executor.shutdown() + } + } + + // Gallery picker launchers + val handleGalleryScanSuccess = remember(onEvent) { + { + qrCode: String -> + Logger.debug("QR from gallery: $qrCode") + onEvent(SendEvent.AddressContinue(qrCode)) + } + } + + val handleGalleryError = remember(app) { + { + e: Exception -> + app?.toast(e) + Unit + } + } + + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { uri -> + uri?.let { + processImageFromGallery( + context = context, + uri = it, + onScanSuccess = handleGalleryScanSuccess, + onError = handleGalleryError + ) + } + } + ) + + val pickMedia = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + processImageFromGallery( + context = context, + uri = it, + onScanSuccess = handleGalleryScanSuccess, + onError = handleGalleryError + ) + } + } + + SendRecipientContent( + previewView = previewView, + onClickFlashlight = { + camera?.cameraControl?.let { control -> + isFlashlightOn = !isFlashlightOn + runCatching { + control.enableTorch(isFlashlightOn) + }.onFailure { e -> + Logger.error("Torch control failed", e) + // Revert state + isFlashlightOn = !isFlashlightOn + } + } + }, + onClickGallery = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pickMedia.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } else { + galleryLauncher.launch("image/*") + } + }, + onClickContact = { + app?.toast(Exception("Coming soon: Contact")) + }, + onClickPaste = { onEvent(SendEvent.Paste) }, + onClickManual = { onEvent(SendEvent.EnterManually) }, + cameraPermissionGranted = cameraPermissionState.status.isGranted, + onRequestPermission = { context.startActivityAppSettings() }, + modifier = modifier + ) +} + +@Composable +private fun SendRecipientContent( + previewView: PreviewView?, + onClickFlashlight: () -> Unit, + onClickGallery: () -> Unit, + onClickContact: () -> Unit, + onClickPaste: () -> Unit, + onClickManual: () -> Unit, + cameraPermissionGranted: Boolean, + onRequestPermission: () -> Unit, + modifier: Modifier = Modifier, +) { Column( modifier = modifier .fillMaxSize() @@ -47,82 +290,195 @@ fun SendRecipientScreen( ) { SheetTopBar(titleText = stringResource(R.string.wallet__send_bitcoin)) Column( - modifier = Modifier.padding(horizontal = 16.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) ) { - VerticalSpacer(32.dp) - Caption13Up(text = stringResource(R.string.wallet__send_to), color = Colors.White64) - VerticalSpacer(16.dp) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + if (cameraPermissionGranted && previewView != null) { + CameraPreviewWithControls( + previewView = previewView, + onClickFlashlight = onClickFlashlight, + onClickGallery = onClickGallery, + modifier = Modifier.fillMaxSize() + ) + } else { + PermissionDenied( + onClickRetry = onRequestPermission, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) + } + } RectangleButton( label = stringResource(R.string.wallet__recipient_contact), - icon = { - Icon( - painter = painterResource(R.drawable.ic_users), - contentDescription = null, - tint = Colors.Brand, - modifier = Modifier.size(28.dp), - ) - }, - modifier = Modifier.padding(bottom = 4.dp).testTag("RecipientContact") + icon = R.drawable.ic_users, + iconTint = Colors.Brand, + modifier = Modifier.testTag("RecipientContact") ) { - scope.launch { - app?.toast(Exception("Coming soon: Contact")) - } + onClickContact() } RectangleButton( label = stringResource(R.string.wallet__recipient_invoice), - icon = { - Icon( - painter = painterResource(R.drawable.ic_clipboard_text), - contentDescription = null, - tint = Colors.Brand, - modifier = Modifier.size(28.dp), - ) - }, - modifier = Modifier.padding(bottom = 4.dp).testTag("RecipientInvoice") + icon = R.drawable.ic_clipboard_text, + iconTint = Colors.Brand, + modifier = Modifier.testTag("RecipientInvoice") ) { - onEvent(SendEvent.Paste) + onClickPaste() } RectangleButton( label = stringResource(R.string.wallet__recipient_manual), - icon = { - Icon( - painter = painterResource(R.drawable.ic_pencil_simple), - contentDescription = null, - tint = Colors.Brand, - modifier = Modifier.size(28.dp), - ) - }, - modifier = Modifier.padding(bottom = 4.dp).testTag("RecipientManual") + icon = R.drawable.ic_pencil_simple, + iconTint = Colors.Brand, + modifier = Modifier.testTag("RecipientManual") ) { - onEvent(SendEvent.EnterManually) + onClickManual() } + } + } +} - RectangleButton( - label = stringResource(R.string.wallet__recipient_scan), - icon = { - Icon( - painter = painterResource(R.drawable.ic_scan), - contentDescription = null, - tint = Colors.Brand, - modifier = Modifier.size(28.dp), - ) - }, - modifier = Modifier.testTag("RecipientScan") - ) { - onEvent(SendEvent.Scan) +@Composable +private fun CameraPreviewWithControls( + previewView: PreviewView, + onClickFlashlight: () -> Unit, + onClickGallery: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(Shapes.medium) + ) { + AndroidView( + modifier = Modifier + .fillMaxSize() + .clipToBounds(), + factory = { + previewView.apply { + setLayerType(LAYER_TYPE_HARDWARE, null) + } } - Spacer(modifier = Modifier.weight(1f)) + ) - Image( - painter = painterResource(R.drawable.coin_stack_logo), + // Gallery button (top-left) + IconButton( + onClick = onClickGallery, + modifier = Modifier + .padding(16.dp) + .clip(CircleShape) + .background(Colors.White64) + .size(48.dp) + .align(Alignment.TopStart) + ) { + Icon( + painter = painterResource(R.drawable.ic_image_square), contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() + tint = Colors.White ) } + + BodyMSB( + "Scan QR", + color = Colors.White, + modifier = Modifier + .padding(top = 31.dp) + .align(Alignment.TopCenter) + ) + + // Flashlight button (top-right) + IconButton( + onClick = onClickFlashlight, + modifier = Modifier + .padding(16.dp) + .clip(CircleShape) + .background(Colors.White64) + .size(48.dp) + .align(Alignment.TopEnd) + ) { + Icon( + painter = painterResource(R.drawable.ic_flashlight), + contentDescription = null, + tint = Colors.White + ) + } + } +} + +@Composable +private fun PermissionDenied( + onClickRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(Shapes.medium) + .background(Colors.Black) + .padding(32.dp) + ) { + Display("SCAN\nQR CODE".withAccent(accentColor = Colors.Brand), color = Colors.White) + + VerticalSpacer(8.dp) + + BodyM( + "Allow camera access to scan bitcoin invoices and pay more quickly.", + color = Colors.White64, + modifier = Modifier.fillMaxWidth() + ) + + VerticalSpacer(32.dp) + + PrimaryButton( + text = "Enable camera", + icon = { + Icon(painter = painterResource(R.drawable.ic_camera), contentDescription = null) + }, + onClick = onClickRetry, + ) + } +} + +private fun processImageFromGallery( + context: Context, + uri: Uri, + onScanSuccess: (String) -> Unit, + onError: (Exception) -> Unit, +) { + try { + val image = InputImage.fromFilePath(context, uri) + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val scanner = BarcodeScanning.getClient(options) + + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + barcode.rawValue?.let { qrCode -> + onScanSuccess(qrCode) + Logger.info("QR from gallery: $qrCode") + return@addOnSuccessListener + } + } + Logger.error("No QR code in image") + onError(Exception(context.getString(R.string.other__qr_error_text))) + } + .addOnFailureListener { e -> + Logger.error("Gallery scan failed", e) + onError(e) + } + } catch (e: IllegalArgumentException) { + Logger.error("Gallery processing failed", e) + onError(e) } } @@ -131,8 +487,15 @@ fun SendRecipientScreen( private fun Preview() { AppThemeSurface { BottomSheetPreview { - SendRecipientScreen( - onEvent = {}, + SendRecipientContent( + previewView = null, + onClickFlashlight = {}, + onClickGallery = {}, + onClickContact = {}, + onClickPaste = {}, + onClickManual = {}, + cameraPermissionGranted = false, + onRequestPermission = {}, modifier = Modifier.sheetHeight(), ) } diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 000000000..fa960b843 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,19 @@ + + + + +