521 lines
16 KiB
Swift
521 lines
16 KiB
Swift
import SwiftUI
|
|
|
|
enum Route: Equatable {
|
|
case home
|
|
case create
|
|
case importWallet
|
|
case dashboard
|
|
case send
|
|
case receive
|
|
case accounts
|
|
case swap
|
|
}
|
|
|
|
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 }
|
|
if arguments.contains("-koinsScreenSwap") { return .swap }
|
|
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)
|
|
case .swap:
|
|
SwapView(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
|
|
@State private var activeAccount = 0
|
|
@State private var qrAddress: AddressQR?
|
|
|
|
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)
|
|
}
|
|
}
|
|
.bottomDrawer(isPresented: Binding(
|
|
get: { selectedTransaction != nil },
|
|
set: { if !$0 { selectedTransaction = nil } }
|
|
)) {
|
|
if let record = selectedTransaction {
|
|
TransactionDetailView(record: record,
|
|
symbol: model.symbol,
|
|
chain: model.chain) { selectedTransaction = nil }
|
|
}
|
|
}
|
|
.bottomDrawer(isPresented: Binding(
|
|
get: { qrAddress != nil },
|
|
set: { if !$0 { qrAddress = nil } }
|
|
)) {
|
|
if let item = qrAddress {
|
|
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil }
|
|
}
|
|
}
|
|
.bottomDrawer(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)
|
|
}
|
|
}
|
|
|
|
if model.chain == .xmr, !model.moneroAccounts.isEmpty {
|
|
// Cake Wallet style: one card per account, swiped horizontally.
|
|
TabView(selection: $activeAccount) {
|
|
ForEach(model.moneroAccounts) { account in
|
|
moneroAccountCard(account)
|
|
.tag(account.index)
|
|
}
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .always))
|
|
.frame(height: 208)
|
|
} else {
|
|
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 let fiat = model.fiatBalance {
|
|
Text("\(fiat)")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
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 }
|
|
SecondaryButton(title: "Swap") { route = .swap }
|
|
}
|
|
|
|
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 func moneroAccountCard(_ account: MoneroAccount) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text(account.title)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Theme.muted)
|
|
Spacer()
|
|
Text("Account \(account.index + 1) of \(model.moneroAccounts.count)")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("\(account.balance) XMR")
|
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.text)
|
|
if let fiat = model.fiatValue(of: account.balance, chain: .xmr) {
|
|
Text(fiat)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
if account.unlocked != account.balance {
|
|
Text("\(account.unlocked) XMR unlocked")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
AddressRow(address: account.address)
|
|
Button {
|
|
qrAddress = AddressQR(title: account.title, address: account.address)
|
|
} label: {
|
|
Image(systemName: "qrcode")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Theme.accent)
|
|
.frame(width: 36, height: 36)
|
|
.background(Theme.surfaceAlt)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, 18)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
} |