- Fixed App Store icon display with ios-marketing idiom - Added iPad orientation support for multitasking - Added UILaunchScreen for iPad requirements - Removed unused BLE permissions and files from build Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
416 lines
14 KiB
Swift
416 lines
14 KiB
Swift
import Foundation
|
|
|
|
// MARK: - API Errors
|
|
|
|
enum APIError: LocalizedError, Equatable {
|
|
case invalidURL
|
|
case noData
|
|
case decodingError(String)
|
|
case serverError(String)
|
|
case unauthorized
|
|
case networkError(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return "Invalid URL"
|
|
case .noData: return "No data received"
|
|
case .decodingError(let msg): return "Decoding error: \(msg)"
|
|
case .serverError(let msg): return msg
|
|
case .unauthorized: return "Unauthorized"
|
|
case .networkError(let msg): return msg
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Login Response
|
|
|
|
struct LoginResponse {
|
|
let userId: Int
|
|
let userFirstName: String
|
|
let token: String
|
|
let photoUrl: String
|
|
}
|
|
|
|
// MARK: - API Service
|
|
|
|
actor APIService {
|
|
static let shared = APIService()
|
|
|
|
private enum Environment {
|
|
case development, production
|
|
|
|
var baseURL: String {
|
|
switch self {
|
|
case .development: return "https://dev.payfrit.com/api"
|
|
case .production: return "https://biz.payfrit.com/api"
|
|
}
|
|
}
|
|
}
|
|
|
|
private let environment: Environment = .development
|
|
var isDev: Bool { environment == .development }
|
|
private var userToken: String?
|
|
private var userId: Int?
|
|
private var businessId: Int = 0
|
|
|
|
var baseURL: String { environment.baseURL }
|
|
|
|
// MARK: - Configuration
|
|
|
|
func setAuth(token: String?, userId: Int?) {
|
|
self.userToken = token
|
|
self.userId = userId
|
|
}
|
|
|
|
func setBusinessId(_ id: Int) {
|
|
self.businessId = id
|
|
}
|
|
|
|
func getToken() -> String? { userToken }
|
|
func getUserId() -> Int? { userId }
|
|
func getBusinessId() -> Int { businessId }
|
|
|
|
// MARK: - Core Request
|
|
|
|
private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] {
|
|
let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)")
|
|
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
|
|
if let token = userToken, !token.isEmpty {
|
|
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
|
}
|
|
if businessId > 0 {
|
|
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
|
|
}
|
|
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
|
}
|
|
}
|
|
|
|
if let json = tryDecodeJSON(data) {
|
|
return json
|
|
}
|
|
throw APIError.decodingError("Non-JSON response")
|
|
}
|
|
|
|
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
|
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
return json
|
|
}
|
|
guard let body = String(data: data, encoding: .utf8),
|
|
let start = body.firstIndex(of: "{"),
|
|
let end = body.lastIndex(of: "}") else { return nil }
|
|
let jsonStr = String(body[start...end])
|
|
guard let jsonData = jsonStr.data(using: .utf8) else { return nil }
|
|
return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
|
|
}
|
|
|
|
private func ok(_ json: [String: Any]) -> Bool {
|
|
for key in ["OK", "ok", "Ok"] {
|
|
if let b = json[key] as? Bool { return b }
|
|
if let i = json[key] as? Int { return i == 1 }
|
|
if let s = json[key] as? String {
|
|
let lower = s.lowercased()
|
|
return lower == "true" || lower == "1" || lower == "yes"
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func err(_ json: [String: Any]) -> String {
|
|
let msg = (json["ERROR"] as? String) ?? (json["error"] as? String)
|
|
?? (json["Error"] as? String) ?? (json["message"] as? String) ?? ""
|
|
return msg.isEmpty ? "Unknown error" : msg
|
|
}
|
|
|
|
nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? {
|
|
for key in keys {
|
|
if let arr = json[key] as? [[String: Any]] { return arr }
|
|
}
|
|
for (_, value) in json {
|
|
if let arr = value as? [[String: Any]], !arr.isEmpty { return arr }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Auth
|
|
|
|
func login(username: String, password: String) async throws -> LoginResponse {
|
|
let json = try await postJSON("/auth/login.cfm", payload: [
|
|
"username": username,
|
|
"password": password
|
|
])
|
|
|
|
guard ok(json) else {
|
|
let e = err(json)
|
|
if e == "bad_credentials" {
|
|
throw APIError.serverError("Invalid email/phone or password")
|
|
}
|
|
throw APIError.serverError("Login failed: \(e)")
|
|
}
|
|
|
|
let uid = (json["UserID"] as? Int)
|
|
?? Int(json["UserID"] as? String ?? "")
|
|
?? (json["UserId"] as? Int)
|
|
?? 0
|
|
let token = (json["Token"] as? String)
|
|
?? (json["token"] as? String)
|
|
?? ""
|
|
|
|
guard uid > 0 else {
|
|
throw APIError.serverError("Login failed: no user ID returned")
|
|
}
|
|
guard !token.isEmpty else {
|
|
throw APIError.serverError("Login failed: no token returned")
|
|
}
|
|
|
|
let firstName = (json["UserFirstName"] as? String)
|
|
?? (json["FirstName"] as? String)
|
|
?? (json["firstName"] as? String)
|
|
?? (json["Name"] as? String)
|
|
?? (json["name"] as? String)
|
|
?? ""
|
|
let photoUrl = (json["UserPhotoUrl"] as? String)
|
|
?? (json["PhotoUrl"] as? String)
|
|
?? (json["photoUrl"] as? String)
|
|
?? ""
|
|
|
|
self.userToken = token
|
|
self.userId = uid
|
|
|
|
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
|
|
}
|
|
|
|
func logout() {
|
|
userToken = nil
|
|
userId = nil
|
|
businessId = 0
|
|
}
|
|
|
|
// MARK: - Businesses
|
|
|
|
func getMyBusinesses() async throws -> [Employment] {
|
|
guard let uid = userId, uid > 0 else {
|
|
throw APIError.serverError("User not logged in")
|
|
}
|
|
|
|
let json = try await postJSON("/workers/myBusinesses.cfm", payload: [
|
|
"UserID": uid
|
|
])
|
|
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load businesses: \(err(json))")
|
|
}
|
|
|
|
guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else {
|
|
return []
|
|
}
|
|
return arr.map { Employment(json: $0) }
|
|
}
|
|
|
|
// MARK: - Beacons
|
|
|
|
func listBeacons() async throws -> [Beacon] {
|
|
let json = try await postJSON("/beacons/list.cfm", payload: [
|
|
"BusinessID": businessId
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load beacons: \(err(json))")
|
|
}
|
|
guard let arr = Self.findArray(json, ["BEACONS", "Beacons", "beacons"]) else { return [] }
|
|
return arr.map { Beacon(json: $0) }
|
|
}
|
|
|
|
func getBeacon(beaconId: Int) async throws -> Beacon {
|
|
let json = try await postJSON("/beacons/get.cfm", payload: [
|
|
"BeaconID": beaconId,
|
|
"BusinessID": businessId
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load beacon: \(err(json))")
|
|
}
|
|
var beaconJson: [String: Any]?
|
|
for key in ["BEACON", "Beacon", "beacon"] {
|
|
if let d = json[key] as? [String: Any] { beaconJson = d; break }
|
|
}
|
|
if beaconJson == nil {
|
|
for (_, value) in json {
|
|
if let d = value as? [String: Any], d.count > 3 { beaconJson = d; break }
|
|
}
|
|
}
|
|
guard let beaconJson = beaconJson else {
|
|
throw APIError.serverError("Invalid beacon response")
|
|
}
|
|
return Beacon(json: beaconJson)
|
|
}
|
|
|
|
func createBeacon(name: String, uuid: String) async throws -> Int {
|
|
let json = try await postJSON("/beacons/create.cfm", payload: [
|
|
"BusinessID": businessId,
|
|
"Name": name,
|
|
"UUID": uuid
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to create beacon: \(err(json))")
|
|
}
|
|
return (json["BeaconID"] as? Int)
|
|
?? (json["ID"] as? Int)
|
|
?? Int(json["BeaconID"] as? String ?? "")
|
|
?? 0
|
|
}
|
|
|
|
func updateBeacon(beaconId: Int, name: String, uuid: String, isActive: Bool) async throws {
|
|
let json = try await postJSON("/beacons/update.cfm", payload: [
|
|
"BeaconID": beaconId,
|
|
"BusinessID": businessId,
|
|
"Name": name,
|
|
"UUID": uuid,
|
|
"IsActive": isActive
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to update beacon: \(err(json))")
|
|
}
|
|
}
|
|
|
|
func deleteBeacon(beaconId: Int) async throws {
|
|
let json = try await postJSON("/beacons/delete.cfm", payload: [
|
|
"BeaconID": beaconId,
|
|
"BusinessID": businessId
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to delete beacon: \(err(json))")
|
|
}
|
|
}
|
|
|
|
// MARK: - Service Points
|
|
|
|
func listServicePoints() async throws -> [ServicePoint] {
|
|
let json = try await postJSON("/servicePoints/list.cfm", payload: [
|
|
"BusinessID": businessId
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load service points: \(err(json))")
|
|
}
|
|
guard let arr = Self.findArray(json, ["SERVICE_POINTS", "ServicePoints", "servicePoints", "SERVICEPOINTS"]) else { return [] }
|
|
return arr.map { ServicePoint(json: $0) }
|
|
}
|
|
|
|
func assignBeaconToServicePoint(servicePointId: Int, beaconId: Int?) async throws {
|
|
var payload: [String: Any] = [
|
|
"ServicePointID": servicePointId,
|
|
"BusinessID": businessId
|
|
]
|
|
if let bid = beaconId {
|
|
payload["BeaconID"] = bid
|
|
}
|
|
let json = try await postJSON("/servicePoints/assignBeacon.cfm", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to assign beacon: \(err(json))")
|
|
}
|
|
}
|
|
|
|
func listServicePointTypes() async throws -> [ServicePointType] {
|
|
let json = try await postJSON("/servicePoints/types.cfm", payload: [
|
|
"BusinessID": businessId
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load service point types: \(err(json))")
|
|
}
|
|
guard let arr = Self.findArray(json, ["TYPES", "Types", "types"]) else { return [] }
|
|
return arr.map { ServicePointType(json: $0) }
|
|
}
|
|
|
|
// MARK: - URL Helpers
|
|
|
|
func resolvePhotoUrl(_ rawUrl: String) -> String {
|
|
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty { return "" }
|
|
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed }
|
|
let baseDomain = environment == .development ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
|
|
if trimmed.hasPrefix("/") { return baseDomain + trimmed }
|
|
return baseDomain + "/" + trimmed
|
|
}
|
|
|
|
// MARK: - Date Parsing
|
|
|
|
private static let iso8601Formatter: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
|
|
private static let iso8601NoFrac: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
|
|
private static let simpleDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}()
|
|
|
|
private static let cfmlDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}()
|
|
|
|
private static let cfmlShortFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}()
|
|
|
|
private static let cfmlAltFormatters: [DateFormatter] = {
|
|
let formats = [
|
|
"MMM dd, yyyy HH:mm:ss",
|
|
"MM/dd/yyyy HH:mm:ss",
|
|
"yyyy-MM-dd HH:mm:ss.S",
|
|
"yyyy-MM-dd'T'HH:mm:ss.S",
|
|
"yyyy-MM-dd'T'HH:mm:ssZ",
|
|
"yyyy-MM-dd'T'HH:mm:ss.SZ",
|
|
]
|
|
return formats.map { fmt in
|
|
let f = DateFormatter()
|
|
f.dateFormat = fmt
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}
|
|
}()
|
|
|
|
nonisolated static func parseDate(_ string: String) -> Date? {
|
|
let s = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if s.isEmpty { return nil }
|
|
|
|
if let epoch = Double(s) {
|
|
if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) }
|
|
if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) }
|
|
}
|
|
|
|
if let d = iso8601Formatter.date(from: s) { return d }
|
|
if let d = iso8601NoFrac.date(from: s) { return d }
|
|
if let d = simpleDateFormatter.date(from: s) { return d }
|
|
if let d = cfmlDateFormatter.date(from: s) { return d }
|
|
if let d = cfmlShortFormatter.date(from: s) { return d }
|
|
for formatter in cfmlAltFormatters {
|
|
if let d = formatter.date(from: s) { return d }
|
|
}
|
|
return nil
|
|
}
|
|
}
|