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:
@@ -28,6 +28,15 @@ final class HomeViewModel {
|
||||
// Set this to navigate Transfer tab pre-filled with a contact
|
||||
var pendingTransferContact: BankContact? = nil
|
||||
|
||||
// Set this to navigate Transfer tab with QR-scanned data
|
||||
var pendingTransferQRData: PaymvQrResult? = nil
|
||||
|
||||
// Set this to navigate Transfer tab with a BML gateway QR URL
|
||||
var pendingBmlQrUrl: String? = nil
|
||||
|
||||
// The card account number that the QR was scanned from (card machine)
|
||||
var pendingQrFromCard: String? = nil
|
||||
|
||||
private let cacheRefreshInterval: TimeInterval = 5 * 60
|
||||
private var lastDashboardRefresh = Date.distantPast
|
||||
private var lastContactsRefresh = Date.distantPast
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user