montana/macOS/MontanaPresence/TimeChainExplorerView.swift

987 lines
41 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Int> = []
@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
}
}
}
}