Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
9604817
feat: ConnectionIssuesView
jvsena42 Mar 31, 2026
b93d729
feat: display connection issues view
jvsena42 Mar 31, 2026
1671051
fix: circle alignment
jvsena42 Mar 31, 2026
61e0371
fix: circle color and fading
jvsena42 Mar 31, 2026
e60c9fb
chore: lint
jvsena42 Mar 31, 2026
aaa3d8d
fix: gradient color
jvsena42 Mar 31, 2026
9f3c1f9
feat: display connection issues screen in transfer flows
jvsena42 Apr 1, 2026
46f955f
refactor: remove unnecessary parameter
jvsena42 Apr 1, 2026
e81a4ff
Merge branch 'feat/send-v60' into feat/connection-issues-view
jvsena42 Apr 1, 2026
a5fde6d
fix: re-trigger updateLimits when switch to online
jvsena42 Apr 1, 2026
4f9c6df
Merge branch 'feat/send-v60' into feat/connection-issues-view
jvsena42 Apr 2, 2026
70f1112
fix: not populated LN balance on canSend fallback
jvsena42 Apr 2, 2026
db7d466
fix: add spacer
jvsena42 Apr 2, 2026
8b11ea7
fix: fallback to cached balance for validation when channels are loading
jvsena42 Apr 2, 2026
5f3da5c
fix: isFirstEmission check
jvsena42 Apr 2, 2026
eec1e8e
fix: await for peer connection before try to close
jvsena42 Apr 2, 2026
49167e1
Merge branch 'feat/send-v60' into feat/connection-issues-view
jvsena42 Apr 2, 2026
20154dc
doc: changelog entry
jvsena42 Apr 2, 2026
4e7e2a2
chore: lint
jvsena42 Apr 2, 2026
0136e97
merge: feat/send-v60 into feat/connection-issues-view
jvsena42 Apr 2, 2026
5caf2b3
doc: consolidate changelog entries
jvsena42 Apr 2, 2026
ef540ab
Merge branch 'feat/send-v60' into feat/connection-issues-view
jvsena42 Apr 2, 2026
e3d6b4f
Update app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt
jvsena42 Apr 2, 2026
2c417d5
Merge branch 'feat/send-v60' into feat/connection-issues-view
jvsena42 Apr 2, 2026
1b796a1
feat: add transfer from savings button on empty spending screen
jvsena42 Apr 2, 2026
3f35635
doc: add changelog entry
jvsena42 Apr 2, 2026
7942b52
chore: backfill changelog pr number
jvsena42 Apr 2, 2026
28da848
Merge branch 'feat/connection-issues-view' into feat/transfer-from-sa…
jvsena42 Apr 2, 2026
99cf123
Update app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletS…
jvsena42 Apr 2, 2026
e0b12fd
Update app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletS…
jvsena42 Apr 2, 2026
ecdb66b
Merge branch 'master' into feat/connection-issues-view
jvsena42 Apr 2, 2026
9805964
Merge branch 'feat/connection-issues-view' into feat/transfer-from-sa…
jvsena42 Apr 2, 2026
984f20c
Merge branch 'master' into feat/connection-issues-view
jvsena42 Apr 6, 2026
2445a59
Merge branch 'feat/connection-issues-view' into feat/transfer-from-sa…
jvsena42 Apr 6, 2026
c032a4d
fix: import
jvsena42 Apr 6, 2026
5392a89
chore: lint
jvsena42 Apr 6, 2026
dc6a27a
chore: lint
jvsena42 Apr 6, 2026
7ec57f9
refactor: replace cache strategy with flow collecting + timeout
jvsena42 Apr 6, 2026
055a783
fix: display SyncNodeView on SendSheet start when node is not running…
jvsena42 Apr 6, 2026
385b655
fix: display sync view early when handling deeplinks
jvsena42 Apr 6, 2026
00a8108
refactor: extract waitForUsableChannels logic from canSend
jvsena42 Apr 6, 2026
3da8e8d
Merge branch 'feat/connection-issues-view' into feat/transfer-from-sa…
ovitrif Apr 6, 2026
fbe31db
fix: don't block navigation by ln fee estimation
jvsena42 Apr 6, 2026
18b4a23
Merge branch 'master' into feat/connection-issues-view
jvsena42 Apr 6, 2026
730a4ae
Merge branch 'master' into feat/transfer-from-savings-button-on-empty…
ovitrif Apr 6, 2026
2d9d0d3
chore: rm unused import
ovitrif Apr 6, 2026
fd8f672
fix: add bg blur to transfer from savings button
ovitrif Apr 6, 2026
2780cc5
fix: secondary button weight layout regression
ovitrif Apr 6, 2026
cb49884
Merge branch 'feat/connection-issues-view' into feat/transfer-from-sa…
ovitrif Apr 6, 2026
ced3109
chore: rm modifier arg trailing comma
ovitrif Apr 6, 2026
34daff4
Merge pull request #882 from synonymdev/feat/transfer-from-savings-bu…
ovitrif Apr 6, 2026
d934929
Merge branch 'master' into feat/connection-issues-view
ovitrif Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ 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
- Connection issues overlay with connectivity fixes across Send, Receive, and Transfer flows #878
- Lightning Connections empty state with onboarding screen #857
- Unified PIN management screen (enable/disable/change in one place) #857
- Support entry in drawer menu #857
Expand Down
27 changes: 14 additions & 13 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ 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
Expand Down Expand Up @@ -983,7 +984,7 @@ class LightningRepo @Inject constructor(
}
}

