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:
@@ -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: {}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user