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>
510 lines
22 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|