diff --git a/visionOS/MempoolVisionOS.xcodeproj/project.pbxproj b/visionOS/MempoolVisionOS.xcodeproj/project.pbxproj index 834c163fd0..1859bdaa11 100644 --- a/visionOS/MempoolVisionOS.xcodeproj/project.pbxproj +++ b/visionOS/MempoolVisionOS.xcodeproj/project.pbxproj @@ -22,6 +22,11 @@ B1C2D3E4F5A67890123456789012346A /* MempoolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A67890123456789012346B /* MempoolView.swift */; }; B1C2D3E4F5A67890123456789012346C /* TransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A67890123456789012346D /* TransactionView.swift */; }; B1C2D3E4F5A67890123456789012346E /* UTXOView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A67890123456789012346F /* UTXOView.swift */; }; + B1C2D3E4F5A67890123456789012348A /* MempoolStrata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123485 /* MempoolStrata.swift */; }; + B1C2D3E4F5A67890123456789012348B /* RecommendedFees.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123486 /* RecommendedFees.swift */; }; + B1C2D3E4F5A67890123456789012348C /* FeePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123487 /* FeePanelView.swift */; }; + B1C2D3E4F5A67890123456789012348D /* SearchPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123488 /* SearchPanelView.swift */; }; + B1C2D3E4F5A67890123456789012348E /* TransactionImmersiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123489 /* TransactionImmersiveView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -41,6 +46,11 @@ B1C2D3E4F5A67890123456789012346D /* TransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionView.swift; sourceTree = ""; }; B1C2D3E4F5A67890123456789012346F /* UTXOView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTXOView.swift; sourceTree = ""; }; B1C2D3E4F5A678901234567890123470 /* MempoolVisionOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MempoolVisionOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B1C2D3E4F5A678901234567890123485 /* MempoolStrata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MempoolStrata.swift; sourceTree = ""; }; + B1C2D3E4F5A678901234567890123486 /* RecommendedFees.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendedFees.swift; sourceTree = ""; }; + B1C2D3E4F5A678901234567890123487 /* FeePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeePanelView.swift; sourceTree = ""; }; + B1C2D3E4F5A678901234567890123488 /* SearchPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPanelView.swift; sourceTree = ""; }; + B1C2D3E4F5A678901234567890123489 /* TransactionImmersiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionImmersiveView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -76,6 +86,8 @@ B1C2D3E4F5A678901234567890123463 /* Block.swift */, B1C2D3E4F5A678901234567890123465 /* Transaction.swift */, B1C2D3E4F5A678901234567890123467 /* UTXO.swift */, + B1C2D3E4F5A678901234567890123485 /* MempoolStrata.swift */, + B1C2D3E4F5A678901234567890123486 /* RecommendedFees.swift */, ); path = Models; sourceTree = ""; @@ -89,6 +101,9 @@ B1C2D3E4F5A67890123456789012346B /* MempoolView.swift */, B1C2D3E4F5A67890123456789012346D /* TransactionView.swift */, B1C2D3E4F5A67890123456789012346F /* UTXOView.swift */, + B1C2D3E4F5A678901234567890123487 /* FeePanelView.swift */, + B1C2D3E4F5A678901234567890123488 /* SearchPanelView.swift */, + B1C2D3E4F5A678901234567890123489 /* TransactionImmersiveView.swift */, ); path = Views; sourceTree = ""; @@ -223,6 +238,11 @@ B1C2D3E4F5A67890123456789012346E /* UTXOView.swift in Sources */, B1C2D3E4F5A678901234567890123456 /* MempoolVisionOSApp.swift in Sources */, 00175B112E51571A003D45DF /* Blockchain3DView.swift in Sources */, + B1C2D3E4F5A67890123456789012348A /* MempoolStrata.swift in Sources */, + B1C2D3E4F5A67890123456789012348B /* RecommendedFees.swift in Sources */, + B1C2D3E4F5A67890123456789012348C /* FeePanelView.swift in Sources */, + B1C2D3E4F5A67890123456789012348D /* SearchPanelView.swift in Sources */, + B1C2D3E4F5A67890123456789012348E /* TransactionImmersiveView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/visionOS/MempoolVisionOS/Models/MempoolStrata.swift b/visionOS/MempoolVisionOS/Models/MempoolStrata.swift new file mode 100644 index 0000000000..dac9c1f7d6 --- /dev/null +++ b/visionOS/MempoolVisionOS/Models/MempoolStrata.swift @@ -0,0 +1,21 @@ +import Foundation + +struct MempoolStrata: Identifiable, Codable { + let id = UUID() + let feeRange: ClosedRange + let transactionCount: Int + let totalSize: Int + let averageFee: Double + let color: StrataColor + + enum StrataColor: String, Codable, CaseIterable { + case red = "high" + case orange = "medium" + case yellow = "low" + case green = "minimal" + } + + var visualHeight: Float { + return Float(transactionCount) / 1000.0 * Float(averageFee) / 50.0 + } +} diff --git a/visionOS/MempoolVisionOS/Models/RecommendedFees.swift b/visionOS/MempoolVisionOS/Models/RecommendedFees.swift new file mode 100644 index 0000000000..fd493510a8 --- /dev/null +++ b/visionOS/MempoolVisionOS/Models/RecommendedFees.swift @@ -0,0 +1,22 @@ +import Foundation + +struct RecommendedFees: Codable { + let fastestFee: Int + let halfHourFee: Int + let hourFee: Int + let economyFee: Int + let minimumFee: Int +} + +struct SearchResult: Identifiable, Codable { + let id = UUID() + let type: SearchResultType + let title: String + let subtitle: String + + enum SearchResultType: String, Codable { + case transaction + case address + case block + } +} diff --git a/visionOS/MempoolVisionOS/Services/MempoolService.swift b/visionOS/MempoolVisionOS/Services/MempoolService.swift index 7d7a466107..6b806ab0f8 100644 --- a/visionOS/MempoolVisionOS/Services/MempoolService.swift +++ b/visionOS/MempoolVisionOS/Services/MempoolService.swift @@ -3,11 +3,16 @@ import Combine class MempoolService: ObservableObject { private let baseURL = "https://mempool.space/api/v1" + private let wsURL = "wss://mempool.space/api/v1/ws" + private var webSocketTask: URLSessionWebSocketTask? @Published var blocks: [Block] = [] @Published var mempoolTransactions: [Transaction] = [] + @Published var mempoolStrata: [MempoolStrata] = [] + @Published var recommendedFees: RecommendedFees? @Published var isLoading = false @Published var error: String? + @Published var isConnectedToWebSocket = false func fetchBlocks() async { await MainActor.run { isLoading = true } @@ -95,4 +100,180 @@ class MempoolService: ObservableObject { } } } -} \ No newline at end of file + + func connectWebSocket() { + guard let url = URL(string: wsURL) else { return } + + webSocketTask = URLSession.shared.webSocketTask(with: url) + webSocketTask?.resume() + + sendWebSocketMessage(["action": "want", "data": ["mempool-blocks", "stats", "blocks"]]) + + receiveWebSocketMessage() + + DispatchQueue.main.async { + self.isConnectedToWebSocket = true + } + } + + private func sendWebSocketMessage(_ message: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: message), + let string = String(data: data, encoding: .utf8) else { return } + + let message = URLSessionWebSocketTask.Message.string(string) + webSocketTask?.send(message) { error in + if let error = error { + print("❌ WebSocket send error: \(error)") + } + } + } + + private func receiveWebSocketMessage() { + webSocketTask?.receive { [weak self] result in + switch result { + case .success(let message): + self?.handleWebSocketMessage(message) + self?.receiveWebSocketMessage() + case .failure(let error): + print("❌ WebSocket receive error: \(error)") + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self?.connectWebSocket() + } + } + } + } + + private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) { + switch message { + case .string(let text): + guard let data = text.data(using: .utf8) else { return } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + DispatchQueue.main.async { + self.processWebSocketData(json) + } + } + } catch { + print("❌ Error parsing WebSocket message: \(error)") + } + case .data(let data): + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + DispatchQueue.main.async { + self.processWebSocketData(json) + } + } + } catch { + print("❌ Error parsing WebSocket data: \(error)") + } + @unknown default: + break + } + } + + private func processWebSocketData(_ json: [String: Any]) { + if let feesData = json["fees"] as? [String: Any] { + if let fastest = feesData["fastestFee"] as? Int, + let halfHour = feesData["halfHourFee"] as? Int, + let hour = feesData["hourFee"] as? Int, + let economy = feesData["economyFee"] as? Int, + let minimum = feesData["minimumFee"] as? Int { + + self.recommendedFees = RecommendedFees( + fastestFee: fastest, + halfHourFee: halfHour, + hourFee: hour, + economyFee: economy, + minimumFee: minimum + ) + } + } + + if let mempoolBlocksData = json["mempool-blocks"] as? [[String: Any]] { + processMempoolBlocks(mempoolBlocksData) + } + } + + private func processMempoolBlocks(_ mempoolBlocks: [[String: Any]]) { + var strata: [MempoolStrata] = [] + + for (index, blockData) in mempoolBlocks.enumerated() { + if let feeRange = blockData["feeRange"] as? [Double], + let nTx = blockData["nTx"] as? Int, + let totalSize = blockData["totalSize"] as? Int, + let medianFee = blockData["medianFee"] as? Double, + feeRange.count >= 2 { + + let color: MempoolStrata.StrataColor + if medianFee > 100 { + color = .red + } else if medianFee > 50 { + color = .orange + } else if medianFee > 20 { + color = .yellow + } else { + color = .green + } + + let stratum = MempoolStrata( + feeRange: feeRange[0]...feeRange[1], + transactionCount: nTx, + totalSize: totalSize, + averageFee: medianFee, + color: color + ) + + strata.append(stratum) + } + } + + self.mempoolStrata = strata + } + + func searchTransactionOrAddress(_ query: String) async -> [SearchResult] { + guard !query.isEmpty else { return [] } + + var results: [SearchResult] = [] + + if query.count == 64 && query.allSatisfy({ $0.isHexDigit }) { + do { + let url = URL(string: "\(baseURL)/tx/\(query)")! + let (_, response) = try await URLSession.shared.data(from: url) + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { + results.append(SearchResult( + type: .transaction, + title: "Transaction", + subtitle: "\(query.prefix(16))..." + )) + } + } catch { + print("Transaction search failed: \(error)") + } + } + + if query.count >= 26 && query.count <= 62 { + do { + let url = URL(string: "\(baseURL)/address/\(query)")! + let (_, response) = try await URLSession.shared.data(from: url) + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { + results.append(SearchResult( + type: .address, + title: "Address", + subtitle: "\(query.prefix(20))..." + )) + } + } catch { + print("Address search failed: \(error)") + } + } + + return results + } + + func disconnectWebSocket() { + webSocketTask?.cancel() + webSocketTask = nil + isConnectedToWebSocket = false + } +} diff --git a/visionOS/MempoolVisionOS/ViewModels/BlockchainViewModel.swift b/visionOS/MempoolVisionOS/ViewModels/BlockchainViewModel.swift index eb6b23e4eb..b532dae4be 100644 --- a/visionOS/MempoolVisionOS/ViewModels/BlockchainViewModel.swift +++ b/visionOS/MempoolVisionOS/ViewModels/BlockchainViewModel.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import RealityKit +import Combine @MainActor class BlockchainViewModel: ObservableObject { @@ -12,8 +13,13 @@ class BlockchainViewModel: ObservableObject { @Published var cameraPosition: SIMD3 = SIMD3(0, 0, 5) @Published var feeDistribution: [Double] = [] @Published var errorMessage: String? + @Published var mempoolStrata: [MempoolStrata] = [] + @Published var recommendedFees: RecommendedFees? + @Published var isConnectedToWebSocket = false + @Published var searchResults: [SearchResult] = [] private let mempoolService = MempoolService() + private var cancellables = Set() enum ViewType { case blockchain @@ -34,6 +40,23 @@ class BlockchainViewModel: ObservableObject { var mempoolTransactions: [Transaction] { mempoolService.mempoolTransactions } + + init() { + mempoolService.$mempoolStrata + .receive(on: DispatchQueue.main) + .assign(to: \.mempoolStrata, on: self) + .store(in: &cancellables) + + mempoolService.$recommendedFees + .receive(on: DispatchQueue.main) + .assign(to: \.recommendedFees, on: self) + .store(in: &cancellables) + + mempoolService.$isConnectedToWebSocket + .receive(on: DispatchQueue.main) + .assign(to: \.isConnectedToWebSocket, on: self) + .store(in: &cancellables) + } func loadData() async { isLoading = true @@ -77,4 +100,16 @@ class BlockchainViewModel: ObservableObject { func clearError() { errorMessage = nil } -} \ No newline at end of file + + func connectToRealTimeData() { + mempoolService.connectWebSocket() + } + + func searchTransactionOrAddress(_ query: String) async -> [SearchResult] { + let results = await mempoolService.searchTransactionOrAddress(query) + await MainActor.run { + self.searchResults = results + } + return results + } +} diff --git a/visionOS/MempoolVisionOS/Views/BlockchainImmersiveView.swift b/visionOS/MempoolVisionOS/Views/BlockchainImmersiveView.swift index 71405212aa..e7b7c8d26b 100644 --- a/visionOS/MempoolVisionOS/Views/BlockchainImmersiveView.swift +++ b/visionOS/MempoolVisionOS/Views/BlockchainImmersiveView.swift @@ -7,17 +7,21 @@ struct BlockchainImmersiveView: View { @StateObject private var viewModel = BlockchainViewModel() @State private var selectedBlockIndex: Int? = nil @State private var eyeTrackingTimer: Timer? - @State private var rootEntity: Entity? // Added for ray casting - @State private var lookedAtBlockIndex: Int? = nil // Track which block user is looking at - @State private var chainOffset: SIMD3 = SIMD3(0, 0, 0) // Track chain position - @State private var baseChainDistance: Float = 0.0 // Base distance for depth control - @State private var isInteracting = false // Track if user is actively interacting - @State private var lastDragTranslation = CGSize.zero // Track last drag translation - @State private var chainVelocity: SIMD3 = SIMD3(0, 0, 0) // Momentum velocity - @State private var decelerationTimer: Timer? // Timer to ease out momentum smoothly - @State private var lastDepthDeltaZ: Float = 0 // Last per-frame depth delta during pinch - @State private var lastDepthUpdateTime: TimeInterval = 0 // Timestamp of last pinch update - @State private var lastMagnificationValue: CGFloat = 1.0 // Previous pinch value for delta computation + @State private var rootEntity: Entity? + @State private var lookedAtBlockIndex: Int? + @State private var chainOffset: SIMD3 = SIMD3(0, 0, 0) + @State private var baseChainDistance: Float = 0.0 + @State private var isInteracting = false + @State private var lastDragTranslation = CGSize.zero + @State private var chainVelocity: SIMD3 = SIMD3(0, 0, 0) + @State private var decelerationTimer: Timer? + @State private var lastDepthDeltaZ: Float = 0 + @State private var lastDepthUpdateTime: TimeInterval = 0 + @State private var lastMagnificationValue: CGFloat = 1.0 + @State private var mempoolStrata: [MempoolStrata] = [] + @State private var showMempoolView = false + @State private var mempoolEntity: Entity? + @State private var selectedStratum: MempoolStrata? // UI toggle for immersion style (Hashable for Picker) private enum ImmersionOption: String, CaseIterable, Hashable { case mixed, full } @@ -135,21 +139,27 @@ struct BlockchainImmersiveView: View { .padding(.vertical, 8) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) } - .offset(y: -200) // Position it above center, in front of user + .offset(y: -200) + } + .overlay(alignment: .topLeading) { + FeePanelView(viewModel: viewModel) + .offset(x: 50, y: 100) + } + .overlay(alignment: .topTrailing) { + SearchPanelView(viewModel: viewModel) + .offset(x: -50, y: 100) } .onAppear { print("🚀 BlockchainImmersiveView appeared - starting data load...") - startEyeTracking() // Start eye tracking - // Load real blockchain data + startEyeTracking() Task { print("📡 Starting data load task...") await viewModel.loadData() + viewModel.connectToRealTimeData() print("🎯 Data load task completed. Blocks available: \(viewModel.blocks.count)") - // Add a small delay to avoid state modification during view update - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + try? await Task.sleep(nanoseconds: 100_000_000) - // Create block entities after data loads await MainActor.run { print("🎯 About to call createBlockEntities from MainActor") print("🎯 Current blocks count: \(viewModel.blocks.count)") @@ -158,6 +168,12 @@ struct BlockchainImmersiveView: View { } } } + .onReceive(viewModel.$mempoolStrata) { strata in + self.mempoolStrata = strata + if showMempoolView { + createMempoolStrataVisualization() + } + } .onChange(of: immersionOption) { _, newValue in switch newValue { case .mixed: immersionStyle = .mixed @@ -723,7 +739,7 @@ struct BlockchainImmersiveView: View { if selectedBlockIndex == index || lookedAtBlockIndex == index { // Selected or looked at block - bright cyan glow with crystal clear transparency var selectedMaterial = SimpleMaterial() - selectedMaterial.color = .init(tint: .cyan) + selectedMaterial.color = PhysicallyBasedMaterial.BaseColor(tint: .cyan) selectedMaterial.roughness = .init(floatLiteral: 0.1) // Very smooth for glass effect selectedMaterial.metallic = .init(floatLiteral: 0.0) // Non-metallic for transparency // selectedMaterial.faceCulling = .none // faceCulling unavailable in visionOS @@ -732,7 +748,7 @@ struct BlockchainImmersiveView: View { // Crystal clear glass material - truly transparent for bright content visibility var clearMaterial = SimpleMaterial() // Use pure white with very low alpha for maximum transparency - clearMaterial.color = .init(tint: UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.1)) + clearMaterial.color = PhysicallyBasedMaterial.BaseColor(tint: UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.1)) clearMaterial.roughness = .init(floatLiteral: 0.05) // Very smooth for crystal clear effect clearMaterial.metallic = .init(floatLiteral: 0.0) // Non-metallic for transparency // clearMaterial.faceCulling = .none // faceCulling unavailable in visionOS @@ -755,7 +771,7 @@ struct BlockchainImmersiveView: View { ] var material = SimpleMaterial() - material.color = .init(tint: colors[index % colors.count]) + material.color = PhysicallyBasedMaterial.BaseColor(tint: colors[index % colors.count]) material.roughness = .init(floatLiteral: 0.2) // Smoother for better light reflection material.metallic = .init(floatLiteral: 0.0) // Non-metallic for stability @@ -1033,25 +1049,20 @@ struct BlockchainImmersiveView: View { guard let rootEntity = self.rootEntity else { return } let dt: Float = 1.0/120.0 - // Integrate position chainOffset += chainVelocity * dt rootEntity.transform.translation = chainOffset - // Apply friction (higher for smoother, longer glide) - let friction: Float = 0.985 + let friction: Float = 0.992 chainVelocity *= friction - // Stop when sufficiently slow let speed = abs(chainVelocity.x) + abs(chainVelocity.y) + abs(chainVelocity.z) - if speed < 0.000003 { - // Snap to a small grid to avoid sub-pixel jitter on text meshes - let snapped = self.roundVector(self.chainOffset, step: 0.0005) + if speed < 0.000001 { + let snapped = self.roundVector(self.chainOffset, step: 0.0001) self.chainOffset = snapped rootEntity.transform.translation = snapped self.decelerationTimer?.invalidate() self.decelerationTimer = nil self.isInteracting = false - // Persist new base Z for future pinches self.baseChainDistance = self.chainOffset.z } } @@ -1063,6 +1074,53 @@ struct BlockchainImmersiveView: View { chainVelocity = SIMD3(0, 0, 0) } + private func createMempoolStrataVisualization() { + guard let rootEntity = self.rootEntity else { return } + + mempoolEntity?.removeFromParent() + + let mempoolContainer = Entity() + mempoolContainer.name = "mempool_strata" + + for (index, stratum) in mempoolStrata.enumerated() { + let stratumEntity = createStratumEntity(stratum: stratum, index: index) + mempoolContainer.addChild(stratumEntity) + } + + mempoolContainer.position = SIMD3(-1.0, 0, -0.5) + rootEntity.addChild(mempoolContainer) + self.mempoolEntity = mempoolContainer + } + + private func createStratumEntity(stratum: MempoolStrata, index: Int) -> ModelEntity { + let height = max(0.05, stratum.visualHeight) + let width: Float = 0.3 + let depth: Float = 0.3 + + let mesh = MeshResource.generateBox(size: SIMD3(width, height, depth)) + var material = SimpleMaterial() + + switch stratum.color { + case .red: material.baseColor = .color(.red) + case .orange: material.baseColor = .color(.orange) + case .yellow: material.baseColor = .color(.yellow) + case .green: material.baseColor = .color(.green) + } + + material.roughness = 0.3 + material.metallic = 0.1 + + let entity = ModelEntity(mesh: mesh, materials: [material]) + + let yOffset = Float(index) * 0.1 + height / 2 + entity.position = SIMD3(0, yOffset, 0) + entity.name = "stratum_\(index)" + + entity.collision = CollisionComponent(shapes: [.generateBox(size: SIMD3(width, height, depth))]) + entity.components.set(InputTargetComponent()) + + return entity + } } diff --git a/visionOS/MempoolVisionOS/Views/FeePanelView.swift b/visionOS/MempoolVisionOS/Views/FeePanelView.swift new file mode 100644 index 0000000000..8f4b8a488f --- /dev/null +++ b/visionOS/MempoolVisionOS/Views/FeePanelView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct FeePanelView: View { + @ObservedObject var viewModel: BlockchainViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Fee Recommendations") + .font(.headline) + .foregroundColor(.white) + + if let fees = viewModel.recommendedFees { + HStack(spacing: 20) { + FeeOptionView( + title: "Fast", + fee: fees.fastestFee, + time: "~10 min", + color: .red + ) + + FeeOptionView( + title: "Medium", + fee: fees.halfHourFee, + time: "~30 min", + color: .orange + ) + + FeeOptionView( + title: "Slow", + fee: fees.hourFee, + time: "~1 hour", + color: .green + ) + } + } else { + ProgressView("Loading fees...") + .foregroundColor(.white) + } + + HStack { + Circle() + .fill(viewModel.isConnectedToWebSocket ? .green : .red) + .frame(width: 8, height: 8) + Text(viewModel.isConnectedToWebSocket ? "Live" : "Offline") + .font(.caption) + .foregroundColor(.gray) + } + } + .padding() + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .onAppear { + viewModel.connectToRealTimeData() + } + } +} + +struct FeeOptionView: View { + let title: String + let fee: Int + let time: String + let color: Color + + var body: some View { + VStack { + Text(title) + .font(.caption) + .foregroundColor(color) + Text("\(fee)") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + Text("sat/vB") + .font(.caption2) + .foregroundColor(.gray) + Text(time) + .font(.caption2) + .foregroundColor(.gray) + } + } +} diff --git a/visionOS/MempoolVisionOS/Views/MempoolView.swift b/visionOS/MempoolVisionOS/Views/MempoolView.swift index 15fb060cf2..963cb6d0de 100644 --- a/visionOS/MempoolVisionOS/Views/MempoolView.swift +++ b/visionOS/MempoolVisionOS/Views/MempoolView.swift @@ -4,18 +4,81 @@ struct MempoolView: View { @ObservedObject var viewModel: BlockchainViewModel var body: some View { - List(viewModel.mempoolTransactions.prefix(50), id: \.id) { transaction in - VStack(alignment: .leading) { - Text("TX: \(transaction.id.prefix(16))...") - .font(.headline) - Text("Fee: \(transaction.fee) sats") - Text("Fee Rate: \(transaction.feeRate, specifier: "%.2f") sat/vB") - Text("Size: \(transaction.size) bytes") - } - .onTapGesture { - viewModel.selectTransaction(transaction) + VStack { + if !viewModel.mempoolStrata.isEmpty { + Text("Mempool Fee Strata") + .font(.title2) + .padding() + + ScrollView { + LazyVStack(spacing: 12) { + ForEach(Array(viewModel.mempoolStrata.enumerated()), id: \.element.id) { index, stratum in + MempoolStrataRow(stratum: stratum, index: index) + } + } + .padding() + } + } else { + List(viewModel.mempoolTransactions.prefix(50), id: \.id) { transaction in + VStack(alignment: .leading) { + Text("TX: \(transaction.id.prefix(16))...") + .font(.headline) + Text("Fee: \(transaction.fee) sats") + Text("Fee Rate: \(transaction.feeRate, specifier: "%.2f") sat/vB") + Text("Size: \(transaction.size) bytes") + } + .onTapGesture { + viewModel.selectTransaction(transaction) + } + } } } .navigationTitle("Mempool") + .onAppear { + viewModel.connectToRealTimeData() + } + } +} + +struct MempoolStrataRow: View { + let stratum: MempoolStrata + let index: Int + + var body: some View { + HStack { + Rectangle() + .fill(colorForStratum(stratum.color)) + .frame(width: 20, height: 40) + .cornerRadius(4) + + VStack(alignment: .leading, spacing: 4) { + Text("Layer \(index + 1)") + .font(.headline) + Text("\(stratum.transactionCount) transactions") + .font(.subheadline) + Text("Avg Fee: \(stratum.averageFee, specifier: "%.1f") sat/vB") + .font(.caption) + Text("Range: \(stratum.feeRange.lowerBound, specifier: "%.1f") - \(stratum.feeRange.upperBound, specifier: "%.1f") sat/vB") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Text("\(stratum.totalSize) bytes") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + + private func colorForStratum(_ color: MempoolStrata.StrataColor) -> Color { + switch color { + case .red: return .red + case .orange: return .orange + case .yellow: return .yellow + case .green: return .green + } } } diff --git a/visionOS/MempoolVisionOS/Views/SearchPanelView.swift b/visionOS/MempoolVisionOS/Views/SearchPanelView.swift new file mode 100644 index 0000000000..85aedab7ef --- /dev/null +++ b/visionOS/MempoolVisionOS/Views/SearchPanelView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct SearchPanelView: View { + @ObservedObject var viewModel: BlockchainViewModel + @State private var searchText = "" + @State private var isSearching = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Search") + .font(.headline) + .foregroundColor(.white) + + HStack { + TextField("Transaction ID or Address", text: $searchText) + .textFieldStyle(.roundedBorder) + .onSubmit { + performSearch() + } + + Button("Search") { + performSearch() + } + .buttonStyle(.borderedProminent) + } + + if isSearching { + ProgressView("Searching...") + .foregroundColor(.white) + } else if !viewModel.searchResults.isEmpty { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(viewModel.searchResults) { result in + SearchResultRow(result: result) { + handleResultSelection(result) + } + } + } + } + .frame(maxHeight: 200) + } + } + .padding() + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + + private func performSearch() { + guard !searchText.isEmpty else { return } + + isSearching = true + + Task { + let results = await viewModel.searchTransactionOrAddress(searchText) + await MainActor.run { + self.isSearching = false + } + } + } + + private func handleResultSelection(_ result: SearchResult) { + switch result.type { + case .transaction: + break + case .address: + break + case .block: + break + } + } +} + +struct SearchResultRow: View { + let result: SearchResult + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 4) { + Text(result.title) + .font(.headline) + .foregroundColor(.white) + Text(result.subtitle) + .font(.caption) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(8) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/visionOS/MempoolVisionOS/Views/TransactionImmersiveView.swift b/visionOS/MempoolVisionOS/Views/TransactionImmersiveView.swift new file mode 100644 index 0000000000..cc333570fa --- /dev/null +++ b/visionOS/MempoolVisionOS/Views/TransactionImmersiveView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import RealityKit + +struct TransactionImmersiveView: View { + let transaction: Transaction + @State private var rootEntity: Entity? + + var body: some View { + RealityView { content in + let root = Entity() + root.name = "transaction_detail_root" + + createTransactionVisualization(root: root) + + content.add(root) + self.rootEntity = root + } + .gesture( + TapGesture() + .targetedToAnyEntity() + .onEnded { value in + handleEntityTap(value.entity) + } + ) + } + + private func createTransactionVisualization(root: Entity) { + for (index, input) in transaction.vin.enumerated() { + let inputEntity = createInputEntity(input: input, index: index) + root.addChild(inputEntity) + } + + for (index, output) in transaction.vout.enumerated() { + let outputEntity = createOutputEntity(output: output, index: index) + root.addChild(outputEntity) + } + + createTransactionFlow(root: root) + } + + private func createInputEntity(input: Transaction.TransactionInput, index: Int) -> ModelEntity { + let mesh = MeshResource.generateSphere(radius: 0.05) + let material = SimpleMaterial(color: .blue, isMetallic: false) + + let entity = ModelEntity(mesh: mesh, materials: [material]) + entity.position = SIMD3(-0.5, Float(index) * 0.15, 0) + entity.name = "input_\(index)" + + return entity + } + + private func createOutputEntity(output: Transaction.TransactionOutput, index: Int) -> ModelEntity { + let mesh = MeshResource.generateSphere(radius: 0.05) + let material = SimpleMaterial(color: .green, isMetallic: false) + + let entity = ModelEntity(mesh: mesh, materials: [material]) + entity.position = SIMD3(0.5, Float(index) * 0.15, 0) + entity.name = "output_\(index)" + + return entity + } + + private func createTransactionFlow(root: Entity) { + + } + + private func handleEntityTap(_ entity: Entity) { + + } +}