Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
ecdb66b
Merge branch 'master' into feat/connection-issues-view
jvsena42 Apr 2, 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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
- 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
24 changes: 22 additions & 2 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1194,19 +1194,39 @@ class LightningRepo @Inject constructor(
}
}

suspend fun awaitPeerConnected(timeout: Duration = 30.seconds) = withContext(bgDispatcher) {
if (lightningService.peers?.any { it.isConnected } == true) return@withContext
Logger.debug("Waiting for peer to reconnect (timeout='$timeout')...", context = TAG)
withTimeoutOrNull(timeout) {
while (lightningService.peers?.any { it.isConnected } != true) {
delay(1.seconds)
}
}
}

suspend fun canSend(amountSats: ULong, fallbackToCachedBalance: Boolean = true) = withContext(bgDispatcher) {
if (!_lightningState.value.nodeLifecycleState.canRun()) {
return@withContext false
}
if (_lightningState.value.nodeLifecycleState.isStarting() && fallbackToCachedBalance) {
return@withContext amountSats <= (cacheStore.data.first().balance?.maxSendLightningSats ?: 0u)
return@withContext amountSats <= cachedLightningBalance()
}
if (lightningService.channels == null) {
withTimeoutOrNull(CHANNELS_READY_TIMEOUT_MS) {
_lightningState.first { lightningService.channels != null }
}
}
return@withContext lightningService.canSend(amountSats)
if (lightningService.canSend(amountSats)) return@withContext true
val channelsLoading = lightningService.channels?.none { it.isUsable } == true
if (fallbackToCachedBalance && channelsLoading) {
return@withContext amountSats <= cachedLightningBalance()
}
return@withContext false
}

private suspend fun cachedLightningBalance(): ULong {
val cached = cacheStore.data.first().balance
return maxOf(cached?.maxSendLightningSats ?: 0u, cached?.totalLightningSats ?: 0u)
}

fun getNodeId(): String? =
Expand Down
13 changes: 11 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
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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -46,6 +52,7 @@ import to.bitkit.ui.walletViewModel

@Composable
fun SavingsConfirmScreen(
isOffline: Boolean,
onConfirm: () -> Unit,
onAdvancedClick: () -> Unit,
onBackClick: () -> Unit,
Expand All @@ -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")
Expand Down
Loading
Loading