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
@@ -0,0 +1,115 @@
import Foundation
import Observation
@MainActor
@Observable
final class TapToPayViewModel {
let account: BankAccount
var phase: Phase = .loading
var token: BmlWalletToken?
var errorMessage: String?
var isReady = false
enum Phase {
case loading
case ready
case error
}
init(account: BankAccount) {
self.account = account
}
func armToken() async {
phase = .loading
errorMessage = nil
BmlContactlessUtil.clearToken()
let loginId = account.loginTag.replacingOccurrences(of: "bml_", with: "")
guard let otpSeed = CredentialStore.shared.load(
forKey: CredentialStore.Keys.bmlOtpSeed(loginId)
) else {
errorMessage = "OTP seed not found for \(loginId). Please log in again."
phase = .error
return
}
let profileId = account.profileId ?? ""
guard let accessToken = CredentialStore.shared.load(
forKey: CredentialStore.Keys.bmlAccessToken(profileId)
), !accessToken.isEmpty else {
errorMessage = "Session not found. Please log in again."
phase = .error
return
}
let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(profileId)) ?? ""
let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(profileId)) ?? ""
let expStr = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(profileId)) ?? "0"
let expiresAt = Int64(expStr) ?? 0
let now = Int64(Date().timeIntervalSince1970 * 1000)
var session = BmlSession(
accessToken: accessToken,
deviceId: deviceId,
refreshToken: refreshTok,
expiresAt: expiresAt
)
// Refresh if expired or expiring within 60s
if expiresAt > 0 && now >= expiresAt - 60_000 {
do {
let fresh = try await BmlLoginFlow().refreshSession(session)
saveSession(fresh, profileId: profileId)
session = fresh
} catch {
errorMessage = "Session expired. Please log in again. (\(error.localizedDescription))"
phase = .error
return
}
}
let otp = Totp.generate(otpSeed)
let internalId = account.internalId ?? ""
do {
let tokens = try await BmlTapToPayClient().fetchTokens(
session: session,
cardId: internalId,
otp: otp
)
guard let firstToken = tokens.first else {
errorMessage = "No payment tokens received from server."
phase = .error
return
}
token = firstToken
BmlContactlessUtil.setToken(firstToken)
phase = .ready
isReady = true
} catch {
if error is CancellationError { return }
errorMessage = error.localizedDescription
phase = .error
}
}
func cancel() {
BmlContactlessUtil.clearToken()
}
private func saveSession(_ sess: BmlSession, profileId: String) {
try? CredentialStore.shared.save(sess.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId))
try? CredentialStore.shared.save(sess.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId))
if !sess.refreshToken.isEmpty {
try? CredentialStore.shared.save(sess.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId))
}
if sess.expiresAt > 0 {
try? CredentialStore.shared.save(String(sess.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId))
}
}
}