diff --git a/Bitkit/Components/ConnectionIssuesView.swift b/Bitkit/Components/ConnectionIssuesView.swift
new file mode 100644
index 000000000..ee2c79d96
--- /dev/null
+++ b/Bitkit/Components/ConnectionIssuesView.swift
@@ -0,0 +1,121 @@
+import SwiftUI
+
+/// A full-screen overlay displayed when the device loses internet connectivity.
+/// Shows a phone illustration with animated dashed gradient rings and a loading spinner.
+struct ConnectionIssuesView: View {
+ let title: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: title, showBackButton: false)
+
+ Spacer().frame(height: 24)
+
+ ZStack(alignment: .center) {
+ DashedRingsLayer(radii: [200])
+
+ Image("phone")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 311)
+
+ DashedRingsLayer(radii: [150, 100, 50])
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ DisplayText(
+ t("other__connection_issues_title"),
+ accentColor: .yellowAccent
+ )
+
+ Spacer().frame(height: 8)
+
+ BodyMText(
+ t("other__connection_issues_explain"),
+ textColor: .white64
+ )
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Spacer().frame(height: 24)
+
+ ActivityIndicator()
+ .frame(maxWidth: .infinity)
+
+ Spacer().frame(height: 16)
+ }
+ .navigationBarHidden(true)
+ .padding(.horizontal, 16)
+ .sheetBackground()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityIdentifier("ConnectionIssuesView")
+ }
+}
+
+// MARK: - Dashed Gradient Rings
+
+private struct DashedRingsLayer: View {
+ let radii: [CGFloat]
+
+ var body: some View {
+ Canvas { context, size in
+ let center = CGPoint(x: size.width * 0.25, y: size.height * 0.40)
+
+ for radius in radii {
+ let rect = CGRect(
+ x: center.x - radius,
+ y: center.y - radius,
+ width: radius * 2,
+ height: radius * 2
+ )
+
+ var path = Path()
+ path.addEllipse(in: rect)
+
+ let gradient = Gradient(colors: [.black, .yellowAccent])
+ let startPoint = CGPoint(x: rect.minX, y: rect.minY)
+ let endPoint = CGPoint(x: rect.maxX, y: rect.maxY)
+
+ context.stroke(
+ path,
+ with: .linearGradient(gradient, startPoint: startPoint, endPoint: endPoint),
+ style: StrokeStyle(lineWidth: 1, dash: [8, 6])
+ )
+ }
+ }
+ .allowsHitTesting(false)
+ }
+}
+
+// MARK: - View Modifier
+
+private struct ConnectionIssuesOverlayModifier: ViewModifier {
+ let title: String
+ @EnvironmentObject private var network: NetworkMonitor
+
+ func body(content: Content) -> some View {
+ ZStack {
+ content
+
+ if !network.isConnected {
+ ConnectionIssuesView(title: title)
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: network.isConnected)
+ }
+}
+
+extension View {
+ /// Overlays a `ConnectionIssuesView` when the device is offline.
+ /// The underlying content remains mounted so navigation state and inputs are preserved.
+ func connectionIssuesOverlay(title: String) -> some View {
+ modifier(ConnectionIssuesOverlayModifier(title: title))
+ }
+}
+
+// MARK: - Preview
+
+#Preview {
+ ConnectionIssuesView(title: "Send Bitcoin")
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Components/SyncNodeView.swift b/Bitkit/Components/SyncNodeView.swift
index 1112293b3..d8e10083f 100644
--- a/Bitkit/Components/SyncNodeView.swift
+++ b/Bitkit/Components/SyncNodeView.swift
@@ -87,3 +87,35 @@ struct SyncNodeView: View {
}
}
}
+
+// MARK: - View Modifier
+
+private struct SyncNodeOverlayModifier: ViewModifier {
+ @EnvironmentObject private var wallet: WalletViewModel
+
+ private var shouldShowSyncOverlay: Bool {
+ guard wallet.nodeLifecycleState == .running else { return true }
+ let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0
+ guard hasAnyChannels else { return false }
+ return !wallet.hasUsableChannels
+ }
+
+ func body(content: Content) -> some View {
+ ZStack {
+ content
+
+ if shouldShowSyncOverlay {
+ SyncNodeView()
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay)
+ }
+}
+
+extension View {
+ /// Overlays a `SyncNodeView` when the node is not running or channels aren't usable yet.
+ func syncNodeOverlay() -> some View {
+ modifier(SyncNodeOverlayModifier())
+ }
+}
diff --git a/Bitkit/Managers/NetworkMonitor.swift b/Bitkit/Managers/NetworkMonitor.swift
index 49b6c716e..2c0b65acd 100644
--- a/Bitkit/Managers/NetworkMonitor.swift
+++ b/Bitkit/Managers/NetworkMonitor.swift
@@ -25,8 +25,11 @@ final class NetworkMonitor: ObservableObject {
// Set the pathUpdateHandler
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
+ let wasConnected = self?.isConnected
+ let isNowConnected = path.status == .satisfied
+
// Check if the device is connected to the internet
- self?.isConnected = path.status == .satisfied
+ self?.isConnected = isNowConnected
// Check if the network is expensive (e.g. cellular data)
self?.isExpensive = path.isExpensive
@@ -36,6 +39,14 @@ final class NetworkMonitor: ObservableObject {
// Update the network path
self?.nwPath = path
+
+ if wasConnected != isNowConnected {
+ let interfaceType = path.availableInterfaces.first?.type
+ Logger
+ .debug(
+ "Network connectivity changed: \(isNowConnected ? "connected" : "disconnected") (interface: \(String(describing: interfaceType)), status: \(path.status))"
+ )
+ }
}
}
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 0b97c483a..223e3a5d8 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -390,6 +390,8 @@
"other__connection_reconnect_msg" = "Lost connection to Electrum, trying to reconnect...";
"other__connection_back_title" = "Internet Connection Restored";
"other__connection_back_msg" = "Bitkit successfully reconnected to the Internet.";
+"other__connection_issues_title" = "Connection\nIssues";
+"other__connection_issues_explain" = "It appears you're disconnected. Please check your connection. Bitkit will try to reconnect every few seconds.";
"other__high_balance__nav_title" = "High Balance";
"other__high_balance__title" = "High\nBalance";
"other__high_balance__text" = "Your wallet balance exceeds $500.\nFor your security, consider moving some of your savings to an offline wallet.";
diff --git a/Bitkit/Views/Scanner/ScannerScreen.swift b/Bitkit/Views/Scanner/ScannerScreen.swift
index 1a6609050..a446c61d5 100644
--- a/Bitkit/Views/Scanner/ScannerScreen.swift
+++ b/Bitkit/Views/Scanner/ScannerScreen.swift
@@ -58,6 +58,8 @@ struct ScannerScreen: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
.bottomSafeAreaPadding()
+ .syncNodeOverlay()
+ .connectionIssuesOverlay(title: t("other__qr_scan"))
.onAppear {
scanner.configure(
app: app,
diff --git a/Bitkit/Views/Scanner/ScannerSheet.swift b/Bitkit/Views/Scanner/ScannerSheet.swift
index 511287b8f..f6badb694 100644
--- a/Bitkit/Views/Scanner/ScannerSheet.swift
+++ b/Bitkit/Views/Scanner/ScannerSheet.swift
@@ -85,6 +85,8 @@ struct ScannerSheet: View {
.presentationDragIndicator(.visible)
}
}
+ .syncNodeOverlay()
+ .connectionIssuesOverlay(title: t("other__qr_scan"))
}
private func handleManualEntrySubmit() async {
diff --git a/Bitkit/Views/Sheets/ForceTransferSheet.swift b/Bitkit/Views/Sheets/ForceTransferSheet.swift
index 9fba2250d..9fd120882 100644
--- a/Bitkit/Views/Sheets/ForceTransferSheet.swift
+++ b/Bitkit/Views/Sheets/ForceTransferSheet.swift
@@ -29,6 +29,7 @@ struct ForceTransferSheet: View {
onContinue: onForceTransfer
)
}
+ .connectionIssuesOverlay(title: t("lightning__force_nav_title"))
}
private func onCancel() {
diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift
index 7c7f7681b..8fa8dcef5 100644
--- a/Bitkit/Views/Transfer/SavingsConfirmView.swift
+++ b/Bitkit/Views/Transfer/SavingsConfirmView.swift
@@ -100,6 +100,7 @@ struct SavingsConfirmView: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
.bottomSafeAreaPadding()
+ .connectionIssuesOverlay(title: t("lightning__transfer__nav_title"))
}
}
diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift
index 750832191..3918060e2 100644
--- a/Bitkit/Views/Transfer/SpendingAmount.swift
+++ b/Bitkit/Views/Transfer/SpendingAmount.swift
@@ -85,6 +85,7 @@ struct SpendingAmount: View {
await calculateMaxTransferAmount()
}
}
+ .connectionIssuesOverlay(title: t("lightning__transfer__nav_title"))
}
private var actionButtons: some View {
diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift
index 9501d9a39..7bca45408 100644
--- a/Bitkit/Views/Transfer/SpendingConfirm.swift
+++ b/Bitkit/Views/Transfer/SpendingConfirm.swift
@@ -134,6 +134,7 @@ struct SpendingConfirm: View {
.task {
await calculateTransactionFee()
}
+ .connectionIssuesOverlay(title: t("lightning__transfer__nav_title"))
}
private func onConfirm() async {
diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
index 9cf6a790d..f393af867 100644
--- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
+++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
@@ -44,6 +44,7 @@ struct ReceiveSheet: View {
}
}
}
+ .connectionIssuesOverlay(title: t("wallet__receive_bitcoin"))
.onAppear {
wallet.invoiceAmountSats = 0
wallet.invoiceNote = ""
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 3d3883ca7..c09bfb0a3 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -95,6 +95,7 @@ struct SendSheet: View {
}
}
.animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay)
+ .connectionIssuesOverlay(title: t("wallet__send_bitcoin"))
.onAppear {
tagManager.clearSelectedTags()
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb0291423..b54c42bef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- Connection issues overlay on Send, Receive, Transfer, and Force Transfer flows #524
- Add transfer from savings button on empty spending wallet when user has on-chain balance #523
### Changed