Adds BML contactless token fetching, APDU emulation utility, TapToPayViewModel, and TapToPayView with NFC animation. Also updates QR scanner, transfer flow, dashboard, and home views. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
234 lines
8.1 KiB
Swift
234 lines
8.1 KiB
Swift
import SwiftUI
|
|
@preconcurrency import AVFoundation
|
|
|
|
struct QRScannerSheet: View {
|
|
var onResult: (String) -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var cameraStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
@State private var scannerError: String?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
|
|
switch cameraStatus {
|
|
case .authorized:
|
|
QRCameraView(
|
|
onResult: { result in
|
|
dismiss()
|
|
onResult(result)
|
|
},
|
|
onError: { message in
|
|
scannerError = message
|
|
}
|
|
)
|
|
.ignoresSafeArea()
|
|
|
|
scannerOverlay
|
|
|
|
case .notDetermined:
|
|
ProgressView("Requesting camera access...")
|
|
.tint(.white)
|
|
.foregroundStyle(.white)
|
|
|
|
case .denied, .restricted:
|
|
scannerMessage(
|
|
title: "Camera Access Needed",
|
|
message: "Allow camera access in Settings to scan PayMV QR codes."
|
|
)
|
|
|
|
@unknown default:
|
|
scannerMessage(
|
|
title: "Camera Unavailable",
|
|
message: "This device cannot start the camera scanner."
|
|
)
|
|
}
|
|
|
|
if let scannerError {
|
|
scannerMessage(title: "Scanner Error", message: scannerError)
|
|
.padding(24)
|
|
}
|
|
}
|
|
.navigationTitle("Scan QR Code")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") { dismiss() }
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
.task {
|
|
await requestCameraAccessIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var scannerOverlay: some View {
|
|
VStack {
|
|
Spacer()
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.stroke(.white.opacity(0.9), lineWidth: 3)
|
|
.frame(width: 260, height: 260)
|
|
.overlay(
|
|
Text("Align QR code inside the frame")
|
|
.font(.caption)
|
|
.foregroundStyle(.white)
|
|
.padding(.top, 300),
|
|
alignment: .center
|
|
)
|
|
Spacer()
|
|
}
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
private func scannerMessage(title: String, message: String) -> some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "camera.viewfinder")
|
|
.font(.system(size: 42))
|
|
Text(title)
|
|
.font(.headline)
|
|
Text(message)
|
|
.font(.subheadline)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.foregroundStyle(.white)
|
|
.padding(24)
|
|
}
|
|
|
|
private func requestCameraAccessIfNeeded() async {
|
|
guard cameraStatus == .notDetermined else { return }
|
|
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
|
cameraStatus = granted ? .authorized : AVCaptureDevice.authorizationStatus(for: .video)
|
|
}
|
|
}
|
|
|
|
private struct QRCameraView: UIViewRepresentable {
|
|
var onResult: (String) -> Void
|
|
var onError: (String) -> Void
|
|
|
|
func makeUIView(context: Context) -> PreviewView {
|
|
let view = PreviewView()
|
|
view.backgroundColor = .black
|
|
context.coordinator.configure(previewView: view)
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: PreviewView, context: Context) {}
|
|
|
|
static func dismantleUIView(_ uiView: PreviewView, coordinator: Coordinator) {
|
|
coordinator.stop()
|
|
uiView.previewLayer.session = nil
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(onResult: onResult, onError: onError)
|
|
}
|
|
|
|
final class PreviewView: UIView {
|
|
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
|
|
|
var previewLayer: AVCaptureVideoPreviewLayer {
|
|
layer as! AVCaptureVideoPreviewLayer
|
|
}
|
|
}
|
|
|
|
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
|
private let onResult: (String) -> Void
|
|
private let onError: (String) -> Void
|
|
private let session = AVCaptureSession()
|
|
private let sessionQueue = DispatchQueue(label: "mv.thijooree.qr-scanner.session")
|
|
private var didCapture = false
|
|
private var isConfigured = false
|
|
|
|
init(onResult: @escaping (String) -> Void, onError: @escaping (String) -> Void) {
|
|
self.onResult = onResult
|
|
self.onError = onError
|
|
}
|
|
|
|
func configure(previewView: PreviewView) {
|
|
previewView.previewLayer.videoGravity = .resizeAspectFill
|
|
previewView.previewLayer.session = session
|
|
|
|
sessionQueue.async { [weak self] in
|
|
guard let self else { return }
|
|
guard !self.isConfigured else {
|
|
self.start()
|
|
return
|
|
}
|
|
|
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
|
|
?? AVCaptureDevice.default(for: .video) else {
|
|
self.reportError("No camera is available on this device.")
|
|
return
|
|
}
|
|
|
|
do {
|
|
let input = try AVCaptureDeviceInput(device: device)
|
|
let output = AVCaptureMetadataOutput()
|
|
|
|
self.session.beginConfiguration()
|
|
self.session.sessionPreset = .high
|
|
|
|
guard self.session.canAddInput(input) else {
|
|
self.session.commitConfiguration()
|
|
self.reportError("The camera input could not be started.")
|
|
return
|
|
}
|
|
self.session.addInput(input)
|
|
|
|
guard self.session.canAddOutput(output) else {
|
|
self.session.commitConfiguration()
|
|
self.reportError("The QR scanner output could not be started.")
|
|
return
|
|
}
|
|
self.session.addOutput(output)
|
|
output.metadataObjectTypes = output.availableMetadataObjectTypes.contains(.qr) ? [.qr] : []
|
|
self.session.commitConfiguration()
|
|
|
|
guard output.metadataObjectTypes.contains(.qr) else {
|
|
self.reportError("QR scanning is not available for this camera.")
|
|
return
|
|
}
|
|
|
|
self.isConfigured = true
|
|
DispatchQueue.main.async {
|
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
|
}
|
|
self.start()
|
|
} catch {
|
|
self.reportError(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
sessionQueue.async { [weak self] in
|
|
guard let self, self.session.isRunning else { return }
|
|
self.session.stopRunning()
|
|
}
|
|
}
|
|
|
|
private func start() {
|
|
guard !session.isRunning else { return }
|
|
session.startRunning()
|
|
}
|
|
|
|
private func reportError(_ message: String) {
|
|
DispatchQueue.main.async { self.onError(message) }
|
|
}
|
|
|
|
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
|
didOutput metadataObjects: [AVMetadataObject],
|
|
from connection: AVCaptureConnection) {
|
|
guard !didCapture,
|
|
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
|
let value = object.stringValue else { return }
|
|
didCapture = true
|
|
stop()
|
|
DispatchQueue.main.async { self.onResult(value) }
|
|
}
|
|
}
|
|
}
|