SPB Git

spb/os-vault Public

Self-custody, multi-chain crypto wallet for macOS. One recovery phrase, six chain families, zero API keys — nothing leaves your Mac.

Swift 96% Shell 3.4% Makefile 0.6%
9.9 KB · 255 lines swift
Raw Blame History
1//2//  HomeView.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI10import BigInt1112struct HomeView: View {13    @EnvironmentObject var app: AppState14    @State private var showSend = false15    @State private var showReceive = false16    @State private var showSettings = false17    @State private var showBitcoin = false18    @State private var showSolana = false19    @State private var showTron = false20    @State private var showXRPL = false21    @State private var showTON = false2223    var body: some View {24        VStack(spacing: 0) {25            header26            Divider()27            ScrollView {28                VStack(spacing: 16) {29                    balanceCard30                    if lowOnGas {31                        gasWarning32                    }33                    actionButtons34                    historySection35                }36                .padding(20)37            }38        }39        .sheet(isPresented: $showSend) { SendView() }40        .sheet(isPresented: $showReceive) { ReceiveView() }41        .sheet(isPresented: $showSettings) { SettingsView() }42        .sheet(isPresented: $showBitcoin) { BitcoinView() }43        .sheet(isPresented: $showSolana) { SolanaView() }44        .sheet(isPresented: $showTron) { TronView() }45        .sheet(isPresented: $showXRPL) { XRPLView() }46        .sheet(isPresented: $showTON) { TONView() }47    }4849    private var header: some View {50        HStack {51            NetworkBadge(network: app.network)52            Spacer()53            if let address = app.address {54                Text(address.shortAddress)55                    .font(.callout.monospaced())56                    .foregroundStyle(.secondary)57                CopyButton(text: address)58                    .labelStyle(.iconOnly)59                    .buttonStyle(.borderless)60            }61            Menu {62                Button("Bitcoin") { showBitcoin = true }63                Button("Solana") { showSolana = true }64                Button("Tron") { showTron = true }65                Button("XRP Ledger") { showXRPL = true }66                Button("TON") { showTON = true }67            } label: {68                Image(systemName: "link.circle")69            }70            .menuStyle(.borderlessButton)71            .frame(width: 40)72            .help("Other chains")73            Button {74                showSettings = true75            } label: {76                Image(systemName: "gearshape")77            }78            .buttonStyle(.borderless)79            Button {80                app.lock()81            } label: {82                Image(systemName: "lock.fill")83            }84            .buttonStyle(.borderless)85            .help("Lock the vault")86        }87        .padding(.horizontal, 20)88        .padding(.vertical, 12)89    }9091    private var tokens: [Token] { Token.available(on: app.network) }9293    private var balanceCard: some View {94        VStack(alignment: .leading, spacing: 12) {95            if !app.balancesLoaded && app.balanceError == nil {96                HStack {97                    ProgressView().controlSize(.small)98                    Text("Loading balances…").foregroundStyle(.secondary)99                }100            }101            if let error = app.balanceError, !app.balancesLoaded {102                Label(error, systemImage: "wifi.exclamationmark")103                    .foregroundStyle(.orange)104                    .font(.callout)105            }106            ForEach(tokens) { token in107                HStack(alignment: .firstTextBaseline) {108                    Text(token.symbol).font(.headline)109                    Text(token.name).font(.caption).foregroundStyle(.secondary)110                    Spacer()111                    VStack(alignment: .trailing, spacing: 2) {112                        Text(TokenAmount.format(app.balances.tokenUnits[token.symbol] ?? 0,113                                                decimals: token.decimals, maxFractionDigits: 6))114                            .font(token.symbol == "USDC" ? .system(size: 28, weight: .bold, design: .rounded)115                                                         : .title3.weight(.semibold))116                            .monospacedDigit()117                        if let fiat = fiatLine(units: app.balances.tokenUnits[token.symbol] ?? 0,118                                               decimals: token.decimals, symbol: token.symbol) {119                            Text(fiat).font(.caption).foregroundStyle(.secondary)120                        }121                    }122                }123            }124            Divider()125            HStack {126                Text("\(app.network.config.nativeSymbol) (gas)").font(.subheadline).foregroundStyle(.secondary)127                Spacer()128                VStack(alignment: .trailing, spacing: 2) {129                    Text(TokenAmount.formatWei(app.balances.ethWei, maxFractionDigits: 6) + " " + app.network.config.nativeSymbol)130                        .font(.subheadline.monospaced())131                        .foregroundStyle(.secondary)132                    if let fiat = fiatLine(units: app.balances.ethWei, decimals: 18,133                                           symbol: app.network.config.nativeSymbol) {134                        Text(fiat).font(.caption).foregroundStyle(.secondary)135                    }136                }137            }138            if let error = app.balanceError, app.balancesLoaded {139                Text("Refresh failed: \(error)").font(.caption).foregroundStyle(.orange)140            }141        }142        .padding(16)143        .background(.quaternary.opacity(0.4))144        .clipShape(RoundedRectangle(cornerRadius: 12))145    }146147    private func fiatLine(units: BigUInt, decimals: Int, symbol: String) -> String? {148        guard app.pricesEnabled, units > 0, let price = app.fiatPrices[symbol] else { return nil }149        let value = PriceService.fiatValue(units: units, decimals: decimals, price: price)150        return "≈ " + PriceService.formatFiat(value, currency: app.fiatCurrency)151    }152153    private var lowOnGas: Bool {154        // Rough per-chain floor for one ERC-20 transfer; refined at send time.155        guard app.balancesLoaded else { return false }156        let floor: BigUInt157        switch app.network {158        case .polygon, .bnb, .avalanche, .gnosis: floor = 5_000_000_000_000_000   // 0.005 native159        case .ethereum: floor = 1_000_000_000_000_000                             // 0.001 ETH160        default: floor = 30_000_000_000_000                                       // cheap L2s161        }162        return app.balances.ethWei < floor163    }164165    private var gasWarning: some View {166        Label("\(app.network.config.nativeSymbol) balance is very low. Gas is paid in \(app.network.config.nativeSymbol) — sends may fail until you top up.",167              systemImage: "fuelpump.slash")168            .font(.callout)169            .foregroundStyle(.orange)170            .frame(maxWidth: .infinity, alignment: .leading)171            .padding(12)172            .background(Color.orange.opacity(0.12))173            .clipShape(RoundedRectangle(cornerRadius: 8))174    }175176    private var actionButtons: some View {177        HStack(spacing: 12) {178            Button {179                showSend = true180            } label: {181                Label("Send", systemImage: "arrow.up.circle.fill")182                    .frame(maxWidth: .infinity)183            }184            .buttonStyle(.borderedProminent)185            .controlSize(.large)186187            Button {188                showReceive = true189            } label: {190                Label("Receive", systemImage: "arrow.down.circle")191                    .frame(maxWidth: .infinity)192            }193            .controlSize(.large)194195            Button {196                Task { await app.refreshBalances() }197            } label: {198                Image(systemName: "arrow.clockwise")199            }200            .controlSize(.large)201            .help("Refresh balances")202        }203    }204205    private var historySection: some View {206        VStack(alignment: .leading, spacing: 8) {207            Text("Activity").font(.headline)208            let visible = app.history.filter { $0.network == app.network }209            if visible.isEmpty {210                Text("Sends from this app will appear here. Full history is on the explorer.")211                    .font(.callout)212                    .foregroundStyle(.secondary)213            }214            ForEach(visible) { record in215                HStack {216                    statusIcon(record.status)217                    VStack(alignment: .leading, spacing: 2) {218                        Text("Sent \(record.displayAmount) \(record.tokenSymbol)")219                            .font(.callout.weight(.medium))220                        Text("to \(record.recipient.shortAddress) · \(record.date.formatted(date: .abbreviated, time: .shortened))")221                            .font(.caption)222                            .foregroundStyle(.secondary)223                    }224                    Spacer()225                    Link(destination: record.explorerURL) {226                        Image(systemName: "arrow.up.right.square")227                    }228                    .help("View on the explorer")229                }230                .padding(10)231                .background(.quaternary.opacity(0.3))232                .clipShape(RoundedRectangle(cornerRadius: 8))233            }234            if let address = app.address {235                Link("View full history on the explorer",236                     destination: app.network.explorerAddressURL(address))237                    .font(.callout)238                    .padding(.top, 4)239            }240        }241        .frame(maxWidth: .infinity, alignment: .leading)242    }243244    private func statusIcon(_ status: TransactionRecord.Status) -> some View {245        switch status {246        case .pending:247            return Image(systemName: "clock").foregroundStyle(Color.orange)248        case .confirmed:249            return Image(systemName: "checkmark.circle.fill").foregroundStyle(Color.green)250        case .failed:251            return Image(systemName: "xmark.circle.fill").foregroundStyle(Color.red)252        }253    }254}255