montana/macOS/MontanaPresence/TimeChainExplorerView.swift

987 lines
41 KiB
Swift
Raw Permalink Normal View History

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
}
}
}
}