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>
390 lines
15 KiB
Swift
390 lines
15 KiB
Swift
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: {}
|
|
)
|
|
}
|