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) } } } }