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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user