ajout de l'import des joueurs de la FFT et l'écran de recherche de joueurs, avec toutes les classes et objets que cela implique
parent
602ae91c40
commit
75d8b1e42b
@ -0,0 +1,27 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23D60" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> |
||||||
|
<entity name="ImportedPlayer" representedClassName=".ImportedPlayer" syncable="YES" codeGenerationType="class"> |
||||||
|
<attribute name="assimilation" attributeType="String"/> |
||||||
|
<attribute name="canonicalFirstName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(firstName)"/> |
||||||
|
<attribute name="canonicalFullName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(lastName)"/> |
||||||
|
<attribute name="canonicalLastName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(lastName)"/> |
||||||
|
<attribute name="clubCode" attributeType="String"/> |
||||||
|
<attribute name="clubName" attributeType="String"/> |
||||||
|
<attribute name="country" attributeType="String"/> |
||||||
|
<attribute name="firstName" attributeType="String"/> |
||||||
|
<attribute name="fullName" attributeType="String"/> |
||||||
|
<attribute name="importDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
||||||
|
<attribute name="lastName" attributeType="String"/> |
||||||
|
<attribute name="license" attributeType="String"/> |
||||||
|
<attribute name="ligueName" attributeType="String"/> |
||||||
|
<attribute name="male" attributeType="Boolean" usesScalarValueType="YES"/> |
||||||
|
<attribute name="points" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
||||||
|
<attribute name="rank" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> |
||||||
|
<attribute name="tournamentCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> |
||||||
|
<uniquenessConstraints> |
||||||
|
<uniquenessConstraint> |
||||||
|
<constraint value="license"/> |
||||||
|
</uniquenessConstraint> |
||||||
|
</uniquenessConstraints> |
||||||
|
</entity> |
||||||
|
</model> |
||||||
@ -0,0 +1,173 @@ |
|||||||
|
// |
||||||
|
// Persistence.swift |
||||||
|
// Padel Tournament |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 24/02/2023. |
||||||
|
// |
||||||
|
|
||||||
|
import CoreData |
||||||
|
|
||||||
|
class PersistenceController: NSObject { |
||||||
|
static let shared = PersistenceController() |
||||||
|
|
||||||
|
private static var _model: NSManagedObjectModel? |
||||||
|
private static func model(name: String) throws -> NSManagedObjectModel { |
||||||
|
if _model == nil { |
||||||
|
_model = try loadModel(name: name, bundle: Bundle.main) |
||||||
|
} |
||||||
|
return _model! |
||||||
|
} |
||||||
|
private static func loadModel(name: String, bundle: Bundle) throws -> NSManagedObjectModel { |
||||||
|
guard let modelURL = bundle.url(forResource: name, withExtension: "momd") else { |
||||||
|
throw CoreDataError.modelURLNotFound(forResourceName: name) |
||||||
|
} |
||||||
|
|
||||||
|
guard let model = NSManagedObjectModel(contentsOf: modelURL) else { |
||||||
|
throw CoreDataError.modelLoadingFailed(forURL: modelURL) |
||||||
|
} |
||||||
|
return model |
||||||
|
} |
||||||
|
|
||||||
|
enum CoreDataError: Error { |
||||||
|
case modelURLNotFound(forResourceName: String) |
||||||
|
case modelLoadingFailed(forURL: URL) |
||||||
|
} |
||||||
|
|
||||||
|
lazy var localContainer : NSPersistentContainer = { |
||||||
|
let baseURL = NSPersistentContainer.defaultDirectoryURL() |
||||||
|
let storeFolderURL = baseURL.appendingPathComponent("CoreDataStores") |
||||||
|
let localStoreFolderURL = storeFolderURL.appendingPathComponent("Local") |
||||||
|
|
||||||
|
let fileManager = FileManager.default |
||||||
|
for folderURL in [localStoreFolderURL] where !fileManager.fileExists(atPath: folderURL.path) { |
||||||
|
do { |
||||||
|
try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil) |
||||||
|
} catch { |
||||||
|
fatalError("#\(#function): Failed to create the store folder: \(error)") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let container = NSPersistentContainer(name: "PadelClubApp", managedObjectModel: try! Self.model(name: "PadelClubApp")) |
||||||
|
|
||||||
|
guard let localStoreDescription = container.persistentStoreDescriptions.first!.copy() as? NSPersistentStoreDescription else { |
||||||
|
fatalError("#\(#function): Copying the private store description returned an unexpected value.") |
||||||
|
} |
||||||
|
localStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) |
||||||
|
localStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) |
||||||
|
localStoreDescription.setValue("DELETE" as NSObject, forPragmaNamed: "journal_mode") |
||||||
|
|
||||||
|
localStoreDescription.url = localStoreFolderURL.appendingPathComponent("local.sqlite") |
||||||
|
|
||||||
|
var storeDescriptions = [localStoreDescription] |
||||||
|
|
||||||
|
/** |
||||||
|
Load the persistent stores. |
||||||
|
*/ |
||||||
|
container.persistentStoreDescriptions = storeDescriptions |
||||||
|
|
||||||
|
container.loadPersistentStores(completionHandler: { (loadedStoreDescription, error) in |
||||||
|
guard error == nil else { |
||||||
|
fatalError("#\(#function): Failed to load persistent stores:\(error!)") |
||||||
|
} |
||||||
|
// if UserDefaults.standard.string(forKey: "lastDataSource") == nil { |
||||||
|
// UserDefaults.standard.setValue("09-2023", forKey: "lastDataSource") |
||||||
|
// } |
||||||
|
}) |
||||||
|
container.viewContext.automaticallyMergesChangesFromParent = true |
||||||
|
container.viewContext.name = "viewContext" |
||||||
|
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
||||||
|
container.viewContext.undoManager = nil |
||||||
|
container.viewContext.shouldDeleteInaccessibleFaults = true |
||||||
|
container.viewContext.transactionAuthor = PersistenceController.authorName |
||||||
|
|
||||||
|
return container |
||||||
|
}() |
||||||
|
|
||||||
|
|
||||||
|
/// Creates and configures a private queue context. |
||||||
|
private func newTaskContext() -> NSManagedObjectContext { |
||||||
|
// Create a private queue context. |
||||||
|
/// - Tag: newBackgroundContext |
||||||
|
let taskContext = localContainer.newBackgroundContext() |
||||||
|
taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
||||||
|
// Set unused undoManager to nil for macOS (it is nil by default on iOS) |
||||||
|
// to reduce resource requirements. |
||||||
|
taskContext.undoManager = nil |
||||||
|
return taskContext |
||||||
|
} |
||||||
|
|
||||||
|
func batchInsertPlayers(_ importedPlayers: [FederalPlayer], importingDate: Date) async { |
||||||
|
guard !importedPlayers.isEmpty else { return } |
||||||
|
let context = newTaskContext() |
||||||
|
context.performAndWait { |
||||||
|
context.transactionAuthor = PersistenceController.remoteDataImportAuthorName |
||||||
|
|
||||||
|
let batchInsert = self.newBatchInsertRequest(with: importedPlayers, importingDate: importingDate) |
||||||
|
do { |
||||||
|
let result = try context.execute(batchInsert) as? NSBatchInsertResult |
||||||
|
if let objectIDs = result?.result as? [NSManagedObjectID], !objectIDs.isEmpty { |
||||||
|
let save = [NSInsertedObjectsKey: objectIDs] |
||||||
|
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: save, into: [localContainer.viewContext]) |
||||||
|
} |
||||||
|
|
||||||
|
} catch { |
||||||
|
print(error.localizedDescription) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func newBatchInsertRequest(with imported: [FederalPlayer], importingDate: Date) |
||||||
|
-> NSBatchInsertRequest { |
||||||
|
// 1 |
||||||
|
var index = 0 |
||||||
|
let total = imported.count |
||||||
|
|
||||||
|
// 2 |
||||||
|
let batchInsert = NSBatchInsertRequest( |
||||||
|
entity: ImportedPlayer.entity()) { (managedObject: NSManagedObject) -> Bool in |
||||||
|
// 3 |
||||||
|
guard index < total else { return true } |
||||||
|
|
||||||
|
if let importedPlayer = managedObject as? ImportedPlayer { |
||||||
|
// 4 |
||||||
|
let data = imported[index] |
||||||
|
importedPlayer.license = data.license |
||||||
|
importedPlayer.ligueName = data.ligue |
||||||
|
importedPlayer.rank = Int64(data.rank) |
||||||
|
importedPlayer.points = data.points ?? 0 |
||||||
|
importedPlayer.assimilation = data.assimilation |
||||||
|
importedPlayer.country = data.country |
||||||
|
importedPlayer.tournamentCount = Int64(data.tournamentCount ?? 0) |
||||||
|
importedPlayer.lastName = data.lastName |
||||||
|
importedPlayer.firstName = data.firstName |
||||||
|
importedPlayer.fullName = data.firstName + " " + data.lastName |
||||||
|
importedPlayer.clubName = data.club |
||||||
|
importedPlayer.clubCode = data.clubCode.replaceCharactersFromSet(characterSet: .whitespaces) |
||||||
|
importedPlayer.male = data.isMale |
||||||
|
importedPlayer.importDate = importingDate |
||||||
|
} |
||||||
|
|
||||||
|
// 5 |
||||||
|
index += 1 |
||||||
|
return false |
||||||
|
} |
||||||
|
return batchInsert |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - History Management |
||||||
|
private static let authorName = "Padel Tournament" |
||||||
|
private static let remoteDataImportAuthorName = "Data Import" |
||||||
|
|
||||||
|
private func mergeChanges(from transactions: [NSPersistentHistoryTransaction]) { |
||||||
|
let context = localContainer.viewContext |
||||||
|
context.perform { |
||||||
|
transactions.forEach { transaction in |
||||||
|
guard let userInfo = transaction.objectIDNotification().userInfo else { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context]) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
// |
||||||
|
// FederalPlayer.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 01/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension ImportedPlayer { |
||||||
|
var isAssimilated: Bool { |
||||||
|
assimilation == "Oui" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
struct FederalPlayer { |
||||||
|
var rank: Int |
||||||
|
var lastName: String |
||||||
|
var firstName: String |
||||||
|
var country: String |
||||||
|
var license: String |
||||||
|
var points: Double? |
||||||
|
var assimilation: String |
||||||
|
var tournamentCount: Int? |
||||||
|
var ligue: String |
||||||
|
var clubCode: String |
||||||
|
var club: String |
||||||
|
var isMale: Bool |
||||||
|
|
||||||
|
var fullNameCanonical: String |
||||||
|
|
||||||
|
/* |
||||||
|
;RANG;NOM;PRENOM;Nationalité;N° Licence;POINTS;Assimilation;NB. DE TOURNOIS JOUES;LIGUE;CODE CLUB;CLUB; |
||||||
|
*/ |
||||||
|
|
||||||
|
var isManPlayer: Bool { |
||||||
|
isMale |
||||||
|
} |
||||||
|
|
||||||
|
var isAssimilated: Bool { |
||||||
|
assimilation == "Oui" |
||||||
|
} |
||||||
|
|
||||||
|
var currentRank: Int { |
||||||
|
rank |
||||||
|
} |
||||||
|
|
||||||
|
init?(_ data: String, isMale: Bool = false) { |
||||||
|
self.isMale = isMale |
||||||
|
|
||||||
|
var result = data.components(separatedBy: .newlines).map { $0.trimmed } |
||||||
|
result = result.reversed().drop(while: { |
||||||
|
$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
||||||
|
}).reversed() as [String] |
||||||
|
|
||||||
|
result = Array(result.drop(while: { |
||||||
|
$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
||||||
|
})) |
||||||
|
|
||||||
|
print(result) |
||||||
|
if result.count < 11 { |
||||||
|
return nil |
||||||
|
} |
||||||
|
if let _rank = Int(result[0]) { |
||||||
|
rank = _rank |
||||||
|
} else { |
||||||
|
return nil |
||||||
|
} |
||||||
|
lastName = result[1] |
||||||
|
firstName = result[2] |
||||||
|
|
||||||
|
fullNameCanonical = result[1].canonicalVersion + " " + result[2].canonicalVersion |
||||||
|
country = result[3] |
||||||
|
license = result[4] |
||||||
|
|
||||||
|
// let matches = result[5].matches(of: try! Regex("[0-9]{1,5}\\.00")) |
||||||
|
// |
||||||
|
// if matches.count == 1 { |
||||||
|
// let pts = result[5][matches.first!.range] |
||||||
|
// points = Double(pts.replacingOccurrences(of: ",", with: ".")) |
||||||
|
// if pts.count < result[5].count { |
||||||
|
// |
||||||
|
// } |
||||||
|
// } |
||||||
|
// |
||||||
|
points = Double(result[5].replacingOccurrences(of: ",", with: ".")) |
||||||
|
assimilation = result[6] |
||||||
|
tournamentCount = Int(result[7]) |
||||||
|
ligue = result[8] |
||||||
|
clubCode = result[9] |
||||||
|
club = result[10] |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
// |
||||||
|
// Array+Extensions.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 03/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension Array { |
||||||
|
func chunked(into size: Int) -> [[Element]] { |
||||||
|
return stride(from: 0, to: count, by: size).map { |
||||||
|
Array(self[$0 ..< Swift.min($0 + size, count)]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func anySatisfy(_ p: (Element) -> Bool) -> Bool { |
||||||
|
return !self.allSatisfy { !p($0) } |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
// |
||||||
|
// Date+Extensions.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 01/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension Date { |
||||||
|
var monthYearFormatted: String { |
||||||
|
formatted(.dateTime.month(.wide).year(.defaultDigits)) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
// |
||||||
|
// FixedWidthInteger+Extensions.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 03/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
public extension FixedWidthInteger { |
||||||
|
func ordinalFormattedSuffix() -> String { |
||||||
|
switch self { |
||||||
|
case 1: return "er" |
||||||
|
default: return "ème" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func ordinalFormatted() -> String { |
||||||
|
self.formatted() + self.ordinalFormattedSuffix() |
||||||
|
} |
||||||
|
|
||||||
|
var pluralSuffix: String { |
||||||
|
self > 1 ? "s" : "" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
// |
||||||
|
// Sequence+Extensions.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 03/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension Sequence { |
||||||
|
func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] { |
||||||
|
return sorted { a, b in |
||||||
|
return a[keyPath: keyPath] < b[keyPath: keyPath] |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
// |
||||||
|
// String+Extensions.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 01/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension String { |
||||||
|
var trimmed: String { |
||||||
|
trimmingCharacters(in: .whitespacesAndNewlines) |
||||||
|
} |
||||||
|
|
||||||
|
func replaceCharactersFromSet(characterSet: CharacterSet, replacementString: String = "") -> String { |
||||||
|
components(separatedBy: characterSet).joined(separator:replacementString) |
||||||
|
} |
||||||
|
|
||||||
|
var canonicalVersion: String { |
||||||
|
trimmed.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ").folding(options: .diacriticInsensitive, locale: .current).lowercased() |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,51 @@ |
|||||||
|
// |
||||||
|
// URL+Extensions.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 01/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension URL { |
||||||
|
|
||||||
|
static var savedDateFormatter: DateFormatter = { |
||||||
|
let df = DateFormatter() |
||||||
|
df.dateFormat = "DD/MM/yyyy" |
||||||
|
return df |
||||||
|
}() |
||||||
|
|
||||||
|
static var importDateFormatter: DateFormatter = { |
||||||
|
let df = DateFormatter() |
||||||
|
df.dateFormat = "MM-yyyy" |
||||||
|
return df |
||||||
|
}() |
||||||
|
|
||||||
|
var dateFromPath: Date { |
||||||
|
let found = deletingPathExtension().path().components(separatedBy: "-").suffix(2).joined(separator: "-") |
||||||
|
if let date = URL.importDateFormatter.date(from: found) { |
||||||
|
return date |
||||||
|
} else { |
||||||
|
return Date() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var index: Int { |
||||||
|
if let i = path().dropLast(12).last?.wholeNumberValue { |
||||||
|
return i |
||||||
|
} |
||||||
|
return 0 |
||||||
|
} |
||||||
|
|
||||||
|
var manData: Bool { |
||||||
|
path().contains("MESSIEURS") |
||||||
|
} |
||||||
|
|
||||||
|
var womanData: Bool { |
||||||
|
path().contains("DAMES") |
||||||
|
} |
||||||
|
|
||||||
|
static var seed: URL? { |
||||||
|
Bundle.main.url(forResource: "SeedData", withExtension: nil) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
// |
||||||
|
// FileImportManager.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 01/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class FileImportManager { |
||||||
|
static let shared = FileImportManager() |
||||||
|
|
||||||
|
func importDataFromFFT() async -> String? { |
||||||
|
if let importingDate = SourceFile.mostRecentDateAvailable { |
||||||
|
for source in SourceFile.allCases { |
||||||
|
for fileURL in source.currentURLs { |
||||||
|
let p = readCSV(inputFile: fileURL) |
||||||
|
await importingChunkOfPlayers(p, importingDate: importingDate) |
||||||
|
} |
||||||
|
} |
||||||
|
return URL.importDateFormatter.string(from: importingDate) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
func readCSV(inputFile: URL) -> [FederalPlayer] { |
||||||
|
do { |
||||||
|
let fileContent = try String(contentsOf: inputFile) |
||||||
|
return loadFromCSV(fileContent: fileContent, isMale: inputFile.manData) |
||||||
|
} catch { |
||||||
|
print("error: \(error)") // to do deal with errors |
||||||
|
} |
||||||
|
return [] |
||||||
|
} |
||||||
|
|
||||||
|
func loadFromCSV(fileContent: String, isMale: Bool) -> [FederalPlayer] { |
||||||
|
let lines = fileContent.components(separatedBy: "\n") |
||||||
|
return lines.compactMap { line in |
||||||
|
if line.components(separatedBy: ";").count < 10 { |
||||||
|
} else { |
||||||
|
let data = line.components(separatedBy: ";").joined(separator: "\n") |
||||||
|
return FederalPlayer(data, isMale: isMale) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func importingChunkOfPlayers(_ players: [FederalPlayer], importingDate: Date) async { |
||||||
|
for chunk in players.chunked(into: 1000) { |
||||||
|
await PersistenceController.shared.batchInsertPlayers(chunk, importingDate: importingDate) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
// |
||||||
|
// NetworkManager.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 01/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
class NetworkManager { |
||||||
|
static let shared: NetworkManager = NetworkManager() |
||||||
|
|
||||||
|
func removeRankingData(lastDateString: String, fileName: String) { |
||||||
|
let dateString = ["CLASSEMENT-PADEL", fileName, lastDateString].joined(separator: "-") + ".csv" |
||||||
|
|
||||||
|
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)! |
||||||
|
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") |
||||||
|
try? FileManager.default.removeItem(at: destinationFileUrl) |
||||||
|
} |
||||||
|
|
||||||
|
func downloadRankingData(lastDateString: String, fileName: String) async throws { |
||||||
|
|
||||||
|
let dateString = ["CLASSEMENT-PADEL", fileName, lastDateString].joined(separator: "-") + ".csv" |
||||||
|
|
||||||
|
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)! |
||||||
|
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") |
||||||
|
let fileURL = URL(string: "https://padelclub.app/static/\(dateString)") |
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: destinationFileUrl.path()) { |
||||||
|
return |
||||||
|
} |
||||||
|
var request = URLRequest(url:fileURL!) |
||||||
|
request.addValue("attachment;filename=\(dateString)", forHTTPHeaderField:"Content-Disposition") |
||||||
|
request.addValue("text/csv", forHTTPHeaderField: "Content-Type") |
||||||
|
|
||||||
|
let task = try await URLSession.shared.download(for: request) |
||||||
|
if let urlResponse = task.1 as? HTTPURLResponse { |
||||||
|
print(urlResponse.statusCode) |
||||||
|
if urlResponse.statusCode == 200 { |
||||||
|
try FileManager.default.copyItem(at: task.0, to: destinationFileUrl) |
||||||
|
} else if urlResponse.statusCode == 404 && fileName == "MESSIEURS" { |
||||||
|
throw NetworkManagerError.fileNotYetAvailable |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
// |
||||||
|
// NetworkManagerError.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 03/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
enum NetworkManagerError: LocalizedError { |
||||||
|
case maintenance |
||||||
|
case fileNotYetAvailable |
||||||
|
case mailFailed |
||||||
|
case mailNotSent //no network no error |
||||||
|
case messageFailed |
||||||
|
case messageNotSent //no network no error |
||||||
|
} |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
// |
||||||
|
// SourceFileManager.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 01/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
enum SourceFile: String, CaseIterable { |
||||||
|
case dames = "DAMES" |
||||||
|
case messieurs = "MESSIEURS" |
||||||
|
|
||||||
|
static var mostRecentDateAvailable: Date? { |
||||||
|
allFiles(false).first?.dateFromPath |
||||||
|
} |
||||||
|
|
||||||
|
static func removeAllFilesFromServer() { |
||||||
|
let docDir = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) |
||||||
|
let allFiles = try! FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) |
||||||
|
allFiles.filter { $0.pathExtension == "csv" }.forEach { url in |
||||||
|
try? FileManager.default.removeItem(at: url) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static var allFiles: [URL] { |
||||||
|
let docDir = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) |
||||||
|
let allFiles = try! FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil).filter({ url in |
||||||
|
url.pathExtension == "csv" |
||||||
|
}) |
||||||
|
|
||||||
|
return (allFiles + (Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil) ?? [])).sorted(by: \.dateFromPath).reversed() |
||||||
|
} |
||||||
|
|
||||||
|
static func allFiles(_ isManPlayer: Bool) -> [URL] { |
||||||
|
allFiles.filter({ url in |
||||||
|
url.path().contains(isManPlayer ? SourceFile.messieurs.rawValue : SourceFile.dames.rawValue) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
static func allFilesSortedByDate(_ isManPlayer: Bool) -> [URL] { |
||||||
|
return allFiles(isManPlayer) |
||||||
|
} |
||||||
|
|
||||||
|
var filesFromServer: [URL] { |
||||||
|
let docDir = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) |
||||||
|
let allFiles = try! FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) |
||||||
|
return allFiles.filter{$0.pathExtension == "csv" && $0.path().contains(rawValue)} |
||||||
|
} |
||||||
|
|
||||||
|
var currentURLs: [URL] { |
||||||
|
var files = Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil)?.filter({ url in |
||||||
|
url.path().contains(rawValue) |
||||||
|
}) ?? [] |
||||||
|
|
||||||
|
files.append(contentsOf: filesFromServer) |
||||||
|
|
||||||
|
if let mostRecent = files.sorted(by: \.dateFromPath).reversed().first { |
||||||
|
return files.filter({ $0.dateFromPath == mostRecent.dateFromPath }) |
||||||
|
} else { |
||||||
|
return [] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var isMan: Bool { |
||||||
|
switch self { |
||||||
|
case .dames: |
||||||
|
return false |
||||||
|
default: |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,436 @@ |
|||||||
|
// |
||||||
|
// SearchViewModel.swift |
||||||
|
// Padel Tournament |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 07/02/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import SwiftUI |
||||||
|
|
||||||
|
class SearchViewModel: ObservableObject, Identifiable { |
||||||
|
let id: UUID = UUID() |
||||||
|
var allowSelection : Int = 0 |
||||||
|
var user: User? = nil |
||||||
|
var codeClub: String? = nil |
||||||
|
var clubName: String? = nil |
||||||
|
var ligueName: String? = nil |
||||||
|
@Published var debouncableText: String = "" |
||||||
|
@Published var searchText: String = "" |
||||||
|
@Published var task: DispatchWorkItem? |
||||||
|
@Published var computedSearchText: String = "" |
||||||
|
@Published var tokens = [SearchToken]() |
||||||
|
@Published var suggestedTokens = [SearchToken]() |
||||||
|
@Published var dataSet: DataSet = .national |
||||||
|
@Published var filterOption = PlayerFilterOption.all |
||||||
|
@Published var hideAssimilation: Bool = false |
||||||
|
@Published var ascending: Bool = true |
||||||
|
@Published var sortOption: SortOption = .rank |
||||||
|
@Published var selectedPlayers: Set<ImportedPlayer> = Set() |
||||||
|
@Published var filterSelectionEnabled: Bool = false |
||||||
|
var forcedSearch = false |
||||||
|
var mostRecentDate: Date? = nil |
||||||
|
|
||||||
|
var selectionIsOver: Bool { |
||||||
|
if allowSingleSelection && selectedPlayers.count == 1 { |
||||||
|
return true |
||||||
|
} else if allowMultipleSelection && selectedPlayers.count == allowSelection { |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
var allowMultipleSelection: Bool { |
||||||
|
allowSelection > 1 || allowSelection == -1 |
||||||
|
} |
||||||
|
|
||||||
|
var allowSingleSelection: Bool { |
||||||
|
allowSelection == 1 |
||||||
|
} |
||||||
|
|
||||||
|
var debounceTrigger: Double { |
||||||
|
dataSet == .national ? 0.4 : 0.1 |
||||||
|
} |
||||||
|
|
||||||
|
var throttleTrigger: Double { |
||||||
|
dataSet == .national ? 0.15 : 0.1 |
||||||
|
} |
||||||
|
|
||||||
|
var contentUnavailableMessage: String { |
||||||
|
var message = ["Vérifiez l'ortographe ou lancez une nouvelle recherche."] |
||||||
|
if tokens.isEmpty { |
||||||
|
message.append("Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois. Dans ce pas, Padel Club ne pourra pas le trouver.") |
||||||
|
} |
||||||
|
return message.joined(separator: "\n") |
||||||
|
} |
||||||
|
|
||||||
|
func getCodeClub() -> String? { |
||||||
|
if let codeClub { return codeClub } |
||||||
|
// if let userCodeClub = user?.player?.codeClub { return userCodeClub } |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func getClubName() -> String? { |
||||||
|
if let clubName { return clubName } |
||||||
|
// if let userClubName = user?.player?.clubName { return userClubName } |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func showIndex() -> Bool { |
||||||
|
if dataSet == .national { return false } |
||||||
|
if filterOption == .all { return false } |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
func prompt(forDataSet: DataSet) -> String { |
||||||
|
switch forDataSet { |
||||||
|
case .national: |
||||||
|
if let mostRecentDate { |
||||||
|
return "base fédérale \(mostRecentDate.monthYearFormatted)" |
||||||
|
} else { |
||||||
|
return "rechercher" |
||||||
|
} |
||||||
|
case .ligue: |
||||||
|
return "dans cette ligue" |
||||||
|
case .club: |
||||||
|
return "dans ce club" |
||||||
|
case .favorite: |
||||||
|
return "dans mes favoris" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func label(forDataSet: DataSet) -> String { |
||||||
|
switch forDataSet { |
||||||
|
case .national: |
||||||
|
return "National" |
||||||
|
case .ligue: |
||||||
|
return (ligueName)?.capitalized ?? "Ma ligue" |
||||||
|
case .club: |
||||||
|
return (clubName)?.capitalized ?? "Mon club" |
||||||
|
case .favorite: |
||||||
|
return "Mes favoris" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func words() -> [String] { |
||||||
|
searchText.trimmed.components(separatedBy: .whitespaces) |
||||||
|
} |
||||||
|
|
||||||
|
func wordsPredicates() -> NSPredicate? { |
||||||
|
let words = words() |
||||||
|
switch words.count { |
||||||
|
case 2: |
||||||
|
let predicates = [ |
||||||
|
NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[0], words[1]), |
||||||
|
NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[1], words[0]), |
||||||
|
] |
||||||
|
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates) |
||||||
|
default: |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func orPredicate() -> NSPredicate? { |
||||||
|
var predicates : [NSPredicate] = [] |
||||||
|
|
||||||
|
switch tokens.first { |
||||||
|
case .none: |
||||||
|
if searchText.isEmpty == false { |
||||||
|
let wordsPredicates = wordsPredicates() |
||||||
|
if let wordsPredicates { |
||||||
|
predicates.append(wordsPredicates) |
||||||
|
} else { |
||||||
|
predicates.append(NSPredicate(format: "canonicalFullName contains[cd] %@", searchText)) |
||||||
|
predicates.append(NSPredicate(format: "license contains[cd] %@", searchText)) |
||||||
|
} |
||||||
|
} |
||||||
|
case .ligue: |
||||||
|
if searchText.isEmpty { |
||||||
|
predicates.append(NSPredicate(format: "ligueName == nil")) |
||||||
|
} else { |
||||||
|
predicates.append(NSPredicate(format: "ligueName contains[cd] %@", searchText)) |
||||||
|
} |
||||||
|
case .club: |
||||||
|
if searchText.isEmpty { |
||||||
|
predicates.append(NSPredicate(format: "clubName == nil")) |
||||||
|
} else { |
||||||
|
predicates.append(NSPredicate(format: "clubName contains[cd] %@", searchText)) |
||||||
|
} |
||||||
|
case .rankMoreThan: |
||||||
|
if searchText.isEmpty || Int(searchText) == 0 { |
||||||
|
predicates.append(NSPredicate(format: "rank == 0")) |
||||||
|
} else { |
||||||
|
predicates.append(NSPredicate(format: "rank >= %@", searchText)) |
||||||
|
} |
||||||
|
case .rankLessThan: |
||||||
|
if searchText.isEmpty || Int(searchText) == 0 { |
||||||
|
predicates.append(NSPredicate(format: "rank == 0")) |
||||||
|
} else { |
||||||
|
predicates.append(NSPredicate(format: "rank <= %@", searchText)) |
||||||
|
} |
||||||
|
case .rankBetween: |
||||||
|
let values = searchText.components(separatedBy: ",") |
||||||
|
if searchText.isEmpty || values.count != 2 { |
||||||
|
predicates.append(NSPredicate(format: "rank == 0")) |
||||||
|
} else { |
||||||
|
predicates.append(NSPredicate(format: "rank BETWEEN {%@,%@}", values.first!, values.last!)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if predicates.isEmpty { |
||||||
|
return nil |
||||||
|
} |
||||||
|
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates) |
||||||
|
} |
||||||
|
|
||||||
|
func predicate() -> NSPredicate? { |
||||||
|
var predicates : [NSPredicate?] = [ |
||||||
|
orPredicate(), |
||||||
|
filterOption == .male ? |
||||||
|
NSPredicate(format: "male == YES") : |
||||||
|
nil, |
||||||
|
filterOption == .female ? |
||||||
|
NSPredicate(format: "male == NO") : |
||||||
|
nil, |
||||||
|
] |
||||||
|
|
||||||
|
if let mostRecentDate { |
||||||
|
predicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg)) |
||||||
|
} |
||||||
|
|
||||||
|
if hideAssimilation { |
||||||
|
predicates.append(NSPredicate(format: "assimilation == %@", "Non")) |
||||||
|
} |
||||||
|
|
||||||
|
switch dataSet { |
||||||
|
case .national: |
||||||
|
break |
||||||
|
case .ligue: |
||||||
|
if let ligueName { |
||||||
|
predicates.append(NSPredicate(format: "ligueName == %@", ligueName)) |
||||||
|
} else { |
||||||
|
predicates.append(NSPredicate(format: "ligueName == nil")) |
||||||
|
} |
||||||
|
case .club: |
||||||
|
if let codeClub { |
||||||
|
predicates.append(NSPredicate(format: "clubCode == %@", codeClub)) |
||||||
|
} else { |
||||||
|
predicates.append(NSPredicate(format: "clubCode == nil")) |
||||||
|
} |
||||||
|
case .favorite: |
||||||
|
predicates.append(NSPredicate(format: "license == nil")) |
||||||
|
} |
||||||
|
|
||||||
|
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates.compactMap({ $0 })) |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
func sortDescriptors() -> [SortDescriptor<ImportedPlayer>] { |
||||||
|
sortOption.sortDescriptors(ascending, dataSet: dataSet) |
||||||
|
} |
||||||
|
|
||||||
|
func nsSortDescriptors() -> [NSSortDescriptor] { |
||||||
|
sortDescriptors().map { NSSortDescriptor($0) } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
enum SearchToken: String, CaseIterable, Identifiable { |
||||||
|
case club = "club" |
||||||
|
case ligue = "ligue" |
||||||
|
case rankMoreThan = "rang >" |
||||||
|
case rankLessThan = "rang <" |
||||||
|
case rankBetween = "rang <>" |
||||||
|
|
||||||
|
var id: String { |
||||||
|
rawValue |
||||||
|
} |
||||||
|
|
||||||
|
var message: String { |
||||||
|
switch self { |
||||||
|
case .club: |
||||||
|
return "Taper le nom d'un club pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois." |
||||||
|
case .ligue: |
||||||
|
return "Taper le nom d'une ligue pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois." |
||||||
|
case .rankMoreThan: |
||||||
|
return "Taper un nombre pour chercher les joueurs ayant un classement supérieur ou égale." |
||||||
|
case .rankLessThan: |
||||||
|
return "Taper un nombre pour chercher les joueurs ayant un classement inférieur ou égale." |
||||||
|
case .rankBetween: |
||||||
|
return "Taper deux nombres séparés par une virgule pour chercher les joueurs dans cette intervalle de classement" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var titleLabel: String { |
||||||
|
switch self { |
||||||
|
case .club: |
||||||
|
return "Chercher un club" |
||||||
|
case .ligue: |
||||||
|
return "Chercher une ligue" |
||||||
|
case .rankMoreThan, .rankLessThan: |
||||||
|
return "Chercher un rang" |
||||||
|
case .rankBetween: |
||||||
|
return "Chercher une intervalle de classement" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var localizedLabel: String { |
||||||
|
switch self { |
||||||
|
case .club: |
||||||
|
return "Club" |
||||||
|
case .ligue: |
||||||
|
return "Ligue" |
||||||
|
case .rankMoreThan: |
||||||
|
return "Rang supérieur ou égale à" |
||||||
|
case .rankLessThan: |
||||||
|
return "Rang inférieur ou égale à" |
||||||
|
case .rankBetween: |
||||||
|
return "Rang entre deux valeurs" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var shortLocalizedLabel: String { |
||||||
|
switch self { |
||||||
|
case .club: |
||||||
|
return "Club" |
||||||
|
case .ligue: |
||||||
|
return "Ligue" |
||||||
|
case .rankMoreThan: |
||||||
|
return "Rang ≥" |
||||||
|
case .rankLessThan: |
||||||
|
return "Rang ≤" |
||||||
|
case .rankBetween: |
||||||
|
return "Rang ≥,≤" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func icon() -> String { |
||||||
|
switch self { |
||||||
|
case .club: |
||||||
|
return "house.and.flag.fill" |
||||||
|
case .ligue: |
||||||
|
return "house.and.flag.fill" |
||||||
|
case .rankMoreThan: |
||||||
|
return "figure.racquetball" |
||||||
|
case .rankLessThan: |
||||||
|
return "figure.racquetball" |
||||||
|
case .rankBetween: |
||||||
|
return "figure.racquetball" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var systemImage: String { |
||||||
|
switch self { |
||||||
|
case .club: |
||||||
|
return "house.and.flag.fill" |
||||||
|
case .ligue: |
||||||
|
return "house.and.flag.fill" |
||||||
|
case .rankMoreThan: |
||||||
|
return "figure.racquetball" |
||||||
|
case .rankLessThan: |
||||||
|
return "figure.racquetball" |
||||||
|
case .rankBetween: |
||||||
|
return "figure.racquetball" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
enum DataSet: Int, CaseIterable, Identifiable { |
||||||
|
case national |
||||||
|
case ligue |
||||||
|
case club |
||||||
|
case favorite |
||||||
|
|
||||||
|
var id: Int { rawValue } |
||||||
|
var localizedLabel: String { |
||||||
|
switch self { |
||||||
|
case .national: |
||||||
|
return "National" |
||||||
|
case .ligue: |
||||||
|
return "Ligue" |
||||||
|
case .club: |
||||||
|
return "Club" |
||||||
|
case .favorite: |
||||||
|
return "Favori" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var tokens: [SearchToken] { |
||||||
|
switch self { |
||||||
|
case .national: |
||||||
|
return [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween] |
||||||
|
case .ligue: |
||||||
|
return [.club, .rankMoreThan, .rankLessThan, .rankBetween] |
||||||
|
case .club: |
||||||
|
return [.rankMoreThan, .rankLessThan, .rankBetween] |
||||||
|
case .favorite: |
||||||
|
return [.rankMoreThan, .rankLessThan, .rankBetween] |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
enum SortOption: Int, CaseIterable, Identifiable { |
||||||
|
case name |
||||||
|
case rank |
||||||
|
case tournamentCount |
||||||
|
case points |
||||||
|
|
||||||
|
var id: Int { self.rawValue } |
||||||
|
var localizedLabel: String { |
||||||
|
switch self { |
||||||
|
case .name: |
||||||
|
return "Nom" |
||||||
|
case .rank: |
||||||
|
return "Rang" |
||||||
|
case .tournamentCount: |
||||||
|
return "Tournoi" |
||||||
|
case .points: |
||||||
|
return "Points" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] { |
||||||
|
switch self { |
||||||
|
case .name: |
||||||
|
return [SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation)] |
||||||
|
case .rank: |
||||||
|
if dataSet == .national { |
||||||
|
return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)] |
||||||
|
} else { |
||||||
|
return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)] |
||||||
|
} |
||||||
|
case .tournamentCount: |
||||||
|
return [SortDescriptor(\ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)] |
||||||
|
case .points: |
||||||
|
return [SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)] |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
enum PlayerFilterOption: Int, Hashable, CaseIterable, Identifiable { |
||||||
|
case all = -1 |
||||||
|
case male = 1 |
||||||
|
case female = 0 |
||||||
|
|
||||||
|
var id: Int { rawValue } |
||||||
|
|
||||||
|
func icon() -> String { |
||||||
|
switch self { |
||||||
|
case .all: |
||||||
|
return "Tous" |
||||||
|
case .male: |
||||||
|
return "Homme" |
||||||
|
case .female: |
||||||
|
return "Femme" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var localizedPlayerLabel: String { |
||||||
|
switch self { |
||||||
|
case .female: |
||||||
|
return "joueuse" |
||||||
|
default: |
||||||
|
return "joueur" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
// |
||||||
|
// RowButtonView.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 03/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import SwiftUI |
||||||
|
|
||||||
|
struct RowButtonView: View { |
||||||
|
let title: String |
||||||
|
var systemImage: String? = nil |
||||||
|
var image: String? = nil |
||||||
|
let action: () -> () |
||||||
|
|
||||||
|
var body: some View { |
||||||
|
Button { |
||||||
|
action() |
||||||
|
} label: { |
||||||
|
HStack { |
||||||
|
Spacer() |
||||||
|
if let systemImage { |
||||||
|
Image(systemName: systemImage) |
||||||
|
} |
||||||
|
if let image { |
||||||
|
Image(image) |
||||||
|
.resizable() |
||||||
|
.scaledToFit() |
||||||
|
.frame(width: 32, height: 32) |
||||||
|
} |
||||||
|
Text(title) |
||||||
|
.foregroundColor(.white) |
||||||
|
.frame(height: 32) |
||||||
|
Spacer() |
||||||
|
} |
||||||
|
.font(.headline) |
||||||
|
} |
||||||
|
.frame(maxWidth: .infinity) |
||||||
|
.buttonStyle(.borderedProminent) |
||||||
|
.tint(.launchScreenBackground) |
||||||
|
.listRowBackground(Color.clear) |
||||||
|
.listRowInsets(EdgeInsets(.zero)) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
// |
||||||
|
// TournamentButtonView.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 03/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import SwiftUI |
||||||
|
|
||||||
|
struct TournamentButtonView: View { |
||||||
|
let tournament: Tournament |
||||||
|
@Binding var selectedId: String? |
||||||
|
|
||||||
|
var body: some View { |
||||||
|
Button { |
||||||
|
if selectedId == tournament.id { |
||||||
|
tournament.navigationPath.removeAll() |
||||||
|
selectedId = nil |
||||||
|
// if tournament.navigationPath.isEmpty { |
||||||
|
// selectedId = nil |
||||||
|
// } else { |
||||||
|
// tournament.navigationPath.removeLast() |
||||||
|
// } |
||||||
|
} else { |
||||||
|
selectedId = tournament.id |
||||||
|
} |
||||||
|
} label: { |
||||||
|
TournamentCellView(tournament: tournament) |
||||||
|
.padding(8) |
||||||
|
.overlay( |
||||||
|
RoundedRectangle(cornerRadius: 20) |
||||||
|
.stroke(Color.black, lineWidth: 2) |
||||||
|
) |
||||||
|
.fixedSize(horizontal: false, vertical: true) |
||||||
|
} |
||||||
|
.overlay(alignment: .top) { |
||||||
|
if selectedId == tournament.id { |
||||||
|
Image(systemName: "ellipsis") |
||||||
|
.offset(y: -10) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,442 @@ |
|||||||
|
// |
||||||
|
// SelectablePlayerListView.swift |
||||||
|
// Padel Tournament |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 10/02/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import SwiftUI |
||||||
|
import CoreData |
||||||
|
import Combine |
||||||
|
import LeStorage |
||||||
|
|
||||||
|
typealias PlayerSelectionAction = ((Set<ImportedPlayer>) -> ()) |
||||||
|
typealias ContentUnavailableAction = ((SearchViewModel) -> ()) |
||||||
|
|
||||||
|
struct SelectablePlayerListView: View { |
||||||
|
let allowSelection: Int |
||||||
|
let playerSelectionAction: PlayerSelectionAction? |
||||||
|
let contentUnavailableAction: ContentUnavailableAction? |
||||||
|
|
||||||
|
@StateObject private var searchViewModel: SearchViewModel |
||||||
|
@Environment(\.dismiss) var dismiss |
||||||
|
@AppStorage("lastDataSource") var lastDataSource: String? |
||||||
|
@AppStorage("importingFiles") var importingFiles: Bool = false |
||||||
|
|
||||||
|
@State private var searchText: String = "" |
||||||
|
var mostRecentDate: Date? { |
||||||
|
guard let lastDataSource else { return nil } |
||||||
|
return URL.importDateFormatter.date(from: lastDataSource) |
||||||
|
} |
||||||
|
|
||||||
|
init(allowSelection: Int = 0, user: User? = nil, dataSet: DataSet = .national, filterOption: PlayerFilterOption = .all, hideAssimilation: Bool = false, ascending: Bool = true, sortOption: SortOption = .rank, fromPlayer: FederalPlayer? = nil, codeClub: String? = nil, ligue: String? = nil, playerSelectionAction: PlayerSelectionAction? = nil, contentUnavailableAction: ContentUnavailableAction? = nil) { |
||||||
|
self.allowSelection = allowSelection |
||||||
|
self.playerSelectionAction = playerSelectionAction |
||||||
|
self.contentUnavailableAction = contentUnavailableAction |
||||||
|
let searchViewModel = SearchViewModel() |
||||||
|
searchViewModel.user = user |
||||||
|
searchViewModel.allowSelection = allowSelection |
||||||
|
searchViewModel.codeClub = fromPlayer?.clubCode ?? codeClub |
||||||
|
searchViewModel.clubName = nil |
||||||
|
searchViewModel.ligueName = fromPlayer?.ligue ?? ligue |
||||||
|
searchViewModel.dataSet = dataSet |
||||||
|
searchViewModel.filterOption = fromPlayer == nil ? filterOption : fromPlayer!.isManPlayer ? .male : .female |
||||||
|
searchViewModel.hideAssimilation = hideAssimilation |
||||||
|
searchViewModel.ascending = ascending |
||||||
|
searchViewModel.sortOption = sortOption |
||||||
|
_searchViewModel = StateObject(wrappedValue: searchViewModel) |
||||||
|
} |
||||||
|
|
||||||
|
var body: some View { |
||||||
|
VStack(spacing: 0) { |
||||||
|
if importingFiles == false { |
||||||
|
if searchViewModel.filterSelectionEnabled == false { |
||||||
|
Picker(selection: $searchViewModel.filterOption) { |
||||||
|
ForEach(PlayerFilterOption.allCases, id: \.self) { scope in |
||||||
|
Text(scope.icon().capitalized) |
||||||
|
} |
||||||
|
} label: { |
||||||
|
} |
||||||
|
.pickerStyle(.segmented) |
||||||
|
.padding(.bottom) |
||||||
|
.padding(.horizontal) |
||||||
|
.background(Material.thick) |
||||||
|
Divider() |
||||||
|
} |
||||||
|
MySearchView(searchViewModel: searchViewModel, contentUnavailableAction: contentUnavailableAction) |
||||||
|
.environment(\.editMode, searchViewModel.allowMultipleSelection ? .constant(.active) : .constant(.inactive)) |
||||||
|
.searchable(text: $searchViewModel.debouncableText, tokens: $searchViewModel.tokens, suggestedTokens: $searchViewModel.suggestedTokens, placement: .navigationBarDrawer(displayMode: .always), prompt: searchViewModel.prompt(forDataSet: searchViewModel.dataSet), token: { token in |
||||||
|
Text(token.shortLocalizedLabel) |
||||||
|
}) |
||||||
|
// .searchSuggestions({ |
||||||
|
// ForEach(searchViewModel.suggestedTokens) { token in |
||||||
|
// Button { |
||||||
|
// searchViewModel.tokens.append(token) |
||||||
|
// } label: { |
||||||
|
// Label(token.localizedLabel, systemImage: token.icon()) |
||||||
|
// } |
||||||
|
// } |
||||||
|
// }) |
||||||
|
|
||||||
|
.onReceive( |
||||||
|
searchViewModel.$debouncableText |
||||||
|
.debounce(for: .seconds(searchViewModel.debounceTrigger), scheduler: DispatchQueue.main) |
||||||
|
.throttle(for: .seconds(searchViewModel.throttleTrigger), scheduler: DispatchQueue.main, latest: true) |
||||||
|
) { |
||||||
|
guard !$0.isEmpty else { |
||||||
|
if searchViewModel.searchText.isEmpty == false { |
||||||
|
searchViewModel.searchText = "" |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
print(">> searching for: \($0)") |
||||||
|
searchViewModel.searchText = $0 |
||||||
|
} |
||||||
|
.scrollDismissesKeyboard(.immediately) |
||||||
|
.navigationBarBackButtonHidden(searchViewModel.allowMultipleSelection) |
||||||
|
// .toolbarRole(searchViewModel.allowMultipleSelection ? .navigationStack : .editor) |
||||||
|
.interactiveDismissDisabled(searchViewModel.selectedPlayers.isEmpty == false) |
||||||
|
.navigationTitle(searchViewModel.label(forDataSet: searchViewModel.dataSet)) |
||||||
|
.navigationBarTitleDisplayMode(.inline) |
||||||
|
} else { |
||||||
|
List { |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
.id(importingFiles) |
||||||
|
.overlay { |
||||||
|
if let importedFile = SourceFile.mostRecentDateAvailable, importingFiles { |
||||||
|
ContentUnavailableView("Importation en cours", systemImage: "square.and.arrow.down", description: Text("Padel Club récupère les données de \(importedFile.monthYearFormatted)")) |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
.onAppear { |
||||||
|
searchViewModel.mostRecentDate = mostRecentDate |
||||||
|
if searchViewModel.tokens.isEmpty { |
||||||
|
searchViewModel.debouncableText.removeAll() |
||||||
|
searchViewModel.searchText.removeAll() |
||||||
|
} |
||||||
|
searchViewModel.allowSelection = allowSelection |
||||||
|
searchViewModel.selectedPlayers.removeAll() |
||||||
|
searchViewModel.hideAssimilation = false |
||||||
|
searchViewModel.ascending = true |
||||||
|
searchViewModel.sortOption = .rank |
||||||
|
searchViewModel.suggestedTokens = searchViewModel.dataSet.tokens |
||||||
|
// searchViewModel.fromPlayer = nil |
||||||
|
// searchViewModel.codeClub = nil |
||||||
|
// searchViewModel.ligueName = nil |
||||||
|
// searchViewModel.user = nil |
||||||
|
// searchViewModel.dataSet = .national |
||||||
|
// searchViewModel.filterOption = .all |
||||||
|
} |
||||||
|
.onChange(of: searchViewModel.selectedPlayers) { |
||||||
|
|
||||||
|
if let playerSelectionAction, searchViewModel.selectionIsOver { |
||||||
|
playerSelectionAction(searchViewModel.selectedPlayers) |
||||||
|
dismiss() |
||||||
|
} |
||||||
|
|
||||||
|
if searchViewModel.tokens.isEmpty && searchViewModel.searchText.isEmpty == false { |
||||||
|
searchViewModel.debouncableText = "" |
||||||
|
} |
||||||
|
|
||||||
|
if searchViewModel.selectedPlayers.isEmpty && searchViewModel.filterSelectionEnabled { |
||||||
|
searchViewModel.filterSelectionEnabled = false |
||||||
|
} |
||||||
|
} |
||||||
|
.onChange(of: searchViewModel.dataSet) { |
||||||
|
searchViewModel.suggestedTokens = searchViewModel.dataSet.tokens |
||||||
|
if searchViewModel.filterSelectionEnabled { |
||||||
|
searchViewModel.filterSelectionEnabled = false |
||||||
|
} |
||||||
|
} |
||||||
|
.toolbar { |
||||||
|
if searchViewModel.allowMultipleSelection { |
||||||
|
ToolbarItemGroup(placement: .topBarLeading) { |
||||||
|
Button(role: .cancel) { |
||||||
|
searchViewModel.selectedPlayers.removeAll() |
||||||
|
dismiss() |
||||||
|
} label: { |
||||||
|
Text("Annuler") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
// .modifierWithCondition(searchViewModel.user != nil) { thisView in |
||||||
|
// thisView |
||||||
|
.toolbarTitleMenu { |
||||||
|
Picker(selection: $searchViewModel.dataSet) { |
||||||
|
ForEach(DataSet.allCases) { dataSet in |
||||||
|
Text(searchViewModel.label(forDataSet: dataSet)).tag(dataSet) |
||||||
|
} |
||||||
|
} label: { |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
// } |
||||||
|
// .bottomBarAlternative(hide: searchViewModel.selectedPlayers.isEmpty) { |
||||||
|
// ZStack { |
||||||
|
// HStack{ |
||||||
|
// Button { |
||||||
|
// searchViewModel.filterSelectionEnabled.toggle() |
||||||
|
// } label: { |
||||||
|
// if searchViewModel.filterSelectionEnabled { |
||||||
|
// Image(systemName: "line.3.horizontal.decrease.circle.fill") |
||||||
|
// } else { |
||||||
|
// Image(systemName: "line.3.horizontal.decrease.circle") |
||||||
|
// } |
||||||
|
// } |
||||||
|
// Spacer() |
||||||
|
// } |
||||||
|
// Button { |
||||||
|
// if let playerSelectionAction { |
||||||
|
// playerSelectionAction(searchViewModel.selectedPlayers) |
||||||
|
// } |
||||||
|
// dismiss() |
||||||
|
// } label: { |
||||||
|
// Text("Ajouter le" + searchViewModel.selectedPlayers.count.pluralSuffix + " \(searchViewModel.selectedPlayers.count) joueur" + searchViewModel.selectedPlayers.count.pluralSuffix) |
||||||
|
// } |
||||||
|
// .buttonStyle(.borderedProminent) |
||||||
|
// } |
||||||
|
// } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
struct MySearchView: View { |
||||||
|
@Environment(\.isSearching) private var isSearching |
||||||
|
@Environment(\.dismissSearch) private var dismissSearch |
||||||
|
@Environment(\.editMode) var editMode |
||||||
|
@ObservedObject var searchViewModel: SearchViewModel |
||||||
|
|
||||||
|
@FetchRequest private var players: FetchedResults<ImportedPlayer> |
||||||
|
let contentUnavailableAction: ContentUnavailableAction? |
||||||
|
|
||||||
|
init(searchViewModel: SearchViewModel, contentUnavailableAction: ContentUnavailableAction? = nil) { |
||||||
|
self.contentUnavailableAction = contentUnavailableAction |
||||||
|
_searchViewModel = ObservedObject(wrappedValue: searchViewModel) |
||||||
|
_players = FetchRequest<ImportedPlayer>(sortDescriptors: searchViewModel.sortDescriptors(), predicate: searchViewModel.predicate()) |
||||||
|
} |
||||||
|
|
||||||
|
var body: some View { |
||||||
|
playersView |
||||||
|
.overlay { |
||||||
|
overlayView() |
||||||
|
} |
||||||
|
.onChange(of: isSearching) { |
||||||
|
if isSearching && searchViewModel.filterSelectionEnabled { |
||||||
|
searchViewModel.filterSelectionEnabled = false |
||||||
|
} |
||||||
|
} |
||||||
|
.onChange(of: searchViewModel.filterSelectionEnabled) { |
||||||
|
if searchViewModel.filterSelectionEnabled && isSearching { |
||||||
|
dismissSearch() |
||||||
|
} |
||||||
|
} |
||||||
|
.listStyle(.grouped) |
||||||
|
.headerProminence(.increased) |
||||||
|
.scrollDismissesKeyboard(.immediately) |
||||||
|
.onChange(of: searchViewModel.searchText) { |
||||||
|
search() |
||||||
|
} |
||||||
|
.onChange(of: searchViewModel.filterOption) { |
||||||
|
search() |
||||||
|
} |
||||||
|
.onChange(of: searchViewModel.dataSet) { |
||||||
|
search() |
||||||
|
} |
||||||
|
.onChange(of: searchViewModel.sortOption) { |
||||||
|
sort() |
||||||
|
} |
||||||
|
.onChange(of: searchViewModel.ascending) { |
||||||
|
sort() |
||||||
|
} |
||||||
|
.onChange(of: searchViewModel.hideAssimilation) { |
||||||
|
search() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var specificBugFixUUID: String { |
||||||
|
if searchViewModel.dataSet == .national { |
||||||
|
return UUID().uuidString |
||||||
|
} else { |
||||||
|
if searchViewModel.tokens.isEmpty && isSearching { |
||||||
|
return UUID().uuidString |
||||||
|
} |
||||||
|
return "specificBugFixUUID" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@ViewBuilder |
||||||
|
var playersView: some View { |
||||||
|
if searchViewModel.allowMultipleSelection { |
||||||
|
List(selection: $searchViewModel.selectedPlayers) { |
||||||
|
if searchViewModel.filterSelectionEnabled { |
||||||
|
let array = Array(searchViewModel.selectedPlayers) |
||||||
|
Section { |
||||||
|
ForEach(array) { player in |
||||||
|
ImportedPlayerView(player: player) |
||||||
|
} |
||||||
|
.onDelete { indexSet in |
||||||
|
for index in indexSet { |
||||||
|
let p = array[index] |
||||||
|
searchViewModel.selectedPlayers.remove(p) |
||||||
|
} |
||||||
|
} |
||||||
|
} header: { |
||||||
|
Text(searchViewModel.selectedPlayers.count.formatted() + " " + searchViewModel.filterOption.localizedPlayerLabel + searchViewModel.selectedPlayers.count.pluralSuffix) |
||||||
|
} |
||||||
|
} else { |
||||||
|
Section { |
||||||
|
ForEach(players, id: \.self) { player in |
||||||
|
ImportedPlayerView(player: player, index: nil) |
||||||
|
} |
||||||
|
} header: { |
||||||
|
if players.isEmpty == false { |
||||||
|
headerView() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
.id(specificBugFixUUID) |
||||||
|
} else { |
||||||
|
List { |
||||||
|
if searchViewModel.dataSet == .national { |
||||||
|
if searchViewModel.allowSingleSelection { |
||||||
|
Section { |
||||||
|
ForEach(players) { player in |
||||||
|
Button { |
||||||
|
searchViewModel.selectedPlayers.insert(player) |
||||||
|
} label: { |
||||||
|
ImportedPlayerView(player: player) |
||||||
|
} |
||||||
|
.buttonStyle(.plain) |
||||||
|
} |
||||||
|
} header: { |
||||||
|
if players.isEmpty == false { |
||||||
|
headerView() |
||||||
|
} |
||||||
|
} |
||||||
|
.id(UUID()) |
||||||
|
} else { |
||||||
|
Section { |
||||||
|
ForEach(players) { player in |
||||||
|
ImportedPlayerView(player: player) |
||||||
|
} |
||||||
|
} header: { |
||||||
|
if players.isEmpty == false { |
||||||
|
headerView() |
||||||
|
} |
||||||
|
} |
||||||
|
.id(UUID()) |
||||||
|
} |
||||||
|
} else { |
||||||
|
Section { |
||||||
|
ForEach(players.indices, id: \.self) { index in |
||||||
|
let player = players[index] |
||||||
|
if searchViewModel.allowSingleSelection { |
||||||
|
Button { |
||||||
|
searchViewModel.selectedPlayers.insert(player) |
||||||
|
} label: { |
||||||
|
ImportedPlayerView(player: player, index: searchViewModel.showIndex() ? (index + 1) : nil) |
||||||
|
.contentShape(Rectangle()) |
||||||
|
} |
||||||
|
.frame(maxWidth: .infinity) |
||||||
|
.buttonStyle(.plain) |
||||||
|
} else { |
||||||
|
ImportedPlayerView(player: player) |
||||||
|
} |
||||||
|
} |
||||||
|
} header: { |
||||||
|
if players.isEmpty == false { |
||||||
|
headerView() |
||||||
|
} |
||||||
|
} |
||||||
|
.id(UUID()) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func headerView() -> some View { |
||||||
|
HStack { |
||||||
|
Text(players.count.formatted() + " " + searchViewModel.filterOption.localizedPlayerLabel + players.count.pluralSuffix) |
||||||
|
Spacer() |
||||||
|
Menu { |
||||||
|
Section { |
||||||
|
ForEach(SortOption.allCases) { option in |
||||||
|
Toggle(isOn: .init(get: { |
||||||
|
return searchViewModel.sortOption == option |
||||||
|
}, set: { value in |
||||||
|
if searchViewModel.sortOption == option { |
||||||
|
searchViewModel.ascending.toggle() |
||||||
|
} |
||||||
|
searchViewModel.sortOption = option |
||||||
|
})) { |
||||||
|
Label(option.localizedLabel, systemImage: searchViewModel.sortOption == option ? (searchViewModel.ascending ? "chevron.up" : "chevron.down") : "") |
||||||
|
} |
||||||
|
} |
||||||
|
} header: { |
||||||
|
Text("Trier par") |
||||||
|
} |
||||||
|
Divider() |
||||||
|
Section { |
||||||
|
Toggle(isOn: .init(get: { |
||||||
|
return searchViewModel.hideAssimilation == false |
||||||
|
}, set: { value in |
||||||
|
searchViewModel.hideAssimilation.toggle() |
||||||
|
})) { |
||||||
|
Text("Afficher") |
||||||
|
} |
||||||
|
Toggle(isOn: .init(get: { |
||||||
|
return searchViewModel.hideAssimilation == true |
||||||
|
}, set: { value in |
||||||
|
searchViewModel.hideAssimilation.toggle() |
||||||
|
})) { |
||||||
|
Text("Masquer") |
||||||
|
} |
||||||
|
} header: { |
||||||
|
Text("Assimilés") |
||||||
|
} |
||||||
|
} label: { |
||||||
|
Label(searchViewModel.sortOption.localizedLabel, systemImage: searchViewModel.ascending ? "chevron.up" : "chevron.down") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@ViewBuilder |
||||||
|
func overlayView() -> some View { |
||||||
|
if let token = searchViewModel.tokens.first, searchViewModel.searchText.isEmpty { |
||||||
|
ContentUnavailableView(token.titleLabel, systemImage: token.systemImage, description: Text(token.message)) |
||||||
|
} else if players.isEmpty && searchViewModel.filterSelectionEnabled == false && searchViewModel.searchText.isEmpty == false { |
||||||
|
ContentUnavailableView { |
||||||
|
Label("Aucun résultat pour «\(searchViewModel.searchText)»", systemImage: "magnifyingglass") |
||||||
|
} description: { |
||||||
|
Text(searchViewModel.contentUnavailableMessage) |
||||||
|
} actions: { |
||||||
|
|
||||||
|
Button { |
||||||
|
searchViewModel.debouncableText = "" |
||||||
|
} label: { |
||||||
|
Text("lancer une nouvelle recherche") |
||||||
|
} |
||||||
|
if let contentUnavailableAction { |
||||||
|
Button { |
||||||
|
contentUnavailableAction(searchViewModel) |
||||||
|
} label: { |
||||||
|
Text("créer \(searchViewModel.searchText)") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func search() { |
||||||
|
//players.nsPredicate = searchViewModel.predicate() |
||||||
|
} |
||||||
|
|
||||||
|
private func sort() { |
||||||
|
//players.nsSortDescriptors = searchViewModel.nsSortDescriptors() |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
// |
||||||
|
// PresentationContext.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 03/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
enum PresentationContext { |
||||||
|
case agenda |
||||||
|
case organizer |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
// |
||||||
|
// Screen.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 03/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
enum Screen: String, Codable { |
||||||
|
case inscription |
||||||
|
case groupStage |
||||||
|
} |
||||||
@ -0,0 +1,55 @@ |
|||||||
|
// |
||||||
|
// DeferredViewModifier.swift |
||||||
|
// PadelClub |
||||||
|
// |
||||||
|
// Created by Razmig Sarkissian on 02/03/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import SwiftUI |
||||||
|
|
||||||
|
/// Defers the rendering of a view for the given period. |
||||||
|
/// |
||||||
|
/// For example: |
||||||
|
/// |
||||||
|
/// ```swift |
||||||
|
/// Text("Hello, world!") |
||||||
|
/// .deferredRendering(for: .seconds(5)) |
||||||
|
/// ``` |
||||||
|
/// |
||||||
|
/// will not display the text "Hello, world!" until five seconds after the |
||||||
|
/// view is initially rendered. If the view is destroyed within the delay, |
||||||
|
/// it never renders. |
||||||
|
/// |
||||||
|
/// This is based on code xwritten by Yonat and Charlton Provatas on |
||||||
|
/// Stack Overflow, see https://stackoverflow.com/a/74765430/1558022 |
||||||
|
/// |
||||||
|
private struct DeferredViewModifier: ViewModifier { |
||||||
|
|
||||||
|
let delay: DispatchTimeInterval |
||||||
|
|
||||||
|
func body(content: Content) -> some View { |
||||||
|
_content(content) |
||||||
|
.onAppear { |
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { |
||||||
|
self.shouldHide = true |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@ViewBuilder |
||||||
|
private func _content(_ content: Content) -> some View { |
||||||
|
if shouldHide == false { |
||||||
|
content |
||||||
|
} else { |
||||||
|
content.hidden() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@State private var shouldHide = false |
||||||
|
} |
||||||
|
|
||||||
|
extension View { |
||||||
|
func deferredRendering(for delay: DispatchTimeInterval) -> some View { |
||||||
|
modifier(DeferredViewModifier(delay: delay)) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue