From 2bbb280380dc267d3fcd8982990a37f2f2c3880c Mon Sep 17 00:00:00 2001 From: Mohamed Azim Date: Mon, 8 Jun 2026 01:27:27 +0500 Subject: [PATCH] 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 --- .../API/BML/BmlContactlessUtil.swift | 171 ++++++++ Thijooree iOS/API/BML/BmlTapToPayClient.swift | 85 ++++ Thijooree iOS/Utils/PaymvQrParser.swift | 13 +- Thijooree iOS/ViewModels/HomeViewModel.swift | 9 + .../ViewModels/TapToPayViewModel.swift | 115 ++++++ Thijooree iOS/Views/Home/DashboardView.swift | 49 ++- Thijooree iOS/Views/Home/HomeView.swift | 8 + Thijooree iOS/Views/Home/TapToPayView.swift | 389 ++++++++++++++++++ Thijooree iOS/Views/Home/TransferView.swift | 57 +++ .../Views/Shared/QRScannerSheet.swift | 233 +++++++++-- 10 files changed, 1080 insertions(+), 49 deletions(-) create mode 100644 Thijooree iOS/API/BML/BmlContactlessUtil.swift create mode 100644 Thijooree iOS/API/BML/BmlTapToPayClient.swift create mode 100644 Thijooree iOS/ViewModels/TapToPayViewModel.swift create mode 100644 Thijooree iOS/Views/Home/TapToPayView.swift diff --git a/Thijooree iOS/API/BML/BmlContactlessUtil.swift b/Thijooree iOS/API/BML/BmlContactlessUtil.swift new file mode 100644 index 0000000..ceb908e --- /dev/null +++ b/Thijooree iOS/API/BML/BmlContactlessUtil.swift @@ -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.. Data { + Data(hexToBytes(hex)) + } + + static func asciiToHex(_ s: String) -> String { + s.compactMap { $0.asciiValue }.map { String(format: "%02X", $0) }.joined() + } +} diff --git a/Thijooree iOS/API/BML/BmlTapToPayClient.swift b/Thijooree iOS/API/BML/BmlTapToPayClient.swift new file mode 100644 index 0000000..e1d856e --- /dev/null +++ b/Thijooree iOS/API/BML/BmlTapToPayClient.swift @@ -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] + } +} diff --git a/Thijooree iOS/Utils/PaymvQrParser.swift b/Thijooree iOS/Utils/PaymvQrParser.swift index 4b6a590..69a9c96 100644 --- a/Thijooree iOS/Utils/PaymvQrParser.swift +++ b/Thijooree iOS/Utils/PaymvQrParser.swift @@ -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) diff --git a/Thijooree iOS/ViewModels/HomeViewModel.swift b/Thijooree iOS/ViewModels/HomeViewModel.swift index bf26a0f..448a82a 100644 --- a/Thijooree iOS/ViewModels/HomeViewModel.swift +++ b/Thijooree iOS/ViewModels/HomeViewModel.swift @@ -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 diff --git a/Thijooree iOS/ViewModels/TapToPayViewModel.swift b/Thijooree iOS/ViewModels/TapToPayViewModel.swift new file mode 100644 index 0000000..3768009 --- /dev/null +++ b/Thijooree iOS/ViewModels/TapToPayViewModel.swift @@ -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)) + } + } +} diff --git a/Thijooree iOS/Views/Home/DashboardView.swift b/Thijooree iOS/Views/Home/DashboardView.swift index db2514e..64cb2de 100644 --- a/Thijooree iOS/Views/Home/DashboardView.swift +++ b/Thijooree iOS/Views/Home/DashboardView.swift @@ -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) diff --git a/Thijooree iOS/Views/Home/HomeView.swift b/Thijooree iOS/Views/Home/HomeView.swift index 3fb5acf..816cec1 100644 --- a/Thijooree iOS/Views/Home/HomeView.swift +++ b/Thijooree iOS/Views/Home/HomeView.swift @@ -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 diff --git a/Thijooree iOS/Views/Home/TapToPayView.swift b/Thijooree iOS/Views/Home/TapToPayView.swift new file mode 100644 index 0000000..01c0b35 --- /dev/null +++ b/Thijooree iOS/Views/Home/TapToPayView.swift @@ -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 { + 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: {} + ) +} diff --git a/Thijooree iOS/Views/Home/TransferView.swift b/Thijooree iOS/Views/Home/TransferView.swift index f698e5a..7bd7e08 100644 --- a/Thijooree iOS/Views/Home/TransferView.swift +++ b/Thijooree iOS/Views/Home/TransferView.swift @@ -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 { diff --git a/Thijooree iOS/Views/Shared/QRScannerSheet.swift b/Thijooree iOS/Views/Shared/QRScannerSheet.swift index 7e07c80..a8678b9 100644 --- a/Thijooree iOS/Views/Shared/QRScannerSheet.swift +++ b/Thijooree iOS/Views/Shared/QRScannerSheet.swift @@ -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) } } } }