montana/iOS/Apps/Montana/Montana/MontanaApp.swift

3675 lines
149 KiB
Swift
Raw 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.

//
// MontanaApp.swift
// Montana Кошелёк времени
//
// Постквантовый протокол идеальных денег
// Bundle ID: network.montana.junona
//
import SwiftUI
import MontanaCore
@main
struct MontanaApp: App {
@StateObject private var auth = AuthService.shared
@StateObject private var wallet = WalletService.shared
@StateObject private var biometricAuth = BiometricAuth.shared
@Environment(\.scenePhase) var scenePhase
init() {
Montana.initialize()
// ОДНОРАЗОВАЯ МИГРАЦИЯ v1.9.3: очистка старых данных
// Keychain сохраняется после удаления приложения - нужно очистить
let migrationKey = "montana_migration_v1_9_3_done"
if !UserDefaults.standard.bool(forKey: migrationKey) {
print("[Montana] Migration v1.9.3: clearing old data")
// Очистить keychain
let keychainKeys = ["private_key", "public_key", "cognitive_key", "pin_code", "pin_hash"]
for key in keychainKeys {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "network.montana.junona",
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
// Очистить UserDefaults
let userDefaultsKeys = [
"montana_address", "montana_mt_number", "montana_mt_id",
"montana_balance", "montana_user", "montana_logged_out",
"montana_last_reported_balance"
]
for key in userDefaultsKeys {
UserDefaults.standard.removeObject(forKey: key)
}
// Пометить миграцию как выполненную
UserDefaults.standard.set(true, forKey: migrationKey)
print("[Montana] Migration complete - app is fresh")
}
}
var body: some Scene {
WindowGroup {
ZStack {
Group {
if auth.state.isAuthorized {
WalletView()
} else {
// ТОЛЬКО когнитивный ключ никаких Telegram/Google
CognitiveKeyView()
}
}
// Face ID блокировка при возврате из фона
if biometricAuth.isLocked && auth.state.isAuthorized {
LockScreenView()
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: biometricAuth.isLocked)
.preferredColorScheme(.dark)
.onOpenURL { url in
handleDeepLink(url)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
biometricAuth.lock()
}
}
}
}
private func handleDeepLink(_ url: URL) {
print("[Montana] Deep link: \(url.absoluteString)")
guard url.scheme == "montana" else { return }
// Формат: montana://path?param=value
// Только когнитивный ключ никаких внешних OAuth
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
// montana://wallet - открыть кошелёк
if components.host == "wallet" {
print("[Montana] Open wallet request")
return
}
// montana://transfer?to=mtXXX&amount=100
if components.host == "transfer",
let queryItems = components.queryItems {
let toAddress = queryItems.first(where: { $0.name == "to" })?.value
let amount = queryItems.first(where: { $0.name == "amount" })?.value
print("[Montana] Transfer request: to=\(toAddress ?? ""), amount=\(amount ?? "")")
// TODO: Handle transfer deep link
return
}
}
}
}
// MARK: - Profile Manager
//
// Montana Native Auth когнитивный ключ + ML-DSA-65
// MT ID: Ɉ-1, Ɉ-2, Ɉ-3... порядковый номер регистрации
@MainActor
class ProfileManager: ObservableObject {
static let shared = ProfileManager()
@Published var isFrozen = false
@Published var profileId: String?
@Published var mtNumber: Int? // Порядковый номер, присвоенный СЕТЬЮ
//
// АДРЕС MONTANA: Ɉ-{номер}-{криптохеш}
// Пример: Ɉ-1-26035a7cd7d04d4702b9dee85497cc95da8c1867
// Адрес хранится как ЕДИНАЯ строка в UserDefaults
//
// ПОЛНЫЙ АДРЕС читается из UserDefaults напрямую
var fullAddress: String {
UserDefaults.standard.string(forKey: "montana_address") ?? "Ɉ-0-"
}
// АЛИАС: короткая форма (Ɉ-1)
// ПРИОРИТЕТ: mtNumber > saved number > address parsing
var alias: String {
// 1. Сначала проверяем mtNumber (в памяти)
if let number = mtNumber, number > 0 {
return "Ɉ-\(number)"
}
// 2. Потом saved number (из UserDefaults)
let saved = UserDefaults.standard.integer(forKey: "montana_mt_number")
if saved > 0 {
return "Ɉ-\(saved)"
}
// 3. Пробуем извлечь из адреса формата Ɉ-{N}-{hash}
let addr = fullAddress
if addr.hasPrefix("Ɉ-") {
let parts = addr.dropFirst(2).split(separator: "-", maxSplits: 1)
if let first = parts.first, let number = Int(first), number > 0 {
return "Ɉ-\(number)"
}
}
// 4. Нет номера ждём регистрацию
return "Ɉ-..."
}
var mtId: String? {
get { alias != "Ɉ-..." ? alias : nil }
set { /* ignore */ }
}
var mtIdDisplay: String { alias }
// Извлечь номер из адреса
private var sequentialNumber: Int {
if let number = mtNumber, number > 0 { return number }
let saved = UserDefaults.standard.integer(forKey: "montana_mt_number")
return saved > 0 ? saved : 0
}
// Уникальный ID устройства
var deviceId: String {
if let existing = UserDefaults.standard.string(forKey: "montana_device_uuid") {
return existing
}
let newId = UUID().uuidString
UserDefaults.standard.set(newId, forKey: "montana_device_uuid")
return newId
}
// 3 узла + сайт (проверены через SSH)
// IP адреса используют HTTP (нет SSL), сайт использует HTTPS
private let endpoints = [
"https://efir.org", // Сайт Timeweb (primary, HTTPS)
"http://176.124.208.93", // Москва (HTTP)
"http://72.56.102.240", // Амстердам (HTTP)
"http://91.200.148.93" // Алматы (HTTP)
]
private init() {}
func loadProfile() {
if UserDefaults.standard.bool(forKey: "montana_logged_out") {
print("[ProfileManager] User logged out, skipping auto-restore")
return
}
loadLocal()
// Если есть адрес, но нет номера регистрируем в сети
if sequentialNumber == 0,
let address = UserDefaults.standard.string(forKey: "montana_address"),
!address.isEmpty {
Task {
await registerInNetwork()
}
}
}
func clearLogoutFlag() {
UserDefaults.standard.removeObject(forKey: "montana_logged_out")
}
private func saveLocal() {
if let id = mtId {
UserDefaults.standard.set(id, forKey: "montana_mt_id")
}
if let number = mtNumber {
UserDefaults.standard.set(number, forKey: "montana_mt_number")
}
}
func loadLocal() {
mtId = UserDefaults.standard.string(forKey: "montana_mt_id")
let savedNumber = UserDefaults.standard.integer(forKey: "montana_mt_number")
if savedNumber > 0 || UserDefaults.standard.object(forKey: "montana_mt_number") != nil {
mtNumber = savedNumber
}
}
func resetOnServer() async {
guard let url = URL(string: "\(endpoints[0])/api/auth/reset") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(deviceId, forHTTPHeaderField: "X-Device-ID")
do {
let (_, _) = try await URLSession.shared.data(for: request)
print("[ProfileManager] Profile reset on server")
} catch {
print("[ProfileManager] Reset error: \(error)")
}
}
//
// РЕГИСТРАЦИЯ В СЕТИ получить порядковый номер
// POST /api/wallet/register { address: "mt..." }
// Response: { number: 42 }
//
func registerInNetwork() async {
guard let address = UserDefaults.standard.string(forKey: "montana_address"),
!address.isEmpty else {
print("[ProfileManager] No address to register")
return
}
// Если уже есть номер не регистрируем повторно
if sequentialNumber > 0 {
print("[ProfileManager] Already registered: Ɉ-\(sequentialNumber)")
return
}
guard let url = URL(string: "\(endpoints[0])/api/wallet/register") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(deviceId, forHTTPHeaderField: "X-Device-ID")
let body: [String: Any] = ["address": address]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let number = json["number"] as? Int {
// Сохраняем номер
mtNumber = number
UserDefaults.standard.set(number, forKey: "montana_mt_number")
// Обновляем адрес на единый формат Ɉ-{N}-{hash}
if let fullAddress = json["full_address"] as? String {
UserDefaults.standard.set(fullAddress, forKey: "montana_address")
// Также сохраняем для активного кошелька
KeychainManager.shared.saveWalletAddress(fullAddress, forWallet: KeychainManager.shared.activeWalletIndex)
}
print("[ProfileManager] Registered: Ɉ-\(number), address updated to unified format")
}
}
} catch {
print("[ProfileManager] Registration error: \(error)")
}
}
func reset() {
isFrozen = false
profileId = nil
mtId = nil
mtNumber = nil
print("[ProfileManager] Local state reset")
}
}
// MARK: - Wallet View
struct WalletView: View {
@ObservedObject var wallet = WalletService.shared
@ObservedObject var auth = AuthService.shared
@ObservedObject var profile = ProfileManager.shared
@State private var showSettings = false
@State private var showSend = false
@State private var showReceive = false
@State private var showHistory = false
@State private var copiedMtId = false
@State private var showMenu = false
@State private var menuOffset: CGFloat = -UIScreen.main.bounds.width * 0.8
private let menuWidth: CGFloat = UIScreen.main.bounds.width * 0.8
// Алиас: делегируем ProfileManager (единый источник правды)
private var displayAlias: String {
ProfileManager.shared.alias
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
// Header
HStack {
Button {
showSettings = true
} label: {
Image(systemName: "line.3.horizontal")
.font(.title2)
.foregroundColor(.white.opacity(0.6))
}
Spacer()
// Network Status (видимая инфраструктура "too big to fail")
NetworkStatusView()
Spacer()
Button {
// Notifications
} label: {
Image(systemName: "bell")
.font(.title2)
.foregroundColor(.white.opacity(0.6))
}
}
.padding(.horizontal, 24)
.padding(.top, 16)
Spacer()
// Main Balance
VStack(spacing: 24) {
// Genesis Photo: МЫ_ПОВСЮДУ
Image("GenesisPhoto")
.resizable()
.scaledToFill()
.frame(width: 100, height: 100)
.clipShape(Circle())
.shadow(color: Color(hex: "10B981").opacity(0.5), radius: 30)
// Алиас + полный адрес
VStack(spacing: 8) {
// Алиас (Ɉ-20)
Button {
UIPasteboard.general.string = displayAlias
withAnimation(.easeInOut(duration: 0.3)) {
copiedMtId = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { copiedMtId = false }
}
} label: {
HStack(spacing: 6) {
Text(displayAlias)
.font(.system(size: 22, weight: .semibold, design: .monospaced))
.foregroundColor(Color(hex: "D4AF37"))
Image(systemName: copiedMtId ? "checkmark" : "doc.on.doc")
.font(.system(size: 12))
.foregroundColor(copiedMtId ? Color(hex: "10B981") : Color(hex: "D4AF37").opacity(0.6))
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color(hex: "D4AF37").opacity(0.1))
.overlay(
Capsule()
.stroke(Color(hex: "D4AF37").opacity(0.3), lineWidth: 1)
)
)
}
// Полный адрес (не урезанный)
if let address = UserDefaults.standard.string(forKey: "montana_address") {
Text(address)
.font(.system(size: 10, weight: .light, design: .monospaced))
.foregroundColor(.white.opacity(0.4))
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.horizontal, 20)
}
}
// Balance with glow and symbol
ZStack {
// Glow effect (green)
Text(formatBalance(wallet.balance))
.font(.system(size: 56, weight: .thin, design: .rounded))
.foregroundColor(Color(hex: "10B981"))
.blur(radius: 20)
.opacity(0.5)
VStack(spacing: 8) {
// Balance + Symbol (bigger)
HStack(spacing: 8) {
Text(formatBalance(wallet.balance))
.font(.system(size: 56, weight: .thin, design: .rounded))
.foregroundColor(Color(hex: "10B981"))
// Coin Symbol same size as balance
Image("CoinSymbol")
.renderingMode(.original)
.resizable()
.scaledToFit()
.frame(height: 56)
}
Text("монет времени")
.font(.system(size: 14, weight: .light))
.foregroundColor(Color(hex: "10B981").opacity(0.7))
.tracking(2)
// Price in RUB + Rate
VStack(spacing: 2) {
HStack(spacing: 4) {
Text("=")
.font(.system(size: 14, weight: .light))
.foregroundColor(.white.opacity(0.4))
Text(formatRubles(wallet.balance * 12.2))
.font(.system(size: 18, weight: .medium, design: .rounded))
.foregroundColor(Color(hex: "D4AF37"))
Text("RUB")
.font(.system(size: 12, weight: .light))
.foregroundColor(.white.opacity(0.4))
}
Text("1 сек = 12.2 RUB")
.font(.system(size: 10, weight: .light))
.foregroundColor(.white.opacity(0.3))
}
.padding(.top, 4)
}
}
// 4 Time Slices + TIME_BANK (всё от генезиса)
GlobalTimeSlices()
.padding(.top, 8)
// Sync indicator
if wallet.isLoading {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color(hex: "10B981")))
.scaleEffect(0.8)
Text("Синхронизация...")
.font(.system(size: 12, weight: .light))
.foregroundColor(.white.opacity(0.5))
}
}
}
Spacer()
// Action Buttons
HStack(spacing: 24) {
ActionCircle(icon: "arrow.up", label: "Отправить") {
showSend = true
}
ActionCircle(icon: "arrow.down", label: "Получить") {
showReceive = true
}
ActionCircle(icon: "clock", label: "История") {
showHistory = true
}
}
.padding(.bottom, 40)
// Footer
VStack(spacing: 4) {
Text("1 секунда присутствия = 1 Ɉ")
.font(.system(size: 12, weight: .light))
.foregroundColor(.white.opacity(0.3))
Text("Генезис Beeple: 1 Ɉ = 12.2 RUB")
.font(.system(size: 10, weight: .light))
.foregroundColor(Color(hex: "D4AF37").opacity(0.4))
}
.padding(.bottom, 32)
}
}
.sheet(isPresented: $showSend) {
SendSheet()
}
.sheet(isPresented: $showReceive) {
ReceiveSheet()
}
.sheet(isPresented: $showHistory) {
NavigationStack {
TransactionHistoryView()
}
}
.onAppear {
wallet.startSession()
}
// Свайп влево для открытия меню
.gesture(
DragGesture()
.onChanged { value in
// Свайп от левого края вправо открываем меню
if value.startLocation.x < 30 && value.translation.width > 0 {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
menuOffset = min(0, -menuWidth + value.translation.width)
}
}
// Свайп влево при открытом меню закрываем
if showMenu && value.translation.width < 0 {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
menuOffset = max(-menuWidth, value.translation.width)
}
}
}
.onEnded { value in
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
if value.translation.width > 50 || (showMenu && value.translation.width > -50) {
menuOffset = 0
showMenu = true
} else {
menuOffset = -menuWidth
showMenu = false
}
}
}
)
// Оверлей меню
.overlay(
ZStack {
// Затемнение фона
if showMenu {
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
menuOffset = -menuWidth
showMenu = false
}
}
}
// Боковое меню
HStack(spacing: 0) {
SideMenuView(showMenu: $showMenu, menuOffset: $menuOffset, menuWidth: menuWidth)
.frame(width: menuWidth)
.offset(x: menuOffset)
Spacer()
}
}
)
.onChange(of: showSettings) { newValue in
if newValue {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
menuOffset = 0
showMenu = true
}
showSettings = false
}
}
.onAppear {
// Загружаем профиль (MT ID) при появлении кошелька
profile.loadProfile()
}
}
private func formatBalance(_ value: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSeparator = " "
return formatter.string(from: NSNumber(value: Int(value))) ?? "0"
}
private func formatRubles(_ value: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSeparator = " "
formatter.maximumFractionDigits = 2
return formatter.string(from: NSNumber(value: value)) ?? "0"
}
private func formatCountdown(_ minutes: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSeparator = " "
return formatter.string(from: NSNumber(value: Int(minutes))) ?? "0"
}
// TAU Time Slices from server (synced)
// τ1 = 1 min, τ2 = 10 min, τ3 = 14 days, τ4 = 4 years
private let TAU1_SEC: Double = 60
private let TAU2_SEC: Double = 600
private let TAU3_SEC: Double = 1_209_600
private let TAU4_SEC: Double = 126_230_400
private func sliceValue(for index: Int) -> String {
// Use synced values from server
switch index {
case 0: return String(format: "%.0f", wallet.tau1) // τ1
case 1: return String(format: "%.1f", wallet.tau2) // τ2
case 2: return String(format: "%.4f", wallet.tau3) // τ3
case 3: return String(format: "%.6f", wallet.tau4) // τ4
default: return "0"
}
}
private func sliceLabel(for index: Int) -> String {
switch index {
case 0: return "1 мин"
case 1: return "10 мин"
case 2: return "14 дн"
case 3: return "4 года"
default: return ""
}
}
private func sliceIsActive(for index: Int) -> Bool {
let total = wallet.balance
switch index {
case 0: return total >= TAU1_SEC
case 1: return total >= TAU2_SEC
case 2: return total >= TAU3_SEC
case 3: return total >= TAU4_SEC
default: return false
}
}
}
// MARK: - Time Slice Component
struct TimeSlice: View {
let value: String
let label: String
let isActive: Bool
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 24, weight: .light, design: .monospaced))
.foregroundColor(isActive ? .white : .white.opacity(0.3))
Text(label)
.font(.system(size: 10, weight: .light))
.foregroundColor(isActive ? Color(hex: "D4AF37") : .white.opacity(0.2))
}
.frame(width: 60, height: 50)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(isActive ? 0.08 : 0.03))
)
}
}
// MARK: - Global Time Slices + TIME_BANK (всё от Genesis 9 Jan 2026)
struct GlobalTimeSlices: View {
// Когнитивный Генезис: 9 января 2026 00:00:00 МСК
// = 8 января 2026 21:00:00 UTC
private let genesisDate: Date = {
var components = DateComponents()
components.year = 2026
components.month = 1
components.day = 9
components.hour = 0
components.minute = 0
components.second = 0
components.timeZone = TimeZone(identifier: "Europe/Moscow")
return Calendar.current.date(from: components) ?? Date()
}()
// TAU Constants (в секундах)
private let TAU1: Double = 60 // 1 минута
private let TAU2: Double = 600 // 10 минут
private let TAU3: Double = 1_209_600 // 14 дней
private let TAU4: Double = 126_230_400 // 4 года
// БАНК ВРЕМЕНИ: ровно 40 лет в секундах (с реальными високосными)
// Високосные: 2028,2032,2036,2040,2044,2048,2052,2056,2060,2064 = 10 лет
// Обычные: 30 лет
// 30 × 365 × 86400 = 946,080,000
// 10 × 366 × 86400 = 316,224,000
// ИТОГО: 1,262,304,000 секунд
// Конец: 9 января 2066 00:00:00 МСК
private let totalReserveSeconds: Int = 1_262_304_000
var body: some View {
// TimelineView для точной синхронизации с часами
TimelineView(.periodic(from: .now, by: 1.0)) { context in
let now = context.date
// Глобальное время с genesis (в секундах)
let secondsSinceGenesis = max(0, Double(now.timeIntervalSince(genesisDate)))
// Иерархические слайсы времени (каскадный сброс)
// τ τ τ τ (как часы: годы дни часы минуты)
// τ: полные 4-летние эпохи (никогда не сбрасывается)
let tau4Value = Int(secondsSinceGenesis / TAU4)
let remainingAfterTau4 = secondsSinceGenesis.truncatingRemainder(dividingBy: TAU4)
// τ: полные 14-дневки внутри текущей эпохи (0-103, потом сброс)
let tau3Value = Int(remainingAfterTau4 / TAU3)
let remainingAfterTau3 = remainingAfterTau4.truncatingRemainder(dividingBy: TAU3)
// τ: полные 10-минутки внутри текущей 14-дневки (0-2015, потом сброс)
let tau2Value = Int(remainingAfterTau3 / TAU2)
let remainingAfterTau2 = remainingAfterTau3.truncatingRemainder(dividingBy: TAU2)
// τ: полные минуты внутри текущей 10-минутки (0-9, потом сброс)
let tau1Value = Int(remainingAfterTau2 / TAU1)
// Секунды внутри текущей минуты (0-59, потом сброс в τ)
let currentSecondInMinute = Int(remainingAfterTau2.truncatingRemainder(dividingBy: TAU1))
// БАНК ВРЕМЕНИ: 1,262,304,000 секунд = ровно 40 лет
// Конец: 9 января 2066 00:00:00 МСК
let endDate = genesisDate.addingTimeInterval(Double(totalReserveSeconds))
let remainingTotalSeconds = max(0, Int(endDate.timeIntervalSince(now)))
let remainingMinutes = remainingTotalSeconds / 60
let remainingSecondsInMinute = remainingTotalSeconds % 60
// Для отображения даты конца (московское время)
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "ru_RU")
dateFormatter.timeZone = TimeZone(identifier: "Europe/Moscow")
dateFormatter.dateFormat = "d MMMM yyyy HH:mm:ss"
let endDateString = dateFormatter.string(from: endDate).uppercased()
// Короткая дата для заголовка
dateFormatter.dateFormat = "d MMMM yyyy"
let endDateShort = dateFormatter.string(from: endDate).uppercased()
// Обратный отсчёт до конца в годах/днях/часах
let secondsToEnd = Double(remainingTotalSeconds)
let yearsToEnd = Int(secondsToEnd / (365.25 * 24 * 60 * 60))
let daysToEnd = Int((secondsToEnd.truncatingRemainder(dividingBy: 365.25 * 24 * 60 * 60)) / (24 * 60 * 60))
let hoursToEnd = Int((secondsToEnd.truncatingRemainder(dividingBy: 24 * 60 * 60)) / (60 * 60))
let minutesToEnd = Int((secondsToEnd.truncatingRemainder(dividingBy: 60 * 60)) / 60)
let secondsToEndDisplay = remainingSecondsInMinute
return VStack(spacing: 12) {
// 5 Time Slices (иерархические каскадный сброс)
// сек τ τ τ τ
HStack(spacing: 8) {
GlobalSlice(value: String(format: "%02d", currentSecondInMinute), label: "сек", isActive: true)
GlobalSlice(value: "\(tau1Value)", label: "τ₁", isActive: tau1Value >= 1)
GlobalSlice(value: formatNumber(tau2Value), label: "τ₂", isActive: tau2Value >= 1)
GlobalSlice(value: "\(tau3Value)", label: "τ₃", isActive: tau3Value >= 1)
GlobalSlice(value: "\(tau4Value)", label: "τ₄", isActive: tau4Value >= 1)
}
// TIME_BANK Countdown
VStack(spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "hourglass")
.font(.system(size: 12))
.foregroundColor(Color(hex: "D4AF37").opacity(0.8))
Text("БАНК ВРЕМЕНИ")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color(hex: "D4AF37").opacity(0.8))
}
// Оставшееся время (минуты + секунды синхронизированы)
HStack(spacing: 4) {
Text(formatNumber(remainingMinutes))
.font(.system(size: 22, weight: .light, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
Text("мин")
.font(.system(size: 10, weight: .light))
.foregroundColor(.white.opacity(0.4))
Text(String(format: "%02d", remainingSecondsInMinute))
.font(.system(size: 22, weight: .light, design: .monospaced))
.foregroundColor(Color(hex: "D4AF37"))
Text("сек")
.font(.system(size: 10, weight: .light))
.foregroundColor(.white.opacity(0.4))
}
// Прошло с генезиса конвертация в дни/месяцы/годы
let genesisYears = Int(secondsSinceGenesis / (365.25 * 24 * 60 * 60))
let genesisDays = Int((secondsSinceGenesis.truncatingRemainder(dividingBy: 365.25 * 24 * 60 * 60)) / (24 * 60 * 60))
let genesisHours = Int((secondsSinceGenesis.truncatingRemainder(dividingBy: 24 * 60 * 60)) / (60 * 60))
let genesisMinutes = Int((secondsSinceGenesis.truncatingRemainder(dividingBy: 60 * 60)) / 60)
let genesisSec = Int(secondsSinceGenesis) % 60
HStack(spacing: 4) {
Text("с генезиса:")
.font(.system(size: 9, weight: .light))
.foregroundColor(.white.opacity(0.3))
if genesisYears > 0 {
Text("\(genesisYears)")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.5))
Text("г")
.font(.system(size: 8, weight: .light))
.foregroundColor(.white.opacity(0.3))
}
Text("\(genesisDays)")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.5))
Text("дн")
.font(.system(size: 8, weight: .light))
.foregroundColor(.white.opacity(0.3))
Text(String(format: "%02d:%02d:%02d", genesisHours, genesisMinutes, genesisSec))
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.4))
}
}
// Разделитель
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(height: 1)
.padding(.horizontal, 40)
.padding(.vertical, 8)
// Дата конца эмиссии (вычислена от 21M минут, МСК)
VStack(spacing: 4) {
Text(endDateShort + " МСК")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(Color(hex: "D4AF37").opacity(0.6))
.tracking(2)
// Обратный отсчёт до конца
HStack(spacing: 4) {
Text("\(yearsToEnd)")
.font(.system(size: 16, weight: .light, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
Text("лет")
.font(.system(size: 8, weight: .light))
.foregroundColor(.white.opacity(0.3))
Text("\(daysToEnd)")
.font(.system(size: 16, weight: .light, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
Text("дн")
.font(.system(size: 8, weight: .light))
.foregroundColor(.white.opacity(0.3))
Text(String(format: "%02d", hoursToEnd))
.font(.system(size: 16, weight: .light, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
Text(":")
.foregroundColor(.white.opacity(0.3))
Text(String(format: "%02d", minutesToEnd))
.font(.system(size: 16, weight: .light, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
Text(":")
.foregroundColor(.white.opacity(0.3))
Text(String(format: "%02d", secondsToEndDisplay))
.font(.system(size: 16, weight: .light, design: .monospaced))
.foregroundColor(Color(hex: "D4AF37").opacity(0.8))
}
Text("последняя секунда эмиссии")
.font(.system(size: 8, weight: .light))
.foregroundColor(.white.opacity(0.2))
}
}
}
}
private func formatNumber(_ value: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSeparator = " "
return formatter.string(from: NSNumber(value: value)) ?? "0"
}
}
// MARK: - Global Slice Component
struct GlobalSlice: View {
let value: String
let label: String
let isActive: Bool
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 20, weight: .light, design: .monospaced))
.foregroundColor(isActive ? .white : .white.opacity(0.3))
.lineLimit(1)
.minimumScaleFactor(0.6)
Text(label)
.font(.system(size: 9, weight: .light))
.foregroundColor(isActive ? Color(hex: "D4AF37") : .white.opacity(0.2))
}
.frame(width: 70, height: 50)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(isActive ? 0.08 : 0.03))
)
}
}
// MARK: - Action Circle
struct ActionCircle: View {
let icon: String
let label: String
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
ZStack {
Circle()
.stroke(Color.white.opacity(0.2), lineWidth: 1)
.frame(width: 56, height: 56)
Image(systemName: icon)
.font(.system(size: 20, weight: .light))
.foregroundColor(.white)
}
Text(label)
.font(.system(size: 12, weight: .light))
.foregroundColor(.white.opacity(0.6))
}
}
}
}
// MARK: - Sidebar Menu (Hamburger)
// MARK: - Side Menu View (свайп влево)
struct SideMenuView: View {
@Binding var showMenu: Bool
@Binding var menuOffset: CGFloat
let menuWidth: CGFloat
@ObservedObject var auth = AuthService.shared
@ObservedObject var profile = ProfileManager.shared
@State private var showSettingsDetail = false
@State private var showTimeChain = false
var body: some View {
ZStack {
Color(hex: "0A0A0A").ignoresSafeArea()
VStack(spacing: 0) {
// Header с аватаром
HStack(spacing: 16) {
Image("GenesisPhoto")
.resizable()
.scaledToFill()
.frame(width: 50, height: 50)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
Text("Montana")
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(.white)
// MT ID только цифры, кликабельный для копирования
if let mtNumber = profile.mtNumber {
Button {
UIPasteboard.general.string = "\(mtNumber)"
// Haptic feedback
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
} label: {
HStack(spacing: 4) {
Text("\(mtNumber)")
.font(.system(size: 14, weight: .medium, design: .monospaced))
.foregroundColor(Color(hex: "D4AF37"))
Image(systemName: "doc.on.doc")
.font(.system(size: 10))
.foregroundColor(Color(hex: "D4AF37").opacity(0.6))
}
}
}
}
Spacer()
// Кнопка закрытия
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
menuOffset = -menuWidth
showMenu = false
}
} label: {
Image(systemName: "xmark")
.font(.title3)
.foregroundColor(.white.opacity(0.5))
}
}
.padding(.horizontal, 20)
.padding(.top, 60)
.padding(.bottom, 20)
Divider()
.background(Color.white.opacity(0.1))
// Приложения Montana
VStack(alignment: .leading, spacing: 0) {
Text("ПРИЛОЖЕНИЯ")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white.opacity(0.3))
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
// Junona (главный экран - кошелёк)
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
menuOffset = -menuWidth
showMenu = false
}
} label: {
MenuRow(icon: "creditcard.fill", name: "Кошелёк", isActive: true)
}
// Цепочка Времени
Button {
showTimeChain = true
} label: {
MenuRow(icon: "pentagon", name: "Цепочка", isActive: true)
}
}
Spacer()
Divider()
.background(Color.white.opacity(0.1))
// Настройки и Выход
VStack(spacing: 0) {
Button {
showSettingsDetail = true
} label: {
HStack(spacing: 16) {
Image(systemName: "gearshape.fill")
.font(.title3)
.foregroundColor(.white.opacity(0.6))
.frame(width: 32)
Text("Настройки")
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.8))
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.white.opacity(0.3))
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
// Выход
Button {
auth.logout()
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
menuOffset = -menuWidth
showMenu = false
}
} label: {
HStack(spacing: 16) {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.title3)
.foregroundColor(.red.opacity(0.7))
.frame(width: 32)
Text("Выйти")
.font(.system(size: 16))
.foregroundColor(.red.opacity(0.7))
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
}
.padding(.bottom, 40)
}
}
.sheet(isPresented: $showSettingsDetail) {
SettingsDetailView()
}
.fullScreenCover(isPresented: $showTimeChain) {
NavigationStack {
TimeChainExplorerView()
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
showTimeChain = false
} label: {
Image(systemName: "xmark")
.foregroundColor(.white.opacity(0.6))
}
}
}
}
}
}
}
// MARK: - Menu Row
struct MenuRow: View {
let icon: String
let name: String
var isActive: Bool = true
var badge: String? = nil
var body: some View {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(isActive ? Color(hex: "10B981") : .white.opacity(0.4))
.frame(width: 32)
Text(name)
.font(.system(size: 16))
.foregroundColor(isActive ? .white : .white.opacity(0.5))
Spacer()
if let badge = badge {
Text(badge)
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white.opacity(0.4))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.white.opacity(0.1))
.cornerRadius(4)
} else if isActive {
Circle()
.fill(Color(hex: "10B981"))
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
// MARK: - Settings Sheet (deprecated - используем SideMenuView)
struct SettingsSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var auth = AuthService.shared
@State private var showSettings = false
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
// Header с аватаром
if let user = auth.currentUser {
HStack(spacing: 16) {
Image("GenesisPhoto")
.resizable()
.scaledToFill()
.frame(width: 60, height: 60)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
Text("Montana")
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(.white)
Text(user.shortAddress)
.font(.caption)
.foregroundColor(.white.opacity(0.5))
.fontDesign(.monospaced)
}
Spacer()
}
.padding(.horizontal, 24)
.padding(.top, 24)
.padding(.bottom, 16)
}
Divider()
.background(Color.white.opacity(0.1))
// Приложения Montana
VStack(alignment: .leading, spacing: 0) {
Text("ПРИЛОЖЕНИЯ")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white.opacity(0.3))
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
// Junona активно
SidebarAppRow(
icon: "message.fill",
name: "Junona",
status: .active,
statusText: "Подключено"
)
// Кошелёк активно
SidebarAppRow(
icon: "creditcard.fill",
name: "Кошелёк",
status: .active,
statusText: "Активен"
)
// Контракты скоро
SidebarAppRow(
icon: "doc.text.fill",
name: "Контракты",
status: .soon,
statusText: "Скоро"
)
}
Spacer()
Divider()
.background(Color.white.opacity(0.1))
// Настройки внизу
VStack(spacing: 0) {
Button {
showSettings = true
} label: {
HStack(spacing: 16) {
Image(systemName: "gearshape.fill")
.font(.title3)
.foregroundColor(.white.opacity(0.6))
.frame(width: 32)
Text("Настройки")
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.8))
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.white.opacity(0.3))
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
// Выход
Button {
auth.logout()
dismiss()
} label: {
HStack(spacing: 16) {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.title3)
.foregroundColor(.red.opacity(0.7))
.frame(width: 32)
Text("Выйти")
.font(.system(size: 16))
.foregroundColor(.red.opacity(0.7))
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
.padding(.bottom, 16)
}
}
.navigationTitle("Меню")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.foregroundColor(.white.opacity(0.6))
}
}
}
.sheet(isPresented: $showSettings) {
SettingsDetailView()
}
}
}
}
// MARK: - Sidebar App Row
enum AppStatus {
case active
case soon
case offline
}
struct SidebarAppRow: View {
let icon: String
let name: String
let status: AppStatus
let statusText: String
var statusColor: Color {
switch status {
case .active: return Color(hex: "10B981") // Зелёный
case .soon: return Color(hex: "D4AF37") // Золотой
case .offline: return .red.opacity(0.7)
}
}
var body: some View {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(MontanaTheme.primary)
.frame(width: 32)
Text(name)
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.9))
Spacer()
// Статус
HStack(spacing: 6) {
Circle()
.fill(statusColor)
.frame(width: 6, height: 6)
Text(statusText)
.font(.system(size: 11))
.foregroundColor(statusColor)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 14)
.background(Color.white.opacity(0.02))
}
}
// MARK: - Settings Detail View
struct SettingsDetailView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var auth = AuthService.shared
@ObservedObject var profile = ProfileManager.shared
@State private var copiedId: String?
@State private var showRecoveryPhrase = false
@State private var showChangePin = false
@State private var showSetupPin = false
@State private var showCreateIdentity = false
@State private var showRestoreIdentity = false
private var mtId: String? {
UserDefaults.standard.string(forKey: "montana_mt_id")
}
private var montanaAddress: String? {
UserDefaults.standard.string(forKey: "montana_address")
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// MARK: - Профиль
SettingsSectionView(title: "ПРОФИЛЬ") {
VStack(spacing: 0) {
// MT ID
if let mtId = mtId {
SettingsRowView(
icon: "person.badge.shield.checkmark.fill",
iconColor: Color(hex: "D4AF37"),
title: "Montana ID",
value: mtId,
isCopyable: true,
copiedId: $copiedId
)
}
Divider().background(Color.white.opacity(0.1))
// Баланс
SettingsRowView(
icon: "clock.fill",
iconColor: Color(hex: "10B981"),
title: "Баланс",
value: "\(Int(WalletService.shared.balance)) Ɉ"
)
}
}
// MARK: - Авторизация
SettingsSectionView(title: "АВТОРИЗАЦИЯ") {
VStack(spacing: 0) {
SettingsRowView(
icon: "brain.head.profile",
iconColor: Color(hex: "D4AF37"),
title: "Когнитивный ключ",
value: "Активен"
)
}
}
// MARK: - Устройства
SettingsSectionView(title: "УСТРОЙСТВА") {
VStack(spacing: 0) {
// Текущее устройство
SettingsDeviceRow(
icon: "iphone",
name: UIDevice.current.name,
deviceId: profile.deviceId,
isCurrent: true,
onCopy: {
UIPasteboard.general.string = profile.deviceId
copiedId = profile.deviceId
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if copiedId == profile.deviceId { copiedId = nil }
}
},
copiedId: copiedId
)
}
}
// MARK: - Безопасность
SettingsSectionView(title: "БЕЗОПАСНОСТЬ") {
VStack(spacing: 0) {
// PIN-код
Button {
if KeychainManager.shared.hasPin {
showChangePin = true
} else {
showSetupPin = true
}
} label: {
HStack(spacing: 12) {
Image(systemName: KeychainManager.shared.hasPin ? "lock.fill" : "lock.open.fill")
.font(.system(size: 18))
.foregroundColor(KeychainManager.shared.hasPin ? Color(hex: "10B981") : .orange)
.frame(width: 28)
Text("PIN-код")
.font(.system(size: 15))
.foregroundColor(.white.opacity(0.6))
Spacer()
Text(KeychainManager.shared.hasPin ? "Включён" : "Не установлен")
.font(.system(size: 13))
.foregroundColor(KeychainManager.shared.hasPin ? Color(hex: "10B981") : .orange)
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
Divider().background(Color.white.opacity(0.1))
// Когнитивный ключ
Button {
if KeychainManager.shared.hasPin {
showRecoveryPhrase = true
} else {
showSetupPin = true
}
} label: {
HStack(spacing: 12) {
Image(systemName: "eye.fill")
.font(.system(size: 18))
.foregroundColor(Color(hex: "7b2fff"))
.frame(width: 28)
Text("Когнитивный ключ")
.font(.system(size: 15))
.foregroundColor(.white.opacity(0.6))
Spacer()
if KeychainManager.shared.hasPin {
Image(systemName: "lock.fill")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.3))
} else {
Text("Нужен PIN")
.font(.system(size: 13))
.foregroundColor(.orange)
}
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
}
// MARK: - Идентичность
SettingsSectionView(title: "ИДЕНТИЧНОСТЬ") {
VStack(spacing: 0) {
// Создать новую
Button {
showCreateIdentity = true
} label: {
HStack(spacing: 12) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 18))
.foregroundColor(Color(hex: "00d4ff"))
.frame(width: 28)
Text("Создать новую идентичность")
.font(.system(size: 15))
.foregroundColor(.white.opacity(0.6))
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
Divider().background(Color.white.opacity(0.1))
// Восстановить
Button {
showRestoreIdentity = true
} label: {
HStack(spacing: 12) {
Image(systemName: "arrow.counterclockwise.circle.fill")
.font(.system(size: 18))
.foregroundColor(Color(hex: "7b2fff"))
.frame(width: 28)
Text("Восстановить из ключа")
.font(.system(size: 15))
.foregroundColor(.white.opacity(0.6))
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
}
// MARK: - О приложении (всегда в конце)
SettingsSectionView(title: "О ПРИЛОЖЕНИИ") {
VStack(spacing: 0) {
SettingsInfoRow(label: "Версия", value: Montana.version)
Divider().background(Color.white.opacity(0.1))
SettingsInfoRow(label: "Криптография", value: "ML-DSA-65")
Divider().background(Color.white.opacity(0.1))
SettingsInfoRow(label: "Стандарт", value: "FIPS 204")
Divider().background(Color.white.opacity(0.1))
SettingsInfoRow(label: "Генезис", value: "9 января 2026")
}
}
Spacer().frame(height: 40)
}
.padding(.top, 16)
}
}
.navigationTitle("Настройки")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Готово") { dismiss() }
.foregroundColor(Color(hex: "D4AF37"))
}
}
.sheet(isPresented: $showRecoveryPhrase) {
RecoveryPhraseView()
}
.sheet(isPresented: $showChangePin) {
ChangePinView()
}
.sheet(isPresented: $showSetupPin) {
SetupPinFromSettingsView()
}
.sheet(isPresented: $showCreateIdentity) {
CreateIdentityView {
profile.loadProfile()
}
}
.sheet(isPresented: $showRestoreIdentity) {
RestoreIdentityView {
profile.loadProfile()
}
}
}
}
}
// MARK: - Settings Section
struct SettingsSectionView<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white.opacity(0.4))
.padding(.horizontal, 20)
content
.background(Color.white.opacity(0.05))
.cornerRadius(12)
.padding(.horizontal, 16)
}
}
}
// MARK: - Settings Row
struct SettingsRowView: View {
let icon: String
let iconColor: Color
let title: String
let value: String
var isCopyable: Bool = false
var fullValue: String? = nil
@Binding var copiedId: String?
var isConnected: Bool = false
init(icon: String, iconColor: Color, title: String, value: String, isCopyable: Bool = false, fullValue: String? = nil, copiedId: Binding<String?>? = nil, isConnected: Bool = false) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.value = value
self.isCopyable = isCopyable
self.fullValue = fullValue
self._copiedId = copiedId ?? .constant(nil)
self.isConnected = isConnected
}
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(iconColor)
.frame(width: 28)
Text(title)
.font(.system(size: 15))
.foregroundColor(.white.opacity(0.6))
Spacer()
if copiedId == (fullValue ?? value) {
Text("Скопировано")
.font(.system(size: 13))
.foregroundColor(Color(hex: "10B981"))
} else {
Text(value)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.lineLimit(1)
}
if isConnected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 16))
.foregroundColor(Color(hex: "10B981"))
}
if isCopyable {
Button {
UIPasteboard.general.string = fullValue ?? value
withAnimation {
copiedId = fullValue ?? value
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
if copiedId == (fullValue ?? value) {
copiedId = nil
}
}
}
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.4))
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
struct SettingsInfoRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.5))
Spacer()
Text(value)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
// MARK: - Settings Device Row
struct SettingsDeviceRow: View {
let icon: String
let name: String
let deviceId: String
let isCurrent: Bool
var onCopy: (() -> Void)?
var copiedId: String?
var body: some View {
HStack(spacing: 12) {
// Иконка устройства
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(isCurrent ? Color(hex: "10B981") : .white.opacity(0.5))
.frame(width: 28)
// Название и ID
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(name)
.font(.system(size: 15))
.foregroundColor(.white)
if isCurrent {
Text("текущее")
.font(.system(size: 11))
.foregroundColor(Color(hex: "10B981"))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color(hex: "10B981").opacity(0.2))
.cornerRadius(4)
}
}
if copiedId == deviceId {
Text("Скопировано")
.font(.system(size: 12))
.foregroundColor(Color(hex: "10B981"))
} else {
Text(String(deviceId.prefix(8)) + "...")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white.opacity(0.4))
}
}
Spacer()
// Копировать
if let onCopy = onCopy {
Button(action: onCopy) {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.4))
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
// MARK: - Send Sheet
struct SendSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var wallet = WalletService.shared
@State private var recipient = ""
@State private var amount = ""
@State private var isSending = false
@State private var statusText = ""
@State private var statusColor: Color = .secondary
@State private var sendSuccess = false
@State private var sentAmount = 0
@State private var sentRecipient = ""
@State private var sentTimestamp = ""
private let gold = Color(hex: "D4AF37")
private let cyan = Color(red: 0, green: 0.83, blue: 1)
private let cardBg = Color.white.opacity(0.05)
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
if sendSuccess {
confirmationView
} else {
sendFormView
}
}
.navigationTitle(sendSuccess ? "" : "Отправить")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
if !sendSuccess {
Button("Отмена") { dismiss() }
.foregroundColor(.white.opacity(0.6))
}
}
}
}
}
private var confirmationView: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundColor(.green)
.padding(.bottom, 20)
Text("Отправлено")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.green)
.padding(.bottom, 28)
VStack(spacing: 16) {
confirmRow(label: "Сумма", value: formatAmt(sentAmount))
confirmRow(label: "Получатель", value: sentRecipient)
confirmRow(label: "Время", value: sentTimestamp)
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(cardBg)
)
.padding(.horizontal, 32)
Spacer()
Button { dismiss() } label: {
Text("Готово")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(cyan)
.cornerRadius(12)
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
}
private func confirmRow(label: String, value: String) -> some View {
HStack {
Text(label)
.font(.system(size: 13))
.foregroundColor(.white.opacity(0.5))
Spacer()
Text(value)
.font(.system(size: 15, weight: .bold, design: .monospaced))
.foregroundColor(.white)
}
}
private var currentBalance: Int {
Int(wallet.balance)
}
private var sendFormView: some View {
VStack(spacing: 24) {
// Balance card
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Доступно")
.font(.system(size: 11, weight: .light))
.foregroundColor(.white.opacity(0.4))
Text(formatAmt(currentBalance))
.font(.system(size: 20, weight: .bold, design: .monospaced))
.foregroundColor(cyan)
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(cardBg)
)
VStack(alignment: .leading, spacing: 8) {
Text("Адрес или номер")
.font(.system(size: 12, weight: .light))
.foregroundColor(.white.opacity(0.4))
TextField("mt... / 1 / @ник", text: $recipient)
.font(.system(size: 16, design: .monospaced))
.foregroundColor(.white)
.autocapitalization(.none)
.disableAutocorrection(true)
.padding()
.background(Color.white.opacity(0.05))
.cornerRadius(12)
}
VStack(alignment: .leading, spacing: 8) {
Text("Сумма")
.font(.system(size: 12, weight: .light))
.foregroundColor(.white.opacity(0.4))
HStack {
TextField("0", text: $amount)
.keyboardType(.numberPad)
.font(.system(size: 32, weight: .thin))
.foregroundColor(.white)
Text("Ɉ")
.font(.system(size: 24, weight: .bold))
.foregroundColor(gold)
}
.padding()
.background(Color.white.opacity(0.05))
.cornerRadius(12)
}
if let amt = Int(amount), amt > currentBalance, currentBalance > 0 {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11))
Text("Недостаточно средств")
}
.font(.system(size: 12))
.foregroundColor(.orange)
}
if !statusText.isEmpty {
Text(statusText)
.font(.system(size: 13))
.foregroundColor(statusColor)
.multilineTextAlignment(.center)
}
Spacer()
Button { sendTransfer() } label: {
HStack(spacing: 8) {
if isSending {
ProgressView().tint(.black)
} else {
Image(systemName: "paperplane.fill")
.foregroundColor(.black)
}
Text("Отправить")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.black)
}
.frame(maxWidth: .infinity)
.padding()
.background(
canSend ? gold : Color.white.opacity(0.2)
)
.cornerRadius(12)
}
.disabled(!canSend)
.padding(.bottom, 32)
}
.padding(.horizontal, 24)
.padding(.top, 32)
}
private var canSend: Bool {
guard let amt = Int(amount), amt > 0 else { return false }
return !recipient.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending
}
private func sendTransfer() {
guard !isSending else { return }
guard let amt = Int(amount), amt > 0 else { return }
let input = recipient.trimmingCharacters(in: .whitespacesAndNewlines)
guard !input.isEmpty else { return }
guard let fromAddr = UserDefaults.standard.string(forKey: "montana_address"), !fromAddr.isEmpty else {
statusText = "Кошелёк не настроен"
statusColor = .orange
return
}
isSending = true
statusText = ""
Task { @MainActor in
defer { isSending = false }
do {
// Resolve address
var toAddr = ""
var toAlias = ""
if input.hasPrefix("mt") && input.count == 42 {
toAddr = input
toAlias = String(input.prefix(8)) + "..." + String(input.suffix(4))
} else {
let lookupID: String
if input.hasPrefix("@") {
lookupID = String(input.dropFirst())
} else if input.hasPrefix("Ɉ-") {
lookupID = String(input.dropFirst(2))
} else {
lookupID = input.filter { $0.isNumber }
}
guard !lookupID.isEmpty else {
statusText = "Неверный адрес"
statusColor = .red
return
}
let result = try await wallet.lookupWallet(identifier: lookupID)
toAddr = result.address
toAlias = result.alias
}
guard toAddr != fromAddr else {
statusText = "Нельзя отправить себе"
statusColor = .orange
return
}
try await wallet.transfer(from: fromAddr, to: toAddr, amount: amt)
let df = DateFormatter()
df.dateFormat = "dd.MM.yyyy HH:mm:ss"
sentAmount = amt
sentRecipient = toAlias.isEmpty ? (String(toAddr.prefix(8)) + "..." + String(toAddr.suffix(4))) : toAlias
sentTimestamp = df.string(from: Date())
sendSuccess = true
} catch {
statusText = "Ошибка: \(error.localizedDescription)"
statusColor = .red
}
}
}
private func formatAmt(_ amount: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSeparator = " "
let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)"
return "\(formatted) Ɉ"
}
}
// MARK: - Receive Sheet
struct ReceiveSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var auth = AuthService.shared
@ObservedObject var profile = ProfileManager.shared
@State private var copiedAlias = false
@State private var copiedAddress = false
var body: some View {
NavigationStack {
ZStack {
// Градиентный фон
LinearGradient(
colors: [Color.black, Color(hex: "0a0a0a")],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
//
// ЛОГО МОНЕТЫ ВРЕМЕНИ
//
ZStack {
// Внешнее свечение
Circle()
.fill(
RadialGradient(
colors: [Color(hex: "D4AF37").opacity(0.3), .clear],
center: .center,
startRadius: 40,
endRadius: 100
)
)
.frame(width: 200, height: 200)
// Кольцо
Circle()
.stroke(
LinearGradient(
colors: [Color(hex: "D4AF37"), Color(hex: "B8860B")],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 3
)
.frame(width: 120, height: 120)
// Символ
Text("Ɉ")
.font(.system(size: 56, weight: .bold, design: .serif))
.foregroundStyle(
LinearGradient(
colors: [Color(hex: "FFD700"), Color(hex: "D4AF37")],
startPoint: .top,
endPoint: .bottom
)
)
}
.padding(.top, 40)
.padding(.bottom, 32)
//
// АЛИАС главный идентификатор
//
VStack(spacing: 20) {
// Заголовок
HStack(spacing: 8) {
Circle()
.fill(Color(hex: "D4AF37"))
.frame(width: 8, height: 8)
Text("АЛИАС")
.font(.system(size: 12, weight: .bold))
.tracking(3)
.foregroundColor(Color(hex: "D4AF37"))
}
// Алиас большой и красивый
Text(profile.alias)
.font(.system(size: 48, weight: .bold, design: .monospaced))
.foregroundStyle(
LinearGradient(
colors: [Color(hex: "FFD700"), Color(hex: "D4AF37")],
startPoint: .leading,
endPoint: .trailing
)
)
// Кнопка копирования
Button {
UIPasteboard.general.string = profile.alias
withAnimation(.spring(response: 0.3)) { copiedAlias = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { copiedAlias = false }
}
} label: {
HStack(spacing: 8) {
Image(systemName: copiedAlias ? "checkmark.circle.fill" : "doc.on.doc.fill")
.font(.system(size: 16))
Text(copiedAlias ? "Скопировано!" : "Копировать алиас")
.font(.system(size: 14, weight: .semibold))
}
.foregroundColor(copiedAlias ? Color(hex: "10B981") : Color(hex: "D4AF37"))
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(
Capsule()
.fill(copiedAlias ? Color(hex: "10B981").opacity(0.15) : Color(hex: "D4AF37").opacity(0.15))
.overlay(
Capsule()
.stroke(copiedAlias ? Color(hex: "10B981").opacity(0.5) : Color(hex: "D4AF37").opacity(0.5), lineWidth: 1)
)
)
}
.scaleEffect(copiedAlias ? 1.05 : 1.0)
// Пояснение
Text("Уникальный номер кошелька.\nПросто скажи: «Отправь на \(profile.alias)»")
.font(.system(size: 13))
.foregroundColor(.white.opacity(0.5))
.multilineTextAlignment(.center)
.lineSpacing(4)
}
.padding(.vertical, 28)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(Color(hex: "D4AF37").opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(
LinearGradient(
colors: [Color(hex: "D4AF37").opacity(0.4), Color(hex: "D4AF37").opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
)
.padding(.horizontal, 20)
// Разделитель
HStack {
Rectangle().fill(.white.opacity(0.1)).frame(height: 1)
Text("или")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.white.opacity(0.3))
.padding(.horizontal, 12)
Rectangle().fill(.white.opacity(0.1)).frame(height: 1)
}
.padding(.horizontal, 40)
.padding(.vertical, 24)
//
// ПОЛНЫЙ АДРЕС криптографический
//
if let user = auth.currentUser {
VStack(spacing: 16) {
// Заголовок
HStack(spacing: 8) {
Image(systemName: "key.fill")
.font(.system(size: 10))
Text("ПОЛНЫЙ АДРЕС")
.font(.system(size: 11, weight: .semibold))
.tracking(2)
}
.foregroundColor(.white.opacity(0.4))
// Адрес в новом формате: Ɉ-{номер}-{хеш}
Text(profile.fullAddress)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 8)
// Кнопка копирования
Button {
UIPasteboard.general.string = profile.fullAddress
withAnimation(.spring(response: 0.3)) { copiedAddress = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { copiedAddress = false }
}
} label: {
HStack(spacing: 6) {
Image(systemName: copiedAddress ? "checkmark.circle.fill" : "doc.on.doc")
.font(.system(size: 12))
Text(copiedAddress ? "Скопировано!" : "Копировать адрес")
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(copiedAddress ? Color(hex: "10B981") : .white.opacity(0.6))
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(
Capsule()
.fill(.white.opacity(0.05))
.overlay(
Capsule()
.stroke(.white.opacity(0.1), lineWidth: 1)
)
)
}
// Пояснение
Text("Криптографический адрес ML-DSA-65\nДля верификации и внешних систем")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.3))
.multilineTextAlignment(.center)
.lineSpacing(3)
}
.padding(.vertical, 20)
.padding(.horizontal, 20)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.white.opacity(0.02))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(.white.opacity(0.06), lineWidth: 1)
)
)
.padding(.horizontal, 20)
}
Spacer(minLength: 60)
}
}
}
.navigationTitle("Получить")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Готово") { dismiss() }
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color(hex: "D4AF37"))
}
}
.onAppear {
profile.loadProfile()
}
}
}
}
// MARK: - Wallet Service (синхронизация с сервером + live update)
@MainActor
class WalletService: ObservableObject {
static let shared = WalletService()
@Published var balance: Double = 0
@Published var sessionStart: Date?
@Published var isLoading = false
// TAU Slices from server
@Published var tau1: Double = 0 // 1 минута
@Published var tau2: Double = 0 // 10 минут
@Published var tau3: Double = 0 // 14 дней
@Published var tau4: Double = 0 // 4 года
// Network Status (видимая инфраструктура "too big to fail")
@Published var networkStatus: NetworkStatus = .connecting
@Published var isConnected = false
@Published var activeNodes = 0
@Published var failoverCount = 0
@Published var endpointLatencies: [String: Int] = [:]
private var sessionStartTime = Date()
private var tickTimer: Timer?
private var syncTimer: Timer?
// Серверы для отказоустойчивости (failover)
// 3 узла сети Montana + сайт Timeweb
// Все проверены через SSH API работает
// IP адреса используют HTTP (нет SSL сертификатов), сайт использует HTTPS
private let endpointConfigs: [(name: String, url: String)] = [
("Timeweb", "https://efir.org"), // Сайт (primary, HTTPS)
("Moscow", "http://176.124.208.93"), // Москва (HTTP)
("Amsterdam", "http://72.56.102.240"), // Амстердам (HTTP)
("Almaty", "http://91.200.148.93") // Алматы (HTTP)
]
private var endpoints: [String] { endpointConfigs.map { $0.url } }
private var currentEndpointIndex = 0
private var baseURL: String { endpoints[currentEndpointIndex] }
// Имя текущего endpoint для UI
var currentEndpointName: String {
guard currentEndpointIndex < endpointConfigs.count else { return "Offline" }
return endpointConfigs[currentEndpointIndex].name
}
// Все endpoints для отображения в NetworkDetailsSheet
var allEndpoints: [EndpointInfo] {
endpointConfigs.enumerated().map { index, config in
EndpointInfo(
name: config.name,
url: config.url.replacingOccurrences(of: "https://", with: ""),
isActive: index == currentEndpointIndex && isConnected,
latency: endpointLatencies[config.url]
)
}
}
// Uptime с момента запуска сессии
var uptimeString: String {
let seconds = Int(Date().timeIntervalSince(sessionStartTime))
if seconds < 60 { return "\(seconds)s" }
if seconds < 3600 { return "\(seconds / 60)m" }
return "\(seconds / 3600)h"
}
// TAU Constants for local tick
private let TAU1_SEC: Double = 60
private let TAU2_SEC: Double = 600
private let TAU3_SEC: Double = 1_209_600
private let TAU4_SEC: Double = 126_230_400
// Ключи для UserDefaults
private let balanceKey = "montana_balance"
private let lastReportedKey = "montana_last_reported"
private var hasSyncedOnce = false
private var lastReportedBalance: Double = 0
private init() {
// Восстанавливаем состояние
lastReportedBalance = UserDefaults.standard.double(forKey: lastReportedKey)
balance = UserDefaults.standard.double(forKey: balanceKey)
currentEndpointIndex = UserDefaults.standard.integer(forKey: "montana_endpoint_index")
if currentEndpointIndex >= endpoints.count { currentEndpointIndex = 0 }
sessionStartTime = Date()
activeNodes = endpoints.count
print("[Montana] Init: balance=\(Int(balance)), endpoints=\(endpoints.count)")
}
/// Сброс баланса при создании новой идентичности
func resetForNewIdentity() {
balance = 0
lastReportedBalance = 0
hasSyncedOnce = false
tau1 = 0; tau2 = 0; tau3 = 0; tau4 = 0
UserDefaults.standard.set(0.0, forKey: balanceKey)
UserDefaults.standard.set(0.0, forKey: lastReportedKey)
print("[Montana] Balance reset for new identity")
}
// Failover: попробовать все серверы для надёжности
// "Too big to fail" автоматическое переключение между узлами
private func tryAllEndpoints<T>(_ operation: (String) async throws -> T) async throws -> T {
var lastError: Error?
networkStatus = .connecting
for i in 0..<endpoints.count {
let index = (currentEndpointIndex + i) % endpoints.count
let endpoint = endpoints[index]
let startTime = Date()
do {
let result = try await operation(endpoint)
// Успех обновляем статистику
let latency = Int(Date().timeIntervalSince(startTime) * 1000)
endpointLatencies[endpoint] = latency
if index != currentEndpointIndex {
// Failover произошёл
failoverCount += 1
currentEndpointIndex = index
UserDefaults.standard.set(currentEndpointIndex, forKey: "montana_endpoint_index")
networkStatus = .failover
print("[Montana] Failover #\(failoverCount)\(endpointConfigs[index].name)")
// Через 2 секунды покажем "connected"
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await MainActor.run {
self.networkStatus = .connected
}
}
} else {
networkStatus = .connected
}
isConnected = true
activeNodes = endpoints.count // Все узлы считаются активными
return result
} catch {
endpointLatencies[endpoint] = nil // Endpoint не отвечает
lastError = error
print("[Montana] Endpoint \(endpointConfigs[index].name) failed: \(error.localizedDescription)")
}
}
// Все endpoints недоступны
networkStatus = .offline
isConnected = false
activeNodes = 0
throw lastError ?? URLError(.cannotConnectToHost)
}
func startSession() {
guard sessionStart == nil else { return }
sessionStart = Date()
// СНАЧАЛА синхронизируем с сервером, ПОТОМ запускаем таймер
Task {
// Пингуем ВСЕ узлы параллельно для отображения статуса
await pingAllEndpoints()
await syncBalance()
await reportPresence() // Сразу отправляем накопленное (если есть)
// Только после первой синхронизации запускаем таймер +1
await MainActor.run {
self.startTimers()
}
}
}
/// Пингует все endpoints параллельно для показа latency/standby
func pingAllEndpoints() async {
await withTaskGroup(of: (String, Int?).self) { group in
for config in endpointConfigs {
group.addTask {
let startTime = Date()
do {
guard let url = URL(string: "\(config.url)/api/health") else {
return (config.url, nil)
}
var request = URLRequest(url: url)
request.timeoutInterval = 5
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
let latency = Int(Date().timeIntervalSince(startTime) * 1000)
return (config.url, latency)
}
return (config.url, nil)
} catch {
return (config.url, nil)
}
}
}
for await (endpoint, latency) in group {
await MainActor.run {
self.endpointLatencies[endpoint] = latency
}
}
}
// Подсчитываем активные узлы и обновляем статус
await MainActor.run {
let respondingNodes = self.endpointLatencies.values.compactMap { $0 }.count
self.activeNodes = respondingNodes
// Если хотя бы один узел отвечает сеть работает
if respondingNodes > 0 {
self.networkStatus = .connected
self.isConnected = true
} else {
self.networkStatus = .offline
self.isConnected = false
}
}
}
private func startTimers() {
guard tickTimer == nil else { return }
// Обновляем UI каждую секунду (+1 Ɉ)
tickTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
self.balance += 1
// Сохраняем каждые 10 секунд
if Int(self.balance) % 10 == 0 {
UserDefaults.standard.set(self.balance, forKey: self.balanceKey)
}
// Update local slices
self.tau1 = self.balance / self.TAU1_SEC
self.tau2 = self.balance / self.TAU2_SEC
self.tau3 = self.balance / self.TAU3_SEC
self.tau4 = self.balance / self.TAU4_SEC
}
}
// Синхронизируем с сервером каждые 30 секунд + отправляем presence
syncTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
Task { @MainActor in
await self?.syncBalance()
await self?.reportPresence()
}
}
}
// MARK: - Presence Reporting
//
// АРХИТЕКТУРА MONTANA:
// - balance = presence_seconds (сколько секунд присутствовал этот tg_id)
// - 100 устройств с одним tg_id = всё равно 60 сек/мин (идентичность)
// - АНТИЦЕНЗУРА: пробуем все endpoints при отправке
func reportPresence() async {
// Ключ кошелька = montana_address
guard let key = UserDefaults.standard.string(forKey: "montana_address"),
!key.isEmpty else { return }
let delta = Int(balance - lastReportedBalance)
guard delta > 0 else { return }
do {
// АНТИЦЕНЗУРА: пробуем все endpoints
let serverBalance = try await tryAllEndpoints { endpoint -> Int in
guard let url = URL(string: "\(endpoint)/api/presence") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(key, forHTTPHeaderField: "X-Device-ID")
request.httpBody = try JSONSerialization.data(withJSONObject: ["seconds": delta])
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let balance = json["balance"] as? Int else {
throw URLError(.cannotParseResponse)
}
return balance
}
// Синхронизируем с сервером
if Double(serverBalance) > self.balance {
self.balance = Double(serverBalance)
UserDefaults.standard.set(self.balance, forKey: balanceKey)
}
lastReportedBalance = Double(serverBalance)
UserDefaults.standard.set(lastReportedBalance, forKey: lastReportedKey)
print("[Montana] Presence: +\(delta)s → balance=\(serverBalance)")
} catch {
print("[Montana] Presence failed on all endpoints: \(error)")
}
}
func stopSession() {
tickTimer?.invalidate()
tickTimer = nil
syncTimer?.invalidate()
syncTimer = nil
sessionStart = nil
}
func syncBalance() async {
// Ключ кошелька = montana_address
guard let key = UserDefaults.standard.string(forKey: "montana_address"),
!key.isEmpty else {
print("[Montana] syncBalance: нет montana_address")
return
}
isLoading = true
defer { isLoading = false }
do {
// АНТИЦЕНЗУРА: пробуем все endpoints
let json = try await tryAllEndpoints { endpoint in
let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? key
guard let url = URL(string: "\(endpoint)/api/balance/\(encodedKey)") else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw URLError(.cannotParseResponse)
}
return json
}
// СЕРВЕР = ЕДИНСТВЕННЫЙ ИСТОЧНИК ИСТИНЫ
let serverBalance = json["balance"] as? Int ?? 0
if !hasSyncedOnce {
self.balance = Double(serverBalance)
lastReportedBalance = self.balance
UserDefaults.standard.set(lastReportedBalance, forKey: lastReportedKey)
print("[Montana] First sync: balance=\(serverBalance)")
} else {
if Double(serverBalance) > self.balance {
self.balance = Double(serverBalance)
}
}
hasSyncedOnce = true
UserDefaults.standard.set(self.balance, forKey: balanceKey)
print("[Montana] Sync: server=\(serverBalance), local=\(Int(balance))")
// TAU slices от сервера
if let slices = json["slices"] as? [String: Any] {
self.tau1 = slices["tau1"] as? Double ?? (self.balance / TAU1_SEC)
self.tau2 = slices["tau2"] as? Double ?? (self.balance / TAU2_SEC)
self.tau3 = slices["tau3"] as? Double ?? (self.balance / TAU3_SEC)
self.tau4 = slices["tau4"] as? Double ?? (self.balance / TAU4_SEC)
}
} catch {
print("[Montana] All endpoints failed: \(error)")
}
}
// MARK: - Transfer API
/// Перевод монет времени
/// - Parameters:
/// - to: Получатель (телефон, tg_id или mt-адрес)
/// - amount: Сумма в Ɉ
/// - Returns: Результат перевода
func transfer(to recipient: String, amount: Int) async -> TransferResult {
// Ключ кошелька: telegram_id ИЛИ google_email
var walletKey: String?
if let tgId = UserDefaults.standard.string(forKey: "montana_telegram_id"),
!tgId.isEmpty, tgId.allSatisfy({ $0.isNumber }) {
walletKey = tgId
} else if let email = UserDefaults.standard.string(forKey: "montana_google_email"),
!email.isEmpty {
walletKey = email
}
guard let key = walletKey else {
return TransferResult(success: false, error: "Не авторизован")
}
guard amount > 0 else {
return TransferResult(success: false, error: "Сумма должна быть > 0")
}
guard Int(balance) >= amount else {
return TransferResult(success: false, error: "Недостаточно средств")
}
do {
let result = try await tryAllEndpoints { endpoint -> TransferResult in
guard let url = URL(string: "\(endpoint)/api/transfer") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(key, forHTTPHeaderField: "X-Device-ID")
request.httpBody = try JSONSerialization.data(withJSONObject: [
"to": recipient,
"amount": amount
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.cannotParseResponse)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw URLError(.cannotParseResponse)
}
if httpResponse.statusCode == 200, json["success"] as? Bool == true {
let newBalance = json["from_balance"] as? Int ?? Int(self.balance) - amount
return TransferResult(
success: true,
txId: json["tx_id"] as? String,
newBalance: newBalance
)
} else {
let error = json["error"] as? String ?? "Ошибка перевода"
return TransferResult(success: false, error: error)
}
}
// Обновляем локальный баланс
if result.success, let newBalance = result.newBalance {
self.balance = Double(newBalance)
UserDefaults.standard.set(self.balance, forKey: balanceKey)
}
return result
} catch {
return TransferResult(success: false, error: error.localizedDescription)
}
}
// MARK: - Transaction History
/// Получить историю транзакций
func fetchTransactions(limit: Int = 50) async -> [Transaction] {
// Ключ кошелька: telegram_id ИЛИ google_email
var walletKey: String?
if let tgId = UserDefaults.standard.string(forKey: "montana_telegram_id"),
!tgId.isEmpty, tgId.allSatisfy({ $0.isNumber }) {
walletKey = tgId
} else if let email = UserDefaults.standard.string(forKey: "montana_google_email"),
!email.isEmpty {
walletKey = email
}
guard let key = walletKey else {
print("[Montana] fetchTransactions: нет ключа кошелька")
return []
}
do {
let transactions = try await tryAllEndpoints { endpoint -> [Transaction] in
let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? key
guard let url = URL(string: "\(endpoint)/api/transactions/\(encodedKey)?limit=\(limit)") else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let txArray = json["transactions"] as? [[String: Any]] else {
throw URLError(.cannotParseResponse)
}
return txArray.compactMap { tx -> Transaction? in
guard let id = tx["id"] as? String,
let type = tx["type"] as? String,
let from = tx["from"] as? String,
let to = tx["to"] as? String,
let amount = tx["amount"] as? Int,
let timestamp = tx["timestamp"] as? String else {
return nil
}
let isIncoming = to == key
return Transaction(
id: id,
type: type,
from: from,
to: to,
amount: amount,
timestamp: timestamp,
isIncoming: isIncoming
)
}
}
return transactions
} catch {
print("[Montana] Transactions failed: \(error)")
return []
}
}
//
// Transfer API (new matches macOS MontanaAPIClient)
//
func transfer(from: String, to: String, amount: Int) async throws {
try await tryAllEndpoints { endpoint in
guard let url = URL(string: "\(endpoint)/api/transfer") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
let timestamp = ISO8601DateFormatter().string(from: Date())
let body: [String: Any] = [
"from_address": from,
"to_address": to,
"amount": amount,
"timestamp": timestamp
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.cannotParseResponse)
}
if httpResponse.statusCode != 200 {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = json["error"] as? String {
throw NSError(domain: "Montana", code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: error])
}
throw URLError(.cannotParseResponse)
}
return ()
}
}
func lookupWallet(identifier: String) async throws -> (address: String, alias: String) {
return try await tryAllEndpoints { endpoint in
let encoded = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier
guard let url = URL(string: "\(endpoint)/api/wallet/lookup/\(encoded)") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.timeoutInterval = 10
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw URLError(.cannotParseResponse)
}
let cryptoHash = json["crypto_hash"] as? String ?? ""
let mtAddress = "mt" + cryptoHash
let alias = json["alias"] as? String ?? ""
return (mtAddress, alias)
}
}
//
// Event Ledger API events & addresses (matches macOS)
//
func fetchMyEvents(address: String, limit: Int = 100) async throws -> [[String: Any]] {
return try await tryAllEndpoints { endpoint in
let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? address
guard let url = URL(string: "\(endpoint)/api/node/events?address=\(encoded)&limit=\(limit)") else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let events = json["events"] as? [[String: Any]] else {
throw URLError(.cannotParseResponse)
}
return events
}
}
func fetchEvents(limit: Int = 50) async throws -> [[String: Any]] {
return try await tryAllEndpoints { endpoint in
guard let url = URL(string: "\(endpoint)/api/node/events?limit=\(limit)") else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let events = json["events"] as? [[String: Any]] else {
throw URLError(.cannotParseResponse)
}
return events
}
}
func fetchAddresses() async throws -> [[String: Any]] {
return try await tryAllEndpoints { endpoint in
guard let url = URL(string: "\(endpoint)/api/addresses") else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let addresses = json["addresses"] as? [[String: Any]] else {
throw URLError(.cannotParseResponse)
}
return addresses
}
}
// MARK: - Lookup
/// Поиск пользователя по телефону
func lookupByPhone(_ phone: String) async -> LookupResult? {
let normalizedPhone = phone
.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: "(", with: "")
.replacingOccurrences(of: ")", with: "")
do {
return try await tryAllEndpoints { endpoint -> LookupResult? in
guard let url = URL(string: "\(endpoint)/api/lookup/phone/\(normalizedPhone)") else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw URLError(.cannotParseResponse)
}
if json["found"] as? Bool == true {
return LookupResult(
found: true,
tgId: json["tg_id"] as? String,
address: json["address"] as? String
)
}
return LookupResult(found: false)
}
} catch {
return nil
}
}
}
// MARK: - Transfer Result
struct TransferResult {
let success: Bool
var txId: String?
var newBalance: Int?
var error: String?
}
// MARK: - Transaction
struct Transaction: Identifiable {
let id: String
let type: String
let from: String
let to: String
let amount: Int
let timestamp: String
let isIncoming: Bool
var formattedDate: String {
// Parse ISO8601 and format nicely
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: timestamp) {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .short
displayFormatter.timeStyle = .short
return displayFormatter.string(from: date)
}
return timestamp
}
}
// MARK: - Lookup Result
struct LookupResult {
let found: Bool
var tgId: String?
var address: String?
}
// MARK: - Network Status View (Видимая инфраструктура "too big to fail")
struct NetworkStatusView: View {
@ObservedObject var wallet = WalletService.shared
@State private var showDetails = false
var body: some View {
Button {
showDetails = true
} label: {
HStack(spacing: 8) {
// Пульсирующий индикатор
Circle()
.fill(wallet.networkStatus.color)
.frame(width: 8, height: 8)
.overlay(
Circle()
.stroke(wallet.networkStatus.color.opacity(0.5), lineWidth: 2)
.scaleEffect(wallet.isConnected ? 1.5 : 1.0)
.opacity(wallet.isConnected ? 0 : 1)
.animation(.easeInOut(duration: 1).repeatForever(autoreverses: false), value: wallet.isConnected)
)
VStack(alignment: .leading, spacing: 2) {
Text(wallet.currentEndpointName)
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white.opacity(0.8))
HStack(spacing: 4) {
Image(systemName: "server.rack")
.font(.system(size: 8))
Text("\(wallet.activeNodes) узлов")
.font(.system(size: 9))
}
.foregroundColor(.white.opacity(0.4))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.05))
.cornerRadius(12)
}
.sheet(isPresented: $showDetails) {
NetworkDetailsSheet()
}
}
}
// MARK: - Network Details Sheet (Полная информация о сети)
struct NetworkDetailsSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var wallet = WalletService.shared
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Заголовок статуса
VStack(spacing: 8) {
ZStack {
Circle()
.fill(wallet.networkStatus.color.opacity(0.2))
.frame(width: 80, height: 80)
Image(systemName: wallet.networkStatus == .connected ? "checkmark.shield.fill" : "exclamationmark.shield.fill")
.font(.system(size: 36))
.foregroundColor(wallet.networkStatus.color)
}
Text(wallet.networkStatus.title)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
Text(wallet.networkStatus.description)
.font(.subheadline)
.foregroundColor(.white.opacity(0.6))
.multilineTextAlignment(.center)
}
.padding(.top, 24)
// Активные серверы
VStack(alignment: .leading, spacing: 12) {
Text("ИНФРАСТРУКТУРА")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white.opacity(0.3))
.padding(.horizontal, 4)
ForEach(wallet.allEndpoints, id: \.name) { endpoint in
EndpointRow(
name: endpoint.name,
url: endpoint.url,
isActive: endpoint.isActive,
latency: endpoint.latency
)
}
}
.padding(.horizontal, 24)
// Статистика отказоустойчивости
VStack(alignment: .leading, spacing: 12) {
Text("ОТКАЗОУСТОЙЧИВОСТЬ")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white.opacity(0.3))
.padding(.horizontal, 4)
HStack(spacing: 16) {
StatBox(value: "\(wallet.activeNodes)", label: "Узлов", icon: "server.rack")
StatBox(value: "\(wallet.failoverCount)", label: "Failover", icon: "arrow.triangle.2.circlepath")
StatBox(value: wallet.uptimeString, label: "Uptime", icon: "clock")
}
}
.padding(.horizontal, 24)
// Информация о протоколе
VStack(spacing: 8) {
Text("Montana Protocol v\(Montana.version)")
.font(.system(size: 12, weight: .light))
.foregroundColor(.white.opacity(0.3))
Text("Децентрализованная сеть — невозможно отключить")
.font(.system(size: 10, weight: .light))
.foregroundColor(Color(hex: "D4AF37").opacity(0.5))
}
.padding(.top, 16)
Spacer()
}
}
}
.navigationTitle("Сеть Montana")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Готово") { dismiss() }
.foregroundColor(.white.opacity(0.6))
}
}
}
}
}
// MARK: - Endpoint Row
struct EndpointRow: View {
let name: String
let url: String
let isActive: Bool
let latency: Int?
/// Статус узла: Active (зелёный), Standby (жёлтый), Offline (серый)
private var nodeStatus: (String, Color) {
if isActive {
return ("Active", Color(hex: "10B981"))
} else if latency != nil {
return ("Standby", Color(hex: "D4AF37"))
} else {
return ("Offline", .white.opacity(0.3))
}
}
var body: some View {
HStack(spacing: 12) {
// Статус (цветной индикатор)
Circle()
.fill(nodeStatus.1)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(name)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white.opacity(isActive ? 0.9 : latency != nil ? 0.7 : 0.4))
// Бейдж статуса
Text(nodeStatus.0)
.font(.system(size: 8, weight: .semibold))
.foregroundColor(nodeStatus.1)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(nodeStatus.1.opacity(0.15))
.cornerRadius(4)
}
Text(url)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.3))
}
Spacer()
// Latency
if let latency = latency {
HStack(spacing: 4) {
Text("\(latency)ms")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(latencyColor(latency))
}
} else {
Text("")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.2))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(isActive ? 0.08 : latency != nil ? 0.05 : 0.02))
.cornerRadius(12)
}
private func latencyColor(_ ms: Int) -> Color {
if ms < 100 { return Color(hex: "10B981") }
if ms < 300 { return Color(hex: "D4AF37") }
return .red.opacity(0.7)
}
}
// MARK: - Stat Box
struct StatBox: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundColor(Color(hex: "D4AF37").opacity(0.7))
Text(value)
.font(.system(size: 20, weight: .light, design: .monospaced))
.foregroundColor(.white)
Text(label)
.font(.system(size: 10, weight: .light))
.foregroundColor(.white.opacity(0.4))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.white.opacity(0.05))
.cornerRadius(12)
}
}
// MARK: - Network Status Enum
enum NetworkStatus {
case connected
case connecting
case failover
case offline
var color: Color {
switch self {
case .connected: return Color(hex: "10B981")
case .connecting: return Color(hex: "D4AF37")
case .failover: return Color(hex: "F59E0B")
case .offline: return .red.opacity(0.7)
}
}
var title: String {
switch self {
case .connected: return "Подключено"
case .connecting: return "Подключение..."
case .failover: return "Резервный узел"
case .offline: return "Нет связи"
}
}
var description: String {
switch self {
case .connected: return "Сеть Montana работает стабильно"
case .connecting: return "Устанавливается соединение"
case .failover: return "Переключение на резервный сервер"
case .offline: return "Проверьте интернет-соединение"
}
}
}
// MARK: - Endpoint Info
struct EndpointInfo {
let name: String
let url: String
let isActive: Bool
let latency: Int?
}
// MARK: - Color Extension
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 6:
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
// MARK: - Transaction History (Event Ledger matches macOS)
struct TransactionHistoryView: View {
@ObservedObject var wallet = WalletService.shared
@State private var displayItems: [HistoryItem] = []
@State private var isLoading = true
@State private var errorText = ""
private let cyan = Color(red: 0, green: 0.83, blue: 1)
private let gold = Color(hex: "D4AF37")
private let cardBg = Color.white.opacity(0.05)
struct HistoryItem: Identifiable {
let id = UUID()
var eventType: String
var amount: Int
var fromAddr: String
var toAddr: String
var fromAlias: String
var toAlias: String
var timestamp: String
var emissionCount: Int
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if isLoading {
VStack(spacing: 16) {
ProgressView()
.tint(gold)
Text("Загрузка...")
.foregroundColor(.white.opacity(0.6))
}
} else if !errorText.isEmpty {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 40))
.foregroundColor(.orange)
Text(errorText)
.foregroundColor(.white.opacity(0.6))
.multilineTextAlignment(.center)
}
.padding()
} else if displayItems.isEmpty {
VStack(spacing: 24) {
Image(systemName: "tray")
.font(.system(size: 48))
.foregroundColor(.white.opacity(0.2))
Text("Нет транзакций")
.font(.title3)
.foregroundColor(.white.opacity(0.5))
}
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(displayItems) { item in
historyRow(item)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
.navigationTitle("История")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button { Task { await loadHistory() } } label: {
Image(systemName: "arrow.clockwise")
.foregroundColor(gold)
}
}
}
.task { await loadHistory() }
.refreshable { await loadHistory() }
}
private func historyRow(_ item: HistoryItem) -> some View {
let myAddr = UserDefaults.standard.string(forKey: "montana_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 {
return item.emissionCount > 1 ? "Эмиссия (10 мин)" : "Эмиссия"
}
if isSent { return "Отправлено" }
return "Получено"
}()
let counterparty: String = {
if isEmission {
let alias = item.fromAlias.isEmpty ? "Ɉ-0" : item.fromAlias
return item.emissionCount > 1 ? "\(alias) ×\(item.emissionCount)" : alias
}
if isSent { return displayAddr(item.toAddr, alias: item.toAlias) }
return displayAddr(item.fromAddr, alias: item.fromAlias)
}()
let amountPrefix = (isSent && !isEmission) ? "-" : "+"
return HStack(spacing: 12) {
Image(systemName: directionIcon)
.foregroundColor(directionColor)
.font(.system(size: 24))
VStack(alignment: .leading, spacing: 4) {
Text(directionLabel)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(directionColor)
HStack(spacing: 4) {
if isSent && !isEmission {
Image(systemName: "arrow.right")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.4))
} else if !isEmission {
Image(systemName: "arrow.left")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.4))
}
Text(counterparty)
.font(.system(size: 13, design: .monospaced))
.foregroundColor(.white.opacity(0.5))
}
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text("\(amountPrefix)\(formatAmount(item.amount))")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(directionColor)
Text(formatTimestamp(item.timestamp))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.3))
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(cardBg)
)
}
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() async {
guard let myAddr = UserDefaults.standard.string(forKey: "montana_address"), !myAddr.isEmpty else {
errorText = "Кошелёк не настроен"
isLoading = false
return
}
isLoading = true
errorText = ""
do {
let events = try await wallet.fetchMyEvents(address: myAddr, limit: 200)
displayItems = consolidateEvents(events)
isLoading = false
} catch {
errorText = "Ошибка загрузки"
isLoading = false
}
}
private func consolidateEvents(_ events: [[String: Any]]) -> [HistoryItem] {
let t2Window: TimeInterval = 600
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
}
}
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 {
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,
date: bucket.date
)
} else {
flushEmission()
emissionBucket = (amount, 1, fromAddr, toAddr, fromAlias, toAlias, timestamp, eventDate)
}
} else {
emissionBucket = (amount, 1, fromAddr, toAddr, fromAlias, toAlias, timestamp, eventDate)
}
} else {
flushEmission()
items.append(HistoryItem(
eventType: eventType,
amount: amount,
fromAddr: fromAddr,
toAddr: toAddr,
fromAlias: fromAlias,
toAlias: toAlias,
timestamp: timestamp,
emissionCount: 1
))
}
}
flushEmission()
return items
}
}