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