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