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
+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 {