import SwiftUI enum Route: Equatable { case home case create case importWallet case dashboard case send case receive case accounts } struct SeedBox: Identifiable { let id = UUID() let seed: String let symbol: String } struct RootView: View { @EnvironmentObject private var model: AppModel @Environment(\.scenePhase) private var scenePhase @State private var route: Route = RootView.initialRoute private static var initialRoute: Route { let arguments = ProcessInfo.processInfo.arguments if arguments.contains("-koinsScreenAccounts") { return .accounts } if arguments.contains("-koinsScreenSend") { return .send } if arguments.contains("-koinsScreenReceive") { return .receive } return .home } @State private var seedBox: SeedBox? var body: some View { ZStack { Theme.background.ignoresSafeArea() ScrollView { VStack(spacing: 18) { switch route { case .home: HomeView(route: $route) case .create: CreateWalletView(route: $route, seedBox: $seedBox) case .importWallet: ImportWalletView(route: $route) case .dashboard: DashboardView(route: $route) case .send: SendView(route: $route) case .receive: ReceiveView(route: $route) case .accounts: AccountsView(route: $route) } } .padding(.horizontal, 20) .padding(.top, 12) .padding(.bottom, 40) } .scrollDismissesKeyboard(.interactively) } .preferredColorScheme(.dark) .overlay { if model.locked, model.faceIDEnabled { LockScreen { Task { await model.authenticate() } } } } .task { await model.bootstrap() } .sheet(item: $seedBox) { box in SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil } } .onChange(of: model.chain) { _ in if route == .dashboard { route = .home } } .onChange(of: scenePhase) { phase in if phase == .background, model.faceIDEnabled, Biometrics.isAvailable { model.locked = true } if phase != .active { Task { await model.persistWallet() } } } } } struct HomeView: View { @EnvironmentObject private var model: AppModel @Binding var route: Route @State private var showDeleteConfirm = false @State private var selectedTransaction: TransactionRecord? @State private var showSettings = false var body: some View { VStack(spacing: 18) { header if model.hasWallet, model.chain == .xrp || model.moneroReady { walletCard } else if model.hasWallet { moneroUnavailableCard } else { emptyState } if let message = model.errorMessage { MessageBanner(message: message, isError: true) } if let message = model.statusMessage { MessageBanner(message: message, isError: false) } } .sheet(item: $selectedTransaction) { record in TransactionDetailView(record: record, symbol: model.symbol, chain: model.chain) { selectedTransaction = nil } } .sheet(isPresented: $showSettings) { SettingsView() .environmentObject(model) } .confirmationDialog("Delete this wallet?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { Button("Delete wallet", role: .destructive) { Task { await model.deleteWallet() route = .home } } Button("Cancel", role: .cancel) {} } message: { Text("The keys stored on this device are removed. Make sure your recovery seed is backed up.") } } private var header: some View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { Text("Koins") .font(.system(size: 30, weight: .heavy)) .foregroundStyle(Theme.text) Text("Monero and XRP wallet") .font(.system(size: 13)) .foregroundStyle(Theme.muted) } Spacer() Button { showSettings = true } label: { Image(systemName: "gearshape") .font(.system(size: 17, weight: .medium)) .foregroundStyle(Theme.muted) .frame(width: 36, height: 36) .background(Theme.surface) .clipShape(Circle()) } } .frame(maxWidth: .infinity, alignment: .leading) } private var walletCard: some View { VStack(spacing: 14) { ChainPicker(selection: Binding(get: { model.chain }, set: { newValue in Task { await model.select(newValue) } })) Card { VStack(alignment: .leading, spacing: 12) { HStack { Text(model.wallet?.name ?? "Wallet") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Theme.text) Spacer() if model.chain == .xmr, let status = model.syncStatus { SyncBadge(status: status) } } VStack(alignment: .leading, spacing: 4) { Text("Balance") .font(.system(size: 11, weight: .medium)) .foregroundStyle(Theme.muted) Text("\(model.balance.total) \(model.symbol)") .font(.system(size: 30, weight: .bold, design: .rounded)) .foregroundStyle(Theme.text) if model.chain == .xmr, model.balance.unlocked != model.balance.total { Text("\(model.balance.unlocked) \(model.symbol) unlocked") .font(.system(size: 11)) .foregroundStyle(Theme.muted) } } AddressRow(address: model.displayAddress) if model.chain == .xmr, let status = model.syncStatus { SyncProgress(status: status, eta: model.syncEta) } } } HStack(spacing: 10) { PrimaryButton(title: "Send") { route = .send } SecondaryButton(title: "Receive") { route = .receive } } if model.chain == .xmr { HStack(spacing: 10) { SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") { Task { await model.startMoneroSync() } } SecondaryButton(title: "Addresses") { route = .accounts } } } if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing { Card { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Circle() .fill(status.connected ? Theme.success : Theme.danger) .frame(width: 7, height: 7) Text("Monero is syncing in the background") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.text) Spacer() Text("\(Int(status.progress * 100))%") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.muted) } SyncProgress(status: status, eta: model.syncEta) } } } HStack(spacing: 10) { SecondaryButton(title: "Refresh") { Task { await model.refresh(force: true) } } SecondaryButton(title: "Delete") { showDeleteConfirm = true } } if !model.transactions.isEmpty { Card { VStack(alignment: .leading, spacing: 10) { Text("Activity") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.muted) ForEach(model.transactions) { record in Button { selectedTransaction = record } label: { TransactionRow(record: record, symbol: model.symbol) } .buttonStyle(.plain) if record.id != model.transactions.last?.id { Divider().overlay(Theme.border) } } } } } else if model.chain == .xrp, model.balance.funded == false { Text("This account is not funded yet. Send XRP to the address above to activate it.") .font(.system(size: 12)) .foregroundStyle(Theme.muted) } } } private var moneroUnavailableCard: some View { VStack(spacing: 14) { ChainPicker(selection: Binding(get: { model.chain }, set: { newValue in Task { await model.select(newValue) } })) Card { VStack(alignment: .leading, spacing: 10) { Text("Monero wallet") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Theme.text) Text("The wallet is on this device but could not be opened. It is safe to retry; nothing was deleted.") .font(.system(size: 12)) .foregroundStyle(Theme.muted) SecondaryButton(title: "Retry") { Task { await model.select(.xmr) } } } } } } private var emptyState: some View { VStack(spacing: 14) { ChainPicker(selection: Binding(get: { model.chain }, set: { newValue in Task { await model.select(newValue) } })) Card { VStack(alignment: .leading, spacing: 8) { Text("\(model.chain.title) wallet") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Theme.text) Text("Create a new wallet on this device, or import one with its recovery seed.") .font(.system(size: 12)) .foregroundStyle(Theme.muted) } } VStack(spacing: 10) { PrimaryButton(title: "Create wallet") { route = .create } SecondaryButton(title: "Import wallet") { route = .importWallet } } } } } struct SyncBadge: View { var status: SyncStatus private var label: String { if status.syncing { let percent = status.progress * 100 return percent < 10 ? String(format: "Syncing %.1f%%", percent) : "Syncing \(Int(percent))%" } return status.connected ? "Synced" : "Offline" } var body: some View { HStack(spacing: 6) { Circle() .fill(status.connected ? Theme.success : Theme.danger) .frame(width: 7, height: 7) Text(label) .font(.system(size: 11, weight: .medium)) .foregroundStyle(Theme.muted) } } } /// Live scan progress: how far the wallet has reached, and roughly how long the /// remaining blocks will take at the observed rate. struct SyncProgress: View { var status: SyncStatus var eta: String? var body: some View { VStack(alignment: .leading, spacing: 6) { ProgressView(value: status.progress) .tint(Theme.accent) HStack(spacing: 8) { Text("Block \(status.height.formatted()) of \(status.daemonHeight.formatted())") Spacer() if status.syncing { Text(eta.map { "about \($0) left" } ?? "estimating…") } else if status.connected { Text("up to date") } } .font(.system(size: 11)) .foregroundStyle(Theme.muted) } } } struct AddressRow: View { var address: String @State private var copied = false var body: some View { HStack(spacing: 8) { Text(address.isEmpty ? "—" : address) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Theme.muted) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 6) Button { UIPasteboard.general.string = address copied = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false } } label: { Image(systemName: copied ? "checkmark" : "doc.on.doc") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.accent) } .disabled(address.isEmpty) } .padding(.horizontal, 12) .padding(.vertical, 10) .background(Theme.surfaceAlt) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } struct SeedRevealView: View { var seed: String var symbol: String var onDone: () -> Void @State private var acknowledged = false var body: some View { ZStack { Theme.background.ignoresSafeArea() VStack(alignment: .leading, spacing: 16) { Text("Your recovery phrase") .font(.system(size: 24, weight: .bold)) .foregroundStyle(Theme.text) Text("Write these words down in order and keep them offline. Anyone with this phrase can spend your \(symbol).") .font(.system(size: 13)) .foregroundStyle(Theme.muted) Text(seed) .font(.system(size: 15, design: .monospaced)) .foregroundStyle(Theme.text) .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(Theme.surface) .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Theme.border)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) Button { UIPasteboard.general.string = seed } label: { Label("Copy phrase", systemImage: "doc.on.doc") .font(.system(size: 14, weight: .medium)) .foregroundStyle(Theme.accent) } Toggle(isOn: $acknowledged) { Text("I have written down my phrase") .font(.system(size: 13)) .foregroundStyle(Theme.muted) } .tint(Theme.accent) Spacer() PrimaryButton(title: "Continue", disabled: !acknowledged, action: onDone) } .padding(22) } } }