import SwiftUI struct TimeChainExplorerView: View { @EnvironmentObject var engine: PresenceEngine @Environment(\.dismiss) private var dismiss @State private var selectedTab = 0 @State private var showSidebar = false // Data @State private var stats: [String: Any] = [:] @State private var tau1Windows: [[String: Any]] = [] @State private var tau2Windows: [[String: Any]] = [] @State private var balances: [[String: Any]] = [] @State private var isLoading = true @State private var errorText = "" @State private var autoRefresh = true @State private var refreshTimer: Timer? @State private var lastRefresh: Date = .distantPast @State private var copiedText: String? @State private var expandedWindows: Set = [] @State private var selectedLayer = "tau1" // Colors private let cyan = Color(red: 0, green: 0.83, blue: 1) private let gold = Color(red: 0.85, green: 0.68, blue: 0.25) private let cardBg = Color(red: 0.09, green: 0.09, blue: 0.12) private let hashColor = Color(red: 0.55, green: 0.55, blue: 0.65) private let tau1Color = Color(red: 0.3, green: 0.85, blue: 0.4) private let tau2Color = Color(red: 0.3, green: 0.6, blue: 1.0) private let tau3Color = Color(red: 0.8, green: 0.4, blue: 1.0) private let tau4Color = Color(red: 1.0, green: 0.5, blue: 0.2) var body: some View { ZStack(alignment: .leading) { VStack(spacing: 0) { // Burger menu HStack { Button(action: { withAnimation(.easeInOut(duration: 0.3)) { showSidebar = true } }) { Image(systemName: "line.3.horizontal") .font(.system(size: 18, weight: .medium)) .foregroundColor(gold) .padding(8) } .buttonStyle(.plain) Spacer() } .padding(.horizontal, 12) .padding(.top, 8) // Header HStack { Image(systemName: "cube.transparent") .foregroundColor(cyan) .font(.system(size: 16)) Text("Таймчейн") .font(.system(size: 14, weight: .bold)) Spacer() if autoRefresh { HStack(spacing: 3) { Circle().fill(.green).frame(width: 6, height: 6) Text("LIVE") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(.green) } .padding(.horizontal, 5) .padding(.vertical, 2) .background(.green.opacity(0.1)) .cornerRadius(4) } Button(action: { autoRefresh.toggle(); setupTimer() }) { Image(systemName: autoRefresh ? "pause.circle" : "play.circle") .foregroundColor(autoRefresh ? .green : .secondary) } .buttonStyle(.plain) Button(action: { loadAllData() }) { Image(systemName: "arrow.clockwise") .foregroundColor(.secondary) } .buttonStyle(.plain) Button(action: { dismiss() }) { Image(systemName: "xmark.circle.fill") .foregroundColor(.secondary) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 8) // Tabs HStack(spacing: 0) { tabButton("Обзор", tab: 0) tabButton("Окна", tab: 1) tabButton("UTXO", tab: 2) tabButton("Поиск", tab: 3) } .padding(.horizontal, 16) .padding(.bottom, 8) Divider() // Copied toast if let copied = copiedText { HStack { Image(systemName: "doc.on.doc.fill").font(.system(size: 9)) Text(copied) .font(.system(size: 9, design: .monospaced)) .lineLimit(1) } .foregroundColor(.green) .padding(.horizontal, 8) .padding(.vertical, 3) .background(.green.opacity(0.1)) .cornerRadius(4) .padding(.top, 4) .transition(.opacity) } if isLoading && stats.isEmpty { Spacer() ProgressView().controlSize(.small) Text("Загрузка таймчейна...") .font(.caption) .foregroundColor(.secondary) Spacer() } else if !errorText.isEmpty && stats.isEmpty { Spacer() VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 24)) .foregroundColor(.orange) Text(errorText) .font(.caption) .foregroundColor(.red) .multilineTextAlignment(.center) Text("API ещё не развёрнут на сервере") .font(.system(size: 10)) .foregroundColor(.secondary) } .padding() Spacer() } else { if selectedTab == 0 { overviewTab } else if selectedTab == 1 { windowsTab } else if selectedTab == 2 { utxoTab } else { searchTab } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { loadAllData() setupTimer() } .onDisappear { refreshTimer?.invalidate() refreshTimer = nil } // Sidebar if showSidebar { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { showSidebar = false } } SharedSidebar(isVisible: $showSidebar) .transition(.move(edge: .leading)) } } } // MARK: - Overview Tab private var overviewTab: some View { ScrollView { VStack(spacing: 12) { // Matryoshka layers card matryoshkaCard // Verification status verificationCard // Supply card supplyCard // TIME_BANK card timeBankCard } .padding(12) } } private var matryoshkaCard: some View { VStack(alignment: .leading, spacing: 10) { HStack { Image(systemName: "square.stack.3d.up") .foregroundColor(gold) .font(.system(size: 14)) Text("Матрёшка") .font(.system(size: 12, weight: .bold)) .foregroundColor(gold) Spacer() } // 4 layers layerRow(name: "τ₁", desc: "1 мин", count: stats["tau1_count"] as? Int ?? 0, pending: stats["pending_tau1"] as? Int ?? 0, color: tau1Color) layerRow(name: "τ₂", desc: "10 мин", count: stats["tau2_count"] as? Int ?? 0, pending: stats["pending_tau2"] as? Int ?? 0, color: tau2Color) layerRow(name: "τ₃", desc: "14 дн", count: stats["tau3_count"] as? Int ?? 0, pending: stats["pending_tau3"] as? Int ?? 0, color: tau3Color) layerRow(name: "τ₄", desc: "4 года", count: stats["tau4_count"] as? Int ?? 0, pending: 0, color: tau4Color) // Last hashes if let lastTau1 = stats["last_tau1_hash"] as? String, lastTau1 != "0000000000000000..." { HStack(spacing: 4) { Text("τ₁ HEAD:") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(.secondary) Text(lastTau1) .font(.system(size: 8, design: .monospaced)) .foregroundColor(hashColor) } } } .padding(12) .background(cardBg) .cornerRadius(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(gold.opacity(0.2), lineWidth: 1)) } private func layerRow(name: String, desc: String, count: Int, pending: Int, color: Color) -> some View { HStack(spacing: 8) { Text(name) .font(.system(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(color) .frame(width: 24, alignment: .leading) Text(desc) .font(.system(size: 9, design: .monospaced)) .foregroundColor(.secondary) .frame(width: 44, alignment: .leading) Spacer() Text("\(count)") .font(.system(size: 13, weight: .bold, design: .monospaced)) .foregroundColor(.white) Text("окон") .font(.system(size: 9)) .foregroundColor(.secondary) if pending > 0 { Text("+\(pending)") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundColor(.orange) .padding(.horizontal, 4) .padding(.vertical, 1) .background(.orange.opacity(0.15)) .cornerRadius(3) } } } private var verificationCard: some View { let verified = stats["verified"] as? Bool ?? false let message = stats["verify_message"] as? String ?? "..." return VStack(alignment: .leading, spacing: 6) { HStack { Image(systemName: verified ? "checkmark.shield.fill" : "xmark.shield.fill") .foregroundColor(verified ? .green : .red) .font(.system(size: 14)) Text("Верификация") .font(.system(size: 12, weight: .bold)) Spacer() Text(verified ? "OK" : "FAIL") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(verified ? .green : .red) .padding(.horizontal, 6) .padding(.vertical, 2) .background((verified ? Color.green : .red).opacity(0.15)) .cornerRadius(4) } Text(message) .font(.system(size: 9, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(2) } .padding(12) .background(cardBg) .cornerRadius(8) .overlay(RoundedRectangle(cornerRadius: 8) .stroke((verified ? Color.green : .red).opacity(0.3), lineWidth: 1)) } private var supplyCard: some View { let totalSupply = stats["total_supply"] as? Int ?? 0 let utxoCount = stats["utxo_count"] as? Int ?? 0 return VStack(alignment: .leading, spacing: 6) { HStack { Image(systemName: "bitcoinsign.circle") .foregroundColor(gold) .font(.system(size: 14)) Text("Эмиссия") .font(.system(size: 12, weight: .bold)) Spacer() } HStack { VStack(alignment: .leading, spacing: 2) { Text("TOTAL SUPPLY") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(.secondary) Text(formatAmount(totalSupply)) .font(.system(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(gold) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text("UTXO") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(.secondary) Text("\(utxoCount)") .font(.system(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(cyan) } } } .padding(12) .background(cardBg) .cornerRadius(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(gold.opacity(0.2), lineWidth: 1)) } private var timeBankCard: some View { let spent = stats["time_bank_spent"] as? Int ?? 0 let remaining = stats["time_bank_remaining"] as? Int ?? 1_262_304_000 let total = 1_262_304_000 let pct = total > 0 ? Double(spent) / Double(total) : 0 return VStack(alignment: .leading, spacing: 6) { HStack { Image(systemName: "hourglass") .foregroundColor(tau2Color) .font(.system(size: 14)) Text("TIME_BANK") .font(.system(size: 12, weight: .bold)) Spacer() Text(String(format: "%.4f%%", pct * 100)) .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundColor(.secondary) } // Progress bar GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3) .fill(Color.white.opacity(0.05)) .frame(height: 6) RoundedRectangle(cornerRadius: 3) .fill(LinearGradient(colors: [tau2Color, cyan], startPoint: .leading, endPoint: .trailing)) .frame(width: geo.size.width * min(pct, 1.0), height: 6) } } .frame(height: 6) HStack { VStack(alignment: .leading, spacing: 1) { Text("ПОТРАЧЕНО") .font(.system(size: 7, weight: .bold, design: .monospaced)) .foregroundColor(.secondary) Text(formatSeconds(spent)) .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundColor(.white.opacity(0.8)) } Spacer() VStack(alignment: .trailing, spacing: 1) { Text("ОСТАЛОСЬ") .font(.system(size: 7, weight: .bold, design: .monospaced)) .foregroundColor(.secondary) Text(formatSeconds(remaining)) .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundColor(tau2Color) } } } .padding(12) .background(cardBg) .cornerRadius(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(tau2Color.opacity(0.2), lineWidth: 1)) } // MARK: - Windows Tab private var windowsTab: some View { VStack(spacing: 0) { // Layer selector HStack(spacing: 0) { layerButton("τ₁", layer: "tau1", color: tau1Color) layerButton("τ₂", layer: "tau2", color: tau2Color) layerButton("τ₃", layer: "tau3", color: tau3Color) layerButton("τ₄", layer: "tau4", color: tau4Color) } .padding(.horizontal, 12) .padding(.vertical, 6) Divider() ScrollView { LazyVStack(spacing: 3) { let windows = selectedLayer == "tau1" ? tau1Windows : selectedLayer == "tau2" ? tau2Windows : [] if windows.isEmpty { VStack(spacing: 8) { Image(systemName: "cube.transparent") .font(.system(size: 24)) .foregroundColor(.secondary.opacity(0.3)) Text("Нет окон \(selectedLayer)") .font(.caption) .foregroundColor(.secondary) } .padding(.top, 30) } else { ForEach(Array(windows.enumerated()), id: \.offset) { idx, window in if selectedLayer == "tau1" { tau1WindowRow(window, index: idx) } else { tau2WindowRow(window, index: idx) } } } } .padding(.horizontal, 10) .padding(.vertical, 6) } } } private func layerButton(_ title: String, layer: String, color: Color) -> some View { Text(title) .font(.system(size: 12, weight: selectedLayer == layer ? .bold : .regular, design: .monospaced)) .foregroundColor(selectedLayer == layer ? color : .secondary) .frame(maxWidth: .infinity) .padding(.vertical, 6) .background(selectedLayer == layer ? color.opacity(0.1) : Color.clear) .cornerRadius(6) .contentShape(Rectangle()) .onTapGesture { selectedLayer = layer if layer == "tau1" || layer == "tau2" { loadWindows() } } } private func tau1WindowRow(_ window: [String: Any], index: Int) -> some View { let windowNum = window["window_number"] as? Int ?? 0 let timestamp = window["timestamp"] as? Int ?? 0 let windowHash = window["window_hash"] as? String ?? "" let prevHash = window["prev_tau1_hash"] as? String ?? "" let txCount = (window["transactions"] as? [Any])?.count ?? 0 let proofCount = (window["presence_proof_hashes"] as? [Any])?.count ?? 0 let nodeId = window["node_id"] as? String ?? "" let isExpanded = expandedWindows.contains(index) return VStack(alignment: .leading, spacing: 0) { Button(action: { withAnimation(.easeInOut(duration: 0.15)) { if expandedWindows.contains(index) { expandedWindows.remove(index) } else { expandedWindows.insert(index) } } }) { HStack(spacing: 6) { Text("τ₁") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(tau1Color) Text("#\(windowNum)") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(.white) Text(shortHash(windowHash)) .font(.system(size: 8, design: .monospaced)) .foregroundColor(hashColor) Spacer() if txCount > 0 { HStack(spacing: 2) { Image(systemName: "arrow.left.arrow.right").font(.system(size: 7)) Text("\(txCount)").font(.system(size: 9, weight: .medium, design: .monospaced)) } .foregroundColor(cyan) } if proofCount > 0 { HStack(spacing: 2) { Image(systemName: "person.badge.clock").font(.system(size: 7)) Text("\(proofCount)").font(.system(size: 9, weight: .medium, design: .monospaced)) } .foregroundColor(.green) } Text(formatNsTimestamp(timestamp)) .font(.system(size: 8, design: .monospaced)) .foregroundColor(.secondary.opacity(0.6)) Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.system(size: 8)) .foregroundColor(.secondary.opacity(0.5)) } } .buttonStyle(.plain) .padding(.horizontal, 8) .padding(.vertical, 6) if isExpanded { Divider().padding(.horizontal, 8) VStack(alignment: .leading, spacing: 5) { windowDetailRow(label: "HASH", value: windowHash) windowDetailRow(label: "PREV", value: prevHash) windowDetailRow(label: "NODE", value: nodeId) windowDetailRow(label: "TX", value: "\(txCount) transactions") windowDetailRow(label: "PROOFS", value: "\(proofCount) presence proofs") windowDetailRow(label: "TIME", value: formatNsTimestampFull(timestamp)) } .padding(.horizontal, 8) .padding(.vertical, 6) } } .background(cardBg) .cornerRadius(6) .overlay(RoundedRectangle(cornerRadius: 6).stroke(tau1Color.opacity(0.1), lineWidth: 1)) } private func tau2WindowRow(_ window: [String: Any], index: Int) -> some View { let windowNum = window["window_number"] as? Int ?? 0 let windowHash = window["window_hash"] as? String ?? "" let emissions = window["total_emissions"] as? Int ?? 0 let coef = window["halving_coefficient"] as? Double ?? 1.0 let bankRemaining = window["time_bank_remaining"] as? Int ?? 0 let tau1Count = (window["tau1_headers"] as? [Any])?.count ?? 0 let merkle = window["tau1_merkle_root"] as? String ?? "" let timestamp = window["timestamp"] as? Int ?? 0 let isExpanded = expandedWindows.contains(1000 + index) return VStack(alignment: .leading, spacing: 0) { Button(action: { withAnimation(.easeInOut(duration: 0.15)) { let key = 1000 + index if expandedWindows.contains(key) { expandedWindows.remove(key) } else { expandedWindows.insert(key) } } }) { HStack(spacing: 6) { Text("τ₂") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(tau2Color) Text("#\(windowNum)") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(.white) Text(shortHash(windowHash)) .font(.system(size: 8, design: .monospaced)) .foregroundColor(hashColor) Spacer() Text("+\(formatAmount(emissions))") .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(.green) Text("×\(String(format: "%.2f", coef))") .font(.system(size: 8, design: .monospaced)) .foregroundColor(.orange) Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.system(size: 8)) .foregroundColor(.secondary.opacity(0.5)) } } .buttonStyle(.plain) .padding(.horizontal, 8) .padding(.vertical, 6) if isExpanded { Divider().padding(.horizontal, 8) VStack(alignment: .leading, spacing: 5) { windowDetailRow(label: "HASH", value: windowHash) windowDetailRow(label: "MERKLE", value: merkle) windowDetailRow(label: "τ₁ COUNT", value: "\(tau1Count) headers") windowDetailRow(label: "EMISSION", value: "\(emissions) Ɉ") windowDetailRow(label: "HALVING", value: "×\(String(format: "%.4f", coef))") windowDetailRow(label: "BANK", value: formatSeconds(bankRemaining)) windowDetailRow(label: "TIME", value: formatNsTimestampFull(timestamp)) } .padding(.horizontal, 8) .padding(.vertical, 6) } } .background(cardBg) .cornerRadius(6) .overlay(RoundedRectangle(cornerRadius: 6).stroke(tau2Color.opacity(0.1), lineWidth: 1)) } private func windowDetailRow(label: String, value: String) -> some View { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 4) { Text(label) .font(.system(size: 7, weight: .bold, design: .monospaced)) .foregroundColor(gold) .frame(width: 52, alignment: .leading) Spacer() Button(action: { copyToClipboard(value) }) { Image(systemName: "doc.on.doc") .font(.system(size: 7)) .foregroundColor(.secondary.opacity(0.4)) } .buttonStyle(.plain) } Text(value) .font(.system(size: 8, design: .monospaced)) .foregroundColor(.white.opacity(0.7)) .lineLimit(2) .textSelection(.enabled) } } // MARK: - UTXO Tab private var utxoTab: some View { ScrollView { LazyVStack(spacing: 3) { if balances.isEmpty { VStack(spacing: 8) { Image(systemName: "wallet.pass") .font(.system(size: 24)) .foregroundColor(.secondary.opacity(0.3)) Text("Нет UTXO-балансов") .font(.caption) .foregroundColor(.secondary) } .padding(.top, 30) } else { // Summary HStack { let total = balances.reduce(0) { $0 + ($1["balance"] as? Int ?? 0) } Text("\(balances.count) адресов") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundColor(cyan) Text("·") .foregroundColor(.secondary) Text("Σ \(formatAmount(total))") .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(gold) Spacer() } .padding(.horizontal, 8) .padding(.vertical, 4) .background(cardBg) .cornerRadius(4) ForEach(Array(balances.enumerated()), id: \.offset) { idx, entry in utxoRow(entry, rank: idx + 1) } } } .padding(.horizontal, 10) .padding(.vertical, 6) } } private func utxoRow(_ entry: [String: Any], rank: Int) -> some View { let address = entry["address"] as? String ?? "" let balance = entry["balance"] as? Int ?? 0 let utxoCount = entry["utxo_count"] as? Int ?? 0 let myAddr = engine.address ?? "" let isMine = address == myAddr return HStack(spacing: 6) { Text("#\(rank)") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(.secondary.opacity(0.5)) .frame(width: 22, alignment: .trailing) Image(systemName: "cube.fill") .foregroundColor(cyan) .font(.system(size: 10)) .frame(width: 16) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(address) .font(.system(size: 8, design: .monospaced)) .foregroundColor(.secondary.opacity(0.7)) .lineLimit(1) .truncationMode(.middle) if isMine { Text("МОЙ") .font(.system(size: 7, weight: .bold)) .foregroundColor(cyan) .padding(.horizontal, 3) .padding(.vertical, 1) .background(cyan.opacity(0.15)) .cornerRadius(3) } } Text("\(utxoCount) UTXO") .font(.system(size: 8, design: .monospaced)) .foregroundColor(.secondary.opacity(0.4)) } Spacer() Text(formatAmount(balance)) .font(.system(size: 11, weight: .bold, design: .monospaced)) .foregroundColor(gold) } .padding(.horizontal, 8) .padding(.vertical, 6) .background(isMine ? cyan.opacity(0.05) : cardBg) .cornerRadius(6) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(isMine ? cyan.opacity(0.3) : Color.clear, lineWidth: 1) ) } // MARK: - Search Tab @State private var searchAddress = "" @State private var searchResult: [String: Any]? @State private var searchTransactions: [[String: Any]] = [] @State private var isSearching = false @State private var searchError = "" private var searchTab: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { // Search input VStack(alignment: .leading, spacing: 8) { Text("Поиск по адресу") .font(.system(size: 11, weight: .semibold)) .foregroundColor(.secondary) HStack(spacing: 8) { TextField("mt... адрес", text: $searchAddress) .textFieldStyle(.plain) .font(.system(size: 11, design: .monospaced)) .padding(8) .background(cardBg) .cornerRadius(6) .onSubmit { searchForAddress() } Button(action: searchForAddress) { HStack(spacing: 4) { if isSearching { ProgressView().controlSize(.small).scaleEffect(0.7) } else { Image(systemName: "magnifyingglass").font(.system(size: 11)) } Text("Искать").font(.system(size: 11, weight: .semibold)) } .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 8) .background(cyan) .cornerRadius(6) } .buttonStyle(.plain) .disabled(searchAddress.isEmpty || isSearching) } } if !searchError.isEmpty { Text(searchError) .font(.system(size: 10)) .foregroundColor(.red) .padding(8) .background(Color.red.opacity(0.1)) .cornerRadius(6) } if let result = searchResult { let addr = result["address"] as? String ?? "" let balance = result["balance"] as? Int ?? 0 VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "person.fill").foregroundColor(cyan) Text("Адрес найден").font(.system(size: 13, weight: .semibold)).foregroundColor(cyan) Spacer() } Text(addr) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) .textSelection(.enabled) Divider() HStack { Text("БАЛАНС").font(.system(size: 8, weight: .bold, design: .monospaced)).foregroundColor(.secondary) Spacer() Text(formatAmount(balance)) .font(.system(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(gold) } } .padding(12) .background(cyan.opacity(0.08)) .cornerRadius(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(cyan.opacity(0.3), lineWidth: 1)) } if !searchTransactions.isEmpty { Text("Транзакции (\(searchTransactions.count))") .font(.system(size: 11, weight: .semibold)) .foregroundColor(.secondary) ForEach(Array(searchTransactions.enumerated()), id: \.offset) { _, tx in let eventType = tx["event_type"] as? String ?? "" let amount = tx["amount"] as? Int ?? 0 let timestamp = tx["timestamp_iso"] as? String ?? "" HStack { Text(eventType) .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(eventType == "EMISSION" ? .green : cyan) Spacer() Text(formatAmount(amount)) .font(.system(size: 11, weight: .bold, design: .monospaced)) .foregroundColor(gold) Text(String(timestamp.prefix(19))) .font(.system(size: 8, design: .monospaced)) .foregroundColor(.secondary.opacity(0.6)) } .padding(8) .background(cardBg) .cornerRadius(6) } } } .padding(12) } } // MARK: - Helpers private func tabButton(_ title: String, tab: Int) -> some View { Text(title) .font(.system(size: 11, weight: selectedTab == tab ? .bold : .regular)) .foregroundColor(selectedTab == tab ? cyan : .secondary) .frame(maxWidth: .infinity) .padding(.vertical, 6) .background(selectedTab == tab ? cyan.opacity(0.1) : Color.clear) .cornerRadius(6) .contentShape(Rectangle()) .onTapGesture { selectedTab = tab } } private func formatAmount(_ amount: Int) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.groupingSeparator = " " let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" return "\(formatted) Ɉ" } private func formatSeconds(_ sec: Int) -> String { if sec >= 86400 * 365 { let years = Double(sec) / (86400.0 * 365.0) return String(format: "%.1f лет", years) } else if sec >= 86400 { let days = sec / 86400 return "\(days) дн." } else if sec >= 3600 { let hours = sec / 3600 return "\(hours) ч." } else if sec >= 60 { let mins = sec / 60 return "\(mins) мин." } return "\(sec) сек." } private func shortHash(_ hash: String) -> String { if hash.count > 12 { return String(hash.prefix(6)) + "…" + String(hash.suffix(4)) } return hash } private func formatNsTimestamp(_ ns: Int) -> String { let seconds = TimeInterval(ns) / 1_000_000_000 let date = Date(timeIntervalSince1970: seconds) let df = DateFormatter() df.dateFormat = "dd.MM HH:mm:ss" return df.string(from: date) } private func formatNsTimestampFull(_ ns: Int) -> String { let seconds = TimeInterval(ns) / 1_000_000_000 let date = Date(timeIntervalSince1970: seconds) let df = DateFormatter() df.dateFormat = "dd.MM.yyyy HH:mm:ss" let base = df.string(from: date) let nanos = ns % 1_000_000_000 return "\(base).\(String(format: "%09d", nanos))" } private func copyToClipboard(_ text: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) withAnimation { copiedText = String(text.prefix(30)) + (text.count > 30 ? "…" : "") } DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { withAnimation { copiedText = nil } } } // MARK: - Data Loading private func setupTimer() { refreshTimer?.invalidate() refreshTimer = nil if autoRefresh { refreshTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in loadAllData() } } } private func loadAllData() { let now = Date() guard now.timeIntervalSince(lastRefresh) >= 2.0 || lastRefresh == .distantPast else { return } lastRefresh = now if stats.isEmpty { isLoading = true } errorText = "" Task { @MainActor in do { // Load stats (includes verification) let fetchedStats = try await engine.api.fetchTimeChainStats() stats = fetchedStats isLoading = false } catch { if stats.isEmpty { errorText = "Ошибка загрузки таймчейна" } isLoading = false } // Load windows and balances in parallel do { async let t1 = engine.api.fetchTimeChainWindows(layer: "tau1", limit: 20) async let t2 = engine.api.fetchTimeChainWindows(layer: "tau2", limit: 20) async let bal = engine.api.fetchTimeChainBalances() tau1Windows = try await t1 tau2Windows = try await t2 balances = try await bal } catch { // Non-critical: windows/balances may not load if chain is empty } } } private func loadWindows() { Task { @MainActor in do { if selectedLayer == "tau1" { tau1Windows = try await engine.api.fetchTimeChainWindows(layer: "tau1", limit: 20) } else if selectedLayer == "tau2" { tau2Windows = try await engine.api.fetchTimeChainWindows(layer: "tau2", limit: 20) } } catch {} } } private func searchForAddress() { guard !searchAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } isSearching = true searchError = "" searchResult = nil searchTransactions = [] Task { @MainActor in do { let query = searchAddress.trimmingCharacters(in: .whitespacesAndNewlines) async let addrData = engine.api.fetchAddressBalance(query: query) async let txData = engine.api.fetchAddressTransactions(query: query) searchResult = try await addrData searchTransactions = try await txData isSearching = false } catch { searchError = "Адрес не найден или ошибка сети" isSearching = false } } } }