payfrit-beacon-ios/PayfritBeacon/Api.swift
John Pinkyfloyd 962a767863 Rewrite app: simplified architecture, CoreLocation beacon scanning, UI fixes
- 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>
2026-02-04 22:07:39 -08:00

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
}