diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index a6cc9f34..d42595ad 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -92,6 +92,7 @@ struct AppScene: View {
.onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) }
.onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) }
.onChange(of: scenePhase) { _, newValue in handleScenePhaseChange(newValue) }
+ .onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) }
.onChange(of: migrations.isShowingMigrationLoading) { _, isLoading in
if !isLoading {
SettingsViewModel.shared.updatePinEnabledState()
@@ -112,12 +113,6 @@ struct AppScene: View {
}
}
}
- .onChange(of: network.isConnected) { _, isConnected in
- // Retry starting wallet when network comes back online
- if isConnected {
- handleNetworkRestored()
- }
- }
.environmentObject(app)
.environmentObject(navigation)
.environmentObject(network)
@@ -543,16 +538,13 @@ struct AppScene: View {
}
private func handleScenePhaseChange(_ newPhase: ScenePhase) {
- Logger.debug("Scene phase changed: \(newPhase)")
+ Logger.info("Scene phase changed: \(newPhase)", context: "AppScene")
if newPhase == .background {
if settings.pinEnabled {
// If PIN is enabled, lock the app when the app goes to the background
isPinVerified = false
}
- if wallet.walletExists == true {
- app.resetAppStatusInit()
- }
}
if newPhase == .active {
@@ -573,28 +565,34 @@ struct AppScene: View {
center.removeDeliveredNotifications(withIdentifiers: deliveredNotifications.map(\.request.identifier))
}
- private func handleNetworkRestored() {
- // Refresh currency rates when network is restored - critical for UI
- // to display balances (MoneyText returns "0" if rates are nil)
- Task {
- await currency.refresh()
- }
+ private func handleNetworkChange(_ isConnected: Bool) {
+ Logger.info("Network changed: \(isConnected ? "connected" : "disconnected")", context: "AppScene")
- guard wallet.walletExists == true,
- scenePhase == .active
- else {
- return
- }
+ app.toast(
+ type: isConnected ? .success : .warning,
+ title: isConnected ? t("other__connection_back_title") : t("other__connection_issue"),
+ description: isConnected ? t("other__connection_back_msg") : t("other__connection_issue_explain")
+ )
+
+ if isConnected {
+ guard wallet.walletExists == true else { return }
- // If node is stopped/failed, restart it
- switch wallet.nodeLifecycleState {
- case .stopped, .errorStarting:
- Logger.info("Network restored, retrying wallet start...", context: "AppScene")
+ // Refresh currency rates when network is restored - critical for UI
+ // to display balances (MoneyText returns "0" if rates are nil)
Task {
- await startWallet()
+ await currency.refresh()
+ }
+
+ // Restart node if necessary (e.g. create/restore was skipped due to offline)
+ switch wallet.nodeLifecycleState {
+ case .stopped, .initializing, .errorStarting:
+ Logger.info("Network restored, retrying wallet start...", context: "AppScene")
+ Task {
+ await startWallet()
+ }
+ default:
+ break
}
- default:
- break
}
}
diff --git a/Bitkit/Components/ActivityIndicator.swift b/Bitkit/Components/ActivityIndicator.swift
index a82f2a65..04e44517 100644
--- a/Bitkit/Components/ActivityIndicator.swift
+++ b/Bitkit/Components/ActivityIndicator.swift
@@ -18,7 +18,6 @@ struct ActivityIndicator: View {
}
var body: some View {
- let strokeWidth = size / 12
let color = theme == .light ? Color.white : Color.black
ZStack {
@@ -31,18 +30,11 @@ struct ActivityIndicator: View {
startAngle: .degrees(0),
endAngle: .degrees(360)
),
- style: StrokeStyle(
- lineWidth: strokeWidth,
- lineCap: .round
- )
+ style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
)
.frame(width: size, height: size)
.rotationEffect(.degrees(isRotating ? 360 : 0))
- .animation(
- .linear(duration: 1.2)
- .repeatForever(autoreverses: false),
- value: isRotating
- )
+ .animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: isRotating)
}
.opacity(opacity)
.onAppear {
diff --git a/Bitkit/Components/AppStatus.swift b/Bitkit/Components/AppStatus.swift
index 383dfe52..3edb7e23 100644
--- a/Bitkit/Components/AppStatus.swift
+++ b/Bitkit/Components/AppStatus.swift
@@ -56,8 +56,11 @@ struct AppStatus: View {
private var appStatus: HealthStatus {
let realStatus = AppStatusHelper.combinedAppStatus(from: wallet, network: network)
- // During init, hide error state but show pending (sync animation)
+ // During init, hide error state but show pending (sync animation).
+ // Always show error when offline so the header reflects no network.
if !app.appStatusInit && realStatus == .error {
+ let internet = AppStatusHelper.internetStatus(network: network)
+ if internet == .error { return .error }
return .ready
}
diff --git a/Bitkit/Components/EllipseLoader.swift b/Bitkit/Components/EllipseLoader.swift
new file mode 100644
index 00000000..8e64f6af
--- /dev/null
+++ b/Bitkit/Components/EllipseLoader.swift
@@ -0,0 +1,130 @@
+import SwiftUI
+
+/// Variants of the ellipse loader: ellipse colors, animation, and center content are defined per variant.
+enum EllipseLoaderVariant {
+ case sync
+ case quickpay
+ case transfer
+ case hardware
+
+ var accentColor: String {
+ switch self {
+ case .sync, .quickpay, .transfer: return "purple"
+ case .hardware: return "blue"
+ }
+ }
+
+ var centerScale: CGFloat {
+ switch self {
+ case .sync, .transfer, .hardware: return 0.85
+ case .quickpay: return 1
+ }
+ }
+
+ var ellipseAnimation: Animation {
+ switch self {
+ case .sync:
+ return Animation.easeOut(duration: 1.5).repeatForever(autoreverses: true)
+ case .quickpay:
+ return Animation.easeOut(duration: 1.6).repeatForever(autoreverses: true)
+ case .transfer:
+ return Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
+ case .hardware:
+ return Animation.linear(duration: 1).repeatForever(autoreverses: true)
+ }
+ }
+}
+
+/// Center content for transfer variant: transfer figure with its own rotation animation.
+private struct AnimatedTransferFigure: View {
+ @State private var rotation: Double = 0
+
+ var body: some View {
+ Image("transfer-figure")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .rotationEffect(.degrees(rotation))
+ .onAppear {
+ withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
+ rotation = 90
+ }
+ }
+ }
+}
+
+/// Center content for quickpay variant: coin stack with subtle rotation.
+private struct AnimatedCoinStack: View {
+ @State private var rotation: Double = 0
+
+ var body: some View {
+ Image("coin-stack-4")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .rotationEffect(.degrees(rotation))
+ .onAppear {
+ withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
+ rotation = 20
+ }
+ }
+ }
+}
+
+/// Animated loading view with rotating ellipses and variant-specific center content.
+/// Sizes to the available space so it can shrink on small screens and leave room for text.
+struct EllipseLoader: View {
+ let variant: EllipseLoaderVariant
+
+ @State private var outerRotation: Double = 0
+ @State private var innerRotation: Double = 0
+
+ var body: some View {
+ GeometryReader { geo in
+ let container = min(geo.size.width, geo.size.height)
+ let figure = container * variant.centerScale
+ let inner = container * 0.7
+
+ ZStack(alignment: .center) {
+ Image("ellipse-outer-\(variant.accentColor)")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: container, height: container)
+ .rotationEffect(.degrees(outerRotation))
+
+ Image("ellipse-inner-\(variant.accentColor)")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: inner, height: inner)
+ .rotationEffect(.degrees(innerRotation))
+
+ centerContent
+ .frame(width: figure, height: figure)
+ }
+ .frame(width: container, height: container)
+ .clipped()
+ }
+ .aspectRatio(1, contentMode: .fit)
+ .frame(maxWidth: .infinity)
+ .onAppear {
+ withAnimation(variant.ellipseAnimation) { outerRotation = -180 }
+ withAnimation(variant.ellipseAnimation) { innerRotation = 180 }
+ }
+ }
+
+ @ViewBuilder private var centerContent: some View {
+ switch variant {
+ case .sync:
+ Image("lightning")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ case .quickpay:
+ AnimatedCoinStack()
+ case .transfer:
+ AnimatedTransferFigure()
+ case .hardware:
+ // TODO: change to hardware figure
+ Image("shield-figure")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ }
+ }
+}
diff --git a/Bitkit/Components/ProgressSteps.swift b/Bitkit/Components/ProgressSteps.swift
new file mode 100644
index 00000000..2772d2e6
--- /dev/null
+++ b/Bitkit/Components/ProgressSteps.swift
@@ -0,0 +1,65 @@
+import SwiftUI
+
+/// A view that displays a list of steps with circles and numbers.
+struct ProgressSteps: View {
+ let steps: [String]
+ let currentStep: Int
+
+ private let size: CGFloat = 32
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Steps with circles and separators
+ GeometryReader { geometry in
+ ZStack(alignment: .center) {
+ // Dashed line background
+ Path { path in
+ let y = geometry.size.height / 2
+ let padding = 36.0 * 2.5 // Account for circle radius (16) + horizontal padding (20)
+ path.move(to: CGPoint(x: padding, y: y))
+ path.addLine(to: CGPoint(x: geometry.size.width - padding, y: y))
+ }
+ .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
+ .foregroundColor(Color.white32)
+
+ // Circles with numbers
+ HStack(spacing: 0) {
+ ForEach(Array(steps.enumerated()), id: \.0) { index, _ in
+ // Circle with number or checkmark
+ ZStack {
+ Circle()
+ .fill(index < currentStep ? Color.purpleAccent : Color.black)
+ .frame(width: size, height: size)
+
+ if index < currentStep {
+ // Checkmark for completed steps
+ Image("check-mark")
+ .foregroundColor(.black)
+ } else {
+ // Number for current and upcoming steps
+ Text("\(index + 1)")
+ .foregroundColor(index == currentStep ? Color.purpleAccent : .white32)
+ .font(.custom(Fonts.regular, size: 17))
+ }
+
+ // Border for uncompleted steps
+ if index >= currentStep {
+ Circle()
+ .stroke(index == currentStep ? Color.purpleAccent : Color.white32, lineWidth: 1)
+ .frame(width: size, height: size)
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+ }
+ }
+ }
+ .frame(height: size)
+
+ VStack {
+ BodySSBText(steps[currentStep], textColor: .white32)
+ }
+ .frame(height: 56)
+ }
+ }
+}
diff --git a/Bitkit/Components/SyncNodeView.swift b/Bitkit/Components/SyncNodeView.swift
deleted file mode 100644
index 1112293b..00000000
--- a/Bitkit/Components/SyncNodeView.swift
+++ /dev/null
@@ -1,89 +0,0 @@
-import SwiftUI
-
-/// Animated loading view with rotating ellipses and lightning icon
-private struct SyncNodeLoadingView: View {
- @State private var outerRotation: Double = 0
- @State private var innerRotation: Double = 0
-
- var size: (container: CGFloat, image: CGFloat, inner: CGFloat) {
- let container: CGFloat = UIScreen.main.isSmall ? 200 : 320
- let image = container * 0.8
- let inner = container * 0.7
-
- return (container: container, image: image, inner: inner)
- }
-
- var body: some View {
- ZStack(alignment: .center) {
- // Outer ellipse
- Image("ellipse-outer-purple")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: size.container, height: size.container)
- .rotationEffect(.degrees(outerRotation))
-
- // Inner ellipse
- Image("ellipse-inner-purple")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: size.inner, height: size.inner)
- .rotationEffect(.degrees(innerRotation))
-
- // Lightning image
- Image("lightning")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: size.image, height: size.image)
- }
- .frame(width: size.container, height: size.container)
- .clipped()
- .frame(maxWidth: .infinity)
- .onAppear {
- withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
- outerRotation = -90
- }
-
- withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
- innerRotation = 120
- }
- }
- }
-}
-
-/// A view that displays while the node is syncing.
-/// Used as an overlay on screens that require the node to be fully synced.
-struct SyncNodeView: View {
- @EnvironmentObject var wallet: WalletViewModel
-
- /// Optional callback when sync completes
- var onSyncComplete: (() -> Void)?
-
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- SheetHeader(title: t("wallet__send_bitcoin"), showBackButton: false)
-
- VStack(spacing: 0) {
- BodyMText(t("lightning__wait_text_top"))
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.bottom, 16)
-
- Spacer()
-
- SyncNodeLoadingView()
-
- Spacer()
-
- BodyMSBText(t("lightning__wait_text_bottom"), textColor: .white32)
- }
- }
- .navigationBarHidden(true)
- .padding(.horizontal, 16)
- .sheetBackground()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .onChange(of: wallet.isSyncingWallet) { _, newValue in
- if !newValue {
- onSyncComplete?()
- }
- }
- }
-}
diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
index 01512565..1ab39565 100644
--- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
@@ -661,7 +661,6 @@
"settings__backup__export" = "Exportieren von Walletdaten auf das Telefon";
"settings__backup__reset" = "Wallet zurücksetzen";
"settings__backup__failed_title" = "Backup fehlgeschlagen";
-"settings__backup__failed_message" = "Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}.";
"settings__backup__latest" = "Letzte Backups";
"settings__backup__status_failed" = "Gescheitertes Backup: {time}";
"settings__backup__status_success" = "Letztes Backup: {time}";
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 766d3dfd..d72b5610 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -305,8 +305,6 @@
"lightning__order_state__open" = "Connection open";
"lightning__order_state__inactive" = "Connection inactive";
"lightning__peer_disconnected" = "Peer disconnected.";
-"lightning__wait_text_top" = "Please wait for Bitkit to connect to the payment network (±10 seconds).";
-"lightning__wait_text_bottom" = "Connecting & Syncing...";
"onboarding__tos_header" = "Bitkit\nterms of use";
"onboarding__tos_checkbox" = "Terms of use";
"onboarding__tos_checkbox_value" = "I declare that I have read and accept the terms of use.";
@@ -1122,6 +1120,10 @@
"wallet__create_wallet_mnemonic_restore_error" = "Please double-check if your recovery phrase is accurate.";
"wallet__send_bitcoin" = "Send Bitcoin";
"wallet__send_from" = "From";
+"wallet__send_sync_title" = "Connecting\nto network";
+"wallet__send_sync_description" = "Please wait for Bitkit to connect to the payment network (±10 seconds).";
+"wallet__send_sync_long_title" = "Connection\nissues";
+"wallet__send_sync_long_description" = "It appears you’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds.";
"wallet__send_to" = "To";
"wallet__recipient_contact" = "Contact";
"wallet__recipient_invoice" = "Paste Invoice";
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index c6077bfd..b3ccffc9 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -556,7 +556,7 @@ class BackupService {
// Convert from milliseconds to seconds (matching Android behavior)
return createdAtMillis / 1000
} catch {
- Logger.debug("Failed to get remote backup timestamp for \(category.rawValue): \(error)", context: "BackupService")
+ Logger.warn("Failed to get remote backup timestamp for \(category.rawValue): \(error)", context: "BackupService")
return nil
}
}
diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift
index bb817c9f..7ef20e48 100644
--- a/Bitkit/Services/VssBackupClient.swift
+++ b/Bitkit/Services/VssBackupClient.swift
@@ -159,7 +159,7 @@ class VssBackupClient {
return nil
}
} catch {
- Logger.debug("VSS 'getObject' error for '\(key)': \(error)", context: "VssBackupClient")
+ Logger.warn("VSS 'getObject' error for '\(key)': \(error)", context: "VssBackupClient")
throw error
}
}
diff --git a/Bitkit/Utilities/AppStatusHelper.swift b/Bitkit/Utilities/AppStatusHelper.swift
index 7ecf506c..3e57c941 100644
--- a/Bitkit/Utilities/AppStatusHelper.swift
+++ b/Bitkit/Utilities/AppStatusHelper.swift
@@ -48,9 +48,13 @@ struct AppStatusHelper {
static func nodeStatus(from wallet: WalletViewModel, network: NetworkMonitor) -> HealthStatus {
let isOnline = network.isConnected
+ if !isOnline {
+ return .error
+ }
+
switch wallet.nodeLifecycleState {
case .running:
- return isOnline ? .ready : .error
+ return .ready
case .starting, .initializing, .stopping, .stopped:
return .pending
case .errorStarting:
@@ -58,13 +62,17 @@ struct AppStatusHelper {
}
}
- static func channelsStatus(from wallet: WalletViewModel) -> HealthStatus {
+ static func channelsStatus(from wallet: WalletViewModel, network: NetworkMonitor) -> HealthStatus {
+ let isOnline = network.isConnected
let hasChannels = wallet.channelCount > 0
- let hasUsableChannels = wallet.channels?.contains(where: \.isUsable) ?? false
+
+ if !isOnline {
+ return .error
+ }
if !hasChannels {
return .error
- } else if hasUsableChannels {
+ } else if wallet.hasUsableChannels {
return .ready
} else {
return .pending
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 07dc3be4..ef54a443 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -76,11 +76,6 @@ class AppViewModel: ObservableObject {
appStatusInit = true
}
- /// Called when app goes to background to reset the status facade
- func resetAppStatusInit() {
- appStatusInit = false
- }
-
private let lightningService: LightningService
private let coreService: CoreService
private let sheetViewModel: SheetViewModel
diff --git a/Bitkit/Views/Offline/OfflineConnectionContent.swift b/Bitkit/Views/Offline/OfflineConnectionContent.swift
new file mode 100644
index 00000000..132cf861
--- /dev/null
+++ b/Bitkit/Views/Offline/OfflineConnectionContent.swift
@@ -0,0 +1,111 @@
+import SwiftUI
+
+/// Shared offline content used by both full-screen and sheet variants.
+struct OfflineConnectionContent: View {
+ private var outerRingRadii: [CGFloat] {
+ UIScreen.main.isSmall ? [150] : [200]
+ }
+
+ private var innerRingRadii: [CGFloat] {
+ UIScreen.main.isSmall ? [100, 50] : [150, 100, 50]
+ }
+
+ private var maxRingRadius: CGFloat {
+ max(outerRingRadii.max() ?? 0, innerRingRadii.max() ?? 0)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Spacer()
+
+ ZStack(alignment: .bottom) {
+ let ringCanvasHeight = DashedRingsLayer.fittingHeight(maxRadius: maxRingRadius)
+
+ DashedRingsLayer(radii: outerRingRadii)
+ .frame(maxWidth: .infinity)
+ .frame(height: ringCanvasHeight)
+
+ Image("phone")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: UIScreen.main.bounds.width * 0.8)
+ .frame(maxHeight: 311)
+
+ DashedRingsLayer(radii: innerRingRadii)
+ .frame(maxWidth: .infinity)
+ .frame(height: ringCanvasHeight)
+ }
+
+ VStack(alignment: .leading, spacing: 0) {
+ DisplayText(t("wallet__send_sync_long_title"), accentColor: .yellowAccent)
+ .padding(.top, 32)
+ .padding(.bottom, 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .layoutPriority(1)
+
+ BodyMText(t("wallet__send_sync_long_description"))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .layoutPriority(1)
+ }
+ .padding(.horizontal, 32)
+ .layoutPriority(1)
+
+ ActivityIndicator(size: 24)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 32)
+ }
+ }
+}
+
+// MARK: - Dashed Gradient Rings
+
+struct DashedRingsLayer: View {
+ let radii: [CGFloat]
+
+ /// Vertical position of ring center in canvas (0 = top). Tuned for bottom-aligned `Image("phone")` with `maxHeight` 311.
+ private static let ringCenterYFraction: CGFloat = 0.56
+
+ /// Canvas height so rings are not clipped for `center` at (0.22×width, `ringCenterYFraction`×height).
+ static func fittingHeight(maxRadius: CGFloat) -> CGFloat {
+ let cy = ringCenterYFraction
+ return max(
+ maxRadius / cy,
+ maxRadius / (1.0 - cy)
+ ) + 16
+ }
+
+ var body: some View {
+ Canvas { context, size in
+ let center = CGPoint(x: size.width * 0.22, y: size.height * Self.ringCenterYFraction)
+
+ 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(stops: [
+ .init(color: Color(white: 0), location: 0),
+ .init(color: Color(white: 0), location: 0.13),
+ .init(color: .yellowAccent, location: 1),
+ ])
+ 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, 8])
+ )
+ }
+ }
+ .allowsHitTesting(false)
+ }
+}
diff --git a/Bitkit/Views/Offline/OfflineScreen.swift b/Bitkit/Views/Offline/OfflineScreen.swift
new file mode 100644
index 00000000..c073313c
--- /dev/null
+++ b/Bitkit/Views/Offline/OfflineScreen.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+
+/// A view that displays when the user is offline.
+struct OfflineScreen: View {
+ let title: String
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ NavigationBar(title: title, showBackButton: true)
+ .padding(.horizontal, 16)
+
+ OfflineConnectionContent()
+ }
+ .navigationBarHidden(true)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .bottomSafeAreaPadding()
+ .background(Color.black)
+ .accessibilityIdentifier("ConnectionIssuesScreen")
+ }
+}
+
+// MARK: - View Modifier
+
+private struct OfflineOverlayModifier: ViewModifier {
+ @EnvironmentObject private var network: NetworkMonitor
+
+ let title: String
+
+ func body(content: Content) -> some View {
+ ZStack {
+ content
+
+ if !network.isConnected {
+ OfflineScreen(title: title)
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: network.isConnected)
+ }
+}
+
+extension View {
+ /// Overlays a `OfflineScreen` when the device is offline.
+ /// The underlying content remains mounted so navigation state and inputs are preserved.
+ func offlineOverlay(title: String) -> some View {
+ modifier(OfflineOverlayModifier(title: title))
+ }
+}
diff --git a/Bitkit/Views/Offline/OfflineSheetScreen.swift b/Bitkit/Views/Offline/OfflineSheetScreen.swift
new file mode 100644
index 00000000..a163ca01
--- /dev/null
+++ b/Bitkit/Views/Offline/OfflineSheetScreen.swift
@@ -0,0 +1,54 @@
+import SwiftUI
+
+/// A view that displays when the user is offline.
+struct OfflineSheetScreen: View {
+ let title: String
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ SheetHeader(title: title, showBackButton: false)
+ .padding(.horizontal, 16)
+
+ OfflineConnectionContent()
+ }
+ .navigationBarHidden(true)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .bottomSafeAreaPadding()
+ .sheetBackground()
+ .accessibilityIdentifier("ConnectionIssuesSheetScreen")
+ }
+}
+
+// MARK: - View Modifier
+
+private struct OfflineSheetOverlayModifier: ViewModifier {
+ @EnvironmentObject private var network: NetworkMonitor
+
+ let title: String
+
+ func body(content: Content) -> some View {
+ ZStack(alignment: .top) {
+ content
+
+ if !network.isConnected {
+ OfflineSheetScreen(title: title)
+ .transition(.opacity)
+
+ // Custom drag indicator - always on top
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Color.white32)
+ .frame(width: 32, height: 4)
+ .padding(.top, 12)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: network.isConnected)
+ }
+}
+
+extension View {
+ /// Overlays a `OfflineSheetScreen` when the device is offline.
+ /// The underlying content remains mounted so navigation state and inputs are preserved.
+ func offlineSheetOverlay(title: String) -> some View {
+ modifier(OfflineSheetOverlayModifier(title: title))
+ }
+}
diff --git a/Bitkit/Views/Settings/AppStatusView.swift b/Bitkit/Views/Settings/AppStatusView.swift
index daa2e069..d7ea925b 100644
--- a/Bitkit/Views/Settings/AppStatusView.swift
+++ b/Bitkit/Views/Settings/AppStatusView.swift
@@ -13,7 +13,7 @@ struct AppStatusView: View {
NavigationBar(title: t("settings__status__title"))
.padding(.bottom, 16)
- ScrollView {
+ ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
internetStatusRow
bitcoinNodeStatusRow
@@ -90,7 +90,7 @@ struct AppStatusView: View {
private var bitcoinNodeStatusRow: some View {
let status = AppStatusHelper.bitcoinNodeStatus(from: wallet, network: network)
- let description: String = t("settings__status__electrum__\(status.rawValue)")
+ let description = t("settings__status__electrum__\(status.rawValue)")
return StatusRow(
imageName: "status-bitcoin",
@@ -106,7 +106,7 @@ struct AppStatusView: View {
private var nodeStatusRow: some View {
let status = AppStatusHelper.nodeStatus(from: wallet, network: network)
- let description: String = t("settings__status__lightning_node__\(status.rawValue)")
+ let description = t("settings__status__lightning_node__\(status.rawValue)")
return StatusRow(
imageName: "status-node",
@@ -121,8 +121,8 @@ struct AppStatusView: View {
}
private var channelsStatusRow: some View {
- let status = AppStatusHelper.channelsStatus(from: wallet)
- let description: String = t("settings__status__lightning_connection__\(status.rawValue)")
+ let status = AppStatusHelper.channelsStatus(from: wallet, network: network)
+ let description = t("settings__status__lightning_connection__\(status.rawValue)")
return StatusRow(
imageName: "status-lightning",
diff --git a/Bitkit/Views/Sheets/ForceTransferSheet.swift b/Bitkit/Views/Sheets/ForceTransferSheet.swift
index 9fba2250..32b373e2 100644
--- a/Bitkit/Views/Sheets/ForceTransferSheet.swift
+++ b/Bitkit/Views/Sheets/ForceTransferSheet.swift
@@ -9,6 +9,7 @@ struct ForceTransferSheet: View {
@EnvironmentObject private var app: AppViewModel
@EnvironmentObject private var sheets: SheetViewModel
@EnvironmentObject private var transfer: TransferViewModel
+
let config: ForceTransferSheetItem
@State private var isLoading = false
@@ -29,6 +30,7 @@ struct ForceTransferSheet: View {
onContinue: onForceTransfer
)
}
+ .offlineSheetOverlay(title: t("lightning__force_nav_title"))
}
private func onCancel() {
diff --git a/Bitkit/Views/Shop/ShopDiscover.swift b/Bitkit/Views/Shop/ShopDiscover.swift
index d54a0b4b..74aec234 100644
--- a/Bitkit/Views/Shop/ShopDiscover.swift
+++ b/Bitkit/Views/Shop/ShopDiscover.swift
@@ -34,8 +34,11 @@ enum ShopTab: String, CaseIterable, CustomStringConvertible {
struct ShopDiscover: View {
@EnvironmentObject var navigation: NavigationViewModel
+
@State private var selectedTab: ShopTab = .shop
+ let navTitle = t("other__shop__discover__nav_title")
+
/// Categories data
private let categories: [ShopCategory] = [
ShopCategory(title: "Apparel", route: "buy/apparel", iconName: "pedestrian"),
@@ -96,7 +99,7 @@ struct ShopDiscover: View {
var body: some View {
VStack(spacing: 0) {
- NavigationBar(title: t("other__shop__discover__nav_title"))
+ NavigationBar(title: navTitle)
.padding(.horizontal, 16)
SegmentedControl(selectedTab: $selectedTab, tabs: ShopTab.allCases, activeColor: .yellowAccent)
@@ -115,6 +118,7 @@ struct ShopDiscover: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationBarHidden(true)
+ .offlineOverlay(title: navTitle)
}
private var shopContent: some View {
diff --git a/Bitkit/Views/Shop/ShopMain.swift b/Bitkit/Views/Shop/ShopMain.swift
index e8ef5cdb..38c4cc73 100644
--- a/Bitkit/Views/Shop/ShopMain.swift
+++ b/Bitkit/Views/Shop/ShopMain.swift
@@ -10,6 +10,8 @@ struct ShopMain: View {
let page: String
+ let navTitle = t("other__shop__main__nav_title")
+
private var uri: String {
let baseUrl = "https://embed.bitrefill.com"
let paymentMethod = "bitcoin" // Payment method "bitcoin" gives a unified invoice
@@ -19,13 +21,14 @@ struct ShopMain: View {
var body: some View {
VStack(spacing: 0) {
- NavigationBar(title: t("other__shop__main__nav_title"))
+ NavigationBar(title: navTitle)
ShopWebView(url: uri, onMessage: handleMessage)
.padding(.top, 16)
}
.navigationBarHidden(true)
.padding(.horizontal, 16)
+ .offlineOverlay(title: navTitle)
}
private func handleMessage(_ message: String) {
diff --git a/Bitkit/Views/Transfer/SavingsConfirmView.swift b/Bitkit/Views/Transfer/SavingsConfirmView.swift
index 7c7f7681..bdde774e 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()
+ .offlineOverlay(title: t("lightning__transfer__nav_title"))
}
}
diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift
index fdfe4483..b710b393 100644
--- a/Bitkit/Views/Transfer/SettingUpView.swift
+++ b/Bitkit/Views/Transfer/SettingUpView.swift
@@ -1,123 +1,6 @@
import BitkitCore
import SwiftUI
-struct SettingUpLoadingView: View {
- @State private var outerRotation: Double = 0
- @State private var innerRotation: Double = 0
- @State private var transferRotation: Double = 0
-
- var size: (container: CGFloat, image: CGFloat, inner: CGFloat) {
- let container: CGFloat = UIScreen.main.isSmall ? 200 : 320
- let image = container * 0.8
- let inner = container * 0.7
-
- return (container: container, image: image, inner: inner)
- }
-
- var body: some View {
- ZStack(alignment: .center) {
- // Outer ellipse
- Image("ellipse-outer-purple")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: size.container, height: size.container)
- .rotationEffect(.degrees(outerRotation))
-
- // Inner ellipse
- Image("ellipse-inner-purple")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: size.inner, height: size.inner)
- .rotationEffect(.degrees(innerRotation))
-
- // Transfer image
- Image("transfer-figure")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: size.image, height: size.image)
- .rotationEffect(.degrees(transferRotation))
- }
- .frame(width: size.container, height: size.container)
- .clipped()
- .frame(maxWidth: .infinity)
- .onAppear {
- withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
- outerRotation = -90
- }
-
- withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
- innerRotation = 120
- }
-
- withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
- transferRotation = 90
- }
- }
- }
-}
-
-struct ProgressSteps: View {
- let steps: [String]
- let currentStep: Int
-
- var body: some View {
- VStack(spacing: 0) {
- // Steps with circles and separators
- GeometryReader { geometry in
- ZStack(alignment: .center) {
- // Dashed line background
- Path { path in
- let y = geometry.size.height / 2
- let padding = 36.0 * 2.5 // Account for circle radius (16) + horizontal padding (20)
- path.move(to: CGPoint(x: padding, y: y))
- path.addLine(to: CGPoint(x: geometry.size.width - padding, y: y))
- }
- .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
- .foregroundColor(Color.white32)
-
- // Circles with numbers
- HStack(spacing: 0) {
- ForEach(Array(steps.enumerated()), id: \.0) { index, _ in
- // Circle with number or checkmark
- ZStack {
- Circle()
- .fill(index < currentStep ? Color.purpleAccent : Color.black)
- .frame(width: 32, height: 32)
-
- if index < currentStep {
- // Checkmark for completed steps
- Image("check-mark")
- .foregroundColor(.black)
- } else {
- // Number for current and upcoming steps
- Text("\(index + 1)")
- .foregroundColor(index == currentStep ? Color.purpleAccent : .white32)
- .font(.custom(Fonts.regular, size: 17))
- }
-
- // Border for current step
- if index == currentStep {
- Circle()
- .stroke(Color.purpleAccent, lineWidth: 2)
- .frame(width: 32, height: 32)
- } else if index > currentStep {
- Circle()
- .stroke(Color.white32, lineWidth: 1)
- .frame(width: 32, height: 32)
- }
- }
- .padding(.horizontal, 16)
- }
- }
- }
- }
-
- BodySSBText(steps[currentStep], textColor: .white32)
- .padding(.top, 16)
- }
- }
-}
-
struct SettingUpView: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var navigation: NavigationViewModel
@@ -158,17 +41,18 @@ struct SettingUpView: View {
NavigationBar(title: navTitle, showBackButton: false)
.padding(.bottom, 16)
- VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 0) {
DisplayText(title, accentColor: .purpleAccent)
- .fixedSize(horizontal: false, vertical: true)
+ .padding(.bottom, 14)
+ .layoutPriority(1)
BodyMText(text, accentColor: .white, accentFont: Fonts.bold)
- .fixedSize(horizontal: false, vertical: true)
-
- Spacer()
+ .layoutPriority(1)
if isTransferring {
- SettingUpLoadingView()
+ EllipseLoader(variant: .transfer)
+ .padding(.top, 32)
+ .padding(.horizontal, 16)
.accessibilityIdentifier("LightningSettingUp")
} else {
Image("check")
@@ -184,7 +68,7 @@ struct SettingUpView: View {
if isTransferring {
ProgressSteps(steps: steps, currentStep: transfer.lightningSetupStep)
- .padding(.bottom, 16)
+ .padding(.vertical, 16)
}
CustomButton(title: buttonTitle) {
@@ -198,8 +82,6 @@ struct SettingUpView: View {
.padding(.horizontal, 16)
.bottomSafeAreaPadding()
.onAppear {
- Logger.debug("View appeared - TransferViewModel is handling order updates")
-
// Auto-mine a block in regtest mode after a 5-second delay
if Env.network == .regtest {
Task {
diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift
index 75083219..98d14017 100644
--- a/Bitkit/Views/Transfer/SpendingAmount.swift
+++ b/Bitkit/Views/Transfer/SpendingAmount.swift
@@ -77,6 +77,7 @@ struct SpendingAmount: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
.bottomSafeAreaPadding()
+ .offlineOverlay(title: t("lightning__transfer__nav_title"))
.task(id: blocktank.info?.options.maxChannelSizeSat) {
await calculateMaxTransferAmount()
}
diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift
index 9501d9a3..77eef6ed 100644
--- a/Bitkit/Views/Transfer/SpendingConfirm.swift
+++ b/Bitkit/Views/Transfer/SpendingConfirm.swift
@@ -131,6 +131,7 @@ struct SpendingConfirm: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
.bottomSafeAreaPadding()
+ .offlineOverlay(title: t("lightning__transfer__nav_title"))
.task {
await calculateTransactionFee()
}
diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
index 9cf6a790..11ddfaf6 100644
--- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
+++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
@@ -30,10 +30,12 @@ struct ReceiveSheetItem: SheetItem {
}
struct ReceiveSheet: View {
+ @EnvironmentObject private var tagManager: TagManager
+ @EnvironmentObject private var wallet: WalletViewModel
+
let config: ReceiveSheetItem
+
@State private var navigationPath: [ReceiveRoute] = []
- @EnvironmentObject private var wallet: WalletViewModel
- @EnvironmentObject private var tagManager: TagManager
var body: some View {
Sheet(id: .receive, data: config) {
@@ -44,6 +46,7 @@ struct ReceiveSheet: View {
}
}
}
+ .offlineSheetOverlay(title: t("wallet__receive_bitcoin"))
.onAppear {
wallet.invoiceAmountSats = 0
wallet.invoiceNote = ""
diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift
index 36c9892b..d6b5c78e 100644
--- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift
+++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift
@@ -1,53 +1,6 @@
import LDKNode
import SwiftUI
-struct LoadingView: View {
- @State private var outerRotation: Double = 0
- @State private var innerRotation: Double = 0
- @State private var imageRotation: Double = 0
-
- var body: some View {
- ZStack(alignment: .center) {
- // Outer ellipse
- Image("ellipse-outer-purple")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 311, height: 311)
- .rotationEffect(.degrees(outerRotation))
-
- // Inner ellipse
- Image("ellipse-inner-purple")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 207, height: 207)
- .rotationEffect(.degrees(innerRotation))
-
- // Image
- Image("coin-stack-4")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 330, height: 330)
- .rotationEffect(.degrees(imageRotation))
- }
- .frame(width: 320, height: 320)
- .clipped()
- .frame(maxWidth: .infinity)
- .onAppear {
- withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
- outerRotation = -180
- }
-
- withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
- innerRotation = 180
- }
-
- withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
- imageRotation = 20
- }
- }
- }
-}
-
struct SendQuickpay: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var sheets: SheetViewModel
@@ -65,11 +18,12 @@ struct SendQuickpay: View {
MoneyStack(sats: Int(invoice.amountSatoshis), showSymbol: true)
}
- Spacer()
+ Spacer(minLength: 32)
- LoadingView()
+ EllipseLoader(variant: .quickpay)
+ .padding(.horizontal, 16)
- Spacer()
+ Spacer(minLength: 32)
DisplayText(t("wallet__send_quickpay__title"), accentColor: .purpleAccent)
}
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 3d3883ca..b65667cf 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -57,6 +57,8 @@ struct SendSheet: View {
/// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue.
/// For onchain: only need node running.
private var shouldShowSyncOverlay: Bool {
+ Logger.debug("shouldShowSyncOverlay: \(wallet.nodeLifecycleState)", context: "SendSheet")
+
// Node must be running
guard wallet.nodeLifecycleState == .running else { return true }
@@ -82,7 +84,7 @@ struct SendSheet: View {
var body: some View {
Sheet(id: .send, data: config) {
if shouldShowSyncOverlay {
- SyncNodeView()
+ SendSyncScreen()
.transition(.opacity)
} else {
NavigationStack(path: $navigationPath) {
@@ -95,6 +97,7 @@ struct SendSheet: View {
}
}
.animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay)
+ .offlineSheetOverlay(title: t("wallet__send_bitcoin"))
.onAppear {
tagManager.clearSelectedTags()
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
diff --git a/Bitkit/Views/Wallets/Send/SendSyncScreen.swift b/Bitkit/Views/Wallets/Send/SendSyncScreen.swift
new file mode 100644
index 00000000..ea5044a8
--- /dev/null
+++ b/Bitkit/Views/Wallets/Send/SendSyncScreen.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+
+/// A view that displays while the node is syncing.
+/// Used as an overlay on screens that require the node to be fully synced.
+struct SendSyncScreen: View {
+ @EnvironmentObject var wallet: WalletViewModel
+
+ /// Optional callback when sync completes
+ var onSyncComplete: (() -> Void)?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ ZStack(alignment: .topLeading) {
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: t("wallet__send_bitcoin"), showBackButton: false)
+
+ Spacer()
+
+ VStack(spacing: 0) {
+ EllipseLoader(variant: .sync)
+
+ DisplayText(t("wallet__send_sync_title"), accentColor: .purpleAccent)
+ .padding(.top, 32)
+ .padding(.bottom, 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .layoutPriority(1)
+
+ BodyMText(t("wallet__send_sync_description"))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .layoutPriority(1)
+ }
+
+ HStack(alignment: .center) {
+ ActivityIndicator(size: 24)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.top, 32)
+ }
+ .padding(.horizontal, 32)
+ }
+ }
+ .navigationBarHidden(true)
+ .sheetBackground()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onChange(of: wallet.isSyncingWallet) { _, newValue in
+ if !newValue {
+ onSyncComplete?()
+ }
+ }
+ }
+}
diff --git a/changelog.d/next/528.added.md b/changelog.d/next/528.added.md
new file mode 100644
index 00000000..797e74bd
--- /dev/null
+++ b/changelog.d/next/528.added.md
@@ -0,0 +1 @@
+Add UI for offline state in various flows