Files

379 lines
12 KiB
Swift

import CoreImage.CIFilterBuiltins
import SwiftUI
enum Theme {
static let background = Color(red: 0.035, green: 0.035, blue: 0.05)
static let surface = Color(red: 0.09, green: 0.09, blue: 0.12)
static let surfaceAlt = Color(red: 0.13, green: 0.13, blue: 0.17)
static let border = Color.white.opacity(0.08)
static let accent = Color(red: 1.0, green: 0.56, blue: 0.16)
static let accentSoft = Color(red: 1.0, green: 0.56, blue: 0.16).opacity(0.15)
static let text = Color.white
static let muted = Color.white.opacity(0.55)
static let danger = Color(red: 1.0, green: 0.36, blue: 0.36)
static let success = Color(red: 0.35, green: 0.85, blue: 0.55)
}
struct PrimaryButton: View {
var title: String
var disabled: Bool = false
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(disabled ? Theme.surfaceAlt : Theme.accent)
.foregroundStyle(disabled ? Theme.muted : Color.black)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.disabled(disabled)
}
}
struct SecondaryButton: View {
var title: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
/// A full-width button that opens a web page, styled like SecondaryButton.
struct LinkButton: View {
var title: String
var systemImage: String = "safari"
var url: URL
var body: some View {
Link(destination: url) {
Label(title, systemImage: systemImage)
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
struct Card<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
struct FieldLabel: View {
var text: String
var body: some View {
Text(text)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
.padding(.leading, 4)
}
}
struct KoinsTextField: View {
var placeholder: String
@Binding var text: String
var multiline = false
var keyboard: UIKeyboardType = .default
var autocapitalization: TextInputAutocapitalization = .never
var body: some View {
Group {
if multiline {
TextEditor(text: $text)
.scrollContentBackground(.hidden)
.frame(minHeight: 96)
.padding(8)
} else {
TextField(placeholder, text: $text)
.keyboardType(keyboard)
.textInputAutocapitalization(autocapitalization)
.autocorrectionDisabled()
.padding(.horizontal, 14)
.padding(.vertical, 13)
}
}
.font(.system(size: 15))
.foregroundStyle(Theme.text)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
struct MessageBanner: View {
var message: String
var isError: Bool
var body: some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(isError ? Theme.danger : Theme.success)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background((isError ? Theme.danger : Theme.success).opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
struct ChainPicker: View {
@Binding var selection: Chain
var body: some View {
HStack(spacing: 6) {
ForEach(Chain.allCases, id: \.self) { chain in
Button {
selection = chain
} label: {
Text(chain.title)
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selection == chain ? Theme.accent : Color.clear)
.foregroundStyle(selection == chain ? Color.black : Theme.muted)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
.padding(4)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
/// Picks which Monero account a payment leaves from. Each account has its own
/// balance, so "Max" and the wallet's spend checks need the matching index.
struct MoneroAccountPicker: View {
var accounts: [MoneroAccount]
@Binding var selection: Int
var onSelect: (() -> Void)?
private var selected: MoneroAccount? {
accounts.first(where: { $0.index == selection }) ?? accounts.first
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Send from")
Menu {
ForEach(accounts) { account in
Button {
selection = account.index
onSelect?()
} label: {
Text("\(account.title) · \(account.balance) XMR")
}
}
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(selected?.title ?? "Primary account")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Theme.text)
Text("\(selected?.unlocked ?? "0") XMR available")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.muted)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
}
struct QRCodeView: View {
var text: String
var body: some View {
if let image = QRCodeView.makeImage(from: text) {
Image(uiImage: image)
.interpolation(.none)
.resizable()
.scaledToFit()
.padding(12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Theme.surfaceAlt)
.aspectRatio(1, contentMode: .fit)
}
}
private static func makeImage(from text: String) -> UIImage? {
QRCodeImage.make(text)
}
}
struct TransactionRow: View {
var record: TransactionRecord
var symbol: String
private var subtitle: String {
if let timestamp = record.timestamp {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return record.confirmed ? formatter.string(from: timestamp) : "Pending confirmation"
}
// Monero transfers carry no timestamp; the block height is the honest label.
if let height = record.height, height > 0 {
return record.confirmed ? "Block \(height)" : "In the transaction pool"
}
return record.confirmed ? "Confirmed" : "Pending"
}
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(record.direction == .incoming ? Theme.success.opacity(0.15) : Theme.accentSoft)
.frame(width: 38, height: 38)
Image(systemName: record.direction == .incoming ? "arrow.down.left" : "arrow.up.right")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.accent)
}
VStack(alignment: .leading, spacing: 3) {
Text(record.direction == .incoming ? "Received" : "Sent")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
VStack(alignment: .trailing, spacing: 3) {
Text("\(record.direction == .incoming ? "+" : "-")\(record.amount) \(symbol)")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.text)
if let fee = record.fee, !fee.isEmpty, record.direction == .outgoing {
Text("fee \(fee)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
.padding(.vertical, 6)
// Buttons only respond where the label has content; the middle of the row
// is a Spacer, so without this the gap does not accept taps.
.contentShape(Rectangle())
}
}
struct ScreenHeader: View {
var title: String
var subtitle: String?
var onBack: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
if let onBack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
.frame(width: 34, height: 34)
.background(Theme.surface)
.clipShape(Circle())
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Theme.text)
if let subtitle {
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
Spacer()
}
}
}
/// Monero's chain sync as its own section, rather than buried in the wallet card.
struct SyncCard: View {
@EnvironmentObject private var model: AppModel
var body: some View {
Card {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Sync")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Spacer()
if let status = model.syncStatus { SyncBadge(status: status) }
}
if let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
} else {
Text("Sync status is not available yet.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
Task { await model.startMoneroSync() }
}
}
}
}
}
/// A small circular icon button for utility actions (refresh, delete, …).
struct IconButton: View {
var systemName: String
var destructive = false
var action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(destructive ? Theme.danger : Theme.muted)
.frame(width: 42, height: 42)
.background(Theme.surface)
.overlay(Circle().stroke(Theme.border))
.clipShape(Circle())
}
}
}