261 lines
7.8 KiB
Swift
261 lines
7.8 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
} |