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