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