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

510 lines
22 KiB
Swift

import Foundation
import Observation
@MainActor
@Observable
final class HomeViewModel {
var accounts: [BankAccount] = []
var isRefreshing = false
var bankErrors: [String: String] = [:]
var hideAmounts = false
// Contacts
var contacts: [BankContact] = []
var isLoadingContacts = false
var contactsError: String? = nil
// Foreign transaction limits (BML)
var foreignLimits: [(userName: String, limits: [BmlForeignLimit])] = []
// MIB financing deals
var mibFinancing: [MibFinanceDeal] = []
var totalPendingFinances: Double {
mibFinancing.reduce(0) { $0 + $1.outstandingAmount }
}
// 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
private enum DashboardRefreshResult {
case mib(accounts: [BankAccount], financing: [MibFinanceDeal], error: String?)
case bml(accounts: [BankAccount], limits: [(userName: String, limits: [BmlForeignLimit])], error: String?)
}
init() {
accounts = AccountCache.shared.load()
contacts = Self.mergeContacts(ContactsCache.shared.load(), saved: SavedContactsStore.shared.load())
if let dashboard = DashboardCache.shared.load() {
foreignLimits = dashboard.foreignLimits.map { (userName: $0.userName, limits: $0.limits) }
mibFinancing = dashboard.mibFinancing
}
if !accounts.isEmpty && (!foreignLimits.isEmpty || !mibFinancing.isEmpty) {
lastDashboardRefresh = Date()
}
if !contacts.isEmpty { lastContactsRefresh = Date() }
}
// MARK: - Grouped views
var mibAccounts: [BankAccount] { casaAccounts.filter { $0.bank == "MIB" } }
var bmlAccounts: [BankAccount] { casaAccounts.filter { $0.bank == "BML" } }
var fahipayAccounts: [BankAccount] { accounts.filter { $0.bank == "FAHIPAY" } }
var cardAccounts: [BankAccount] {
accounts.filter {
$0.profileType == "BML_CREDIT" ||
$0.profileType == "BML_DEBIT" ||
$0.profileType == "BML_PREPAID" ||
$0.profileType == "MIB_CARD"
}
}
private var casaAccounts: [BankAccount] {
accounts.filter {
$0.profileType != "BML_CREDIT" &&
$0.profileType != "BML_DEBIT" &&
$0.profileType != "BML_PREPAID" &&
$0.profileType != "BML_LOAN" &&
$0.profileType != "MIB_CARD"
}
}
// MARK: - Balance totals (CASA only)
var totalMvrBalance: Double {
casaAccounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.availableBalance }
}
var totalUsdBalance: Double {
casaAccounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.availableBalance }
}
// MARK: - Credit / prepaid totals
var hasCreditAccounts: Bool { !bmlCardAccounts.isEmpty }
var totalMvrCredit: Double {
bmlCardAccounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.availableBalance }
}
var totalUsdCredit: Double {
bmlCardAccounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.availableBalance }
}
private var bmlCardAccounts: [BankAccount] {
accounts.filter {
$0.profileType == "BML_CREDIT" ||
$0.profileType == "BML_DEBIT" ||
$0.profileType == "BML_PREPAID"
}
}
// MARK: - Blocked amounts
var totalBlockedMvr: Double {
accounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.blockedAmount }
}
var totalBlockedUsd: Double {
accounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.blockedAmount }
}
// MARK: - Formatting
func fmt(_ amount: Double, currency: String) -> String {
"\(currency) \(Self.balanceFmt.string(from: NSNumber(value: amount)) ?? "0.00")"
}
private static let balanceFmt: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
f.minimumFractionDigits = 2
f.maximumFractionDigits = 2
f.groupingSeparator = ","
f.usesGroupingSeparator = true
return f
}()
// MARK: - Refresh
// MIB and BML run in parallel; within each bank one login/session is shared for
// all sub-requests (accounts + financing for MIB, accounts + limits for BML).
func refreshIfNeeded() async {
guard accounts.isEmpty || Date().timeIntervalSince(lastDashboardRefresh) >= cacheRefreshInterval else { return }
await refresh()
}
func refresh() async {
guard !isRefreshing else { return }
isRefreshing = true
bankErrors = [:]
var completedRefresh = false
defer {
if completedRefresh { lastDashboardRefresh = Date() }
isRefreshing = false
}
await withTaskGroup(of: DashboardRefreshResult.self) { group in
group.addTask {
let result = await Self.fetchMibAll()
return .mib(accounts: result.accounts, financing: result.financing, error: result.error)
}
group.addTask {
let result = await Self.fetchBmlAll()
return .bml(accounts: result.accounts, limits: result.limits, error: result.error)
}
for await result in group {
guard !Task.isCancelled else { return }
switch result {
case .mib(let freshAccounts, let financing, let error):
if let error {
bankErrors["MIB"] = error
} else {
replaceAccounts(for: "MIB", with: freshAccounts)
mibFinancing = financing
DashboardCache.shared.save(foreignLimits: foreignLimits, mibFinancing: mibFinancing)
}
case .bml(let freshAccounts, let limits, let error):
if let error {
bankErrors["BML"] = error
} else {
replaceAccounts(for: "BML", with: freshAccounts)
foreignLimits = limits
DashboardCache.shared.save(foreignLimits: foreignLimits, mibFinancing: mibFinancing)
}
}
}
}
completedRefresh = !Task.isCancelled
}
private func replaceAccounts(for bank: String, with freshAccounts: [BankAccount]) {
guard !freshAccounts.isEmpty || !accounts.contains(where: { $0.bank == bank }) else { return }
accounts.removeAll { $0.bank == bank }
accounts += freshAccounts
AccountCache.shared.save(accounts)
}
// MARK: - MIB: one login per user → accounts + financing with the same session
private static func fetchMibAll() async -> (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?) {
let ids = CredentialStore.shared.loginIds(for: "MIB")
var allAccounts: [BankAccount] = []
var allFinancing: [MibFinanceDeal] = []
var lastErr: String?
await withTaskGroup(of: (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?).self) { group in
for username in ids {
group.addTask { await fetchMibUser(username: username) }
}
for await result in group {
allAccounts += result.accounts
allFinancing += result.financing
if let e = result.error { lastErr = e }
}
}
return (allAccounts, allFinancing, lastErr)
}
private static func fetchMibUser(username: String) async -> (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?) {
guard
let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)),
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username))
else {
return ([], [], "MIB credentials missing for \(username). Please log in again.")
}
do {
let flow = MibLoginFlow()
let accs = try await flow.login(username: username, passwordHash: hash, otpSeed: seed)
// Reuse the established session — no second login needed
guard let session = await flow.lastSession else { return (accs, [], nil) }
let deals = (try? await MibFinancingClient(session: session).fetchFinancing()) ?? []
return (accs, deals, nil)
} catch {
if Self.isCancellation(error) { return ([], [], nil) }
return ([], [], error.localizedDescription)
}
}
// MARK: - BML: one session per user → accounts + limits + userinfo in parallel
private static func fetchBmlAll() async -> (accounts: [BankAccount], limits: [(String, [BmlForeignLimit])], error: String?) {
let ids = CredentialStore.shared.loginIds(for: "BML")
var allAccounts: [BankAccount] = []
var allLimits: [(String, [BmlForeignLimit])] = []
var lastErr: String?
for username in ids {
let r = await fetchBmlUserAll(username: username)
allAccounts += r.accounts
if let lim = r.limits { allLimits.append(lim) }
if let e = r.error { lastErr = e }
}
return (allAccounts, allLimits, lastErr)
}
private static func fetchBmlUserAll(username: String) async -> (accounts: [BankAccount], limits: (String, [BmlForeignLimit])?, error: String?) {
let loginTag = "bml_\(username)"
let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username))
// Try cached profiles first
if !pids.isEmpty {
// Load first valid session to use for limits/userinfo
var firstSession: BmlSession? = nil
var accountsAll: [BankAccount] = []
var accountsError: String? = nil
for pid in pids {
guard let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)),
!token.isEmpty else {
// Token missing — fall back to full login
return await bmlFullLoginAll(username: username)
}
let expStr = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(pid)) ?? "0"
let expiresAt = Int64(expStr) ?? 0
let now = Int64(Date().timeIntervalSince1970 * 1000)
let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(pid)) ?? ""
let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(pid)) ?? ""
var sess = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt)
if expiresAt > 0 && now >= expiresAt - 60_000 {
do {
let fresh = try await BmlLoginFlow().refreshSession(sess)
saveBmlSession(fresh, profileId: pid)
sess = fresh
} catch {
if Self.isCancellation(error) { return ([], nil, nil) }
return await bmlFullLoginAll(username: username)
}
}
if firstSession == nil { firstSession = sess }
do {
let accs = try await BmlAccountClient(session: sess)
.fetchAccounts(loginTag: loginTag, profileId: pid)
accountsAll += accs
} catch {
if Self.isCancellation(error) { return ([], nil, nil) }
if case BmlError.sessionExpired = error {
return await bmlFullLoginAll(username: username)
}
accountsError = error.localizedDescription
}
}
// Fetch limits + userinfo using first session, in parallel with nothing else to wait on
var limitsTuple: (String, [BmlForeignLimit])? = nil
if let sess = firstSession {
limitsTuple = await fetchBmlLimits(session: sess, username: username)
}
return (accountsAll, limitsTuple, accountsError)
}
// No cached profiles → full login
return await bmlFullLoginAll(username: username)
}
private static func bmlFullLoginAll(username: String) async -> (accounts: [BankAccount], limits: (String, [BmlForeignLimit])?, error: String?) {
guard
let pw = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlPassword(username)),
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(username))
else {
return ([], nil, "BML credentials missing for \(username). Please log in again.")
}
do {
let flow = BmlLoginFlow()
let loginTag = "bml_\(username)"
let profiles = try await flow.login(username: username, password: pw, otpSeed: seed)
var allAccounts: [BankAccount] = []
var firstSession: BmlSession? = nil
for p in profiles where p.profileType != "business" {
if case .success(let sess, let accs) = try await flow.activateProfile(p, loginTag: loginTag) {
allAccounts += accs
saveBmlSession(sess, profileId: p.profileId)
if firstSession == nil { firstSession = sess }
}
}
try? CredentialStore.shared.saveStringArray(
profiles.map { $0.profileId },
forKey: CredentialStore.Keys.bmlProfiles(username)
)
var limitsTuple: (String, [BmlForeignLimit])? = nil
if let sess = firstSession {
limitsTuple = await fetchBmlLimits(session: sess, username: username)
}
return (allAccounts, limitsTuple, nil)
} catch {
if Self.isCancellation(error) { return ([], nil, nil) }
return ([], nil, error.localizedDescription)
}
}
// Fetch limits + userinfo in parallel using an already-established session
private static func fetchBmlLimits(session: BmlSession, username: String) async -> (String, [BmlForeignLimit])? {
do {
async let limitsTask = BmlForeignLimitsClient().fetchForeignLimits(session: session)
async let userInfoTask = BmlAccountClient(session: session).fetchUserInfo()
let (limits, userInfo) = try await (limitsTask, userInfoTask)
let displayName = userInfo?.fullName.isEmpty == false ? userInfo!.fullName : username
return (displayName, limits)
} catch {
return nil
}
}
nonisolated private static func isCancellation(_ error: Error) -> Bool {
if error is CancellationError { return true }
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
let nsError = error as NSError
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled
}
// MARK: - Contacts
func fetchContactsIfNeeded() async {
guard contacts.isEmpty || Date().timeIntervalSince(lastContactsRefresh) >= cacheRefreshInterval else { return }
await fetchContacts()
}
func fetchContacts() async {
guard !isLoadingContacts else { return }
isLoadingContacts = true
contactsError = nil
var completedFetch = false
defer {
if completedFetch { lastContactsRefresh = Date() }
isLoadingContacts = false
}
// MIB and BML contacts fetched in parallel
async let mibResult = Self.fetchMibContacts()
async let bmlResult = Self.fetchBmlContacts()
let (mibC, bmlC) = await (mibResult, bmlResult)
let fetched = mibC.contacts + bmlC.contacts
if !fetched.isEmpty || contacts.isEmpty {
contacts = Self.mergeContacts(fetched, saved: SavedContactsStore.shared.load())
ContactsCache.shared.save(contacts)
}
if let e = mibC.error { contactsError = e }
if let e = bmlC.error { contactsError = (contactsError.map { $0 + "; " } ?? "") + e }
completedFetch = true
}
private static func mergeContacts(_ base: [BankContact], saved: [BankContact]) -> [BankContact] {
var merged = base
for contact in saved where !merged.contains(where: { $0.benefAccount == contact.benefAccount && $0.source == contact.source }) {
merged.append(contact)
}
return merged
}
private static func fetchMibContacts() async -> (contacts: [BankContact], error: String?) {
let ids = CredentialStore.shared.loginIds(for: "MIB")
var all: [BankContact] = []
var lastErr: String?
// Each MIB user runs in parallel; credentials captured before entering the task
await withTaskGroup(of: (contacts: [BankContact], error: String?).self) { group in
for username in ids {
let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username))
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username))
let loginTag = "mib_\(username)"
guard let hash, let seed else { continue }
group.addTask {
do {
let flow = MibLoginFlow()
_ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed)
guard let session = await flow.lastSession else { return ([], nil) }
let contacts = try await MibContactsClient(session: session).fetchContacts(loginTag: loginTag)
return (contacts, nil)
} catch {
if Self.isCancellation(error) { return ([], nil) }
return ([], error.localizedDescription)
}
}
}
for await result in group {
all += result.contacts
if let e = result.error { lastErr = e }
}
}
return (all, lastErr)
}
private static func fetchBmlContacts() async -> (contacts: [BankContact], error: String?) {
let ids = CredentialStore.shared.loginIds(for: "BML")
var all: [BankContact] = []
var lastErr: String?
for username in ids {
let loginTag = "bml_\(username)"
let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username))
guard let pid = pids.first,
let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)),
!token.isEmpty else { continue }
let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(pid)) ?? ""
let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(pid)) ?? ""
let expiresAt = Int64(CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(pid)) ?? "0") ?? 0
let sess = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt)
do {
let contacts = try await BmlContactsClient(bmlSession: sess).fetchContacts(loginTag: loginTag)
all += contacts
} catch {
if Self.isCancellation(error) { continue }
lastErr = error.localizedDescription
}
}
return (all, lastErr)
}
func saveContact(_ contact: BankContact) {
SavedContactsStore.shared.save(contact)
contacts = Self.mergeContacts(contacts, saved: SavedContactsStore.shared.load())
ContactsCache.shared.save(contacts)
}
// MARK: - Grouped contacts
var mibContacts: [BankContact] { contacts.filter { $0.source == "MIB" } }
var bmlContacts: [BankContact] { contacts.filter { $0.source == "BML" } }
var fahipayContacts: [BankContact] { contacts.filter { $0.source == "FAHIPAY" } }
func contacts(matching query: String) -> [BankContact] {
guard !query.isEmpty else { return contacts }
let q = query.lowercased()
return contacts.filter {
$0.displayName.lowercased().contains(q) ||
$0.benefAccount.contains(q) ||
($0.benefNickName?.lowercased().contains(q) ?? false)
}
}
private static func saveBmlSession(_ 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))
}
}
}