- Flatten project structure: remove Models/, Services/, ViewModels/, Views/ subdirs - Replace APIService actor with simpler Api class, IS_DEV flag controls dev vs prod URL - Rewrite BeaconScanner to use CoreLocation (CLBeaconRegion ranging) instead of CoreBluetooth — iOS blocks iBeacon data from CBCentralManager - Add SVG logo on login page with proper scaling (was showing green square) - Make login page scrollable, add "enter 6-digit code" OTP instruction - Fix text input visibility (white on white) with .foregroundColor(.primary) - Add diagonal orange DEV ribbon banner (lower-left corner), gated on Api.IS_DEV - Update app icon: logo 10% larger, wifi icon closer - Add en.lproj/InfoPlist.strings for display name localization - Fix scan flash: keep isScanning=true until enrichment completes - Add Podfile with SVGKit, Kingfisher, CocoaLumberjack dependencies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
365 lines
13 KiB
Swift
365 lines
13 KiB
Swift
import Foundation
|
|
|
|
class Api {
|
|
static let shared = Api()
|
|
|
|
// ── DEV toggle: flip to false for production ──
|
|
static let IS_DEV = true
|
|
|
|
private static var BASE_URL: String {
|
|
IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api"
|
|
}
|
|
|
|
private let session: URLSession
|
|
private var authToken: String?
|
|
|
|
private init() {
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 30
|
|
config.timeoutIntervalForResource = 30
|
|
session = URLSession(configuration: config)
|
|
}
|
|
|
|
func setAuthToken(_ token: String?) {
|
|
authToken = token
|
|
}
|
|
|
|
func getAuthToken() -> String? {
|
|
return authToken
|
|
}
|
|
|
|
private func buildRequest(endpoint: String) -> URLRequest {
|
|
let url = URL(string: "\(Api.BASE_URL)\(endpoint)")!
|
|
var request = URLRequest(url: url)
|
|
if let token = authToken {
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
|
}
|
|
return request
|
|
}
|
|
|
|
private func postRequest(endpoint: String, body: [String: Any], extraHeaders: [String: String] = [:]) async throws -> [String: Any] {
|
|
var request = buildRequest(endpoint: endpoint)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
for (key, value) in extraHeaders {
|
|
request.setValue(value, forHTTPHeaderField: key)
|
|
}
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw ApiException("Invalid response")
|
|
}
|
|
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
|
|
throw ApiException("Request failed: \(httpResponse.statusCode)")
|
|
}
|
|
guard !data.isEmpty else {
|
|
throw ApiException("Empty response")
|
|
}
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw ApiException("Invalid JSON response")
|
|
}
|
|
return json
|
|
}
|
|
|
|
private func getRequest(endpoint: String) async throws -> [String: Any] {
|
|
var request = buildRequest(endpoint: endpoint)
|
|
request.httpMethod = "GET"
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw ApiException("Invalid response")
|
|
}
|
|
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
|
|
throw ApiException("Request failed: \(httpResponse.statusCode)")
|
|
}
|
|
guard !data.isEmpty else {
|
|
throw ApiException("Empty response")
|
|
}
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw ApiException("Invalid JSON response")
|
|
}
|
|
return json
|
|
}
|
|
|
|
// =========================================================================
|
|
// AUTH
|
|
// =========================================================================
|
|
|
|
func sendLoginOtp(phone: String) async throws -> OtpResponse {
|
|
let json = try await postRequest(endpoint: "/auth/loginOTP.cfm", body: ["Phone": phone])
|
|
|
|
guard let uuid = (json["UUID"] as? String) ?? (json["uuid"] as? String), !uuid.isEmpty else {
|
|
throw ApiException("Server error - please try again")
|
|
}
|
|
|
|
return OtpResponse(uuid: uuid)
|
|
}
|
|
|
|
func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse {
|
|
let json = try await postRequest(endpoint: "/auth/verifyLoginOTP.cfm", body: [
|
|
"UUID": uuid,
|
|
"OTP": otp
|
|
])
|
|
|
|
let ok = parseBool(json["OK"] ?? json["ok"])
|
|
if !ok {
|
|
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Invalid code"
|
|
throw ApiException(error)
|
|
}
|
|
|
|
return LoginResponse(
|
|
userId: parseIntValue(json["UserID"] ?? json["USERID"]) ?? 0,
|
|
token: ((json["Token"] ?? json["TOKEN"] ?? json["token"]) as? String) ?? "",
|
|
userFirstName: ((json["UserFirstName"] ?? json["USERFIRSTNAME"]) as? String) ?? ""
|
|
)
|
|
}
|
|
|
|
// =========================================================================
|
|
// BUSINESSES
|
|
// =========================================================================
|
|
|
|
func listBusinesses() async throws -> [Business] {
|
|
let json = try await postRequest(endpoint: "/businesses/list.cfm", body: [:])
|
|
|
|
guard let businesses = (json["BUSINESSES"] ?? json["businesses"] ?? json["Items"] ?? json["ITEMS"]) as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return businesses.compactMap { b in
|
|
guard let businessId = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else {
|
|
return nil
|
|
}
|
|
let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? ""
|
|
let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String
|
|
return Business(businessId: businessId, name: name, headerImageExtension: headerImageExtension)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// BEACONS
|
|
// =========================================================================
|
|
|
|
func listAllBeacons() async throws -> [String: Int] {
|
|
let json = try await getRequest(endpoint: "/beacons/list_all.cfm")
|
|
|
|
guard let items = (json["ITEMS"] ?? json["items"]) as? [[String: Any]] else {
|
|
return [:]
|
|
}
|
|
|
|
var result: [String: Int] = [:]
|
|
for item in items {
|
|
guard let uuid = ((item["UUID"] ?? item["uuid"] ?? item["BeaconUUID"] ?? item["BEACONUUID"]) as? String)?
|
|
.replacingOccurrences(of: "-", with: "").uppercased(),
|
|
let beaconId = parseIntValue(item["BeaconID"] ?? item["BEACONID"]) else {
|
|
continue
|
|
}
|
|
result[uuid] = beaconId
|
|
}
|
|
return result
|
|
}
|
|
|
|
func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] {
|
|
if uuids.isEmpty { return [] }
|
|
|
|
let json = try await postRequest(endpoint: "/beacons/lookup.cfm", body: ["UUIDs": uuids])
|
|
|
|
guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return beacons.compactMap { b in
|
|
guard let uuid = ((b["UUID"] ?? b["uuid"]) as? String)?
|
|
.replacingOccurrences(of: "-", with: "").uppercased() else {
|
|
return nil
|
|
}
|
|
return BeaconLookupResult(
|
|
uuid: uuid,
|
|
beaconId: parseIntValue(b["BeaconID"] ?? b["BEACONID"]) ?? 0,
|
|
businessId: parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) ?? 0,
|
|
businessName: ((b["BusinessName"] ?? b["BUSINESSNAME"]) as? String) ?? "",
|
|
servicePointName: ((b["ServicePointName"] ?? b["SERVICEPOINTNAME"]) as? String) ?? "",
|
|
beaconName: ((b["BeaconName"] ?? b["BEACONNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? ""
|
|
)
|
|
}
|
|
}
|
|
|
|
func listBeacons(businessId: Int) async throws -> [BeaconInfo] {
|
|
let json = try await postRequest(
|
|
endpoint: "/beacons/list.cfm",
|
|
body: ["BusinessID": businessId],
|
|
extraHeaders: ["X-Business-Id": String(businessId)]
|
|
)
|
|
|
|
guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return beacons.compactMap { b in
|
|
let beaconId = parseIntValue(b["BeaconID"] ?? b["BEACONID"] ?? b["ID"]) ?? 0
|
|
let name = ((b["Name"] ?? b["NAME"] ?? b["BeaconName"] ?? b["BEACONNAME"]) as? String) ?? ""
|
|
let uuid = ((b["UUID"] ?? b["uuid"]) as? String)?
|
|
.replacingOccurrences(of: "-", with: "").uppercased() ?? ""
|
|
let isActive = parseBool(b["IsActive"] ?? b["ISACTIVE"] ?? true)
|
|
return BeaconInfo(beaconId: beaconId, name: name, uuid: uuid, isActive: isActive)
|
|
}
|
|
}
|
|
|
|
func saveBeacon(businessId: Int, name: String, uuid: String, macAddress: String? = nil) async throws -> SavedBeacon {
|
|
var params: [String: Any] = [
|
|
"BusinessID": businessId,
|
|
"Name": name,
|
|
"UUID": uuid
|
|
]
|
|
if let mac = macAddress, !mac.isEmpty {
|
|
params["MACAddress"] = mac
|
|
}
|
|
|
|
let json = try await postRequest(
|
|
endpoint: "/beacons/save.cfm",
|
|
body: params,
|
|
extraHeaders: ["X-Business-Id": String(businessId)]
|
|
)
|
|
|
|
if !parseBool(json["OK"] ?? json["ok"]) {
|
|
throw ApiException(((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save beacon")
|
|
}
|
|
|
|
let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any]
|
|
|
|
return SavedBeacon(
|
|
beaconId: parseIntValue(beacon?["BeaconID"] ?? beacon?["BEACONID"] ?? beacon?["ID"]) ?? 0,
|
|
name: (beacon?["Name"] ?? beacon?["NAME"]) as? String ?? name,
|
|
uuid: uuid,
|
|
macAddress: (beacon?["MACAddress"] ?? beacon?["MACADDRESS"]) as? String
|
|
)
|
|
}
|
|
|
|
func lookupByMac(macAddress: String) async throws -> MacLookupResult? {
|
|
let json = try await postRequest(endpoint: "/beacons/lookupByMac.cfm", body: ["MACAddress": macAddress])
|
|
|
|
if !parseBool(json["OK"] ?? json["ok"]) {
|
|
return nil
|
|
}
|
|
|
|
guard let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] else {
|
|
return nil
|
|
}
|
|
|
|
return MacLookupResult(
|
|
beaconId: parseIntValue(beacon["BeaconID"] ?? beacon["BEACONID"]) ?? 0,
|
|
businessId: parseIntValue(beacon["BusinessID"] ?? beacon["BUSINESSID"]) ?? 0,
|
|
businessName: ((beacon["BusinessName"] ?? beacon["BUSINESSNAME"]) as? String) ?? "",
|
|
beaconName: ((beacon["BeaconName"] ?? beacon["BEACONNAME"]) as? String) ?? "",
|
|
uuid: ((beacon["UUID"] ?? beacon["uuid"]) as? String) ?? "",
|
|
macAddress: ((beacon["MACAddress"] ?? beacon["MACADDRESS"]) as? String) ?? "",
|
|
servicePointName: ((beacon["ServicePointName"] ?? beacon["SERVICEPOINTNAME"]) as? String) ?? ""
|
|
)
|
|
}
|
|
|
|
func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool {
|
|
let json = try await postRequest(
|
|
endpoint: "/beacons/wipe.cfm",
|
|
body: ["BusinessID": businessId, "BeaconID": beaconId],
|
|
extraHeaders: ["X-Business-Id": String(businessId)]
|
|
)
|
|
|
|
if !parseBool(json["OK"] ?? json["ok"]) {
|
|
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to wipe beacon"
|
|
let message = (json["MESSAGE"] ?? json["message"]) as? String
|
|
throw ApiException(message ?? error)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// =========================================================================
|
|
// HELPERS
|
|
// =========================================================================
|
|
|
|
private func parseBool(_ value: Any?) -> Bool {
|
|
switch value {
|
|
case nil:
|
|
return false
|
|
case let b as Bool:
|
|
return b
|
|
case let n as NSNumber:
|
|
return n.intValue != 0
|
|
case let s as String:
|
|
return ["true", "1"].contains(s.lowercased())
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func parseIntValue(_ value: Any?) -> Int? {
|
|
if let n = value as? NSNumber {
|
|
return n.intValue
|
|
}
|
|
if let s = value as? String, let i = Int(s) {
|
|
return i
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
struct ApiException: LocalizedError {
|
|
let message: String
|
|
init(_ message: String) { self.message = message }
|
|
var errorDescription: String? { message }
|
|
}
|
|
|
|
// =========================================================================
|
|
// DATA MODELS
|
|
// =========================================================================
|
|
|
|
struct OtpResponse {
|
|
let uuid: String
|
|
}
|
|
|
|
struct LoginResponse {
|
|
let userId: Int
|
|
let token: String
|
|
let userFirstName: String
|
|
}
|
|
|
|
struct Business: Identifiable {
|
|
var id: Int { businessId }
|
|
let businessId: Int
|
|
let name: String
|
|
let headerImageExtension: String?
|
|
}
|
|
|
|
struct BeaconLookupResult {
|
|
let uuid: String
|
|
let beaconId: Int
|
|
let businessId: Int
|
|
let businessName: String
|
|
let servicePointName: String
|
|
let beaconName: String
|
|
}
|
|
|
|
struct BeaconInfo {
|
|
let beaconId: Int
|
|
let name: String
|
|
let uuid: String
|
|
let isActive: Bool
|
|
}
|
|
|
|
struct SavedBeacon {
|
|
let beaconId: Int
|
|
let name: String
|
|
let uuid: String
|
|
let macAddress: String?
|
|
}
|
|
|
|
struct MacLookupResult {
|
|
let beaconId: Int
|
|
let businessId: Int
|
|
let businessName: String
|
|
let beaconName: String
|
|
let uuid: String
|
|
let macAddress: String
|
|
let servicePointName: String
|
|
}
|