private suspend fun waitForUsableChannels() {
suspend fun waitForUsableChannels() {
if (lightningService.channels?.any { it.isUsable } == true) return

Logger.info("Waiting for usable channels before sending payment", context = TAG)
Expand Down Expand Up @@ -1203,19 +1204,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? =
Expand Down Expand Up @@ -1401,7 +1403,6 @@ 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
val SEND_LN_TIMEOUT = 10.seconds
}
Expand Down
18 changes: 0 additions & 18 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import to.bitkit.data.backup.VssStoreIdProvider
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
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
Expand Down Expand Up @@ -618,23 +617,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,
Expand Down
21 changes: 19 additions & 2 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -380,12 +381,14 @@ fun ContentView(

is Sheet.Receive -> {
val walletState by walletViewModel.walletState.collectAsStateWithLifecycle()
val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle()
ReceiveSheet(
walletState = walletState,
isOffline = connectivityState != ConnectivityState.CONNECTED,
navigateToExternalConnection = {
navController.navigateTo(ExternalConnection())
appViewModel.hideSheet()
}
},
)
}

Expand Down Expand Up @@ -574,7 +577,9 @@ private fun RootNavHost(
)
}
composableWithDefaultTransitions<Routes.SavingsConfirm> {
val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle()
SavingsConfirmScreen(
isOffline = connectivityState != ConnectivityState.CONNECTED,
onConfirm = { navController.navigateTo(Routes.SavingsProgress) },
onAdvancedClick = { navController.navigateTo(Routes.SavingsAdvanced) },
onBackClick = { navController.popBackStack() },
Expand Down Expand Up @@ -605,23 +610,27 @@ private fun RootNavHost(
)
}
composableWithDefaultTransitions<Routes.SpendingAmount> {
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) },
toast = { title, description ->
appViewModel.toast(
type = Toast.ToastType.ERROR,
title = title,
description = description
description = description,
)
},
)
}
composableWithDefaultTransitions<Routes.SpendingConfirm> {
val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle()
SpendingConfirmScreen(
viewModel = transferViewModel,
isOffline = connectivityState != ConnectivityState.CONNECTED,
onBackClick = { navController.popBackStack() },
onCloseClick = { navController.navigateToHome() },
onLearnMoreClick = { navController.navigateTo(Routes.TransferLiquidity) },
Expand Down Expand Up @@ -806,6 +815,7 @@ private fun NavGraphBuilder.home(
}
composableWithDefaultTransitions<Routes.Spending> {
val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle()
val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle()
val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle()
val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle()

Expand All @@ -822,6 +832,13 @@ private fun NavGraphBuilder.home(
navController.navigateToTransferSavingsAvailability()
}
},
onTransferFromSavingsClick = {
if (!hasSeenSpendingIntro) {
navController.navigateToTransferSpendingIntro()
} else {
navController.navigateToTransferSpendingAmount()
}
},
onBackClick = { navController.popBackStack() },
)
}
Expand Down
140 changes: 140 additions & 0 deletions app/src/main/java/to/bitkit/ui/components/ConnectionIssuesView.kt
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading