parent
04a833b2c7
commit
daa34132c4
@ -0,0 +1,92 @@ |
|||||||
|
// |
||||||
|
// ChatService.swift |
||||||
|
// Chat |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 11/12/2023. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
enum Method: String { |
||||||
|
case get = "GET" |
||||||
|
case post = "POST" |
||||||
|
case put = "PUT" |
||||||
|
case delete = "DELETE" |
||||||
|
} |
||||||
|
|
||||||
|
enum ServiceError: Error { |
||||||
|
case urlCreationError(url: String) |
||||||
|
} |
||||||
|
|
||||||
|
class Services { |
||||||
|
|
||||||
|
init(url: String) { |
||||||
|
self._baseURL = url |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate var _baseURL: String |
||||||
|
|
||||||
|
fileprivate var jsonDecoder: JSONDecoder = { |
||||||
|
let decoder = JSONDecoder() |
||||||
|
let dateFormatter = DateFormatter() |
||||||
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" |
||||||
|
decoder.dateDecodingStrategy = .formatted(dateFormatter) |
||||||
|
return decoder |
||||||
|
}() |
||||||
|
|
||||||
|
// MARK: - Base |
||||||
|
|
||||||
|
fileprivate func runRequest<T : Decodable>(_ request: URLRequest) async throws -> T { |
||||||
|
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) |
||||||
|
return try jsonDecoder.decode(T.self, from: task.0) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func getRequest(servicePath: String) throws -> URLRequest { |
||||||
|
return try self._baseRequest(servicePath: servicePath, method: .get) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func postRequest(servicePath: String) throws -> URLRequest { |
||||||
|
return try self._baseRequest(servicePath: servicePath, method: .post) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func putRequest(servicePath: String) throws -> URLRequest { |
||||||
|
return try self._baseRequest(servicePath: servicePath, method: .put) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func deleteRequest(servicePath: String) throws -> URLRequest { |
||||||
|
return try self._baseRequest(servicePath: servicePath, method: .delete) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _baseRequest(servicePath: String, method: Method) throws -> URLRequest { |
||||||
|
let urlString = _baseURL + servicePath |
||||||
|
guard let url = URL(string: urlString) else { |
||||||
|
throw ServiceError.urlCreationError(url: urlString) |
||||||
|
} |
||||||
|
var request = URLRequest(url: url) |
||||||
|
request.httpMethod = method.rawValue |
||||||
|
return request |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Services |
||||||
|
|
||||||
|
func get<T : Storable>() async throws -> [T] { |
||||||
|
let getRequest = try getRequest(servicePath: T.resourceName + "/") |
||||||
|
return try await self.runRequest(getRequest) |
||||||
|
} |
||||||
|
|
||||||
|
func insert<T : Storable>(_ instance: T) async throws -> T { |
||||||
|
let postRequest = try postRequest(servicePath: T.resourceName + "/") |
||||||
|
return try await self.runRequest(postRequest) |
||||||
|
} |
||||||
|
|
||||||
|
func update<T : Storable>(_ instance: T) async throws -> T { |
||||||
|
let postRequest = try putRequest(servicePath: T.resourceName + "/") |
||||||
|
return try await self.runRequest(postRequest) |
||||||
|
} |
||||||
|
|
||||||
|
func delete<T : Storable>(_ instance: T) async throws -> T { |
||||||
|
let postRequest = try deleteRequest(servicePath: T.resourceName + "/") |
||||||
|
return try await self.runRequest(postRequest) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,181 @@ |
|||||||
|
// |
||||||
|
// StoredCollection.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 02/02/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
public protocol Storable : Codable, Identifiable where ID : Hashable { |
||||||
|
static var resourceName: String { get } |
||||||
|
} |
||||||
|
|
||||||
|
public class StoredCollection<T : Storable> : RandomAccessCollection, ObservableObject { |
||||||
|
|
||||||
|
let synchronized: Bool |
||||||
|
|
||||||
|
@Published public fileprivate(set) var items: [T] = [] |
||||||
|
|
||||||
|
fileprivate var _serviceProvider: ServiceProvider |
||||||
|
|
||||||
|
fileprivate var _hasChanged: Bool = false { |
||||||
|
didSet { |
||||||
|
if self._hasChanged == true { |
||||||
|
self.objectWillChange.send() |
||||||
|
self._scheduleWrite() |
||||||
|
self._hasChanged = false |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
init(synchronized: Bool, serviceProvider: ServiceProvider) { |
||||||
|
self.synchronized = synchronized |
||||||
|
self._serviceProvider = serviceProvider |
||||||
|
self._load() |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate var _fileName: String { |
||||||
|
return T.resourceName + ".json" |
||||||
|
} |
||||||
|
|
||||||
|
public func addOrUpdate(instance: T) { |
||||||
|
defer { |
||||||
|
self._hasChanged = true |
||||||
|
} |
||||||
|
|
||||||
|
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
||||||
|
self.items[index] = instance |
||||||
|
self._sendUpdateIfNecessary(instance) |
||||||
|
} else { |
||||||
|
self.items.append(instance) |
||||||
|
self._sendInsertionIfNecessary(instance) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
public func delete(instance: T) { |
||||||
|
defer { |
||||||
|
self._hasChanged = true |
||||||
|
} |
||||||
|
|
||||||
|
self.items.removeAll { $0.id == instance.id } |
||||||
|
self._sendDeletionIfNecessary(instance) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _scheduleWrite() { |
||||||
|
self._write() |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _write() { |
||||||
|
DispatchQueue(label: "lestorage.queue.write", qos: .background).async { |
||||||
|
do { |
||||||
|
let jsonString = try self.items.jsonString() |
||||||
|
let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName) |
||||||
|
} catch { |
||||||
|
Logger.error(error) // TODO how to notify the main project |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _load() { |
||||||
|
|
||||||
|
do { |
||||||
|
let url = try FileUtils.directoryURLForFileName(self._fileName) |
||||||
|
if FileManager.default.fileExists(atPath: url.path()) { |
||||||
|
self._loadAsync() |
||||||
|
} |
||||||
|
} catch { |
||||||
|
Logger.log(error) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _loadAsync() { |
||||||
|
DispatchQueue(label: "lestorage.queue.read", qos: .background).async { |
||||||
|
|
||||||
|
do { |
||||||
|
let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName) |
||||||
|
if let decoded: [T] = try jsonString.decodeArray() { |
||||||
|
DispatchQueue.main.sync { |
||||||
|
Logger.log("loaded \(self._fileName) with \(decoded.count) items") |
||||||
|
self.items = decoded |
||||||
|
self.objectWillChange.send() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} catch { |
||||||
|
Logger.error(error) // TODO how to notify the main project |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Synchronization |
||||||
|
|
||||||
|
fileprivate func _sendInsertionIfNecessary(_ instance: T) { |
||||||
|
guard self.synchronized else { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
Task { |
||||||
|
do { |
||||||
|
let _ = try await self._serviceProvider.service?.insert(instance) |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _sendUpdateIfNecessary(_ instance: T) { |
||||||
|
guard self.synchronized else { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
Task { |
||||||
|
do { |
||||||
|
let _ = try await self._serviceProvider.service?.insert(instance) |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _sendDeletionIfNecessary(_ instance: T) { |
||||||
|
guard self.synchronized else { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
Task { |
||||||
|
do { |
||||||
|
let _ = try await self._serviceProvider.service?.delete(instance) |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - RandomAccessCollection |
||||||
|
|
||||||
|
public var startIndex: Int { return self.items.startIndex } |
||||||
|
|
||||||
|
public var endIndex: Int { return self.items.endIndex } |
||||||
|
|
||||||
|
public func index(after i: Int) -> Int { |
||||||
|
return self.items.index(after: i) |
||||||
|
} |
||||||
|
|
||||||
|
open subscript(index: Int) -> T { |
||||||
|
get { |
||||||
|
return self.items[index] |
||||||
|
} |
||||||
|
set(newValue) { |
||||||
|
self.items[index] = newValue |
||||||
|
self._hasChanged = true |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
// |
||||||
|
// Codable+Extensions.swift |
||||||
|
// Poker Analytics 6 |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 18/01/2023. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension Encodable { |
||||||
|
|
||||||
|
func jsonString() throws -> String { |
||||||
|
let data = try self.jsonData() |
||||||
|
return String(data: data, encoding: .utf8) ?? "" |
||||||
|
} |
||||||
|
|
||||||
|
func jsonData() throws -> Data { |
||||||
|
let encoder: JSONEncoder = JSONEncoder() |
||||||
|
return try encoder.encode(self) |
||||||
|
} |
||||||
|
|
||||||
|
func prettyJSONString() throws -> String { |
||||||
|
let encoder: JSONEncoder = JSONEncoder() |
||||||
|
encoder.outputFormatting = .prettyPrinted |
||||||
|
let data = try encoder.encode(self) |
||||||
|
return String(data: data, encoding: .utf8) ?? "" |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
extension String { |
||||||
|
|
||||||
|
func decode<T : Decodable>() throws -> T? { |
||||||
|
return try self.data(using: .utf8)?.decode() |
||||||
|
} |
||||||
|
|
||||||
|
func decodeArray<T : Decodable>() throws -> [T]? { |
||||||
|
return try self.data(using: .utf8)?.decodeArray() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
extension Data { |
||||||
|
|
||||||
|
func decode<T : Decodable>() throws -> T { |
||||||
|
return try JSONDecoder().decode(T.self, from: self) |
||||||
|
} |
||||||
|
|
||||||
|
func decodeArray<T : Decodable>() throws -> [T] { |
||||||
|
return try JSONDecoder().decode([T].self, from: self) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
// |
||||||
|
// FileWriter.swift |
||||||
|
// Poker Analytics 4 |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 04/09/2018. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
enum FileError : Error { |
||||||
|
case documentDirectoryNotFound |
||||||
|
} |
||||||
|
|
||||||
|
enum FileFormat { |
||||||
|
case csv |
||||||
|
case html |
||||||
|
} |
||||||
|
|
||||||
|
class FileUtils { |
||||||
|
|
||||||
|
static func pathsFromDocumentsDirectory() throws -> [String] { |
||||||
|
let documentsURL: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] |
||||||
|
return try FileManager.default.contentsOfDirectory(atPath: documentsURL.path) |
||||||
|
} |
||||||
|
|
||||||
|
static func readDocumentFile(fileName: String) throws -> String { |
||||||
|
let fileURL: URL = try self.directoryURLForFileName(fileName) |
||||||
|
// Logger.log("url = \(fileURL.absoluteString)") |
||||||
|
return try String(contentsOf: fileURL, encoding: .utf8) |
||||||
|
|
||||||
|
// if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { |
||||||
|
// let fileURL: URL = dir.appendingPathComponent(fileName) |
||||||
|
// Logger.log("url = \(fileURL.absoluteString)") |
||||||
|
// return try String(contentsOf: fileURL, encoding: .utf8) |
||||||
|
// } |
||||||
|
// throw FileError.documentDirectoryNotFound |
||||||
|
} |
||||||
|
|
||||||
|
static func readFile(fileURL: URL) throws -> String { |
||||||
|
return try String(contentsOf: fileURL, encoding: .utf8) |
||||||
|
} |
||||||
|
|
||||||
|
static func directoryURLForFileName(_ fileName: String) throws -> URL { |
||||||
|
if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { |
||||||
|
return dir.appendingPathComponent(fileName) |
||||||
|
} |
||||||
|
throw FileError.documentDirectoryNotFound |
||||||
|
} |
||||||
|
|
||||||
|
static func writeToDocumentDirectory(content: String, fileName: String) throws -> URL { |
||||||
|
|
||||||
|
if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { |
||||||
|
let fileURL: URL = dir.appendingPathComponent(fileName) |
||||||
|
try content.write(to: fileURL, atomically: false, encoding: .utf8) |
||||||
|
return fileURL |
||||||
|
} |
||||||
|
throw FileError.documentDirectoryNotFound |
||||||
|
} |
||||||
|
|
||||||
|
@discardableResult static func writeToDocumentDirectory(data: Data, fileName: String) throws -> URL { |
||||||
|
|
||||||
|
if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { |
||||||
|
let fileURL: URL = dir.appendingPathComponent(fileName) |
||||||
|
try data.write(to: fileURL) |
||||||
|
return fileURL |
||||||
|
} |
||||||
|
throw FileError.documentDirectoryNotFound |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
// |
||||||
|
// Logger.swift |
||||||
|
// Poker Analytics 4 |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 29/03/2018. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
#if !DEBUG |
||||||
|
import Firebase |
||||||
|
#endif |
||||||
|
|
||||||
|
@objc public class Logger : NSObject { |
||||||
|
|
||||||
|
@objc static public func log(_ message: Any, file: String = #file, function: String = #function, line: Int = #line) { |
||||||
|
let filestr: NSString = NSString(string: file) |
||||||
|
print("\(filestr.lastPathComponent).\(line).\(function): \(message)") |
||||||
|
} |
||||||
|
|
||||||
|
@objc static public func error(_ error: Error) { |
||||||
|
Logger.error(error, file: #file, function: #function, line: #line) |
||||||
|
} |
||||||
|
|
||||||
|
static public func error(_ error: Error, file: String = #file, function: String = #function, line: Int = #line) { |
||||||
|
let filestr: NSString = NSString(string: file) |
||||||
|
var fireBaseError: Error { |
||||||
|
if let customError = error as? CustomNSError & LocalizedError { |
||||||
|
return customError.fireBaseError |
||||||
|
} else { |
||||||
|
return error |
||||||
|
} |
||||||
|
} |
||||||
|
print("ERROR: \(filestr.lastPathComponent).\(line).\(function): \(fireBaseError)") |
||||||
|
} |
||||||
|
|
||||||
|
@objc static public func w(_ message: Any, file: String = #file, function: String = #function, line: Int = #line) { |
||||||
|
let filestr: NSString = NSString(string: file) |
||||||
|
print("Warning: \(filestr.lastPathComponent).\(line).\(function): \(message)") |
||||||
|
} |
||||||
|
|
||||||
|
@objc static public func crashLogging(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { |
||||||
|
let fileName: String = file.components(separatedBy: "/").last ?? file |
||||||
|
#if DEBUG |
||||||
|
NSLogv("%@.%i.%@: %@", getVaList([fileName, line, function, message])) |
||||||
|
#else |
||||||
|
Crashlytics.crashlytics().log(format: "%@.%i.%@: %@", arguments: getVaList([fileName, line, function, message])) |
||||||
|
#endif |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
extension LocalizedError where Self: CustomNSError { |
||||||
|
var simpleErrorDescription: String { |
||||||
|
let mirror = Mirror(reflecting: self) |
||||||
|
if let label = mirror.children.first?.label { |
||||||
|
return label |
||||||
|
} else { |
||||||
|
return String(describing:self) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var fireBaseError: NSError { |
||||||
|
NSError(domain: Self.errorDomain + "." + self.simpleErrorDescription, code: self.errorCode, userInfo: self.errorUserInfo) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue