diff --git a/Thijooree iOS.xcodeproj/project.pbxproj b/Thijooree iOS.xcodeproj/project.pbxproj
index 532c9e8..862581d 100644
--- a/Thijooree iOS.xcodeproj/project.pbxproj
+++ b/Thijooree iOS.xcodeproj/project.pbxproj
@@ -400,6 +400,8 @@
DEVELOPMENT_TEAM = 437JYGSZYP;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_NSCameraUsageDescription = "Used to scan PayMV QR codes for transfers";
+ INFOPLIST_KEY_NSFaceIDUsageDescription = "Thijooree uses Face ID to protect access to your banking credentials.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -432,6 +434,8 @@
DEVELOPMENT_TEAM = 437JYGSZYP;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_NSCameraUsageDescription = "Used to scan PayMV QR codes for transfers";
+ INFOPLIST_KEY_NSFaceIDUsageDescription = "Thijooree uses Face ID to protect access to your banking credentials.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
diff --git a/Thijooree iOS/API/BML/BmlAccountClient.swift b/Thijooree iOS/API/BML/BmlAccountClient.swift
new file mode 100644
index 0000000..81d6e09
--- /dev/null
+++ b/Thijooree iOS/API/BML/BmlAccountClient.swift
@@ -0,0 +1,235 @@
+import Foundation
+
+private let BML_API_BASE = "https://www.bankofmaldives.com.mv/internetbanking"
+private let BML_API_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
+private let BML_API_VERSION = "2.1.44.348"
+
+final class BmlAccountClient {
+
+ private let session: BmlSession
+ private let urlSession: URLSession = {
+ let cfg = URLSessionConfiguration.default
+ cfg.timeoutIntervalForRequest = 30
+ return URLSession(configuration: cfg)
+ }()
+
+ init(session: BmlSession) {
+ self.session = session
+ }
+
+ // MARK: - Dashboard / Accounts
+
+ func fetchAccounts(
+ loginTag: String,
+ profileName: String = "Personal",
+ profileId: String = ""
+ ) async throws -> [BankAccount] {
+ let data = try await apiGet("/api/mobile/dashboard")
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == true,
+ let payload = root["payload"] as? [String: Any],
+ let dashboard = payload["dashboard"] as? [[String: Any]] else {
+ return []
+ }
+ return parseDashboard(dashboard, loginTag: loginTag, profileName: profileName, profileId: profileId)
+ }
+
+ // MARK: - Profile check (lightweight ping to verify session)
+
+ func checkProfile() async throws {
+ let data = try await apiGet("/api/mobile/profile")
+ if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == false {
+ throw BmlError.sessionExpired
+ }
+ }
+
+ // MARK: - User info
+
+ func fetchUserInfo() async throws -> BmlUserInfo? {
+ let data = try await apiGet("/api/mobile/userinfo")
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == true,
+ let payload = root["payload"] as? [String: Any],
+ let user = payload["user"] as? [String: Any] else { return nil }
+ return BmlUserInfo(
+ fullName: (user["fullname"] as? String ?? "").trimmingCharacters(in: .whitespaces),
+ email: (user["email"] as? String ?? "").trimmingCharacters(in: .whitespaces),
+ mobile: (user["mobile_phone"] as? String ?? "").trimmingCharacters(in: .whitespaces),
+ customerId: (user["customer_number"] as? String ?? "").trimmingCharacters(in: .whitespaces),
+ idCard: (user["idcard"] as? String ?? "").trimmingCharacters(in: .whitespaces),
+ birthdate: (user["birthdate"] as? String ?? "").trimmingCharacters(in: .whitespaces)
+ )
+ }
+
+ // MARK: - Loan detail
+
+ func fetchLoanDetail(internalId: String) async throws -> BmlLoanDetail? {
+ let data = try await apiGet("/api/mobile/account/\(internalId)")
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == true,
+ let p = root["payload"] as? [String: Any] else { return nil }
+ return BmlLoanDetail(
+ loanAmount: p["loanAmount"] as? Double ?? 0,
+ outstandingAmt: p["outstandingAmt"] as? Double ?? 0,
+ repayAmount: p["repayAmount"] as? Double ?? 0,
+ intRate: p["intRate"] as? Double ?? 0,
+ loanStatus: p["loanStatus"] as? String ?? "",
+ startDate: p["startDate"] as? String ?? "",
+ endDate: p["endDate"] as? String ?? "",
+ noOfRepayOverdue: p["noOfRepayOverdue"] as? Int ?? 0,
+ overdueAmount: p["overdueAmount"] as? Double ?? 0
+ )
+ }
+
+ // MARK: - Transfer OTP channels
+
+ func fetchTransferChannels() async throws -> [BmlOtpChannel] {
+ let data = try await apiGet("/api/mobile/transfer")
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == true,
+ let arr = (root["payload"] as? [String: Any])?["transfer"] as? [String: Any],
+ let channels = arr["otpChannel"] as? [[String: Any]] else { return [] }
+ return channels.map {
+ BmlOtpChannel(
+ channel: $0["channel"] as? String ?? "",
+ description: $0["description"] as? String ?? "",
+ masked: $0["masked"] as? String ?? ""
+ )
+ }
+ }
+
+ // MARK: - Dashboard parsing
+
+ private func parseDashboard(
+ _ dashboard: [[String: Any]],
+ loginTag: String,
+ profileName: String,
+ profileId: String
+ ) -> [BankAccount] {
+ var casaAccounts: [BankAccount] = []
+ var cardAccounts: [BankAccount] = []
+ var loanAccounts: [BankAccount] = []
+
+ for item in dashboard {
+ let currency = item["currency"] as? String ?? "MVR"
+ let accountType = item["account_type"] as? String ?? "CASA"
+ let product = item["product"] as? String ?? ""
+ let accountNum = Self.stringValue(item["account"])
+ let status = item["account_status"] as? String ?? "Active"
+ let internalId = Self.stringValue(item["id"])
+
+ switch accountType {
+ case "CASA":
+ let avail = item["availableBalance"] as? Double ?? 0
+ casaAccounts.append(BankAccount(
+ id: "BML_\(accountNum)_\(loginTag)",
+ bank: "BML",
+ profileName: profileName,
+ profileType: "BML",
+ productCode: item["product_code"] as? String ?? "",
+ accountNumber: accountNum,
+ accountBriefName: item["alias"] as? String ?? "",
+ currencyName: currency,
+ accountTypeName: product,
+ availableBalance: avail,
+ currentBalance: item["ledgerBalance"] as? Double ?? 0,
+ blockedAmount: item["lockedAmount"] as? Double ?? 0,
+ mvrBalance: currency == "MVR" ? avail : nil,
+ statusDesc: status,
+ profileImageHash: nil,
+ loginTag: loginTag,
+ profileId: profileId,
+ internalId: internalId
+ ))
+
+ case "Loan":
+ let outstanding = abs(item["availableBalance"] as? Double ?? 0)
+ loanAccounts.append(BankAccount(
+ id: "BML_LOAN_\(accountNum)_\(loginTag)",
+ bank: "BML",
+ profileName: profileName,
+ profileType: "BML_LOAN",
+ productCode: item["product_code"] as? String ?? "",
+ accountNumber: accountNum,
+ accountBriefName: item["alias"] as? String ?? "",
+ currencyName: currency,
+ accountTypeName: product,
+ availableBalance: outstanding,
+ currentBalance: outstanding,
+ blockedAmount: 0,
+ mvrBalance: nil,
+ statusDesc: status,
+ profileImageHash: nil,
+ loginTag: loginTag,
+ profileId: profileId,
+ internalId: internalId
+ ))
+
+ case "Card":
+ let isPrepaid = item["prepaid_card"] as? Bool ?? false
+ let isVisible = item["account_visible"] as? Bool ?? false
+ let cardBalance = item["cardBalance"] as? [String: Any]
+ let avail = cardBalance?["AvailableLimit"] as? Double ?? 0
+ let current = cardBalance?["CurrentBalance"] as? Double ?? 0
+ let cardType: String = isPrepaid ? "BML_PREPAID" : isVisible ? "BML_CREDIT" : "BML_DEBIT"
+ let alias = (item["alias"] as? String ?? "").isEmpty ? product : (item["alias"] as? String ?? product)
+ cardAccounts.append(BankAccount(
+ id: "BML_CARD_\(accountNum)_\(loginTag)",
+ bank: "BML",
+ profileName: profileName,
+ profileType: cardType,
+ productCode: item["product_code"] as? String ?? "",
+ accountNumber: accountNum,
+ accountBriefName: alias,
+ currencyName: currency,
+ accountTypeName: product,
+ availableBalance: avail,
+ currentBalance: current,
+ blockedAmount: 0,
+ mvrBalance: currency == "MVR" ? avail : nil,
+ statusDesc: status,
+ profileImageHash: nil,
+ loginTag: loginTag,
+ profileId: profileId,
+ internalId: internalId
+ ))
+
+ default:
+ break
+ }
+ }
+
+ return casaAccounts + cardAccounts + loanAccounts
+ }
+
+ private static func stringValue(_ value: Any?) -> String {
+ switch value {
+ case let string as String:
+ return string
+ case let int as Int:
+ return String(int)
+ case let int64 as Int64:
+ return String(int64)
+ case let double as Double:
+ return double.rounded() == double ? String(Int64(double)) : String(double)
+ default:
+ return ""
+ }
+ }
+
+ // MARK: - HTTP
+
+ private func apiGet(_ path: String) async throws -> Data {
+ var req = URLRequest(url: URL(string: "\(BML_API_BASE)\(path)")!)
+ req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
+ req.setValue(BML_API_UA, forHTTPHeaderField: "User-Agent")
+ req.setValue(BML_API_VERSION, forHTTPHeaderField: "x-app-version")
+
+ let (data, response) = try await urlSession.data(for: req)
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
+ if code == 401 || code == 419 { throw BmlError.sessionExpired }
+ if code >= 500 { throw BmlError.networkError("Server error HTTP \(code)") }
+ return data
+ }
+}
diff --git a/Thijooree iOS/API/BML/BmlContactsClient.swift b/Thijooree iOS/API/BML/BmlContactsClient.swift
new file mode 100644
index 0000000..4902188
--- /dev/null
+++ b/Thijooree iOS/API/BML/BmlContactsClient.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+// Mirrors Kotlin BmlContactsClient exactly.
+// Endpoint: GET /api/mobile/contacts
+// Response: { success: true, payload: [ { id, name, alias, account, status, currency }, ... ] }
+final class BmlContactsClient {
+
+ private let base = "https://www.bankofmaldives.com.mv/internetbanking"
+ private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
+ private let ver = "2.1.44.348"
+
+ private let bmlSession: BmlSession
+ private let urlSession: URLSession
+
+ init(bmlSession: BmlSession) {
+ self.bmlSession = bmlSession
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.timeoutIntervalForRequest = 30
+ self.urlSession = URLSession(configuration: cfg)
+ }
+
+ func fetchContacts(loginTag: String) async throws -> [BankContact] {
+ var req = URLRequest(url: URL(string: "\(base)/api/mobile/contacts")!)
+ req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
+ req.setValue(ua, forHTTPHeaderField: "User-Agent")
+ req.setValue(ver, forHTTPHeaderField: "x-app-version")
+
+ let (data, response) = try await urlSession.data(for: req)
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
+ if code == 401 || code == 419 { throw BmlError.sessionExpired }
+ if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
+
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == true,
+ let payload = root["payload"] as? [[String: Any]] else {
+ return []
+ }
+
+ return payload.compactMap { item -> BankContact? in
+ let account = item["account"] as? String ?? ""
+ guard !account.isEmpty else { return nil }
+
+ let id = item["id"] as? Int ?? 0
+ let name = item["name"] as? String ?? ""
+ let alias = (item["alias"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? name
+ let status = item["status"] as? String ?? "S"
+ let currency = item["currency"] as? String ?? "MVR"
+
+ return BankContact(
+ id: "BML_bml_\(id)_\(loginTag)",
+ benefNo: "bml_\(id)",
+ benefName: name,
+ benefNickName: alias == name ? nil : alias,
+ benefAccount: account,
+ benefType: "I",
+ bankColor: "#0066A1",
+ benefBankName: "Bank of Maldives",
+ bankCode: "",
+ benefStatus: status,
+ transferCyDesc: currency,
+ customerImgHash: nil,
+ benefCategoryId: "BML",
+ profileId: loginTag,
+ source: "BML"
+ )
+ }
+ }
+}
diff --git a/Thijooree iOS/API/BML/BmlForeignLimitsClient.swift b/Thijooree iOS/API/BML/BmlForeignLimitsClient.swift
new file mode 100644
index 0000000..3abea0b
--- /dev/null
+++ b/Thijooree iOS/API/BML/BmlForeignLimitsClient.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+// Mirrors Kotlin BmlForeignLimitsClient exactly.
+// Endpoint: GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits
+final class BmlForeignLimitsClient {
+
+ private let base = "https://app.bankofmaldives.com.mv/api/v2"
+ private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
+ private let ver = "2.1.44.348"
+
+ private let urlSession: URLSession = {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.timeoutIntervalForRequest = 30
+ return URLSession(configuration: cfg)
+ }()
+
+ func fetchForeignLimits(session: BmlSession) async throws -> [BmlForeignLimit] {
+ var req = URLRequest(url: URL(string: "\(base)/foreign-limits")!)
+ req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
+ req.setValue(ua, "User-Agent")
+ req.setValue(ver, "x-app-version")
+ req.setValue("application/json","Accept")
+
+ let (data, response) = try await urlSession.data(for: req)
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
+ if code == 401 || code == 419 { throw BmlError.sessionExpired }
+ if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
+
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == true,
+ let payload = root["payload"] as? [[String: Any]] else {
+ return []
+ }
+
+ return payload.map { item in
+ let usage = item["usageByCategory"] as? [String: Any] ?? [:]
+ let atm = usage["ATM"] as? [String: Any] ?? [:]
+ let ecom = usage["ECOM"] as? [String: Any] ?? [:]
+ let pos = usage["POS"] as? [String: Any] ?? [:]
+ return BmlForeignLimit(
+ type: item["type"] as? String ?? "Debit",
+ used: item["used"] as? Double ?? 0,
+ totalLimit: item["totalLimit"] as? Double ?? 0,
+ generalCap: item["generalCap"] as? Double ?? 0,
+ generalRemaining: item["generalRemaining"] as? Double ?? 0,
+ medicalRemaining: item["medicalRemaining"] as? Double ?? 0,
+ isAtmEnabled: item["isAtmEnabled"] as? Bool ?? false,
+ isPosEnabled: item["isPosEnabled"] as? Bool ?? false,
+ atmRemaining: atm["remaining"] as? Double ?? 0,
+ atmLimit: atm["limit"] as? Double ?? 0,
+ ecomRemaining: ecom["remaining"] as? Double ?? 0,
+ ecomLimit: ecom["limit"] as? Double ?? 0,
+ posRemaining: pos["remaining"] as? Double ?? 0,
+ posLimit: pos["limit"] as? Double ?? 0
+ )
+ }
+ }
+}
+
+private extension URLRequest {
+ mutating func setValue(_ value: String, _ field: String) {
+ setValue(value, forHTTPHeaderField: field)
+ }
+}
diff --git a/Thijooree iOS/API/BML/BmlLoginFlow.swift b/Thijooree iOS/API/BML/BmlLoginFlow.swift
new file mode 100644
index 0000000..9e1445a
--- /dev/null
+++ b/Thijooree iOS/API/BML/BmlLoginFlow.swift
@@ -0,0 +1,504 @@
+import CryptoKit
+import Foundation
+
+private let BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
+private let BML_CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
+private let BML_REDIRECT = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
+private let BML_APP_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
+private let BML_APP_VERSION = "2.1.44.348"
+private let BML_WEB_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
+
+// Prevents URLSession from following redirects and captures Set-Cookie headers from
+// redirect responses. The 302 response is only fully accessible here, before completionHandler(nil)
+// is called — by the time data(for:delegate:) returns, the redirect response headers may be gone.
+//
+// Also stores lastRedirectLocation so callers can reliably read the Location header even when
+// the async data(for:) return value loses it after redirect cancellation.
+private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate {
+ var onRedirectResponse: (@Sendable (HTTPURLResponse) -> Void)?
+ // Written synchronously inside willPerformHTTPRedirection before completionHandler(nil);
+ // read by BmlLoginFlow after data(for:) returns — sequential by design, no data race.
+ var lastRedirectLocation = ""
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse,
+ newRequest request: URLRequest,
+ completionHandler: @escaping @Sendable (URLRequest?) -> Void
+ ) {
+ lastRedirectLocation = response.value(forHTTPHeaderField: "Location") ?? ""
+ onRedirectResponse?(response)
+ completionHandler(nil)
+ }
+}
+
+final class BmlLoginFlow {
+
+ private let noRedirect = NoRedirectDelegate()
+
+ // Ephemeral session with automatic cookie handling.
+ // noRedirect.onRedirectResponse feeds Set-Cookie headers from every 302 response
+ // into forceCookies() — the only reliable place to see redirect response headers,
+ // since data(for:delegate:) receives a stripped response after the redirect is cancelled.
+ // Declared as `let` (not lazy) to avoid @MainActor inference on the accessing methods.
+ private let webSession: URLSession
+
+ init() {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.timeoutIntervalForRequest = 30
+ webSession = URLSession(configuration: cfg, delegate: noRedirect, delegateQueue: nil)
+ noRedirect.onRedirectResponse = { [weak self] redirectResp in
+ self?.forceCookies(from: redirectResp)
+ self?.captureRedirectCookies(from: redirectResp)
+ }
+ }
+
+ // Extra store for cookies captured from redirect responses.
+ // Keyed by name; injected explicitly into the authorize request header.
+ private var redirectCookieStore: [String: String] = [:]
+
+ private var codeVerifier = ""
+ private var codeChallenge = ""
+ private var deviceId = ""
+
+ private(set) var lastProfiles: [BmlProfile] = []
+
+ // MARK: - Login
+
+ func login(username: String, password: String, otpSeed: String) async throws -> [BmlProfile] {
+ codeVerifier = generateCodeVerifier()
+ codeChallenge = generateCodeChallenge(codeVerifier)
+ deviceId = generateDeviceId()
+
+ // Step 1: GET /web/login — seeds XSRF-TOKEN + blaze_session cookies
+ try await webGet("\(BML_BASE_URL)/web/login")
+ guard let xsrf = xsrfToken() else { throw BmlError.loginFailed("Could not fetch login page") }
+
+ // Step 2: POST credentials
+ let credBody = try jsonBody(["username": username, "password": password, "code": ""])
+ let credResp = try await webPost("\(BML_BASE_URL)/web/login", body: credBody, xsrf: xsrf)
+ guard credResp.status == 302 else {
+ throw BmlError.loginFailed("Login failed — check your username/password")
+ }
+
+ // Step 3: GET 2FA page — follow any canonical redirect so we land on the actual page,
+ // which sets a fresh XSRF-TOKEN cookie needed for the OTP POST.
+ let twoFaGetResp = try await webGet("\(BML_BASE_URL)/web/login/2fa")
+ if twoFaGetResp.status == 302 {
+ let twoFaLoc = twoFaGetResp.location.isEmpty ? noRedirect.lastRedirectLocation : twoFaGetResp.location
+ if !twoFaLoc.isEmpty {
+ let twoFaUrl = twoFaLoc.hasPrefix("http") ? twoFaLoc : "https://www.bankofmaldives.com.mv\(twoFaLoc)"
+ try await webGet(twoFaUrl)
+ }
+ }
+ let xsrf2 = xsrfToken() ?? xsrf
+
+ // Step 4: POST TOTP — try current window, then ±1 windows to handle device clock skew.
+ // A successful 2FA POST redirects somewhere other than /web/login (e.g. /web/profile).
+ // A failed OTP redirects back to /web/login or /web/login/2fa.
+ // Re-fetch XSRF before each attempt in case the server rotates it on a failed try.
+ var twoFaSucceeded = false
+ var lastOtp = ""
+ var lastRedirectLoc = ""
+ for timeStep in [0, -1, 1] {
+ // Refresh XSRF before each attempt — server may invalidate it after a failed POST.
+ let freshXsrf = xsrfToken() ?? xsrf2
+ let otp = Totp.generate(otpSeed, timeStep: timeStep)
+ lastOtp = otp
+ let body = try jsonBody(["code": otp, "channel": "authenticator"])
+ let resp = try await webPost("\(BML_BASE_URL)/web/login/2fa", body: body, xsrf: freshXsrf)
+ let loc = resp.location.isEmpty ? noRedirect.lastRedirectLocation : resp.location
+ lastRedirectLoc = loc
+ if resp.status == 302 && !loc.contains("/web/login") {
+ twoFaSucceeded = true
+ break
+ }
+ }
+ guard twoFaSucceeded else {
+ throw BmlError.otpFailed("OTP verification failed (code tried: \(lastOtp), server redirected to: \(lastRedirectLoc)). If the code matches your authenticator, the XSRF-TOKEN or session cookie may be invalid.")
+ }
+
+ // Step 5: GET /web/profile — 302 = single profile, 200 = picker.
+ // For 302, follow the redirect (e.g. to /web/redirect) so the server-side OAuth
+ // session state is established before we call /oauth/authorize.
+ // NOTE: The async data(for:delegate:) API may not return the Location header on the
+ // response object after redirect cancellation. We fall back to noRedirect.lastRedirectLocation
+ // which is written synchronously inside willPerformHTTPRedirection before the task completes.
+ let profileResp = try await webGet("\(BML_BASE_URL)/web/profile")
+ if profileResp.status == 302 {
+ let loc = profileResp.location.isEmpty ? noRedirect.lastRedirectLocation : profileResp.location
+ if !loc.isEmpty {
+ let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)"
+ try await webGet(redirectUrl)
+ }
+ lastProfiles = [BmlProfile(profileId: username, name: "Personal", type: "Profile", profileType: "default", autoActivated: true)]
+ } else {
+ lastProfiles = parseProfiles(profileResp.body)
+ }
+ return lastProfiles
+ }
+
+ // MARK: - Profile activation
+
+ func activateProfile(_ profile: BmlProfile, loginTag: String) async throws -> BmlActivationResult {
+ if profile.autoActivated {
+ let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
+ return .success(session, accounts)
+ }
+
+ var req = makeWebRequest(url: "\(BML_BASE_URL)/web/profile/\(profile.profileId)")
+ if let xsrf = xsrfToken() { req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN") }
+ let (_, resp) = try await webSession.data(for: req, delegate: noRedirect)
+ let http = resp as! HTTPURLResponse
+ forceCookies(from: http)
+ let loc = http.value(forHTTPHeaderField: "Location") ?? ""
+
+ let needsBusinessOtp = http.statusCode == 302 && loc.contains("/web/profile/2fa/business")
+ if needsBusinessOtp {
+ let channels = try await fetchBusinessOtpChannels()
+ return .needsBusinessOtp(channels)
+ } else if http.statusCode == 409 || http.statusCode == 302 {
+ // Follow the redirect to establish server-side OAuth session state
+ if !loc.isEmpty {
+ let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)"
+ try await webGet(redirectUrl)
+ }
+ let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
+ return .success(session, accounts)
+ } else {
+ throw BmlError.profileActivationFailed("HTTP \(http.statusCode)")
+ }
+ }
+
+ func requestBusinessOtp(channel: String) async throws {
+ guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired }
+ let body = try jsonBody(["code": "", "channel": channel])
+ let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf)
+ guard resp.status == 302 else { throw BmlError.loginFailed("Failed to request OTP (HTTP \(resp.status))") }
+ }
+
+ func submitBusinessOtp(channel: String, code: String, profile: BmlProfile, loginTag: String) async throws -> (BmlSession, [BankAccount]) {
+ try await webGet("\(BML_BASE_URL)/web/profile/2fa/business")
+ guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired }
+ let body = try jsonBody(["code": code, "channel": channel])
+ let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf)
+ let loc = resp.location
+ if resp.status == 409 || (resp.status == 302 && loc.contains("/web/redirect")) {
+ return try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
+ } else if resp.status == 302 {
+ throw BmlError.otpFailed("Invalid OTP — please try again")
+ } else {
+ throw BmlError.otpFailed("Business OTP failed (HTTP \(resp.status))")
+ }
+ }
+
+ // MARK: - Token refresh
+
+ func refreshSession(_ session: BmlSession) async throws -> BmlSession {
+ var req = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!)
+ req.httpMethod = "POST"
+ req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
+ let fields = [
+ ("grant_type", "refresh_token"),
+ ("refresh_token", session.refreshToken),
+ ("client_id", BML_CLIENT_ID),
+ ("Device-ID", session.deviceId),
+ ("User-Agent", BML_APP_UA),
+ ("x-app-version", BML_APP_VERSION)
+ ]
+ req.httpBody = Data(fields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8)
+ req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+
+ let (data, _) = try await makeApiSession().data(for: req)
+ guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let newAccess = (obj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
+ throw BmlError.sessionExpired
+ }
+ let newRefresh = (obj["refresh_token"] as? String ?? "").isEmpty ? session.refreshToken : obj["refresh_token"] as! String
+ let expiresIn = obj["expires_in"] as? Int64 ?? 0
+ let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0
+ return BmlSession(accessToken: newAccess, deviceId: session.deviceId, refreshToken: newRefresh, expiresAt: expiresAt)
+ }
+
+ // MARK: - OAuth + account fetch
+
+ private func doOAuthAndFetchAccounts(
+ loginTag: String, profileName: String, profileId: String
+ ) async throws -> (BmlSession, [BankAccount]) {
+ // Device-ID, User-Agent (APP UA), x-app-version are query params — same as Android.
+ var comps = URLComponents(string: "\(BML_BASE_URL)/oauth/authorize")!
+ comps.queryItems = [
+ .init(name: "redirect_uri", value: BML_REDIRECT),
+ .init(name: "client_id", value: BML_CLIENT_ID),
+ .init(name: "response_type", value: "code"),
+ .init(name: "state", value: randomUrlSafe(16)),
+ .init(name: "nonce", value: randomUrlSafe(12)),
+ .init(name: "code_challenge", value: codeChallenge),
+ .init(name: "code_challenge_method", value: "S256"),
+ .init(name: "Device-ID", value: deviceId),
+ .init(name: "User-Agent", value: BML_APP_UA),
+ .init(name: "x-app-version", value: BML_APP_VERSION),
+ ]
+ // Sync any cookies captured from redirect responses into the session cookie storage.
+ // This covers blaze_identity and any other cookies that forceCookies() may have missed
+ // due to redirect responses being cancelled before URLSession auto-processes them.
+ if let storage = webSession.configuration.httpCookieStorage {
+ for (name, value) in redirectCookieStore where !name.hasPrefix("__REDIRECT__") {
+ if let cookie = HTTPCookie(properties: [
+ .name: name,
+ .value: value,
+ .domain: "www.bankofmaldives.com.mv",
+ .path: "/",
+ .secure: "TRUE"
+ ]) {
+ storage.setCookie(cookie)
+ }
+ }
+ }
+
+ // Build authorize request with all session cookies (now including redirect-captured ones).
+ var authorizeReq = makeWebRequest(url: comps.url!.absoluteString)
+ let allCookies = webSession.configuration.httpCookieStorage?.cookies ?? []
+ let cookieDict: [String: String] = Dictionary(
+ uniqueKeysWithValues: allCookies.map { ($0.name, $0.value) }
+ )
+ if !cookieDict.isEmpty {
+ authorizeReq.setValue(
+ cookieDict.map { "\($0.key)=\($0.value)" }.joined(separator: "; "),
+ forHTTPHeaderField: "Cookie"
+ )
+ authorizeReq.httpShouldHandleCookies = false
+ }
+ let sentCookieNames = cookieDict.keys.joined(separator: ",")
+ let redirectMarkers = redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.sorted()
+ .compactMap { redirectCookieStore[$0] }.joined(separator: "→")
+
+ let (_, authorizeRaw) = try await webSession.data(for: authorizeReq, delegate: noRedirect)
+ let authorizeHttp = authorizeRaw as! HTTPURLResponse
+ forceCookies(from: authorizeHttp)
+ let loc = authorizeHttp.value(forHTTPHeaderField: "Location") ?? ""
+
+ guard !loc.isEmpty,
+ let urlComps = URLComponents(string: loc),
+ let code = urlComps.queryItems?.first(where: { $0.name == "code" })?.value else {
+ let rStore = redirectCookieStore.keys.filter { !$0.hasPrefix("__REDIRECT__") }.sorted().joined(separator: ",")
+ throw BmlError.oauthFailed("OAuth authorize failed (HTTP \(authorizeHttp.statusCode), location: \(loc), cookies: [\(sentCookieNames)], redirectPaths: [\(redirectMarkers)], redirectStore: [\(rStore)])")
+ }
+
+ let tokenFields = [
+ ("Device-ID", deviceId),
+ ("code", code),
+ ("grant_type", "authorization_code"),
+ ("User-Agent", BML_APP_UA),
+ ("redirect_uri", BML_REDIRECT),
+ ("code_verifier", codeVerifier),
+ ("client_id", BML_CLIENT_ID),
+ ("x-app-version", BML_APP_VERSION)
+ ]
+ var tokenReq = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!)
+ tokenReq.httpMethod = "POST"
+ tokenReq.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
+ tokenReq.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ tokenReq.httpBody = Data(tokenFields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8)
+
+ let (tokenData, _) = try await webSession.data(for: tokenReq, delegate: noRedirect)
+ guard let tokenObj = try? JSONSerialization.jsonObject(with: tokenData) as? [String: Any],
+ let accessToken = (tokenObj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
+ throw BmlError.oauthFailed("Token exchange failed")
+ }
+
+ let refreshToken = tokenObj["refresh_token"] as? String ?? ""
+ let expiresIn = tokenObj["expires_in"] as? Int64 ?? 0
+ let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0
+
+ let session = BmlSession(accessToken: accessToken, deviceId: deviceId, refreshToken: refreshToken, expiresAt: expiresAt)
+ let client = BmlAccountClient(session: session)
+ let accounts = try await client.fetchAccounts(loginTag: loginTag, profileName: profileName, profileId: profileId)
+ return (session, accounts)
+ }
+
+ // MARK: - Business OTP channels
+
+ private func fetchBusinessOtpChannels() async throws -> [BmlOtpChannel] {
+ let resp = try await webGet("\(BML_BASE_URL)/web/profile/2fa/business")
+ return parseBusinessOtpChannels(resp.body)
+ }
+
+ // MARK: - Inertia.js parsing
+
+ private func extractInertiaJson(_ html: String) -> [String: Any]? {
+ guard let range = html.range(of: #"data-page="([^"]+)""#, options: .regularExpression) else { return nil }
+ var escaped = String(html[range])
+ escaped = escaped.replacingOccurrences(of: #"data-page=""#, with: "").dropLast().description
+ let unescaped = escaped
+ .replacingOccurrences(of: """, with: "\"")
+ .replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "'", with: "'")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+ return try? JSONSerialization.jsonObject(with: Data(unescaped.utf8)) as? [String: Any]
+ }
+
+ private func parseProfiles(_ html: String) -> [BmlProfile] {
+ guard let root = extractInertiaJson(html),
+ let props = root["props"] as? [String: Any],
+ let profiles = props["profiles"] as? [[String: Any]] else { return [] }
+ return profiles.compactMap { p in
+ guard let profileObj = p["profile"] as? [String: Any] else { return nil }
+ return BmlProfile(
+ profileId: p["profile_id"] as? String ?? "",
+ name: p["name"] as? String ?? "",
+ type: p["type"] as? String ?? "",
+ profileType: profileObj["profile_type"] as? String ?? "default",
+ autoActivated: false
+ )
+ }
+ }
+
+ private func parseBusinessOtpChannels(_ html: String) -> [BmlOtpChannel] {
+ guard let root = extractInertiaJson(html),
+ let props = root["props"] as? [String: Any],
+ let channels = props["channels"] as? [[String: Any]] else { return [] }
+ return channels.map { c in
+ BmlOtpChannel(
+ channel: c["channel"] as? String ?? "",
+ description: c["description"] as? String ?? "",
+ masked: c["masked"] as? String ?? ""
+ )
+ }
+ }
+
+ // MARK: - Cookie helpers
+
+ // Stores raw name=value pairs from redirect response cookies into redirectCookieStore.
+ // Uses direct string parsing of allHeaderFields to bypass HTTPCookie parsing quirks.
+ // Also records a __REDIRECT__{n} marker so we know if this was ever called at all.
+ private func captureRedirectCookies(from response: HTTPURLResponse) {
+ let path = response.url?.path ?? "nil"
+ let location = response.value(forHTTPHeaderField: "Location") ?? "?"
+ let markerKey = "__REDIRECT__\(redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.count)"
+ redirectCookieStore[markerKey] = "\(path)→\(location)" // source path → redirect destination
+
+ for (key, value) in response.allHeaderFields {
+ guard let k = key.base as? String,
+ k.lowercased() == "set-cookie",
+ let v = value as? String else { continue }
+ // URLSession may combine multiple Set-Cookie headers with "\n"
+ for cookieLine in v.components(separatedBy: "\n") {
+ guard let nameValuePart = cookieLine.split(separator: ";").first else { continue }
+ let parts = nameValuePart.split(separator: "=", maxSplits: 1)
+ guard parts.count == 2 else { continue }
+ let name = parts[0].trimmingCharacters(in: .whitespaces)
+ let val = String(parts[1]).trimmingCharacters(in: .whitespaces)
+ redirectCookieStore[name] = val
+ }
+ }
+ }
+
+ // After every response, force-write Set-Cookie headers into the session's ephemeral
+ // cookie storage. URLSession may skip automatic Set-Cookie processing for redirect
+ // responses when the redirect is cancelled via NoRedirectDelegate.completionHandler(nil).
+ //
+ // IMPORTANT: allHeaderFields is a [AnyHashable: Any] dict — if the server sends multiple
+ // Set-Cookie headers, URLSession joins them with "\n" into a single entry. Building a
+ // plain [String: String] and passing it to HTTPCookie.cookies() would only parse the
+ // first cookie. We split on "\n" and call HTTPCookie.cookies() once per line instead.
+ private func forceCookies(from response: HTTPURLResponse) {
+ guard let storage = webSession.configuration.httpCookieStorage,
+ let url = response.url else { return }
+ for (key, value) in response.allHeaderFields {
+ guard let k = key.base as? String,
+ k.caseInsensitiveCompare("set-cookie") == .orderedSame,
+ let v = value as? String else { continue }
+ for line in v.components(separatedBy: "\n") {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { continue }
+ let cookies = HTTPCookie.cookies(
+ withResponseHeaderFields: ["Set-Cookie": trimmed],
+ for: url
+ )
+ cookies.forEach { storage.setCookie($0) }
+ }
+ }
+ }
+
+ private func xsrfToken() -> String? {
+ guard let url = URL(string: BML_BASE_URL) else { return nil }
+ return webSession.configuration.httpCookieStorage?
+ .cookies(for: url)?.first { $0.name == "XSRF-TOKEN" }?.value
+ }
+
+ // MARK: - HTTP helpers
+
+ @discardableResult
+ private func webGet(_ urlString: String) async throws -> (status: Int, body: String, location: String) {
+ let req = makeWebRequest(url: urlString)
+ let (data, resp) = try await webSession.data(for: req, delegate: noRedirect)
+ let http = resp as! HTTPURLResponse
+ forceCookies(from: http)
+ return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "")
+ }
+
+ private func webPost(_ urlString: String, body: Data, xsrf: String) async throws -> (status: Int, body: String, location: String) {
+ var req = makeWebRequest(url: urlString)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN")
+ req.httpBody = body
+ let (data, resp) = try await webSession.data(for: req, delegate: noRedirect)
+ let http = resp as! HTTPURLResponse
+ forceCookies(from: http)
+ return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "")
+ }
+
+ private func makeWebRequest(url: String) -> URLRequest {
+ var req = URLRequest(url: URL(string: url)!)
+ req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
+ return req
+ }
+
+ private func jsonBody(_ dict: [String: String]) throws -> Data {
+ try JSONSerialization.data(withJSONObject: dict)
+ }
+
+ private func makeApiSession() -> URLSession {
+ let cfg = URLSessionConfiguration.default
+ cfg.timeoutIntervalForRequest = 30
+ return URLSession(configuration: cfg)
+ }
+
+ // MARK: - PKCE / crypto
+
+ private func generateCodeVerifier() -> String {
+ var bytes = [UInt8](repeating: 0, count: 72)
+ _ = SecRandomCopyBytes(kSecRandomDefault, 72, &bytes)
+ return Data(bytes).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ private func generateCodeChallenge(_ verifier: String) -> String {
+ let hash = SHA256.hash(data: Data(verifier.utf8))
+ return Data(hash).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ private func randomUrlSafe(_ byteCount: Int) -> String {
+ var bytes = [UInt8](repeating: 0, count: byteCount)
+ _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes)
+ return Data(bytes).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ private func generateDeviceId() -> String {
+ var bytes = [UInt8](repeating: 0, count: 8)
+ _ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes)
+ return bytes.map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/Thijooree iOS/API/BML/BmlModels.swift b/Thijooree iOS/API/BML/BmlModels.swift
new file mode 100644
index 0000000..7fc2862
--- /dev/null
+++ b/Thijooree iOS/API/BML/BmlModels.swift
@@ -0,0 +1,106 @@
+import Foundation
+
+struct BmlSession: Codable {
+ let accessToken: String
+ let deviceId: String
+ let refreshToken: String
+ let expiresAt: Int64 // Unix millis; 0 = unknown
+
+ var isExpired: Bool {
+ expiresAt > 0 && Int64(Date().timeIntervalSince1970 * 1000) >= expiresAt
+ }
+}
+
+struct BmlProfile: Codable, Identifiable {
+ let profileId: String
+ let name: String
+ let type: String // "Profile" or "Business"
+ let profileType: String // "default" or "business"
+ let autoActivated: Bool
+
+ var id: String { profileId }
+}
+
+struct BmlOtpChannel: Codable {
+ let channel: String
+ let description: String
+ let masked: String
+}
+
+enum BmlActivationResult {
+ case success(BmlSession, [BankAccount])
+ case needsBusinessOtp([BmlOtpChannel])
+}
+
+struct BmlUserInfo {
+ let fullName: String
+ let email: String
+ let mobile: String
+ let customerId: String
+ let idCard: String
+ let birthdate: String
+}
+
+struct BmlLoanDetail {
+ let loanAmount: Double
+ let outstandingAmt: Double
+ let repayAmount: Double
+ let intRate: Double
+ let loanStatus: String
+ let startDate: String
+ let endDate: String
+ let noOfRepayOverdue: Int
+ let overdueAmount: Double
+}
+
+struct BmlWalletToken {
+ let token: String
+ let expiry: String
+ let appCode: String
+ let serviceCode: String
+ let data: String
+ let validUntil: String
+}
+
+struct BmlForeignLimit: Codable {
+ let type: String
+ let used: Double
+ let totalLimit: Double
+ let generalCap: Double
+ let generalRemaining: Double
+ let medicalRemaining: Double
+ let isAtmEnabled: Bool
+ let isPosEnabled: Bool
+ let atmRemaining: Double
+ let atmLimit: Double
+ let ecomRemaining: Double
+ let ecomLimit: Double
+ let posRemaining: Double
+ let posLimit: Double
+}
+
+enum BmlError: LocalizedError {
+ case networkError(String)
+ case loginFailed(String)
+ case otpFailed(String)
+ case serverError(String)
+ case sessionExpired
+ case profileActivationFailed(String)
+ case oauthFailed(String)
+ case invalidResponse
+ case endpointNotFound
+
+ var errorDescription: String? {
+ switch self {
+ case .networkError(let msg): return "Network error: \(msg)"
+ case .loginFailed(let msg): return "Login failed: \(msg)"
+ case .otpFailed(let msg): return "OTP failed: \(msg)"
+ case .serverError(let msg): return msg
+ case .sessionExpired: return "Session expired."
+ case .profileActivationFailed(let msg): return "Profile activation failed: \(msg)"
+ case .oauthFailed(let msg): return "OAuth failed: \(msg)"
+ case .invalidResponse: return "Unexpected server response."
+ case .endpointNotFound: return "Endpoint not found."
+ }
+ }
+}
diff --git a/Thijooree iOS/API/BML/BmlTransferClient.swift b/Thijooree iOS/API/BML/BmlTransferClient.swift
new file mode 100644
index 0000000..4018c0b
--- /dev/null
+++ b/Thijooree iOS/API/BML/BmlTransferClient.swift
@@ -0,0 +1,238 @@
+import Foundation
+
+struct BmlAccountLookupResult: Sendable {
+ let accountNumber: String
+ let accountName: String
+ let bankName: String
+ let trnType: String // "IAT" BML-to-BML, "QTR" Favara, "DOT" other bank
+ let bank: String?
+}
+
+struct BmlTransferInit: Sendable {
+ let code: Int // 22 = OTP required
+}
+
+struct BmlTransferReceipt: Sendable {
+ let reference: String
+ let timestamp: String
+ let message: String
+}
+
+// BML 2-step transfer:
+// Step 1 — POST /api/mobile/transfer with transfer details (no otp).
+// Server responds {"success":true,"code":22} when OTP is required.
+// Step 2 — POST same endpoint adding otp field.
+// Server responds {"success":true,"payload":{"reference":...,"timestamp":...}}.
+//
+// Field names from Kotlin: debitAccount, creditAccount, debitAmount, transfertype, currency, channel, otp, remarks
+final class BmlTransferClient {
+
+ private let bmlSession: BmlSession
+ private let urlSession: URLSession
+
+ private let base = "https://www.bankofmaldives.com.mv/internetbanking"
+ private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
+ private let ver = "2.1.44.348"
+
+ init(bmlSession: BmlSession) {
+ self.bmlSession = bmlSession
+ let cfg = URLSessionConfiguration.default
+ cfg.timeoutIntervalForRequest = 30
+ self.urlSession = URLSession(configuration: cfg)
+ }
+
+ // MARK: - Account lookup
+
+ func lookupAccount(_ input: String) async throws -> BmlAccountLookupResult {
+ let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ if Self.isMibAccount(trimmed) {
+ return try await lookupMibAccount(trimmed)
+ }
+
+ // Kotlin uses /api/mobile/validate/account/ for BML/Favara validation.
+ let data = try await apiGet("/api/mobile/validate/account/\(Self.pathComponent(trimmed))")
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == true,
+ let payload = root["payload"] as? [String: Any] else {
+ throw BmlError.serverError("Account not found")
+ }
+ return Self.lookupResult(from: payload, fallbackAccount: trimmed, defaultType: "IAT", defaultBank: nil)
+ }
+
+ private func lookupMibAccount(_ account: String) async throws -> BmlAccountLookupResult {
+ // Kotlin BmlValidateClient verifies MIB accounts through the Favara account-verification endpoint.
+ let data = try await apiGet("/api/mobile/favara/account-verification/\(Self.pathComponent(account))/MIB")
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ root["success"] as? Bool == true else {
+ throw BmlError.serverError("Account not found")
+ }
+ let payload = root["payload"] as? [String: Any] ?? root
+ return Self.lookupResult(from: payload, fallbackAccount: account, defaultType: "DOT", defaultBank: "MIB")
+ }
+
+ private static func lookupResult(
+ from payload: [String: Any],
+ fallbackAccount: String,
+ defaultType: String,
+ defaultBank: String?
+ ) -> BmlAccountLookupResult {
+ let trnType = payload["trnType"] as? String
+ ?? payload["transferType"] as? String
+ ?? defaultType
+ let name = payload["name"] as? String
+ ?? payload["account_name"] as? String
+ ?? payload["accountName"] as? String
+ ?? payload["contact_name"] as? String
+ ?? payload["BfyNm"] as? String
+ ?? ""
+
+ let cdtr = payload["CdtrAcct"] as? [String: Any]
+ let account = cdtr?["Acct"] as? String
+ ?? payload["account"] as? String
+ ?? payload["accountNumber"] as? String
+ ?? payload["benefAccount"] as? String
+ ?? fallbackAccount
+ let bank = payload["bank"] as? String ?? defaultBank
+ let bankName = bank == "MIB" ? "Maldives Islamic Bank" : "Bank of Maldives"
+
+ return BmlAccountLookupResult(
+ accountNumber: account,
+ accountName: name,
+ bankName: bankName,
+ trnType: trnType,
+ bank: bank
+ )
+ }
+
+ private static func isMibAccount(_ value: String) -> Bool {
+ value.count == 17 && value.hasPrefix("9") && value.allSatisfy(\.isNumber)
+ }
+
+ private static func pathComponent(_ value: String) -> String {
+ value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value
+ }
+
+ // MARK: - Step 1: Initiate (no OTP)
+
+ func initiateTransfer(
+ fromAccount: BankAccount,
+ toAccount: String,
+ amount: Double,
+ currency: String,
+ trnType: String,
+ bank: String?
+ ) async throws -> BmlTransferInit {
+ let debitAccount = try debitAccountCode(fromAccount)
+ var body: [String: Any] = [
+ "debitAccount": debitAccount,
+ "creditAccount": toAccount,
+ "debitAmount": amount,
+ "transfertype": trnType,
+ "currency": currency,
+ "channel": "token"
+ ]
+ if let bank, !bank.isEmpty {
+ body["bank"] = bank
+ }
+
+ let data = try await apiPost("/api/mobile/transfer", body: body)
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw BmlError.invalidResponse
+ }
+ guard root["success"] as? Bool == true else {
+ let msg = root["message"] as? String ?? ""
+ let code = root["code"] as? Int
+ let extra = (root["payload"] as? [String: Any])?["message"] as? String ?? ""
+ let full = [msg, extra].filter { !$0.isEmpty }.joined(separator: " — ")
+ let suffix = code.map { " (code \($0))" } ?? ""
+ throw BmlError.serverError(full.isEmpty ? "Transfer failed\(suffix)" : "\(full)\(suffix)")
+ }
+ return BmlTransferInit(code: root["code"] as? Int ?? 0)
+ }
+
+ // MARK: - Step 2: Confirm with OTP
+
+ func confirmTransfer(
+ fromAccount: BankAccount,
+ toAccount: String,
+ amount: Double,
+ currency: String,
+ trnType: String,
+ remarks: String,
+ otp: String,
+ bank: String?
+ ) async throws -> BmlTransferReceipt {
+ let debitAccount = try debitAccountCode(fromAccount)
+ var body: [String: Any] = [
+ "debitAccount": debitAccount,
+ "creditAccount": toAccount,
+ "debitAmount": amount,
+ "transfertype": trnType,
+ "currency": currency,
+ "channel": "token",
+ "otp": otp
+ ]
+ if !remarks.isEmpty {
+ body["remarks"] = remarks
+ }
+ if let bank, !bank.isEmpty {
+ body["bank"] = bank
+ }
+
+ let data = try await apiPost("/api/mobile/transfer", body: body)
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw BmlError.invalidResponse
+ }
+ guard root["success"] as? Bool == true else {
+ let msg = root["message"] as? String ?? ""
+ let code = root["code"] as? Int
+ let extra = (root["payload"] as? [String: Any])?["message"] as? String ?? ""
+ let full = [msg, extra].filter { !$0.isEmpty }.joined(separator: " — ")
+ let suffix = code.map { " (code \($0))" } ?? ""
+ throw BmlError.serverError(full.isEmpty ? "Transfer confirmation failed\(suffix)" : "\(full)\(suffix)")
+ }
+ let payload = root["payload"] as? [String: Any] ?? [:]
+ return BmlTransferReceipt(
+ reference: payload["reference"] as? String ?? "",
+ timestamp: payload["timestamp"] as? String ?? "",
+ message: root["message"] as? String ?? "Transfer successful"
+ )
+ }
+
+ private func debitAccountCode(_ account: BankAccount) throws -> String {
+ let code = (account.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !code.isEmpty else {
+ throw BmlError.serverError("BML debit account id missing. Refresh dashboard and select the account again.")
+ }
+ return code
+ }
+
+ // MARK: - HTTP
+
+ private func apiGet(_ path: String) async throws -> Data {
+ var req = URLRequest(url: URL(string: "\(base)\(path)")!)
+ req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
+ req.setValue(ua, forHTTPHeaderField: "User-Agent")
+ req.setValue(ver, forHTTPHeaderField: "x-app-version")
+ let (data, resp) = try await urlSession.data(for: req)
+ let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
+ if code == 401 || code == 419 { throw BmlError.sessionExpired }
+ if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
+ return data
+ }
+
+ private func apiPost(_ path: String, body: [String: Any]) async throws -> Data {
+ var req = URLRequest(url: URL(string: "\(base)\(path)")!)
+ req.httpMethod = "POST"
+ req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
+ req.setValue(ua, forHTTPHeaderField: "User-Agent")
+ req.setValue(ver, forHTTPHeaderField: "x-app-version")
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ let (data, resp) = try await urlSession.data(for: req)
+ let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
+ if code == 401 || code == 419 { throw BmlError.sessionExpired }
+ if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
+ return data
+ }
+}
diff --git a/Thijooree iOS/API/Fahipay/FahipayLoginFlow.swift b/Thijooree iOS/API/Fahipay/FahipayLoginFlow.swift
new file mode 100644
index 0000000..c4ad2e6
--- /dev/null
+++ b/Thijooree iOS/API/Fahipay/FahipayLoginFlow.swift
@@ -0,0 +1,173 @@
+import Foundation
+
+final class FahipayLoginFlow {
+
+ private let baseURL = "https://fahipay.mv"
+ private let webUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
+
+ private let cookieStorage = HTTPCookieStorage()
+ private lazy var urlSession: URLSession = {
+ let cfg = URLSessionConfiguration.default
+ cfg.httpCookieStorage = cookieStorage
+ cfg.timeoutIntervalForRequest = 30
+ cfg.httpShouldSetCookies = true
+ return URLSession(configuration: cfg)
+ }()
+
+ // MARK: - Session cookie management
+
+ func setSessionCookie(_ value: String) {
+ guard let url = URL(string: baseURL),
+ let cookie = HTTPCookie(properties: [
+ .name: "__Secure-sess",
+ .value: value,
+ .domain: "fahipay.mv",
+ .path: "/",
+ .secure: "TRUE"
+ ]) else { return }
+ cookieStorage.setCookie(cookie)
+ }
+
+ func getSessionCookieValue() -> String? {
+ cookieStorage.cookies?.first { $0.name == "__Secure-sess" }?.value
+ }
+
+ // MARK: - Login
+
+ /**
+ * Step 1: POST /api/app/login/
+ * Returns FahipayLoginStep:
+ * twoFactorRequired = false + authId set → login complete
+ * twoFactorRequired = true + authId nil → call verifyTotp() next
+ */
+ func login(idCard: String, password: String, deviceUuid: String) async throws -> FahipayLoginStep {
+ try await initSession()
+
+ let parts: [(String, String)] = [
+ ("email", idCard),
+ ("password", password),
+ ("grant_type", "auth_id"),
+ ("lang", "en"),
+ ("version", "2.0.0"),
+ ("platform", "BasedBank")
+ ] + deviceParts(deviceUuid)
+
+ var req = URLRequest(url: URL(string: "\(baseURL)/api/app/login/")!)
+ req.httpMethod = "POST"
+ req.setValue(webUA, forHTTPHeaderField: "User-Agent")
+ req.setValue("application/json", forHTTPHeaderField: "accept")
+ let (contentType, body) = buildMultipartBody(parts: parts)
+ req.setValue(contentType, forHTTPHeaderField: "Content-Type")
+ req.httpBody = body
+
+ let (data, _) = try await urlSession.data(for: req)
+ guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw FahipayError.invalidResponse
+ }
+ guard obj["type"] as? String == "success" else {
+ throw FahipayError.loginFailed(obj["msg"] as? String ?? "Login failed — check your ID card and password")
+ }
+
+ let authId = (obj["authID"] as? String).flatMap { $0.isEmpty ? nil : $0 }
+ let twoFa = obj["two_factor_required"] as? Bool ?? false
+ return FahipayLoginStep(twoFactorRequired: twoFa, authId: authId)
+ }
+
+ // MARK: - TOTP verification
+
+ /**
+ * Step 2 (if 2FA required): POST /api/app/otp/
+ * Returns authId on success.
+ */
+ func verifyTotp(code: String, deviceUuid: String) async throws -> String {
+ let parts: [(String, String)] = [
+ ("code", code),
+ ("channel", "totp"),
+ ("action", "login"),
+ ("grant_type", "auth_id"),
+ ("lang", "en"),
+ ("version", "2.0.0"),
+ ("platform", "BasedBank")
+ ] + deviceParts(deviceUuid)
+
+ var req = URLRequest(url: URL(string: "\(baseURL)/api/app/otp/")!)
+ req.httpMethod = "POST"
+ req.setValue(webUA, forHTTPHeaderField: "User-Agent")
+ req.setValue("application/json", forHTTPHeaderField: "accept")
+ let (contentType, body) = buildMultipartBody(parts: parts)
+ req.setValue(contentType, forHTTPHeaderField: "Content-Type")
+ req.httpBody = body
+
+ let (data, _) = try await urlSession.data(for: req)
+ guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw FahipayError.invalidResponse
+ }
+ guard obj["type"] as? String == "success" else {
+ throw FahipayError.otpFailed(obj["msg"] as? String ?? "OTP verification failed")
+ }
+ guard let authId = (obj["authID"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
+ throw FahipayError.otpFailed("No authID in OTP response")
+ }
+ return authId
+ }
+
+ // MARK: - Session init
+
+ // Establishes the __Secure-sess cookie required for the login + OTP flow
+ private func initSession() async throws {
+ var req = URLRequest(url: URL(string: "\(baseURL)/api/app/lang/data/")!)
+ req.setValue(webUA, forHTTPHeaderField: "User-Agent")
+ _ = try await urlSession.data(for: req)
+ }
+
+ // MARK: - Device info
+
+ private func deviceParts(_ uuid: String) -> [(String, String)] {
+ [
+ ("device[available]", "true"),
+ ("device[platform]", "iOS"),
+ ("device[uuid]", uuid),
+ ("device[model]", "iPhone"),
+ ("device[manufacturer]", "Apple"),
+ ("device[isVirtual]", "false"),
+ ("device[serial]", "unknown")
+ ]
+ }
+
+ // MARK: - Multipart body with lowercase content-disposition headers (Fahipay requirement)
+
+ private func buildMultipartBody(parts: [(String, String)]) -> (contentType: String, body: Data) {
+ let boundary = UUID().uuidString
+ var data = Data()
+
+ for (name, value) in parts {
+ let valueBytes = Data(value.utf8)
+ data += "--\(boundary)\r\n".utf8Data
+ // Fahipay requires lowercase "content-disposition", not standard "Content-Disposition"
+ data += "content-disposition: form-data; name=\"\(name)\"\r\n".utf8Data
+ data += "Content-Length: \(valueBytes.count)\r\n".utf8Data
+ data += "\r\n".utf8Data
+ data += valueBytes
+ data += "\r\n".utf8Data
+ }
+ data += "--\(boundary)--\r\n".utf8Data
+
+ return ("multipart/form-data; boundary=\(boundary)", data)
+ }
+
+ // MARK: - Device UUID generation
+
+ static func generateDeviceUuid() -> String {
+ var bytes = [UInt8](repeating: 0, count: 8)
+ _ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes)
+ return bytes.map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+private extension String {
+ var utf8Data: Data { Data(utf8) }
+}
+
+private extension Data {
+ static func += (lhs: inout Data, rhs: Data) { lhs.append(rhs) }
+}
diff --git a/Thijooree iOS/API/Fahipay/FahipayModels.swift b/Thijooree iOS/API/Fahipay/FahipayModels.swift
new file mode 100644
index 0000000..79e1c93
--- /dev/null
+++ b/Thijooree iOS/API/Fahipay/FahipayModels.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+struct FahipaySession: Codable {
+ let authId: String
+ let sessionCookie: String
+}
+
+struct FahipayLoginStep {
+ let twoFactorRequired: Bool
+ let authId: String?
+}
+
+struct FahipayContactGroup: Codable, Identifiable {
+ let id: String
+ let name: String
+ let contacts: [BankContact]
+}
+
+enum FahipayError: LocalizedError {
+ case networkError(String)
+ case loginFailed(String)
+ case otpFailed(String)
+ case sessionExpired
+ case invalidResponse
+
+ var errorDescription: String? {
+ switch self {
+ case .networkError(let msg): return "Network error: \(msg)"
+ case .loginFailed(let msg): return "Login failed: \(msg)"
+ case .otpFailed(let msg): return "OTP verification failed: \(msg)"
+ case .sessionExpired: return "Session expired."
+ case .invalidResponse: return "Unexpected server response."
+ }
+ }
+}
diff --git a/Thijooree iOS/API/MIB/MibContactsClient.swift b/Thijooree iOS/API/MIB/MibContactsClient.swift
new file mode 100644
index 0000000..0b6dff4
--- /dev/null
+++ b/Thijooree iOS/API/MIB/MibContactsClient.swift
@@ -0,0 +1,137 @@
+import Foundation
+
+// Mirrors the Kotlin MibContactsClient exactly.
+// Endpoint: POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
+// Auth: Cookie header with xxid, IBSID, mbnonce, mbmodel (same session established by MibLoginFlow).
+actor MibContactsClient {
+
+ private let wvBase = "https://faisamobilex-wv.mib.com.mv"
+
+ private let urlSession: URLSession = {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.timeoutIntervalForRequest = 30
+ cfg.timeoutIntervalForResource = 60
+ cfg.httpCookieAcceptPolicy = .never
+ cfg.httpShouldSetCookies = false
+ return URLSession(configuration: cfg)
+ }()
+
+ private let session: MibSession
+
+ init(session: MibSession) {
+ self.session = session
+ }
+
+ // MARK: - Public
+
+ func fetchContacts(loginTag: String) async throws -> [BankContact] {
+ var all: [BankContact] = []
+ var page = 1
+ let pageSize = 100
+
+ while true {
+ let start = (page - 1) * pageSize + 1
+ let end = page * pageSize
+
+ let formFields: [(String, String)] = [
+ ("page", String(page)),
+ ("search", ""),
+ ("searchCategoryId", "0"),
+ ("benefType", "A"),
+ ("sortBenef", "name"),
+ ("sortDir", "asc"),
+ ("start", String(start)),
+ ("end", String(end)),
+ ("includeCount", "1"),
+ ]
+
+ let (contacts, totalCount) = try await fetchPage(formFields: formFields, loginTag: loginTag)
+ all.append(contentsOf: contacts)
+ if all.count >= totalCount || contacts.isEmpty { break }
+ page += 1
+ }
+ return all
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(formFields: [(String, String)], loginTag: String) async throws -> ([BankContact], Int) {
+ let url = URL(string: "\(wvBase)/ajaxBeneficiary/main")!
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue(
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
+ forHTTPHeaderField: "User-Agent"
+ )
+ request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
+ request.setValue("*/*", forHTTPHeaderField: "Accept")
+ request.setValue(wvBase, forHTTPHeaderField: "Origin")
+ request.setValue("\(wvBase)/beneficiary?dashurl=1", forHTTPHeaderField: "Referer")
+
+ var allowed = CharacterSet.alphanumerics
+ allowed.insert(charactersIn: "-._~")
+ let body = formFields.map { k, v in
+ "\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
+ }.joined(separator: "&")
+ request.httpBody = Data(body.utf8)
+
+ let (data, response) = try await urlSession.data(for: request)
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
+ if code == 419 { throw MibError.sessionExpired }
+ if code >= 500 { throw MibError.networkError("HTTP \(code)") }
+
+ guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw MibError.invalidResponse
+ }
+
+ let totalCount = obj["total_count"] as? Int
+ ?? Int(obj["total_count"] as? String ?? "") ?? 0
+
+ guard let arr = obj["data"] as? [[String: Any]] else {
+ return ([], totalCount)
+ }
+
+ let contacts = arr.compactMap { d -> BankContact? in
+ let account = d["benefAccount"] as? String ?? ""
+ guard !account.isEmpty else { return nil }
+
+ let benefNo = d["benefNo"] as? String ?? account
+ // Android: benefType "I"=Internal(MIB), "L"=Local(IPS), "S"=Swift
+ let rawType = d["benefType"] as? String ?? "I"
+ let benefType: String
+ switch rawType {
+ case "I": benefType = "MIB"
+ case "L": benefType = "LOCAL"
+ case "S": benefType = "SWIFT"
+ default: benefType = rawType
+ }
+
+ return BankContact(
+ id: "MIB_\(benefNo)_\(loginTag)",
+ benefNo: benefNo,
+ benefName: d["benefName"] as? String ?? "",
+ benefNickName: (d["benefNickName"] as? String).flatMap { $0.isEmpty ? nil : $0 },
+ benefAccount: account,
+ benefType: benefType,
+ bankColor: d["bankColor"] as? String,
+ benefBankName: d["benefBankName"] as? String,
+ bankCode: d["bankCode"] as? String,
+ benefStatus: d["benefStatus"] as? String ?? "Active",
+ transferCyDesc: d["transferCyDesc"] as? String ?? "MVR",
+ customerImgHash: (d["customerImgHash"] as? String).flatMap { $0.isEmpty || $0 == "null" ? nil : $0 },
+ benefCategoryId: d["benefCategoryID"] as? String,
+ profileId: nil,
+ source: "MIB"
+ )
+ }
+
+ return (contacts, totalCount)
+ }
+
+ private var cookieHeader: String {
+ "mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
+ }
+}
diff --git a/Thijooree iOS/API/MIB/MibCrypto.swift b/Thijooree iOS/API/MIB/MibCrypto.swift
new file mode 100644
index 0000000..0b086fc
--- /dev/null
+++ b/Thijooree iOS/API/MIB/MibCrypto.swift
@@ -0,0 +1,289 @@
+import CommonCrypto
+import CryptoKit
+import Foundation
+
+// MARK: - BigUInt
+
+struct BigUInt {
+ private(set) var words: [UInt32] // little-endian (index 0 = least significant)
+
+ static let zero = BigUInt(words: [0])
+ static let one = BigUInt(words: [1])
+
+ init(words: [UInt32]) {
+ self.words = words
+ normalize()
+ }
+
+ init(decimal string: String) {
+ var result = BigUInt.zero
+ let ten = BigUInt(words: [10])
+ for ch in string {
+ guard let d = ch.wholeNumberValue else { continue }
+ result = result.multiplied(by: ten)
+ result = result.adding(BigUInt(words: [UInt32(d)]))
+ }
+ self = result
+ }
+
+ private mutating func normalize() {
+ while words.count > 1 && words.last == 0 { words.removeLast() }
+ }
+
+ var isZero: Bool { words.count == 1 && words[0] == 0 }
+ var isOdd: Bool { words[0] & 1 == 1 }
+
+ var bitWidth: Int {
+ let n = words.count
+ guard n > 0 else { return 0 }
+ let top = words[n - 1]
+ if top == 0 { return max(0, (n - 1) * 32) }
+ return (n - 1) * 32 + (32 - top.leadingZeroBitCount)
+ }
+
+ func bit(_ index: Int) -> Bool {
+ let word = index / 32
+ let bit = index % 32
+ guard word < words.count else { return false }
+ return (words[word] >> bit) & 1 == 1
+ }
+
+ // MARK: Comparison
+
+ static func < (lhs: BigUInt, rhs: BigUInt) -> Bool {
+ if lhs.words.count != rhs.words.count {
+ return lhs.words.count < rhs.words.count
+ }
+ for i in stride(from: lhs.words.count - 1, through: 0, by: -1) {
+ if lhs.words[i] != rhs.words[i] { return lhs.words[i] < rhs.words[i] }
+ }
+ return false
+ }
+
+ static func == (lhs: BigUInt, rhs: BigUInt) -> Bool { lhs.words == rhs.words }
+
+ // MARK: Addition
+
+ func adding(_ other: BigUInt) -> BigUInt {
+ let maxLen = max(words.count, other.words.count)
+ var result = [UInt32](repeating: 0, count: maxLen + 1)
+ var carry: UInt64 = 0
+ for i in 0..> 32
+ }
+ result[maxLen] = UInt32(carry)
+ return BigUInt(words: result)
+ }
+
+ // MARK: Subtraction (self >= other assumed)
+
+ func subtracting(_ other: BigUInt) -> BigUInt {
+ var result = words
+ var borrow: Int64 = 0
+ for i in 0.. BigUInt {
+ let n = words.count
+ let m = other.words.count
+ var result = [UInt32](repeating: 0, count: n + m)
+ for i in 0..> 32
+ }
+ result[i + m] += UInt32(carry)
+ }
+ return BigUInt(words: result)
+ }
+
+ // MARK: Modulo (binary shift-subtract)
+
+ func modulo(_ divisor: BigUInt) -> BigUInt {
+ if divisor.isZero { return .zero }
+ if self < divisor { return self }
+
+ var remainder = BigUInt.zero
+ let totalBits = bitWidth
+
+ for i in stride(from: totalBits - 1, through: 0, by: -1) {
+ remainder = remainder.shiftedLeft1()
+ if bit(i) {
+ remainder.words[0] |= 1
+ }
+ if !(remainder < divisor) {
+ remainder = remainder.subtracting(divisor)
+ }
+ }
+ return remainder
+ }
+
+ private func shiftedLeft1() -> BigUInt {
+ var result = [UInt32](repeating: 0, count: words.count + 1)
+ var carry: UInt32 = 0
+ for i in 0..> 32)
+ }
+ result[words.count] = carry
+ return BigUInt(words: result)
+ }
+
+ // MARK: ModPow — right-to-left binary
+
+ func modPow(exp: BigUInt, mod: BigUInt) -> BigUInt {
+ if mod == .one { return .zero }
+ var result = BigUInt.one
+ var base = modulo(mod)
+ var e = exp
+
+ while !e.isZero {
+ if e.isOdd {
+ result = result.multiplied(by: base).modulo(mod)
+ }
+ e = e.shiftedRight1()
+ base = base.multiplied(by: base).modulo(mod)
+ }
+ return result
+ }
+
+ private func shiftedRight1() -> BigUInt {
+ var result = words
+ var borrow: UInt32 = 0
+ for i in stride(from: result.count - 1, through: 0, by: -1) {
+ let new = (result[i] >> 1) | (borrow << 31)
+ borrow = result[i] & 1
+ result[i] = new
+ }
+ return BigUInt(words: result)
+ }
+
+ // MARK: Decimal String (O(n) per digit via UInt64 carry)
+
+ var decimalString: String {
+ if isZero { return "0" }
+ var digits: [Character] = []
+ var remaining = words
+
+ while !(remaining.count == 1 && remaining[0] == 0) {
+ var rem: UInt64 = 0
+ for i in stride(from: remaining.count - 1, through: 0, by: -1) {
+ let cur = (rem << 32) | UInt64(remaining[i])
+ remaining[i] = UInt32(cur / 10)
+ rem = cur % 10
+ }
+ digits.append(Character(String(rem)))
+ while remaining.count > 1 && remaining.last == 0 { remaining.removeLast() }
+ }
+ return String(digits.reversed())
+ }
+}
+
+// MARK: - MibCrypto
+
+enum MibCrypto {
+ static let defaultKey = "8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678"
+
+ // DH exponent A
+ private static let dhA = BigUInt(decimal: "1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577")
+
+ // DH modulus P
+ private static let dhP = BigUInt(decimal: "2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919")
+
+ // Pre-computed: 2^A mod P (sent to server in every DH key exchange)
+ static let cmod = "2301533261465719294935473752300816828871347570489984010719302185643154930578030858802510266223676458248149736055131586081546441805822467879511077637292661430926459449504165353261883687521672133746481188567585259995381323120449147180627189549882525129015625540437713248150022924125681681822594907090716721855161928482983925605401801685462643917325004120294159320617793870605573541358655167992228254033496751384810855107557385690184061912872019448968632814193704749"
+
+ // Derives session key: Base64(SHA256(smod^A mod P as decimal string))
+ static func deriveSessionKey(_ smod: String) -> String {
+ let smodInt = BigUInt(decimal: smod)
+ let shared = smodInt.modPow(exp: dhA, mod: dhP)
+ let secretStr = shared.decimalString
+ let sha = SHA256.hash(data: Data(secretStr.utf8))
+ return Data(sha).base64EncodedString()
+ }
+
+ // MARK: Blowfish/ECB/PKCS7 Encrypt
+
+ static func encrypt(_ json: [String: Any], key: String) throws -> String {
+ guard let jsonData = try? JSONSerialization.data(withJSONObject: json) else {
+ throw MibError.encryptionFailed
+ }
+ let keyBytes = key.data(using: .isoLatin1) ?? Data(key.utf8)
+ let encrypted = try blowfishProcess(data: jsonData, key: keyBytes, operation: CCOperation(kCCEncrypt))
+ return encrypted.base64EncodedString()
+ }
+
+ static func decrypt(_ base64: String, key: String) throws -> [String: Any] {
+ guard let cipherData = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else {
+ throw MibError.decryptionFailed
+ }
+ let keyBytes = key.data(using: .isoLatin1) ?? Data(key.utf8)
+ let plainData = try blowfishProcess(data: cipherData, key: keyBytes, operation: CCOperation(kCCDecrypt))
+ guard let json = try? JSONSerialization.jsonObject(with: plainData) as? [String: Any] else {
+ throw MibError.decryptionFailed
+ }
+ return json
+ }
+
+ private static func blowfishProcess(data: Data, key: Data, operation: CCOperation) throws -> Data {
+ var outLen = 0
+ let bufLen = data.count + kCCBlockSizeBlowfish
+ var outBuf = [UInt8](repeating: 0, count: bufLen)
+
+ let status = data.withUnsafeBytes { dataPtr in
+ key.withUnsafeBytes { keyPtr in
+ CCCrypt(
+ operation,
+ CCAlgorithm(kCCAlgorithmBlowfish),
+ CCOptions(kCCOptionECBMode | kCCOptionPKCS7Padding),
+ keyPtr.baseAddress, key.count,
+ nil,
+ dataPtr.baseAddress, data.count,
+ &outBuf, bufLen,
+ &outLen
+ )
+ }
+ }
+ guard status == kCCSuccess else {
+ throw operation == CCOperation(kCCEncrypt) ? MibError.encryptionFailed : MibError.decryptionFailed
+ }
+ return Data(outBuf[0.. String {
+ sha256Upper(password)
+ }
+
+ static func sha256Upper(_ input: String) -> String {
+ let hash = SHA256.hash(data: Data(input.utf8))
+ return hash.map { String(format: "%02X", $0) }.joined()
+ }
+
+ // pgf03 = SHA256(clientSalt + SHA256(passwordHash + userSalt))
+ static func computePgf03(passwordHash: String, userSalt: String, clientSalt: String) -> String {
+ let inner = sha256Upper(passwordHash + userSalt)
+ return sha256Upper(clientSalt + inner)
+ }
+}
diff --git a/Thijooree iOS/API/MIB/MibFinancingClient.swift b/Thijooree iOS/API/MIB/MibFinancingClient.swift
new file mode 100644
index 0000000..823890a
--- /dev/null
+++ b/Thijooree iOS/API/MIB/MibFinancingClient.swift
@@ -0,0 +1,80 @@
+import Foundation
+
+// Mirrors Kotlin MibFinancingClient exactly.
+// Fetches the /financing?dashurl=1 HTML page and parses finance-card-holder data attributes.
+actor MibFinancingClient {
+
+ private let wvBase = "https://faisamobilex-wv.mib.com.mv"
+ private let session: MibSession
+
+ private let urlSession: URLSession = {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.timeoutIntervalForRequest = 30
+ cfg.timeoutIntervalForResource = 60
+ return URLSession(configuration: cfg)
+ }()
+
+ init(session: MibSession) {
+ self.session = session
+ }
+
+ func fetchFinancing() async throws -> [MibFinanceDeal] {
+ var req = URLRequest(url: URL(string: "\(wvBase)/financing?dashurl=1")!)
+ req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ req.setValue(
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
+ forHTTPHeaderField: "User-Agent"
+ )
+ req.setValue("mv.com.mib.faisamobilex", forHTTPHeaderField: "X-Requested-With")
+
+ let (data, response) = try await urlSession.data(for: req)
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
+ if code == 419 { throw MibError.sessionExpired }
+ if code >= 500 { throw MibError.networkError("HTTP \(code)") }
+
+ guard let html = String(data: data, encoding: .utf8) else { return [] }
+ return parseHtml(html)
+ }
+
+ // MARK: - HTML parsing (mirrors Kotlin regex approach)
+
+ private func parseHtml(_ html: String) -> [MibFinanceDeal] {
+ guard let cardRe = try? NSRegularExpression(pattern: #"finance-card-holder[^>]+>"#),
+ let attrRe = try? NSRegularExpression(pattern: #"data-(\w+)\s*=\s*"([^"]*)""#) else {
+ return []
+ }
+
+ let ns = html as NSString
+ return cardRe.matches(in: html, range: NSRange(location: 0, length: ns.length)).compactMap { m in
+ let cardStr = ns.substring(with: m.range)
+ let cardNS = cardStr as NSString
+ var attrs: [String: String] = [:]
+ attrRe.matches(in: cardStr, range: NSRange(location: 0, length: cardNS.length)).forEach { a in
+ let key = cardNS.substring(with: a.range(at: 1))
+ let val = cardNS.substring(with: a.range(at: 2))
+ attrs[key] = val
+ }
+ guard let dealNo = attrs["dealNo"] else { return nil }
+ return MibFinanceDeal(
+ dealNo: dealNo,
+ productDesc: attrs["productDesc"] ?? "",
+ dealStatus: attrs["dealStatus"] ?? "",
+ statusDesc: attrs["statusDesc"] ?? "",
+ dealAmount: Double(attrs["dealAmount"] ?? "") ?? 0,
+ paidAmount: Double(attrs["paidAmount"] ?? "") ?? 0,
+ outstandingAmount: Double(attrs["outstandingAmount"] ?? "") ?? 0,
+ dealDate: attrs["dealDate"] ?? "",
+ overdueAmount: Double(attrs["overdueAmount"] ?? "") ?? 0,
+ installmentAmount: Double(attrs["installmentAmount"] ?? "") ?? 0,
+ noOfInstallments: Int(attrs["noOfInstallments"] ?? "") ?? 0,
+ lastPaidDate: attrs["lastPaidDate"] ?? "",
+ lastPayAmount: Double(attrs["lastPayAmount"] ?? "") ?? 0,
+ currency: attrs["curCodeDesc"] ?? "MVR"
+ )
+ }
+ }
+
+ private var cookieHeader: String {
+ "mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
+ }
+}
diff --git a/Thijooree iOS/API/MIB/MibLoginFlow.swift b/Thijooree iOS/API/MIB/MibLoginFlow.swift
new file mode 100644
index 0000000..1850456
--- /dev/null
+++ b/Thijooree iOS/API/MIB/MibLoginFlow.swift
@@ -0,0 +1,416 @@
+import Foundation
+
+private struct SessionExpiredError: Error {}
+
+actor MibLoginFlow {
+
+ private let baseURL = URL(string: "https://faisanet.mib.com.mv/faisamobilex_smvc/")!
+
+ private(set) var lastSession: MibSession?
+ private(set) var lastProfiles: [MibProfile] = []
+ var onSessionRefreshed: (@Sendable (MibSession, [MibProfile]) -> Void)?
+
+ private var loginId: String = ""
+ private var storedUsername: String?
+ private var storedPasswordHash: String?
+ private var storedOtpSeed: String?
+ private var isRelogging = false
+
+ private let urlSession: URLSession = {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.timeoutIntervalForRequest = 30
+ cfg.timeoutIntervalForResource = 60
+ cfg.httpCookieAcceptPolicy = .never
+ cfg.httpShouldSetCookies = false
+ return URLSession(configuration: cfg)
+ }()
+
+ // MARK: - Public entry point
+
+ func login(username: String, passwordHash: String, otpSeed: String) async throws -> [BankAccount] {
+ loginId = username
+ storedUsername = username
+ storedPasswordHash = passwordHash
+ storedOtpSeed = otpSeed
+
+ let appId = getOrCreateAppId(for: username)
+ let key1 = credentialString("mib_\(username)_enc_key1")
+ let key2 = credentialString("mib_\(username)_enc_key2")
+
+ if let k1 = key1, let k2 = key2 {
+ return try await regularLogin(username: username, passwordHash: passwordHash, appId: appId, key1: k1, key2: k2)
+ } else {
+ return try await firstTimeRegistration(username: username, passwordHash: passwordHash, otpSeed: otpSeed, appId: appId)
+ }
+ }
+
+ // MARK: - First-time registration
+
+ private func firstTimeRegistration(
+ username: String, passwordHash: String, otpSeed: String, appId: String
+ ) async throws -> [BankAccount] {
+ let (session1, _) = try await initialKeyExchange(appId: appId, encKey: MibCrypto.defaultKey, sfunc: "r")
+
+ let userSalt = try await getAuthType(session: session1, username: username)
+ let clientSalt = MibNonce.randomAlpha(32)
+ let pgf03 = MibCrypto.computePgf03(passwordHash: passwordHash, userSalt: userSalt, clientSalt: clientSalt)
+
+ var regPayload = baseData(session: session1, routePath: "C41")
+ regPayload["uname"] = username
+ regPayload["pgf03"] = pgf03
+ regPayload["clientSalt"] = clientSalt
+
+ let regResp = try await doRequest(session: session1, data: regPayload, sfunc: "n")
+ guard regResp["success"] as? Bool == true else {
+ throw MibError.serverError(regResp["reasonText"] as? String ?? "Registration init failed")
+ }
+
+ let otp = Totp.generate(otpSeed)
+ var otpPayload = baseData(session: session1, routePath: "C42")
+ otpPayload["otp"] = otp
+ otpPayload["uname"] = username
+ otpPayload["otpType"] = "3"
+
+ let otpResp = try await doRequest(session: session1, data: otpPayload, sfunc: "n")
+ guard otpResp["success"] as? Bool == true else {
+ throw MibError.serverError(otpResp["reasonText"] as? String ?? "OTP verification failed")
+ }
+
+ guard let dataArr = otpResp["data"] as? [[String: Any]],
+ let keyData = dataArr.first,
+ let key1 = keyData["key1"] as? String,
+ let key2 = keyData["key2"] as? String else {
+ throw MibError.invalidResponse
+ }
+
+ try? CredentialStore.shared.save(key1, forKey: "mib_\(username)_enc_key1")
+ try? CredentialStore.shared.save(key2, forKey: "mib_\(username)_enc_key2")
+
+ return try await regularLogin(username: username, passwordHash: passwordHash, appId: appId, key1: key1, key2: key2)
+ }
+
+ // MARK: - Regular login
+
+ private func regularLogin(
+ username: String, passwordHash: String,
+ appId: String, key1: String, key2: String
+ ) async throws -> [BankAccount] {
+ let (session2, _) = try await initialKeyExchange(appId: appId, encKey: key1, sfunc: "i", key2: key2)
+
+ let userSalt = try await getAuthType(session: session2, username: username)
+ let clientSalt = MibNonce.randomAlpha(32)
+ let pgf03 = MibCrypto.computePgf03(passwordHash: passwordHash, userSalt: userSalt, clientSalt: clientSalt)
+
+ var loginPayload = baseData(session: session2, routePath: "A41")
+ loginPayload["uname"] = username
+ loginPayload["pgf03"] = pgf03
+ loginPayload["clientSalt"] = clientSalt
+ loginPayload["pmodTime"] = 0
+ loginPayload["requireBankData"] = 1
+
+ let loginResp = try await doRequest(session: session2, data: loginPayload, sfunc: "n")
+ guard loginResp["success"] as? Bool == true else {
+ throw MibError.serverError(loginResp["reasonText"] as? String ?? "Login failed")
+ }
+
+ let profiles = parseProfiles(from: loginResp)
+ lastSession = session2
+ lastProfiles = profiles
+
+ // Single-profile fast path: server returned balances directly in A41
+ if loginResp["profileSelected"] as? Bool == true,
+ let balances = loginResp["accountBalance"] as? [[String: Any]], !balances.isEmpty {
+ let selectedId = loginResp["selectedProfileId"] as? String ?? ""
+ if let profile = profiles.first(where: { $0.profileId == selectedId }) ?? profiles.first {
+ return balances.map { makeAccount(from: $0, profile: profile, loginTag: "mib_\(username)") }
+ + makeCards(from: loginResp, profile: profile, loginTag: "mib_\(username)")
+ }
+ }
+
+ let hidden = hiddenProfileIds(for: username)
+ let visible = hidden.isEmpty ? profiles : profiles.filter { !hidden.contains($0.profileId) }
+ return try await fetchAllProfiles(session: session2, profiles: visible, loginTag: "mib_\(username)")
+ }
+
+ // MARK: - Profile fetching
+
+ func fetchAllProfiles(session: MibSession, profiles: [MibProfile], loginTag: String) async throws -> [BankAccount] {
+ var allAccounts: [BankAccount] = []
+ for profile in profiles {
+ var payload = baseData(session: session, routePath: "P47")
+ payload["profileType"] = profile.profileType
+ payload["profileId"] = profile.profileId
+ let resp = try await doRequest(session: session, data: payload, sfunc: "n")
+ guard resp["success"] as? Bool == true,
+ let balances = resp["accountBalance"] as? [[String: Any]] else { continue }
+ allAccounts += balances.map { makeAccount(from: $0, profile: profile, loginTag: loginTag) }
+ allAccounts += makeCards(from: resp, profile: profile, loginTag: loginTag)
+ }
+ return allAccounts
+ }
+
+ func switchProfile(session: MibSession, profile: MibProfile) async throws {
+ var payload = baseData(session: session, routePath: "P47")
+ payload["profileType"] = profile.profileType
+ payload["profileId"] = profile.profileId
+ _ = try await doRequest(session: session, data: payload, sfunc: "n")
+ }
+
+ func fetchProfileImage(session: MibSession, imageHash: String) async throws -> String? {
+ var payload = baseData(session: session, routePath: "P41")
+ payload["imageHash"] = imageHash
+ let resp = try await doRequest(session: session, data: payload, sfunc: "n")
+ guard resp["success"] as? Bool == true else { return nil }
+ return (resp["profileImage"] as? String).flatMap { $0.isEmpty ? nil : $0 }
+ }
+
+ // MARK: - DH Key Exchange
+
+ private func initialKeyExchange(
+ appId: String, encKey: String, sfunc: String, key2: String? = nil
+ ) async throws -> (MibSession, String) {
+ let innerPayload: [String: Any] = [
+ "cmod": MibCrypto.cmod,
+ "appId": appId,
+ "routePath": "S40",
+ "sodium": MibNonce.randomSodium(),
+ "xxid": MibNonce.randomXxid()
+ ]
+
+ let encrypted = try MibCrypto.encrypt(innerPayload, key: encKey)
+ var formFields = [("sfunc", sfunc), ("data", encrypted)]
+ if let k2 = key2 { formFields.append(("key2", k2)) }
+
+ let responseStr = try await postForm(fields: formFields)
+
+ // Server may return a plain JSON error (not Blowfish-encrypted) for auth/config failures.
+ let trimmed = responseStr.trimmingCharacters(in: .whitespaces)
+ let respJson: [String: Any]
+ if trimmed.hasPrefix("{"),
+ let plainObj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] {
+ respJson = plainObj
+ } else {
+ respJson = try MibCrypto.decrypt(responseStr, key: encKey)
+ }
+
+ guard respJson["success"] as? Bool == true else {
+ throw MibError.serverError(respJson["reasonText"] as? String ?? "Key exchange failed")
+ }
+
+ let smod = respJson["smod"] as? String ?? ""
+ let xxid = respJson["xxid"] as? String ?? ""
+ let nonceGen = respJson["nonceGenerator"] as? String ?? ""
+ let sessionKey = MibCrypto.deriveSessionKey(smod)
+
+ return (MibSession(appId: appId, xxid: xxid, nonceGenerator: nonceGen, sessionKey: sessionKey), xxid)
+ }
+
+ private func getAuthType(session: MibSession, username: String) async throws -> String {
+ var payload = baseData(session: session, routePath: "A44")
+ payload["uname"] = username
+ let resp = try await doRequest(session: session, data: payload, sfunc: "n")
+ guard let arr = resp["data"] as? [[String: Any]],
+ let salt = arr.first?["userSalt"] as? String else { throw MibError.invalidResponse }
+ return salt
+ }
+
+ // MARK: - Request engine with session recovery
+
+ private func doRequest(session: MibSession, data: [String: Any], sfunc: String) async throws -> [String: Any] {
+ do {
+ return try await sendRequest(session: session, data: data, sfunc: sfunc)
+ } catch is SessionExpiredError {
+ guard !isRelogging else { throw MibError.sessionExpired }
+ isRelogging = true
+ defer { isRelogging = false }
+
+ guard let u = storedUsername, let ph = storedPasswordHash, let os = storedOtpSeed else {
+ throw MibError.sessionExpired
+ }
+ _ = try await login(username: u, passwordHash: ph, otpSeed: os)
+ guard let newSession = lastSession else { throw MibError.sessionExpired }
+ onSessionRefreshed?(newSession, lastProfiles)
+
+ var retryData = data
+ retryData["nonce"] = MibNonce.generate(newSession.nonceGenerator)
+ retryData["appId"] = newSession.appId
+ retryData["sodium"] = MibNonce.randomSodium()
+ retryData["xxid"] = newSession.xxid
+ return try await sendRequest(session: newSession, data: retryData, sfunc: sfunc)
+ }
+ }
+
+ private func sendRequest(session: MibSession, data: [String: Any], sfunc: String) async throws -> [String: Any] {
+ let encrypted = try MibCrypto.encrypt(data, key: session.sessionKey)
+ let formFields = [("xxid", session.xxid), ("sfunc", sfunc), ("data", encrypted)]
+ let responseStr = try await postForm(fields: formFields)
+
+ let trimmed = responseStr.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty { throw SessionExpiredError() }
+
+ if trimmed.hasPrefix("{"),
+ let obj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] {
+ if obj["reasonCode"] as? String == "505" { throw SessionExpiredError() }
+ return obj
+ }
+ return try MibCrypto.decrypt(responseStr, key: session.sessionKey)
+ }
+
+ // MARK: - HTTP
+
+ private func postForm(fields: [(String, String)]) async throws -> String {
+ var request = URLRequest(url: baseURL)
+ request.httpMethod = "POST"
+ request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+ request.setValue("android/1.0", forHTTPHeaderField: "User-Agent")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ // RFC 3986 unreserved chars only — ensures + in base64 values is encoded as %2B.
+ // .urlQueryAllowed leaves + unencoded; servers parsing application/x-www-form-urlencoded
+ // decode + as a space, silently corrupting any base64 ciphertext that contains it.
+ var formValueAllowed = CharacterSet.alphanumerics
+ formValueAllowed.insert(charactersIn: "-._~")
+ let body = fields.map { k, v in
+ let ek = k.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? k
+ let ev = v.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? v
+ return "\(ek)=\(ev)"
+ }.joined(separator: "&")
+ request.httpBody = Data(body.utf8)
+
+ let (data, response) = try await urlSession.data(for: request)
+ guard let http = response as? HTTPURLResponse else { throw MibError.networkError("No HTTP response") }
+
+ if http.statusCode == 419 { throw SessionExpiredError() }
+ if http.statusCode >= 500 { throw MibError.networkError("Server error HTTP \(http.statusCode)") }
+
+ return String(data: data, encoding: .utf8) ?? ""
+ }
+
+ // MARK: - Helpers
+
+ private func baseData(session: MibSession, routePath: String) -> [String: Any] {
+ [
+ "nonce": MibNonce.generate(session.nonceGenerator),
+ "appId": session.appId,
+ "sodium": MibNonce.randomSodium(),
+ "routePath": routePath,
+ "xxid": session.xxid
+ ]
+ }
+
+ private func parseProfiles(from json: [String: Any]) -> [MibProfile] {
+ guard let arr = json["operatingProfiles"] as? [[String: Any]] else { return [] }
+ return arr.map { p in
+ MibProfile(
+ profileId: p["profileId"] as? String ?? "",
+ customerProfileId: p["customerProfileId"] as? String ?? "",
+ annexId: p["annexId"] as? String ?? "",
+ customerId: p["customerId"] as? String ?? "",
+ name: p["name"] as? String ?? "",
+ cifType: p["cifType"] as? String ?? "",
+ profileType: p["profileType"] as? String ?? "",
+ color: p["color"] as? String ?? "",
+ customerImage: (p["customerImage"] as? String).flatMap { $0.isEmpty ? nil : $0 }
+ )
+ }
+ }
+
+ private func makeAccount(from a: [String: Any], profile: MibProfile, loginTag: String) -> BankAccount {
+ let accNum = a["accountNumber"] as? String ?? ""
+ let rawBlocked = a["blockedAmount"] as? String ?? "0.00"
+ return BankAccount(
+ id: "MIB_\(accNum)_\(loginTag)",
+ bank: "MIB",
+ profileName: profile.name,
+ profileType: profile.profileType,
+ productCode: profile.cifType,
+ accountNumber: accNum,
+ accountBriefName: a["accountBriefName"] as? String ?? "",
+ currencyName: a["currencyName"] as? String ?? "MVR",
+ accountTypeName: a["accountTypeName"] as? String ?? "",
+ availableBalance: Double(a["availableBalance"] as? String ?? "") ?? 0,
+ currentBalance: Double(a["currentBalance"] as? String ?? "") ?? 0,
+ blockedAmount: abs(Double(rawBlocked) ?? 0),
+ mvrBalance: Double(a["mvrBalance"] as? String ?? ""),
+ statusDesc: a["statusDesc"] as? String ?? "",
+ profileImageHash: profile.customerImage,
+ loginTag: loginTag,
+ profileId: profile.profileId,
+ internalId: nil
+ )
+ }
+
+ private func makeCards(from response: [String: Any], profile: MibProfile, loginTag: String) -> [BankAccount] {
+ let keys = ["cards", "cardList", "cardDetails", "debitCards", "creditCards", "accountCards"]
+ let cardArrays = keys.compactMap { response[$0] as? [[String: Any]] }
+ return cardArrays.flatMap { $0 }.compactMap { makeCardAccount(from: $0, profile: profile, loginTag: loginTag) }
+ }
+
+ private func makeCardAccount(from card: [String: Any], profile: MibProfile, loginTag: String) -> BankAccount? {
+ let cardId = firstString(card, keys: ["cardId", "id", "cardNo", "cardNumber", "maskedCardNumber"])
+ let maskedNumber = firstString(card, keys: ["maskedCardNumber", "maskedCardNo", "cardNumber", "cardNo", "cardId"])
+ guard !cardId.isEmpty || !maskedNumber.isEmpty else { return nil }
+
+ let productCode = firstString(card, keys: ["cardType", "productCode", "cardProductCode"])
+ let typeName = firstString(card, keys: ["cardTypeDesc", "cardDescription", "productName"])
+ let holder = firstString(card, keys: ["cardHolderName", "holderName", "customerName"])
+ let status = firstString(card, keys: ["cardStatus", "statusDesc", "status"])
+ let identifier = cardId.isEmpty ? maskedNumber : cardId
+
+ return BankAccount(
+ id: "MIB_CARD_\(identifier)_\(loginTag)",
+ bank: "MIB",
+ profileName: holder.isEmpty ? profile.name : holder,
+ profileType: "MIB_CARD",
+ productCode: productCode,
+ accountNumber: maskedNumber.isEmpty ? identifier : maskedNumber,
+ accountBriefName: holder,
+ currencyName: firstString(card, keys: ["currencyName", "currency"]).isEmpty ? "MVR" : firstString(card, keys: ["currencyName", "currency"]),
+ accountTypeName: typeName.isEmpty ? "MIB Card" : typeName,
+ availableBalance: doubleValue(card, keys: ["availableBalance", "availableLimit", "balance"]),
+ currentBalance: doubleValue(card, keys: ["currentBalance", "balance"]),
+ blockedAmount: 0,
+ mvrBalance: nil,
+ statusDesc: status.isEmpty ? "Active" : status,
+ profileImageHash: profile.customerImage,
+ loginTag: loginTag,
+ profileId: profile.profileId,
+ internalId: cardId.isEmpty ? nil : cardId
+ )
+ }
+
+ private func firstString(_ dictionary: [String: Any], keys: [String]) -> String {
+ for key in keys {
+ if let value = dictionary[key] as? String, !value.isEmpty { return value }
+ if let value = dictionary[key] as? NSNumber { return value.stringValue }
+ }
+ return ""
+ }
+
+ private func doubleValue(_ dictionary: [String: Any], keys: [String]) -> Double {
+ for key in keys {
+ if let value = dictionary[key] as? Double { return value }
+ if let value = dictionary[key] as? NSNumber { return value.doubleValue }
+ if let value = dictionary[key] as? String, let parsed = Double(value.replacingOccurrences(of: ",", with: "")) { return parsed }
+ }
+ return 0
+ }
+
+ private func getOrCreateAppId(for username: String) -> String {
+ let key = "mib_\(username)_enc_app_id"
+ if let existing = credentialString(key) { return existing }
+ let newId = MibNonce.generateAppId()
+ try? CredentialStore.shared.save(newId, forKey: key)
+ return newId
+ }
+
+ private func credentialString(_ key: String) -> String? {
+ CredentialStore.shared.load(forKey: key)
+ }
+
+ private func hiddenProfileIds(for username: String) -> Set {
+ let key = "mib_\(username)_hidden_profiles"
+ guard let str = CredentialStore.shared.load(forKey: key) else { return [] }
+ return Set(str.split(separator: ",").map(String.init))
+ }
+}
diff --git a/Thijooree iOS/API/MIB/MibModels.swift b/Thijooree iOS/API/MIB/MibModels.swift
new file mode 100644
index 0000000..7a158f6
--- /dev/null
+++ b/Thijooree iOS/API/MIB/MibModels.swift
@@ -0,0 +1,84 @@
+import Foundation
+
+struct MibSession: Codable {
+ let appId: String
+ let xxid: String
+ let nonceGenerator: String
+ let sessionKey: String
+}
+
+struct MibProfile: Codable, Identifiable {
+ let profileId: String
+ let customerProfileId: String
+ let annexId: String
+ let customerId: String
+ let name: String
+ let cifType: String
+ let profileType: String
+ let color: String
+ let customerImage: String?
+
+ var id: String { profileId }
+}
+
+struct MibCard: Codable, Identifiable {
+ let cardId: String
+ let maskedCardNumber: String
+ let cardStatus: String
+ let cardType: String
+ let cardTypeDesc: String
+ let customerId: String
+ let phoneNumber: String
+ let cardHolderName: String
+ let loginTag: String
+
+ var id: String { cardId }
+}
+
+struct MibFinanceDeal: Codable, Identifiable {
+ let dealNo: String
+ let productDesc: String
+ let dealStatus: String
+ let statusDesc: String
+ let dealAmount: Double
+ let paidAmount: Double
+ let outstandingAmount: Double
+ let dealDate: String
+ let overdueAmount: Double
+ let installmentAmount: Double
+ let noOfInstallments: Int
+ let lastPaidDate: String
+ let lastPayAmount: Double
+ let currency: String
+
+ var id: String { dealNo }
+}
+
+struct MibTransferResult {
+ let success: Bool
+ let trxId: String
+ let date: String
+ let errorMessage: String
+}
+
+enum MibError: LocalizedError {
+ case networkError(String)
+ case serverError(String)
+ case sessionExpired
+ case invalidCredentials
+ case decryptionFailed
+ case encryptionFailed
+ case invalidResponse
+
+ var errorDescription: String? {
+ switch self {
+ case .networkError(let msg): return "Network error: \(msg)"
+ case .serverError(let msg): return "Server error: \(msg)"
+ case .sessionExpired: return "Session expired. Please log in again."
+ case .invalidCredentials: return "Invalid credentials."
+ case .decryptionFailed: return "Failed to decrypt response."
+ case .encryptionFailed: return "Failed to encrypt request."
+ case .invalidResponse: return "Unexpected server response."
+ }
+ }
+}
diff --git a/Thijooree iOS/API/MIB/MibNonce.swift b/Thijooree iOS/API/MIB/MibNonce.swift
new file mode 100644
index 0000000..0e8b21c
--- /dev/null
+++ b/Thijooree iOS/API/MIB/MibNonce.swift
@@ -0,0 +1,93 @@
+import Foundation
+
+enum MibNonce {
+ /**
+ * Generates the nonce string from the nonceGenerator token returned by DH key exchange.
+ *
+ * Phase 1: for each dash-separated group, take the first token's digits * random(1-99),
+ * pad to 5 digits, compute digitSum and lastTwo.
+ * Phase 2: for each group tokens 1-7, apply the operation letter with carry chain.
+ * Operations: M=(carry%num)+ds+cumSum, A=carry+num+ds+cumSum,
+ * S=carry²+num+ds+cumSum, X=carry*num+ds+cumSum, C=carry³+num+ds+cumSum
+ */
+ static func generate(_ nonceGenerator: String) -> String {
+ let groups = nonceGenerator.split(separator: "-", omittingEmptySubsequences: false).map(String.init)
+
+ var paddedList: [String] = []
+ var lastTwoList: [Int] = []
+ var digitSumList: [Int] = []
+ var cumSum = 0
+
+ // Phase 1
+ for group in groups {
+ let tokens = group.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
+ let nStr = tokens[0].filter { $0.isNumber }
+ let n = Int(nStr) ?? 1
+ let r = Int.random(in: 1...99)
+ let product = n * r
+ let padded = String(format: "%05d", product)
+ let ds = padded.compactMap { $0.wholeNumberValue }.reduce(0, +)
+ let lt = Int(padded.suffix(2)) ?? 0
+ paddedList.append(padded)
+ lastTwoList.append(lt)
+ digitSumList.append(ds)
+ cumSum += ds
+ }
+
+ // Phase 2
+ var resultGroups: [String] = []
+ for (i, group) in groups.enumerated() {
+ let tokens = group.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
+ var carry = lastTwoList[i]
+ let ds = digitSumList[i]
+ var nonceDigits: [Int] = []
+
+ for j in 1...7 {
+ guard j < tokens.count else { nonceDigits.append(0); continue }
+ let token = tokens[j]
+ let op = token.filter { $0.isLetter }
+ let num = Int(token.filter { $0.isNumber }) ?? 1
+ let value: Int64
+ switch op {
+ case "M": value = Int64(carry % num) + Int64(ds) + Int64(cumSum)
+ case "A": value = Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
+ case "S": value = Int64(carry) * Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
+ case "X": value = Int64(carry) * Int64(num) + Int64(ds) + Int64(cumSum)
+ case "C": value = Int64(carry) * Int64(carry) * Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
+ default: value = 0
+ }
+ let digit = Int(String(abs(value)).suffix(2)) ?? 0
+ nonceDigits.append(digit)
+ carry = digit
+ }
+
+ let digitStr = nonceDigits.map { String(format: "%02d", $0) }.joined(separator: " ")
+ resultGroups.append("\(paddedList[i]) \(digitStr)")
+ }
+
+ return resultGroups.joined(separator: "-")
+ }
+
+ // Random long in [1_000_000, 16_000_000)
+ static func randomSodium() -> String {
+ String(Int64.random(in: 1_000_000..<16_000_000))
+ }
+
+ // Random long in [0, 2^40)
+ static func randomXxid() -> String {
+ String(Int64.random(in: 0..<(1 << 40)))
+ }
+
+ // Generates persistent App ID in IOS format: "IOS17.2-{15 alphanumeric chars}"
+ static func generateAppId() -> String {
+ let chars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
+ let random = (0..<15).map { _ in chars.randomElement()! }
+ return "IOS17.2-\(String(random))"
+ }
+
+ // Random alphanumeric string of given length
+ static func randomAlpha(_ length: Int) -> String {
+ let chars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
+ return String((0.. MibAccountLookupResult {
+ let trimmed = query.trimmingCharacters(in: .whitespaces)
+ // 17-digit starting with 9 → MIB internal
+ if trimmed.count == 17 && trimmed.hasPrefix("9") {
+ return try await lookupMibInternal(trimmed)
+ }
+ // 13-digit starting with 7 → IPS/local (BML etc.)
+ if trimmed.count == 13 && trimmed.hasPrefix("7") {
+ return try await lookupIPS(trimmed)
+ }
+ // Everything else → Favara alias
+ return try await lookupFavara(trimmed)
+ }
+
+ private func lookupMibInternal(_ number: String) async throws -> MibAccountLookupResult {
+ let resp = try await postFormJSON(
+ path: "ajaxBeneficiary/getAccountName",
+ fields: [("accountNo", number)]
+ )
+ guard resp["success"] as? Bool == true else {
+ throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
+ }
+ // Server returns accountName at root level or inside data dict/array
+ let name: String
+ if let n = resp["accountName"] as? String, !n.isEmpty {
+ name = n
+ } else if let dataDict = resp["data"] as? [String: Any],
+ let n = dataDict["accountName"] as? String {
+ name = n
+ } else if let dataArr = resp["data"] as? [[String: Any]],
+ let n = dataArr.first?["accountName"] as? String {
+ name = n
+ } else {
+ throw MibError.serverError("Account not found")
+ }
+ return MibAccountLookupResult(
+ accountNumber: number,
+ accountName: name,
+ bankName: "Maldives Islamic Bank",
+ bankCode: "2",
+ aliasId: nil,
+ network: .mibInternal
+ )
+ }
+
+ private func lookupIPS(_ number: String) async throws -> MibAccountLookupResult {
+ // Kotlin field name is benefAccount, not accountNo
+ let resp = try await postFormJSON(
+ path: "AjaxAlias/getIPSAccount",
+ fields: [("benefAccount", number)]
+ )
+ guard resp["success"] as? Bool == true else {
+ throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
+ }
+ let name = resp["accountName"] as? String
+ ?? (resp["data"] as? [[String: Any]])?.first?["accountName"] as? String
+ ?? "Unknown"
+ let bankName = resp["bankName"] as? String ?? "Local Bank"
+ return MibAccountLookupResult(
+ accountNumber: number,
+ accountName: name,
+ bankName: bankName,
+ bankCode: "3",
+ aliasId: nil,
+ network: .local
+ )
+ }
+
+ private func lookupFavara(_ alias: String) async throws -> MibAccountLookupResult {
+ // Kotlin field name is aliasName, not alias
+ let resp = try await postFormJSON(
+ path: "AjaxAlias/getAlias",
+ fields: [("aliasName", alias)]
+ )
+ guard resp["success"] as? Bool == true else {
+ throw MibError.serverError(resp["reasonText"] as? String ?? "Alias not found")
+ }
+ // Response: { data: { BfyNm, CdtrAcct: { Acct, FinInstnId } } }
+ guard let dataDict = resp["data"] as? [String: Any] else {
+ throw MibError.serverError("Alias not found")
+ }
+ let name = dataDict["BfyNm"] as? String ?? "Unknown"
+ let cdtrAcct = dataDict["CdtrAcct"] as? [String: Any]
+ let accountNum = cdtrAcct?["Acct"] as? String ?? alias
+ let bic = cdtrAcct?["FinInstnId"] as? String ?? ""
+ // Determine network based on account number format
+ let network: TransferNetwork = accountNum.count == 17 && accountNum.hasPrefix("9")
+ ? .mibInternal : .local
+ let bankCode = network == .mibInternal ? "2" : "3"
+ return MibAccountLookupResult(
+ accountNumber: accountNum,
+ accountName: name,
+ bankName: bic.isEmpty ? "Maldives Islamic Bank" : bic,
+ bankCode: bankCode,
+ aliasId: alias,
+ network: network
+ )
+ }
+
+ // MARK: - Transfer
+
+ func executeTransfer(
+ from fromAccount: BankAccount,
+ to lookup: MibAccountLookupResult,
+ amount: Double,
+ currency: String,
+ purpose: String,
+ otp: String // caller generates TOTP before entering actor context
+ ) async throws -> MibTransferResult {
+ let endpoint = lookup.network == .mibInternal
+ ? "ajaxTransfer/transferInternal"
+ : "ajaxTransfer/transferLocal"
+
+ // Kotlin maps currency name → ISO 4217 numeric code
+ let currencyCode: String
+ switch currency.uppercased() {
+ case "MVR": currencyCode = "462"
+ case "USD": currencyCode = "840"
+ default: currencyCode = currency
+ }
+
+ let fields: [(String, String)] = [
+ ("benefName", lookup.accountName.isEmpty ? "Recipient" : lookup.accountName),
+ ("benefNo", "0"), // Kotlin always sends "0"
+ ("fromAccountNo", fromAccount.accountNumber),
+ ("benefAccountNo", lookup.accountNumber),
+ ("transferCy", currencyCode),
+ ("benefCurrencyCode", currencyCode),
+ ("amount", String(format: "%.2f", amount)),
+ ("bankNo", lookup.bankCode), // "2" MIB internal, "3" local
+ ("purpose", purpose.isEmpty ? "-" : purpose),
+ ("otp", otp),
+ ("otpType", "3")
+ ]
+
+ let resp = try await postFormJSON(path: endpoint, fields: fields)
+ guard resp["success"] as? Bool == true else {
+ throw MibError.serverError(resp["reasonText"] as? String ?? "Transfer failed")
+ }
+ // Response: { success: true, data: [{ trxId, date }] }
+ let dataArr = resp["data"] as? [[String: Any]]
+ let trxId = dataArr?.first?["trxId"] as? String
+ ?? resp["trxId"] as? String ?? ""
+ let date = dataArr?.first?["date"] as? String
+ ?? resp["transactionDate"] as? String ?? ""
+ return MibTransferResult(success: true, trxId: trxId, date: date, errorMessage: "")
+ }
+
+ // MARK: - HTTP
+
+ private var cookieHeader: String {
+ "mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
+ }
+
+ private func postFormJSON(path: String, fields: [(String, String)]) async throws -> [String: Any] {
+ guard let url = URL(string: "\(wvBase)/\(path)") else {
+ throw MibError.networkError("Bad URL: \(path)")
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+ request.setValue(
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
+ forHTTPHeaderField: "User-Agent"
+ )
+ request.setValue("*/*", forHTTPHeaderField: "Accept")
+ request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue(wvBase, forHTTPHeaderField: "Origin")
+ request.setValue("\(wvBase)/transfer/quick", forHTTPHeaderField: "Referer")
+
+ var allowed = CharacterSet.alphanumerics
+ allowed.insert(charactersIn: "-._~")
+ let body = fields.map { k, v in
+ "\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
+ }.joined(separator: "&")
+ request.httpBody = Data(body.utf8)
+
+ let (data, response) = try await urlSession.data(for: request)
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
+ if code == 419 { throw MibError.sessionExpired }
+ if code >= 500 { throw MibError.networkError("HTTP \(code)") }
+
+ guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw MibError.invalidResponse
+ }
+ return obj
+ }
+}
diff --git a/Thijooree iOS/API/Models/BankModels.swift b/Thijooree iOS/API/Models/BankModels.swift
new file mode 100644
index 0000000..2ab4840
--- /dev/null
+++ b/Thijooree iOS/API/Models/BankModels.swift
@@ -0,0 +1,151 @@
+import Foundation
+
+// MARK: - Unified bank data models
+// These aggregate MIB, BML, and Fahipay responses into a common schema.
+// All types are Codable for cache serialisation and Hashable for use in SwiftUI ForEach.
+
+struct BankAccount: Identifiable, Codable, Hashable {
+ let id: String // Synthetic: "\(bank)_\(accountNumber)_\(loginTag)"
+ let bank: String // "MIB" | "BML" | "FAHIPAY"
+ let profileName: String
+ let profileType: String
+ let productCode: String
+ let accountNumber: String
+ let accountBriefName: String
+ let currencyName: String // "MVR" | "USD" | ...
+ let accountTypeName: String
+ let availableBalance: Double
+ let currentBalance: Double
+ let blockedAmount: Double
+ let mvrBalance: Double? // Converted balance when currency != MVR
+ let statusDesc: String
+ let profileImageHash: String?
+ let loginTag: String // loginId that owns this account
+ let profileId: String?
+ let internalId: String?
+
+ var isActive: Bool {
+ statusDesc.lowercased().contains("active")
+ }
+
+ var formattedAvailableBalance: String {
+ "\(currencyName) \(Self.balanceFormatter.string(from: NSNumber(value: availableBalance)) ?? "0.00")"
+ }
+
+ private static let balanceFormatter: NumberFormatter = {
+ let f = NumberFormatter()
+ f.numberStyle = .decimal
+ f.minimumFractionDigits = 2
+ f.maximumFractionDigits = 2
+ return f
+ }()
+}
+
+struct BankContact: Identifiable, Codable, Hashable {
+ let id: String // Unique composite across banks
+ let benefNo: String
+ let benefName: String
+ let benefNickName: String?
+ let benefAccount: String
+ let benefType: String // "MIB" | "LOCAL" | "SWIFT" | "BML" | "FAHIPAY"
+ let bankColor: String?
+ let benefBankName: String?
+ let bankCode: String?
+ let benefStatus: String
+ let transferCyDesc: String?
+ let customerImgHash: String?
+ let benefCategoryId: String?
+ let profileId: String?
+ let source: String // "MIB" | "BML" | "FAHIPAY"
+
+ var displayName: String {
+ if let nick = benefNickName, !nick.isEmpty { return nick }
+ return benefName
+ }
+}
+
+struct BankTransaction: Identifiable, Codable, Hashable {
+ let id: String
+ let date: Date
+ let description: String
+ let amount: Double // Positive = credit, negative = debit
+ let currency: String
+ let counterpartyName: String?
+ let reference: String?
+ let accountNumber: String
+ let accountDisplayName: String
+ let source: String // "MIB" | "BML" | "BML_CARD" | "FAHIPAY"
+ let iconUrl: String?
+
+ var isCredit: Bool { amount >= 0 }
+
+ var formattedAmount: String {
+ let sign = isCredit ? "+" : "-"
+ let abs = Self.amountFormatter.string(from: NSNumber(value: Swift.abs(amount))) ?? "0.00"
+ return "\(sign) \(currency) \(abs)"
+ }
+
+ private static let amountFormatter: NumberFormatter = {
+ let f = NumberFormatter()
+ f.numberStyle = .decimal
+ f.minimumFractionDigits = 2
+ f.maximumFractionDigits = 2
+ return f
+ }()
+}
+
+struct BankContactCategory: Identifiable, Codable, Hashable {
+ let id: String
+ let categoryName: String
+ let numBenef: Int
+ let source: String // "MIB" | "FAHIPAY"
+}
+
+struct BankNotification: Identifiable, Codable, Hashable {
+ let id: String
+ let title: String
+ let body: String
+ let date: Date
+ var isRead: Bool
+ let source: String
+}
+
+// MARK: - Transfer
+
+struct TransferResult: Codable {
+ let success: Bool
+ let transactionId: String?
+ let reference: String?
+ let date: Date?
+ let errorMessage: String?
+}
+
+struct TransferReceiptData: Sendable, Equatable {
+ let fromAccountNumber: String
+ let fromBankName: String
+ let toAccountNumber: String
+ let toAccountName: String
+ let amount: Double
+ let currency: String
+ let reference: String
+ let date: String
+ let message: String
+}
+
+struct RecentPick: Identifiable, Codable, Hashable {
+ let id: String
+ let benefName: String
+ let benefAccount: String
+ let transferNetwork: String // TransferNetwork.rawValue
+ let bankName: String?
+ let lastUsed: Date
+}
+
+// MARK: - Connectivity
+
+enum ConnectivityError: String, CaseIterable, Codable {
+ case noInternet = "NO_INTERNET"
+ case mib = "MIB"
+ case bml = "BML"
+ case fahipay = "FAHIPAY"
+}
diff --git a/Thijooree iOS/API/Models/TransferNetwork.swift b/Thijooree iOS/API/Models/TransferNetwork.swift
new file mode 100644
index 0000000..e9e9603
--- /dev/null
+++ b/Thijooree iOS/API/Models/TransferNetwork.swift
@@ -0,0 +1,33 @@
+import Foundation
+
+// Mirrors Android's TransferNetwork enum exactly (same raw values).
+enum TransferNetwork: String, Codable, CaseIterable, Equatable {
+ case mibInternal = "MIB"
+ case local = "LOCAL"
+ case swift = "SWIFT"
+ case bml = "BML"
+ case fahipay = "FAHIPAY"
+
+ var displayName: String {
+ switch self {
+ case .mibInternal: return "MIB Internal"
+ case .local: return "Local (IPS)"
+ case .swift: return "International (SWIFT)"
+ case .bml: return "BML"
+ case .fahipay: return "Fahipay"
+ }
+ }
+
+ var bankFullName: String {
+ switch self {
+ case .mibInternal: return "Maldives Islamic Bank"
+ case .local: return "Local Bank Transfer"
+ case .swift: return "International Wire Transfer"
+ case .bml: return "Bank of Maldives"
+ case .fahipay: return "Fahipay"
+ }
+ }
+
+ var isInternational: Bool { self == .swift }
+ var requiresSwiftCode: Bool { self == .swift }
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_gold.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_gold.imageset/Contents.json
new file mode 100644
index 0000000..e6704f3
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_gold.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "amex_credit_gold.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_gold.imageset/amex_credit_gold.png b/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_gold.imageset/amex_credit_gold.png
new file mode 100644
index 0000000..54d9b5b
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_gold.imageset/amex_credit_gold.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_green.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_green.imageset/Contents.json
new file mode 100644
index 0000000..f934c95
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_green.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "amex_credit_green.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_green.imageset/amex_credit_green.png b/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_green.imageset/amex_credit_green.png
new file mode 100644
index 0000000..503a4b8
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_amex_credit_green.imageset/amex_credit_green.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_gold.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_gold.imageset/Contents.json
new file mode 100644
index 0000000..ad3a932
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_gold.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "amex_debit_gold.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_gold.imageset/amex_debit_gold.png b/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_gold.imageset/amex_debit_gold.png
new file mode 100644
index 0000000..e60cfad
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_gold.imageset/amex_debit_gold.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_green.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_green.imageset/Contents.json
new file mode 100644
index 0000000..d338737
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_green.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "amex_debit_green.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_green.imageset/amex_debit_green.png b/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_green.imageset/amex_debit_green.png
new file mode 100644
index 0000000..fd4b1b4
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_amex_debit_green.imageset/amex_debit_green.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_platinum.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_amex_platinum.imageset/Contents.json
new file mode 100644
index 0000000..e466d88
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_amex_platinum.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "amex_platinum.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_amex_platinum.imageset/amex_platinum.png b/Thijooree iOS/Assets.xcassets/card_bml_amex_platinum.imageset/amex_platinum.png
new file mode 100644
index 0000000..6b89668
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_amex_platinum.imageset/amex_platinum.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_defaultcard.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_defaultcard.imageset/Contents.json
new file mode 100644
index 0000000..8f2a233
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_defaultcard.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "defaultcard.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_defaultcard.imageset/defaultcard.png b/Thijooree iOS/Assets.xcassets/card_bml_defaultcard.imageset/defaultcard.png
new file mode 100644
index 0000000..e82104e
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_defaultcard.imageset/defaultcard.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master.imageset/Contents.json
new file mode 100644
index 0000000..018417e
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master.imageset/master.png b/Thijooree iOS/Assets.xcassets/card_bml_master.imageset/master.png
new file mode 100644
index 0000000..26d6e84
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master.imageset/master.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_business_debit.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_business_debit.imageset/Contents.json
new file mode 100644
index 0000000..b88acd7
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_business_debit.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_business_debit.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_business_debit.imageset/master_business_debit.png b/Thijooree iOS/Assets.xcassets/card_bml_master_business_debit.imageset/master_business_debit.png
new file mode 100644
index 0000000..b8ccd41
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_business_debit.imageset/master_business_debit.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_gold.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_gold.imageset/Contents.json
new file mode 100644
index 0000000..33b162d
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_gold.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_gold.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_gold.imageset/master_gold.png b/Thijooree iOS/Assets.xcassets/card_bml_master_gold.imageset/master_gold.png
new file mode 100644
index 0000000..0c5c53e
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_gold.imageset/master_gold.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_islamic.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_islamic.imageset/Contents.json
new file mode 100644
index 0000000..c870c4a
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_islamic.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_islamic.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_islamic.imageset/master_islamic.png b/Thijooree iOS/Assets.xcassets/card_bml_master_islamic.imageset/master_islamic.png
new file mode 100644
index 0000000..7898296
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_islamic.imageset/master_islamic.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_masveriyaa.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_masveriyaa.imageset/Contents.json
new file mode 100644
index 0000000..bf3741c
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_masveriyaa.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_masveriyaa.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_masveriyaa.imageset/master_masveriyaa.png b/Thijooree iOS/Assets.xcassets/card_bml_master_masveriyaa.imageset/master_masveriyaa.png
new file mode 100644
index 0000000..01980ce
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_masveriyaa.imageset/master_masveriyaa.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_odiveriyaa.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_odiveriyaa.imageset/Contents.json
new file mode 100644
index 0000000..e91a6d3
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_odiveriyaa.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_odiveriyaa.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_odiveriyaa.imageset/master_odiveriyaa.png b/Thijooree iOS/Assets.xcassets/card_bml_master_odiveriyaa.imageset/master_odiveriyaa.png
new file mode 100644
index 0000000..859295c
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_odiveriyaa.imageset/master_odiveriyaa.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_passport.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_passport.imageset/Contents.json
new file mode 100644
index 0000000..f54dcb1
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_passport.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_passport.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_passport.imageset/master_passport.png b/Thijooree iOS/Assets.xcassets/card_bml_master_passport.imageset/master_passport.png
new file mode 100644
index 0000000..55868a3
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_passport.imageset/master_passport.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_platinum.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_platinum.imageset/Contents.json
new file mode 100644
index 0000000..3d380db
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_platinum.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_platinum.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_platinum.imageset/master_platinum.png b/Thijooree iOS/Assets.xcassets/card_bml_master_platinum.imageset/master_platinum.png
new file mode 100644
index 0000000..982908e
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_platinum.imageset/master_platinum.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid.imageset/Contents.json
new file mode 100644
index 0000000..e0d8af0
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_prepaid.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid.imageset/master_prepaid.png b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid.imageset/master_prepaid.png
new file mode 100644
index 0000000..11c31b7
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid.imageset/master_prepaid.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_business.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_business.imageset/Contents.json
new file mode 100644
index 0000000..6ed16ba
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_business.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_prepaid_business.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_business.imageset/master_prepaid_business.png b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_business.imageset/master_prepaid_business.png
new file mode 100644
index 0000000..53f165d
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_business.imageset/master_prepaid_business.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_travel.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_travel.imageset/Contents.json
new file mode 100644
index 0000000..5dcd0fc
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_travel.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_prepaid_travel.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_travel.imageset/master_prepaid_travel.png b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_travel.imageset/master_prepaid_travel.png
new file mode 100644
index 0000000..a76cce0
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_prepaid_travel.imageset/master_prepaid_travel.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_world.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_master_world.imageset/Contents.json
new file mode 100644
index 0000000..52a1326
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_master_world.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "master_world.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_master_world.imageset/master_world.png b/Thijooree iOS/Assets.xcassets/card_bml_master_world.imageset/master_world.png
new file mode 100644
index 0000000..c1b200e
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_master_world.imageset/master_world.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_corporate.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_corporate.imageset/Contents.json
new file mode 100644
index 0000000..fd7f238
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_corporate.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_corporate.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_corporate.imageset/visa_corporate.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_corporate.imageset/visa_corporate.png
new file mode 100644
index 0000000..6b790eb
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_corporate.imageset/visa_corporate.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_credit.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_credit.imageset/Contents.json
new file mode 100644
index 0000000..0326a73
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_credit.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_credit.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_credit.imageset/visa_credit.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_credit.imageset/visa_credit.png
new file mode 100644
index 0000000..aa931ce
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_credit.imageset/visa_credit.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_debit.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit.imageset/Contents.json
new file mode 100644
index 0000000..94f9d29
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_debit.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_debit.imageset/visa_debit.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit.imageset/visa_debit.png
new file mode 100644
index 0000000..758cfa6
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit.imageset/visa_debit.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_generic.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_generic.imageset/Contents.json
new file mode 100644
index 0000000..90a7727
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_generic.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_debit_generic.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_generic.imageset/visa_debit_generic.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_generic.imageset/visa_debit_generic.png
new file mode 100644
index 0000000..b07a2c7
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_generic.imageset/visa_debit_generic.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_islamic.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_islamic.imageset/Contents.json
new file mode 100644
index 0000000..21d5baa
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_islamic.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_debit_islamic.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_islamic.imageset/visa_debit_islamic.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_islamic.imageset/visa_debit_islamic.png
new file mode 100644
index 0000000..6110d7f
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_islamic.imageset/visa_debit_islamic.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_platinum.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_platinum.imageset/Contents.json
new file mode 100644
index 0000000..f7bd845
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_platinum.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_debit_platinum.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_platinum.imageset/visa_debit_platinum.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_platinum.imageset/visa_debit_platinum.png
new file mode 100644
index 0000000..e8d11d0
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_debit_platinum.imageset/visa_debit_platinum.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_gold.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_gold.imageset/Contents.json
new file mode 100644
index 0000000..054b23b
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_gold.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_gold.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_gold.imageset/visa_gold.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_gold.imageset/visa_gold.png
new file mode 100644
index 0000000..b7cf5ce
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_gold.imageset/visa_gold.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_infinite.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_infinite.imageset/Contents.json
new file mode 100644
index 0000000..ebc5717
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_infinite.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_infinite.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_infinite.imageset/visa_infinite.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_infinite.imageset/visa_infinite.png
new file mode 100644
index 0000000..e1f48a5
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_infinite.imageset/visa_infinite.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_platinum.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_platinum.imageset/Contents.json
new file mode 100644
index 0000000..28b20d6
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_platinum.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_platinum.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_platinum.imageset/visa_platinum.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_platinum.imageset/visa_platinum.png
new file mode 100644
index 0000000..25c5852
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_platinum.imageset/visa_platinum.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_student_black.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_student_black.imageset/Contents.json
new file mode 100644
index 0000000..5312f1b
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_student_black.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_student_black.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_student_black.imageset/visa_student_black.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_student_black.imageset/visa_student_black.png
new file mode 100644
index 0000000..ebb4fb5
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_student_black.imageset/visa_student_black.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_student_blue.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_bml_visa_student_blue.imageset/Contents.json
new file mode 100644
index 0000000..6c050d5
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_bml_visa_student_blue.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_student_blue.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_bml_visa_student_blue.imageset/visa_student_blue.png b/Thijooree iOS/Assets.xcassets/card_bml_visa_student_blue.imageset/visa_student_blue.png
new file mode 100644
index 0000000..461f411
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_bml_visa_student_blue.imageset/visa_student_blue.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_fais_wear_smart_sticker.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_fais_wear_smart_sticker.imageset/Contents.json
new file mode 100644
index 0000000..63258cb
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_fais_wear_smart_sticker.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "fais_wear_smart_sticker.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_fais_wear_smart_sticker.imageset/fais_wear_smart_sticker.png b/Thijooree iOS/Assets.xcassets/card_mib_fais_wear_smart_sticker.imageset/fais_wear_smart_sticker.png
new file mode 100644
index 0000000..3da5d04
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_fais_wear_smart_sticker.imageset/fais_wear_smart_sticker.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_faisa_card.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_faisa_card.imageset/Contents.json
new file mode 100644
index 0000000..c8f7b20
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_faisa_card.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "faisa_card.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_faisa_card.imageset/faisa_card.png b/Thijooree iOS/Assets.xcassets/card_mib_faisa_card.imageset/faisa_card.png
new file mode 100644
index 0000000..724a2a0
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_faisa_card.imageset/faisa_card.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_black.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_black.imageset/Contents.json
new file mode 100644
index 0000000..2c1dd0b
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_black.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "faisa_wear_ring_black.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_black.imageset/faisa_wear_ring_black.png b/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_black.imageset/faisa_wear_ring_black.png
new file mode 100644
index 0000000..42dc12a
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_black.imageset/faisa_wear_ring_black.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_floral.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_floral.imageset/Contents.json
new file mode 100644
index 0000000..c4a894b
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_floral.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "faisa_wear_ring_floral.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_floral.imageset/faisa_wear_ring_floral.png b/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_floral.imageset/faisa_wear_ring_floral.png
new file mode 100644
index 0000000..5a2fe79
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_faisa_wear_ring_floral.imageset/faisa_wear_ring_floral.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa.imageset/Contents.json
new file mode 100644
index 0000000..49d44f3
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_bingaa.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa.imageset/visa_bingaa.png b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa.imageset/visa_bingaa.png
new file mode 100644
index 0000000..99ebd2c
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa.imageset/visa_bingaa.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_mvr.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_mvr.imageset/Contents.json
new file mode 100644
index 0000000..fb60f86
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_mvr.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_bingaa_mvr.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_mvr.imageset/visa_bingaa_mvr.png b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_mvr.imageset/visa_bingaa_mvr.png
new file mode 100644
index 0000000..99ebd2c
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_mvr.imageset/visa_bingaa_mvr.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_usd.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_usd.imageset/Contents.json
new file mode 100644
index 0000000..22112be
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_usd.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_bingaa_usd.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_usd.imageset/visa_bingaa_usd.png b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_usd.imageset/visa_bingaa_usd.png
new file mode 100644
index 0000000..99ebd2c
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_visa_bingaa_usd.imageset/visa_bingaa_usd.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_black_platinum.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_visa_black_platinum.imageset/Contents.json
new file mode 100644
index 0000000..b56195c
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_visa_black_platinum.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_black_platinum.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_black_platinum.imageset/visa_black_platinum.png b/Thijooree iOS/Assets.xcassets/card_mib_visa_black_platinum.imageset/visa_black_platinum.png
new file mode 100644
index 0000000..859cbc7
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_visa_black_platinum.imageset/visa_black_platinum.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_blue_everyday.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_visa_blue_everyday.imageset/Contents.json
new file mode 100644
index 0000000..d829f36
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_visa_blue_everyday.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_blue_everyday.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_blue_everyday.imageset/visa_blue_everyday.png b/Thijooree iOS/Assets.xcassets/card_mib_visa_blue_everyday.imageset/visa_blue_everyday.png
new file mode 100644
index 0000000..d40c411
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_visa_blue_everyday.imageset/visa_blue_everyday.png differ
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_business.imageset/Contents.json b/Thijooree iOS/Assets.xcassets/card_mib_visa_business.imageset/Contents.json
new file mode 100644
index 0000000..e71a18d
--- /dev/null
+++ b/Thijooree iOS/Assets.xcassets/card_mib_visa_business.imageset/Contents.json
@@ -0,0 +1,4 @@
+{
+ "images": [{"filename": "visa_business.png", "idiom": "universal", "scale": "1x"}],
+ "info": {"author": "xcode", "version": 1}
+}
diff --git a/Thijooree iOS/Assets.xcassets/card_mib_visa_business.imageset/visa_business.png b/Thijooree iOS/Assets.xcassets/card_mib_visa_business.imageset/visa_business.png
new file mode 100644
index 0000000..1820954
Binary files /dev/null and b/Thijooree iOS/Assets.xcassets/card_mib_visa_business.imageset/visa_business.png differ
diff --git a/Thijooree iOS/ContentView.swift b/Thijooree iOS/ContentView.swift
index 4472bd3..7c1094d 100644
--- a/Thijooree iOS/ContentView.swift
+++ b/Thijooree iOS/ContentView.swift
@@ -1,61 +1,27 @@
-//
-// ContentView.swift
-// Thijooree iOS
-//
-// Created by Mohamed Azim on 6/6/26.
-//
-
import SwiftUI
-import SwiftData
-struct ContentView: View {
- @Environment(\.modelContext) private var modelContext
- @Query private var items: [Item]
+// Root navigation controller. Switches between top-level app states driven by AppViewModel.route.
+struct AppRouter: View {
+ @Environment(AppViewModel.self) private var appViewModel
var body: some View {
- NavigationSplitView {
- List {
- ForEach(items) { item in
- NavigationLink {
- Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
- } label: {
- Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
- }
- }
- .onDelete(perform: deleteItems)
- }
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- EditButton()
- }
- ToolbarItem {
- Button(action: addItem) {
- Label("Add Item", systemImage: "plus")
- }
- }
- }
- } detail: {
- Text("Select an item")
- }
- }
-
- private func addItem() {
- withAnimation {
- let newItem = Item(timestamp: Date())
- modelContext.insert(newItem)
- }
- }
-
- private func deleteItems(offsets: IndexSet) {
- withAnimation {
- for index in offsets {
- modelContext.delete(items[index])
+ Group {
+ switch appViewModel.route {
+ case .onboarding:
+ OnboardingView()
+ case .login:
+ BankSelectionView()
+ case .lock:
+ LockScreenView { appViewModel.unlock() }
+ case .home:
+ HomeView()
}
}
+ .animation(.easeInOut(duration: 0.25), value: appViewModel.route)
}
}
#Preview {
- ContentView()
- .modelContainer(for: Item.self, inMemory: true)
+ AppRouter()
+ .environment(AppViewModel())
}
diff --git a/Thijooree iOS/Item.swift b/Thijooree iOS/Item.swift
deleted file mode 100644
index d60122b..0000000
--- a/Thijooree iOS/Item.swift
+++ /dev/null
@@ -1,18 +0,0 @@
-//
-// Item.swift
-// Thijooree iOS
-//
-// Created by Mohamed Azim on 6/6/26.
-//
-
-import Foundation
-import SwiftData
-
-@Model
-final class Item {
- var timestamp: Date
-
- init(timestamp: Date) {
- self.timestamp = timestamp
- }
-}
diff --git a/Thijooree iOS/Security/PinHash.swift b/Thijooree iOS/Security/PinHash.swift
new file mode 100644
index 0000000..850fa1a
--- /dev/null
+++ b/Thijooree iOS/Security/PinHash.swift
@@ -0,0 +1,38 @@
+import CommonCrypto
+import Foundation
+
+enum PinHash {
+ private static let iterations: UInt32 = 100_000
+ private static let keyLength = 32 // 256 bits
+
+ static func generateSalt() -> Data {
+ var salt = Data(count: 16)
+ _ = salt.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
+ return salt
+ }
+
+ static func hash(_ input: String, salt: Data) -> String {
+ var derivedKey = Data(count: keyLength)
+ let inputData = Data(input.utf8)
+
+ _ = derivedKey.withUnsafeMutableBytes { derivedPtr in
+ inputData.withUnsafeBytes { inputPtr in
+ salt.withUnsafeBytes { saltPtr in
+ CCKeyDerivationPBKDF(
+ CCPBKDFAlgorithm(kCCPBKDF2),
+ inputPtr.baseAddress, inputData.count,
+ saltPtr.baseAddress, salt.count,
+ CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
+ iterations,
+ derivedPtr.baseAddress, keyLength
+ )
+ }
+ }
+ }
+ return derivedKey.base64EncodedString()
+ }
+
+ static func verify(_ input: String, against storedHash: String, salt: Data) -> Bool {
+ hash(input, salt: salt) == storedHash
+ }
+}
diff --git a/Thijooree iOS/Security/Totp.swift b/Thijooree iOS/Security/Totp.swift
new file mode 100644
index 0000000..fb0ad1d
--- /dev/null
+++ b/Thijooree iOS/Security/Totp.swift
@@ -0,0 +1,51 @@
+import CryptoKit
+import Foundation
+
+enum Totp {
+ // timeStep offsets the counter by N periods — use -1/+1 to handle clock skew at window boundaries.
+ static func generate(_ base32Secret: String, digits: Int = 6, period: Int = 30, timeStep: Int = 0) -> String {
+ let counter = Int64(Date().timeIntervalSince1970) / Int64(period) + Int64(timeStep)
+ guard let keyBytes = base32Decode(base32Secret.uppercased()) else { return String(repeating: "0", count: digits) }
+ let otp = hotp(key: keyBytes, counter: counter, digits: digits)
+ return String(format: "%0\(digits)d", otp)
+ }
+
+ private static func hotp(key: [UInt8], counter: Int64, digits: Int) -> Int {
+ var counterBigEndian = counter.bigEndian
+ let counterData = withUnsafeBytes(of: &counterBigEndian) { Data($0) }
+
+ let hmac = HMAC.authenticationCode(
+ for: counterData,
+ using: SymmetricKey(data: Data(key))
+ )
+ let hash = Array(hmac)
+
+ let offset = Int(hash[hash.count - 1] & 0x0F)
+ let truncated = (Int(hash[offset]) & 0x7F) << 24
+ | Int(hash[offset + 1]) << 16
+ | Int(hash[offset + 2]) << 8
+ | Int(hash[offset + 3])
+
+ let modulus = Int(pow(10.0, Double(digits)))
+ return truncated % modulus
+ }
+
+ private static func base32Decode(_ input: String) -> [UInt8]? {
+ let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
+ var bits = 0
+ var bitCount = 0
+ var result: [UInt8] = []
+
+ for char in input where char != "=" {
+ guard let idx = alphabet.firstIndex(of: char) else { return nil }
+ let value = alphabet.distance(from: alphabet.startIndex, to: idx)
+ bits = (bits << 5) | value
+ bitCount += 5
+ if bitCount >= 8 {
+ bitCount -= 8
+ result.append(UInt8((bits >> bitCount) & 0xFF))
+ }
+ }
+ return result
+ }
+}
diff --git a/Thijooree iOS/Storage/AccountCache.swift b/Thijooree iOS/Storage/AccountCache.swift
new file mode 100644
index 0000000..9c22601
--- /dev/null
+++ b/Thijooree iOS/Storage/AccountCache.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+// Encrypted account cache backed by UserDefaults (AES-256-GCM via CacheEncryption).
+// Used by HomeViewModel to display cached data instantly on launch while a fresh
+// network fetch runs in the background.
+struct AccountCache {
+ static let shared = AccountCache()
+ private let udKey = "thijooree_accounts_v1"
+ private init() {}
+
+ func save(_ accounts: [BankAccount]) {
+ guard let json = try? JSONEncoder().encode(accounts),
+ let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
+ UserDefaults.standard.set(encrypted, forKey: udKey)
+ }
+
+ func load() -> [BankAccount] {
+ guard let encrypted = UserDefaults.standard.string(forKey: udKey),
+ let json = try? CacheEncryption.shared.decryptData(encrypted),
+ let accounts = try? JSONDecoder().decode([BankAccount].self, from: json) else { return [] }
+ return accounts
+ }
+
+ func clear() {
+ UserDefaults.standard.removeObject(forKey: udKey)
+ }
+}
diff --git a/Thijooree iOS/Storage/CacheEncryption.swift b/Thijooree iOS/Storage/CacheEncryption.swift
new file mode 100644
index 0000000..483f602
--- /dev/null
+++ b/Thijooree iOS/Storage/CacheEncryption.swift
@@ -0,0 +1,149 @@
+import Foundation
+import CryptoKit
+import Security
+
+enum CacheEncryptionError: Error, LocalizedError {
+ case keyStorageFailed(OSStatus)
+ case encryptionFailed
+ case decryptionFailed
+ case invalidData
+
+ var errorDescription: String? {
+ switch self {
+ case .keyStorageFailed(let s): return "Keychain key storage failed (OSStatus \(s))"
+ case .encryptionFailed: return "AES-GCM encryption failed"
+ case .decryptionFailed: return "AES-GCM decryption failed"
+ case .invalidData: return "Data is not valid Base64 or UTF-8"
+ }
+ }
+}
+
+// AES-256-GCM encryption for all local caches.
+// Mirrors Android's CacheEncryption (AES-256-GCM, AndroidKeyStore-backed key).
+// The symmetric key is generated once and stored in Keychain, inaccessible when device is locked.
+final class CacheEncryption {
+ static let shared = CacheEncryption()
+
+ private let keychainService = "sh.sar.thijooree.cachekey"
+ private let keychainAccount = "aes256_master_key"
+ private let lock = NSLock()
+ private var cachedKey: SymmetricKey?
+
+ private init() {}
+
+ // MARK: - String convenience
+
+ func encrypt(_ string: String) throws -> String {
+ guard let data = string.data(using: .utf8) else {
+ throw CacheEncryptionError.invalidData
+ }
+ return try encryptData(data)
+ }
+
+ func decryptString(_ base64: String) throws -> String {
+ let data = try decryptData(base64)
+ guard let string = String(data: data, encoding: .utf8) else {
+ throw CacheEncryptionError.invalidData
+ }
+ return string
+ }
+
+ // MARK: - Data operations
+
+ // Returns a Base64 string containing the AES-GCM sealed box (nonce + ciphertext + tag).
+ func encryptData(_ plaintext: Data) throws -> String {
+ let key = try loadOrCreateKey()
+ guard let combined = (try? AES.GCM.seal(plaintext, using: key))?.combined else {
+ throw CacheEncryptionError.encryptionFailed
+ }
+ return combined.base64EncodedString()
+ }
+
+ // Decrypts a Base64 AES-GCM sealed box back to the original data.
+ func decryptData(_ base64: String) throws -> Data {
+ guard let combined = Data(base64Encoded: base64) else {
+ throw CacheEncryptionError.invalidData
+ }
+ let key = try loadOrCreateKey()
+ do {
+ return try AES.GCM.open(AES.GCM.SealedBox(combined: combined), using: key)
+ } catch {
+ throw CacheEncryptionError.decryptionFailed
+ }
+ }
+
+ // MARK: - Key lifecycle
+
+ // Clears the cached key and removes it from Keychain.
+ // Call on full logout so cached data becomes unreadable.
+ func purge() {
+ lock.lock()
+ defer { lock.unlock() }
+ cachedKey = nil
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: keychainService,
+ kSecAttrAccount as String: keychainAccount
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+
+ // MARK: - Private
+
+ private func loadOrCreateKey() throws -> SymmetricKey {
+ lock.lock()
+ defer { lock.unlock() }
+
+ if let key = cachedKey { return key }
+
+ if let key = loadKeyFromKeychain() {
+ cachedKey = key
+ return key
+ }
+
+ let newKey = SymmetricKey(size: .bits256)
+ try saveKeyToKeychain(newKey)
+ cachedKey = newKey
+ return newKey
+ }
+
+ private func loadKeyFromKeychain() -> SymmetricKey? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: keychainService,
+ kSecAttrAccount as String: keychainAccount,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var result: AnyObject?
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data, data.count == 32 else { return nil }
+ return SymmetricKey(data: data)
+ }
+
+ private func saveKeyToKeychain(_ key: SymmetricKey) throws {
+ let keyData = key.withUnsafeBytes { Data($0) }
+
+ let searchQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: keychainService,
+ kSecAttrAccount as String: keychainAccount
+ ]
+ let attributes: [String: Any] = [
+ kSecValueData as String: keyData,
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ ]
+
+ let addQuery = searchQuery.merging(attributes) { _, new in new }
+ let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
+
+ if addStatus == errSecDuplicateItem {
+ let updateStatus = SecItemUpdate(searchQuery as CFDictionary, attributes as CFDictionary)
+ guard updateStatus == errSecSuccess else {
+ throw CacheEncryptionError.keyStorageFailed(updateStatus)
+ }
+ } else if addStatus != errSecSuccess {
+ throw CacheEncryptionError.keyStorageFailed(addStatus)
+ }
+ }
+}
diff --git a/Thijooree iOS/Storage/ContactsCache.swift b/Thijooree iOS/Storage/ContactsCache.swift
new file mode 100644
index 0000000..8404bdb
--- /dev/null
+++ b/Thijooree iOS/Storage/ContactsCache.swift
@@ -0,0 +1,25 @@
+import Foundation
+
+// Encrypted contacts cache backed by UserDefaults (same pattern as AccountCache).
+struct ContactsCache {
+ static let shared = ContactsCache()
+ private let udKey = "thijooree_contacts_v1"
+ private init() {}
+
+ func save(_ contacts: [BankContact]) {
+ guard let json = try? JSONEncoder().encode(contacts),
+ let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
+ UserDefaults.standard.set(encrypted, forKey: udKey)
+ }
+
+ func load() -> [BankContact] {
+ guard let encrypted = UserDefaults.standard.string(forKey: udKey),
+ let json = try? CacheEncryption.shared.decryptData(encrypted),
+ let contacts = try? JSONDecoder().decode([BankContact].self, from: json) else { return [] }
+ return contacts
+ }
+
+ func clear() {
+ UserDefaults.standard.removeObject(forKey: udKey)
+ }
+}
diff --git a/Thijooree iOS/Storage/CredentialStore.swift b/Thijooree iOS/Storage/CredentialStore.swift
new file mode 100644
index 0000000..9e08050
--- /dev/null
+++ b/Thijooree iOS/Storage/CredentialStore.swift
@@ -0,0 +1,217 @@
+import Foundation
+import Security
+
+enum CredentialStoreError: Error, LocalizedError {
+ case encodingFailed
+ case saveFailed(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .encodingFailed: return "Failed to encode value for Keychain storage"
+ case .saveFailed(let s): return "Keychain write failed (OSStatus \(s))"
+ }
+ }
+}
+
+// Keychain-backed credential storage for all three banks.
+// Key naming mirrors Android's CredentialStore exactly so the logic maps 1-to-1.
+// Sensitive string values are pre-encrypted by callers via CacheEncryption before storing here.
+// Non-sensitive state (security_method, onboarding_done) lives in UserDefaults, not here.
+final class CredentialStore {
+ static let shared = CredentialStore()
+
+ private let service = "sh.sar.thijooree"
+
+ private init() {}
+
+ // MARK: - Core Keychain CRUD
+
+ func save(_ string: String, forKey key: String) throws {
+ guard let data = string.data(using: .utf8) else {
+ throw CredentialStoreError.encodingFailed
+ }
+ try save(data, forKey: key)
+ }
+
+ func save(_ data: Data, forKey key: String) throws {
+ let search = baseQuery(for: key)
+ let attributes: [String: Any] = [
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ ]
+ let addQuery = search.merging(attributes) { _, new in new }
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
+
+ if status == errSecDuplicateItem {
+ let updateStatus = SecItemUpdate(
+ search as CFDictionary,
+ [kSecValueData as String: data] as CFDictionary
+ )
+ guard updateStatus == errSecSuccess else {
+ throw CredentialStoreError.saveFailed(updateStatus)
+ }
+ } else if status != errSecSuccess {
+ throw CredentialStoreError.saveFailed(status)
+ }
+ }
+
+ func load(forKey key: String) -> String? {
+ guard let data = loadData(forKey: key) else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ func loadData(forKey key: String) -> Data? {
+ var query = baseQuery(for: key)
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+ var result: AnyObject?
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
+ return result as? Data
+ }
+
+ func delete(forKey key: String) {
+ SecItemDelete(baseQuery(for: key) as CFDictionary)
+ }
+
+ func exists(forKey key: String) -> Bool {
+ SecItemCopyMatching(baseQuery(for: key) as CFDictionary, nil) == errSecSuccess
+ }
+
+ // MARK: - JSON array helpers (for login ID lists)
+
+ func saveStringArray(_ array: [String], forKey key: String) throws {
+ guard let data = try? JSONSerialization.data(withJSONObject: array) else {
+ throw CredentialStoreError.encodingFailed
+ }
+ try save(data, forKey: key)
+ }
+
+ func loadStringArray(forKey key: String) -> [String] {
+ guard let data = loadData(forKey: key),
+ let array = try? JSONSerialization.jsonObject(with: data) as? [String] else {
+ return []
+ }
+ return array
+ }
+
+ // MARK: - Multi-login helpers
+
+ func hasAnyCredentials() -> Bool {
+ !loadStringArray(forKey: Keys.mibLoginIds).isEmpty
+ || !loadStringArray(forKey: Keys.bmlLoginIds).isEmpty
+ || !loadStringArray(forKey: Keys.fahipayLoginIds).isEmpty
+ }
+
+ func loginIds(for bank: String) -> [String] {
+ switch bank {
+ case "MIB": return loadStringArray(forKey: Keys.mibLoginIds)
+ case "BML": return loadStringArray(forKey: Keys.bmlLoginIds)
+ case "FAHIPAY": return loadStringArray(forKey: Keys.fahipayLoginIds)
+ default: return []
+ }
+ }
+
+ func addLoginId(_ loginId: String, toBank bank: String) throws {
+ let key = loginIdsKey(for: bank)
+ var ids = loadStringArray(forKey: key)
+ guard !ids.contains(loginId) else { return }
+ ids.append(loginId)
+ try saveStringArray(ids, forKey: key)
+ }
+
+ func removeLoginId(_ loginId: String, fromBank bank: String) throws {
+ let key = loginIdsKey(for: bank)
+ var ids = loadStringArray(forKey: key)
+ ids.removeAll { $0 == loginId }
+ try saveStringArray(ids, forKey: key)
+ }
+
+ // Removes every Keychain entry associated with a given login.
+ func purgeLogin(_ loginId: String, bank: String) {
+ switch bank {
+ case "MIB":
+ [Keys.mibPassword(loginId), Keys.mibOtpSeed(loginId),
+ Keys.mibKey1(loginId), Keys.mibKey2(loginId),
+ Keys.mibAppId(loginId)].forEach(delete)
+ try? removeLoginId(loginId, fromBank: bank)
+
+ case "BML":
+ let profileIds = loadStringArray(forKey: Keys.bmlProfiles(loginId))
+ profileIds.forEach { pid in
+ [Keys.bmlAccessToken(pid), Keys.bmlDeviceId(pid),
+ Keys.bmlRefreshToken(pid), Keys.bmlExpTime(pid)].forEach(delete)
+ }
+ [Keys.bmlPassword(loginId), Keys.bmlOtpSeed(loginId),
+ Keys.bmlProfiles(loginId)].forEach(delete)
+ try? removeLoginId(loginId, fromBank: bank)
+
+ case "FAHIPAY":
+ [Keys.fahipayIdCard(loginId), Keys.fahipayPassword(loginId),
+ Keys.fahipaySessionCookie(loginId), Keys.fahipayAuthId(loginId)].forEach(delete)
+ try? removeLoginId(loginId, fromBank: bank)
+
+ default:
+ break
+ }
+ }
+
+ // MARK: - Private
+
+ private func baseQuery(for key: String) -> [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key
+ ]
+ }
+
+ private func loginIdsKey(for bank: String) -> String {
+ switch bank {
+ case "MIB": return Keys.mibLoginIds
+ case "BML": return Keys.bmlLoginIds
+ case "FAHIPAY": return Keys.fahipayLoginIds
+ default: return "\(bank.lowercased())_login_ids"
+ }
+ }
+}
+
+// MARK: - Keychain key constants
+
+extension CredentialStore {
+ // Key names mirror Android's CredentialStore exactly.
+ enum Keys {
+ // Login ID lists (stored as JSON arrays)
+ static let mibLoginIds = "mib_login_ids"
+ static let bmlLoginIds = "bml_login_ids"
+ static let fahipayLoginIds = "fahipay_login_ids"
+
+ // MIB per-login
+ static func mibPassword(_ id: String) -> String { "mib_\(id)_enc_password" }
+ static func mibOtpSeed(_ id: String) -> String { "mib_\(id)_enc_otp_seed" }
+ static func mibKey1(_ id: String) -> String { "mib_\(id)_enc_key1" }
+ static func mibKey2(_ id: String) -> String { "mib_\(id)_enc_key2" }
+ static func mibAppId(_ id: String) -> String { "mib_\(id)_enc_app_id" }
+
+ // BML per-login
+ static func bmlPassword(_ id: String) -> String { "bml_\(id)_enc_password" }
+ static func bmlOtpSeed(_ id: String) -> String { "bml_\(id)_enc_otp_seed" }
+ static func bmlProfiles(_ id: String) -> String { "bml_\(id)_all_profiles" }
+
+ // BML per-profile (tokens stored separately per profile)
+ static func bmlAccessToken(_ pid: String) -> String { "bml_\(pid)_enc_access_token" }
+ static func bmlDeviceId(_ pid: String) -> String { "bml_\(pid)_enc_device_id" }
+ static func bmlRefreshToken(_ pid: String) -> String { "bml_\(pid)_enc_refresh_token" }
+ static func bmlExpTime(_ pid: String) -> String { "bml_\(pid)_exp_time" }
+
+ // Fahipay per-login
+ static func fahipayIdCard(_ id: String) -> String { "fahipay_\(id)_enc_id_card" }
+ static func fahipayPassword(_ id: String) -> String { "fahipay_\(id)_enc_password" }
+ static func fahipaySessionCookie(_ id: String) -> String { "fahipay_\(id)_enc_session_cookie" }
+ static func fahipayAuthId(_ id: String) -> String { "fahipay_\(id)_auth_id" }
+
+ // Security (Keychain — the hash is derived from PIN/pattern via PBKDF2)
+ static let securityHash = "security_hash"
+ static let securityHashSalt = "security_hash_salt"
+ // security_method and onboarding_done live in UserDefaults (not sensitive)
+ }
+}
diff --git a/Thijooree iOS/Storage/DashboardCache.swift b/Thijooree iOS/Storage/DashboardCache.swift
new file mode 100644
index 0000000..53ed45f
--- /dev/null
+++ b/Thijooree iOS/Storage/DashboardCache.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+struct CachedBmlForeignLimits: Codable {
+ let userName: String
+ let limits: [BmlForeignLimit]
+}
+
+struct DashboardCachePayload: Codable {
+ let foreignLimits: [CachedBmlForeignLimits]
+ let mibFinancing: [MibFinanceDeal]
+}
+
+struct DashboardCache {
+ static let shared = DashboardCache()
+ private let udKey = "thijooree_dashboard_v1"
+ private init() {}
+
+ func save(foreignLimits: [(userName: String, limits: [BmlForeignLimit])], mibFinancing: [MibFinanceDeal]) {
+ let payload = DashboardCachePayload(
+ foreignLimits: foreignLimits.map { CachedBmlForeignLimits(userName: $0.userName, limits: $0.limits) },
+ mibFinancing: mibFinancing
+ )
+ guard let json = try? JSONEncoder().encode(payload),
+ let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
+ UserDefaults.standard.set(encrypted, forKey: udKey)
+ }
+
+ func load() -> DashboardCachePayload? {
+ guard let encrypted = UserDefaults.standard.string(forKey: udKey),
+ let json = try? CacheEncryption.shared.decryptData(encrypted),
+ let payload = try? JSONDecoder().decode(DashboardCachePayload.self, from: json) else { return nil }
+ return payload
+ }
+
+ func clear() {
+ UserDefaults.standard.removeObject(forKey: udKey)
+ }
+}
diff --git a/Thijooree iOS/Storage/SavedContactsStore.swift b/Thijooree iOS/Storage/SavedContactsStore.swift
new file mode 100644
index 0000000..bc3cc37
--- /dev/null
+++ b/Thijooree iOS/Storage/SavedContactsStore.swift
@@ -0,0 +1,41 @@
+import Foundation
+
+// Locally persisted contacts saved from transfer receipts.
+// Encrypted with AES-256-GCM via CacheEncryption, stored in UserDefaults.
+struct SavedContactsStore {
+ static let shared = SavedContactsStore()
+ private let udKey = "thijooree_saved_contacts_v1"
+ private init() {}
+
+ func save(_ contact: BankContact) {
+ var contacts = load()
+ // Replace existing entry for the same account+source, then prepend (most-recent-first)
+ contacts.removeAll { $0.benefAccount == contact.benefAccount && $0.source == contact.source }
+ contacts.insert(contact, at: 0)
+ if contacts.count > 100 { contacts = Array(contacts.prefix(100)) }
+ persist(contacts)
+ }
+
+ func load() -> [BankContact] {
+ guard let encrypted = UserDefaults.standard.string(forKey: udKey),
+ let json = try? CacheEncryption.shared.decryptData(encrypted),
+ let contacts = try? JSONDecoder().decode([BankContact].self, from: json) else { return [] }
+ return contacts
+ }
+
+ func delete(id: String) {
+ var contacts = load()
+ contacts.removeAll { $0.id == id }
+ persist(contacts)
+ }
+
+ func clear() {
+ UserDefaults.standard.removeObject(forKey: udKey)
+ }
+
+ private func persist(_ contacts: [BankContact]) {
+ guard let json = try? JSONEncoder().encode(contacts),
+ let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
+ UserDefaults.standard.set(encrypted, forKey: udKey)
+ }
+}
diff --git a/Thijooree iOS/Thijooree_iOSApp.swift b/Thijooree iOS/Thijooree_iOSApp.swift
index a6724d7..27900df 100644
--- a/Thijooree iOS/Thijooree_iOSApp.swift
+++ b/Thijooree iOS/Thijooree_iOSApp.swift
@@ -1,32 +1,14 @@
-//
-// Thijooree_iOSApp.swift
-// Thijooree iOS
-//
-// Created by Mohamed Azim on 6/6/26.
-//
-
import SwiftUI
-import SwiftData
@main
struct Thijooree_iOSApp: App {
- var sharedModelContainer: ModelContainer = {
- let schema = Schema([
- Item.self,
- ])
- let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
-
- do {
- return try ModelContainer(for: schema, configurations: [modelConfiguration])
- } catch {
- fatalError("Could not create ModelContainer: \(error)")
- }
- }()
+ @State private var appViewModel = AppViewModel()
var body: some Scene {
WindowGroup {
- ContentView()
+ AppRouter()
+ .environment(appViewModel)
+ .tint(Color(red: 0.247, green: 0.396, blue: 0.678)) // #3F65AD — MIB brand blue
}
- .modelContainer(sharedModelContainer)
}
}
diff --git a/Thijooree iOS/Utils/PaymvQrParser.swift b/Thijooree iOS/Utils/PaymvQrParser.swift
new file mode 100644
index 0000000..4b6a590
--- /dev/null
+++ b/Thijooree iOS/Utils/PaymvQrParser.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+struct PaymvQrResult {
+ let account: String
+ let amount: String?
+ let merchantName: String?
+ let purpose: String?
+}
+
+enum PaymvQrParser {
+ static func parse(_ qr: String) -> PaymvQrResult? {
+ let tlvs = parseTLV(qr)
+
+ // Tag 26: merchant account info — sub-tag 03 = account number
+ var account: String?
+ if let tag26 = tlvs["26"] {
+ let sub = parseTLV(tag26)
+ account = sub["03"]
+ }
+
+ guard let acc = account, !acc.isEmpty else { return nil }
+
+ let amount = tlvs["54"]
+ let name = tlvs["59"]
+
+ // Tag 62: additional data — sub-tag 08 = purpose/reference
+ var purpose: String?
+ if let tag62 = tlvs["62"] {
+ let sub = parseTLV(tag62)
+ purpose = sub["08"]
+ }
+
+ return PaymvQrResult(account: acc, amount: amount, merchantName: name, purpose: purpose)
+ }
+
+ private static func parseTLV(_ s: String) -> [String: String] {
+ var result: [String: String] = [:]
+ var idx = s.startIndex
+ while s.distance(from: idx, to: s.endIndex) >= 4 {
+ let idEnd = s.index(idx, offsetBy: 2)
+ let lenEnd = s.index(idEnd, offsetBy: 2)
+ let id = String(s[idx..= len else { break }
+ let valEnd = s.index(idx, offsetBy: len)
+ result[id] = String(s[idx.. Route {
+ guard UserDefaults.standard.bool(forKey: "onboarding_done") else {
+ return .onboarding
+ }
+ guard CredentialStore.shared.hasAnyCredentials() else {
+ return .login
+ }
+ return requiresLock() ? .lock : .home
+ }
+
+ private static func requiresLock() -> Bool {
+ let method = UserDefaults.standard.string(forKey: "security_method") ?? ""
+ return !method.isEmpty && method != "none"
+ }
+}
diff --git a/Thijooree iOS/ViewModels/HomeViewModel.swift b/Thijooree iOS/ViewModels/HomeViewModel.swift
new file mode 100644
index 0000000..bf26a0f
--- /dev/null
+++ b/Thijooree iOS/ViewModels/HomeViewModel.swift
@@ -0,0 +1,500 @@
+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
+
+ 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))
+ }
+ }
+}
diff --git a/Thijooree iOS/ViewModels/LockViewModel.swift b/Thijooree iOS/ViewModels/LockViewModel.swift
new file mode 100644
index 0000000..fb5cb9c
--- /dev/null
+++ b/Thijooree iOS/ViewModels/LockViewModel.swift
@@ -0,0 +1,85 @@
+import Foundation
+import Observation
+
+@Observable
+final class LockViewModel {
+ private static let maxAttempts = 5
+ private static let lockoutDuration = 30.0
+
+ var pinDigits: [Int] = []
+ var hintMessage = ""
+ var isVerifying = false
+
+ private let autoUnlock: Bool
+ private(set) var pinLength: Int
+
+ init() {
+ self.autoUnlock = UserDefaults.standard.bool(forKey: "auto_unlock_pin")
+ self.pinLength = max(4, UserDefaults.standard.integer(forKey: "pin_length"))
+ }
+
+ // MARK: - Display
+
+ var dotsDisplay: String {
+ let n = pinDigits.count
+ let total = autoUnlock ? pinLength : max(n, 4)
+ return String(repeating: "●", count: n)
+ + String(repeating: "○", count: max(total - n, 0))
+ }
+
+ // MARK: - Lockout
+
+ var lockoutRemaining: TimeInterval {
+ let fails = UserDefaults.standard.integer(forKey: "lock_fail_count")
+ guard fails >= Self.maxAttempts else { return 0 }
+ let lastFail = UserDefaults.standard.double(forKey: "lock_last_fail_time")
+ let elapsed = Date().timeIntervalSince1970 - lastFail
+ return max(0, Self.lockoutDuration - elapsed)
+ }
+
+ var isLockedOut: Bool { lockoutRemaining > 0 }
+
+ func resetFailures() {
+ UserDefaults.standard.removeObject(forKey: "lock_fail_count")
+ UserDefaults.standard.removeObject(forKey: "lock_last_fail_time")
+ }
+
+ @discardableResult
+ func incrementFailures() -> Int {
+ let fails = UserDefaults.standard.integer(forKey: "lock_fail_count") + 1
+ UserDefaults.standard.set(fails, forKey: "lock_fail_count")
+ UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lock_last_fail_time")
+ return fails
+ }
+
+ // MARK: - Key handling
+
+ // Returns true when PIN should be submitted
+ func handleKey(_ key: String) -> Bool {
+ guard !isVerifying else { return false }
+ switch key {
+ case "⌫":
+ if !pinDigits.isEmpty { pinDigits.removeLast() }
+ case "✓":
+ if pinDigits.count >= 4 { return true }
+ default:
+ if let d = Int(key), pinDigits.count < 8 {
+ pinDigits.append(d)
+ if autoUnlock && pinDigits.count == pinLength { return true }
+ }
+ }
+ return false
+ }
+
+ func currentPin() -> String { pinDigits.map(String.init).joined() }
+ func clearPin() { pinDigits = [] }
+
+ func failureHint() -> String {
+ let fails = incrementFailures()
+ let remaining = Self.maxAttempts - fails
+ if remaining <= 0 {
+ return "Too many attempts. Try again in \(Int(Self.lockoutDuration))s."
+ }
+ return "\(remaining) attempt\(remaining == 1 ? "" : "s") remaining"
+ }
+}
diff --git a/Thijooree iOS/ViewModels/LoginViewModel.swift b/Thijooree iOS/ViewModels/LoginViewModel.swift
new file mode 100644
index 0000000..dc78d35
--- /dev/null
+++ b/Thijooree iOS/ViewModels/LoginViewModel.swift
@@ -0,0 +1,138 @@
+import Foundation
+import Observation
+
+@Observable
+final class LoginViewModel {
+
+ enum LoginState {
+ case idle
+ case loading(String)
+ case fahipayNeedTotp
+ case error(String)
+ }
+
+ var state: LoginState = .idle
+
+ // Persisted across navigation for Fahipay 2-step flow
+ private var fahipayFlow: FahipayLoginFlow?
+ private var fahipayIdCard = ""
+ private var fahipayPassword = ""
+
+ // MARK: - MIB
+
+ func loginMib(username: String, password: String, otpSeed: String) async throws -> [BankAccount] {
+ let hash = MibCrypto.hashPassword(password)
+ let flow = MibLoginFlow()
+ let accounts = try await flow.login(username: username, passwordHash: hash, otpSeed: otpSeed)
+
+ try? CredentialStore.shared.save(hash, forKey: CredentialStore.Keys.mibPassword(username))
+ try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.mibOtpSeed(username))
+ try? CredentialStore.shared.addLoginId(username, toBank: "MIB")
+
+ return accounts
+ }
+
+ // MARK: - BML
+
+ func loginBml(username: String, password: String, otpSeed: String) async throws -> [BankAccount] {
+ let loginTag = "bml_\(username)"
+ let flow = BmlLoginFlow()
+ let profiles = try await flow.login(username: username, password: password, otpSeed: otpSeed)
+ guard !profiles.isEmpty else { throw BmlError.loginFailed("No profiles found for this account") }
+
+ var accumulated: [BankAccount] = []
+ for profile in profiles where profile.profileType != "business" {
+ let result = try await flow.activateProfile(profile, loginTag: loginTag)
+ if case .success(let session, let accs) = result {
+ accumulated += accs
+ saveSession(session, profileId: profile.profileId)
+ }
+ }
+
+ try? CredentialStore.shared.save(password, forKey: CredentialStore.Keys.bmlPassword(username))
+ try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.bmlOtpSeed(username))
+ try? CredentialStore.shared.addLoginId(username, toBank: "BML")
+ let ids = profiles.map { $0.profileId }
+ try? CredentialStore.shared.saveStringArray(ids, forKey: CredentialStore.Keys.bmlProfiles(username))
+
+ return accumulated
+ }
+
+ private func saveSession(_ session: BmlSession, profileId: String) {
+ try? CredentialStore.shared.save(session.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId))
+ try? CredentialStore.shared.save(session.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId))
+ if !session.refreshToken.isEmpty {
+ try? CredentialStore.shared.save(session.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId))
+ }
+ if session.expiresAt > 0 {
+ try? CredentialStore.shared.save(String(session.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId))
+ }
+ }
+
+ // MARK: - Fahipay step 1
+
+ func loginFahipay(idCard: String, password: String) async throws -> FahipayLoginStep {
+ let flow = FahipayLoginFlow()
+ fahipayFlow = flow
+ fahipayIdCard = idCard
+ fahipayPassword = password
+ return try await flow.login(idCard: idCard, password: password, deviceUuid: fahipayDeviceUuid())
+ }
+
+ // MARK: - Fahipay step 2 (TOTP)
+
+ func verifyFahipayTotp(_ code: String) async throws {
+ guard let flow = fahipayFlow else { throw FahipayError.sessionExpired }
+ let authId = try await flow.verifyTotp(code: code, deviceUuid: fahipayDeviceUuid())
+ let cookie = flow.getSessionCookieValue() ?? ""
+ saveFahipaySession(authId: authId, sessionCookie: cookie)
+ }
+
+ // Called when no 2FA is needed (authId already returned from step 1)
+ func completeFahipayLogin(authId: String) {
+ let cookie = fahipayFlow?.getSessionCookieValue() ?? ""
+ saveFahipaySession(authId: authId, sessionCookie: cookie)
+ }
+
+ private func saveFahipaySession(authId: String, sessionCookie: String) {
+ // Use authId as the persisted login identifier
+ let loginId = authId
+ try? CredentialStore.shared.save(fahipayIdCard, forKey: CredentialStore.Keys.fahipayIdCard(loginId))
+ try? CredentialStore.shared.save(fahipayPassword, forKey: CredentialStore.Keys.fahipayPassword(loginId))
+ try? CredentialStore.shared.save(sessionCookie, forKey: CredentialStore.Keys.fahipaySessionCookie(loginId))
+ try? CredentialStore.shared.save(authId, forKey: CredentialStore.Keys.fahipayAuthId(loginId))
+ try? CredentialStore.shared.addLoginId(loginId, toBank: "FAHIPAY")
+ }
+
+ // MARK: - Helpers
+
+ private func fahipayDeviceUuid() -> String {
+ let key = "fahipay_device_uuid"
+ if let existing = CredentialStore.shared.load(forKey: key) { return existing }
+ let uuid = FahipayLoginFlow.generateDeviceUuid()
+ try? CredentialStore.shared.save(uuid, forKey: key)
+ return uuid
+ }
+
+ // Strips otpauth:// URI and whitespace/dashes, uppercases — mirrors Android resolveOtpSeed
+ static func resolveOtpSeed(_ raw: String) -> String {
+ var secret = raw.trimmingCharacters(in: .whitespaces)
+ if secret.lowercased().hasPrefix("otpauth://totp/"),
+ let url = URLComponents(string: secret),
+ let s = url.queryItems?.first(where: { $0.name == "secret" })?.value {
+ secret = s
+ }
+ return secret.replacingOccurrences(of: " ", with: "")
+ .replacingOccurrences(of: "-", with: "")
+ .uppercased()
+ }
+
+ // Returns true if the string looks like a valid Base32 OTP seed (not a 6-digit code)
+ static func isValidOtpSeed(_ raw: String) -> Bool {
+ let seed = resolveOtpSeed(raw)
+ if seed.isEmpty { return false }
+ if seed.count == 6 && seed.allSatisfy({ $0.isNumber }) { return false } // reject plain codes
+ let base32 = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=")
+ return seed.unicodeScalars.allSatisfy { base32.contains($0) }
+ }
+}
diff --git a/Thijooree iOS/ViewModels/TransferViewModel.swift b/Thijooree iOS/ViewModels/TransferViewModel.swift
new file mode 100644
index 0000000..6778503
--- /dev/null
+++ b/Thijooree iOS/ViewModels/TransferViewModel.swift
@@ -0,0 +1,327 @@
+import Foundation
+import Observation
+
+// Pending BML transfer kept between step 1 (initiate) and step 2 (OTP confirm).
+struct BmlPendingTransfer: Sendable {
+ let fromAccount: BankAccount
+ let toAccount: String
+ let toName: String
+ let amount: Double
+ let currency: String
+ let remarks: String
+ let trnType: String
+ let bank: String?
+ let bmlSession: BmlSession
+}
+
+@MainActor
+@Observable
+final class TransferViewModel {
+
+ // MARK: - Form state
+
+ var fromAccount: BankAccount? = nil
+ var toInput: String = ""
+ var amountInput: String = ""
+ var purposeInput: String = ""
+
+ // MARK: - Lookup
+
+ enum LookupState {
+ case idle
+ case loading
+ case found(MibAccountLookupResult) // MIB lookup result with full details
+ case bmlReady(String) // BML: account name returned by server
+ case error(String)
+ }
+ var lookupState: LookupState = .idle
+
+ // MARK: - Transfer flow
+
+ enum TransferState: Equatable {
+ case idle
+ case bmlStep2 // BML 2-step: awaiting OTP entry
+ case processing
+ case success(TransferReceiptData)
+ case failure(String)
+ }
+ var transferState: TransferState = .idle
+
+ private var bmlPending: BmlPendingTransfer? = nil
+ var bmlTrnType: String = "IAT" // set from lookup result; "IAT" BML-to-BML, "QTR" Favara, "DOT" other bank
+ var bmlBank: String? = nil // Kotlin sends bank="MIB" for MVR BML-to-MIB DOT transfers
+ var bmlCreditAccountOverride: String? = nil
+
+ // MARK: - Derived
+
+ var parsedAmount: Double {
+ Double(amountInput.replacingOccurrences(of: ",", with: "")) ?? 0
+ }
+
+ var canLookup: Bool {
+ guard let bank = fromAccount?.bank else { return false }
+ return (bank == "MIB" || bank == "BML") && !toInput.trimmingCharacters(in: .whitespaces).isEmpty
+ }
+
+ var canTransfer: Bool {
+ fromAccount != nil && parsedAmount > 0 && hasValidLookup
+ }
+
+ private var hasValidLookup: Bool {
+ switch lookupState {
+ case .found, .bmlReady: return true
+ default:
+ // BML: allow submit without explicit lookup (server validates)
+ return fromAccount?.bank == "BML" && !toInput.isEmpty
+ }
+ }
+
+ // MARK: - Beneficiary lookup
+
+ func lookupBeneficiary() async {
+ guard let from = fromAccount else { return }
+ let query = toInput.trimmingCharacters(in: .whitespaces)
+ guard !query.isEmpty else { return }
+ lookupState = .loading
+
+ if from.bank == "MIB" {
+ do {
+ let session = try await loadMibSession(loginTag: from.loginTag)
+ let client = MibTransferClient(session: session)
+ let result = try await client.lookupAccount(query)
+ lookupState = .found(result)
+ } catch {
+ lookupState = .error(error.localizedDescription)
+ }
+ } else if from.bank == "BML" {
+ do {
+ let sess = try await loadBmlSession(loginTag: from.loginTag)
+ let client = BmlTransferClient(bmlSession: sess)
+ let result = try await client.lookupAccount(query)
+ bmlTrnType = result.trnType
+ bmlBank = result.bank
+ toInput = result.accountNumber // use resolved account number
+ lookupState = .bmlReady(result.accountName)
+ } catch {
+ lookupState = .error(error.localizedDescription)
+ }
+ }
+ }
+
+ // MARK: - Submit transfer
+
+ func submitTransfer() async {
+ guard let from = fromAccount, parsedAmount > 0 else { return }
+ transferState = .processing
+
+ do {
+ switch from.bank {
+ case "MIB":
+ let receipt = try await performMibTransfer(from: from)
+ transferState = .success(receipt)
+
+ case "BML":
+ try await initiateBmlTransfer(from: from)
+ transferState = .bmlStep2
+
+ default:
+ transferState = .failure("Transfers not yet supported for \(from.bank)")
+ }
+ } catch {
+ transferState = .failure(error.localizedDescription)
+ }
+ }
+
+ // MARK: - BML OTP confirm
+
+ func confirmBmlOtp(otpSeed: String) async {
+ guard let pending = bmlPending else { return }
+ transferState = .processing
+ let otp = Totp.generate(otpSeed)
+ do {
+ let receipt = try await finalizeBmlTransfer(pending: pending, otp: otp)
+ transferState = .success(receipt)
+ bmlPending = nil
+ } catch {
+ transferState = .failure(error.localizedDescription)
+ }
+ }
+
+ func reset() {
+ fromAccount = nil
+ toInput = ""
+ amountInput = ""
+ purposeInput = ""
+ lookupState = .idle
+ transferState = .idle
+ bmlPending = nil
+ bmlTrnType = "IAT"
+ bmlBank = nil
+ bmlCreditAccountOverride = nil
+ }
+
+ // MARK: - MIB transfer
+
+ private func performMibTransfer(from: BankAccount) async throws -> TransferReceiptData {
+ guard case .found(let lookup) = lookupState else {
+ throw MibError.invalidResponse
+ }
+ let username = from.loginTag.replacingOccurrences(of: "mib_", with: "")
+ guard let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)),
+ let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username)) else {
+ throw MibError.serverError("MIB credentials not found. Please log in again.")
+ }
+ // Generate TOTP on @MainActor before entering actor context.
+ let otp = Totp.generate(seed)
+ let flow = MibLoginFlow()
+ _ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed)
+ guard let session = await flow.lastSession else { throw MibError.sessionExpired }
+
+ let client = MibTransferClient(session: session)
+ let result = try await client.executeTransfer(
+ from: from,
+ to: lookup,
+ amount: parsedAmount,
+ currency: from.currencyName,
+ purpose: purposeInput,
+ otp: otp
+ )
+ return TransferReceiptData(
+ fromAccountNumber: from.accountNumber,
+ fromBankName: "Maldives Islamic Bank",
+ toAccountNumber: lookup.accountNumber,
+ toAccountName: lookup.accountName,
+ amount: parsedAmount,
+ currency: from.currencyName,
+ reference: result.trxId,
+ date: result.date,
+ message: "Transfer successful"
+ )
+ }
+
+ // MARK: - BML transfer
+
+ private func initiateBmlTransfer(from: BankAccount) async throws {
+ guard from.profileType == "BML" else {
+ throw BmlError.serverError("BML card accounts cannot be used as the debit account for transfers. Select a BML account instead.")
+ }
+ let sess = try await loadBmlSession(loginTag: from.loginTag)
+ let source = try await resolveBmlDebitSource(from, session: sess)
+ let client = BmlTransferClient(bmlSession: sess)
+ let overrideAccount = bmlCreditAccountOverride?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let toAcc = overrideAccount.isEmpty ? toInput.trimmingCharacters(in: .whitespaces) : overrideAccount
+ let toName: String = {
+ if case .bmlReady(let n) = lookupState, !n.isEmpty { return n }
+ return ""
+ }()
+
+ _ = try await client.initiateTransfer(
+ fromAccount: source,
+ toAccount: toAcc,
+ amount: parsedAmount,
+ currency: source.currencyName,
+ trnType: bmlTrnType,
+ bank: bmlBank
+ )
+ bmlPending = BmlPendingTransfer(
+ fromAccount: source,
+ toAccount: toAcc,
+ toName: toName,
+ amount: parsedAmount,
+ currency: source.currencyName,
+ remarks: purposeInput,
+ trnType: bmlTrnType,
+ bank: bmlBank,
+ bmlSession: sess
+ )
+ }
+
+ private func resolveBmlDebitSource(_ account: BankAccount, session: BmlSession) async throws -> BankAccount {
+ let currentId = (account.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if !currentId.isEmpty { return account }
+
+ let freshAccounts = try await BmlAccountClient(session: session).fetchAccounts(
+ loginTag: account.loginTag,
+ profileId: account.profileId ?? ""
+ )
+ if let match = freshAccounts.first(where: {
+ $0.accountNumber == account.accountNumber &&
+ $0.profileType == account.profileType &&
+ !(($0.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }) {
+ return match
+ }
+ throw BmlError.serverError("BML debit account id missing. Refresh dashboard and select the account again.")
+ }
+
+ private func finalizeBmlTransfer(pending: BmlPendingTransfer, otp: String) async throws -> TransferReceiptData {
+ let client = BmlTransferClient(bmlSession: pending.bmlSession)
+ let receipt = try await client.confirmTransfer(
+ fromAccount: pending.fromAccount,
+ toAccount: pending.toAccount,
+ amount: pending.amount,
+ currency: pending.currency,
+ trnType: pending.trnType,
+ remarks: pending.remarks,
+ otp: otp,
+ bank: pending.bank
+ )
+ return TransferReceiptData(
+ fromAccountNumber: pending.fromAccount.accountNumber,
+ fromBankName: "Bank of Maldives",
+ toAccountNumber: pending.toAccount,
+ toAccountName: pending.toName,
+ amount: pending.amount,
+ currency: pending.currency,
+ reference: receipt.reference,
+ date: receipt.timestamp,
+ message: receipt.message
+ )
+ }
+
+ // MARK: - Session loaders
+
+ private func loadMibSession(loginTag: String) async throws -> MibSession {
+ let username = loginTag.replacingOccurrences(of: "mib_", with: "")
+ guard let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)),
+ let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username)) else {
+ throw MibError.serverError("MIB credentials not found")
+ }
+ let flow = MibLoginFlow()
+ _ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed)
+ guard let session = await flow.lastSession else { throw MibError.sessionExpired }
+ return session
+ }
+
+ private func loadBmlSession(loginTag: String) async throws -> BmlSession {
+ let username = loginTag.replacingOccurrences(of: "bml_", with: "")
+ let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username))
+ for pid in pids {
+ guard let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)),
+ !token.isEmpty else { continue }
+ 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)) ?? ""
+ if expiresAt == 0 || now < expiresAt - 60_000 {
+ return BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt)
+ }
+ // Token near expiry — refresh
+ let stale = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt)
+ return try await BmlLoginFlow().refreshSession(stale)
+ }
+ // Fall back to full re-login
+ guard let pw = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlPassword(username)),
+ let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(username)) else {
+ throw BmlError.serverError("BML credentials not found")
+ }
+ let flow = BmlLoginFlow()
+ let profiles = try await flow.login(username: username, password: pw, otpSeed: seed)
+ guard let profile = profiles.first else { throw BmlError.serverError("No BML profile found") }
+ if case .success(let sess, _) = try await flow.activateProfile(profile, loginTag: loginTag) {
+ return sess
+ }
+ throw BmlError.serverError("BML session could not be established")
+ }
+}
diff --git a/Thijooree iOS/Views/Home/AccountsView.swift b/Thijooree iOS/Views/Home/AccountsView.swift
new file mode 100644
index 0000000..4ad5d79
--- /dev/null
+++ b/Thijooree iOS/Views/Home/AccountsView.swift
@@ -0,0 +1,196 @@
+import SwiftUI
+
+// Mirrors fragment_accounts.xml (RecyclerView) + item_account.xml layout:
+// [40dp circle logo] [Name / Number(mono) / Type] [Balance + blocked + send button]
+// Divider between items, grouped by bank with section headers.
+struct AccountsView: View {
+ @Environment(HomeViewModel.self) private var vm
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if vm.accounts.isEmpty && !vm.isRefreshing {
+ emptyState
+ } else {
+ accountList
+ }
+ }
+ .navigationTitle("Accounts")
+ .toolbar {
+ if vm.isRefreshing {
+ ToolbarItem(placement: .topBarTrailing) { ProgressView() }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ withAnimation { vm.hideAmounts.toggle() }
+ } label: {
+ Image(systemName: vm.hideAmounts ? "eye.slash" : "eye")
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: - Account list
+
+ private var accountList: some View {
+ List {
+ bankSection(title: "MIB Faisanet", accounts: vm.mibAccounts, color: bankColor("MIB"))
+ bankSection(title: "Bank of Maldives", accounts: vm.bmlAccounts, color: bankColor("BML"))
+ bankSection(title: "Fahipay", accounts: vm.fahipayAccounts, color: bankColor("FAHIPAY"))
+ }
+ .listStyle(.plain)
+ .refreshable { await vm.refresh() }
+ }
+
+ @ViewBuilder
+ private func bankSection(title: String, accounts: [BankAccount], color: Color) -> some View {
+ if !accounts.isEmpty {
+ Section {
+ ForEach(accounts) { account in
+ AccountRow(account: account, hideAmounts: vm.hideAmounts)
+ .listRowInsets(EdgeInsets()) // remove default insets — row provides its own
+ .listRowSeparator(.hidden)
+ }
+ } header: {
+ HStack(spacing: 6) {
+ Circle().fill(color).frame(width: 8, height: 8)
+ Text(title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(nil)
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ }
+ }
+ }
+
+ // MARK: - Empty state
+
+ private var emptyState: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "building.columns")
+ .font(.system(size: 52))
+ .foregroundStyle(.tertiary)
+ Text("No accounts yet")
+ .font(.headline)
+ Text("Your accounts will appear here after logging in to a bank.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+// MARK: - Account row (item_account.xml)
+// Layout: [40dp circle] [Name / Number(mono) / Type] → [Balance / Blocked / SendBtn(40dp)]
+// Padding: 16dp horizontal, 14dp vertical. Divider below.
+
+private struct AccountRow: View {
+ let account: BankAccount
+ let hideAmounts: Bool
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(alignment: .center, spacing: 0) {
+ // ── Bank logo circle (ShapeableImageView 40dp) ──────────────────
+ bankLogo
+ .padding(.trailing, 12)
+
+ // ── Left: name / number / type ──────────────────────────────────
+ VStack(alignment: .leading, spacing: 2) {
+ Text(displayName)
+ .font(.subheadline.weight(.medium)) // textAppearanceTitleMedium
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+
+ Text(account.accountNumber)
+ .font(.callout) // textAppearanceTitleSmall
+ .foregroundStyle(.secondary)
+ .fontDesign(.monospaced) // android:fontFamily="monospace"
+ .lineLimit(1)
+
+ if !account.accountTypeName.isEmpty {
+ Text(account.accountTypeName)
+ .font(.caption) // textAppearanceBodySmall
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer(minLength: 16)
+
+ // ── Right: balance / blocked / send button ──────────────────────
+ VStack(alignment: .trailing, spacing: 2) {
+ Text(hideAmounts ? "••••••" : account.formattedAvailableBalance)
+ .font(.callout.weight(.medium)) // textAppearanceTitleSmall
+ .foregroundStyle(.primary)
+ .monospacedDigit()
+ .lineLimit(1)
+
+ if account.blockedAmount > 0 && !hideAmounts {
+ Text("Blocked: \(account.currencyName) \(String(format: "%,.2f", account.blockedAmount))")
+ .font(.caption2)
+ .foregroundStyle(.red)
+ .lineLimit(1)
+ }
+
+ // Transfer button (40x40dp, borderless, tinted primary)
+ Button { /* Transfer — Phase 7 */ } label: {
+ Image(systemName: "arrow.up.right")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundStyle(.tint)
+ .frame(width: 40, height: 40)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .padding(.top, 4)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 14)
+
+ // Divider (1dp, 16dp horizontal margin, colorOutlineVariant)
+ Divider()
+ .padding(.horizontal, 16)
+ }
+ }
+
+ // MARK: - Helpers
+
+ private var displayName: String {
+ account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName
+ }
+
+ private var bankLogo: some View {
+ Circle()
+ .fill(color.opacity(0.12))
+ .frame(width: 40, height: 40)
+ .overlay {
+ Text(String(account.bank.prefix(1)))
+ .font(.headline)
+ .foregroundStyle(color)
+ }
+ }
+
+ private var color: Color { bankColor(account.bank) }
+}
+
+// MARK: - Shared bank color
+
+private func bankColor(_ bank: String) -> Color {
+ switch bank {
+ case "MIB": return Color(red: 0.247, green: 0.396, blue: 0.678) // #3F65AD primary
+ case "BML": return Color(red: 0.0, green: 0.47, blue: 0.80)
+ case "FAHIPAY": return .purple
+ default: return .gray
+ }
+}
+
+#Preview {
+ AccountsView()
+ .environment(HomeViewModel())
+}
diff --git a/Thijooree iOS/Views/Home/ContactsView.swift b/Thijooree iOS/Views/Home/ContactsView.swift
new file mode 100644
index 0000000..5633fbc
--- /dev/null
+++ b/Thijooree iOS/Views/Home/ContactsView.swift
@@ -0,0 +1,253 @@
+import SwiftUI
+
+struct ContactsView: View {
+ @Environment(HomeViewModel.self) private var homeVM
+ @State private var searchText = ""
+
+ private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
+
+ private var filtered: [BankContact] {
+ homeVM.contacts(matching: searchText)
+ }
+
+ private var grouped: [(String, [BankContact])] {
+ let order = ["MIB", "BML", "FAHIPAY"]
+ return order.compactMap { source in
+ let group = filtered.filter { $0.source == source }
+ return group.isEmpty ? nil : (source, group)
+ }
+ }
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if homeVM.isLoadingContacts && homeVM.contacts.isEmpty {
+ loadingView
+ } else if filtered.isEmpty && homeVM.contactsError == nil && !homeVM.isLoadingContacts {
+ emptyView
+ } else {
+ contactsList
+ }
+ }
+ .navigationTitle("Contacts")
+ .navigationBarTitleDisplayMode(.large)
+ .searchable(text: $searchText, prompt: "Search name or account")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ if homeVM.isLoadingContacts {
+ ProgressView().controlSize(.small)
+ } else {
+ Button {
+ Task { await homeVM.fetchContacts() }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ }
+ }
+ }
+ .task {
+ await homeVM.fetchContactsIfNeeded()
+ }
+ .refreshable {
+ await homeVM.fetchContacts()
+ }
+ }
+ }
+
+ // MARK: - Contact list
+
+ private var contactsList: some View {
+ List {
+ if let err = homeVM.contactsError {
+ Section {
+ VStack(alignment: .leading, spacing: 6) {
+ Label("Fetch error", systemImage: "exclamationmark.triangle.fill")
+ .font(.caption.bold())
+ .foregroundStyle(.orange)
+ Text(err)
+ .font(.caption)
+ .foregroundStyle(.orange)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ ForEach(grouped, id: \.0) { source, contacts in
+ Section(header: sectionHeader(source)) {
+ ForEach(contacts) { contact in
+ ContactRow(contact: contact) {
+ homeVM.pendingTransferContact = contact
+ }
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ }
+
+ // MARK: - Section header
+
+ private func sectionHeader(_ source: String) -> some View {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(bankColor(source))
+ .frame(width: 8, height: 8)
+ Text(bankFullName(source))
+ .font(.caption.bold())
+ .foregroundStyle(bankColor(source))
+ }
+ }
+
+ // MARK: - Empty / loading
+
+ private var emptyView: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "person.badge.plus")
+ .font(.system(size: 48))
+ .foregroundStyle(.secondary)
+ Text(searchText.isEmpty ? "No saved contacts" : "No results for \"\(searchText)\"")
+ .font(.title3.bold())
+ .foregroundStyle(.secondary)
+ if searchText.isEmpty {
+ Text("After a successful transfer, tap \"Save Contact\" on the receipt to add the recipient here.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 40)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var loadingView: some View {
+ VStack(spacing: 16) {
+ ProgressView()
+ Text("Loading contacts…")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func bankColor(_ source: String) -> Color {
+ switch source {
+ case "MIB": return Color(red: 0.247, green: 0.396, blue: 0.678)
+ case "BML": return .blue
+ case "FAHIPAY": return .purple
+ default: return .gray
+ }
+ }
+
+ private func bankFullName(_ source: String) -> String {
+ switch source {
+ case "MIB": return "Maldives Islamic Bank"
+ case "BML": return "Bank of Maldives"
+ case "FAHIPAY": return "Fahipay"
+ default: return source
+ }
+ }
+}
+
+// MARK: - Contact row
+
+struct ContactRow: View {
+ let contact: BankContact
+ var onSend: () -> Void
+
+ private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
+
+ var body: some View {
+ HStack(spacing: 12) {
+ avatarCircle
+ VStack(alignment: .leading, spacing: 3) {
+ Text(contact.displayName)
+ .font(.subheadline.bold())
+ .lineLimit(1)
+ HStack(spacing: 6) {
+ Text(contact.benefAccount)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ if let bank = contact.benefBankName, bank != "Maldives Islamic Bank", bank != "Bank of Maldives" {
+ Text("·")
+ .foregroundStyle(.secondary)
+ Text(bank)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ typeBadge
+ }
+ Spacer()
+ Button(action: onSend) {
+ Image(systemName: "paperplane.fill")
+ .font(.subheadline)
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ .background(primary)
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.vertical, 4)
+ .contentShape(Rectangle())
+ }
+
+ private var avatarCircle: some View {
+ ZStack {
+ Circle()
+ .fill(avatarColor.opacity(0.15))
+ .frame(width: 40, height: 40)
+ Text(initials)
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(avatarColor)
+ }
+ }
+
+ private var typeBadge: some View {
+ Text(typeLabel)
+ .font(.caption2.bold())
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(typeColor.opacity(0.12))
+ .foregroundStyle(typeColor)
+ .clipShape(Capsule())
+ }
+
+ private var initials: String {
+ let words = contact.displayName.split(separator: " ").prefix(2)
+ return words.compactMap { $0.first }.map(String.init).joined()
+ }
+
+ private var avatarColor: Color {
+ switch contact.source {
+ case "MIB": return Color(red: 0.247, green: 0.396, blue: 0.678)
+ case "BML": return .blue
+ case "FAHIPAY": return .purple
+ default: return .gray
+ }
+ }
+
+ private var typeLabel: String {
+ switch contact.benefType {
+ case "MIB": return "MIB"
+ case "LOCAL": return "IPS"
+ case "SWIFT": return "SWIFT"
+ case "BML": return "BML"
+ default: return contact.benefType
+ }
+ }
+
+ private var typeColor: Color {
+ switch contact.benefType {
+ case "SWIFT": return .orange
+ case "LOCAL": return .green
+ default: return primary
+ }
+ }
+}
+
+#Preview {
+ ContactsView()
+ .environment(HomeViewModel())
+}
diff --git a/Thijooree iOS/Views/Home/DashboardView.swift b/Thijooree iOS/Views/Home/DashboardView.swift
new file mode 100644
index 0000000..db2514e
--- /dev/null
+++ b/Thijooree iOS/Views/Home/DashboardView.swift
@@ -0,0 +1,595 @@
+import SwiftUI
+
+// Mirrors fragment_dashboard.xml:
+// • 2-column balance cards (MVR + USD) — always shown
+// • Credit row — visible only when credit/prepaid cards exist
+// • Blocked funds row — visible only when blockedAmount > 0
+// • Card carousel — visible only when card accounts exist
+// • Error banners
+// • Fixed bottom quick-action bar (2 outlined buttons, outside scroll)
+struct DashboardView: View {
+ @Environment(HomeViewModel.self) private var vm
+ @Environment(AppViewModel.self) private var app
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ // Indeterminate progress bar while loading (mirrors LinearProgressIndicator)
+ if vm.isRefreshing {
+ ProgressView(value: nil as Double?)
+ .progressViewStyle(.linear)
+ .tint(.accentColor)
+ .frame(height: 3)
+ }
+
+ // Scrollable content area
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+
+ // ── 1. Balance summary row (MVR + USD) ───────────────────
+ HStack(spacing: 8) {
+ BalanceCard(label: "MVR Balance",
+ value: vm.fmt(vm.totalMvrBalance, currency: "MVR"),
+ hidden: vm.hideAmounts,
+ style: .normal)
+ BalanceCard(label: "USD Balance",
+ value: vm.fmt(vm.totalUsdBalance, currency: "USD"),
+ hidden: vm.hideAmounts,
+ style: .normal)
+ }
+
+ // ── 2. Available credit row (conditional) ────────────────
+ if vm.hasCreditAccounts {
+ HStack(spacing: 8) {
+ BalanceCard(label: "MVR Available Credit",
+ value: vm.fmt(vm.totalMvrCredit, currency: "MVR"),
+ hidden: vm.hideAmounts,
+ style: .normal)
+ BalanceCard(label: "USD Available Credit",
+ value: vm.fmt(vm.totalUsdCredit, currency: "USD"),
+ hidden: vm.hideAmounts,
+ style: .normal)
+ }
+ }
+
+ // ── 3. Blocked funds row (conditional) ───────────────────
+ if vm.totalBlockedMvr > 0 || vm.totalBlockedUsd > 0 {
+ HStack(spacing: 8) {
+ if vm.totalBlockedMvr > 0 {
+ BalanceCard(label: "Blocked MVR",
+ value: vm.fmt(vm.totalBlockedMvr, currency: "MVR"),
+ hidden: vm.hideAmounts,
+ style: .error)
+ .frame(maxWidth: .infinity)
+ }
+ if vm.totalBlockedUsd > 0 {
+ BalanceCard(label: "Blocked USD",
+ value: vm.fmt(vm.totalBlockedUsd, currency: "USD"),
+ hidden: vm.hideAmounts,
+ style: .error)
+ .frame(maxWidth: .infinity)
+ }
+ // Pad when only one side has a value
+ if vm.totalBlockedMvr == 0 || vm.totalBlockedUsd == 0 {
+ Spacer().frame(maxWidth: .infinity)
+ }
+ }
+ }
+
+ // ── 4. Pending finances (MIB) ────────────────────────────
+ if vm.totalPendingFinances > 0 {
+ BalanceCard(label: "Pending Finances",
+ value: vm.fmt(vm.totalPendingFinances, currency: "MVR"),
+ hidden: vm.hideAmounts,
+ style: .normal)
+ }
+
+ // ── 5. Foreign transaction limits (BML) ──────────────────
+ ForEach(vm.foreignLimits, id: \.0) { userName, limits in
+ ForeignLimitsCard(userName: userName, limits: limits, hidden: vm.hideAmounts)
+ }
+
+ // ── 6. Card carousel ─────────────────────────────────────
+ if !vm.cardAccounts.isEmpty {
+ cardCarousel
+ }
+
+ // ── 7. Error banners ─────────────────────────────────────
+ ForEach(vm.bankErrors.sorted(by: { $0.key < $1.key }), id: \.key) { bank, msg in
+ ErrorBanner(bank: bank, message: msg)
+ }
+
+ // ── 8. Loading skeletons (no data yet) ───────────────────
+ if vm.isRefreshing && vm.accounts.isEmpty {
+ ForEach(0..<3, id: \.self) { _ in SkeletonCard() }
+ }
+
+ // ── 9. Empty state ───────────────────────────────────────
+ if !vm.isRefreshing && vm.accounts.isEmpty && vm.bankErrors.isEmpty {
+ emptyState
+ }
+ }
+ .padding(16)
+ }
+ .refreshable { await vm.refresh() }
+
+ Divider()
+
+ // ── Fixed bottom quick-action bar ────────────────────────────────
+ HStack(spacing: 8) {
+ QuickActionButton(icon: "arrow.up.right", label: "Transfer") {}
+ QuickActionButton(icon: "qrcode", label: "PayMV QR") {}
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ .padding(.bottom, max(16, 0))
+ }
+ .background(Color(.systemBackground))
+ .navigationTitle("Thijooree")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar { toolbarItems }
+ }
+ }
+
+ // MARK: - Card Carousel
+
+ private var cardCarousel: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 0) {
+ ForEach(vm.cardAccounts) { card in
+ CardCarouselItem(account: card, hide: vm.hideAmounts)
+ .padding(.trailing, 12)
+ }
+ }
+ }
+ // Extend carousel to bleed past the 16dp parent padding
+ .padding(.horizontal, -16)
+ .padding(.leading, 16)
+ }
+
+ // MARK: - Toolbar (matches toolbar_menu.xml: bell + visibility + lock)
+
+ @ToolbarContentBuilder
+ private var toolbarItems: some ToolbarContent {
+ ToolbarItem(placement: .topBarTrailing) {
+ HStack(spacing: 4) {
+ Button { /* notifications — Phase 12 */ } label: {
+ Image(systemName: "bell")
+ }
+ Button {
+ withAnimation { vm.hideAmounts.toggle() }
+ } label: {
+ Image(systemName: vm.hideAmounts ? "eye.slash" : "eye")
+ }
+ Button { app.lock() } label: {
+ Image(systemName: "lock")
+ }
+ }
+ }
+ }
+
+ // MARK: - Empty state
+
+ private var emptyState: some View {
+ VStack(spacing: 12) {
+ Image(systemName: "building.columns")
+ .font(.system(size: 44))
+ .foregroundStyle(.tertiary)
+ Text("No accounts")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+ Text("Pull to refresh or log in to a bank.")
+ .font(.subheadline)
+ .foregroundStyle(.tertiary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ }
+}
+
+// MARK: - Balance card (mirrors MaterialCardView with 12dp radius, 1dp elevation, 16dp padding)
+
+private struct BalanceCard: View {
+ enum Style { case normal, error }
+
+ let label: String
+ let value: String
+ let hidden: Bool
+ let style: Style
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(label)
+ .font(.caption)
+ .foregroundStyle(style == .error ? errorLabelColor : Color.secondary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ Text(hidden ? hiddenValue : value)
+ .font(.headline)
+ .foregroundStyle(style == .error ? errorValueColor : Color.primary)
+ .monospacedDigit()
+ .lineLimit(1)
+ .minimumScaleFactor(0.75)
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(style == .error ? Color(.systemRed).opacity(0.12) : Color(.secondarySystemGroupedBackground),
+ in: RoundedRectangle(cornerRadius: 12))
+ .shadow(color: .black.opacity(0.06), radius: 1, x: 0, y: 1)
+ }
+
+ private var hiddenValue: String {
+ String(value.prefix(4)) + " ••••••"
+ }
+
+ private var errorLabelColor: Color { Color(.systemRed).opacity(0.8) }
+ private var errorValueColor: Color { Color(.systemRed) }
+}
+
+// MARK: - Card carousel item
+
+struct CardCarouselItem: View {
+ let account: BankAccount
+ let hide: Bool
+
+ var body: some View {
+ VStack(spacing: 0) {
+ ZStack(alignment: .bottomLeading) {
+ // Real card art — fall back to colour gradient if no asset
+ if let name = cardImageName, UIImage(named: name) != nil {
+ Image(name)
+ .resizable()
+ .scaledToFill()
+ .frame(width: 300, height: 180)
+ .clipped()
+ } else {
+ Rectangle()
+ .fill(cardGradient)
+ .frame(width: 300, height: 180)
+ }
+
+ // Scrim for text legibility
+ LinearGradient(
+ stops: [
+ .init(color: .clear, location: 0.35),
+ .init(color: .black.opacity(0.65), location: 1.0)
+ ],
+ startPoint: .top, endPoint: .bottom
+ )
+
+ // Name + masked number
+ VStack(alignment: .leading, spacing: 2) {
+ Text(account.profileName.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)
+ }
+
+ // Action buttons
+ HStack(spacing: 4) {
+ CardActionButton(icon: "qrcode", label: "Scan to Pay") {}
+ CardActionButton(icon: "wave.3.right", label: "Tap to Pay") {}
+ }
+ .padding(.horizontal, 10)
+ .padding(.top, 8)
+ .padding(.bottom, 10)
+ }
+ .frame(width: 300)
+ .background(Color(.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 2)
+ }
+
+ // MARK: - Image name resolution
+
+ private var cardImageName: String? {
+ switch account.bank {
+ case "BML": return "card_bml_\(bmlAsset(account.productCode))"
+ case "MIB": return mibCardImageName(account.productCode)
+ default: return nil
+ }
+ }
+
+ // Mirrors BmlCardParser.productCodeToAsset() exactly
+ 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"
+ }
+ }
+
+ // Mirrors CardsFragment.cardImageAsset(MibCard)
+ 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 maskedNumber: String {
+ let raw = account.accountNumber
+ guard raw.count >= 4 else { return raw }
+ if hide { return "•••• •••• •••• ••••" }
+ return "•••• \(String(raw.suffix(4)))"
+ }
+
+ 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: - Card action button (matches TonalButton style, 11sp text, 16dp icon)
+
+private struct CardActionButton: View {
+ let icon: String
+ let label: String
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Label(label, systemImage: icon)
+ .font(.system(size: 11, weight: .medium))
+ .padding(.vertical, 6)
+ .frame(maxWidth: .infinity)
+ .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
+ .foregroundStyle(.tint)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+// MARK: - Quick action button (matches Widget.Material3.Button.OutlinedButton)
+
+private struct QuickActionButton: View {
+ let icon: String
+ let label: String
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Label(label, systemImage: icon)
+ .font(.subheadline.weight(.medium))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(.tint, lineWidth: 1))
+ .foregroundStyle(.tint)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+// MARK: - Error banner
+
+private struct ErrorBanner: View {
+ let bank: String
+ let message: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("\(bank) — could not refresh")
+ .font(.caption.bold())
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ Spacer()
+ }
+ .padding(12)
+ .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
+ }
+}
+
+// MARK: - Skeleton placeholder card
+
+private struct SkeletonCard: View {
+ @State private var shimmer = false
+
+ var body: some View {
+ HStack(spacing: 8) {
+ SkeletonRect(width: nil, height: 60).frame(maxWidth: .infinity)
+ SkeletonRect(width: nil, height: 60).frame(maxWidth: .infinity)
+ }
+ .opacity(shimmer ? 0.4 : 0.9)
+ .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: shimmer)
+ .onAppear { shimmer = true }
+ }
+}
+
+private struct SkeletonRect: View {
+ let width: CGFloat?
+ let height: CGFloat
+ var body: some View {
+ RoundedRectangle(cornerRadius: 12)
+ .fill(Color(.systemFill))
+ .frame(width: width, height: height)
+ }
+}
+
+// MARK: - Foreign Limits Card
+
+private struct ForeignLimitsCard: View {
+ let userName: String
+ let limits: [BmlForeignLimit]
+ let hidden: Bool
+
+ @State private var expandedIndices: Set = []
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ // Header: name + card-type badge
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(userName.isEmpty ? "BML" : userName)
+ .font(.subheadline.bold())
+ Text("USD Foreign Transaction Limits")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(limits.first?.type ?? "Debit")
+ .font(.caption2.bold())
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(.green.opacity(0.2))
+ .foregroundStyle(.green)
+ .clipShape(Capsule())
+ }
+
+ ForEach(Array(limits.enumerated()), id: \.offset) { idx, limit in
+ Divider()
+ LimitCardSection(
+ limit: limit,
+ hidden: hidden,
+ isExpanded: expandedIndices.contains(idx)
+ ) {
+ withAnimation(.easeInOut(duration: 0.22)) {
+ if expandedIndices.contains(idx) { expandedIndices.remove(idx) }
+ else { expandedIndices.insert(idx) }
+ }
+ }
+ }
+ }
+ .padding(16)
+ .background(Color(.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .shadow(color: .black.opacity(0.06), radius: 1, x: 0, y: 1)
+ }
+}
+
+// One limit entry (ECOM + General always; ATM/POS/Medical when expanded)
+private struct LimitCardSection: View {
+ let limit: BmlForeignLimit
+ let hidden: Bool
+ let isExpanded: Bool
+ let onToggle: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ // Always visible
+ limitRow("Online (ECOM)", remaining: limit.ecomRemaining, total: limit.ecomLimit)
+ limitRow("General", remaining: limit.generalRemaining, total: limit.generalCap)
+
+ // Expand / collapse toggle
+ Button(action: onToggle) {
+ HStack(spacing: 4) {
+ Text(isExpanded ? "Show less" : "Show more")
+ .font(.caption)
+ Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
+ .font(.caption2)
+ }
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.plain)
+
+ // Expanded section: ATM, POS, Medical
+ if isExpanded {
+ Divider()
+ limitRow(
+ limit.isAtmEnabled ? "ATM" : "ATM (Disabled)",
+ remaining: limit.atmRemaining, total: limit.atmLimit
+ )
+ limitRow(
+ limit.isPosEnabled ? "POS" : "POS (Disabled)",
+ remaining: limit.posRemaining, total: limit.posLimit
+ )
+ limitRow("Medical", remaining: limit.medicalRemaining, total: limit.totalLimit)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func limitRow(_ label: String, remaining: Double, total: Double) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(label)
+ .font(.subheadline)
+ .foregroundStyle(label.contains("Disabled") ? Color.secondary.opacity(0.5) : Color.secondary)
+ Spacer()
+ Text(hidden ? "USD ••••••" : fmtLimit(remaining, total))
+ .font(.subheadline.bold())
+ .monospacedDigit()
+ .foregroundStyle(label.contains("Disabled") ? .secondary : .primary)
+ }
+ ProgressView(value: (hidden || total <= 0) ? 0.0 : min(remaining / total, 1.0))
+ .progressViewStyle(.linear)
+ .tint(progressTint(remaining, total))
+ }
+ }
+
+ // "USD 1,234.56 / 5,000" — mirrors Kotlin format
+ private func fmtLimit(_ remaining: Double, _ total: Double) -> String {
+ let rf = NumberFormatter(); rf.numberStyle = .decimal
+ rf.minimumFractionDigits = 2; rf.maximumFractionDigits = 2; rf.usesGroupingSeparator = true
+ let tf = NumberFormatter(); tf.numberStyle = .decimal
+ tf.minimumFractionDigits = 0; tf.maximumFractionDigits = 0; tf.usesGroupingSeparator = true
+ let r = rf.string(from: NSNumber(value: remaining)) ?? "0.00"
+ let t = tf.string(from: NSNumber(value: total)) ?? "0"
+ return "USD \(r) / \(t)"
+ }
+
+ private func progressTint(_ remaining: Double, _ total: Double) -> Color {
+ guard total > 0 else { return .secondary }
+ let ratio = remaining / total
+ if ratio > 0.5 { return .green }
+ if ratio > 0.25 { return .orange }
+ return .red
+ }
+}
+
+#Preview {
+ DashboardView()
+ .environment(HomeViewModel())
+ .environment(AppViewModel())
+}
diff --git a/Thijooree iOS/Views/Home/HomeView.swift b/Thijooree iOS/Views/Home/HomeView.swift
new file mode 100644
index 0000000..3fb5acf
--- /dev/null
+++ b/Thijooree iOS/Views/Home/HomeView.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+// Root home container — 5-tab bottom bar matching Android bottom_nav_menu.xml
+struct HomeView: View {
+ @State private var vm = HomeViewModel()
+ @State private var selectedTab = 0
+
+ var body: some View {
+ TabView(selection: $selectedTab) {
+ DashboardView()
+ .tabItem { Label("Dashboard", systemImage: "house.fill") }
+ .tag(0)
+
+ AccountsView()
+ .tabItem { Label("Accounts", systemImage: "building.columns.fill") }
+ .tag(1)
+
+ ContactsView()
+ .tabItem { Label("Contacts", systemImage: "person.2.fill") }
+ .tag(2)
+
+ TransferView()
+ .tabItem { Label("Transfer", systemImage: "arrow.up.right") }
+ .tag(3)
+
+ placeholderTab("More", icon: "ellipsis")
+ .tabItem { Label("More", systemImage: "ellipsis") }
+ .tag(4)
+ }
+ .environment(vm)
+ .task { await vm.refreshIfNeeded() }
+ // When a contact's "Send" button is tapped, jump to the Transfer tab
+ .onChange(of: vm.pendingTransferContact) { _, contact in
+ if contact != nil { selectedTab = 3 }
+ }
+ }
+
+ @ViewBuilder
+ private func placeholderTab(_ title: String, icon: String) -> some View {
+ NavigationStack {
+ VStack(spacing: 16) {
+ Image(systemName: icon)
+ .font(.system(size: 44))
+ .foregroundStyle(.secondary)
+ Text(title)
+ .font(.title2.bold())
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(.systemBackground))
+ .navigationTitle(title)
+ }
+ }
+}
+
+#Preview {
+ HomeView()
+ .environment(AppViewModel())
+}
diff --git a/Thijooree iOS/Views/Home/TransferReceiptView.swift b/Thijooree iOS/Views/Home/TransferReceiptView.swift
new file mode 100644
index 0000000..df449b5
--- /dev/null
+++ b/Thijooree iOS/Views/Home/TransferReceiptView.swift
@@ -0,0 +1,176 @@
+import SwiftUI
+
+struct TransferReceiptView: View {
+ let receipt: TransferReceiptData
+ var onSaveContact: ((BankContact) -> Void)? = nil
+ var onDone: () -> Void
+
+ @State private var contactSaved = false
+
+ private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 0) {
+ successHeader
+ detailsCard
+ Spacer(minLength: 32)
+ doneButton
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 32)
+ }
+ .background(Color(.systemGroupedBackground))
+ .navigationTitle("Receipt")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Done") { onDone() }
+ .foregroundStyle(primary)
+ }
+ }
+ }
+ }
+
+ // MARK: - Sub-views
+
+ private var successHeader: some View {
+ VStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(Color.green.opacity(0.15))
+ .frame(width: 80, height: 80)
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 44))
+ .foregroundStyle(.green)
+ }
+ .padding(.top, 32)
+
+ Text("Transfer Successful")
+ .font(.title2.bold())
+
+ Text(amountText)
+ .font(.system(size: 36, weight: .bold, design: .rounded))
+ .foregroundStyle(primary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.bottom, 24)
+ }
+
+ private var detailsCard: some View {
+ VStack(spacing: 0) {
+ row(label: "From", value: receipt.fromAccountNumber)
+ Divider().padding(.leading, 16)
+ row(label: "Bank", value: receipt.fromBankName)
+ Divider().padding(.leading, 16)
+ row(label: "To Account", value: receipt.toAccountNumber)
+ Divider().padding(.leading, 16)
+ row(label: "Beneficiary", value: receipt.toAccountName.isEmpty ? "—" : receipt.toAccountName)
+ if !receipt.reference.isEmpty {
+ Divider().padding(.leading, 16)
+ row(label: "Reference", value: receipt.reference)
+ }
+ if !receipt.date.isEmpty {
+ Divider().padding(.leading, 16)
+ row(label: "Date", value: receipt.date)
+ }
+ if !receipt.message.isEmpty && receipt.message != "Transfer successful" {
+ Divider().padding(.leading, 16)
+ row(label: "Note", value: receipt.message)
+ }
+ }
+ .background(Color(.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+
+ private var doneButton: some View {
+ VStack(spacing: 12) {
+ if let onSave = onSaveContact {
+ Button {
+ onSave(makeContact())
+ contactSaved = true
+ } label: {
+ Label(contactSaved ? "Contact Saved" : "Save Contact",
+ systemImage: contactSaved ? "checkmark.circle.fill" : "person.badge.plus")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(contactSaved ? Color.green.opacity(0.15) : Color(.secondarySystemGroupedBackground))
+ .foregroundStyle(contactSaved ? .green : primary)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .disabled(contactSaved)
+ }
+ Button(action: onDone) {
+ Text("Done")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(primary)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ }
+
+ private func makeContact() -> BankContact {
+ let source = receipt.fromBankName.lowercased().contains("islamic") ? "MIB" : "BML"
+ let displayName = receipt.toAccountName.isEmpty ? receipt.toAccountNumber : receipt.toAccountName
+ return BankContact(
+ id: "LOCAL_\(source)_\(receipt.toAccountNumber)",
+ benefNo: receipt.toAccountNumber,
+ benefName: displayName,
+ benefNickName: nil,
+ benefAccount: receipt.toAccountNumber,
+ benefType: source,
+ bankColor: nil,
+ benefBankName: nil,
+ bankCode: nil,
+ benefStatus: "Active",
+ transferCyDesc: receipt.currency,
+ customerImgHash: nil,
+ benefCategoryId: nil,
+ profileId: nil,
+ source: source
+ )
+ }
+
+ private func row(label: String, value: String) -> some View {
+ HStack {
+ Text(label)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .frame(width: 110, alignment: .leading)
+ Text(value)
+ .font(.subheadline)
+ .multilineTextAlignment(.trailing)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ }
+
+ private var amountText: String {
+ let f = NumberFormatter()
+ f.numberStyle = .decimal
+ f.minimumFractionDigits = 2
+ f.maximumFractionDigits = 2
+ f.usesGroupingSeparator = true
+ return "\(receipt.currency) \(f.string(from: NSNumber(value: receipt.amount)) ?? "0.00")"
+ }
+}
+
+#Preview {
+ TransferReceiptView(receipt: TransferReceiptData(
+ fromAccountNumber: "7701234567890",
+ fromBankName: "Bank of Maldives",
+ toAccountNumber: "7709876543210",
+ toAccountName: "Ahmed Mohamed",
+ amount: 1500.00,
+ currency: "MVR",
+ reference: "TXN20260607001",
+ date: "2026-06-07 14:32:00",
+ message: "Transfer successful"
+ )) {}
+}
diff --git a/Thijooree iOS/Views/Home/TransferView.swift b/Thijooree iOS/Views/Home/TransferView.swift
new file mode 100644
index 0000000..f698e5a
--- /dev/null
+++ b/Thijooree iOS/Views/Home/TransferView.swift
@@ -0,0 +1,819 @@
+import SwiftUI
+
+struct TransferView: View {
+ private enum FocusedField {
+ case toAccount
+ case amount
+ case remarks
+ }
+
+ @Environment(HomeViewModel.self) private var homeVM
+ @State private var vm = TransferViewModel()
+ @State private var showAccountPicker = false
+ @State private var showContactPicker = false
+ @State private var showBmlOtp = false
+ @State private var showQRScanner = false
+ @State private var accountSearchText = ""
+ @State private var contactSearchText = ""
+ @State private var toInputText = ""
+ @State private var amountInputText = ""
+ @State private var purposeInputText = ""
+ @State private var completedReceipt: ReceiptPresentation? = nil
+ @State private var pendingReceiptAfterOtp: TransferReceiptData? = nil
+ @State private var isProgrammaticToInputChange = false
+ @FocusState private var focusedField: FocusedField?
+
+ private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
+
+ private struct ReceiptPresentation: Identifiable {
+ let id = UUID()
+ let receipt: TransferReceiptData
+ }
+
+ // Accounts eligible to send from
+ private var sendableAccounts: [BankAccount] {
+ homeVM.accounts.filter {
+ ($0.bank == "MIB" || $0.bank == "BML") &&
+ !["BML_CREDIT", "BML_DEBIT", "BML_PREPAID", "BML_LOAN", "MIB_CARD"].contains($0.profileType) &&
+ $0.isActive
+ }
+ }
+
+ private var filteredSendableAccounts: [BankAccount] {
+ guard !accountSearchText.isEmpty else { return sendableAccounts }
+ let query = accountSearchText.lowercased()
+ return sendableAccounts.filter {
+ $0.bank.lowercased().contains(query) ||
+ $0.accountNumber.contains(query) ||
+ $0.accountBriefName.lowercased().contains(query) ||
+ $0.accountTypeName.lowercased().contains(query)
+ }
+ }
+
+ private var destinationAccounts: [BankAccount] {
+ homeVM.accounts.filter {
+ ($0.bank == "MIB" || $0.bank == "BML") &&
+ $0.profileType != "BML_LOAN" &&
+ $0.id != vm.fromAccount?.id
+ }
+ }
+
+ private var filteredDestinationAccounts: [BankAccount] {
+ guard !contactSearchText.isEmpty else { return destinationAccounts }
+ let query = contactSearchText.lowercased()
+ return destinationAccounts.filter {
+ $0.bank.lowercased().contains(query) ||
+ $0.accountNumber.contains(query) ||
+ $0.accountBriefName.lowercased().contains(query) ||
+ $0.accountTypeName.lowercased().contains(query)
+ }
+ }
+
+ private var filteredContacts: [BankContact] {
+ guard !contactSearchText.isEmpty else { return homeVM.contacts }
+ let query = contactSearchText.lowercased()
+ return homeVM.contacts.filter {
+ $0.displayName.lowercased().contains(query) ||
+ $0.benefAccount.contains(query) ||
+ ($0.benefBankName?.lowercased().contains(query) ?? false) ||
+ ($0.benefCategoryId?.lowercased().contains(query) ?? false)
+ }
+ }
+
+ private var canSubmitTransfer: Bool {
+ guard let from = vm.fromAccount else { return false }
+ let amount = Double(amountInputText.replacingOccurrences(of: ",", with: "")) ?? 0
+ guard amount > 0 else { return false }
+ switch vm.lookupState {
+ case .found, .bmlReady:
+ return true
+ default:
+ return from.bank == "BML" && !toInputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+ }
+
+ private var groupedContacts: [(String, [BankContact])] {
+ let groups = Dictionary(grouping: filteredContacts) { contactCategoryTitle($0) }
+ return groups.keys.sorted().compactMap { key in
+ guard let contacts = groups[key], !contacts.isEmpty else { return nil }
+ return (key, contacts.sorted { $0.displayName < $1.displayName })
+ }
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 16) {
+ fromAccountCard
+ toAccountCard
+ amountCard
+ remarksCard
+ transferButton
+ }
+ .padding(16)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .background(Color(.systemGroupedBackground))
+ .contentShape(Rectangle())
+ .onTapGesture { focusedField = nil }
+ .navigationTitle("Transfer")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+ .sheet(isPresented: $showAccountPicker) {
+ accountPickerSheet
+ .onDisappear { accountSearchText = "" }
+ }
+ .sheet(isPresented: $showBmlOtp, onDismiss: presentPendingReceiptAfterOtp) {
+ bmlOtpSheet
+ }
+ .sheet(isPresented: $showContactPicker) {
+ contactPickerSheet
+ .onDisappear { contactSearchText = "" }
+ }
+ .sheet(item: $completedReceipt, onDismiss: finishCompletedTransfer) { presentation in
+ TransferReceiptView(
+ receipt: presentation.receipt,
+ onSaveContact: { contact in
+ homeVM.saveContact(contact)
+ }
+ ) {
+ completedReceipt = nil
+ finishCompletedTransfer()
+ }
+ }
+ .sheet(isPresented: $showQRScanner) {
+ QRScannerSheet { qrContent in
+ handleQR(qrContent)
+ }
+ }
+ .onChange(of: vm.transferState) { _, state in
+ switch state {
+ case .success(let receipt):
+ focusedField = nil
+ if showBmlOtp {
+ pendingReceiptAfterOtp = receipt
+ showBmlOtp = false
+ } else {
+ completedReceipt = ReceiptPresentation(receipt: receipt)
+ }
+ case .bmlStep2:
+ focusedField = nil
+ showBmlOtp = true
+ default:
+ break
+ }
+ }
+ .onChange(of: homeVM.pendingTransferContact) { _, contact in
+ guard let contact else { return }
+ selectContact(contact)
+ homeVM.pendingTransferContact = nil
+ }
+ .toolbar {
+ ToolbarItemGroup(placement: .keyboard) {
+ Spacer()
+ Button("Done") { focusedField = nil }
+ }
+ }
+ }
+
+ // MARK: - From account card
+
+ private var fromAccountCard: some View {
+ Button { showAccountPicker = true } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("From Account")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ if let acc = vm.fromAccount {
+ Text(acc.accountBriefName.isEmpty ? acc.accountTypeName : acc.accountBriefName)
+ .font(.subheadline.bold())
+ Text(acc.accountNumber)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ Text(acc.formattedAvailableBalance)
+ .font(.caption)
+ .foregroundStyle(primary)
+ } else {
+ Text("Select account")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ Image(systemName: "chevron.down")
+ .foregroundStyle(.secondary)
+ }
+ .padding(16)
+ .background(Color(.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .buttonStyle(.plain)
+ }
+
+ // MARK: - To account card
+
+ private var toAccountCard: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("To Account")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 10) {
+ // Text field with lookup icon inside its border
+ HStack(spacing: 8) {
+ TextField("Account Number or Favara ID", text: $toInputText)
+ .focused($focusedField, equals: .toAccount)
+ .keyboardType(.asciiCapable)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .submitLabel(.next)
+ .onSubmit { focusedField = .amount }
+ .font(.subheadline)
+ .onChange(of: toInputText) { _, _ in
+ if isProgrammaticToInputChange {
+ isProgrammaticToInputChange = false
+ return
+ }
+ vm.lookupState = .idle
+ vm.bmlTrnType = "IAT"
+ vm.bmlBank = nil
+ vm.bmlCreditAccountOverride = nil
+ }
+
+ if vm.fromAccount?.bank == "MIB" || vm.fromAccount?.bank == "BML" {
+ lookupButton
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(Color(.systemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color(.separator), lineWidth: 0.5)
+ )
+
+ // Contact picker
+ Button { showContactPicker = true } label: {
+ Image(systemName: "person.fill")
+ .font(.subheadline)
+ .foregroundStyle(.white)
+ .frame(width: 40, height: 40)
+ .background(Color.green)
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+
+ // QR scanner
+ Button { showQRScanner = true } label: {
+ Image(systemName: "qrcode.viewfinder")
+ .font(.subheadline)
+ .foregroundStyle(.white)
+ .frame(width: 40, height: 40)
+ .background(Color.green)
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+
+ lookupResultRow
+ }
+ .padding(16)
+ .background(Color(.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+
+ @ViewBuilder
+ private var lookupButton: some View {
+ switch vm.lookupState {
+ case .loading:
+ ProgressView()
+ .controlSize(.small)
+ default:
+ Button {
+ performBeneficiaryLookup()
+ } label: {
+ Image(systemName: "magnifyingglass")
+ .font(.subheadline)
+ .foregroundStyle(toInputText.isEmpty ? Color.secondary : primary)
+ }
+ .disabled(toInputText.isEmpty)
+ }
+ }
+
+ @ViewBuilder
+ private var lookupResultRow: some View {
+ switch vm.lookupState {
+ case .found(let r):
+ HStack(spacing: 6) {
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(r.accountName).font(.caption.bold())
+ Text(r.bankName).font(.caption2).foregroundStyle(.secondary)
+ }
+ }
+ case .bmlReady(let name) where !name.isEmpty:
+ HStack(spacing: 6) {
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
+ Text(name).font(.caption.bold())
+ }
+ case .error(let msg):
+ HStack(spacing: 6) {
+ Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
+ Text(msg).font(.caption).foregroundStyle(.red)
+ }
+ default:
+ EmptyView()
+ }
+ }
+
+ // MARK: - Amount card
+
+ private var amountCard: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Amount")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 16)
+ .padding(.top, 12)
+ HStack {
+ Text(vm.fromAccount?.currencyName ?? "MVR")
+ .font(.subheadline.bold())
+ .foregroundStyle(primary)
+ .frame(width: 44)
+ TextField("0.00", text: $amountInputText)
+ .focused($focusedField, equals: .amount)
+ .keyboardType(.decimalPad)
+ .submitLabel(.next)
+ .onSubmit { focusedField = .remarks }
+ .font(.title3.bold())
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 12)
+ }
+ .background(Color(.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+
+ // MARK: - Remarks card
+
+ private var remarksCard: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Remarks")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 16)
+ .padding(.top, 12)
+ TextField("Fund Transfer", text: $purposeInputText)
+ .focused($focusedField, equals: .remarks)
+ .submitLabel(.done)
+ .onSubmit { focusedField = nil }
+ .font(.subheadline)
+ .padding(.horizontal, 16)
+ .padding(.bottom, 12)
+ }
+ .background(Color(.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+
+ // MARK: - Transfer button
+
+ @ViewBuilder
+ private var transferButton: some View {
+ if case .processing = vm.transferState {
+ ProgressView("Processing transfer…")
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ } else {
+ VStack(spacing: 8) {
+ if case .failure(let msg) = vm.transferState {
+ Text(msg)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ }
+ Button {
+ syncFormToViewModel()
+ focusedField = nil
+ Task { await vm.submitTransfer() }
+ } label: {
+ Text("Transfer")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(canSubmitTransfer ? primary : Color.gray.opacity(0.4))
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .disabled(!canSubmitTransfer)
+ }
+ }
+ }
+
+ // MARK: - Account picker sheet
+
+ private var accountPickerSheet: some View {
+ NavigationStack {
+ List(filteredSendableAccounts) { acc in
+ Button {
+ vm.fromAccount = acc
+ vm.lookupState = .idle
+ vm.bmlTrnType = "IAT"
+ vm.bmlBank = nil
+ vm.bmlCreditAccountOverride = nil
+ showAccountPicker = false
+ } label: {
+ HStack {
+ bankDot(acc.bank)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(acc.accountBriefName.isEmpty ? acc.accountTypeName : acc.accountBriefName)
+ .font(.subheadline.bold())
+ .foregroundStyle(.primary)
+ Text(acc.accountNumber)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(acc.formattedAvailableBalance)
+ .font(.caption.bold())
+ .foregroundStyle(primary)
+ }
+ }
+ }
+ .navigationTitle("From Account")
+ .navigationBarTitleDisplayMode(.inline)
+ .searchable(text: $accountSearchText, prompt: "Search account")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Cancel") { showAccountPicker = false }
+ }
+ }
+ }
+ }
+
+ // MARK: - BML OTP sheet
+
+ private var bmlOtpSheet: some View {
+ NavigationStack {
+ VStack(spacing: 24) {
+ Image(systemName: "lock.shield.fill")
+ .font(.system(size: 48))
+ .foregroundStyle(primary)
+ Text("Confirm Transfer")
+ .font(.title2.bold())
+ Text("The OTP will be generated automatically from your stored authenticator seed.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+
+ if case .processing = vm.transferState {
+ ProgressView("Confirming…")
+ } else {
+ Button {
+ guard let pid = vm.fromAccount?.profileId
+ ?? vm.fromAccount.flatMap({ CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles($0.loginTag.replacingOccurrences(of: "bml_", with: ""))).first }),
+ let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(
+ vm.fromAccount?.loginTag.replacingOccurrences(of: "bml_", with: "") ?? ""
+ )) else { return }
+ let _ = pid // suppress unused warning
+ Task { await vm.confirmBmlOtp(otpSeed: seed) }
+ } label: {
+ Text("Confirm with Authenticator")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(primary)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .padding(.horizontal, 24)
+
+ if case .failure(let msg) = vm.transferState {
+ Text(msg)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(24)
+ .navigationTitle("OTP Confirmation")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") {
+ vm.transferState = .idle
+ showBmlOtp = false
+ }
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ .onChange(of: vm.transferState) { _, state in
+ if case .success = state {
+ showBmlOtp = false
+ }
+ }
+ }
+
+ // MARK: - Contact picker sheet
+
+ private var contactPickerSheet: some View {
+ NavigationStack {
+ List {
+ if !filteredDestinationAccounts.isEmpty {
+ Section("My Accounts") {
+ ForEach(filteredDestinationAccounts) { account in
+ Button {
+ selectDestinationAccount(account)
+ showContactPicker = false
+ } label: {
+ destinationAccountRow(account)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ ForEach(groupedContacts, id: \.0) { title, contacts in
+ Section(title) {
+ ForEach(contacts) { contact in
+ Button {
+ selectContact(contact)
+ showContactPicker = false
+ } label: {
+ contactPickerRow(contact)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .navigationTitle("Select Contact")
+ .navigationBarTitleDisplayMode(.inline)
+ .searchable(text: $contactSearchText, prompt: "Search contacts")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Cancel") { showContactPicker = false }
+ }
+ }
+ }
+ }
+
+ // MARK: - Transfer completion
+
+ private func finishCompletedTransfer() {
+ completedReceipt = nil
+ pendingReceiptAfterOtp = nil
+ focusedField = nil
+ showBmlOtp = false
+ vm.reset()
+ toInputText = ""
+ amountInputText = ""
+ purposeInputText = ""
+ }
+
+ private func presentPendingReceiptAfterOtp() {
+ guard let receipt = pendingReceiptAfterOtp else { return }
+ pendingReceiptAfterOtp = nil
+ completedReceipt = ReceiptPresentation(receipt: receipt)
+ }
+
+ private func syncFormToViewModel() {
+ vm.toInput = toInputText
+ vm.amountInput = amountInputText
+ vm.purposeInput = purposeInputText
+ }
+
+ private func setToInput(_ value: String) {
+ guard toInputText != value else {
+ vm.toInput = value
+ isProgrammaticToInputChange = false
+ return
+ }
+ isProgrammaticToInputChange = true
+ toInputText = value
+ vm.toInput = value
+ }
+
+ // MARK: - QR handler
+
+ private func performBeneficiaryLookup() {
+ syncFormToViewModel()
+ if resolveCachedRecipient() { return }
+ Task { await vm.lookupBeneficiary() }
+ }
+
+ private func resolveCachedRecipient() -> Bool {
+ let query = toInputText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !query.isEmpty else { return false }
+
+ if let account = destinationAccounts.first(where: { $0.accountNumber == query }) {
+ selectDestinationAccount(account)
+ return true
+ }
+ if let contact = homeVM.contacts.first(where: {
+ $0.benefAccount == query ||
+ $0.benefNo == query ||
+ $0.displayName.caseInsensitiveCompare(query) == .orderedSame
+ }) {
+ selectContact(contact)
+ return true
+ }
+ return false
+ }
+
+ private func handleQR(_ content: String) {
+ if let result = PaymvQrParser.parse(content) {
+ setToInput(result.account)
+ vm.lookupState = .idle
+ if let amount = result.amount, !amount.isEmpty {
+ amountInputText = amount
+ vm.amountInput = amount
+ }
+ if let purpose = result.purpose, !purpose.isEmpty {
+ purposeInputText = purpose
+ vm.purposeInput = purpose
+ } else if let name = result.merchantName, !name.isEmpty {
+ purposeInputText = "Pay \(name)"
+ vm.purposeInput = purposeInputText
+ }
+ // Trigger lookup for the filled account
+ if vm.fromAccount?.bank == "MIB" {
+ Task { await vm.lookupBeneficiary() }
+ } else if vm.fromAccount?.bank == "BML" {
+ Task { await vm.lookupBeneficiary() }
+ }
+ } else {
+ // Fallback: treat raw content as an account number
+ setToInput(content)
+ vm.lookupState = .idle
+ }
+ }
+
+ private func destinationAccountRow(_ account: BankAccount) -> some View {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(contactColor(account.bank).opacity(0.15))
+ .frame(width: 36, height: 36)
+ Text(accountInitials(account))
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(contactColor(account.bank))
+ }
+ VStack(alignment: .leading, spacing: 3) {
+ Text(account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName)
+ .font(.subheadline.bold())
+ .foregroundStyle(.primary)
+ Text("\(account.bank) · \(account.accountNumber)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer()
+ Text(account.formattedAvailableBalance)
+ .font(.caption.bold())
+ .foregroundStyle(primary)
+ }
+ }
+
+ private func contactPickerRow(_ contact: BankContact) -> some View {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(contactColor(contact.source).opacity(0.15))
+ .frame(width: 36, height: 36)
+ Text(contactInitials(contact.displayName))
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(contactColor(contact.source))
+ }
+ VStack(alignment: .leading, spacing: 3) {
+ Text(contact.displayName)
+ .font(.subheadline.bold())
+ .foregroundStyle(.primary)
+ Text(contactSubtitle(contact))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer()
+ Text(contact.source)
+ .font(.caption2.bold())
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ .background(contactColor(contact.source).opacity(0.12), in: Capsule())
+ .foregroundStyle(contactColor(contact.source))
+ }
+ }
+
+ private func selectDestinationAccount(_ account: BankAccount) {
+ setToInput(account.accountNumber)
+ guard let from = vm.fromAccount else { return }
+
+ if from.bank == "MIB" {
+ let isMibInternal = account.bank == "MIB"
+ vm.lookupState = .found(MibAccountLookupResult(
+ accountNumber: account.accountNumber,
+ accountName: account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName,
+ bankName: isMibInternal ? "Maldives Islamic Bank" : "Bank of Maldives",
+ bankCode: isMibInternal ? "2" : "3",
+ aliasId: nil,
+ network: isMibInternal ? .mibInternal : .local
+ ))
+ } else if from.bank == "BML" {
+ if account.bank == "MIB" {
+ vm.bmlTrnType = "DOT"
+ vm.bmlBank = "MIB"
+ vm.bmlCreditAccountOverride = nil
+ } else if isBmlCard(account) {
+ vm.bmlTrnType = "CPA"
+ vm.bmlBank = nil
+ vm.bmlCreditAccountOverride = (account.internalId ?? "").isEmpty ? account.accountNumber : account.internalId
+ } else {
+ vm.bmlTrnType = "IAT"
+ vm.bmlBank = nil
+ vm.bmlCreditAccountOverride = nil
+ }
+ vm.lookupState = .bmlReady(account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName)
+ }
+ }
+
+ private func selectContact(_ contact: BankContact) {
+ setToInput(contact.benefAccount)
+ guard let fromBank = vm.fromAccount?.bank else { return }
+
+ if fromBank == "MIB" {
+ let isMibInternal = contact.source == "MIB" && contact.benefType != "LOCAL"
+ vm.lookupState = .found(MibAccountLookupResult(
+ accountNumber: contact.benefAccount,
+ accountName: contact.displayName,
+ bankName: contact.benefBankName ?? (isMibInternal ? "Maldives Islamic Bank" : "Local Bank"),
+ bankCode: contact.bankCode ?? (isMibInternal ? "2" : "3"),
+ aliasId: contact.benefNo,
+ network: isMibInternal ? .mibInternal : .local
+ ))
+ } else if fromBank == "BML" {
+ vm.bmlTrnType = contact.source == "MIB" ? "DOT" : "IAT"
+ vm.bmlBank = contact.source == "MIB" ? "MIB" : nil
+ vm.bmlCreditAccountOverride = nil
+ vm.lookupState = .bmlReady(contact.displayName)
+ }
+ }
+
+ private func contactCategoryTitle(_ contact: BankContact) -> String {
+ if let category = contact.benefCategoryId, !category.isEmpty, category != "BML" {
+ return category
+ }
+ switch contact.source {
+ case "MIB": return "MIB Contacts"
+ case "BML": return "BML Contacts"
+ default: return "Other Contacts"
+ }
+ }
+
+ private func contactSubtitle(_ contact: BankContact) -> String {
+ let bank = contact.benefBankName ?? contact.source
+ return "\(bank) · \(contact.benefAccount)"
+ }
+
+ private func accountInitials(_ account: BankAccount) -> String {
+ let label = account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName
+ let words = label.split(separator: " ").prefix(2)
+ let initials = words.compactMap { $0.first }.map(String.init).joined()
+ return initials.isEmpty ? account.bank : initials
+ }
+
+ private func isBmlCard(_ account: BankAccount) -> Bool {
+ account.profileType == "BML_CREDIT" ||
+ account.profileType == "BML_DEBIT" ||
+ account.profileType == "BML_PREPAID"
+ }
+
+ private func contactInitials(_ name: String) -> String {
+ let words = name.split(separator: " ").prefix(2)
+ return words.compactMap { $0.first }.map(String.init).joined()
+ }
+
+ private func contactColor(_ source: String) -> Color {
+ switch source {
+ case "MIB": return primary
+ case "BML": return .blue
+ default: return .purple
+ }
+ }
+
+ private func bankDot(_ bank: String) -> some View {
+ Circle()
+ .fill(bank == "MIB" ? Color(red: 0.247, green: 0.396, blue: 0.678) : Color.blue)
+ .frame(width: 10, height: 10)
+ }
+}
+
+#Preview {
+ TransferView()
+ .environment(HomeViewModel())
+}
diff --git a/Thijooree iOS/Views/Login/BankSelectionView.swift b/Thijooree iOS/Views/Login/BankSelectionView.swift
new file mode 100644
index 0000000..1d3a53b
--- /dev/null
+++ b/Thijooree iOS/Views/Login/BankSelectionView.swift
@@ -0,0 +1,102 @@
+import SwiftUI
+
+struct BankSelectionView: View {
+ @State private var selectedBank: String? = nil
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ VStack(spacing: 8) {
+ Image(systemName: "building.columns.fill")
+ .font(.system(size: 44))
+ .foregroundStyle(.tint)
+ Text("Add a Bank Account")
+ .font(.title2.bold())
+ Text("Choose which bank to connect.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.top, 40)
+ .padding(.bottom, 32)
+
+ VStack(spacing: 16) {
+ BankCard(
+ bank: "MIB",
+ name: "Maldives Islamic Bank",
+ subtitle: "Faisanet personal & business",
+ icon: "building.2.fill",
+ color: .green,
+ destination: { CredentialsView(bank: "MIB") }
+ )
+ BankCard(
+ bank: "BML",
+ name: "Bank of Maldives",
+ subtitle: "Internet banking · all profiles",
+ icon: "creditcard.fill",
+ color: .blue,
+ destination: { CredentialsView(bank: "BML") }
+ )
+ BankCard(
+ bank: "FAHIPAY",
+ name: "Fahipay Wallet",
+ subtitle: "Mobile wallet · Ooredoo · Dhiraagu",
+ icon: "wallet.pass.fill",
+ color: .orange,
+ destination: { CredentialsView(bank: "FAHIPAY") }
+ )
+ }
+ .padding(.horizontal, 20)
+
+ Spacer()
+ }
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+ }
+}
+
+private struct BankCard: View {
+ let bank: String
+ let name: String
+ let subtitle: String
+ let icon: String
+ let color: Color
+ @ViewBuilder let destination: () -> Dest
+
+ var body: some View {
+ NavigationLink(destination: destination()) {
+ HStack(spacing: 16) {
+ RoundedRectangle(cornerRadius: 12)
+ .fill(color.opacity(0.15))
+ .frame(width: 52, height: 52)
+ .overlay(
+ Image(systemName: icon)
+ .font(.system(size: 24))
+ .foregroundStyle(color)
+ )
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(name)
+ .font(.headline)
+ .foregroundStyle(.primary)
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(16)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+#Preview {
+ BankSelectionView()
+}
diff --git a/Thijooree iOS/Views/Login/CredentialsView.swift b/Thijooree iOS/Views/Login/CredentialsView.swift
new file mode 100644
index 0000000..318e33a
--- /dev/null
+++ b/Thijooree iOS/Views/Login/CredentialsView.swift
@@ -0,0 +1,249 @@
+import SwiftUI
+
+struct CredentialsView: View {
+ let bank: String
+
+ @Environment(AppViewModel.self) private var app
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var vm = LoginViewModel()
+
+ @State private var username = ""
+ @State private var password = ""
+ @State private var otpSeed = ""
+ @State private var totpCode = "" // Fahipay 2-step verification code
+
+ @State private var isLoading = false
+ @State private var errorText = ""
+ @State private var showOtpPreview = false
+
+ private var isFahipay: Bool { bank == "FAHIPAY" }
+ private var awaitingFahipayTotp: Bool {
+ if case .fahipayNeedTotp = vm.state { return true }
+ return false
+ }
+
+ private var resolvedSeed: String { LoginViewModel.resolveOtpSeed(otpSeed) }
+
+ private var canSubmit: Bool {
+ let u = username.trimmingCharacters(in: .whitespaces)
+ let p = password
+ if awaitingFahipayTotp { return totpCode.count == 6 }
+ if isFahipay { return !u.isEmpty && !p.isEmpty }
+ return !u.isEmpty && !p.isEmpty && LoginViewModel.isValidOtpSeed(otpSeed)
+ }
+
+ // MARK: - Body
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 24) {
+ bankHeader
+
+ credentialFields
+
+ if !isFahipay && !resolvedSeed.isEmpty && LoginViewModel.isValidOtpSeed(otpSeed) {
+ OtpPreviewView(seed: resolvedSeed)
+ .padding(.horizontal, 4)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+
+ if !errorText.isEmpty {
+ Text(errorText)
+ .font(.callout)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ .transition(.opacity)
+ }
+
+ Button(submitLabel) { attemptLogin() }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(!canSubmit || isLoading)
+ .padding(.horizontal, 4)
+
+ if isLoading {
+ ProgressView()
+ }
+ }
+ .padding(24)
+ }
+ .navigationTitle(bankTitle)
+ .navigationBarTitleDisplayMode(.large)
+ .animation(.easeInOut(duration: 0.2), value: resolvedSeed)
+ .animation(.easeInOut(duration: 0.2), value: errorText)
+ }
+
+ // MARK: - Bank header
+
+ @ViewBuilder
+ private var bankHeader: some View {
+ VStack(spacing: 6) {
+ Text(bankDescription)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ // MARK: - Fields
+
+ @ViewBuilder
+ private var credentialFields: some View {
+ VStack(spacing: 14) {
+ if awaitingFahipayTotp {
+ // Fahipay 2-step: only show the TOTP field
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Authenticator Code")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("6-digit code", text: $totpCode)
+ .keyboardType(.numberPad)
+ .textContentType(.oneTimeCode)
+ .padding(12)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
+ }
+
+ Text("Open your authenticator app and enter the 6-digit code for Fahipay.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+
+ } else {
+ // Username
+ VStack(alignment: .leading, spacing: 6) {
+ Text(isFahipay ? "ID Card Number" : "Username")
+ .font(.caption).foregroundStyle(.secondary)
+ TextField(isFahipay ? "A000000" : "Enter username", text: $username)
+ .textContentType(.username)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .padding(12)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
+ .disabled(awaitingFahipayTotp)
+ }
+
+ // Password
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Password")
+ .font(.caption).foregroundStyle(.secondary)
+ SecureField("Enter password", text: $password)
+ .textContentType(.password)
+ .padding(12)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
+ .disabled(awaitingFahipayTotp)
+ }
+
+ // OTP seed (MIB + BML only)
+ if !isFahipay {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Authenticator Secret")
+ .font(.caption).foregroundStyle(.secondary)
+ TextField("Paste TOTP secret or otpauth:// URI", text: $otpSeed)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .textContentType(.none)
+ .padding(12)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
+ }
+
+ Text("This is the secret from your authenticator app — not the 6-digit code.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+ }
+ }
+
+ // MARK: - Login logic
+
+ private func attemptLogin() {
+ withAnimation { errorText = "" }
+ isLoading = true
+
+ Task {
+ do {
+ switch bank {
+ case "MIB":
+ let accounts = try await vm.loginMib(
+ username: username.trimmingCharacters(in: .whitespaces),
+ password: password,
+ otpSeed: resolvedSeed
+ )
+ await MainActor.run { finish(accounts: accounts) }
+
+ case "BML":
+ let accounts = try await vm.loginBml(
+ username: username.trimmingCharacters(in: .whitespaces),
+ password: password,
+ otpSeed: resolvedSeed
+ )
+ await MainActor.run { finish(accounts: accounts) }
+
+ case "FAHIPAY":
+ if awaitingFahipayTotp {
+ try await vm.verifyFahipayTotp(totpCode)
+ await MainActor.run { finish(accounts: []) }
+ } else {
+ let step = try await vm.loginFahipay(
+ idCard: username.trimmingCharacters(in: .whitespaces),
+ password: password
+ )
+ await MainActor.run {
+ if step.twoFactorRequired {
+ withAnimation { vm.state = .fahipayNeedTotp }
+ } else if let authId = step.authId {
+ vm.completeFahipayLogin(authId: authId)
+ finish(accounts: [])
+ }
+ }
+ }
+
+ default: break
+ }
+ } catch {
+ await MainActor.run {
+ withAnimation { errorText = error.localizedDescription }
+ }
+ }
+ await MainActor.run { isLoading = false }
+ }
+ }
+
+ private func finish(accounts: [BankAccount]) {
+ app.loginSucceeded()
+ }
+
+ // MARK: - Computed strings
+
+ private var bankTitle: String {
+ switch bank {
+ case "MIB": return "MIB Faisanet"
+ case "BML": return "Bank of Maldives"
+ case "FAHIPAY": return "Fahipay Wallet"
+ default: return bank
+ }
+ }
+
+ private var bankDescription: String {
+ switch bank {
+ case "MIB": return "Sign in with your Faisanet username and password.\nPaste your TOTP secret from your authenticator app."
+ case "BML": return "Sign in with your BML Internet Banking credentials.\nAll personal profiles will be activated automatically."
+ case "FAHIPAY": return "Sign in with your Fahipay ID card number and password."
+ default: return ""
+ }
+ }
+
+ private var submitLabel: String {
+ if awaitingFahipayTotp { return "Verify Code" }
+ return "Sign In"
+ }
+}
+
+#Preview {
+ NavigationStack {
+ CredentialsView(bank: "MIB")
+ .environment(AppViewModel())
+ }
+}
diff --git a/Thijooree iOS/Views/Onboarding/OnboardingView.swift b/Thijooree iOS/Views/Onboarding/OnboardingView.swift
new file mode 100644
index 0000000..525d982
--- /dev/null
+++ b/Thijooree iOS/Views/Onboarding/OnboardingView.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+
+struct OnboardingView: View {
+ @Environment(AppViewModel.self) private var app
+ @State private var page = 0
+ @State private var pinDone = false
+
+ var body: some View {
+ TabView(selection: $page) {
+ WelcomePage(onNext: { page = 1 })
+ .tag(0)
+
+ SecuritySetupView(onComplete: {
+ pinDone = true
+ withAnimation { page = 2 }
+ })
+ .tag(1)
+
+ FinishPage(onGetStarted: { app.completeOnboarding() })
+ .tag(2)
+ }
+ .tabViewStyle(.page(indexDisplayMode: .never))
+ // Prevent swiping forward past the PIN page until it's configured
+ .gesture(
+ DragGesture().onEnded { v in
+ let forward = v.translation.width < -40
+ if forward && page == 1 && !pinDone { return }
+ }
+ )
+ .ignoresSafeArea()
+ }
+}
+
+// MARK: - Page 1: Welcome
+
+private struct WelcomePage: View {
+ let onNext: () -> Void
+
+ var body: some View {
+ ZStack {
+ Color(.systemBackground).ignoresSafeArea()
+
+ VStack(spacing: 0) {
+ Spacer()
+
+ VStack(spacing: 20) {
+ Image(systemName: "building.columns.fill")
+ .font(.system(size: 72))
+ .foregroundStyle(.tint)
+
+ VStack(spacing: 8) {
+ Text("Thijooree")
+ .font(.largeTitle.bold())
+ Text("MIB · BML · Fahipay — one app.")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+
+ VStack(alignment: .leading, spacing: 12) {
+ FeatureRow(icon: "lock.shield.fill", text: "Your credentials stay on your device — no backend, no middleman.")
+ FeatureRow(icon: "faceid", text: "Face ID & Touch ID unlock in an instant.")
+ FeatureRow(icon: "arrow.triangle.2.circlepath", text: "Balances from all your banks in one tap.")
+ }
+ .padding(.horizontal, 32)
+ .padding(.top, 8)
+ }
+
+ Spacer()
+
+ Button("Get Started") { onNext() }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .padding(.horizontal, 40)
+ .padding(.bottom, 48)
+ }
+ }
+ }
+}
+
+private struct FeatureRow: View {
+ let icon: String
+ let text: String
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: icon)
+ .font(.system(size: 20))
+ .foregroundStyle(.tint)
+ .frame(width: 28)
+ Text(text)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+// MARK: - Page 3: Finish
+
+private struct FinishPage: View {
+ let onGetStarted: () -> Void
+
+ var body: some View {
+ ZStack {
+ Color(.systemBackground).ignoresSafeArea()
+
+ VStack(spacing: 32) {
+ Spacer()
+
+ VStack(spacing: 16) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 72))
+ .foregroundStyle(.green)
+
+ Text("You're all set!")
+ .font(.title.bold())
+
+ Text("Add your first bank account to get started.")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 40)
+ }
+
+ Spacer()
+
+ Button("Continue to Thijooree") { onGetStarted() }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .padding(.horizontal, 40)
+ .padding(.bottom, 48)
+ }
+ }
+ }
+}
+
+#Preview {
+ OnboardingView()
+ .environment(AppViewModel())
+}
diff --git a/Thijooree iOS/Views/Security/LockScreenView.swift b/Thijooree iOS/Views/Security/LockScreenView.swift
new file mode 100644
index 0000000..d231098
--- /dev/null
+++ b/Thijooree iOS/Views/Security/LockScreenView.swift
@@ -0,0 +1,170 @@
+import LocalAuthentication
+import SwiftUI
+
+struct LockScreenView: View {
+ var onUnlocked: () -> Void
+
+ @State private var vm = LockViewModel()
+ @State private var hintText = ""
+ @State private var lockoutSeconds = 0
+ @State private var lockoutTimer: Timer?
+
+ private let biometricsEnabled = UserDefaults.standard.bool(forKey: "biometrics_enabled")
+
+ var body: some View {
+ VStack(spacing: 28) {
+ Spacer()
+
+ VStack(spacing: 8) {
+ Image(systemName: "lock.fill")
+ .font(.system(size: 48))
+ .foregroundStyle(.secondary)
+
+ Text("Enter PIN")
+ .font(.title2).bold()
+ }
+
+ Text(vm.dotsDisplay)
+ .font(.system(size: 30, design: .monospaced))
+ .kerning(6)
+ .frame(height: 44)
+ .animation(.spring(response: 0.2), value: vm.pinDigits.count)
+
+ Group {
+ if lockoutSeconds > 0 {
+ Text("Try again in \(lockoutSeconds)s")
+ .foregroundStyle(.red)
+ } else if !hintText.isEmpty {
+ Text(hintText)
+ .foregroundStyle(.red)
+ } else {
+ Text(" ") // placeholder to keep layout stable
+ }
+ }
+ .font(.callout)
+
+ NumpadView { key in handleKey(key) }
+ .disabled(lockoutSeconds > 0 || vm.isVerifying)
+ .opacity(lockoutSeconds > 0 ? 0.4 : 1)
+
+ if biometricsEnabled {
+ Button { triggerBiometric() } label: {
+ Label(biometricLabel(), systemImage: biometricIcon())
+ .font(.callout)
+ }
+ .foregroundStyle(.secondary)
+ .padding(.top, 4)
+ }
+
+ Spacer()
+ }
+ .task {
+ if biometricsEnabled { triggerBiometric() }
+ // Resume any active lockout on re-appearance
+ if vm.isLockedOut { startLockoutTimer() }
+ }
+ .onDisappear {
+ lockoutTimer?.invalidate()
+ lockoutTimer = nil
+ }
+ }
+
+ // MARK: - Key handling
+
+ private func handleKey(_ key: String) {
+ guard !vm.isLockedOut else { startLockoutTimer(); return }
+ hintText = ""
+ if vm.handleKey(key) { verifyPin() }
+ }
+
+ // MARK: - Verification
+
+ private func verifyPin() {
+ guard !vm.isVerifying else { return }
+ vm.isVerifying = true
+ let entered = vm.currentPin()
+ vm.clearPin()
+
+ Task {
+ let ok = await verifyInBackground(entered)
+ await MainActor.run {
+ vm.isVerifying = false
+ if ok {
+ vm.resetFailures()
+ onUnlocked()
+ } else {
+ showFailure()
+ }
+ }
+ }
+ }
+
+ private func verifyInBackground(_ input: String) async -> Bool {
+ await Task.detached(priority: .userInitiated) {
+ guard
+ let saltB64 = CredentialStore.shared.load(forKey: CredentialStore.Keys.securityHashSalt),
+ let salt = Data(base64Encoded: saltB64),
+ let stored = CredentialStore.shared.load(forKey: CredentialStore.Keys.securityHash)
+ else { return false }
+ return PinHash.verify(input, against: stored, salt: salt)
+ }.value
+ }
+
+ private func showFailure() {
+ withAnimation { hintText = vm.failureHint() }
+ if vm.isLockedOut {
+ startLockoutTimer()
+ } else {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+ withAnimation { hintText = "" }
+ }
+ }
+ }
+
+ // MARK: - Lockout timer
+
+ private func startLockoutTimer() {
+ lockoutSeconds = Int(ceil(vm.lockoutRemaining))
+ guard lockoutSeconds > 0 else { return }
+ hintText = ""
+ lockoutTimer?.invalidate()
+ lockoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
+ lockoutSeconds = Int(ceil(vm.lockoutRemaining))
+ if lockoutSeconds <= 0 {
+ lockoutTimer?.invalidate()
+ lockoutTimer = nil
+ }
+ }
+ }
+
+ // MARK: - Biometric
+
+ private func triggerBiometric() {
+ let ctx = LAContext()
+ var err: NSError?
+ guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) else { return }
+ ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "Unlock Thijooree") { success, _ in
+ guard success else { return }
+ Task { @MainActor in
+ vm.resetFailures()
+ onUnlocked()
+ }
+ }
+ }
+
+ private func biometricLabel() -> String {
+ let ctx = LAContext()
+ _ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
+ return ctx.biometryType == .faceID ? "Face ID" : "Touch ID"
+ }
+
+ private func biometricIcon() -> String {
+ let ctx = LAContext()
+ _ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
+ return ctx.biometryType == .faceID ? "faceid" : "touchid"
+ }
+}
+
+#Preview {
+ LockScreenView(onUnlocked: {})
+}
diff --git a/Thijooree iOS/Views/Security/SecuritySetupView.swift b/Thijooree iOS/Views/Security/SecuritySetupView.swift
new file mode 100644
index 0000000..8102077
--- /dev/null
+++ b/Thijooree iOS/Views/Security/SecuritySetupView.swift
@@ -0,0 +1,180 @@
+import LocalAuthentication
+import SwiftUI
+
+struct SecuritySetupView: View {
+ var onComplete: () -> Void
+ var changeMode: Bool = false
+
+ private enum Step { case enterPin, confirmPin, done }
+
+ @State private var step: Step = .enterPin
+ @State private var pinDigits: [Int] = []
+ @State private var firstPin = ""
+ @State private var hintText = ""
+ @State private var biometricsAvailable = false
+ @State private var biometricsEnabled = UserDefaults.standard.bool(forKey: "biometrics_enabled")
+
+ var body: some View {
+ VStack(spacing: 28) {
+ Spacer()
+
+ switch step {
+ case .enterPin, .confirmPin:
+ pinSetupContent
+ case .done:
+ doneContent
+ }
+
+ Spacer()
+ }
+ .animation(.easeInOut(duration: 0.2), value: step)
+ .task {
+ let ctx = LAContext()
+ biometricsAvailable = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
+ }
+ }
+
+ // MARK: - PIN setup
+
+ @ViewBuilder
+ private var pinSetupContent: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "lock.fill")
+ .font(.system(size: 40))
+ .foregroundStyle(.secondary)
+
+ Text(step == .enterPin ? "Create a PIN" : "Confirm PIN")
+ .font(.title2).bold()
+
+ Text("Use 4–8 digits")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text(dotsDisplay)
+ .font(.system(size: 30, design: .monospaced))
+ .kerning(6)
+ .frame(height: 44)
+ .animation(.spring(response: 0.2), value: pinDigits.count)
+
+ if !hintText.isEmpty {
+ Text(hintText)
+ .font(.callout)
+ .foregroundStyle(.red)
+ .transition(.opacity)
+ }
+
+ NumpadView { key in handleKey(key) }
+ }
+
+ // MARK: - Done screen
+
+ @ViewBuilder
+ private var doneContent: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "checkmark.shield.fill")
+ .font(.system(size: 64))
+ .foregroundStyle(.green)
+
+ Text("PIN Created")
+ .font(.title2).bold()
+
+ Text("Your app is now protected.")
+ .foregroundStyle(.secondary)
+ }
+
+ if biometricsAvailable {
+ VStack(spacing: 4) {
+ let label = biometricLabel()
+ Toggle(isOn: $biometricsEnabled) {
+ Label("Use \(label) to unlock", systemImage: biometricIcon())
+ }
+ .padding(.horizontal, 32)
+ .tint(.accentColor)
+ .onChange(of: biometricsEnabled) {
+ UserDefaults.standard.set(biometricsEnabled, forKey: "biometrics_enabled")
+ }
+
+ Text("Recommended for quick access")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Button("Continue") { onComplete() }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .padding(.horizontal, 40)
+ }
+
+ // MARK: - Logic
+
+ private var dotsDisplay: String {
+ let n = pinDigits.count
+ let total = max(n, 4)
+ return String(repeating: "●", count: n)
+ + String(repeating: "○", count: total - n)
+ }
+
+ private func handleKey(_ key: String) {
+ withAnimation { hintText = "" }
+ switch key {
+ case "⌫":
+ if !pinDigits.isEmpty { pinDigits.removeLast() }
+ case "✓":
+ if pinDigits.count >= 4 { submitPin() }
+ default:
+ if let d = Int(key), pinDigits.count < 8 {
+ pinDigits.append(d)
+ }
+ }
+ }
+
+ private func submitPin() {
+ let entered = pinDigits.map(String.init).joined()
+ switch step {
+ case .enterPin:
+ firstPin = entered
+ pinDigits = []
+ step = .confirmPin
+
+ case .confirmPin:
+ if entered == firstPin {
+ savePin(entered)
+ withAnimation { step = .done }
+ } else {
+ withAnimation { hintText = "PINs don't match — try again" }
+ pinDigits = []
+ }
+
+ case .done:
+ break
+ }
+ }
+
+ private func savePin(_ pin: String) {
+ let salt = PinHash.generateSalt()
+ let hash = PinHash.hash(pin, salt: salt)
+ try? CredentialStore.shared.save(salt.base64EncodedString(), forKey: CredentialStore.Keys.securityHashSalt)
+ try? CredentialStore.shared.save(hash, forKey: CredentialStore.Keys.securityHash)
+ UserDefaults.standard.set("pin", forKey: "security_method")
+ UserDefaults.standard.set(pin.count, forKey: "pin_length")
+ UserDefaults.standard.set(true, forKey: "auto_unlock_pin")
+ }
+
+ private func biometricLabel() -> String {
+ let ctx = LAContext()
+ _ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
+ return ctx.biometryType == .faceID ? "Face ID" : "Touch ID"
+ }
+
+ private func biometricIcon() -> String {
+ let ctx = LAContext()
+ _ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
+ return ctx.biometryType == .faceID ? "faceid" : "touchid"
+ }
+}
+
+#Preview {
+ SecuritySetupView(onComplete: {})
+}
diff --git a/Thijooree iOS/Views/Shared/NumpadView.swift b/Thijooree iOS/Views/Shared/NumpadView.swift
new file mode 100644
index 0000000..eac0694
--- /dev/null
+++ b/Thijooree iOS/Views/Shared/NumpadView.swift
@@ -0,0 +1,58 @@
+import SwiftUI
+
+struct NumpadView: View {
+ let onKey: (String) -> Void
+
+ private let rows: [[String]] = [
+ ["1", "2", "3"],
+ ["4", "5", "6"],
+ ["7", "8", "9"],
+ ["⌫", "0", "✓"]
+ ]
+
+ var body: some View {
+ VStack(spacing: 12) {
+ ForEach(rows, id: \.self) { row in
+ HStack(spacing: 20) {
+ ForEach(row, id: \.self) { key in
+ NumpadKey(label: key) { onKey(key) }
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct NumpadKey: View {
+ let label: String
+ let action: () -> Void
+
+ var isConfirm: Bool { label == "✓" }
+
+ var body: some View {
+ Button(action: action) {
+ Group {
+ switch label {
+ case "⌫":
+ Image(systemName: "delete.left")
+ .font(.system(size: 22))
+ case "✓":
+ Image(systemName: "checkmark")
+ .font(.system(size: 22, weight: .semibold))
+ default:
+ Text(label)
+ .font(.system(size: 28, weight: .light))
+ }
+ }
+ .frame(width: 72, height: 72)
+ .background(isConfirm ? Color.accentColor : Color(.systemFill))
+ .foregroundStyle(isConfirm ? .white : .primary)
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+#Preview {
+ NumpadView { _ in }
+}
diff --git a/Thijooree iOS/Views/Shared/OtpPreviewView.swift b/Thijooree iOS/Views/Shared/OtpPreviewView.swift
new file mode 100644
index 0000000..e247335
--- /dev/null
+++ b/Thijooree iOS/Views/Shared/OtpPreviewView.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+
+struct OtpPreviewView: View {
+ let seed: String
+
+ @State private var code = ""
+ @State private var progress = 0.0 // 0.0 – 1.0 within the current 30s window
+ @State private var timer: Timer?
+
+ var body: some View {
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Current OTP")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Text(formatted(code))
+ .font(.system(size: 22, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.primary)
+ }
+
+ Spacer()
+
+ ZStack {
+ Circle()
+ .stroke(Color(.systemFill), lineWidth: 3)
+ Circle()
+ .trim(from: 0, to: progress)
+ .stroke(progressColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ .animation(.linear(duration: 0.8), value: progress)
+ Text("\(Int(progress * 30))")
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ .frame(width: 36, height: 36)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ .contentShape(Rectangle())
+ .onTapGesture { UIPasteboard.general.string = code }
+ .onAppear { refresh(); startTimer() }
+ .onDisappear { timer?.invalidate(); timer = nil }
+ }
+
+ private var progressColor: Color {
+ progress < 0.33 ? .red : progress < 0.6 ? .orange : .green
+ }
+
+ private func formatted(_ otp: String) -> String {
+ guard otp.count == 6 else { return otp }
+ return "\(otp.prefix(3)) \(otp.suffix(3))"
+ }
+
+ private func refresh() {
+ code = (try? Totp.generate(seed)) ?? Totp.generate(seed)
+ let seconds = Int(Date().timeIntervalSince1970) % 30
+ progress = Double(30 - seconds) / 30.0
+ }
+
+ private func startTimer() {
+ timer?.invalidate()
+ timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in refresh() }
+ }
+}
+
+#Preview {
+ OtpPreviewView(seed: "JBSWY3DPEHPK3PXP")
+ .padding()
+}
diff --git a/Thijooree iOS/Views/Shared/QRScannerSheet.swift b/Thijooree iOS/Views/Shared/QRScannerSheet.swift
new file mode 100644
index 0000000..7e07c80
--- /dev/null
+++ b/Thijooree iOS/Views/Shared/QRScannerSheet.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+import AVFoundation
+
+struct QRScannerSheet: View {
+ var onResult: (String) -> Void
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ QRCameraView(onResult: { result in
+ dismiss()
+ onResult(result)
+ })
+ .ignoresSafeArea()
+ .navigationTitle("Scan QR Code")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") { dismiss() }
+ .foregroundStyle(.white)
+ }
+ }
+ }
+ }
+}
+
+private struct QRCameraView: UIViewRepresentable {
+ var onResult: (String) -> Void
+
+ func makeUIView(context: Context) -> UIView {
+ let view = UIView()
+ view.backgroundColor = .black
+
+ let session = AVCaptureSession()
+ guard let device = AVCaptureDevice.default(for: .video),
+ let input = try? AVCaptureDeviceInput(device: device) else {
+ return view
+ }
+ if session.canAddInput(input) { session.addInput(input) }
+
+ let output = AVCaptureMetadataOutput()
+ if session.canAddOutput(output) {
+ session.addOutput(output)
+ output.setMetadataObjectsDelegate(context.coordinator, queue: .main)
+ output.metadataObjectTypes = [.qr]
+ }
+
+ let preview = AVCaptureVideoPreviewLayer(session: session)
+ preview.videoGravity = .resizeAspectFill
+ preview.frame = view.bounds
+ view.layer.addSublayer(preview)
+ context.coordinator.previewLayer = preview
+
+ DispatchQueue.global(qos: .userInitiated).async { session.startRunning() }
+ context.coordinator.session = session
+ return view
+ }
+
+ func updateUIView(_ uiView: UIView, context: Context) {
+ context.coordinator.previewLayer?.frame = uiView.bounds
+ }
+
+ func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
+
+ final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
+ var onResult: (String) -> Void
+ var session: AVCaptureSession?
+ var previewLayer: AVCaptureVideoPreviewLayer?
+ private var didCapture = false
+
+ init(onResult: @escaping (String) -> Void) {
+ self.onResult = onResult
+ }
+
+ func metadataOutput(_ output: AVCaptureMetadataOutput,
+ didOutput objects: [AVMetadataObject],
+ from connection: AVCaptureConnection) {
+ guard !didCapture,
+ let obj = objects.first as? AVMetadataMachineReadableCodeObject,
+ let str = obj.stringValue else { return }
+ didCapture = true
+ session?.stopRunning()
+ DispatchQueue.main.async { self.onResult(str) }
+ }
+ }
+}