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