Add Tap to Pay feature and misc fixes

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>
This commit is contained in:
2026-06-08 01:27:27 +05:00
co-authored by Claude Sonnet 4.6
parent 160ef97e68
commit 2bbb280380
10 changed files with 1080 additions and 49 deletions
+190 -43
View File
@@ -1,17 +1,56 @@
import SwiftUI
import AVFoundation
@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 {
QRCameraView(onResult: { result in
dismiss()
onResult(result)
})
.ignoresSafeArea()
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 {
@@ -20,67 +59,175 @@ struct QRScannerSheet: View {
.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) -> UIView {
let view = UIView()
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.backgroundColor = .black
let session = AVCaptureSession()
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device) else {
return view
}
if session.canAddInput(input) { session.addInput(input) }
let output = AVCaptureMetadataOutput()
if session.canAddOutput(output) {
session.addOutput(output)
output.setMetadataObjectsDelegate(context.coordinator, queue: .main)
output.metadataObjectTypes = [.qr]
}
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
preview.frame = view.bounds
view.layer.addSublayer(preview)
context.coordinator.previewLayer = preview
DispatchQueue.global(qos: .userInitiated).async { session.startRunning() }
context.coordinator.session = session
context.coordinator.configure(previewView: view)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
context.coordinator.previewLayer?.frame = uiView.bounds
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) }
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 {
var onResult: (String) -> Void
var session: AVCaptureSession?
var previewLayer: AVCaptureVideoPreviewLayer?
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) {
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 objects: [AVMetadataObject],
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard !didCapture,
let obj = objects.first as? AVMetadataMachineReadableCodeObject,
let str = obj.stringValue else { return }
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue else { return }
didCapture = true
session?.stopRunning()
DispatchQueue.main.async { self.onResult(str) }
stop()
DispatchQueue.main.async { self.onResult(value) }
}
}
}