Files
modeazimandClaude Sonnet 4.6 2bbb280380 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>
2026-06-08 01:27:27 +05:00

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