- Add global IS_DEV flag controlling API endpoint (dev vs biz.payfrit.com), dev ribbon banner, and magic OTP hints - Add diagonal orange DEV ribbon overlay (Widgets/DevRibbon.swift) - Replace app icon with properly centered dark-outline SVG on white background - Fix display name with InfoPlist.strings localization - Redesign business selection cards with initial letter, status pill, task count - Make businesses only tappable when pending tasks > 0 (dimmed otherwise) - Simplify LoginScreen and RootView to use IS_DEV directly - Fix hardcoded dev URLs to respect IS_DEV flag Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
588 lines
21 KiB
Swift
588 lines
21 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Dev Flag
|
|
|
|
/// Master flag: flip to `false` for production builds.
|
|
/// Controls API endpoint, dev banner, and magic OTPs.
|
|
let IS_DEV = true
|
|
|
|
// MARK: - API Errors
|
|
|
|
enum APIError: LocalizedError {
|
|
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: - Chat Messages Result
|
|
|
|
struct ChatMessagesResult {
|
|
let messages: [ChatMessage]
|
|
let chatClosed: Bool
|
|
}
|
|
|
|
// MARK: - API Service
|
|
|
|
actor APIService {
|
|
static let shared = APIService()
|
|
|
|
private static let devBaseURL = "https://dev.payfrit.com/api"
|
|
private static let prodBaseURL = "https://biz.payfrit.com/api"
|
|
|
|
private var userToken: String?
|
|
private var userId: Int?
|
|
private var businessId: Int = 0
|
|
|
|
var baseURL: String { IS_DEV ? Self.devBaseURL : Self.prodBaseURL }
|
|
|
|
// 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 = 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)")
|
|
}
|
|
}
|
|
|
|
// Try to parse JSON, handling CFML prefix junk
|
|
if let json = tryDecodeJSON(data) {
|
|
return json
|
|
}
|
|
throw APIError.decodingError("Non-JSON response")
|
|
}
|
|
|
|
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
|
|
// Try direct parse
|
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
return json
|
|
}
|
|
// Try extracting JSON from mixed response
|
|
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 {
|
|
if let b = json["OK"] as? Bool { return b }
|
|
if let b = json["ok"] as? Bool { return b }
|
|
if let i = json["OK"] as? Int { return i == 1 }
|
|
if let s = json["OK"] as? String { return s == "true" || s == "1" || s == "YES" }
|
|
return false
|
|
}
|
|
|
|
private func err(_ json: [String: Any]) -> String {
|
|
(json["ERROR"] as? String) ?? (json["error"] as? String)
|
|
?? (json["Error"] as? String) ?? (json["message"] as? String) ?? ""
|
|
}
|
|
|
|
/// All expected PascalCase keys our models use.
|
|
nonisolated private static let expectedKeys: [String] = [
|
|
"TaskID", "TaskBusinessID", "TaskCategoryID", "TaskTypeID", "TaskTitle",
|
|
"TaskDetails", "TaskCreatedOn", "TaskStatusID", "TaskSourceType", "TaskSourceID",
|
|
"TaskCategoryName", "TaskCategoryColor",
|
|
"EmployeeID", "BusinessID", "BusinessName", "BusinessAddress", "BusinessCity",
|
|
"EmployeeStatusID", "PendingTaskCount",
|
|
"OrderID", "OrderRemarks", "OrderSubmittedOn",
|
|
"ServicePointID", "ServicePointName", "ServicePointTypeID",
|
|
"DeliveryAddress", "DeliveryLat", "DeliveryLng",
|
|
"CustomerUserID", "CustomerFirstName", "CustomerLastName",
|
|
"CustomerPhone", "CustomerPhotoUrl", "BeaconUUID",
|
|
"LineItems", "TableMembers",
|
|
"LineItemID", "ItemName", "Quantity", "PriceCents", "Remark",
|
|
"IsModifier", "ParentLineItemID",
|
|
"UserID", "FirstName", "LastName", "IsHost",
|
|
"MessageID", "SenderUserID", "SenderType", "SenderName", "Text", "MessageText",
|
|
"CreatedOn", "IsRead",
|
|
"OK", "ERROR", "TASKS", "BUSINESSES", "MESSAGES", "TASK",
|
|
"UserFirstName", "Token", "PhotoUrl", "UserPhotoUrl",
|
|
"TIER", "STRIPE", "ACTIVATION",
|
|
"HasAccount", "PayoutsEnabled", "SetupIncomplete",
|
|
"BalanceCents", "CapCents", "RemainingCents", "IsComplete", "ProgressPercent",
|
|
"ENTRIES", "TOTALS", "ID",
|
|
"GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt",
|
|
"TotalGrossCents", "TotalWithheldCents", "TotalNetCents",
|
|
"AccountID", "ACCOUNT_ID", "URL", "url",
|
|
"CHAT_CLOSED", "chat_closed", "PayCents"
|
|
]
|
|
|
|
/// Build a lookup: stripped key (no underscores, lowercased) → expected PascalCase key.
|
|
/// This handles ALL CAPS, underscore_separated, camelCase, PascalCase — any format.
|
|
nonisolated private static let keyLookup: [String: String] = {
|
|
var map: [String: String] = [:]
|
|
for k in expectedKeys {
|
|
// Strip underscores and lowercase for fuzzy matching
|
|
let stripped = k.replacingOccurrences(of: "_", with: "").lowercased()
|
|
map[stripped] = k
|
|
// Also add exact uppercased (for direct ALL CAPS match)
|
|
map[k.uppercased()] = k
|
|
}
|
|
return map
|
|
}()
|
|
|
|
/// Normalize dictionary keys: maps ANY casing/format to expected PascalCase.
|
|
/// Strips underscores and lowercases for fuzzy matching against known keys.
|
|
nonisolated static func normalizeKeys(_ dict: [String: Any]) -> [String: Any] {
|
|
var result: [String: Any] = [:]
|
|
for (key, value) in dict {
|
|
// Try exact uppercased match first, then stripped fuzzy match
|
|
let stripped = key.replacingOccurrences(of: "_", with: "").lowercased()
|
|
let normalizedKey = keyLookup[key.uppercased()] ?? keyLookup[stripped] ?? key
|
|
if let arr = value as? [[String: Any]] {
|
|
result[normalizedKey] = arr.map { normalizeKeys($0) }
|
|
} else if let sub = value as? [String: Any] {
|
|
result[normalizedKey] = normalizeKeys(sub)
|
|
} else {
|
|
result[normalizedKey] = value
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Find an array value in the JSON by trying multiple key variants
|
|
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 }
|
|
}
|
|
// Fallback: search all values for the first array of dicts
|
|
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)
|
|
?? ""
|
|
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: - Tasks
|
|
|
|
func listPendingTasks(categoryId: Int? = nil) async throws -> [WorkTask] {
|
|
var payload: [String: Any] = ["BusinessID": businessId]
|
|
if let cid = categoryId, cid > 0 {
|
|
payload["CategoryID"] = cid
|
|
}
|
|
|
|
let json = try await postJSON("/tasks/listPending.cfm", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load tasks: \(err(json))")
|
|
}
|
|
|
|
guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] }
|
|
return arr.map { WorkTask(json: $0) }
|
|
}
|
|
|
|
func acceptTask(taskId: Int) async throws {
|
|
// Flutter only sends TaskID + BusinessID (no UserID)
|
|
let json = try await postJSON("/tasks/accept.cfm", payload: [
|
|
"TaskID": taskId,
|
|
"BusinessID": businessId
|
|
])
|
|
|
|
guard ok(json) else {
|
|
let e = err(json)
|
|
if e == "already_accepted" {
|
|
throw APIError.serverError("This task has already been claimed by someone else.")
|
|
}
|
|
throw APIError.serverError("Failed to accept task: \(e)")
|
|
}
|
|
}
|
|
|
|
func listMyTasks(filterType: String = "active") async throws -> [WorkTask] {
|
|
var payload: [String: Any] = [
|
|
"UserID": userId ?? 0,
|
|
"FilterType": filterType
|
|
]
|
|
if businessId > 0 {
|
|
payload["BusinessID"] = businessId
|
|
}
|
|
|
|
let json = try await postJSON("/tasks/listMine.cfm", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load my tasks: \(err(json))")
|
|
}
|
|
|
|
guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] }
|
|
return arr.map { WorkTask(json: $0) }
|
|
}
|
|
|
|
func completeTask(taskId: Int) async throws {
|
|
let json = try await postJSON("/tasks/complete.cfm", payload: [
|
|
"TaskID": taskId,
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to complete task: \(err(json))")
|
|
}
|
|
}
|
|
|
|
func closeChat(taskId: Int) async throws {
|
|
let json = try await postJSON("/tasks/completeChat.cfm", payload: [
|
|
"TaskID": taskId
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to close chat: \(err(json))")
|
|
}
|
|
}
|
|
|
|
func getTaskDetails(taskId: Int) async throws -> TaskDetails {
|
|
let json = try await postJSON("/tasks/getDetails.cfm", payload: [
|
|
"TaskID": taskId
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load task details: \(err(json))")
|
|
}
|
|
|
|
// Find the TASK object — try known keys, then fallback to first dict value
|
|
var taskJson: [String: Any]?
|
|
for key in ["TASK", "Task", "task"] {
|
|
if let d = json[key] as? [String: Any] { taskJson = d; break }
|
|
}
|
|
if taskJson == nil {
|
|
// Fallback: look for first nested dict that looks like a task
|
|
for (_, value) in json {
|
|
if let d = value as? [String: Any], d.count > 3 { taskJson = d; break }
|
|
}
|
|
}
|
|
guard let taskJson = taskJson else {
|
|
throw APIError.serverError("Invalid task details response")
|
|
}
|
|
|
|
let details = TaskDetails(json: taskJson)
|
|
return details
|
|
}
|
|
|
|
// MARK: - Chat
|
|
|
|
func getChatMessages(taskId: Int, afterMessageId: Int? = nil) async throws -> ChatMessagesResult {
|
|
var payload: [String: Any] = ["TaskID": taskId]
|
|
if let after = afterMessageId, after > 0 {
|
|
payload["AfterMessageID"] = after
|
|
}
|
|
|
|
let json = try await postJSON("/chat/getMessages.cfm", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load chat messages")
|
|
}
|
|
|
|
let arr = Self.findArray(json, ["MESSAGES", "Messages", "messages"]) ?? []
|
|
let messages: [ChatMessage] = arr.map { ChatMessage(json: $0) }
|
|
let chatClosed = (json["CHAT_CLOSED"] as? Bool) == true || (json["chat_closed"] as? Bool) == true
|
|
|
|
return ChatMessagesResult(messages: messages, chatClosed: chatClosed)
|
|
}
|
|
|
|
func sendChatMessage(taskId: Int, message: String, userId: Int? = nil, senderType: String? = nil) async throws -> Int {
|
|
var payload: [String: Any] = [
|
|
"TaskID": taskId,
|
|
"Message": message
|
|
]
|
|
if let uid = userId { payload["UserID"] = uid }
|
|
if let st = senderType { payload["SenderType"] = st }
|
|
|
|
let json = try await postJSON("/chat/sendMessage.cfm", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to send message")
|
|
}
|
|
|
|
return (json["MessageID"] as? Int) ?? (json["MESSAGE_ID"] as? Int) ?? 0
|
|
}
|
|
|
|
func markChatMessagesRead(taskId: Int, readerType: String) async throws {
|
|
let json = try await postJSON("/chat/markRead.cfm", payload: [
|
|
"TaskID": taskId,
|
|
"ReaderType": readerType
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to mark messages as read")
|
|
}
|
|
}
|
|
|
|
// MARK: - Payout / Tier Endpoints
|
|
|
|
func getTierStatus() async throws -> TierStatus {
|
|
let json = try await postJSON("/workers/tierStatus.cfm", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load tier status: \(err(json))")
|
|
}
|
|
let data = try JSONSerialization.data(withJSONObject: json)
|
|
return (try? JSONDecoder().decode(TierStatus.self, from: data)) ?? TierStatus()
|
|
}
|
|
|
|
func createStripeAccount() async throws -> String {
|
|
let json = try await postJSON("/workers/createAccount.cfm", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to create Stripe account: \(err(json))")
|
|
}
|
|
return (json["AccountID"] as? String) ?? (json["ACCOUNT_ID"] as? String) ?? ""
|
|
}
|
|
|
|
func getOnboardingLink() async throws -> String {
|
|
let json = try await postJSON("/workers/onboardingLink.cfm", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to get onboarding link: \(err(json))")
|
|
}
|
|
return (json["URL"] as? String) ?? (json["url"] as? String) ?? ""
|
|
}
|
|
|
|
func getEarlyUnlockUrl() async throws -> String {
|
|
let json = try await postJSON("/workers/earlyUnlock.cfm", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to get unlock URL: \(err(json))")
|
|
}
|
|
return (json["URL"] as? String) ?? (json["url"] as? String) ?? ""
|
|
}
|
|
|
|
func getLedger() async throws -> LedgerResponse {
|
|
let json = try await postJSON("/workers/ledger.cfm", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load ledger: \(err(json))")
|
|
}
|
|
let data = try JSONSerialization.data(withJSONObject: json)
|
|
return try JSONDecoder().decode(LedgerResponse.self, from: data)
|
|
}
|
|
|
|
// MARK: - Debug
|
|
|
|
/// Returns raw JSON string for a given endpoint (for debugging key issues)
|
|
func debugRawJSON(_ path: String, payload: [String: Any]) async -> String {
|
|
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
|
|
guard let url = URL(string: urlString) else { return "Invalid URL" }
|
|
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)
|
|
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
|
|
return "Network error"
|
|
}
|
|
let raw = String(data: data, encoding: .utf8) ?? "Non-UTF8"
|
|
// Return first 2000 chars
|
|
return String(raw.prefix(2000))
|
|
}
|
|
|
|
// MARK: - URL Helpers
|
|
|
|
/// Resolve a photo URL — if it starts with "/" prepend the base domain
|
|
nonisolated static 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 = IS_DEV ? "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 }
|
|
|
|
// Try epoch (milliseconds or seconds)
|
|
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
|
|
}
|
|
|
|
}
|
|
|
|
|