987 lines
41 KiB
Swift
987 lines
41 KiB
Swift
|
|
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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|