366 lines
14 KiB
Swift
366 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
struct HistoryView: View {
|
|
@EnvironmentObject var engine: PresenceEngine
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var displayItems: [HistoryItem] = []
|
|
@State private var isLoading = true
|
|
@State private var errorText = ""
|
|
@State private var lastRefresh: Date = .distantPast
|
|
@State private var showSidebar = false
|
|
|
|
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 goldLight = Color(red: 0.95, green: 0.82, blue: 0.45)
|
|
private let cardBg = Color(red: 0.09, green: 0.09, blue: 0.12)
|
|
|
|
struct HistoryItem: Identifiable {
|
|
let id = UUID()
|
|
var eventType: String // EMISSION or TRANSFER
|
|
var amount: Int
|
|
var fromAddr: String
|
|
var toAddr: String
|
|
var fromAlias: String
|
|
var toAlias: String
|
|
var timestamp: String
|
|
var emissionCount: Int // how many emissions consolidated (1 for transfers)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .leading) {
|
|
VStack(spacing: 0) {
|
|
// ── BURGER MENU BUTTON ──
|
|
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: "clock.arrow.circlepath")
|
|
.foregroundColor(gold)
|
|
Text("\u{0418}\u{0441}\u{0442}\u{043e}\u{0440}\u{0438}\u{044f}")
|
|
.font(.system(size: 14, weight: .bold))
|
|
Spacer()
|
|
Button(action: { loadHistory() }) {
|
|
Image(systemName: "arrow.clockwise")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("\u{041e}\u{0431}\u{043d}\u{043e}\u{0432}\u{0438}\u{0442}\u{044c}")
|
|
Button(action: { dismiss() }) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 8)
|
|
|
|
Divider()
|
|
|
|
if isLoading {
|
|
Spacer()
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text("\u{0417}\u{0430}\u{0433}\u{0440}\u{0443}\u{0437}\u{043a}\u{0430}...")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
} else if !errorText.isEmpty {
|
|
Spacer()
|
|
Text(errorText)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
.multilineTextAlignment(.center)
|
|
.padding()
|
|
Spacer()
|
|
} else if displayItems.isEmpty {
|
|
Spacer()
|
|
Image(systemName: "tray")
|
|
.font(.system(size: 28))
|
|
.foregroundColor(.secondary.opacity(0.5))
|
|
Text("\u{041d}\u{0435}\u{0442} \u{0442}\u{0440}\u{0430}\u{043d}\u{0437}\u{0430}\u{043a}\u{0446}\u{0438}\u{0439}")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.top, 4)
|
|
Spacer()
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 4) {
|
|
ForEach(displayItems) { item in
|
|
historyRow(item)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.onAppear { loadHistory() }
|
|
|
|
// Sidebar overlay
|
|
if showSidebar {
|
|
Color.black.opacity(0.3)
|
|
.ignoresSafeArea()
|
|
.onTapGesture {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
showSidebar = false
|
|
}
|
|
}
|
|
|
|
SharedSidebar(isVisible: $showSidebar)
|
|
.transition(.move(edge: .leading))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func historyRow(_ item: HistoryItem) -> some View {
|
|
let myAddr = engine.address ?? ""
|
|
let isSent = item.fromAddr == myAddr
|
|
let isReceived = item.toAddr == myAddr
|
|
let isEmission = item.eventType == "EMISSION"
|
|
|
|
let directionIcon: String = {
|
|
if isEmission && isReceived { return "arrow.down.circle.fill" }
|
|
if isSent { return "arrow.up.circle.fill" }
|
|
return "arrow.down.circle.fill"
|
|
}()
|
|
|
|
let directionColor: Color = {
|
|
if isEmission { return .green }
|
|
if isSent { return .orange }
|
|
return cyan
|
|
}()
|
|
|
|
let directionLabel: String = {
|
|
if isEmission && isReceived {
|
|
if item.emissionCount > 1 {
|
|
return "\u{042d}\u{043c}\u{0438}\u{0441}\u{0441}\u{0438}\u{044f} (10 \u{043c}\u{0438}\u{043d})"
|
|
}
|
|
return "\u{042d}\u{043c}\u{0438}\u{0441}\u{0441}\u{0438}\u{044f}"
|
|
}
|
|
if isSent { return "\u{041e}\u{0442}\u{043f}\u{0440}\u{0430}\u{0432}\u{043b}\u{0435}\u{043d}\u{043e}" }
|
|
return "\u{041f}\u{043e}\u{043b}\u{0443}\u{0447}\u{0435}\u{043d}\u{043e}"
|
|
}()
|
|
|
|
let counterparty: String = {
|
|
if isEmission {
|
|
let alias = item.fromAlias.isEmpty ? "\u{0248}-0" : item.fromAlias
|
|
if item.emissionCount > 1 {
|
|
return "\(alias) \u{00d7}\(item.emissionCount)"
|
|
}
|
|
return alias
|
|
}
|
|
if isSent { return displayAddr(item.toAddr, alias: item.toAlias) }
|
|
return displayAddr(item.fromAddr, alias: item.fromAlias)
|
|
}()
|
|
|
|
let amountPrefix: String = {
|
|
if isSent && !isEmission { return "-" }
|
|
return "+"
|
|
}()
|
|
|
|
return VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: directionIcon)
|
|
.foregroundColor(directionColor)
|
|
.font(.system(size: 14))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(directionLabel)
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundColor(directionColor)
|
|
HStack(spacing: 4) {
|
|
if isSent && !isEmission {
|
|
Image(systemName: "arrow.right")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(.secondary)
|
|
} else if !isEmission {
|
|
Image(systemName: "arrow.left")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Text(counterparty)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("\(amountPrefix)\(formatAmount(item.amount))")
|
|
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
|
.foregroundColor(directionColor)
|
|
Text(formatTimestamp(item.timestamp))
|
|
.font(.system(size: 8, design: .monospaced))
|
|
.foregroundColor(Color.secondary.opacity(0.6))
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(cardBg)
|
|
.cornerRadius(6)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func displayAddr(_ addr: String, alias: String) -> String {
|
|
if !alias.isEmpty { return alias }
|
|
guard addr.count > 10 else { return addr }
|
|
return String(addr.prefix(6)) + "..." + String(addr.suffix(4))
|
|
}
|
|
|
|
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) \u{0248}"
|
|
}
|
|
|
|
private func formatTimestamp(_ ts: String) -> String {
|
|
guard ts.count >= 20 else { return ts }
|
|
let parts = ts.split(separator: ".", maxSplits: 1)
|
|
guard parts.count >= 1 else { return ts }
|
|
|
|
let isoFormatter = ISO8601DateFormatter()
|
|
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = isoFormatter.date(from: ts) {
|
|
let df = DateFormatter()
|
|
df.dateFormat = "dd.MM.yyyy HH:mm"
|
|
return df.string(from: date)
|
|
}
|
|
|
|
isoFormatter.formatOptions = [.withInternetDateTime]
|
|
if let date = isoFormatter.date(from: String(parts[0]) + "Z") {
|
|
let df = DateFormatter()
|
|
df.dateFormat = "dd.MM.yyyy HH:mm"
|
|
return df.string(from: date)
|
|
}
|
|
|
|
return String(ts.prefix(16))
|
|
}
|
|
|
|
private func loadHistory() {
|
|
guard let myAddr = engine.address, !myAddr.isEmpty else {
|
|
errorText = "\u{041a}\u{043e}\u{0448}\u{0435}\u{043b}\u{0451}\u{043a} \u{043d}\u{0435} \u{043d}\u{0430}\u{0441}\u{0442}\u{0440}\u{043e}\u{0435}\u{043d}"
|
|
isLoading = false
|
|
return
|
|
}
|
|
let now = Date()
|
|
guard now.timeIntervalSince(lastRefresh) >= 2.0 || lastRefresh == .distantPast else { return }
|
|
lastRefresh = now
|
|
isLoading = true
|
|
errorText = ""
|
|
Task { @MainActor in
|
|
do {
|
|
let events = try await engine.api.fetchMyEvents(address: myAddr, limit: 200)
|
|
displayItems = consolidateEvents(events)
|
|
isLoading = false
|
|
} catch {
|
|
errorText = "\u{041e}\u{0448}\u{0438}\u{0431}\u{043a}\u{0430} \u{0437}\u{0430}\u{0433}\u{0440}\u{0443}\u{0437}\u{043a}\u{0438}"
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Consolidate EMISSION events per T2 window (600 sec), keep TRANSFER events as-is.
|
|
/// Result: newest first, transfers always visible between consolidated emission blocks.
|
|
private func consolidateEvents(_ events: [[String: Any]]) -> [HistoryItem] {
|
|
let t2Window: TimeInterval = 600 // 10 minutes
|
|
|
|
var items: [HistoryItem] = []
|
|
var emissionBucket: (amount: Int, count: Int, fromAddr: String, toAddr: String,
|
|
fromAlias: String, toAlias: String, timestamp: String, date: Date)?
|
|
|
|
let isoFormatter = ISO8601DateFormatter()
|
|
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let isoFallback = ISO8601DateFormatter()
|
|
isoFallback.formatOptions = [.withInternetDateTime]
|
|
|
|
func parseDate(_ ts: String) -> Date? {
|
|
isoFormatter.date(from: ts) ?? isoFallback.date(from: ts)
|
|
}
|
|
|
|
func flushEmission() {
|
|
if let bucket = emissionBucket {
|
|
items.append(HistoryItem(
|
|
eventType: "EMISSION",
|
|
amount: bucket.amount,
|
|
fromAddr: bucket.fromAddr,
|
|
toAddr: bucket.toAddr,
|
|
fromAlias: bucket.fromAlias,
|
|
toAlias: bucket.toAlias,
|
|
timestamp: bucket.timestamp,
|
|
emissionCount: bucket.count
|
|
))
|
|
emissionBucket = nil
|
|
}
|
|
}
|
|
|
|
// Events come newest-first from API
|
|
for event in events {
|
|
let eventType = event["event_type"] as? String ?? ""
|
|
let amount = event["amount"] as? Int ?? 0
|
|
let fromAddr = String((event["from_addr"] as? String ?? "").prefix(100))
|
|
let toAddr = String((event["to_addr"] as? String ?? "").prefix(100))
|
|
let fromAlias = event["from_alias"] as? String ?? ""
|
|
let toAlias = event["to_alias"] as? String ?? ""
|
|
let timestamp = event["timestamp_iso"] as? String ?? (event["timestamp"] as? String ?? "")
|
|
|
|
if eventType == "EMISSION" {
|
|
let eventDate = parseDate(timestamp) ?? Date.distantPast
|
|
if let bucket = emissionBucket {
|
|
// Same T2 window? (within 600 sec of first emission in bucket)
|
|
if abs(bucket.date.timeIntervalSince(eventDate)) <= t2Window {
|
|
emissionBucket = (
|
|
amount: bucket.amount + amount,
|
|
count: bucket.count + 1,
|
|
fromAddr: bucket.fromAddr,
|
|
toAddr: bucket.toAddr,
|
|
fromAlias: bucket.fromAlias,
|
|
toAlias: bucket.toAlias,
|
|
timestamp: bucket.timestamp, // keep newest timestamp
|
|
date: bucket.date
|
|
)
|
|
} else {
|
|
// Different T2 window — flush previous and start new
|
|
flushEmission()
|
|
emissionBucket = (amount, 1, fromAddr, toAddr, fromAlias, toAlias, timestamp, eventDate)
|
|
}
|
|
} else {
|
|
emissionBucket = (amount, 1, fromAddr, toAddr, fromAlias, toAlias, timestamp, eventDate)
|
|
}
|
|
} else {
|
|
// TRANSFER — flush any pending emission bucket, then add transfer
|
|
flushEmission()
|
|
items.append(HistoryItem(
|
|
eventType: eventType,
|
|
amount: amount,
|
|
fromAddr: fromAddr,
|
|
toAddr: toAddr,
|
|
fromAlias: fromAlias,
|
|
toAlias: toAlias,
|
|
timestamp: timestamp,
|
|
emissionCount: 1
|
|
))
|
|
}
|
|
}
|
|
flushEmission() // flush remaining
|
|
|
|
return items // already newest-first
|
|
}
|
|
}
|