From 960481756bd7b61fa1ac26e493d41f129a02eed5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 31 Mar 2026 10:44:32 -0300 Subject: [PATCH 01/18] feat: ConnectionIssuesView --- .../ui/components/ConnectionIssuesView.kt | 144 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 2 files changed, 146 insertions(+) create mode 100644 app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt new file mode 100644 index 000000000..612dd591d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -0,0 +1,144 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.ContentScale +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 to.bitkit.R +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@Composable +fun ConnectionIssuesView( + titleText: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("ConnectionIssueView"), + ) { + SheetTopBar(titleText = titleText) + + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + DashedRingsLayer(outerOnly = true) + + Image( + painter = painterResource(R.drawable.phone), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(311.dp), + ) + + DashedRingsLayer(outerOnly = false) + } + + Display( + text = stringResource(R.string.other__connection_issues_title) + .withAccent(accentColor = Colors.Yellow), + modifier = Modifier.fillMaxWidth(), + ) + + VerticalSpacer(8.dp) + + BodyM( + text = stringResource(R.string.other__connection_issues_explain), + color = Colors.White64, + modifier = Modifier.fillMaxWidth(), + ) + + VerticalSpacer(24.dp) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + GradientCircularProgressIndicator( + strokeWidth = 1.dp, + modifier = Modifier.size(32.dp), + ) + } + + VerticalSpacer(16.dp) + } +} + + +private val outerRing = DashedRingSpec( + radiusFraction = 0.60f, + color = Colors.White.copy(alpha = 0.08f), +) + +private val innerRings = listOf( + DashedRingSpec(radiusFraction = 0.15f, color = Colors.Yellow.copy(alpha = 0.4f)), + DashedRingSpec(radiusFraction = 0.30f, color = Colors.Yellow.copy(alpha = 0.25f)), + DashedRingSpec(radiusFraction = 0.45f, color = Colors.Brand.copy(alpha = 0.15f)), +) + +@Composable +private fun DashedRingsLayer(outerOnly: Boolean, modifier: Modifier = Modifier) { + val rings = if (outerOnly) listOf(outerRing) else innerRings + Canvas(modifier = modifier.fillMaxSize()) { + val center = Offset(size.width * 0.35f, size.height * 0.55f) + rings.forEach { ring -> drawDashedRing(ring, center) } + } +} + +private fun DrawScope.drawDashedRing(ring: DashedRingSpec, center: Offset) { + drawCircle( + color = ring.color, + radius = size.minDimension * ring.radiusFraction, + center = center, + style = Stroke( + width = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(8.dp.toPx(), 6.dp.toPx()), + ), + ), + ) +} + +private data class DashedRingSpec( + val radiusFraction: Float, + val color: Color, +) + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + ConnectionIssuesView(titleText = "Send Bitcoin") + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 24f968538..a2b3a3acb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -350,6 +350,8 @@ Internet Connection Restored Internet Connectivity Issues It appears you’re disconnected, trying to reconnect... + It appears you\’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds. + Connection\nIssues]]> Claiming your Bitkit gift code... Claiming Gift Bitkit couldn\'t claim the funds. Please try again later or contact us. From b93d72926d2605421e587863d5dab80709e659aa Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 31 Mar 2026 11:08:07 -0300 Subject: [PATCH 02/18] feat: display connection issues view --- app/src/main/java/to/bitkit/ui/ContentView.kt | 5 +- .../screens/wallets/receive/ReceiveSheet.kt | 254 +++++---- .../to/bitkit/ui/sheets/ForceTransferSheet.kt | 37 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 512 +++++++++--------- 4 files changed, 440 insertions(+), 368 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f4e30ee01..a653e929c 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -48,6 +48,7 @@ import to.bitkit.env.Env import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.models.WidgetType +import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.Routes.ExternalConnection import to.bitkit.ui.components.AuthCheckScreen import to.bitkit.ui.components.DrawerMenu @@ -380,12 +381,14 @@ fun ContentView( is Sheet.Receive -> { val walletState by walletViewModel.walletState.collectAsStateWithLifecycle() + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() ReceiveSheet( walletState = walletState, + isOffline = connectivityState != ConnectivityState.CONNECTED, navigateToExternalConnection = { navController.navigateTo(ExternalConnection()) appViewModel.hideSheet() - } + }, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index dd19fc938..b9e1add89 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -1,6 +1,11 @@ package to.bitkit.ui.screens.wallets.receive +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable @@ -11,13 +16,16 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.serialization.Serializable +import to.bitkit.R import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletState +import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.navigateTo import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.screens.wallets.send.AddTagScreen @@ -31,6 +39,7 @@ import to.bitkit.viewmodels.SettingsViewModel fun ReceiveSheet( navigateToExternalConnection: () -> Unit, walletState: WalletState, + isOffline: Boolean, editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel(), ) { @@ -49,138 +58,155 @@ fun ReceiveSheet( wallet.refreshReceiveState() } - Column( + Box( modifier = Modifier .fillMaxWidth() - .sheetHeight() - .imePadding() - .testTag("ReceiveScreen") + .sheetHeight(), ) { - NavHost( - navController = navController, - startDestination = ReceiveRoute.QR, + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .testTag("ReceiveScreen"), ) { - composableWithDefaultTransitions { - LaunchedEffect(cjitInvoice.value) { - showCreateCjit.value = !cjitInvoice.value.isNullOrBlank() - } + NavHost( + navController = navController, + startDestination = ReceiveRoute.QR, + ) { + composableWithDefaultTransitions { + LaunchedEffect(cjitInvoice.value) { + showCreateCjit.value = !cjitInvoice.value.isNullOrBlank() + } - ReceiveQrScreen( - cjitInvoice = cjitInvoice.value, - walletState = walletState, - lightningState = lightningState, - onClickReceiveCjit = { - if (lightningState.isGeoBlocked) { - navController.navigateTo(ReceiveRoute.GeoBlock) - } else { - showCreateCjit.value = true - navController.navigateTo(ReceiveRoute.Amount) - } - }, - onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, - ) - } - composableWithDefaultTransitions { - ReceiveAmountScreen( - onCjitCreated = { entry -> - cjitEntryDetails.value = entry - navController.navigateTo(ReceiveRoute.Confirm) - }, - onBack = { navController.popBackStack() }, - ) - } - composableWithDefaultTransitions { - LocationBlockScreen( - onBackPressed = { navController.popBackStack() }, - navigateAdvancedSetup = navigateToExternalConnection, - ) - } - composableWithDefaultTransitions { - cjitEntryDetails.value?.let { entryDetails -> - ReceiveConfirmScreen( - entry = entryDetails, - onLearnMore = { navController.navigateTo(ReceiveRoute.Liquidity) }, - onContinue = { invoice -> - cjitInvoice.value = invoice - navController.navigateTo(ReceiveRoute.QR) { popUpTo(ReceiveRoute.QR) { inclusive = true } } + ReceiveQrScreen( + cjitInvoice = cjitInvoice.value, + walletState = walletState, + lightningState = lightningState, + onClickReceiveCjit = { + if (lightningState.isGeoBlocked) { + navController.navigateTo(ReceiveRoute.GeoBlock) + } else { + showCreateCjit.value = true + navController.navigateTo(ReceiveRoute.Amount) + } }, - onBack = { navController.popBackStack() }, + onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, ) } - } - composableWithDefaultTransitions { - cjitEntryDetails.value?.let { entryDetails -> - ReceiveConfirmScreen( - entry = entryDetails, - onLearnMore = { navController.navigateTo(ReceiveRoute.LiquidityAdditional) }, - onContinue = { invoice -> - cjitInvoice.value = invoice - navController.navigateTo(ReceiveRoute.QR) { popUpTo(ReceiveRoute.QR) { inclusive = true } } + composableWithDefaultTransitions { + ReceiveAmountScreen( + onCjitCreated = { entry -> + cjitEntryDetails.value = entry + navController.navigateTo(ReceiveRoute.Confirm) }, - isAdditional = true, onBack = { navController.popBackStack() }, ) } - } - composableWithDefaultTransitions { - cjitEntryDetails.value?.let { entryDetails -> - val context = LocalContext.current - val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() - - ReceiveLiquidityScreen( - entry = entryDetails, - onContinue = { navController.popBackStack() }, - onBack = { navController.popBackStack() }, - hasNotificationPermission = notificationsGranted, - onSwitchClick = { context.openNotificationSettings() }, + composableWithDefaultTransitions { + LocationBlockScreen( + onBackPressed = { navController.popBackStack() }, + navigateAdvancedSetup = navigateToExternalConnection, ) } - } - composableWithDefaultTransitions { - cjitEntryDetails.value?.let { entryDetails -> - val context = LocalContext.current - val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() - - ReceiveLiquidityScreen( - entry = entryDetails, - onContinue = { navController.popBackStack() }, - isAdditional = true, + composableWithDefaultTransitions { + cjitEntryDetails.value?.let { entryDetails -> + ReceiveConfirmScreen( + entry = entryDetails, + onLearnMore = { navController.navigateTo(ReceiveRoute.Liquidity) }, + onContinue = { invoice -> + cjitInvoice.value = invoice + navController.navigateTo( + ReceiveRoute.QR + ) { popUpTo(ReceiveRoute.QR) { inclusive = true } } + }, + onBack = { navController.popBackStack() }, + ) + } + } + composableWithDefaultTransitions { + cjitEntryDetails.value?.let { entryDetails -> + ReceiveConfirmScreen( + entry = entryDetails, + onLearnMore = { navController.navigateTo(ReceiveRoute.LiquidityAdditional) }, + onContinue = { invoice -> + cjitInvoice.value = invoice + navController.navigateTo( + ReceiveRoute.QR + ) { popUpTo(ReceiveRoute.QR) { inclusive = true } } + }, + isAdditional = true, + onBack = { navController.popBackStack() }, + ) + } + } + composableWithDefaultTransitions { + cjitEntryDetails.value?.let { entryDetails -> + val context = LocalContext.current + val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + + ReceiveLiquidityScreen( + entry = entryDetails, + onContinue = { navController.popBackStack() }, + onBack = { navController.popBackStack() }, + hasNotificationPermission = notificationsGranted, + onSwitchClick = { context.openNotificationSettings() }, + ) + } + } + composableWithDefaultTransitions { + cjitEntryDetails.value?.let { entryDetails -> + val context = LocalContext.current + val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + + ReceiveLiquidityScreen( + entry = entryDetails, + onContinue = { navController.popBackStack() }, + isAdditional = true, + onBack = { navController.popBackStack() }, + hasNotificationPermission = notificationsGranted, + onSwitchClick = { context.openNotificationSettings() }, + ) + } + } + composableWithDefaultTransitions { + val walletUiState by wallet.walletState.collectAsStateWithLifecycle() + @Suppress("ViewModelForwarding") + EditInvoiceScreen( + amountInputViewModel = editInvoiceAmountViewModel, + walletUiState = walletUiState, onBack = { navController.popBackStack() }, - hasNotificationPermission = notificationsGranted, - onSwitchClick = { context.openNotificationSettings() }, + updateInvoice = wallet::updateBip21Invoice, + onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, + onClickTag = wallet::removeTag, + onDescriptionUpdate = wallet::updateBip21Description, + navigateReceiveConfirm = { entry -> + cjitEntryDetails.value = entry + navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) + }, + ) + } + composableWithDefaultTransitions { + AddTagScreen( + onBack = { + navController.popBackStack() + }, + onTagSelected = { tag -> + wallet.addTagToSelected(tag) + navController.popBackStack() + }, + tqgInputTestTag = "TagInputReceive", + addButtonTestTag = "ReceiveTagsSubmit", ) } } - composableWithDefaultTransitions { - val walletUiState by wallet.walletState.collectAsStateWithLifecycle() - @Suppress("ViewModelForwarding") - EditInvoiceScreen( - amountInputViewModel = editInvoiceAmountViewModel, - walletUiState = walletUiState, - onBack = { navController.popBackStack() }, - updateInvoice = wallet::updateBip21Invoice, - onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, - onClickTag = wallet::removeTag, - onDescriptionUpdate = wallet::updateBip21Description, - navigateReceiveConfirm = { entry -> - cjitEntryDetails.value = entry - navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) - } - ) - } - composableWithDefaultTransitions { - AddTagScreen( - onBack = { - navController.popBackStack() - }, - onTagSelected = { tag -> - wallet.addTagToSelected(tag) - navController.popBackStack() - }, - tqgInputTestTag = "TagInputReceive", - addButtonTestTag = "ReceiveTagsSubmit", - ) - } + } + + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView(titleText = stringResource(R.string.wallet__receive_bitcoin)) } } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/ForceTransferSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/ForceTransferSheet.kt index 0800b4de2..d8f3a0466 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/ForceTransferSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/ForceTransferSheet.kt @@ -1,7 +1,11 @@ package to.bitkit.ui.sheets +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio @@ -18,8 +22,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -39,15 +45,28 @@ fun ForceTransferSheet( transferViewModel: TransferViewModel, ) { val isLoading by transferViewModel.isForceTransferLoading.collectAsStateWithLifecycle() - Content( - isLoading = isLoading, - onForceTransfer = { - transferViewModel.forceTransfer { - appViewModel.hideSheet() - } - }, - onCancel = { appViewModel.hideSheet() }, - ) + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() + val isOffline = connectivityState != ConnectivityState.CONNECTED + + Box { + Content( + isLoading = isLoading, + onForceTransfer = { + transferViewModel.forceTransfer { + appViewModel.hideSheet() + } + }, + onCancel = { appViewModel.hideSheet() }, + ) + + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView(titleText = stringResource(R.string.lightning__transfer__nav_title)) + } + } } @Composable diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 9b9431d65..6c751db8b 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -1,5 +1,9 @@ package to.bitkit.ui.sheets +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -11,15 +15,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import kotlinx.serialization.Serializable +import to.bitkit.R import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.repositories.ConnectivityState +import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.navigateTo import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.screens.wallets.send.AddTagScreen @@ -56,6 +64,9 @@ fun SendSheet( walletViewModel: WalletViewModel, startDestination: SendRoute = SendRoute.Recipient, ) { + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() + val isOffline = connectivityState != ConnectivityState.CONNECTED + LaunchedEffect(startDestination) { // always reset state on new user-initiated send if (startDestination == SendRoute.Recipient) { @@ -63,274 +74,287 @@ fun SendSheet( appViewModel.resetQuickPay() } } - Column( + Box( modifier = Modifier .fillMaxWidth() - .sheetHeight() - .imePadding() - .testTag("SendSheet") + .sheetHeight(), ) { - val navController = rememberNavController() - LaunchedEffect(appViewModel, navController) { - appViewModel.sendEffect.collect { - when (it) { - is SendEffect.NavigateToAmount -> navController.navigateTo(SendRoute.Amount) - is SendEffect.NavigateToAddress -> navController.navigateTo(SendRoute.Address) - is SendEffect.NavigateToScan -> navController.navigateTo(SendRoute.QrScanner) - is SendEffect.NavigateToCoinSelection -> navController.navigateTo(SendRoute.CoinSelection) - is SendEffect.NavigateToConfirm -> navController.navigateTo(SendRoute.Confirm) - is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) - is SendEffect.PaymentSuccess -> { - appViewModel.clearClipboardForAutoRead() - navController.navigateTo(SendRoute.Success) { - popUpTo(navController.graph.id) { inclusive = true } + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .testTag("SendSheet"), + ) { + val navController = rememberNavController() + LaunchedEffect(appViewModel, navController) { + appViewModel.sendEffect.collect { + when (it) { + is SendEffect.NavigateToAmount -> navController.navigateTo(SendRoute.Amount) + is SendEffect.NavigateToAddress -> navController.navigateTo(SendRoute.Address) + is SendEffect.NavigateToScan -> navController.navigateTo(SendRoute.QrScanner) + is SendEffect.NavigateToCoinSelection -> navController.navigateTo(SendRoute.CoinSelection) + is SendEffect.NavigateToConfirm -> navController.navigateTo(SendRoute.Confirm) + is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) + is SendEffect.PaymentSuccess -> { + appViewModel.clearClipboardForAutoRead() + navController.navigateTo(SendRoute.Success) { + popUpTo(navController.graph.id) { inclusive = true } + } } - } - is SendEffect.NavigateToQuickPay -> navController.navigateTo(SendRoute.QuickPay) - is SendEffect.NavigateToWithdrawConfirm -> navController.navigateTo( - SendRoute.WithdrawConfirm - ) - is SendEffect.NavigateToWithdrawError -> navController.navigateTo(SendRoute.WithdrawError) - is SendEffect.NavigateToFee -> navController.navigateTo(SendRoute.FeeRate) - is SendEffect.NavigateToFeeCustom -> navController.navigateTo(SendRoute.FeeCustom) - is SendEffect.NavigateToComingSoon -> navController.navigateTo(SendRoute.ComingSoon) - is SendEffect.NavigateToPending -> navController.navigateTo( - SendRoute.Pending(it.paymentHash, it.amount) - ) { popUpTo(startDestination) { inclusive = true } } + is SendEffect.NavigateToQuickPay -> navController.navigateTo(SendRoute.QuickPay) + is SendEffect.NavigateToWithdrawConfirm -> navController.navigateTo( + SendRoute.WithdrawConfirm + ) + is SendEffect.NavigateToWithdrawError -> navController.navigateTo(SendRoute.WithdrawError) + is SendEffect.NavigateToFee -> navController.navigateTo(SendRoute.FeeRate) + is SendEffect.NavigateToFeeCustom -> navController.navigateTo(SendRoute.FeeCustom) + is SendEffect.NavigateToComingSoon -> navController.navigateTo(SendRoute.ComingSoon) + is SendEffect.NavigateToPending -> navController.navigateTo( + SendRoute.Pending(it.paymentHash, it.amount) + ) { popUpTo(startDestination) { inclusive = true } } + } } } - } - NavHost( - navController = navController, - startDestination = startDestination, - ) { - composableWithDefaultTransitions { - SendRecipientScreen( - onEvent = { appViewModel.setSendEvent(it) } - ) - } - composableWithDefaultTransitions { - val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - SendAddressScreen( - uiState = uiState, - onBack = { navController.popBackStack() }, - onEvent = { appViewModel.setSendEvent(it) }, - ) - } - composableWithDefaultTransitions { - val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() - SendAmountScreen( - uiState = uiState, - nodeLifecycleState = lightningState.nodeLifecycleState, - canGoBack = startDestination != SendRoute.Amount, - onBack = { - if (!navController.popBackStack()) { - appViewModel.hideSheet() - } - }, - onEvent = { appViewModel.setSendEvent(it) } - ) - } - composableWithDefaultTransitions { - QrScanningScreen( - onBack = { navController.popBackStack() }, - onScanSuccess = { - navController.popBackStack() - appViewModel.onScanResult(data = it) - }, - ) - } - composableWithDefaultTransitions { - val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - SendCoinSelectionScreen( - requiredAmount = sendUiState.amount, - address = sendUiState.address, - onBack = { navController.popBackStack() }, - onContinue = { utxos -> appViewModel.setSendEvent(SendEvent.CoinSelectionContinue(utxos)) }, - ) - } - navigationWithDefaultTransitions( - startDestination = SendRoute.FeeRate, + NavHost( + navController = navController, + startDestination = startDestination, ) { - composableWithDefaultTransitions { - val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - val parentEntry = remember(it) { navController.getBackStackEntry(SendRoute.FeeNav) } - SendFeeRateScreen( - sendUiState = sendUiState, - viewModel = hiltViewModel(parentEntry), + composableWithDefaultTransitions { + SendRecipientScreen( + onEvent = { appViewModel.setSendEvent(it) } + ) + } + composableWithDefaultTransitions { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + SendAddressScreen( + uiState = uiState, onBack = { navController.popBackStack() }, - onContinue = { navController.popBackStack() }, - onSelect = { speed -> appViewModel.onSelectSpeed(speed) }, - onSelectInstant = { - appViewModel.switchToLightning() + onEvent = { appViewModel.setSendEvent(it) }, + ) + } + composableWithDefaultTransitions { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() + SendAmountScreen( + uiState = uiState, + nodeLifecycleState = lightningState.nodeLifecycleState, + canGoBack = startDestination != SendRoute.Amount, + onBack = { + if (!navController.popBackStack()) { + appViewModel.hideSheet() + } + }, + onEvent = { appViewModel.setSendEvent(it) } + ) + } + composableWithDefaultTransitions { + QrScanningScreen( + onBack = { navController.popBackStack() }, + onScanSuccess = { navController.popBackStack() + appViewModel.onScanResult(data = it) }, ) } - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(SendRoute.FeeNav) } - SendFeeCustomScreen( - viewModel = hiltViewModel(parentEntry), + composableWithDefaultTransitions { + val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + SendCoinSelectionScreen( + requiredAmount = sendUiState.amount, + address = sendUiState.address, onBack = { navController.popBackStack() }, - onContinue = { speed -> appViewModel.setTransactionSpeed(speed) }, + onContinue = { utxos -> appViewModel.setSendEvent(SendEvent.CoinSelectionContinue(utxos)) }, ) } - } - composableWithDefaultTransitions { - val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() - - SendConfirmScreen( - savedStateHandle = it.savedStateHandle, - uiState = uiState, - isNodeRunning = lightningState.nodeLifecycleState.isRunning(), - canGoBack = startDestination != SendRoute.Confirm, - onBack = { - if (!navController.popBackStack()) { - appViewModel.hideSheet() - } - }, - onEvent = { e -> appViewModel.setSendEvent(e) }, - onClickAddTag = { navController.navigateTo(SendRoute.AddTag) }, - onClickTag = { tag -> appViewModel.removeTag(tag) }, - onNavigateToPin = { navController.navigateTo(SendRoute.PinCheck) }, - ) - } - composableWithDefaultTransitions { - val sendDetail by appViewModel.successSendUiState.collectAsStateWithLifecycle() - NewTransactionSheetView( - details = sendDetail, - onCloseClick = { appViewModel.hideSheet() }, - onDetailClick = { appViewModel.onClickSendDetail() }, - modifier = Modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - .testTag("SendSuccess") - ) - } - composableWithDefaultTransitions { - val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - WithdrawConfirmScreen( - uiState = uiState, - onBack = { navController.popBackStack() }, - onConfirm = { appViewModel.onConfirmWithdraw() }, - ) - } - composableWithDefaultTransitions { - val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - WithdrawErrorScreen( - uiState = uiState, - onBack = { navController.popBackStack() }, - onClickScan = { navController.navigateTo(SendRoute.QrScanner) }, - onClickSupport = { navController.navigateTo(SendRoute.Support) }, - ) - } - // TODO navigate to main support screen, not inside SEND sheet - composableWithDefaultTransitions { - SupportScreen(navController) - } - composableWithDefaultTransitions { - AddTagScreen( - onBack = { navController.popBackStack() }, - onTagSelected = { tag -> - appViewModel.addTagToSelected(tag) - navController.popBackStack() - }, - tqgInputTestTag = "TagInputSend", - addButtonTestTag = "SendTagsSubmit", - ) - } - composableWithDefaultTransitions { - SendPinCheckScreen( - onBack = { - navController.previousBackStackEntry - ?.savedStateHandle - ?.set(PIN_CHECK_RESULT_KEY, false) - navController.popBackStack() - }, - onSuccess = { - navController.previousBackStackEntry - ?.savedStateHandle - ?.set(PIN_CHECK_RESULT_KEY, true) - navController.popBackStack() - appViewModel.setSendEvent(SendEvent.PayConfirmed) - }, - ) - } - composableWithDefaultTransitions { - val quickPayData by appViewModel.quickPayData.collectAsStateWithLifecycle() - SendQuickPayScreen( - quickPayData = requireNotNull(quickPayData), - onPaymentComplete = { paymentHash, amountWithFee -> - appViewModel.onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = paymentHash, - sats = amountWithFee, - ), + navigationWithDefaultTransitions( + startDestination = SendRoute.FeeRate, + ) { + composableWithDefaultTransitions { + val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + val parentEntry = remember(it) { navController.getBackStackEntry(SendRoute.FeeNav) } + SendFeeRateScreen( + sendUiState = sendUiState, + viewModel = hiltViewModel(parentEntry), + onBack = { navController.popBackStack() }, + onContinue = { navController.popBackStack() }, + onSelect = { speed -> appViewModel.onSelectSpeed(speed) }, + onSelectInstant = { + appViewModel.switchToLightning() + navController.popBackStack() + }, ) - }, - onPaymentPending = { paymentHash, amount -> - navController.navigateTo(SendRoute.Pending(paymentHash, amount)) { - popUpTo(startDestination) { inclusive = true } - } - }, - onShowError = { errorMessage -> - navController.navigateTo(SendRoute.Error(errorMessage)) } - ) - } - composableWithDefaultTransitions { - val route = it.toRoute() - SendPendingScreen( - paymentHash = route.paymentHash, - amount = route.amount, - onPaymentSuccess = { paymentHash -> - appViewModel.onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = paymentHash, - sats = route.amount, - ), + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(SendRoute.FeeNav) } + SendFeeCustomScreen( + viewModel = hiltViewModel(parentEntry), + onBack = { navController.popBackStack() }, + onContinue = { speed -> appViewModel.setTransactionSpeed(speed) }, ) - }, - onPaymentError = { - navController.navigateTo(SendRoute.Error()) { - popUpTo { inclusive = true } + } + } + composableWithDefaultTransitions { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() + + SendConfirmScreen( + savedStateHandle = it.savedStateHandle, + uiState = uiState, + isNodeRunning = lightningState.nodeLifecycleState.isRunning(), + canGoBack = startDestination != SendRoute.Confirm, + onBack = { + if (!navController.popBackStack()) { + appViewModel.hideSheet() + } + }, + onEvent = { e -> appViewModel.setSendEvent(e) }, + onClickAddTag = { navController.navigateTo(SendRoute.AddTag) }, + onClickTag = { tag -> appViewModel.removeTag(tag) }, + onNavigateToPin = { navController.navigateTo(SendRoute.PinCheck) }, + ) + } + composableWithDefaultTransitions { + val sendDetail by appViewModel.successSendUiState.collectAsStateWithLifecycle() + NewTransactionSheetView( + details = sendDetail, + onCloseClick = { appViewModel.hideSheet() }, + onDetailClick = { appViewModel.onClickSendDetail() }, + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("SendSuccess") + ) + } + composableWithDefaultTransitions { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + WithdrawConfirmScreen( + uiState = uiState, + onBack = { navController.popBackStack() }, + onConfirm = { appViewModel.onConfirmWithdraw() }, + ) + } + composableWithDefaultTransitions { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + WithdrawErrorScreen( + uiState = uiState, + onBack = { navController.popBackStack() }, + onClickScan = { navController.navigateTo(SendRoute.QrScanner) }, + onClickSupport = { navController.navigateTo(SendRoute.Support) }, + ) + } + // TODO navigate to main support screen, not inside SEND sheet + composableWithDefaultTransitions { + SupportScreen(navController) + } + composableWithDefaultTransitions { + AddTagScreen( + onBack = { navController.popBackStack() }, + onTagSelected = { tag -> + appViewModel.addTagToSelected(tag) + navController.popBackStack() + }, + tqgInputTestTag = "TagInputSend", + addButtonTestTag = "SendTagsSubmit", + ) + } + composableWithDefaultTransitions { + SendPinCheckScreen( + onBack = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(PIN_CHECK_RESULT_KEY, false) + navController.popBackStack() + }, + onSuccess = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(PIN_CHECK_RESULT_KEY, true) + navController.popBackStack() + appViewModel.setSendEvent(SendEvent.PayConfirmed) + }, + ) + } + composableWithDefaultTransitions { + val quickPayData by appViewModel.quickPayData.collectAsStateWithLifecycle() + SendQuickPayScreen( + quickPayData = requireNotNull(quickPayData), + onPaymentComplete = { paymentHash, amountWithFee -> + appViewModel.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = paymentHash, + sats = amountWithFee, + ), + ) + }, + onPaymentPending = { paymentHash, amount -> + navController.navigateTo(SendRoute.Pending(paymentHash, amount)) { + popUpTo(startDestination) { inclusive = true } + } + }, + onShowError = { errorMessage -> + navController.navigateTo(SendRoute.Error(errorMessage)) } - }, - onClose = { appViewModel.hideSheet() }, - onViewDetails = { rawId -> appViewModel.navigateToActivity(rawId) }, - viewModel = hiltViewModel(), - ) - } - composableWithDefaultTransitions { - ComingSoonSheetContent( - onWalletOverviewClick = { appViewModel.hideSheet() }, - onBack = { navController.popBackStack() }, - ) - } - composableWithDefaultTransitions { - val route = it.toRoute() - SendErrorScreen( - message = route.message, - onRetry = { - navController.navigateTo(SendRoute.Recipient) { - popUpTo(navController.graph.id) { inclusive = true } + ) + } + composableWithDefaultTransitions { + val route = it.toRoute() + SendPendingScreen( + paymentHash = route.paymentHash, + amount = route.amount, + onPaymentSuccess = { paymentHash -> + appViewModel.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = paymentHash, + sats = route.amount, + ), + ) + }, + onPaymentError = { + navController.navigateTo(SendRoute.Error()) { + popUpTo { inclusive = true } + } + }, + onClose = { appViewModel.hideSheet() }, + onViewDetails = { rawId -> appViewModel.navigateToActivity(rawId) }, + viewModel = hiltViewModel(), + ) + } + composableWithDefaultTransitions { + ComingSoonSheetContent( + onWalletOverviewClick = { appViewModel.hideSheet() }, + onBack = { navController.popBackStack() }, + ) + } + composableWithDefaultTransitions { + val route = it.toRoute() + SendErrorScreen( + message = route.message, + onRetry = { + navController.navigateTo(SendRoute.Recipient) { + popUpTo(navController.graph.id) { inclusive = true } + } + }, + onClose = { + appViewModel.hideSheet() } - }, - onClose = { - appViewModel.hideSheet() - } - ) + ) + } } } + + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView(titleText = stringResource(R.string.wallet__send_bitcoin)) + } } } From 1671051f77d7467bf5b5a30b04b2164be2e724b9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 31 Mar 2026 11:18:09 -0300 Subject: [PATCH 03/18] fix: circle alignment --- .../java/to/bitkit/ui/components/ConnectionIssuesView.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index 612dd591d..ef3009532 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -46,7 +46,6 @@ fun ConnectionIssuesView( SheetTopBar(titleText = titleText) Box( - contentAlignment = Alignment.BottomCenter, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -57,7 +56,9 @@ fun ConnectionIssuesView( painter = painterResource(R.drawable.phone), contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.size(311.dp), + modifier = Modifier + .size(311.dp) + .align(Alignment.CenterStart), ) DashedRingsLayer(outerOnly = false) @@ -109,7 +110,7 @@ private val innerRings = listOf( private fun DashedRingsLayer(outerOnly: Boolean, modifier: Modifier = Modifier) { val rings = if (outerOnly) listOf(outerRing) else innerRings Canvas(modifier = modifier.fillMaxSize()) { - val center = Offset(size.width * 0.35f, size.height * 0.55f) + val center = Offset(size.width * 0.25f, size.height * 0.40f) rings.forEach { ring -> drawDashedRing(ring, center) } } } From 61e03710681d018630d4b36224d37943d02b996d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 31 Mar 2026 11:40:57 -0300 Subject: [PATCH 04/18] fix: circle color and fading --- .../ui/components/ConnectionIssuesView.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index ef3009532..8262c0341 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.DrawScope @@ -97,13 +98,13 @@ fun ConnectionIssuesView( private val outerRing = DashedRingSpec( radiusFraction = 0.60f, - color = Colors.White.copy(alpha = 0.08f), + color = Colors.Yellow.copy(alpha = 0.08f), ) private val innerRings = listOf( DashedRingSpec(radiusFraction = 0.15f, color = Colors.Yellow.copy(alpha = 0.4f)), DashedRingSpec(radiusFraction = 0.30f, color = Colors.Yellow.copy(alpha = 0.25f)), - DashedRingSpec(radiusFraction = 0.45f, color = Colors.Brand.copy(alpha = 0.15f)), + DashedRingSpec(radiusFraction = 0.45f, color = Colors.Yellow.copy(alpha = 0.15f)), ) @Composable @@ -111,6 +112,20 @@ private fun DashedRingsLayer(outerOnly: Boolean, modifier: Modifier = Modifier) val rings = if (outerOnly) listOf(outerRing) else innerRings Canvas(modifier = modifier.fillMaxSize()) { val center = Offset(size.width * 0.25f, size.height * 0.40f) + + if (outerOnly) { + val fadeRadius = size.minDimension * 0.45f + drawCircle( + brush = Brush.radialGradient( + colors = listOf(Colors.White.copy(alpha = 0.06f), Color.Transparent), + center = center, + radius = fadeRadius, + ), + radius = fadeRadius, + center = center, + ) + } + rings.forEach { ring -> drawDashedRing(ring, center) } } } From e60c9fb9ccfa9f9d1dd04c59c55c3360c0f2cb8b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 31 Mar 2026 13:35:28 -0300 Subject: [PATCH 05/18] chore: lint --- .../main/java/to/bitkit/ui/components/ConnectionIssuesView.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index 8262c0341..d4f1a2962 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -95,7 +95,6 @@ fun ConnectionIssuesView( } } - private val outerRing = DashedRingSpec( radiusFraction = 0.60f, color = Colors.Yellow.copy(alpha = 0.08f), From aaa3d8d3a262c4330b42493453d45f1de31f00ac Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 31 Mar 2026 13:53:06 -0300 Subject: [PATCH 06/18] fix: gradient color --- .../ui/components/ConnectionIssuesView.kt | 50 ++++++------------- 1 file changed, 15 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index d4f1a2962..7d91930b5 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -47,6 +47,7 @@ fun ConnectionIssuesView( SheetTopBar(titleText = titleText) Box( + contentAlignment = Alignment.Center, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -59,7 +60,7 @@ fun ConnectionIssuesView( contentScale = ContentScale.Fit, modifier = Modifier .size(311.dp) - .align(Alignment.CenterStart), + .align(Alignment.Center), ) DashedRingsLayer(outerOnly = false) @@ -95,44 +96,28 @@ fun ConnectionIssuesView( } } -private val outerRing = DashedRingSpec( - radiusFraction = 0.60f, - color = Colors.Yellow.copy(alpha = 0.08f), -) - -private val innerRings = listOf( - DashedRingSpec(radiusFraction = 0.15f, color = Colors.Yellow.copy(alpha = 0.4f)), - DashedRingSpec(radiusFraction = 0.30f, color = Colors.Yellow.copy(alpha = 0.25f)), - DashedRingSpec(radiusFraction = 0.45f, color = Colors.Yellow.copy(alpha = 0.15f)), -) +private val outerRingRadii = listOf(200f) +private val innerRingRadii = listOf(150f, 100f, 50f) @Composable private fun DashedRingsLayer(outerOnly: Boolean, modifier: Modifier = Modifier) { - val rings = if (outerOnly) listOf(outerRing) else innerRings + val radii = if (outerOnly) outerRingRadii else innerRingRadii Canvas(modifier = modifier.fillMaxSize()) { val center = Offset(size.width * 0.25f, size.height * 0.40f) - - if (outerOnly) { - val fadeRadius = size.minDimension * 0.45f - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Colors.White.copy(alpha = 0.06f), Color.Transparent), - center = center, - radius = fadeRadius, - ), - radius = fadeRadius, - center = center, - ) - } - - rings.forEach { ring -> drawDashedRing(ring, center) } + radii.forEach { radiusDp -> drawDashedGradientRing(radiusDp, center) } } } -private fun DrawScope.drawDashedRing(ring: DashedRingSpec, center: Offset) { +private fun DrawScope.drawDashedGradientRing(radiusDp: Float, center: Offset) { + val radius = radiusDp.dp.toPx() + val brush = Brush.linearGradient( + colors = listOf(Color.Black, Colors.Yellow), + start = Offset(center.x - radius, center.y - radius), + end = Offset(center.x + radius, center.y + radius), + ) drawCircle( - color = ring.color, - radius = size.minDimension * ring.radiusFraction, + brush = brush, + radius = radius, center = center, style = Stroke( width = 1.dp.toPx(), @@ -143,11 +128,6 @@ private fun DrawScope.drawDashedRing(ring: DashedRingSpec, center: Offset) { ) } -private data class DashedRingSpec( - val radiusFraction: Float, - val color: Color, -) - @Preview(showSystemUi = true) @Composable private fun Preview() { From 9f3c1f93e896973a42f870ed2fea0b85f390b0bc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 06:33:40 -0300 Subject: [PATCH 07/18] feat: display connection issues screen in transfer flows --- app/src/main/java/to/bitkit/ui/ContentView.kt | 8 ++- .../ui/components/ConnectionIssuesView.kt | 3 + .../screens/transfer/SavingsConfirmScreen.kt | 44 ++++++++---- .../screens/transfer/SpendingAmountScreen.kt | 72 ++++++++++++------- .../screens/transfer/SpendingConfirmScreen.kt | 43 +++++++---- 5 files changed, 116 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index a653e929c..073fb1767 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -577,7 +577,9 @@ private fun RootNavHost( ) } composableWithDefaultTransitions { + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SavingsConfirmScreen( + isOffline = connectivityState != ConnectivityState.CONNECTED, onConfirm = { navController.navigateTo(Routes.SavingsProgress) }, onAdvancedClick = { navController.navigateTo(Routes.SavingsAdvanced) }, onBackClick = { navController.popBackStack() }, @@ -608,8 +610,10 @@ private fun RootNavHost( ) } composableWithDefaultTransitions { + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingAmountScreen( viewModel = transferViewModel, + isOffline = connectivityState != ConnectivityState.CONNECTED, onBackClick = { navController.popBackStack() }, onOrderCreated = { navController.navigateTo(Routes.SpendingConfirm) }, toastException = { appViewModel.toast(it) }, @@ -617,14 +621,16 @@ private fun RootNavHost( appViewModel.toast( type = Toast.ToastType.ERROR, title = title, - description = description + description = description, ) }, ) } composableWithDefaultTransitions { + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingConfirmScreen( viewModel = transferViewModel, + isOffline = connectivityState != ConnectivityState.CONNECTED, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, onLearnMoreClick = { navController.navigateTo(Routes.TransferLiquidity) }, diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index 7d91930b5..2baf85883 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -9,6 +9,7 @@ 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.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,11 +36,13 @@ import to.bitkit.ui.utils.withAccent fun ConnectionIssuesView( titleText: String, modifier: Modifier = Modifier, + includeStatusBarPadding: Boolean = false, ) { Column( modifier = modifier .fillMaxSize() .gradientBackground() + .then(if (includeStatusBarPadding) Modifier.statusBarsPadding() else Modifier) .navigationBarsPadding() .padding(horizontal = 16.dp) .testTag("ConnectionIssueView"), diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index da45ad90f..c15803a5d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -1,6 +1,10 @@ package to.bitkit.ui.screens.transfer +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -30,6 +34,7 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.filterOpen import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.Display import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.PrimaryButton @@ -46,6 +51,7 @@ import to.bitkit.ui.walletViewModel @Composable fun SavingsConfirmScreen( + isOffline: Boolean, onConfirm: () -> Unit, onAdvancedClick: () -> Unit, onBackClick: () -> Unit, @@ -70,19 +76,31 @@ fun SavingsConfirmScreen( val amount = channels.sumOf { it.amountOnClose } - SavingsConfirmContent( - amount = amount, - hasMultiple = hasMultiple, - hasSelected = hasSelected, - onBackClick = onBackClick, - onAmountClick = { currency.switchUnit() }, - onAdvancedClick = onAdvancedClick, - onSelectAllClick = { transfer.setSelectedChannelIds(emptySet()) }, - onConfirm = { - transfer.onTransferToSavingsConfirm(channels) - onConfirm() - }, - ) + Box { + SavingsConfirmContent( + amount = amount, + hasMultiple = hasMultiple, + hasSelected = hasSelected, + onBackClick = onBackClick, + onAmountClick = { currency.switchUnit() }, + onAdvancedClick = onAdvancedClick, + onSelectAllClick = { transfer.setSelectedChannelIds(emptySet()) }, + onConfirm = { + transfer.onTransferToSavingsConfirm(channels) + onConfirm() + }, + ) + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView( + titleText = stringResource(R.string.lightning__transfer__nav_title), + includeStatusBarPadding = true, + ) + } + } } @Suppress("MagicNumber") diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index d313421c2..b8b94bd88 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -1,6 +1,10 @@ package to.bitkit.ui.screens.transfer +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -23,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -52,6 +57,7 @@ import kotlin.math.min @Composable fun SpendingAmountScreen( viewModel: TransferViewModel, + isOffline: Boolean, onBackClick: () -> Unit = {}, onOrderCreated: () -> Unit = {}, toastException: (Throwable) -> Unit, @@ -78,33 +84,45 @@ fun SpendingAmountScreen( } } - Content( - isNodeRunning = isNodeRunning, - uiState = uiState, - amountInputViewModel = amountInputViewModel, - currencies = currencies, - onBackClick = onBackClick, - onClickQuarter = { - val quarter = uiState.balanceAfterFeeQuarter() - val max = uiState.maxAllowedToSend - if (quarter > max) { - toast( - context.getString(R.string.lightning__spending_amount__error_max__title), - context.getString(R.string.lightning__spending_amount__error_max__description) - .replace("{amount}", "$max"), - ) - } - val cappedQuarter = min(quarter, max) - viewModel.updateLimits(cappedQuarter) - amountInputViewModel.setSats(cappedQuarter, currencies) - }, - onClickMaxAmount = { - val newAmountSats = uiState.maxAllowedToSend - viewModel.updateLimits(newAmountSats) - amountInputViewModel.setSats(newAmountSats, currencies) - }, - onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.sats) }, - ) + Box { + Content( + isNodeRunning = isNodeRunning, + uiState = uiState, + amountInputViewModel = amountInputViewModel, + currencies = currencies, + onBackClick = onBackClick, + onClickQuarter = { + val quarter = uiState.balanceAfterFeeQuarter() + val max = uiState.maxAllowedToSend + if (quarter > max) { + toast( + context.getString(R.string.lightning__spending_amount__error_max__title), + context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", "$max"), + ) + } + val cappedQuarter = min(quarter, max) + viewModel.updateLimits(cappedQuarter) + amountInputViewModel.setSats(cappedQuarter, currencies) + }, + onClickMaxAmount = { + val newAmountSats = uiState.maxAllowedToSend + viewModel.updateLimits(newAmountSats) + amountInputViewModel.setSats(newAmountSats, currencies) + }, + onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.sats) }, + ) + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView( + titleText = stringResource(R.string.lightning__transfer__nav_title), + includeStatusBarPadding = true, + ) + } + } } @Suppress("ViewModelForwarding") diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index ebdf4766f..629455c0e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -1,5 +1,8 @@ package to.bitkit.ui.screens.transfer +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -45,6 +48,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.ChannelStatusUi +import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.Display import to.bitkit.ui.components.FeeInfo import to.bitkit.ui.components.FillHeight @@ -68,6 +72,7 @@ import to.bitkit.viewmodels.TransferViewModel @Composable fun SpendingConfirmScreen( viewModel: TransferViewModel, + isOffline: Boolean, onBackClick: () -> Unit = {}, onCloseClick: () -> Unit = {}, onLearnMoreClick: () -> Unit = {}, @@ -91,21 +96,33 @@ fun SpendingConfirmScreen( onPermissionChange = { granted -> settingsViewModel.setNotificationPreference(granted) }, - showPermissionDialog = false + showPermissionDialog = false, ) - Content( - onBackClick = onBackClick, - onLearnMoreClick = onLearnMoreClick, - onAdvancedClick = onAdvancedClick, - onConfirm = onConfirm, - onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, - onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, - order = order, - hasNotificationPermission = notificationsGranted, - onSwitchClick = { context.openNotificationSettings() }, - isAdvanced = isAdvanced, - ) + Box { + Content( + onBackClick = onBackClick, + onLearnMoreClick = onLearnMoreClick, + onAdvancedClick = onAdvancedClick, + onConfirm = onConfirm, + onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, + onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, + order = order, + hasNotificationPermission = notificationsGranted, + onSwitchClick = { context.openNotificationSettings() }, + isAdvanced = isAdvanced, + ) + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView( + titleText = stringResource(R.string.lightning__transfer__nav_title), + includeStatusBarPadding = true, + ) + } + } } @Suppress("MagicNumber") From 46f955f84b631b2e200b8d615d71f89a1e9a97d4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 06:40:08 -0300 Subject: [PATCH 08/18] refactor: remove unnecessary parameter --- .../main/java/to/bitkit/ui/components/ConnectionIssuesView.kt | 3 --- .../java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt | 3 ++- .../java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 3 ++- .../to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt | 3 ++- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index 2baf85883..7d91930b5 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -9,7 +9,6 @@ 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.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,13 +35,11 @@ import to.bitkit.ui.utils.withAccent fun ConnectionIssuesView( titleText: String, modifier: Modifier = Modifier, - includeStatusBarPadding: Boolean = false, ) { Column( modifier = modifier .fillMaxSize() .gradientBackground() - .then(if (includeStatusBarPadding) Modifier.statusBarsPadding() else Modifier) .navigationBarsPadding() .padding(horizontal = 16.dp) .testTag("ConnectionIssueView"), diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index c15803a5d..a756a320a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -97,7 +98,7 @@ fun SavingsConfirmScreen( ) { ConnectionIssuesView( titleText = stringResource(R.string.lightning__transfer__nav_title), - includeStatusBarPadding = true, + modifier = Modifier.statusBarsPadding(), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index b8b94bd88..28d828080 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -119,7 +120,7 @@ fun SpendingAmountScreen( ) { ConnectionIssuesView( titleText = stringResource(R.string.lightning__transfer__nav_title), - includeStatusBarPadding = true, + modifier = Modifier.statusBarsPadding(), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index 629455c0e..7bc62dca1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -119,7 +120,7 @@ fun SpendingConfirmScreen( ) { ConnectionIssuesView( titleText = stringResource(R.string.lightning__transfer__nav_title), - includeStatusBarPadding = true, + modifier = Modifier.statusBarsPadding(), ) } } From a5fde6daf4fe730f3476da3a358061571227bb06 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 14:49:50 -0300 Subject: [PATCH 09/18] fix: re-trigger updateLimits when switch to online --- .../java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index 28d828080..d2ed7e081 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -71,7 +71,7 @@ fun SpendingAmountScreen( val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - LaunchedEffect(Unit) { + LaunchedEffect(isOffline) { viewModel.updateLimits() } From 70f1112bead48da2175f30fdd6c51fce7b4ca9d4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 09:29:15 -0300 Subject: [PATCH 10/18] fix: not populated LN balance on canSend fallback --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 04bde8c44..6ee1dacbc 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1186,7 +1186,10 @@ class LightningRepo @Inject constructor( return@withContext false } if (_lightningState.value.nodeLifecycleState.isStarting() && fallbackToCachedBalance) { - return@withContext amountSats <= (cacheStore.data.first().balance?.maxSendLightningSats ?: 0u) + val cached = cacheStore.data.first().balance + val maxSend = cached?.maxSendLightningSats ?: 0u + val totalLn = cached?.totalLightningSats ?: 0u + return@withContext amountSats <= maxOf(maxSend, totalLn) } if (lightningService.channels == null) { withTimeoutOrNull(CHANNELS_READY_TIMEOUT_MS) { From db7d466c17ab7c016ebdcdd0e2c3abe969f8e589 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 09:49:31 -0300 Subject: [PATCH 11/18] fix: add spacer --- .../main/java/to/bitkit/ui/components/ConnectionIssuesView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index 7d91930b5..b88ebf05d 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -45,6 +45,7 @@ fun ConnectionIssuesView( .testTag("ConnectionIssueView"), ) { SheetTopBar(titleText = titleText) + VerticalSpacer(24.dp) Box( contentAlignment = Alignment.Center, From 8b11ea796bcdc7f82f1e790258e9bd163c2f8732 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 10:08:20 -0300 Subject: [PATCH 12/18] fix: fallback to cached balance for validation when channels are loading --- .../to/bitkit/repositories/LightningRepo.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 6ee1dacbc..5333ea210 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1186,17 +1186,24 @@ class LightningRepo @Inject constructor( return@withContext false } if (_lightningState.value.nodeLifecycleState.isStarting() && fallbackToCachedBalance) { - val cached = cacheStore.data.first().balance - val maxSend = cached?.maxSendLightningSats ?: 0u - val totalLn = cached?.totalLightningSats ?: 0u - return@withContext amountSats <= maxOf(maxSend, totalLn) + return@withContext amountSats <= cachedLightningBalance() } if (lightningService.channels == null) { withTimeoutOrNull(CHANNELS_READY_TIMEOUT_MS) { _lightningState.first { lightningService.channels != null } } } - return@withContext lightningService.canSend(amountSats) + if (lightningService.canSend(amountSats)) return@withContext true + val channelsLoading = lightningService.channels?.none { it.isUsable } == true + if (fallbackToCachedBalance && channelsLoading) { + return@withContext amountSats <= cachedLightningBalance() + } + return@withContext false + } + + private suspend fun cachedLightningBalance(): ULong { + val cached = cacheStore.data.first().balance + return maxOf(cached?.maxSendLightningSats ?: 0u, cached?.totalLightningSats ?: 0u) } fun getNodeId(): String? = From 5f3da5c03d9db05a65b6cf4597888c9f8bb8b521 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 10:31:45 -0300 Subject: [PATCH 13/18] fix: isFirstEmission check --- app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt b/app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt index 0ef916d3f..971a0bb55 100644 --- a/app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt +++ b/app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt @@ -24,7 +24,7 @@ fun IsOnlineTracker( LaunchedEffect(connectivityState) { // Skip the first emission to prevent toast on startup if (isFirstEmission) { - setIsFirstEmission(true) + setIsFirstEmission(false) return@LaunchedEffect } From eec1e8e06375d365f86d3c93229808f5b7d78aa8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 11:07:00 -0300 Subject: [PATCH 14/18] fix: await for peer connection before try to close --- .../main/java/to/bitkit/repositories/LightningRepo.kt | 10 ++++++++++ .../java/to/bitkit/viewmodels/TransferViewModel.kt | 1 + 2 files changed, 11 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 5333ea210..8f4f0d043 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1181,6 +1181,16 @@ class LightningRepo @Inject constructor( } } + suspend fun awaitPeerConnected(timeout: Duration = 30.seconds) = withContext(bgDispatcher) { + if (lightningService.peers?.any { it.isConnected } == true) return@withContext + Logger.debug("Waiting for peer to reconnect (timeout=$timeout)...", context = TAG) + withTimeoutOrNull(timeout) { + while (lightningService.peers?.any { it.isConnected } != true) { + delay(1.seconds) + } + } + } + suspend fun canSend(amountSats: ULong, fallbackToCachedBalance: Boolean = true) = withContext(bgDispatcher) { if (!_lightningState.value.nodeLifecycleState.canRun()) { return@withContext false diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 2391e7ca4..446086603 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -460,6 +460,7 @@ class TransferViewModel @Inject constructor( ): Pair, List> = lightningRepo.separateTrustedChannels(channels) private suspend fun closeChannels(channels: List): List { + lightningRepo.awaitPeerConnected() val channelsFailedToClose = coroutineScope { channels.map { channel -> async { From 20154dc48d2b35e92e2dff9109de9f128c7e6fde Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 11:57:25 -0300 Subject: [PATCH 15/18] doc: changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ff3030b6..1a233fb20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Fix unified invoices only showing savings option after reconnecting from offline #878 +- Fix connectivity toasts never appearing due to incorrect state flag #878 +- Fix transfer to savings failing when peer not yet reconnected after internet toggle #878 - Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 - Fix crash when returning app to foreground on Receive screen #875 - Show loading state on Spending tab when node is not running #875 ### Added +- Connection issues overlay view across Send, Receive, and Transfer flows #878 - Show/hide details toggle on send confirmation screen with coin-stack animation #863 - "Send from" payment method switcher (Savings/Spending) for unified BIP21 payments #863 - "Instant" Lightning option on fee rate selection screen for unified payments #863 From 4e7e2a2d33fb476d1cec24321764346c851f723e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 11:57:43 -0300 Subject: [PATCH 16/18] chore: lint --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 2 +- .../java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt | 2 +- .../java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 2 +- .../java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index ab792bd4e..502ba43f1 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1196,7 +1196,7 @@ class LightningRepo @Inject constructor( suspend fun awaitPeerConnected(timeout: Duration = 30.seconds) = withContext(bgDispatcher) { if (lightningService.peers?.any { it.isConnected } == true) return@withContext - Logger.debug("Waiting for peer to reconnect (timeout=$timeout)...", context = TAG) + Logger.debug("Waiting for peer to reconnect (timeout='$timeout')...", context = TAG) withTimeoutOrNull(timeout) { while (lightningService.peers?.any { it.isConnected } != true) { delay(1.seconds) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index a756a320a..6359ef1a6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -98,7 +98,7 @@ fun SavingsConfirmScreen( ) { ConnectionIssuesView( titleText = stringResource(R.string.lightning__transfer__nav_title), - modifier = Modifier.statusBarsPadding(), + modifier = Modifier.statusBarsPadding() ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index d2ed7e081..7de5e1bb8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -120,7 +120,7 @@ fun SpendingAmountScreen( ) { ConnectionIssuesView( titleText = stringResource(R.string.lightning__transfer__nav_title), - modifier = Modifier.statusBarsPadding(), + modifier = Modifier.statusBarsPadding() ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index 7bc62dca1..f5da7dc42 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -120,7 +120,7 @@ fun SpendingConfirmScreen( ) { ConnectionIssuesView( titleText = stringResource(R.string.lightning__transfer__nav_title), - modifier = Modifier.statusBarsPadding(), + modifier = Modifier.statusBarsPadding() ) } } From 5caf2b3f3fb9151f572f84dbc59ab88753a462c3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 13:29:19 -0300 Subject: [PATCH 17/18] doc: consolidate changelog entries --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d117d57..a3cf98ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- Fix unified invoices only showing savings option after reconnecting from offline #878 -- Fix connectivity toasts never appearing due to incorrect state flag #878 -- Fix transfer to savings failing when peer not yet reconnected after internet toggle #878 - Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 - Fix crash when returning app to foreground on Receive screen #875 - Show loading state on Spending tab when node is not running #875 ### Added -- Connection issues overlay view across Send, Receive, and Transfer flows #878 +- Connection issues overlay with connectivity fixes across Send, Receive, and Transfer flows #878 - Show/hide details toggle on send confirmation screen with coin-stack animation #863 - "Send from" payment method switcher (Savings/Spending) for unified BIP21 payments #863 - "Instant" Lightning option on fee rate selection screen for unified payments #863 From e3d6b4f2bb1458be247dc539f506b9de174ae155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Sena?= Date: Thu, 2 Apr 2026 14:06:41 -0300 Subject: [PATCH 18/18] Update app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../main/java/to/bitkit/ui/components/ConnectionIssuesView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt index b88ebf05d..42eaf85b7 100644 --- a/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -70,7 +70,7 @@ fun ConnectionIssuesView( Display( text = stringResource(R.string.other__connection_issues_title) .withAccent(accentColor = Colors.Yellow), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) VerticalSpacer(8.dp)