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
+44 -5
View File
@@ -10,6 +10,9 @@ import SwiftUI
struct DashboardView: View {
@Environment(HomeViewModel.self) private var vm
@Environment(AppViewModel.self) private var app
@State private var showQRScanner = false
@State private var scannedCardAccountNumber: String? = nil
@State private var tapToPayAccount: BankAccount? = nil
var body: some View {
NavigationStack {
@@ -118,8 +121,7 @@ struct DashboardView: View {
// Fixed bottom quick-action bar
HStack(spacing: 8) {
QuickActionButton(icon: "arrow.up.right", label: "Transfer") {}
QuickActionButton(icon: "qrcode", label: "PayMV QR") {}
}
QuickActionButton(icon: "qrcode", label: "PayMV QR") { showQRScanner = true } }
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, max(16, 0))
@@ -128,6 +130,16 @@ struct DashboardView: View {
.navigationTitle("Thijooree")
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarItems }
.sheet(isPresented: $showQRScanner) {
QRScannerSheet { qrContent in
handleQR(qrContent)
}
}
.sheet(item: $tapToPayAccount) { account in
TapToPayView(account: account) {
tapToPayAccount = nil
}
}
}
}
@@ -137,7 +149,12 @@ struct DashboardView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(vm.cardAccounts) { card in
CardCarouselItem(account: card, hide: vm.hideAmounts)
CardCarouselItem(account: card, hide: vm.hideAmounts, onScanToPay: {
scannedCardAccountNumber = card.accountNumber
showQRScanner = true
}, onTapToPay: {
tapToPayAccount = card
})
.padding(.trailing, 12)
}
}
@@ -168,6 +185,26 @@ struct DashboardView: View {
}
}
// MARK: - QR handler
private func handleQR(_ content: String) {
let bmlUrl = PaymvQrParser.extractBmlGatewayUrl(content)
if content.hasPrefix("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != nil {
vm.pendingBmlQrUrl = bmlUrl ?? content
vm.pendingQrFromCard = scannedCardAccountNumber
return
}
if let result = PaymvQrParser.parse(content) {
vm.pendingTransferQRData = result
vm.pendingQrFromCard = scannedCardAccountNumber
} else {
let fallback = PaymvQrResult(account: content, amount: nil, merchantName: nil, purpose: nil)
vm.pendingTransferQRData = fallback
vm.pendingQrFromCard = scannedCardAccountNumber
}
}
// MARK: - Empty state
private var emptyState: some View {
@@ -232,6 +269,8 @@ private struct BalanceCard: View {
struct CardCarouselItem: View {
let account: BankAccount
let hide: Bool
let onScanToPay: () -> Void
let onTapToPay: () -> Void
var body: some View {
VStack(spacing: 0) {
@@ -274,8 +313,8 @@ struct CardCarouselItem: View {
// Action buttons
HStack(spacing: 4) {
CardActionButton(icon: "qrcode", label: "Scan to Pay") {}
CardActionButton(icon: "wave.3.right", label: "Tap to Pay") {}
CardActionButton(icon: "qrcode", label: "Scan to Pay") { onScanToPay() }
CardActionButton(icon: "wave.3.right", label: "Tap to Pay") { onTapToPay() }
}
.padding(.horizontal, 10)
.padding(.top, 8)
+8
View File
@@ -33,6 +33,14 @@ struct HomeView: View {
.onChange(of: vm.pendingTransferContact) { _, contact in
if contact != nil { selectedTab = 3 }
}
// When a QR code is scanned from Dashboard, jump to the Transfer tab
.onChange(of: vm.pendingTransferQRData) { _, qrData in
if qrData != nil { selectedTab = 3 }
}
// When a BML gateway QR is scanned from Dashboard, jump to the Transfer tab
.onChange(of: vm.pendingBmlQrUrl) { _, url in
if url != nil { selectedTab = 3 }
}
}
@ViewBuilder
+389
View File
@@ -0,0 +1,389 @@
import SwiftUI
import Combine
struct TapToPayView: View {
let account: BankAccount
let onDismiss: () -> Void
@State private var viewModel: TapToPayViewModel
init(account: BankAccount, onDismiss: @escaping () -> Void) {
self.account = account
self.onDismiss = onDismiss
self._viewModel = State(wrappedValue: TapToPayViewModel(account: account))
}
var body: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
VStack(spacing: 0) {
// Drag indicator
Capsule()
.fill(Color(.systemFill))
.frame(width: 36, height: 5)
.padding(.top, 12)
Spacer()
// Card display
cardView
.padding(.horizontal, 24)
Spacer().frame(height: 20)
// NFC animation area
nfcAnimationArea
.frame(height: 200)
Spacer().frame(height: 16)
// Status text
statusText
.padding(.horizontal, 32)
Spacer()
Divider()
// Cancel / Retry button
VStack(spacing: 12) {
if viewModel.phase == .error {
Button(action: {
Task { await viewModel.armToken() }
}) {
Label("Retry", systemImage: "arrow.clockwise")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(.blue)
}
Button(action: {
viewModel.cancel()
onDismiss()
}) {
Text("Cancel")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemFill), lineWidth: 1)
)
}
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.top, 12)
.padding(.bottom, max(24, 0))
}
}
.task {
await viewModel.armToken()
}
}
// MARK: - Card View
private var cardView: some View {
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
if let name = cardImageName, UIImage(named: name) != nil {
Image(name)
.resizable()
.scaledToFit()
.frame(height: 200)
.clipped()
} else {
Rectangle()
.fill(cardGradient)
.frame(height: 200)
}
LinearGradient(
stops: [
.init(color: .clear, location: 0.35),
.init(color: .black.opacity(0.65), location: 1.0)
],
startPoint: .top, endPoint: .bottom
)
VStack(alignment: .leading, spacing: 2) {
Text(account.accountBriefName.uppercased())
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 3, x: 1, y: 1)
Text(maskedNumber)
.font(.caption2)
.foregroundStyle(.white.opacity(0.85))
.fontDesign(.monospaced)
}
.padding(12)
}
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 2)
}
}
// MARK: - NFC Animation
private var nfcAnimationArea: some View {
NfcRingsAnimation()
.allowsHitTesting(false)
}
// MARK: - Status Text
@ViewBuilder
private var statusText: some View {
switch viewModel.phase {
case .loading:
VStack(spacing: 12) {
ProgressView()
.scaleEffect(1.2)
Text("Fetching payment token...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
case .ready:
VStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 28))
.foregroundStyle(.green)
Text("Ready to Tap")
.font(.headline)
Text("Hold your phone near the terminal")
.font(.subheadline)
.foregroundStyle(.secondary)
}
case .error:
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 28))
.foregroundStyle(.orange)
Text("Failed to arm token")
.font(.headline)
if let msg = viewModel.errorMessage {
Text(msg)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
}
}
// MARK: - Helpers
private var maskedNumber: String {
let raw = account.accountNumber
guard raw.count >= 4 else { return raw }
return "•••• \(String(raw.suffix(4)))"
}
private var cardImageName: String? {
switch account.bank {
case "BML": return "card_bml_\(bmlAsset(account.productCode))"
case "MIB": return mibCardImageName(account.productCode)
default: return nil
}
}
private func bmlAsset(_ code: String) -> String {
switch code {
case "C8201","C8001","C8009": return "master_prepaid"
case "C8205","C8005","C8008": return "master_prepaid_travel"
case "C3007","C3017","C3097","C3095","C3077","C3177": return "amex_debit_green"
case "C3003","C3013","C3053","C3023","C3033","C3052": return "amex_debit_gold"
case "C3009","C3019","C3029","C3099","C3088","C3188": return "amex_credit_gold"
case "C3001","C3011","C3050","C3051","C3031": return "amex_credit_green"
case "C3005","C3015","C3055","C3054": return "amex_platinum"
case "C1003","C1013","C1083","C1084","C1103","C1113","C1183","C1184": return "visa_gold"
case "C1007","C1027","C1097","C1107","C1197","C1077","C1177": return "visa_debit"
case "C1020","C1021": return "visa_debit_platinum"
case "C8020","C8022": return "master_gold"
case "C8902","C8907","C8909","C8912","C8992","C8996","C8997","C8982","C8983": return "master_islamic"
case "C8101": return "master_masveriyaa"
case "C8102": return "master_odiveriyaa"
case "C8010","C8011": return "master_platinum"
case "C8040","C8044": return "master_world"
case "C8030","C8033": return "master_business_debit"
case "C8901","C8991","C8980","C8981": return "master_passport"
case "C1090","C1130","C1033","C1133": return "visa_corporate"
case "C8905","C8995": return "visa_credit"
case "C1001","C1011","C1082","C1081","C1101","C1111","C1181","C1182": return "visa_debit_generic"
case "C1005","C1006","C1030","C1089": return "visa_debit_islamic"
case "C1017": return "visa_infinite"
case "C1009","C1019","C1085","C1086","C1109","C1119","C1185","C1186": return "visa_platinum"
case "C1050","C1051","C1087","C1088","C1150","C1151","C1187","C1188",
"C1040","C1041","C1047","C1048","C1140","C1141","C1147","C1148": return "visa_student_black"
case "C8925","C8926": return "visa_student_blue"
case "C1071","C1073","C1061","C1063","C1161","C1163": return "master"
case "C1070","C1072","C1059","C1062","C1159","C1162": return "master_prepaid_business"
default: return "defaultcard"
}
}
private func mibCardImageName(_ cardType: String) -> String? {
switch cardType {
case "51": return "card_mib_faisa_card"
case "53": return "card_mib_visa_black_platinum"
case "57": return "card_mib_visa_blue_everyday"
case "70": return "card_mib_visa_business"
case "701": return "card_mib_visa_bingaa_mvr"
case "702": return "card_mib_visa_bingaa_usd"
default: return nil
}
}
private var cardGradient: LinearGradient {
switch account.bank {
case "BML": return LinearGradient(
colors: [Color(red: 0.1, green: 0.3, blue: 0.7),
Color(red: 0.05, green: 0.15, blue: 0.4)],
startPoint: .topLeading, endPoint: .bottomTrailing)
default: return LinearGradient(
colors: [Color(red: 0.15, green: 0.5, blue: 0.25),
Color(red: 0.05, green: 0.3, blue: 0.15)],
startPoint: .topLeading, endPoint: .bottomTrailing)
}
}
}
// MARK: - NFC Rings Animation (SwiftUI port of Android NfcTapAnimationView)
private struct NfcRingsAnimation: View {
@State private var progress: Double = 0
var body: some View {
Canvas { context, size in
let cx = size.width / 2
let cy = size.height / 2
let dp: CGFloat = 1
let posW: CGFloat = 44 * dp
let posH: CGFloat = 72 * dp
let posX = cx - posW / 2
let phoneW: CGFloat = 52 * dp
let phoneH: CGFloat = 90 * dp
let phoneX = cx - phoneW / 2
let phoneY = cy + 10 * dp
let posY = cy - 150 * dp
let gapTop = posY + posH + 4 * dp
let originY = phoneY
let maxR = max((originY - gapTop) - 4 * dp, 10)
// POS terminal body
context.fill(
Path(roundedRect: CGRect(x: posX, y: posY, width: posW, height: posH),
cornerRadius: 7 * dp),
with: .color(Color(.systemFill))
)
context.stroke(
Path(roundedRect: CGRect(x: posX, y: posY, width: posW, height: posH),
cornerRadius: 7 * dp),
with: .color(.primary),
lineWidth: 2.5 * dp
)
// POS screen
context.fill(
Path(roundedRect: CGRect(x: posX + 4 * dp, y: posY + 6 * dp,
width: posW - 8 * dp, height: posH * 0.45),
cornerRadius: 4 * dp),
with: .color(.blue.opacity(0.3))
)
// POS card slot
let slotY = posY + posH * 0.72
context.stroke(
Path { p in
p.move(to: CGPoint(x: posX + 6 * dp, y: slotY))
p.addLine(to: CGPoint(x: posX + posW - 6 * dp, y: slotY))
},
with: .color(.primary),
lineWidth: 2 * dp
)
// Phone body
context.fill(
Path(roundedRect: CGRect(x: phoneX, y: phoneY,
width: phoneW, height: phoneH),
cornerRadius: 8 * dp),
with: .color(Color(.systemFill))
)
context.stroke(
Path(roundedRect: CGRect(x: phoneX, y: phoneY,
width: phoneW, height: phoneH),
cornerRadius: 8 * dp),
with: .color(.primary),
lineWidth: 2.5 * dp
)
// Phone screen
context.fill(
Path(roundedRect: CGRect(x: phoneX + 4 * dp, y: phoneY + 10 * dp,
width: phoneW - 8 * dp, height: phoneH - 25 * dp),
cornerRadius: 4 * dp),
with: .color(.blue.opacity(0.3))
)
// Animated NFC rings
for i in 0..<3 {
let p = (progress + CGFloat(i) / 3).truncatingRemainder(dividingBy: 1)
let r = min(p * maxR + 6 * dp, maxR)
let alpha = ((1 - p) * 0.8).clamped(to: 0...0.8)
let ringRect = CGRect(x: cx - r, y: originY - r,
width: r * 2, height: r * 2)
context.stroke(
Path(ellipseIn: ringRect),
with: .color(.blue.opacity(alpha)),
lineWidth: 3 * dp
)
}
}
.onReceive(Timer.publish(every: 1.0 / 60, on: .main, in: .common).autoconnect()) { _ in
progress = Date().timeIntervalSince1970
.truncatingRemainder(dividingBy: 1.6) / 1.6
}
}
}
extension Comparable {
func clamped(to limits: ClosedRange<Self>) -> Self {
min(max(self, limits.lowerBound), limits.upperBound)
}
}
// MARK: - Preview
#Preview {
TapToPayView(
account: BankAccount(
id: "BML_1234_bml_test",
bank: "BML",
profileName: "Personal",
profileType: "BML_DEBIT",
productCode: "C1007",
accountNumber: "411111XXXXXX1234",
accountBriefName: "John Doe",
currencyName: "MVR",
accountTypeName: "Visa Debit",
availableBalance: 5000,
currentBalance: 5200,
blockedAmount: 0,
mvrBalance: nil,
statusDesc: "Active",
profileImageHash: nil,
loginTag: "bml_test",
profileId: "test_pid",
internalId: "card_123"
),
onDismiss: {}
)
}
@@ -168,6 +168,16 @@ struct TransferView: View {
selectContact(contact)
homeVM.pendingTransferContact = nil
}
.onChange(of: homeVM.pendingTransferQRData) { _, qrData in
guard let qrData else { return }
handleQRFromDashboard(qrData)
homeVM.pendingTransferQRData = nil
}
.onChange(of: homeVM.pendingBmlQrUrl) { _, url in
guard let url else { return }
handleBmlQrFromDashboard(url)
homeVM.pendingBmlQrUrl = nil
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
@@ -655,6 +665,53 @@ struct TransferView: View {
}
}
private func handleQRFromDashboard(_ result: PaymvQrResult) {
setToInput(result.account)
vm.lookupState = .idle
if let amount = result.amount, !amount.isEmpty {
amountInputText = amount
vm.amountInput = amount
}
if let purpose = result.purpose, !purpose.isEmpty {
purposeInputText = purpose
vm.purposeInput = purpose
} else if let name = result.merchantName, !name.isEmpty {
purposeInputText = "Pay \(name)"
vm.purposeInput = purposeInputText
}
if let cardNumber = homeVM.pendingQrFromCard {
if let cardAccount = homeVM.accounts.first(where: {
$0.accountNumber == cardNumber &&
($0.profileType == "BML_PREPAID" || $0.profileType == "BML_CREDIT" || $0.profileType == "BML_DEBIT")
}) {
vm.fromAccount = cardAccount
}
homeVM.pendingQrFromCard = nil
}
if vm.fromAccount?.bank == "MIB" {
Task { await vm.lookupBeneficiary() }
} else if vm.fromAccount?.bank == "BML" {
Task { await vm.lookupBeneficiary() }
}
}
private func handleBmlQrFromDashboard(_ url: String) {
setToInput(url)
vm.lookupState = .idle
if let cardNumber = homeVM.pendingQrFromCard {
if let cardAccount = homeVM.accounts.first(where: {
$0.accountNumber == cardNumber &&
($0.profileType == "BML_PREPAID" || $0.profileType == "BML_CREDIT" || $0.profileType == "BML_DEBIT")
}) {
vm.fromAccount = cardAccount
}
homeVM.pendingQrFromCard = nil
}
}
private func destinationAccountRow(_ account: BankAccount) -> some View {
HStack(spacing: 12) {
ZStack {
+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) }
}
}
}