Fill in payments by scanning a QR code or picking one from photos
This commit is contained in:
@@ -166,15 +166,7 @@ struct QRCodeView: View {
|
||||
}
|
||||
|
||||
private static func makeImage(from text: String) -> UIImage? {
|
||||
guard !text.isEmpty else { return nil }
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(text.utf8)
|
||||
filter.correctionLevel = "M"
|
||||
guard let output = filter.outputImage else { return nil }
|
||||
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
||||
let context = CIContext()
|
||||
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
||||
return UIImage(cgImage: cgImage)
|
||||
QRCodeImage.make(text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@@ -124,6 +125,9 @@ struct SendView: View {
|
||||
@State private var tag = ""
|
||||
@State private var priority: TransactionPriority = .defaultPriority
|
||||
@State private var localError: String?
|
||||
@State private var status: String?
|
||||
@State private var showScanner = false
|
||||
@State private var photoItem: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
@@ -131,6 +135,20 @@ struct SendView: View {
|
||||
subtitle: "Balance \(model.balance.total) \(model.symbol)",
|
||||
onBack: { route = .home })
|
||||
|
||||
HStack(spacing: 10) {
|
||||
SecondaryButton(title: "Scan QR") { showScanner = true }
|
||||
PhotosPicker(selection: $photoItem, matching: .images, photoLibrary: .shared()) {
|
||||
Text("Photo")
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
|
||||
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
|
||||
@@ -163,6 +181,9 @@ struct SendView: View {
|
||||
if let message = localError ?? model.errorMessage {
|
||||
MessageBanner(message: message, isError: true)
|
||||
}
|
||||
if let message = status {
|
||||
MessageBanner(message: message, isError: false)
|
||||
}
|
||||
if let message = model.statusMessage {
|
||||
MessageBanner(message: message, isError: false)
|
||||
}
|
||||
@@ -184,6 +205,65 @@ struct SendView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.sheet(isPresented: $showScanner) {
|
||||
QRScannerView(onScan: { value in
|
||||
showScanner = false
|
||||
applyScanned(value)
|
||||
}, onCancel: {
|
||||
showScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.onChange(of: photoItem) { item in
|
||||
guard let item else { return }
|
||||
Task {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
if let value = QRCodeReader.decodeText(in: data) {
|
||||
applyScanned(value)
|
||||
} else {
|
||||
localError = "No QR code found in that image"
|
||||
}
|
||||
} else {
|
||||
localError = "That image could not be read"
|
||||
}
|
||||
photoItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills the form from a scanned code, accepting both plain addresses and
|
||||
/// wallet payment URIs (`monero:…?tx_amount=`, `ripple:…?amount=&dt=`).
|
||||
private func applyScanned(_ value: String) {
|
||||
localError = nil
|
||||
status = nil
|
||||
guard let payload = QRCodeReader.parse(value) else {
|
||||
localError = "That QR code is not a payment request"
|
||||
return
|
||||
}
|
||||
|
||||
let isForThisChain = model.chain == .xrp
|
||||
? AddressFormat.isXRP(payload.address)
|
||||
: AddressFormat.isMonero(payload.address)
|
||||
guard isForThisChain else {
|
||||
localError = model.chain == .xrp
|
||||
? "That looks like a Monero address. Switch to Monero to use it."
|
||||
: "That looks like an XRP address. Switch to XRP to use it."
|
||||
return
|
||||
}
|
||||
|
||||
destination = payload.address
|
||||
if let scannedAmount = payload.amount, !scannedAmount.isEmpty {
|
||||
amount = scannedAmount
|
||||
}
|
||||
if model.chain == .xrp, let scannedTag = payload.tag {
|
||||
tag = String(scannedTag)
|
||||
}
|
||||
|
||||
var filled: [String] = ["address"]
|
||||
if payload.amount?.isEmpty == false { filled.append("amount") }
|
||||
if payload.tag != nil { filled.append("tag") }
|
||||
if payload.paymentId?.isEmpty == false { filled.append("payment ID") }
|
||||
status = "Filled \(filled.joined(separator: ", ")) from the QR code"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// Camera QR scanner, presented as a sheet. On a simulator (no capture device)
|
||||
/// it explains that instead of showing a dead viewfinder.
|
||||
struct QRScannerView: UIViewControllerRepresentable {
|
||||
var onScan: (String) -> Void
|
||||
var onCancel: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let controller = ScannerViewController()
|
||||
controller.onScan = onScan
|
||||
controller.onCancel = onCancel
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: ScannerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onScan: ((String) -> Void)?
|
||||
var onCancel: (() -> Void)?
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var hasScanned = false
|
||||
private var messageLabel: UILabel?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
|
||||
let cancel = UIButton(type: .system)
|
||||
cancel.setTitle("Cancel", for: .normal)
|
||||
cancel.setTitleColor(.white, for: .normal)
|
||||
cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
cancel.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(cancel)
|
||||
NSLayoutConstraint.activate([
|
||||
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||
cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||
])
|
||||
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(label)
|
||||
NSLayoutConstraint.activate([
|
||||
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
|
||||
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
|
||||
])
|
||||
messageLabel = label
|
||||
|
||||
requestCamera()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.bounds
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
if session.isRunning { session.stopRunning() }
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
onCancel?()
|
||||
}
|
||||
|
||||
private func requestCamera() {
|
||||
guard let device = AVCaptureDevice.default(for: .video) else {
|
||||
showMessage("This device has no camera. Use “Photo” to pick a QR from the library instead.")
|
||||
return
|
||||
}
|
||||
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
configure(device: device)
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
if granted {
|
||||
self?.configure(device: device)
|
||||
} else {
|
||||
self?.showMessage("Camera access is off. Enable it in Settings, or use “Photo”.")
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
showMessage("Camera access is off. Enable it in Settings, or use “Photo”.")
|
||||
}
|
||||
}
|
||||
|
||||
private func configure(device: AVCaptureDevice) {
|
||||
guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else {
|
||||
showMessage("The camera could not be started. Use “Photo” instead.")
|
||||
return
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
showMessage("The camera could not be started. Use “Photo” instead.")
|
||||
return
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
layer.frame = view.bounds
|
||||
view.layer.insertSublayer(layer, at: 0)
|
||||
previewLayer = layer
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
private func showMessage(_ text: String) {
|
||||
messageLabel?.text = text
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection) {
|
||||
guard !hasScanned else { return }
|
||||
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let value = object.stringValue, !value.isEmpty else { return }
|
||||
hasScanned = true
|
||||
session.stopRunning()
|
||||
onScan?(value)
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,15 @@ struct SeedBox: Identifiable {
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var route: Route = ProcessInfo.processInfo.arguments.contains("-koinsScreenAccounts")
|
||||
? .accounts : .home
|
||||
@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 {
|
||||
|
||||
Reference in New Issue
Block a user