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