diff --git a/CHANGELOG.md b/CHANGELOG.md index db4df41e2..eb03fa31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- Fix Spending and Savings screens scrolling behind top bar and add gradient fade effect #892 ### Changed - Improve Pubky profile restore, contact editing, and contact routing flows #905 @@ -25,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show loading state on Spending tab when node is not running #875 ### Added +- Transfer from Savings button on empty Spending screen when savings balance exists #882 - Pubky profile onboarding with contact sync, import, and editing #824 - Lightning Connections empty state with onboarding screen #857 - Unified PIN management screen (enable/disable/change in one place) #857 diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 3a1c0bf63..eddab2ffd 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -246,7 +246,7 @@ internal object Env { @Suppress("ConstPropertyName") object Defaults { /** Default Bolt11 invoice expiry in seconds. */ - const val bolt11InvoiceExpirySeconds = 3_600u + const val bolt11ExpirySec = 86_400u /** Recommended transaction base fee in sats */ const val recommendedBaseFee = 256u diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index d5c7b44d4..348cda76e 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -41,7 +41,6 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.di.BgDispatcher -import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.nowTimestamp @@ -57,6 +56,7 @@ import javax.inject.Named import javax.inject.Singleton import kotlin.math.ceil import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds @Singleton @@ -463,7 +463,7 @@ class BlocktankRepo @Inject constructor( val invoice = lightningRepo.createInvoice( amountSats = null, description = "blocktank-gift-code:$code", - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = 1.hours.inWholeSeconds.toUInt(), ).getOrThrow() Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 76bc4f89c..2eb6a1ebd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -58,10 +58,12 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toPeerDetailsList +import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NATIVE_WITNESS_TYPES @@ -93,6 +95,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -151,6 +154,10 @@ class LightningRepo @Inject constructor( return@collect } + if (_lightningState.value.nodeLifecycleState.isRunning()) { + connectToTrustedPeers() + } + // Start retry loop if sync is failing startSyncRetryLoopIfNeeded() } @@ -533,7 +540,11 @@ class LightningRepo @Inject constructor( private fun handleLdkEvent(event: Event) { when (event) { - is Event.ChannelPending, is Event.ChannelReady -> scope.launch { refreshChannelCache() } + is Event.ChannelPending, is Event.ChannelReady -> scope.launch { + refreshChannelCache() + syncState() + } + is Event.ChannelClosed -> scope.launch { registerClosedChannel(event.channelId, event.reason) } else -> Unit } @@ -917,7 +928,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoice( amountSats: ULong? = null, description: String, - expirySeconds: UInt = 86_400u, + expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() runCatching { lightningService.receive(amountSats, description, expirySeconds) } @@ -926,7 +937,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoiceMsats( amountMsats: ULong, description: String, - expirySeconds: UInt = 86_400u, + expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { updateGeoBlockState() runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } @@ -1009,15 +1020,69 @@ class LightningRepo @Inject constructor( } } - private suspend fun waitForUsableChannels() { - if (lightningService.channels?.any { it.isUsable } == true) return + suspend fun waitForUsableChannels() = withContext(bgDispatcher) { + var state = _lightningState.value + if (!state.nodeLifecycleState.canRun()) { + delayNoUsableChannelsFeedback() + return@withContext + } + if (state.hasUsableChannels()) return@withContext + + state = waitForChannelsToLoadIfNeeded(state) ?: return@withContext + if (!state.nodeLifecycleState.canRun()) { + delayNoUsableChannelsFeedback() + return@withContext + } + + if (state.channels.isEmpty()) { + if (state.nodeLifecycleState.isRunning()) { + syncState() + state = _lightningState.value + } + + if (state.channels.isEmpty()) { + delayNoUsableChannelsFeedback() + return@withContext + } + if (state.hasUsableChannels()) return@withContext + } Logger.info("Waiting for usable channels before sending payment", context = TAG) - syncState() - withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT_MS) { - _lightningState.first { state -> state.channels.any { it.isUsable } } - } ?: Logger.warn("Timeout waiting for usable channels", context = TAG) + val finalState = withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT) { + _lightningState.first { it.shouldStopWaitingForUsableChannels() } + } ?: run { + Logger.warn("Timed out waiting for usable channels", context = TAG) + return@withContext + } + + if (!finalState.nodeLifecycleState.canRun() || finalState.channels.isEmpty()) { + delayNoUsableChannelsFeedback() + } + } + + private suspend fun waitForChannelsToLoadIfNeeded(state: LightningState): LightningState? { + if (state.channels.isNotEmpty() || state.nodeLifecycleState.isRunning()) return state + + Logger.info("Waiting for node to load channels before sending payment", context = TAG) + return withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT) { + _lightningState.first { it.shouldStopWaitingForLoadedChannels() } + } ?: run { + Logger.warn("Timed out waiting for node to load channels", context = TAG) + null + } + } + + private fun LightningState.hasUsableChannels() = channels.any { it.isUsable } + + private fun LightningState.shouldStopWaitingForLoadedChannels() = + !nodeLifecycleState.canRun() || nodeLifecycleState.isRunning() || channels.isNotEmpty() + + private fun LightningState.shouldStopWaitingForUsableChannels() = + !nodeLifecycleState.canRun() || channels.isEmpty() || hasUsableChannels() + + private suspend fun delayNoUsableChannelsFeedback() { + delay(NO_USABLE_CHANNELS_FEEDBACK_DELAY) } @Suppress("LongParameterList") @@ -1229,19 +1294,20 @@ class LightningRepo @Inject constructor( } } - suspend fun canSend(amountSats: ULong, fallbackToCachedBalance: Boolean = true) = withContext(bgDispatcher) { - if (!_lightningState.value.nodeLifecycleState.canRun()) { - return@withContext false - } - if (_lightningState.value.nodeLifecycleState.isStarting() && fallbackToCachedBalance) { - return@withContext amountSats <= (cacheStore.data.first().balance?.maxSendLightningSats ?: 0u) - } - if (lightningService.channels == null) { - withTimeoutOrNull(CHANNELS_READY_TIMEOUT_MS) { - _lightningState.first { lightningService.channels != null } + 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) } } - return@withContext lightningService.canSend(amountSats) + } + + fun canSend(amountSats: ULong): Boolean { + val state = _lightningState.value + if (!state.nodeLifecycleState.canRun()) return false + return state.channels.totalNextOutboundHtlcLimitSats() >= amountSats } fun getNodeId(): String? = @@ -1478,8 +1544,8 @@ class LightningRepo @Inject constructor( private const val LENGTH_CHANNEL_ID_PREVIEW = 10 private const val MS_SYNC_LOOP_DEBOUNCE = 500L private const val SYNC_RETRY_DELAY_MS = 15_000L - private const val CHANNELS_READY_TIMEOUT_MS = 15_000L - private const val CHANNELS_USABLE_TIMEOUT_MS = 15_000L + private val CHANNELS_USABLE_TIMEOUT = 15.seconds + private val NO_USABLE_CHANNELS_FEEDBACK_DELAY = 2_500.milliseconds val SEND_LN_TIMEOUT = 10.seconds private val PROBE_TIMEOUT = 60.seconds } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 95f9f43aa..8baa73bb5 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -70,6 +70,7 @@ import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.amountSats import to.bitkit.ext.channelId @@ -1523,11 +1524,15 @@ class BlocktankService( ) } - suspend fun regtestCloseChannel(fundingTxId: String, vout: UInt, forceCloseAfterS: ULong = 86_400uL): String { + suspend fun regtestCloseChannel( + fundingTxId: String, + vout: UInt, + forceCloseAfterS: UInt = Defaults.bolt11ExpirySec, + ): String { return com.synonym.bitkitcore.regtestCloseChannel( fundingTxId = fundingTxId, vout = vout, - forceCloseAfterS = forceCloseAfterS, + forceCloseAfterS = forceCloseAfterS.toULong(), ) } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 977a928b8..979bb6459 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -48,7 +48,6 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env -import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList import to.bitkit.ext.uri import to.bitkit.models.OpenChannelResult @@ -596,7 +595,7 @@ class LightningService @Inject constructor( suspend fun receive( sat: ULong? = null, description: String, - expirySecs: UInt = Defaults.bolt11InvoiceExpirySeconds, + expirySecs: UInt = Defaults.bolt11ExpirySec, ): String { return receiveMsats(amountMsat = sat?.let { it * 1000u }, description = description, expirySecs = expirySecs) } @@ -604,7 +603,7 @@ class LightningService @Inject constructor( suspend fun receiveMsats( amountMsat: ULong? = null, description: String, - expirySecs: UInt = Defaults.bolt11InvoiceExpirySeconds, + expirySecs: UInt = Defaults.bolt11ExpirySec, ): String { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -630,23 +629,6 @@ class LightningService @Inject constructor( } } - fun canSend(amountSats: ULong): Boolean { - val channels = this.channels - if (channels == null) { - Logger.warn("Channels not available", context = TAG) - return false - } - - val totalNextOutboundHtlcLimitSats = channels.totalNextOutboundHtlcLimitSats() - - if (totalNextOutboundHtlcLimitSats < amountSats) { - Logger.warn("Insufficient outbound capacity: $totalNextOutboundHtlcLimitSats < $amountSats", context = TAG) - return false - } - - return true - } - suspend fun send( address: Address, sats: ULong, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ea0241ad4..4b2d36b74 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 @@ -194,6 +195,7 @@ import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QrScanningSheet import to.bitkit.ui.sheets.QuickPayIntroSheet +import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.SendSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.utils.AutoReadClipboardHandler @@ -410,13 +412,15 @@ fun ContentView( is Sheet.Receive -> { val walletState by walletViewModel.walletState.collectAsStateWithLifecycle() + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() ReceiveSheet( startRoute = sheet.route, walletState = walletState, + isOffline = connectivityState != ConnectivityState.CONNECTED, navigateToExternalConnection = { navController.navigateTo(ExternalConnection()) appViewModel.hideSheet() - } + }, ) } @@ -516,7 +520,7 @@ fun ContentView( TabBar( onSendClick = { appViewModel.showSheet(Sheet.Send()) }, onReceiveClick = { appViewModel.showSheet(Sheet.Receive()) }, - onScanClick = { appViewModel.showScannerSheet() }, + onScanClick = { appViewModel.showSheet(Sheet.Send(SendRoute.QrScanner)) }, ) } } @@ -623,7 +627,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() }, @@ -654,8 +660,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) }, @@ -663,14 +671,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) }, @@ -854,6 +864,7 @@ private fun NavGraphBuilder.home( } composableWithDefaultTransitions { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() + val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() @@ -870,6 +881,13 @@ private fun NavGraphBuilder.home( navController.navigateToTransferSavingsAvailability() } }, + onTransferFromSavingsClick = { + if (!hasSeenSpendingIntro) { + navController.navigateToTransferSpendingIntro() + } else { + navController.navigateToTransferSpendingAmount() + } + }, onBackClick = { navController.popBackStack() }, ) } 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..247909b49 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt @@ -0,0 +1,140 @@ +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.Brush +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) + VerticalSpacer(24.dp) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + DashedRingsLayer(outerOnly = true) + + Image( + painter = painterResource(R.drawable.phone), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .size(311.dp) + .align(Alignment.Center) + ) + + 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 outerRingRadii = listOf(200f) +private val innerRingRadii = listOf(150f, 100f, 50f) + +@Composable +private fun DashedRingsLayer(outerOnly: Boolean, modifier: Modifier = Modifier) { + val radii = if (outerOnly) outerRingRadii else innerRingRadii + Canvas(modifier = modifier.fillMaxSize()) { + val center = Offset(size.width * 0.25f, size.height * 0.40f) + radii.forEach { radiusDp -> drawDashedGradientRing(radiusDp, center) } + } +} + +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( + brush = brush, + radius = radius, + center = center, + style = Stroke( + width = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(8.dp.toPx(), 6.dp.toPx()), + ), + ), + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + ConnectionIssuesView(titleText = "Send Bitcoin") + } + } +} 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 } 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..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 @@ -1,12 +1,17 @@ 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 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 @@ -30,6 +35,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 +52,7 @@ import to.bitkit.ui.walletViewModel @Composable fun SavingsConfirmScreen( + isOffline: Boolean, onConfirm: () -> Unit, onAdvancedClick: () -> Unit, onBackClick: () -> Unit, @@ -70,19 +77,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), + modifier = Modifier.statusBarsPadding() + ) + } + } } @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..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 @@ -1,11 +1,16 @@ 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 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 @@ -23,6 +28,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 +58,7 @@ import kotlin.math.min @Composable fun SpendingAmountScreen( viewModel: TransferViewModel, + isOffline: Boolean, onBackClick: () -> Unit = {}, onOrderCreated: () -> Unit = {}, toastException: (Throwable) -> Unit, @@ -64,7 +71,7 @@ fun SpendingAmountScreen( val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - LaunchedEffect(Unit) { + LaunchedEffect(isOffline) { viewModel.updateLimits() } @@ -78,33 +85,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), + modifier = Modifier.statusBarsPadding() + ) + } + } } @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..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 @@ -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 @@ -10,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 @@ -45,6 +49,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 +73,7 @@ import to.bitkit.viewmodels.TransferViewModel @Composable fun SpendingConfirmScreen( viewModel: TransferViewModel, + isOffline: Boolean, onBackClick: () -> Unit = {}, onCloseClick: () -> Unit = {}, onLearnMoreClick: () -> Unit = {}, @@ -91,21 +97,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), + modifier = Modifier.statusBarsPadding() + ) + } + } } @Suppress("MagicNumber") 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 3be209061..df3151119 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 @@ -53,7 +53,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -145,6 +144,7 @@ import to.bitkit.ui.sheets.PinRoute import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Insets +import to.bitkit.ui.theme.TopBarGradient import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel @@ -837,20 +837,13 @@ private fun TopBar( onNavigateToAppStatus: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, ) { - val topbarGradient = Brush.verticalGradient( - colorStops = arrayOf( - 0.5f to Colors.Black, - 1.0f to Color.Transparent, - ) - ) - Box( modifier = Modifier .fillMaxWidth() .hazeEffect(state = hazeState) { - mask = topbarGradient + mask = TopBarGradient } - .background(topbarGradient) + .background(TopBarGradient) .zIndex(1f) ) { TopAppBar( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 980581b0d..45efea812 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -3,13 +3,10 @@ package to.bitkit.ui.screens.wallets import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -17,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -30,7 +28,9 @@ 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 androidx.compose.ui.zIndex import com.synonym.bitkitcore.Activity +import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList @@ -42,15 +42,18 @@ import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.IncomingTransfer import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.StatusBarSpacer import to.bitkit.ui.components.TabBar +import to.bitkit.ui.components.TopBarSpacer +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.wallets.activity.components.ActivityListGrouped +import to.bitkit.ui.screens.wallets.activity.components.activityListGroupedItems import to.bitkit.ui.screens.wallets.activity.utils.previewOnchainActivityItems import to.bitkit.ui.shared.util.blockPointerInputPassthrough import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.TopBarGradient import to.bitkit.ui.utils.withAccent @Composable @@ -76,6 +79,7 @@ fun SavingsWalletScreen( } val hazeState = rememberHazeState() + Box( modifier = Modifier .fillMaxSize() @@ -101,18 +105,31 @@ fun SavingsWalletScreen( .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Vertical)) ) } - ScreenColumn(noBackground = true) { - AppTopBar( - titleText = stringResource(R.string.wallet__savings__title), - icon = R.drawable.ic_btc_circle, - onBackClick = onBackClick, - actions = { - DrawerNavIcon() + + AppTopBar( + titleText = stringResource(R.string.wallet__savings__title), + icon = R.drawable.ic_btc_circle, + onBackClick = onBackClick, + actions = { + DrawerNavIcon() + }, + modifier = Modifier + .hazeEffect(state = hazeState) { + mask = TopBarGradient } - ) - Column( - modifier = Modifier.padding(horizontal = 16.dp) - ) { + .background(TopBarGradient) + .zIndex(1f) + .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Vertical)) + ) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + item { StatusBarSpacer() } + item { TopBarSpacer() } + item { BalanceHeaderView( sats = balances.totalOnchainSats.toLong(), testTag = "TotalBalance", @@ -120,19 +137,23 @@ fun SavingsWalletScreen( .fillMaxWidth() .testTag("TotalBalance") ) + } - if (balances.balanceInTransferToSavings > 0u) { + if (balances.balanceInTransferToSavings > 0u) { + item { IncomingTransfer( amount = balances.balanceInTransferToSavings, remainingDuration = forceCloseRemainingDuration, modifier = Modifier.padding(vertical = 8.dp) ) } + } - if (!showEmptyState) { - Spacer(modifier = Modifier.height(32.dp)) + if (!showEmptyState) { + item { VerticalSpacer(32.dp) } - if (canTransfer) { + if (canTransfer) { + item { SecondaryButton( onClick = onTransferToSpendingClick, text = stringResource(R.string.wallet__transfer_to_spending), @@ -147,17 +168,18 @@ fun SavingsWalletScreen( modifier = Modifier.testTag("TransferToSpending") ) } - - ActivityListGrouped( - items = onchainActivities, - onActivityItemClick = onActivityItemClick, - onEmptyActivityRowClick = onEmptyActivityRowClick, - showFooter = true, - onAllActivityButtonClick = onAllActivityButtonClick, - ) } + + activityListGroupedItems( + items = onchainActivities, + onActivityItemClick = onActivityItemClick, + onEmptyActivityRowClick = onEmptyActivityRowClick, + showFooter = true, + onAllActivityButtonClick = onAllActivityButtonClick, + ) } } + if (showEmptyState) { EmptyStateView( text = stringResource(R.string.wallet__savings__onboarding).withAccent(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 48de07762..2cedaeafb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -3,7 +3,6 @@ package to.bitkit.ui.screens.wallets import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -28,7 +28,9 @@ 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 androidx.compose.ui.zIndex import com.synonym.bitkitcore.Activity +import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList @@ -42,16 +44,18 @@ import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.IncomingTransfer import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.StatusBarSpacer import to.bitkit.ui.components.TabBar +import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.wallets.activity.components.ActivityListGrouped +import to.bitkit.ui.screens.wallets.activity.components.activityListGroupedItems import to.bitkit.ui.screens.wallets.activity.utils.previewLightningActivityItems import to.bitkit.ui.shared.util.blockPointerInputPassthrough import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.TopBarGradient import to.bitkit.ui.utils.withAccent @Composable @@ -62,6 +66,7 @@ fun SpendingWalletScreen( onActivityItemClick: (String) -> Unit, onEmptyActivityRowClick: () -> Unit, onTransferToSavingsClick: () -> Unit, + onTransferFromSavingsClick: () -> Unit, onBackClick: () -> Unit, balances: BalanceState = LocalBalances.current, ) { @@ -75,8 +80,11 @@ fun SpendingWalletScreen( val hasChannels = channels.isNotEmpty() mutableStateOf(hasLnBalance && hasChannels) } - + val canTransferFromSavings by remember(showEmptyState, balances.totalOnchainSats) { + mutableStateOf(showEmptyState && balances.totalOnchainSats > 0uL) + } val hazeState = rememberHazeState() + Box( modifier = Modifier .fillMaxSize() @@ -101,18 +109,31 @@ fun SpendingWalletScreen( .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Vertical)) ) } - ScreenColumn(noBackground = true) { - AppTopBar( - titleText = stringResource(R.string.wallet__spending__title), - icon = R.drawable.ic_ln_circle, - onBackClick = onBackClick, - actions = { - DrawerNavIcon() + + AppTopBar( + titleText = stringResource(R.string.wallet__spending__title), + icon = R.drawable.ic_ln_circle, + onBackClick = onBackClick, + actions = { + DrawerNavIcon() + }, + modifier = Modifier + .hazeEffect(state = hazeState) { + mask = TopBarGradient } - ) - Column( - modifier = Modifier.padding(horizontal = 16.dp) - ) { + .background(TopBarGradient) + .zIndex(1f) + .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Vertical)) + ) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + item { StatusBarSpacer() } + item { TopBarSpacer() } + item { BalanceHeaderView( sats = balances.totalLightningSats.toLong(), testTag = "TotalBalance", @@ -120,18 +141,42 @@ fun SpendingWalletScreen( .fillMaxWidth() .testTag("TotalBalance") ) + } - if (balances.balanceInTransferToSpending > 0u) { + if (balances.balanceInTransferToSpending > 0u) { + item { IncomingTransfer( amount = balances.balanceInTransferToSpending, modifier = Modifier.padding(vertical = 8.dp) ) } + } + + if (canTransferFromSavings) { + item { VerticalSpacer(32.dp) } + + item { + SecondaryButton( + onClick = onTransferFromSavingsClick, + text = stringResource(R.string.lightning__funding__button1), + icon = { + Icon( + painter = painterResource(R.drawable.ic_transfer), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + hazeState = hazeState, + modifier = Modifier.testTag("TransferFromSavings") + ) + } + } - if (!showEmptyState) { - VerticalSpacer(32.dp) + if (!showEmptyState) { + item { VerticalSpacer(32.dp) } - if (canTransfer) { + if (canTransfer) { + item { SecondaryButton( onClick = onTransferToSavingsClick, text = stringResource(R.string.wallet__transfer_to_savings), @@ -146,17 +191,18 @@ fun SpendingWalletScreen( modifier = Modifier.testTag("TransferToSavings") ) } - - ActivityListGrouped( - items = lightningActivities, - onActivityItemClick = onActivityItemClick, - onEmptyActivityRowClick = onEmptyActivityRowClick, - showFooter = true, - onAllActivityButtonClick = onAllActivityButtonClick, - ) } + + activityListGroupedItems( + items = lightningActivities, + onActivityItemClick = onActivityItemClick, + onEmptyActivityRowClick = onEmptyActivityRowClick, + showFooter = true, + onAllActivityButtonClick = onAllActivityButtonClick, + ) } } + if (showEmptyState) { EmptyStateView( text = stringResource(R.string.wallet__spending__onboarding).withAccent(accentColor = Colors.Purple), @@ -180,6 +226,7 @@ private fun Preview() { onActivityItemClick = {}, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, + onTransferFromSavingsClick = {}, onBackClick = {}, balances = BalanceState(totalLightningSats = 50_000u), ) @@ -200,6 +247,7 @@ private fun PreviewTransfer() { onActivityItemClick = {}, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, + onTransferFromSavingsClick = {}, onBackClick = {}, balances = BalanceState( totalLightningSats = 50_000u, @@ -223,6 +271,7 @@ private fun PreviewNoActivity() { onActivityItemClick = {}, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, + onTransferFromSavingsClick = {}, onBackClick = {}, balances = BalanceState(totalLightningSats = 50_000u), ) @@ -243,7 +292,29 @@ private fun PreviewEmpty() { onActivityItemClick = {}, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, + onTransferFromSavingsClick = {}, + onBackClick = {}, + ) + TabBar() + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewEmptyWithSavings() { + AppThemeSurface { + Box { + SpendingWalletScreen( + channels = persistentListOf(), + lightningActivities = persistentListOf(), + onAllActivityButtonClick = {}, + onActivityItemClick = {}, + onEmptyActivityRowClick = {}, + onTransferToSavingsClick = {}, + onTransferFromSavingsClick = {}, onBackClick = {}, + balances = BalanceState(totalOnchainSats = 100_000u), ) TabBar() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 10ee33dfd..4cfdf4204 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -58,7 +59,7 @@ fun ActivityListGrouped( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxSize() ) { - if (items != null && items.isNotEmpty()) { + if (!items.isNullOrEmpty()) { val groupedItems = groupActivityItems(items) LazyColumn( @@ -149,6 +150,92 @@ fun ActivityListGrouped( } } +@Suppress("LongMethod") +fun LazyListScope.activityListGroupedItems( + items: ImmutableList?, + onActivityItemClick: (String) -> Unit, + onEmptyActivityRowClick: () -> Unit, + showFooter: Boolean = false, + onAllActivityButtonClick: () -> Unit = {}, +) { + if (!items.isNullOrEmpty()) { + val groupedItems = groupActivityItems(items) + itemsIndexed( + items = groupedItems, + key = { index, item -> + when (item) { + is String -> "header_$item" + is Activity -> when (item) { + is Activity.Lightning -> "lightning_${item.rawId()}" + is Activity.Onchain -> "onchain_${item.rawId()}" + } + + else -> "item_$index" + } + }, + ) { index, item -> + when (item) { + is String -> { + Caption13Up( + text = item, + color = Colors.White64, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .animateItem( + fadeInSpec = tween(durationMillis = 300), + fadeOutSpec = tween(durationMillis = 300), + placementSpec = tween(durationMillis = 300), + ) + ) + } + + is Activity -> { + Column( + modifier = Modifier + .animateItem( + fadeInSpec = tween(durationMillis = 300), + fadeOutSpec = tween(durationMillis = 300), + placementSpec = tween(durationMillis = 300), + ) + ) { + ActivityRow(item, onActivityItemClick, testTag = "Activity-$index") + VerticalSpacer(16.dp) + } + } + } + } + if (showFooter) { + item { + TertiaryButton( + text = stringResource(R.string.wallet__activity_show_all), + onClick = onAllActivityButtonClick, + modifier = Modifier + .wrapContentWidth() + .padding(top = 8.dp) + ) + } + } + item { + VerticalSpacer(120.dp) + } + } else { + if (showFooter) { + item { EmptyActivityRow(onClick = onEmptyActivityRowClick) } + } else { + item { + BodyM( + text = stringResource(R.string.wallet__activity_no), + color = Colors.White64, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } + } + } +} + // region utils @Suppress("CyclomaticComplexMethod") private fun groupActivityItems(activityItems: List): List { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 860f0e51b..51517f181 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -87,6 +87,7 @@ fun EditInvoiceScreen( var keyboardVisible by remember { mutableStateOf(false) } var isSoftKeyboardVisible by keyboardAsState() val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val isLoading by editInvoiceVM.isLoading.collectAsStateWithLifecycle() LaunchedEffect(Unit) { editInvoiceVM.editInvoiceEffect.collect { effect -> @@ -142,9 +143,10 @@ fun EditInvoiceScreen( }, onContinueKeyboard = { keyboardVisible = false }, onContinueGeneral = { editInvoiceVM.onClickContinue() }, + isLoading = isLoading, onClickAddTag = onClickAddTag, onClickTag = onClickTag, - isSoftKeyboardVisible = isSoftKeyboardVisible + isSoftKeyboardVisible = isSoftKeyboardVisible, ) } @@ -164,6 +166,7 @@ fun EditInvoiceContent( onTextChanged: (String) -> Unit, onClickTag: (String) -> Unit, modifier: Modifier = Modifier, + isLoading: Boolean = false, currencies: CurrencyState = LocalCurrencies.current, ) { BoxWithConstraints( @@ -310,7 +313,7 @@ fun EditInvoiceContent( .fillMaxWidth() .padding(bottom = 16.dp) ) { - tags.map { tagText -> + tags.forEach { tagText -> TagButton( text = tagText, displayIconClose = true, @@ -338,6 +341,7 @@ fun EditInvoiceContent( PrimaryButton( text = stringResource(R.string.wallet__receive_show_qr), onClick = onContinueGeneral, + isLoading = isLoading, modifier = Modifier.testTag("ShowQrReceive") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index 4ce84a419..de07cf93f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt @@ -4,7 +4,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel 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 to.bitkit.repositories.WalletRepo import to.bitkit.utils.Logger @@ -17,6 +20,10 @@ class EditInvoiceVM @Inject constructor( private val _editInvoiceEffect = MutableSharedFlow(extraBufferCapacity = 1) val editInvoiceEffect = _editInvoiceEffect.asSharedFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + private fun editInvoiceEffect(effect: EditInvoiceScreenEffects) = viewModelScope.launch { _editInvoiceEffect.emit( effect @@ -25,6 +32,7 @@ class EditInvoiceVM @Inject constructor( fun onClickContinue() { viewModelScope.launch { + _isLoading.update { true } walletRepo.shouldRequestAdditionalLiquidity().onSuccess { shouldRequest -> if (shouldRequest) { editInvoiceEffect(EditInvoiceScreenEffects.NavigateAddLiquidity) @@ -32,9 +40,10 @@ class EditInvoiceVM @Inject constructor( editInvoiceEffect(EditInvoiceScreenEffects.UpdateInvoice) } }.onFailure { - Logger.warn("Error checking for liquidity, navigating back to QR Screen", context = TAG) + Logger.warn("Failed to check for liquidity, navigating back to QR screen", context = TAG) editInvoiceEffect(EditInvoiceScreenEffects.UpdateInvoice) } + _isLoading.update { false } } } 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 b5fb5933f..3151c4189 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, startRoute: ReceiveRoute = ReceiveRoute.QR, editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel(), @@ -56,138 +65,155 @@ fun ReceiveSheet( } } - 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/settings/BlocktankRegtestScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt index fcc6ff45c..8c7da3f38 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt @@ -270,7 +270,7 @@ fun BlocktankRegtestScreen( runCatching { val voutNum = vout.toUIntOrNull() ?: error("Invalid Vout: $vout") val closeAfter = - forceCloseAfter.toULongOrNull() ?: error("Invalid Force Close After: $forceCloseAfter") + forceCloseAfter.toUIntOrNull() ?: error("Invalid Force Close After: $forceCloseAfter") val closingTxId = viewModel.regtestCloseChannel( fundingTxId = fundingTxId, vout = voutNum, diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt index f6cf74e20..1a915251c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.settings import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import to.bitkit.env.Defaults import to.bitkit.services.CoreService import javax.inject.Inject @@ -28,7 +29,11 @@ class BlocktankRegtestViewModel @Inject constructor( ) } - suspend fun regtestCloseChannel(fundingTxId: String, vout: UInt, forceCloseAfterS: ULong = 86_400uL): String { + suspend fun regtestCloseChannel( + fundingTxId: String, + vout: UInt, + forceCloseAfterS: UInt = Defaults.bolt11ExpirySec, + ): String { return coreService.blocktank.regtestCloseChannel( fundingTxId = fundingTxId, vout = vout, 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 bdfef1bf5..d54b7631c 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 @@ -7,19 +11,25 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf 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.components.SyncNodeView import to.bitkit.ui.navigateTo import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.screens.wallets.send.AddTagScreen @@ -56,6 +66,18 @@ fun SendSheet( walletViewModel: WalletViewModel, startDestination: SendRoute = SendRoute.Recipient, ) { + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() + val isOffline by remember { derivedStateOf { connectivityState != ConnectivityState.CONNECTED } } + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() + + val shouldShowSyncOverlay by remember { + derivedStateOf { + if (!lightningState.nodeLifecycleState.isRunning()) return@derivedStateOf true + val hasAnyChannels = lightningState.channels.isNotEmpty() + hasAnyChannels && lightningState.channels.none { it.isUsable } + } + } + LaunchedEffect(startDestination) { // always reset state on new user-initiated send if (startDestination == SendRoute.Recipient) { @@ -63,276 +85,302 @@ 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() }, + 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() }, - onContinue = { navController.popBackStack() }, - onSelect = { speed -> appViewModel.onSelectSpeed(speed) }, - onSelectInstant = { - appViewModel.switchToLightning() + 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 -> - appViewModel.preserveContactPaymentContext(paymentHash) - navController.navigateTo(SendRoute.Pending(paymentHash, amount)) { - popUpTo(startDestination) { inclusive = true } - } - }, - onShowError = { errorMessage -> - appViewModel.clearActiveContactPaymentContext() - 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 -> + appViewModel.preserveContactPaymentContext(paymentHash) + navController.navigateTo(SendRoute.Pending(paymentHash, amount)) { + popUpTo(startDestination) { inclusive = true } + } + }, + onShowError = { errorMessage -> + appViewModel.clearActiveContactPaymentContext() + 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)) + } + + AnimatedVisibility( + visible = shouldShowSyncOverlay && !isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + SyncNodeView( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + ) + } } } diff --git a/app/src/main/java/to/bitkit/ui/theme/Defaults.kt b/app/src/main/java/to/bitkit/ui/theme/Defaults.kt index ec19f9885..811c80717 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Defaults.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Defaults.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.Dp @@ -138,3 +139,10 @@ object Insets { @OptIn(ExperimentalMaterial3Api::class) val TopBarHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight + +val TopBarGradient: Brush = Brush.verticalGradient( + colorStops = arrayOf( + 0.5f to Colors.Black, + 1.0f to Color.Transparent, + ), +) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2768a55c2..2e95d70d6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -155,6 +155,7 @@ import java.math.BigDecimal import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -991,8 +992,9 @@ class AppViewModel @Inject constructor( } if (invoice.amountSatoshis > 0uL) { - val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats - if (maxSendLightning == 0uL || !lightningRepo.canSend(invoice.amountSatoshis)) { + lightningRepo.syncState() + if (!lightningRepo.canSend(invoice.amountSatoshis)) { + val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats val shortfall = invoice.amountSatoshis.safe() - maxSendLightning.safe() showAddressValidationError( titleRes = R.string.other__pay_insufficient_spending, @@ -1078,6 +1080,7 @@ class AppViewModel @Inject constructor( ) return@takeIf false } + lightningRepo.waitForUsableChannels() val canSend = lightningRepo.canSend(lnInv.amountSatoshis.coerceAtLeast(1u)) if (!canSend) { val nodeState = lightningRepo.lightningState.value.nodeLifecycleState @@ -1396,10 +1399,12 @@ class AppViewModel @Inject constructor( resetSendState() resetQuickPay() + val fromMainScanner = isMainScanner val input = result.removeLightningSchemes() // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 if (Bip21Utils.isDuplicatedBip21(input)) { + hideSheet() toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), @@ -1436,25 +1441,29 @@ class AppViewModel @Inject constructor( .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } .getOrNull() - handleDecodedScan(scan, input) + handleDecodedScan(scan, input, fromMainScanner) } @Suppress("CyclomaticComplexMethod") - private suspend fun handleDecodedScan(scan: Scanner?, input: String) = when (scan) { - is Scanner.OnChain -> onScanOnchain(scan.invoice, input) - is Scanner.Lightning -> onScanLightning(scan.invoice, input) - is Scanner.LnurlPay -> onScanLnurlPay(scan.data) - is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data) } - is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data) } + private suspend fun handleDecodedScan( + scan: Scanner?, + input: String, + fromMainScanner: Boolean, + ) = when (scan) { + is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner) + is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner) + is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner) + is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data, fromMainScanner) } + is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data, fromMainScanner) } is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } is Scanner.NodeId -> handleNonPaymentScan { onScanNodeId(scan) } is Scanner.Gift -> handleNonPaymentScan { onScanGift(scan.code, scan.amount) } else -> { - if (scan == null) { - Logger.warn("Failed to decode scan data", context = TAG) - } else { - Logger.warn("Received unhandled scan data '$scan'", context = TAG) - } + hideSheet() + Logger.warn( + if (scan == null) "Failed to decode scan data" else "Received unhandled scan data '$scan'", + context = TAG, + ) toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.other__qr_error_header), @@ -1486,9 +1495,14 @@ class AppViewModel @Inject constructor( } @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") - private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { + private suspend fun onScanOnchain( + invoice: OnChainInvoice, + scanResult: String, + fromMainScanner: Boolean, + ) { val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } .getOrElse { + hideSheet() toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), @@ -1500,6 +1514,7 @@ class AppViewModel @Inject constructor( } if (NetworkValidationHelper.isNetworkMismatch(validatedAddress.network.toLdkNetwork(), Env.network)) { + hideSheet() toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), @@ -1533,16 +1548,13 @@ class AppViewModel @Inject constructor( val quickPayHandled = handleQuickPayIfApplicable( amountSats = lnAmountSats, invoice = lnInvoice, + fromMainScanner = fromMainScanner, ) if (quickPayHandled) return + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) refreshOnchainSendIfNeeded() estimateLightningRoutingFeesIfNeeded() - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } return } @@ -1584,15 +1596,16 @@ class AppViewModel @Inject constructor( context = TAG, ) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLightning(invoice: LightningInvoice, scanResult: String) { + private suspend fun onScanLightning( + invoice: LightningInvoice, + scanResult: String, + fromMainScanner: Boolean, + ) { if (invoice.isExpired) { + hideSheet() toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), @@ -1603,10 +1616,16 @@ class AppViewModel @Inject constructor( return } - val quickPayHandled = handleQuickPayIfApplicable(amountSats = invoice.amountSatoshis, invoice = invoice) + val quickPayHandled = handleQuickPayIfApplicable( + amountSats = invoice.amountSatoshis, + invoice = invoice, + fromMainScanner = fromMainScanner, + ) if (quickPayHandled) return + lightningRepo.waitForUsableChannels() if (!lightningRepo.canSend(invoice.amountSatoshis)) { + hideSheet() val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats val shortfall = invoice.amountSatoshis.safe() - maxSendLightning.safe() toast( @@ -1633,29 +1652,23 @@ class AppViewModel @Inject constructor( if (invoice.amountSatoshis > 0uL) { Logger.info("Found amount in invoice, proceeding with payment", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) return } Logger.info("No amount found in invoice, proceeding to enter amount", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlPay(data: LnurlPayData) { + private suspend fun onScanLnurlPay(data: LnurlPayData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) val isFixed = data.isFixedAmount() val displaySats = data.minSendableSat() + lightningRepo.waitForUsableChannels() if (!lightningRepo.canSend(displaySats.coerceAtLeast(1u))) { + hideSheet() toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.other__lnurl_pay_error), @@ -1678,26 +1691,22 @@ class AppViewModel @Inject constructor( if (isFixed) { Logger.info("Found fixed amount '$displaySats' sats in lnurlPay, proceeding with payment", context = TAG) - val quickPayHandled = handleQuickPayIfApplicable(amountSats = displaySats, lnurlPay = data) + val quickPayHandled = handleQuickPayIfApplicable( + amountSats = displaySats, + lnurlPay = data, + fromMainScanner = fromMainScanner, + ) if (quickPayHandled) return - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) return } Logger.info("No amount found in lnurlPay, proceeding to enter amount manually", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData) { + private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) val isFixed = data.isFixedAmount() @@ -1705,6 +1714,7 @@ class AppViewModel @Inject constructor( val maxWithdrawable = data.maxWithdrawableSat() if (!isFixed && minWithdrawable > maxWithdrawable) { + hideSheet() toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.other__lnurl_withdr_error), @@ -1725,30 +1735,39 @@ class AppViewModel @Inject constructor( if (isFixed || minWithdrawable == maxWithdrawable) { delay(TRANSITION_SCREEN_MS) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.WithdrawConfirm)) - } else { - setSendEffect(SendEffect.NavigateToWithdrawConfirm) - } + navigateToSendRoute( + fromMainScanner, + SendRoute.WithdrawConfirm, + SendEffect.NavigateToWithdrawConfirm, + ) return } - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlAuth(data: LnurlAuthData) { + private suspend fun onScanLnurlAuth(data: LnurlAuthData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) - if (!isMainScanner) { + if (!fromMainScanner) { hideSheet() delay(TRANSITION_SCREEN_MS) } showSheet(Sheet.LnurlAuth(domain = data.domain, lnurl = data.uri, k1 = data.k1)) } + private fun navigateToSendRoute( + fromMainScanner: Boolean, + route: SendRoute, + effect: SendEffect, + ) { + if (fromMainScanner) { + showSheet(Sheet.Send(route)) + return + } + + setSendEffect(effect) + } + fun requestLnurlAuth(callback: String, k1: String, domain: String) { viewModelScope.launch { lightningRepo.requestLnurlAuth( @@ -1816,6 +1835,7 @@ class AppViewModel @Inject constructor( private suspend fun handleQuickPayIfApplicable( amountSats: ULong, + fromMainScanner: Boolean, lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { @@ -1851,11 +1871,7 @@ class AppViewModel @Inject constructor( Logger.debug("QuickPayData: $quickPayData", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.QuickPay)) - } else { - setSendEffect(SendEffect.NavigateToQuickPay) - } + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) return true } @@ -2079,7 +2095,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoiceMsats( amountMsats = lnurl.data.maxWithdrawable, description = lnurl.data.defaultDescription, - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = LNURL_WITHDRAW_EXPIRY_SEC, ) } else { val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast( @@ -2089,7 +2105,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoice( amountSats = withdrawAmountSats, description = lnurl.data.defaultDescription, - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = LNURL_WITHDRAW_EXPIRY_SEC, ) }.getOrNull() @@ -2789,6 +2805,7 @@ class AppViewModel @Inject constructor( private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val PUBKYAUTH_SCHEME = "pubkyauth" + private val LNURL_WITHDRAW_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt() } } 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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b7a6cd54..790513476 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -405,6 +405,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. diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 877e23dd7..dfd058c5f 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -7,6 +7,7 @@ import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.ILspNode import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -64,6 +65,10 @@ import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") class LightningRepoTest : BaseUnitTest() { + companion object { + private const val NO_USABLE_CHANNELS_FEEDBACK_DELAY_MS = 2_500L + } + private lateinit var sut: LightningRepo private val lightningService = mock() @@ -363,17 +368,91 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `canSend should return false when node is stopped`() = test { - assertFalse(sut.canSend(1000uL, fallbackToCachedBalance = true)) + assertFalse(sut.canSend(1000uL)) } @Test - fun `canSend should return service value when node is running`() = test { + fun `canSend should return true when channels have sufficient capacity`() = test { startNodeForTesting() - whenever(lightningService.canSend(any())).thenReturn(true) + val channel = createChannelDetails().copy( + isUsable = true, + nextOutboundHtlcLimitMsat = 2_000_000u, + ) + whenever(lightningService.channels).thenReturn(listOf(channel)) + sut.syncState() assertTrue(sut.canSend(1000uL)) } + @Test + fun `canSend should return false when channels have insufficient capacity`() = test { + startNodeForTesting() + val channel = createChannelDetails().copy( + isUsable = true, + nextOutboundHtlcLimitMsat = 500_000u, + ) + whenever(lightningService.channels).thenReturn(listOf(channel)) + sut.syncState() + + assertFalse(sut.canSend(1000uL)) + } + + @Test + fun `waitForUsableChannels waits for running state before treating empty channels as absent`() = test { + sut.setInitNodeLifecycleState() + val channel = createChannelDetails().copy( + isUsable = true, + nextOutboundHtlcLimitMsat = 2_000_000u, + ) + whenever(lightningService.channels).thenReturn(listOf(channel)) + + val wait = async { sut.waitForUsableChannels() } + + assertFalse(wait.isCompleted) + + startNodeForTesting() + + assertTrue(wait.isCompleted) + assertEquals(listOf(channel), sut.lightningState.value.channels) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `waitForUsableChannels delays before returning when node cannot run`() = test { + val wait = async { sut.waitForUsableChannels() } + + assertFalse(wait.isCompleted) + + testScheduler.advanceTimeBy(NO_USABLE_CHANNELS_FEEDBACK_DELAY_MS - 1) + + assertFalse(wait.isCompleted) + + testScheduler.advanceTimeBy(1) + testScheduler.runCurrent() + + assertTrue(wait.isCompleted) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `waitForUsableChannels delays before returning when running node has no channels`() = test { + whenever(lightningService.channels).thenReturn(emptyList()) + startNodeForTesting() + + val wait = async { sut.waitForUsableChannels() } + + assertFalse(wait.isCompleted) + + testScheduler.advanceTimeBy(NO_USABLE_CHANNELS_FEEDBACK_DELAY_MS - 1) + + assertFalse(wait.isCompleted) + + testScheduler.advanceTimeBy(1) + testScheduler.runCurrent() + + assertTrue(wait.isCompleted) + } + @Test fun `wipeStorage should stop node and call service wipe`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 5f52fd256..99bf1e647 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -135,7 +135,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(100uL)) whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) } .thenReturn(Result.success(2u)) - whenever { lightningRepo.canSend(any(), any()) }.thenReturn(true) + whenever(lightningRepo.canSend(any())).thenReturn(true) } private fun createViewModel() = AppViewModel( @@ -371,6 +371,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(contactKey, pendingContactPaymentContext(paymentHash)?.publicKey) } + @Test + fun `main scanner lightning scan opens send sheet`() = test { + val bolt11 = "lnbcrt1scanner" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `main scanner zero amount lightning scan opens amount sheet`() = test { + val bolt11 = "lnbcrt1zeroamount" + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + } + + @Test + fun `main scanner lightning scan opens QuickPay when enabled`() = test { + val bolt11 = "lnbcrt1scannerquickpay" + enableQuickPay(thresholdSats = 1000u) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" diff --git a/changelog.d/next/878.added.md b/changelog.d/next/878.added.md new file mode 100644 index 000000000..f7fa8c9e2 --- /dev/null +++ b/changelog.d/next/878.added.md @@ -0,0 +1 @@ +Connection issues overlay with connectivity fixes across Send, Receive, and Transfer flows diff --git a/changelog.d/next/925.fixed.md b/changelog.d/next/925.fixed.md new file mode 100644 index 000000000..1e44ac09f --- /dev/null +++ b/changelog.d/next/925.fixed.md @@ -0,0 +1 @@ +Payment QR scans now route reliably and avoid unnecessary delays when Lightning channels are unavailable.