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
@@ -0,0 +1,171 @@
import Foundation
enum BmlContactlessUtil {
// MARK: - APDU Instruction Bytes
static let INS_SELECT: UInt8 = 0xA4
static let INS_GPO: UInt8 = 0xA8
static let INS_READ: UInt8 = 0xB2
// MARK: - PPSE
static let PPSE_HEX = "325041592E5359532E4444463031"
static let PPSE_BYTES: [UInt8] = [
0x32, 0x50, 0x41, 0x59, 0x2E, 0x53, 0x59, 0x53, 0x2E, 0x44, 0x44, 0x46, 0x30, 0x31
]
// MARK: - Status Words
static let SW_OK_HEX = "9000"
static let SW_OK = Data([0x90, 0x00])
static let SW_UNKNOWN_ERROR = Data([0x6F, 0x00])
static let SW_INS_NOT_SUPPORTED = Data([0x6D, 0x00])
// MARK: - Active token
static var activeToken: BmlWalletToken?
static var onTransactionComplete: ((Bool) -> Void)?
static func setToken(_ token: BmlWalletToken) { activeToken = token }
static func clearToken() { activeToken = nil; onTransactionComplete = nil }
// MARK: - Application label
static func applicationLabel(aidHex: String) -> String {
if aidHex.hasPrefix("A0000000031010") { return "VISA" }
if aidHex.hasPrefix("A0000000041010") { return "MASTERCARD" }
if aidHex.hasPrefix("A000000025") { return "AMEX" }
return "BML"
}
// MARK: - APDU Processing
/// Process an APDU command and return the response bytes.
/// Returns nil if the APDU is malformed.
static func processCommandApdu(_ command: Data) -> Data {
guard command.count >= 5 else { return SW_UNKNOWN_ERROR }
let ins = command[1]
switch ins {
case INS_SELECT:
return handleSelect(command)
case INS_GPO:
return handleGpo()
case INS_READ:
return handleReadRecord()
default:
return SW_INS_NOT_SUPPORTED
}
}
// MARK: - APDU Handlers
private static func handleSelect(_ command: Data) -> Data {
let lc = Int(command[4])
guard command.count >= 5 + lc, lc > 0 else { return SW_UNKNOWN_ERROR }
let data = command.subdata(in: 5..<5 + lc)
if data == Data(PPSE_BYTES) {
guard let token = activeToken else { return SW_UNKNOWN_ERROR }
return hexToData(buildSelectPpseResponse(
aid: token.appCode,
label: applicationLabel(aidHex: token.appCode),
priority: "01"
))
}
guard let token = activeToken else { return SW_UNKNOWN_ERROR }
let aidBytes = Data(hexToBytes(token.appCode))
if data == aidBytes {
return hexToData(buildSelectAidResponse(
aid: token.appCode,
label: applicationLabel(aidHex: token.appCode)
))
}
return SW_UNKNOWN_ERROR
}
private static func handleGpo() -> Data {
let miscData = "008008010100"
let body = tlv("80", miscData)
return hexToData(body + SW_OK_HEX)
}
private static func handleReadRecord() -> Data {
guard let token = activeToken else { return SW_UNKNOWN_ERROR }
let track2 = buildTrack2(token)
let body = tlv("70", tlv("57", track2))
let response = hexToData(body + SW_OK_HEX)
onTransactionComplete?(true)
return response
}
// MARK: - Response Builders
static func buildSelectPpseResponse(aid: String, label: String, priority: String) -> String {
let priorityTlv = tlv("87", priority)
let aidTlv = tlv("4F", aid)
let appEntry = tlv("61", aidTlv + priorityTlv)
let ppseTlv = tlv("84", PPSE_HEX)
let inner = tlv("BF0C", appEntry)
let propTpl = tlv("A5", inner)
let fci = tlv("6F", ppseTlv + propTpl)
return fci + SW_OK_HEX
}
static func buildSelectAidResponse(aid: String, label: String) -> String {
let aidTlv = tlv("84", aid)
let labelTlv = tlv("50", asciiToHex(label))
let pdolTlv = tlv("9F38", "9F6602")
let propTpl = tlv("A5", labelTlv + pdolTlv)
let fci = tlv("6F", aidTlv + propTpl)
return fci + SW_OK_HEX
}
static func buildTrack2(_ token: BmlWalletToken) -> String {
var t2 = "\(token.token)D\(token.expiry)\(token.serviceCode)\(token.data)"
if t2.count % 2 != 0 { t2 += "F" }
return t2
}
// MARK: - TLV Encoding
/// Builds a BER-TLV triplet: tag (hex, 1-2 bytes) + DER length + data (hex).
static func tlv(_ tagHex: String, _ dataHex: String) -> String {
let lenBytes = dataHex.count / 2
let lenHex: String
switch lenBytes {
case 0...0x7F:
lenHex = String(format: "%02X", lenBytes)
case 0x80...0xFF:
lenHex = "81" + String(format: "%02X", lenBytes)
default:
lenHex = "82" + String(format: "%02X", lenBytes >> 8) + String(format: "%02X", lenBytes & 0xFF)
}
return tagHex + lenHex + dataHex
}
// MARK: - Hex Utilities
static func hexToBytes(_ hex: String) -> [UInt8] {
let s = hex.uppercased()
var result: [UInt8] = []
var index = s.startIndex
while index < s.endIndex {
let next = s.index(index, offsetBy: 2)
result.append(UInt8(s[index..<next], radix: 16) ?? 0)
index = next
}
return result
}
static func hexToData(_ hex: String) -> Data {
Data(hexToBytes(hex))
}
static func asciiToHex(_ s: String) -> String {
s.compactMap { $0.asciiValue }.map { String(format: "%02X", $0) }.joined()
}
}
@@ -0,0 +1,85 @@
import Foundation
private let BML_API_BASE = "https://www.bankofmaldives.com.mv/internetbanking"
private let BML_API_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let BML_API_VERSION = "2.1.44.348"
final class BmlTapToPayClient {
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}()
func fetchTokens(
session: BmlSession,
cardId: String,
otp: String,
quantity: Int = 3
) async throws -> [BmlWalletToken] {
let url = "\(BML_API_BASE)/api/mobile/walletpayments/gettoken"
// Step 1: initiate
let base: [String: Any] = ["type": "track2", "cardid": cardId, "quantity": quantity]
let step1 = try await post(session: session, url: url, body: base)
if step1.code == 0 { return step1.tokens }
if step1.code != 99 { throw BmlError.serverError(step1.message ?? "Token request failed") }
// Step 2: request OTP channel
var body2 = base
body2["channel"] = "token"
let step2 = try await post(session: session, url: url, body: body2)
if step2.code != 22 { throw BmlError.serverError(step2.message ?? "OTP channel request failed") }
// Step 3: submit TOTP
var body3 = body2
body3["otp"] = otp
let step3 = try await post(session: session, url: url, body: body3)
if step3.code != 0 { throw BmlError.serverError(step3.message ?? "Token fetch failed") }
return step3.tokens
}
private func post(session: BmlSession, url: String, body: [String: Any]) async throws -> TokenResponse {
var req = URLRequest(url: URL(string: url)!)
req.httpMethod = "POST"
req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(BML_API_UA, forHTTPHeaderField: "User-Agent")
req.setValue(BML_API_VERSION, forHTTPHeaderField: "x-app-version")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await urlSession.data(for: req)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("Server error HTTP \(code)") }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw BmlError.invalidResponse
}
let respCode = root["code"] as? Int ?? -1
let message = root["message"] as? String
let payload = root["payload"] as? [[String: Any]] ?? []
let tokens: [BmlWalletToken] = payload.map { item in
BmlWalletToken(
token: item["token"] as? String ?? "",
expiry: item["expiry"] as? String ?? "",
appCode: item["app_code"] as? String ?? "",
serviceCode: item["service_code"] as? String ?? "",
data: item["data"] as? String ?? "",
validUntil: item["valid_until"] as? String ?? ""
)
}
return TokenResponse(code: respCode, message: message, tokens: tokens)
}
private struct TokenResponse {
let code: Int
let message: String?
let tokens: [BmlWalletToken]
}
}
+12 -1
View File
@@ -1,6 +1,6 @@
import Foundation
struct PaymvQrResult {
struct PaymvQrResult: Equatable {
let account: String
let amount: String?
let merchantName: String?
@@ -8,6 +8,17 @@ struct PaymvQrResult {
}
enum PaymvQrParser {
static func extractBmlGatewayUrl(_ raw: String) -> String? {
if raw.hasPrefix("https://pay.bml.com.mv/app/") { return raw }
let root = parseTLV(raw)
guard let bmlMerchantInfo = root["35"].flatMap({ parseTLV($0) }),
let inner = bmlMerchantInfo["20"].flatMap({ parseTLV($0) }),
let url = inner["01"],
url.hasPrefix("https://pay.bml.com.mv/app/") else { return nil }
return url
}
static func parse(_ qr: String) -> PaymvQrResult? {
let tlvs = parseTLV(qr)
@@ -28,6 +28,15 @@ final class HomeViewModel {
// Set this to navigate Transfer tab pre-filled with a contact
var pendingTransferContact: BankContact? = nil
// Set this to navigate Transfer tab with QR-scanned data
var pendingTransferQRData: PaymvQrResult? = nil
// Set this to navigate Transfer tab with a BML gateway QR URL
var pendingBmlQrUrl: String? = nil
// The card account number that the QR was scanned from (card machine)
var pendingQrFromCard: String? = nil
private let cacheRefreshInterval: TimeInterval = 5 * 60
private var lastDashboardRefresh = Date.distantPast
private var lastContactsRefresh = Date.distantPast
@@ -0,0 +1,115 @@
import Foundation
import Observation
@MainActor
@Observable
final class TapToPayViewModel {
let account: BankAccount
var phase: Phase = .loading
var token: BmlWalletToken?
var errorMessage: String?
var isReady = false
enum Phase {
case loading
case ready
case error
}
init(account: BankAccount) {
self.account = account
}
func armToken() async {
phase = .loading
errorMessage = nil
BmlContactlessUtil.clearToken()
let loginId = account.loginTag.replacingOccurrences(of: "bml_", with: "")
guard let otpSeed = CredentialStore.shared.load(
forKey: CredentialStore.Keys.bmlOtpSeed(loginId)
) else {
errorMessage = "OTP seed not found for \(loginId). Please log in again."
phase = .error
return
}
let profileId = account.profileId ?? ""
guard let accessToken = CredentialStore.shared.load(
forKey: CredentialStore.Keys.bmlAccessToken(profileId)
), !accessToken.isEmpty else {
errorMessage = "Session not found. Please log in again."
phase = .error
return
}
let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(profileId)) ?? ""
let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(profileId)) ?? ""
let expStr = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(profileId)) ?? "0"
let expiresAt = Int64(expStr) ?? 0
let now = Int64(Date().timeIntervalSince1970 * 1000)
var session = BmlSession(
accessToken: accessToken,
deviceId: deviceId,
refreshToken: refreshTok,
expiresAt: expiresAt
)
// Refresh if expired or expiring within 60s
if expiresAt > 0 && now >= expiresAt - 60_000 {
do {
let fresh = try await BmlLoginFlow().refreshSession(session)
saveSession(fresh, profileId: profileId)
session = fresh
} catch {
errorMessage = "Session expired. Please log in again. (\(error.localizedDescription))"
phase = .error
return
}
}
let otp = Totp.generate(otpSeed)
let internalId = account.internalId ?? ""
do {
let tokens = try await BmlTapToPayClient().fetchTokens(
session: session,
cardId: internalId,
otp: otp
)
guard let firstToken = tokens.first else {
errorMessage = "No payment tokens received from server."
phase = .error
return
}
token = firstToken
BmlContactlessUtil.setToken(firstToken)
phase = .ready
isReady = true
} catch {
if error is CancellationError { return }
errorMessage = error.localizedDescription
phase = .error
}
}
func cancel() {
BmlContactlessUtil.clearToken()
}
private func saveSession(_ sess: BmlSession, profileId: String) {
try? CredentialStore.shared.save(sess.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId))
try? CredentialStore.shared.save(sess.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId))
if !sess.refreshToken.isEmpty {
try? CredentialStore.shared.save(sess.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId))
}
if sess.expiresAt > 0 {
try? CredentialStore.shared.save(String(sess.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId))
}
}
}
+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 {
+187 -40
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
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) }
}
}
}