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) } + } + } +}