Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
121 changes: 121 additions & 0 deletions Bitkit/Components/ConnectionIssuesView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
32 changes: 32 additions & 0 deletions Bitkit/Components/SyncNodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
13 changes: 12 additions & 1 deletion Bitkit/Managers/NetworkMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))"
)
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "<accent>Connection</accent>\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\n<accent>Balance</accent>";
"other__high_balance__text" = "<accent>Your wallet balance exceeds $500.</accent>\nFor your security, consider moving some of your savings to an offline wallet.";
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Views/Scanner/ScannerScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Views/Scanner/ScannerSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ struct ScannerSheet: View {
.presentationDragIndicator(.visible)
}
}
.syncNodeOverlay()
.connectionIssuesOverlay(title: t("other__qr_scan"))
}

private func handleManualEntrySubmit() async {
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Views/Sheets/ForceTransferSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct ForceTransferSheet: View {
onContinue: onForceTransfer
)
}
.connectionIssuesOverlay(title: t("lightning__force_nav_title"))
}

private func onCancel() {
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Views/Transfer/SavingsConfirmView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ struct SavingsConfirmView: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
.bottomSafeAreaPadding()
.connectionIssuesOverlay(title: t("lightning__transfer__nav_title"))
}
}

Expand Down
1 change: 1 addition & 0 deletions Bitkit/Views/Transfer/SpendingAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ struct SpendingAmount: View {
await calculateMaxTransferAmount()
}
}
.connectionIssuesOverlay(title: t("lightning__transfer__nav_title"))
}

private var actionButtons: some View {
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Views/Transfer/SpendingConfirm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ struct SpendingConfirm: View {
.task {
await calculateTransactionFee()
}
.connectionIssuesOverlay(title: t("lightning__transfer__nav_title"))
}

private func onConfirm() async {
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ struct ReceiveSheet: View {
}
}
}
.connectionIssuesOverlay(title: t("wallet__receive_bitcoin"))
.onAppear {
wallet.invoiceAmountSats = 0
wallet.invoiceNote = ""
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Views/Wallets/Send/SendSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading